A card's branch may be a phase's, and a member's PR is based on it
github asked one question in four places by writing task/<stem> out each time. It asks branch_of() now, which answers phase/<stem> for a phase card — so from review/ onwards a phase is an ordinary card: its PR opens, its worktree is driven, and merge & clean up finds the branch it is meant to take apart. A phase member's PR is opened against its phase's branch rather than main. Its branch was cut from there, so that is the only base whose diff is the member's own work — and a PR into main carrying a whole phase would invite exactly the merge this design refuses to make. The base is published first, so the remote has something to open against; the main-is-ahead guard still stands in front of every PR into main. A phase's own PR says what is in it: the member list, in run order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+83
-21
@@ -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/<stem>`;
|
||||
a phase runs on `phase/<stem>`, 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/<stem>
|
||||
|
||||
# 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:
|
||||
|
||||
Reference in New Issue
Block a user