$ cat node-template.py
P
Pull request
// Open a pull request between two branches of a GitHub repository — or, when one is already open, update that one (title, its own part of the description, draft or ready). Never opens a second. Built for the pull request that ships the working branch to its base once the Lanes are merged: wire "Working branch".repo and "Working branch".base_branch. It never merges and never pushes; merging into the base stays a person's decision on GitHub.
Process
Integration
#github#pull-request#ship#repo-write#vibe
template.py
1"""Pull request — open one, or bring the open one up to date. Never a second.23Each Lane opens its own draft pull request into the working branch. What4nothing opened was the last one: the working branch → the base, carrying every5merged Lane as one change a person reviews and merges. This node opens it —6or any other pull request between two branches — and on every later run7updates the SAME pull request instead of failing on "already exists" or8opening a duplicate (the Lane's `findPullRequest` follows the same rule).910## It owns only its own part of the body1112A person may edit the description on GitHub. So the node writes its text13between `<!-- emblema:pull-request -->` markers and on an update replaces only14what is between them; anything written around them stays. A pull request it15did not open gets the block appended, never its description replaced.1617## What it never does1819It never merges, and it never pushes: opening a pull request changes no20branch. Merging into the base stays a person's decision on GitHub — Merge PR21refuses any target but the working branch.2223Draft ↔ ready goes through GraphQL: the REST API cannot change it on an24existing pull request.2526## In a lane (vibe-coding Lane split, D13, D32)2728A lane is Worktree → Code agent → Pull request: the Code agent pushes the29lane's branch and runs its checks, and this node opens the lane's pull request30from its `pr_title` and `pr_body`. Two contracts make that safe:3132 - `expected_head_sha` — wire the Code agent's `head_sha`. The PR then opens33 (or is updated) only while the branch on GitHub is still at the commit the34 checks ran on; if anything pushed after them, nothing is written and the35 run fails naming both commits. Without the input, nothing is pinned.36 - `outcome: "pr_ready"` and `pr_url` whenever a pull request is open after37 the run. Phase 7 lands only lanes that finished `pr_ready`, and the edge38 guard stops PR status and Merge from running behind anything else.39"""4041from gais import github_repo as gh42from gais._errors import RemoteError4344BEGIN = "<!-- emblema:pull-request -->"45END = "<!-- /emblema:pull-request -->"46TITLE_MAX = 256474849def block(body: str) -> str:50 return f"{BEGIN}\n{body.strip()}\n{END}"515253def merge_body(current: str, body: str) -> str:54 """`current` with the node's block replaced — or appended when absent."""55 current = current or ""56 start, end = current.find(BEGIN), current.find(END)57 if start != -1 and end > start:58 return current[:start] + block(body) + current[end + len(END):]59 return (current.rstrip() + "\n\n" + block(body)).lstrip() if current.strip() else block(body)606162def run(inputs: dict, client: gh.Client) -> dict:63 owner, repo, repo_branch = gh.parse_repo(inputs.get("repo"))64 head = str(inputs.get("head") or "").strip() or repo_branch65 base = str(inputs.get("base") or "").strip()66 title = " ".join(str(inputs.get("title") or "").split())[:TITLE_MAX]67 body = str(inputs.get("body") or "")68 draft = gh.flag(inputs.get("draft"), True)69 expected = str(inputs.get("expected_head_sha") or "").strip()7071 if not head:72 raise gh.RepoError("Set the branch the pull request comes from (`head`), or wire `repo` from "73 "\"Working branch\".repo.")74 if not base:75 base = gh.repository(client, owner, repo).get("default_branch") or ""76 for name in (head, base):77 problem = gh.branch_problem(name)78 if problem:79 raise gh.RepoError(problem)80 if head == base:81 raise gh.RepoError(f"A pull request from '{head}' into itself — set `base` to the branch it ships into.")82 if expected:83 # D32: the PR is for the commit the checks ran on, or for nothing.84 actual = gh.branch_sha(client, owner, repo, head)85 if not actual:86 raise gh.RepoError(f"The branch '{head}' does not exist in {owner}/{repo}.")87 if not actual.startswith(expected) and not expected.startswith(actual):88 raise gh.RepoError(f"'{head}' moved after its checks ran (it is at {actual[:12]}, the checks ran "89 f"on {expected[:12]}) — nothing was opened or updated. Rerun the Code agent "90 "so the checks see what is on the branch.")9192 existing = gh.find_open_pr(client, owner, repo, head, base)93 if existing:94 number = int(existing["number"])95 changes: dict = {}96 if title and title != existing.get("title"):97 changes["title"] = title98 if body.strip():99 new_body = merge_body(existing.get("body") or "", body)100 if new_body != (existing.get("body") or ""):101 changes["body"] = new_body102 if changes:103 try:104 existing = client.request("PATCH", f"/repos/{owner}/{repo}/pulls/{number}", changes) or existing105 except RemoteError as e:106 raise gh.explain(e, f"to update #{number}", gh.NEED["pulls_write"]) from None107 flipped = bool(existing.get("draft")) != draft108 if flipped:109 gh.set_draft(client, existing.get("node_id") or "", draft, number)110 result, created = ("updated" if changes or flipped else "unchanged"), False111 pr = existing112 else:113 if not title:114 raise gh.RepoError("Set a `title` — it is needed to open the pull request.")115 payload = {"title": title, "head": head, "base": base, "body": block(body) if body.strip() else "",116 "draft": draft}117 try:118 pr = client.request("POST", f"/repos/{owner}/{repo}/pulls", payload) or {}119 except RemoteError as e:120 said = gh.explain(e, f"to open a pull request from '{head}' into '{base}'", gh.NEED["pulls_write"])121 if e.http_status == 422 and "no commits between" in str(said).lower():122 return {123 "result": "no_changes", "created": False, "number": 0, "url": "", "draft": draft,124 "state": "", "head": head, "base": base, "head_sha": "",125 "summary": f"Nothing to open: '{head}' has no commits that '{base}' does not.",126 "summary_line": f"{owner}/{repo} {head} → {base}: no commits to open a PR for",127 }128 raise said from None129 number = int(pr.get("number") or 0)130 result, created = "created", True131132 brief = gh.pr_brief(pr)133 is_draft = draft if result != "unchanged" else brief["draft"]134 verb = {"created": "Opened", "updated": "Updated", "unchanged": "Already up to date:"}[result]135 return {136 # A pull request is open after this run (D13): what Phase 7 and the137 # board read as "this lane is ready to land".138 "outcome": "pr_ready",139 "pr_url": brief["url"],140 "result": result,141 "created": created,142 "number": number,143 "url": brief["url"],144 "draft": is_draft,145 "state": brief["state"],146 "head": head,147 "base": base,148 "head_sha": brief["head_sha"],149 "summary": f"{verb} #{number} {brief['title'] or title} — {head} → {base}"150 f"{' (draft)' if is_draft else ''}\n{brief['url']}",151 "summary_line": f"{owner}/{repo}#{number} {head} → {base} {result}",152 }153154155def main() -> None:156 gh.run_node("pull-request", run)157158159if __name__ == "__main__":160 main()161$ git log --oneline
v1.0.0
HEAD
2026-10-14First marketplace publish: vibe-coding lanes and the gstack plan flow (PR #1313).