$ cat node-template.py

M

Merge PR

// Merge a Lane's pull request into the plan's working branch — and only there: the default branch, protected branches and any pull request into another branch are refused, so what ships to the base is always merged by a person. Lands exactly the commit that was checked (wire PR status's `head_sha`), and only when nothing blocks it — the same blockers PR status shows. Marks a draft ready first. Squash by default. Run it only after the user said to merge.

Process
Integration
#github#pull-request#merge#repo-land#vibe
template.py
1"""Merge PR — land a Lane's pull request on the plan's working branch.23The last step of a Lane: its pull request, reviewed and green, merged into the4working branch, where the other Lanes' work collects before it ships to the5base as ONE pull request a person merges. Every other agent surveyed stops6short of merging (Copilot's agent cannot, Claude Code's auto mode blocks a7merge no human approved, Cursor leaves it to "a human to review before8anything merges"). This node merges, under three rules that keep a person's9decision in charge:1011  1. ONLY into the working branch. `repo` is wired from "Working branch" —12     its `base_branch` IS the working branch — and a pull request into any13     other branch is refused. The default branch and every protected branch14     are refused whatever `repo` says. What ships to the base is merged by a15     person on GitHub, through the pull request Pull request opens. (The node16     trusts the wiring for the rest: a `repo` TYPED as an unprotected base,17     together with a pull request into that base, would pass. The instruction18     only ever wires it from "Working branch".)19  2. ONLY the commit that was checked. `expected_head_sha` (wire PR status's20     `head_sha`) is sent to GitHub as the merge's `sha`: if a push landed21     after the user saw the card, GitHub refuses, and so does this node22     before it asks.23  3. ONLY when nothing blocks it, by the same list PR status shows on the24     card (`gais.github_repo.merge_blockers`): open, merges cleanly, CI not25     failing or running (and readable, unless `require_checks` is off), no26     changes requested.2728A draft is marked ready first: the user's answer on the merge card is the29review a draft waits for, and GitHub will not merge a draft.3031A pull request that is already merged is success (`already_merged`), so a32rerun after a lost response does no harm.33"""3435from gais import github_repo as gh36from gais._errors import RemoteError3738DECISION_QUERY = """39query($owner: String!, $name: String!, $number: Int!) {40  repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewDecision } }41}42"""43METHODS = ("squash", "merge")444546def review_decision(client: gh.Client, owner: str, repo: str, number: int) -> str:47    """"" when it cannot be read — PR status already showed it on the card, and48    GitHub itself enforces required reviews on a protected base."""49    try:50        data, _ = client.graphql(DECISION_QUERY, {"owner": owner, "name": repo, "number": number})51    except RemoteError:52        return ""53    return (((data.get("repository") or {}).get("pullRequest") or {}).get("reviewDecision")) or ""545556def run(inputs: dict, client: gh.Client) -> dict:57    owner, repo, target = gh.parse_repo(inputs.get("repo"))58    if not target:59        raise gh.RepoError("Wire `repo` from \"Working branch\".repo: its branch is the only one Merge PR merges into.")60    parsed = gh.parse_pr(inputs.get("pr"))61    if not parsed:62        raise gh.RepoError("Wire the pull request to merge (a Lane's `pr_url`).")63    if parsed[0] and (parsed[0].lower(), parsed[1].lower()) != (owner.lower(), repo.lower()):64        raise gh.RepoError(f"The pull request is in {parsed[0]}/{parsed[1]}, but the working branch is in "65                           f"{owner}/{repo}.")66    number = parsed[2]67    expected = str(inputs.get("expected_head_sha") or "").strip()68    method = str(inputs.get("method") or "squash").strip().lower()69    if method not in METHODS:70        raise gh.RepoError(f"Merge method '{method}' is not one of: {', '.join(METHODS)}.")71    require_checks = gh.flag(inputs.get("require_checks"), True)7273    info = gh.repository(client, owner, repo)74    refusal = gh.write_refusal(client, owner, repo, target, info)75    if refusal:76        raise gh.RepoError(f"{refusal} Merge PR only lands work on the plan's working branch.")7778    brief = gh.pr_brief(gh.get_pr(client, owner, repo, number))79    if brief["base"] != target:80        raise gh.RepoError(81            f"#{number} merges into '{brief['base']}', not the working branch '{target}'. Merge PR only lands "82            f"Lane work on the working branch; what ships to '{brief['base']}' is merged by a person on GitHub.")83    if brief["state"] == "merged":84        return {85            "result": "already_merged", "merged": True, "merge_sha": "", "number": number, "url": brief["url"],86            "into": target, "branch": brief["head"], "method": method,87            "summary": f"#{number} was already merged into **{target}**.",88            "summary_line": f"{owner}/{repo}#{number} already merged into {target}",89        }90    if not expected:91        raise gh.RepoError("Set `expected_head_sha` — wire PR status's `head_sha` — so the merge lands exactly "92                           "the commit that was checked.")93    if not brief["head_sha"].startswith(expected):94        raise gh.RepoError(f"#{number} moved since it was checked (it is at {brief['head_sha'][:8]}, the card "95                           f"showed {expected[:8]}). Read its PR status again before merging.")9697    ci = gh.ci_state(client, owner, repo, brief["head_sha"])98    blockers = gh.merge_blockers(brief, ci, review_decision(client, owner, repo, number),99                                 require_checks=require_checks)100    if blockers:101        raise gh.RepoError(f"#{number} was not merged: " + "; ".join(blockers) + ".")102103    was_draft = brief["draft"]104    if was_draft:105        gh.set_draft(client, brief["node_id"], False, number)106107    body = {"merge_method": method, "sha": brief["head_sha"],108            "commit_title": f"{brief['title']} (#{number})" if method == "squash" else f"Merge #{number}: {brief['title']}"}109    try:110        merged = client.request("PUT", f"/repos/{owner}/{repo}/pulls/{number}/merge", body) or {}111    except RemoteError as e:112        after = " It was marked ready for review first." if was_draft else ""113        if e.http_status == 409:114            raise gh.RepoError(f"#{number} moved while it was being merged; nothing was merged.{after} "115                               "Read its PR status again.") from None116        raise gh.RepoError(str(gh.explain(e, f"to merge #{number}", gh.NEED["contents_write"])) + after) from None117118    sha = merged.get("sha") or ""119    return {120        "result": "merged", "merged": True, "merge_sha": sha, "number": number, "url": brief["url"],121        "into": target, "branch": brief["head"], "method": method,122        "summary": (f"Merged #{number} {brief['title']} into **{target}** ({method}, {sha[:7]})."123                    + (" It was a draft; it was marked ready first." if was_draft else "")124                    + (f" CI: {ci['state']}." if ci["state"] != "success" else "")),125        "summary_line": f"{owner}/{repo}#{number} merged into {target} at {sha[:8]}",126    }127128129def main() -> None:130    gh.run_node("merge-pr", run)131132133if __name__ == "__main__":134    main()135

$ git log --oneline

v1.0.0
HEAD
2026-10-14

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