$ 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-14First marketplace publish: vibe-coding lanes and the gstack plan flow (PR #1313).