$ cat node-template.py
P
PR status
// Everything to check before merging a pull request, read from GitHub with no model: open or merged, draft, whether it still merges cleanly or fell behind its base, CI on its head commit, the review decision, and the review comments still open. Returns `blockers` — the same list Merge PR refuses on — and turns open comments from people with write access into fixes plus a ready question card, the same shape as Code review's. Wire a Lane's `pr_url`. Reads only.
Process
Integration
#github#pull-request#ci#review#repo-read#vibe
template.py
1"""PR status — everything a person checks before merging a pull request.23A Lane opens a draft pull request and its Code review reviews it. Between that4and a merge sit the things GitHub knows and no other node reads: whether the5pull request still merges cleanly, whether it fell behind its base, what CI6said about its head commit, whether a reviewer requested changes, and which7review comments are still open. This node reads all of them, with no model,8and says in one list — `blockers` — why it cannot be merged yet. Merge PR refuses9on exactly the same list (`gais.github_repo.merge_blockers`), so the card10built from this node can never promise a merge that Merge PR then refuses.1112## Review comments become fixes, but only trusted ones1314A human's open review thread on a Lane's pull request is work for that Lane.15It comes back as `fixes` + a ready-made `fixQuestion`, the same shape as the16Code review's, so the agent hands the picks to the Lane through Decisions17exactly as it does for review findings. Two filters decide what counts:1819 - only comments by the repository's OWNER / MEMBER / COLLABORATOR — on a20 public repository anyone can comment, and a Lane executes what it reads;21 - never text the platform wrote itself (marked `<!-- emblema:`), so a Code22 review posted to GitHub is not handed back to the Lane a second time.2324Threads are read through GraphQL — only it knows whether a thread is25resolved. Top-level comments have no resolved state, so only those written26after the head commit count: an older one was written before the last push27and has most likely been addressed.2829Reads only.30"""3132from gais import github_repo as gh33from gais._errors import RemoteError3435THREADS_QUERY = """36query($owner: String!, $name: String!, $number: Int!) {37 repository(owner: $owner, name: $name) {38 pullRequest(number: $number) {39 reviewDecision40 commits(last: 1) { nodes { commit { committedDate } } }41 reviewThreads(first: 100) {42 nodes {43 isResolved isOutdated path line originalLine44 comments(first: 20) { nodes { author { login } authorAssociation body url createdAt } }45 }46 }47 reviews(last: 50) { nodes { state author { login } authorAssociation body url submittedAt } }48 comments(last: 50) { nodes { author { login } authorAssociation body url createdAt } }49 }50 }51}52"""5354FIX_CHARS = 1500555657def resolve_target(inputs: dict, client: gh.Client) -> tuple[str, str, int]:58 """(owner, repo, number) from `pr`, else the open PR from `branch`."""59 repo_value = inputs.get("repo")60 parsed = gh.parse_pr(inputs.get("pr"))61 if parsed and parsed[0]:62 return parsed[0], parsed[1], parsed[2]63 if not repo_value:64 raise gh.RepoError("Wire the pull request (a Lane's `pr_url`), or set `repo` and a PR number or branch.")65 owner, repo, _ = gh.parse_repo(repo_value)66 if parsed:67 return owner, repo, parsed[2]68 branch = str(inputs.get("branch") or "").strip()69 if not branch:70 raise gh.RepoError("Nothing to read: wire the pull request (a Lane's `pr_url`) or its `branch`.")71 found = gh.find_open_pr(client, owner, repo, branch)72 if not found:73 raise gh.RepoError(f"No open pull request comes from '{branch}' in {owner}/{repo}.")74 return owner, repo, int(found["number"])757677def trusted(node: dict) -> bool:78 return (node.get("authorAssociation") in gh.TRUSTED_ASSOCIATIONS79 and gh.MARKER not in (node.get("body") or ""))808182def login(node: dict) -> str:83 return ((node.get("author") or {}).get("login")) or "someone"848586def first_line(text: str, limit: int = 90) -> str:87 line = next((ln.strip() for ln in (text or "").splitlines() if ln.strip()), "")88 return line if len(line) <= limit else line[: limit - 1] + "…"899091def read_conversation(client: gh.Client, owner: str, repo: str, number: int) -> dict:92 """reviewDecision, open trusted threads and fresh trusted comments.9394 A GraphQL failure does not fail the node: the merge-relevant facts come95 from REST. It is reported in `note` so nobody reads "no comments" as "no96 one commented".97 """98 try:99 data, errors = client.graphql(THREADS_QUERY, {"owner": owner, "name": repo, "number": number})100 except RemoteError as e:101 return {"decision": "", "threads": [], "comments": [], "ignored": 0,102 "note": str(gh.explain(e, f"to read the review comments of #{number}", gh.NEED["pulls_read"]))}103 pr = ((data.get("repository") or {}).get("pullRequest")) or {}104 if not pr:105 detail = "; ".join(str(e.get("message", "")) for e in errors) or "no data"106 return {"decision": "", "threads": [], "comments": [], "ignored": 0,107 "note": f"The review comments of #{number} could not be read ({detail})."}108109 last_commit = ((((pr.get("commits") or {}).get("nodes") or [{}])[0] or {}).get("commit") or {}).get("committedDate") or ""110 threads, comments, ignored = [], [], 0111 for t in (pr.get("reviewThreads") or {}).get("nodes") or []:112 if t.get("isResolved"):113 continue114 posts = (t.get("comments") or {}).get("nodes") or []115 if not posts:116 continue117 opener = posts[0]118 if not trusted(opener):119 ignored += 1120 continue121 replies = [p for p in posts[1:] if trusted(p)]122 body = opener.get("body") or ""123 if replies:124 body += "\n\n" + "\n\n".join(f"{login(p)}: {p.get('body') or ''}" for p in replies)125 threads.append({126 "path": t.get("path") or "",127 "line": t.get("line") or t.get("originalLine"),128 "outdated": bool(t.get("isOutdated")),129 "author": login(opener),130 "body": body.strip(),131 "url": opener.get("url") or "",132 })133134 for kind, rows, stamp in (("review", (pr.get("reviews") or {}).get("nodes") or [], "submittedAt"),135 ("comment", (pr.get("comments") or {}).get("nodes") or [], "createdAt")):136 for c in rows:137 body = (c.get("body") or "").strip()138 if not body or (kind == "review" and c.get("state") not in ("CHANGES_REQUESTED", "COMMENTED")):139 continue140 if last_commit and (c.get(stamp) or "") < last_commit:141 continue # written before the last push — most likely already addressed142 if not trusted(c):143 ignored += 1144 continue145 comments.append({"kind": kind, "author": login(c), "body": body, "url": c.get("url") or "",146 "state": c.get("state") or ""})147148 note = ""149 if errors:150 note = "Part of the review conversation could not be read: " + "; ".join(151 str(e.get("message", "")) for e in errors)152 return {"decision": pr.get("reviewDecision") or "", "threads": threads, "comments": comments,153 "ignored": ignored, "note": note}154155156def fixes_from(conversation: dict) -> list[dict]:157 """Open threads and fresh comments as fixes — ids T1…, C1…."""158 out = []159 for i, t in enumerate(conversation["threads"], start=1):160 where = f"{t['path']}:{t['line']}" if t["path"] and t["line"] else t["path"]161 out.append({162 "id": f"T{i}",163 "title": first_line(t["body"]),164 "where": where,165 "author": t["author"],166 "url": t["url"],167 "request": True,168 "fix": (f"Address {t['author']}'s review comment"169 f"{' on ' + where if where else ''}: {t['body']}")[:FIX_CHARS],170 })171 for i, c in enumerate(conversation["comments"], start=1):172 out.append({173 "id": f"C{i}",174 "request": c.get("state") == "CHANGES_REQUESTED",175 "title": first_line(c["body"]),176 "where": "",177 "author": c["author"],178 "url": c["url"],179 "fix": f"Address {c['author']}'s {'review' if c['kind'] == 'review' else 'comment'}: {c['body']}"[:FIX_CHARS],180 })181 return out182183184def _recommend(fixes: list) -> str:185 """Review threads and change requests are asks; a plain comment may be chat."""186 asks = [f["id"] for f in fixes if f["request"]][:12]187 if not asks:188 return ""189 return (f"Address {', '.join(asks)} — "190 f"{'a review request' if len(asks) == 1 else 'review requests'} from people with write access.")191192193def render(brief: dict, ci: dict, conversation: dict, fixes: list, blockers: list) -> str:194 head = f"**#{brief['number']} {brief['title']}** — {brief['head']} → {brief['base']}"195 state = brief["state"] + (" (draft)" if brief["draft"] and brief["state"] == "open" else "")196 lines = [head, "", f"- State: {state}"]197 if brief["state"] == "open":198 merges = ("conflicts with its base" if brief["conflict"]199 else "not computed yet" if brief["mergeable"] is None200 else "behind its base, merges cleanly" if brief["behind_base"]201 else "merges cleanly")202 lines.append(f"- Merge: {merges}")203 lines.append(f"- CI: {ci['state']}" + (f" — failed: {', '.join(f['name'] for f in ci['failed'][:5])}"204 if ci["failed"] else ""))205 if ci.get("note"):206 lines.append(f" - {ci['note']}")207 if conversation["decision"]:208 lines.append(f"- Review decision: {conversation['decision'].replace('_', ' ').lower()}")209 lines.append(f"- Open review comments: {len(fixes)}"210 + (f" ({conversation['ignored']} from people without write access ignored)"211 if conversation["ignored"] else ""))212 for f in fixes[:10]:213 lines.append(f" - {f['id']} {f['author']}{' · ' + f['where'] if f['where'] else ''}: {f['title']}")214 if conversation["note"]:215 lines.append(f" - {conversation['note']}")216 lines += ["", "**Ready to merge.**" if not blockers else "**Not ready to merge:** " + "; ".join(blockers) + "."]217 return "\n".join(lines)218219220def run(inputs: dict, client: gh.Client) -> dict:221 owner, repo, number = resolve_target(inputs, client)222 brief = gh.pr_brief(gh.get_pr(client, owner, repo, number))223 ci = (gh.ci_state(client, owner, repo, brief["head_sha"]) if brief["head_sha"]224 else {"state": "none", "failed": [], "pending": [], "passed": [], "note": ""})225 conversation = read_conversation(client, owner, repo, number)226 fixes = fixes_from(conversation)227 blockers = gh.merge_blockers(brief, ci, conversation["decision"])228 question = gh.question_card(229 "prFixes", "PR comments",230 f"Which review comments on #{number} should the Lane address?",231 "The comments you pick go to the Lane through Decisions; it reruns on its branch and its pull request updates.",232 [{"id": f["id"], "label": f["title"], "recommended": f["request"],233 "description": f"{f['author']}{' · ' + f['where'] if f['where'] else ''}"} for f in fixes],234 recommendation=_recommend(fixes),235 )236 out = {237 # Listed field by field, not spread from `brief`: the schema-coverage238 # lint reads output keys from the source.239 # No `repo` output on purpose: Merge PR's `repo` must come from "Working240 # branch" — it is what limits a merge to the working branch. A `repo`241 # here, naming this PR's own base, would make that check pass for any242 # pull request it was wired from.243 "number": brief["number"],244 "url": brief["url"],245 "title": brief["title"],246 "state": brief["state"],247 "draft": brief["draft"],248 "head": brief["head"],249 "base": brief["base"],250 "head_sha": brief["head_sha"],251 "mergeable": brief["mergeable"],252 "mergeable_state": brief["mergeable_state"],253 "conflict": brief["conflict"],254 "behind_base": brief["behind_base"],255 "checks": ci,256 "review_decision": conversation["decision"],257 "fixes": fixes,258 "fixQuestion": question,259 "ready_to_merge": not blockers,260 "blockers": blockers,261 "summary": render(brief, ci, conversation, fixes, blockers),262 }263 out["summary_line"] = (f"{owner}/{repo}#{number} {brief['state']}, CI {ci['state']}, "264 f"{len(fixes)} open comments, {'ready' if not blockers else 'blocked'}")265 return out266267268def main() -> None:269 gh.run_node("pr-status", run)270271272if __name__ == "__main__":273 main()274$ git log --oneline
v1.0.0
HEAD
2026-10-14First marketplace publish: vibe-coding lanes and the gstack plan flow (PR #1313).