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

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