diff --git a/manager/core/github.py b/manager/core/github.py index f498b82..26f7fdf 100644 --- a/manager/core/github.py +++ b/manager/core/github.py @@ -27,7 +27,8 @@ import config import drive as drive_mod import reports import state -from taskfiles import STATUS_RE, commit_edit, find_stage_of, move_task, read_task +from taskfiles import (STATUS_RE, collect, commit_edit, find_stage_of, + move_task, read_task) PR_STATE: dict[str, dict] = {} # filename -> {verdict, detail, url, ts} _OPENING: set[str] = set() # filenames with a PR-open in flight @@ -52,6 +53,59 @@ def _branch_exists(branch: str) -> bool: return _run(["git", "rev-parse", "--verify", "--quiet", branch]).returncode == 0 +def branch_of(filename: str) -> str: + """The branch a card's work lives on. Ordinary cards get `task/`; + a phase runs on `phase/`, its own integration branch (phases.py), + and from review/ onwards it is reviewed, driven and completed through + the same apparatus as any other card.""" + phase = f"phase/{filename[:-3]}" + return phase if _branch_exists(phase) else f"task/{filename[:-3]}" + + +def _woven(filename: str, stage: str) -> dict: + """The card as the board shows it. A card's phase is *derived* across + the whole board (`taskfiles.weave_phases`), and both what a PR is based + on and what a phase's PR says depend on that reading, so a lone + `read_task` is not enough here.""" + for group in collect()["stages"]: + for task in group["tasks"]: + if task["file"] == filename: + return task + return read_task(config.TASKS / stage / filename, stage) + + +def _pr_base(task: dict) -> str: + """What a PR is opened against. A phase member's branch was cut from + its phase's branch, so that is the only base whose diff is the member's + own work — a PR into main would carry the whole phase, and invite a + merge into main that this board exists not to make. Everything else, + the phase card included, goes into main.""" + phase = task.get("phase") + if phase: + branch = f"phase/{phase['file'][:-3]}" + if _branch_exists(branch): + return branch + return "main" + + +def _pr_body(filename: str, task: dict) -> str: + """The PR's body. A phase's PR is the one a whole run produces, so it + says what is in it: the member list, in the order it ran.""" + if task.get("isPhase"): + cards = "\n".join(f"- {m['number']} — {m['title']}" + for m in task.get("members") or []) + return (f"Phase: `{filename}` — tracked in `.task-manager/tasks/review/`.\n\n" + f"Opened by the board when every card in the phase had been " + f"merged into its branch.\n\n## Cards in this phase\n\n" + f"{cards or '_none_'}\n") + body = (f"Task: `{filename}` — tracked in `.task-manager/tasks/review/`.\n\n" + f"Opened by the board when the card moved to review.") + summary = _agent_report(filename) + if summary: + body += f"\n\n## Agent summary\n\n{summary}" + return body + + def _write_pr_line(filename: str, url: str) -> None: """The url joins the header — and in team mode commits itself, so the gate that stops a second board opening a second PR travels to the other @@ -115,14 +169,14 @@ def open_pr_now(filename: str) -> str: def _open_pr(filename: str) -> str: - branch = f"task/{filename[:-3]}" + branch = branch_of(filename) if not _branch_exists(branch): # nothing to publish — a hand-moved card without agent work raise _Quiet(f"{filename} has no {branch} branch — nothing to open a PR from") stage = find_stage_of(filename) if stage != "review": raise _Quiet(f"{filename} is not in review/ — PRs open from there") - task = read_task(config.TASKS / stage / filename, stage) + task = _woven(filename, stage) if task.get("pr"): raise _Quiet(f"{filename} already has a PR: {task['pr']}") @@ -132,24 +186,26 @@ def _open_pr(filename: str) -> str: ("no git remote configured" if rname is None else "gh is not installed")) - # The PR's diff is computed against the remote main — refuse to open one - # that would drag unpushed main commits along with it. - _run(["git", "fetch", rname, "main"], timeout=120) - ahead = _run(["git", "rev-list", "--count", f"{rname}/main..main"]).stdout.strip() - if ahead.isdigit() and int(ahead) > 0: - raise ValueError(f"won't open a PR for {filename}: main is {ahead} commits " - f"ahead of {rname} — push main first, then move the card again") + base = _pr_base(task) + if base == "main": + # The PR's diff is computed against the remote main — refuse to open one + # that would drag unpushed main commits along with it. + _run(["git", "fetch", rname, "main"], timeout=120) + ahead = _run(["git", "rev-list", "--count", f"{rname}/main..main"]).stdout.strip() + if ahead.isdigit() and int(ahead) > 0: + raise ValueError(f"won't open a PR for {filename}: main is {ahead} commits " + f"ahead of {rname} — push main first, then move the card again") + else: + # A phase branch is the board's own: publish it so the member's PR + # has a base on the remote to be opened against. + _run(["git", "push", "-u", rname, base], timeout=180) push = _run(["git", "push", "-u", rname, branch], timeout=180) if push.returncode != 0: raise ValueError(f"push failed for {branch}: {push.stderr.strip()[:140]}") - body = (f"Task: `{filename}` — tracked in `.task-manager/tasks/review/`.\n\n" - f"Opened by the board when the card moved to review.") - summary = _agent_report(filename) - if summary: - body += f"\n\n## Agent summary\n\n{summary}" - result = _run([config.GH_BIN, "pr", "create", "--head", branch, "--base", "main", + body = _pr_body(filename, task) + result = _run([config.GH_BIN, "pr", "create", "--head", branch, "--base", base, "--title", task["title"], "--body", body], timeout=120) if result.returncode != 0: # The rare double-fire: two attempts crossed and GitHub already has @@ -417,7 +473,7 @@ def complete_task(filename: str, stage: str) -> dict: def _complete(filename: str, stage: str) -> dict: """The steps themselves, run under the claim complete_task holds.""" stem = filename[:-3] - branch = f"task/{stem}" + branch = branch_of(filename) # a phase's own branch, or task/ # 1. the app must not keep running code that is about to be merged away d = drive_mod.DRIVE @@ -512,11 +568,17 @@ def _merge_on_origin(filename: str, stage: str, branch: str) -> None: def task_branches() -> list[str]: - """Stems of all task/* branches — the UI uses this to say honestly - whether a review card has work attached.""" + """Stems of all task/* and phase/* branches — the UI uses this to say + honestly whether a card has work attached, and a phase card's work is + its own integration branch.""" result = _run(["git", "for-each-ref", "--format=%(refname:short)", - "refs/heads/task/"]) - return [ref[len("task/"):] for ref in result.stdout.split() if ref.startswith("task/")] + "refs/heads/task/", "refs/heads/phase/"]) + stems = [] + for ref in result.stdout.split(): + for prefix in ("task/", "phase/"): + if ref.startswith(prefix): + stems.append(ref[len(prefix):]) + return stems def reconcile() -> None: