$ 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-14

First marketplace publish: vibe-coding lanes and the gstack plan flow (PR #1313).