Skip to content

Commit eda7731

Browse files
johanzanderclaude
andauthored
feat: give issues and PRs an explicit state machine, so the backlog loop can progress work (#658)
* docs: design the issue/PR state machine, whose collapsed In Review column stalled the fleet Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM * docs: plan the issue/PR state machine in 13 tasks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM * docs: correct the plan against the real test harness, found in the pre-flight scan Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM * fix: report every PR on an issue, which one-to-many made invisible pr_for returned matches[0], silently discarding every PR past the first -- guaranteed to happen since the no-auto-close rule means an issue routinely carries a beta PR and a separate graduation PR. Replaced with prs_for, which returns the full sorted set as `prs: [{number, mergeable, isDraft}]`, and taught both the open-PR matcher and the merged-PR refs scan the `Refs #N` verb an intermediate PR is required to use. backlog-rhythm.sh and its tests follow the same shape change. * fix: request isDraft in gh pr list so prs[].isDraft stops resolving to null Review round 1 on Task 2: prs_for emitted isDraft on every PR object, but the open-PR gh pr list --json list never asked gh for that field, so it was permanently null in production and only ever populated in tests because the fixture handed the field over regardless of what --json actually requested. Added isDraft to the --json selection, and added a test that inspects the digest's real gh pr list invocation (failing loudly if isDraft is absent from --json) rather than trusting a fixture that would mask the same regression again. * feat: name the merged-but-unreleased phase, and stop a wait rewriting it column() gains In Verification for an issue whose fix merged to main but has not yet reached a stable release, and stops letting Awaiting rewrite the phase -- Status is the phase, Awaiting is the wait, and they are now orthogonal. Ready for Dev still requires no blocker and no wait, so an unsettled item still cannot read as dispatchable. * feat: count resume handoffs, so a twice-dead session is a fact not a feeling * feat: compute what open PRs already touch, which the collision gate needs exactly Adds a top-level in_flight_files map (path -> [PR numbers]) to backlog-digest.sh, built from one `gh pr diff --name-only` per open PR. No error suppression on that call: a PR whose diff cannot be read must fail the digest, not silently read as touching nothing. * feat: make an escalation loud, which suppressing Awaiting made quiet Awaiting is now signed: reporter/upstream/discussion mean someone else owes us and stay quiet, while maintainer means the loop cannot advance without a human decision and must rank first in the pass. Two more triggers derive an escalation the same way: two handed-back implementation sessions (resume_count >= 2), and three CHANGES_REQUESTED review rounds without an intervening approval. Both are guarded so the same item never also reports resume_implementation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM * test: strengthen the resume-guard escalation test to discriminate test_two_resume_handoffs_escalate asserted only that escalated fired, never that resume_implementation was absent, so the and .resume_count < 2 guard on resume_implementation had no test that would catch its removal. Add the missing negative assertion; verified it fails with the guard removed and passes with it restored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM * fix: rank actions right-to-left, which an alphabetical sort had inverted sort_by(.action, ...) put dispatchable before resume_implementation for no reason beyond d < m < r, so every backlog-rhythm pass led with start new work and buried finish started work -- the inverse of the empty-the-board- from-the-right flow policy. Each action now gets a numeric rank field (0 = escalations, then rightmost column first, unmatched actions rank last at 6) and actions sort on rank before action name. * fix: name every grooming action explicitly, narrow the rank catch-all set_awaiting, add_card, set_priority, move_card and triage_labels were landing last only because they fell through an unlabeled else branch -- correct by position, but any future action would silently inherit last place the same way, with nothing to flag it. Name all five at rank 6 explicitly (a no-op for current ordering), and move the catch-all to a distinct rank 9 so an action with no rank branch sorts conspicuously after grooming instead of blending into it. * feat: cap work in progress at 3, which unbounded WIP had jammed 8 open PRs, all drafts, 6 conflicting, none ever approved -- the observable shape of unbounded WIP. In Progress and In Review count as one piece of work (a branch and its PR at two stages); In Verification does not count, since it has already merged and is only waiting on a release. dispatchable is suppressed while over the limit, and the WIP line is now reported on every human-readable pass, including the quiet path, so a suppressed queue is never silent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM * test: pin that In Verification never counts toward WIP Review finding on the WIP-limit commit: the exclusion was correct but unpinned, so a mutation widening the column filter to include In Verification would pass the existing suite silently. This is a deliberate semantic (already merged to main, waiting only on a release, occupying no implementation slot) rather than an accident of implementation, and exactly the kind of thing a future maintainer could plausibly "fix" the wrong way. Verified the new test discriminates: temporarily widened the jq select to include In Verification, confirmed the test fails (count 4/over True instead of 0/False), reverted, confirmed it passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM * feat: gate dispatch on file collisions, which advisory prose never prevented Task 9 of the issue/PR state machine plan. backlog-rhythm.sh now checks a Ready for Dev candidate's predicted_files against in_flight_files (Task 5) before reporting dispatchable: a clash queues the candidate behind the in-flight PR, an unpredicted touch-set blocks dispatch outright, and two Ready items that would collide with each other are folded into one cluster. Also closes the gap the removed 2>/dev/null || true left in backlog-digest.sh: both scripts run under set -euo pipefail, so one PR whose diff gh cannot read used to abort the whole rhythm pass and block all triage/dispatch. A failed gh pr diff is now recorded in undiffable_prs instead, and backlog-rhythm.sh suppresses dispatchable fleet-wide (like the WIP limit) while emitting one undiffable_pr action per affected PR, reported in both JSON and human output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM * feat: extract the review loop into advance-pr, so advancing a PR costs a step not a session Every open PR resolves to one action today -- run /implement-issue at $1-4 a session -- which is why 8 PRs sit open, 6 conflicting, none approved. advance-pr moves Step 11's review loop out into its own skill: one transition-table row per PR fact, exactly one state advanced per invocation, then exit. Three callers reach it: implement-issue, backlog-rhythm.sh, and the maintainer directly -- one copy of the loop instead of a second one going stale, per backlog/SKILL.md's own argument. * fix: resolve project-id for the board write, add --limit, handle orphan PRs Fix round 1 review findings on advance-pr: - gh project item-edit's ID-based form requires --project-id alongside --id/--field-id/--single-select-option-id or it fails outright ("project-id must be provided"); add the gh project view resolution step. - gh project item-list defaults to --limit 30 and truncates silently past it (57 items on the live board today); pass --limit 200 like backlog-digest.sh does. - a PR with no Refs #N / Closes #N has no issue to write Awaiting onto; stop and report it rather than let the lookup silently return nothing. * refactor: delegate the review loop to advance-pr, keeping one copy Step 11 now invokes /advance-pr repeatedly instead of restating the verdict-handling loop, per the plan in .superpowers/sdd/2026-08-18-issue-pr-state-machine/. Also requires Refs #<issue> (not Closes) on every non-graduation PR, teaches Step 0 to open an issue for bare refactors/TODO items, and posts the resume-handoff marker on resume so double-handoffs are detectable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM * fix: point remaining Step 11 gh-pr-ready references at advance-pr Fix round 1 review found three surviving lines that told a reader to run `gh pr ready` directly, or credited Step 11 itself with the flip, bypassing advance-pr for exactly the case Task 11 moved out of this skill. Worst was the Step 0 resume table, which told a resumed session to run `gh pr ready` on an APPROVED PR with no mergeability re-check or push-after-approval rule -- both now live only in advance-pr -- which is the #609 failure reintroduced. Reworded all four to route through advance-pr while keeping each line's original reasoning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM * fix: gate the CHANGES_REQUESTED rework row on whether the caller holds Step 2/3 context A bare /advance-pr <n> or a backlog-rhythm dispatch has no Step 2 diagnosis or Step 3 scope assessment to distinguish a real review finding from a decision already made and rejected deliberately -- exactly the failure superpowers:receiving-code-review exists to prevent. Split the transition table's CHANGES_REQUESTED row in two: rework in place only when the caller is implement-issue Step 11 (holds that context); otherwise collect the findings and hand back to /implement-issue <n> rather than guess. * docs: document the state machine the scripts now implement Rewrites backlog/SKILL.md's states, rhythm action table, flow policy and issue-card deferral to match backlog-rhythm.sh/backlog-digest.sh as they actually run today, including two gaps found along the way: resume_implementation never calls /advance-pr directly (Step 0 does, via implement-issue Step 11), and predicted_files has no production data source yet, so the collision gate can only ever report needs_touch_set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM * fix: route a mechanical draft PR through advance-pr, not implement-issue The rhythm pass handed every unfinished draft PR to a whole implement-issue session, which is the gap between this design and its own headline promise: advancing a PR costs a step, not a session. Splits the resume_implementation PR branch by verdict, using the same reviewDecision/last-non-COMMENTED-review seam already used elsewhere: a draft needing only a mechanical next step (no review yet, review in flight, approved but not green) now routes to /advance-pr <n>; a draft carrying CHANGES_REQUESTED still routes to /implement-issue <n>, because only that session holds the Step 2/3 context needed to tell a real review finding apart from a decision already made and rejected on purpose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM * fix: close the gaps a whole-branch review found in the PR state machine Seven fixes from a review of the advance-pr/backlog-rhythm/implement-issue branch: - CONFLICTING draft PRs now route to /advance-pr instead of a sweep-prs handoff that writes nothing and re-emits forever (non-draft still goes to sweep-prs). - request-pr-review.sh gates its trigger comment on the existing review_run_state() detector so a review already in flight is never re-triggered by a second caller. - advance-pr never edits code: CHANGES_REQUESTED always hands back to implement-issue, which is the session holding the Step 2/3 context; this supersedes the earlier "rework in place" carve-out. - Restored the gh api pulls/<n>/comments command in implement-issue Step 11 so inline review findings are actually fetched (they never show up in --json reviews or --json comments). - implement-issue's hard-cap text now says the 3-round escalation is derived by backlog-rhythm.sh, not written by advance-pr, matching advance-pr's own "two escalations this skill owns" section. - mark_ready's detail now names /advance-pr instead of a bare gh pr ready, so the mergeability re-check and push-after-approval rule can't be skipped at the one call site an unattended pass reads. - Deferral (Awaiting/P4) recorded on an issue card now suppresses its PR's actions the same way a pr_board card does, matching backlog SKILL.md's documented "one card per unit of work, on the issue" model. Every guard added or changed here was verified by mutation: inverted or removed, confirmed the covering test reddens, restored, confirmed green. See .superpowers/sdd/2026-08-18-issue-pr-state-machine/final-fix-report.md (gitignored, not part of this commit) for the per-finding evidence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM * docs: fix stale CONFLICTING routing and rework-scope wording backlog/SKILL.md still routed every CONFLICTING PR to sweep-prs; the rhythm script has branched on isDraft since the advance-pr split, so a conflicted draft now goes to /advance-pr and only a non-draft still goes to sweep-prs. Narrow advance-pr's "never edits code" absolute (and its implement-issue echo) to what is actually true: it never reworks a review verdict, not that it never touches the diff at all -- merging main to resolve a conflict and fixing a red check stay mechanical rows. Also corrects a stale "blind rework" justification and an operator-facing rhythm detail string that still said advance-pr does the rework. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uvZsYtTvvyhGFxjGc29kM --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f70f387 commit eda7731

11 files changed

Lines changed: 3345 additions & 301 deletions

File tree

.claude/skills/advance-pr/SKILL.md

Lines changed: 269 additions & 0 deletions
Large diffs are not rendered by default.

.claude/skills/backlog/SKILL.md

Lines changed: 218 additions & 43 deletions
Large diffs are not rendered by default.

.claude/skills/implement-issue/SKILL.md

Lines changed: 75 additions & 155 deletions
Large diffs are not rendered by default.

backend/tests/test_backlog_digest.py

Lines changed: 269 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -116,24 +116,52 @@ def _gh_shim(
116116
prs: list,
117117
project_items: list,
118118
merged_prs: list | None = None,
119+
pr_files: dict[int, list[str]] | None = None,
120+
fail_pr_diff: list[int] | None = None,
119121
) -> str:
120-
"""A `gh` that answers the four subcommands the digest calls.
122+
"""A `gh` that answers the subcommands the digest calls.
121123
122124
`pr list` is called twice with different `--state` values, so this shim
123125
branches on the whole argument string rather than just `$1 $2`. Matching
124126
only the subcommand returned the OPEN list for both calls, which made every
125127
open PR look merged and reported every issue as having landed.
128+
129+
`pr diff <n> --name-only` answers from `pr_files`, keyed by PR number. The
130+
match requires `--name-only` literally in the call -- a shim that answered
131+
from the subcommand alone could not catch the digest asking for the full
132+
diff (or forgetting the flag entirely) and paying for -- or mis-parsing --
133+
the whole patch instead of a bare path list.
134+
135+
`fail_pr_diff` lists PR numbers whose `pr diff --name-only` call exits
136+
non-zero, simulating a deleted fork head, a rate limit, or a transient
137+
network error -- the case `undiffable_prs` exists to record rather than
138+
crash on.
126139
"""
140+
files_by_pr = {str(k): v for k, v in (pr_files or {}).items()}
141+
fail_ns = {str(n) for n in (fail_pr_diff or [])}
127142
return f"""
128143
merged=$(cat <<'EOF'
129144
{json.dumps(merged_prs or [])}
130145
EOF
131146
)
147+
pr_files_json=$(cat <<'EOF'
148+
{json.dumps(files_by_pr)}
149+
EOF
150+
)
151+
fail_ns='{" ".join(sorted(fail_ns))}'
132152
case "$*" in
133153
*"issue list"*) cat <<'EOF'
134154
{json.dumps(issues)}
135155
EOF
136156
;;
157+
*"pr diff "*"--name-only"*)
158+
n=$(printf '%s' "$*" | awk '{{for(i=1;i<=NF;i++) if($i=="diff"){{print $(i+1); exit}}}}')
159+
for f in $fail_ns; do
160+
if [ "$f" = "$n" ]; then echo "gh: unable to diff PR $n" >&2; exit 1; fi
161+
done
162+
printf '%s' "$pr_files_json" | jq -r --arg n "$n" '(.[$n] // [])[]'
163+
;;
164+
*"pr diff"*) echo "gh pr diff missing --name-only: $*" >&2; exit 1 ;;
137165
*"pr list"*"--state merged"*) printf '%s\\n' "$merged" ;;
138166
*"pr list"*) cat <<'EOF'
139167
{json.dumps(prs)}
@@ -194,6 +222,91 @@ def _comment(login: str, body: str = "...", at: str = "2026-08-10T00:00:00Z") ->
194222
return {"body": body, "author": {"login": login}, "createdAt": at}
195223

196224

225+
def _pr(number: int, **over: object) -> dict:
226+
"""A PR as `gh pr list --json ...` returns it to the digest."""
227+
pr: dict = {
228+
"number": number,
229+
"title": f"pr {number}",
230+
"body": "",
231+
"headRefName": f"fix/pr-{number}",
232+
"isDraft": True,
233+
"mergeable": "MERGEABLE",
234+
}
235+
pr.update(over)
236+
return pr
237+
238+
239+
def test_an_issue_with_several_prs_reports_all_of_them(bin_dir: Path) -> None:
240+
"""The no-auto-close rule means a beta PR and a graduation PR both point at
241+
one issue. Returning only the first made the second invisible to every
242+
board pass, and derived the column from an arbitrary one of the two.
243+
244+
Branch names here deliberately carry NO issue number, so the association is
245+
proved by the body reference alone -- including the new `Refs` verb, which
246+
is the only spelling an intermediate PR is allowed."""
247+
_write_shim(
248+
bin_dir,
249+
"gh",
250+
_gh_shim(
251+
[_issue(500, labels=[{"name": "bug"}])],
252+
[
253+
_pr(501, body="Refs #500", headRefName="fix/beta-work"),
254+
_pr(
255+
502,
256+
body="Closes #500",
257+
headRefName="fix/graduation",
258+
mergeable="CONFLICTING",
259+
),
260+
],
261+
[],
262+
),
263+
)
264+
265+
item = _run(bin_dir)["items"][0]
266+
assert [p["number"] for p in item["prs"]] == [501, 502]
267+
assert item["prs"][1]["mergeable"] == "CONFLICTING"
268+
assert "pr" not in item
269+
270+
271+
def test_open_pr_list_actually_requests_isdraft(bin_dir: Path) -> None:
272+
"""`prs_for` emits `isDraft` on every PR object, but jq can only surface a
273+
field the `gh pr list --json ...` call actually requested -- a fixture
274+
that hands `isDraft` over regardless of the requested field list (like
275+
`_gh_shim` does) cannot catch a `--json` selection that omits it. This
276+
shim inspects the real invocation instead of trusting the fixture: it
277+
fails loudly, naming the missing field, if the open-PR `gh pr list` call
278+
does not ask for `isDraft` -- and only then hands back a PR whose
279+
`isDraft` is `False`, so the assertion below also proves the value flows
280+
through end to end rather than resolving to a silent null."""
281+
issue = _issue(650, labels=[{"name": "bug"}])
282+
pr = _pr(651, body="Fixes #650", isDraft=False)
283+
shim = f"""
284+
case "$*" in
285+
*"issue list"*) cat <<'EOF'
286+
{json.dumps([issue])}
287+
EOF
288+
;;
289+
*"pr diff "*"--name-only"*) : ;;
290+
*"pr list"*"--state merged"*) printf '%s\\n' '[]' ;;
291+
*"pr list"*)
292+
case "$*" in
293+
*isDraft*) : ;;
294+
*) echo "gh pr list --json is missing isDraft: $*" >&2; exit 1 ;;
295+
esac
296+
cat <<'EOF'
297+
{json.dumps([pr])}
298+
EOF
299+
;;
300+
*"project item-list"*) printf '%s\\n' '{{"items": []}}' ;;
301+
*) echo "unexpected gh call: $*" >&2; exit 1 ;;
302+
esac
303+
"""
304+
_write_shim(bin_dir, "gh", shim)
305+
306+
item = _run(bin_dir)["items"][0]
307+
assert item["prs"][0]["isDraft"] is False
308+
309+
197310
def test_human_comment_alone_does_not_move_the_column(bin_dir: Path) -> None:
198311
"""A human comment is NOT a blocker, and used to be treated as one.
199312
@@ -391,15 +504,13 @@ def test_closed_blocked_by_reference_does_not_block(bin_dir: Path) -> None:
391504
assert item["column"] == "Ready for Dev"
392505

393506

394-
def test_a_wait_outranks_a_live_worktree_but_the_worktree_stays_visible(
507+
def test_a_wait_no_longer_outranks_a_live_worktree(
395508
bin_dir: Path,
396509
) -> None:
397-
"""Confirming intent, per review of #623.
398-
399-
A recorded wait beats a live worktree in `column`, because an item whose
400-
scope is unsettled must not read as progressing — that is the whole point of
401-
the precedence fix. The risk is hiding active undelivered code, so the
402-
worktree is still reported on the item; only the column defers to the wait.
510+
"""Supersedes the #623 intent test now that Task 3 makes Status and
511+
Awaiting orthogonal (see task-3-brief.md). A live worktree is still real
512+
progress even while a wait is recorded — the wait is reported alongside
513+
it, not as a phase override.
403514
"""
404515
issue = _issue(704, labels=[{"name": "needs-debug-log"}])
405516
_write_shim(bin_dir, "gh", _gh_shim([issue], [], [], []))
@@ -409,9 +520,9 @@ def test_a_wait_outranks_a_live_worktree_but_the_worktree_stays_visible(
409520

410521
item = _run(bin_dir)["items"][0]
411522

412-
assert item["column"] == "Analysis"
523+
assert item["column"] == "In Progress"
413524
assert item["awaiting"] == "reporter"
414-
# The code is still visible — the wait changes the column, not the evidence.
525+
# The code is still visible — the wait is reported, not folded into the column.
415526
assert item["worktree"] == "/repo/wt/704"
416527
assert item["worktree_branch"] == "fix/issue-704-live"
417528
assert item["stale_worktree"] is False
@@ -453,6 +564,39 @@ def test_worktree_on_an_unmerged_branch_is_in_progress(bin_dir: Path) -> None:
453564
assert item["column"] == "In Progress"
454565

455566

567+
def test_a_merged_pr_with_the_issue_open_is_in_verification(bin_dir: Path) -> None:
568+
"""Merged to main, not yet in a stable release. The digest used to leave
569+
this period unnamed, so a fix awaiting real-world confirmation sat in
570+
whatever column it happened to be in."""
571+
issue = _issue(510, labels=[{"name": "bug"}, {"name": "analyzed"}])
572+
merged = [_pr(511, body="Refs #510", headRefName="fix/issue-510")]
573+
_write_shim(bin_dir, "gh", _gh_shim([issue], [], [], merged))
574+
575+
assert _run(bin_dir)["items"][0]["column"] == "In Verification"
576+
577+
578+
def test_an_open_pr_outranks_a_merged_one(bin_dir: Path) -> None:
579+
"""A graduation PR still open means the work is In Review, not verified."""
580+
issue = _issue(512, labels=[{"name": "bug"}])
581+
prs = [_pr(514, body="Closes #512", headRefName="fix/issue-512-b")]
582+
merged = [_pr(513, body="Refs #512", headRefName="fix/issue-512-a")]
583+
_write_shim(bin_dir, "gh", _gh_shim([issue], prs, [], merged))
584+
585+
assert _run(bin_dir)["items"][0]["column"] == "In Review"
586+
587+
588+
def test_a_wait_no_longer_rewrites_the_phase(bin_dir: Path) -> None:
589+
"""Status is the phase, Awaiting is the wait, and they are orthogonal. An
590+
In Review item blocked on the maintainer must not report Analysis."""
591+
issue = _issue(515, labels=[{"name": "bug"}, {"name": "blocked"}])
592+
prs = [_pr(516, body="Refs #515", headRefName="fix/issue-515")]
593+
_write_shim(bin_dir, "gh", _gh_shim([issue], prs, []))
594+
595+
item = _run(bin_dir)["items"][0]
596+
assert item["column"] == "In Review"
597+
assert item["blocked"] is True
598+
599+
456600
def test_merged_pr_does_not_close_an_open_issue(bin_dir: Path) -> None:
457601
"""A merged PR that closes an issue must NOT be read as Done while the
458602
issue is open. This project's beta PRs deliberately omit `Closes #N` until
@@ -529,12 +673,12 @@ def test_conflicting_pr_is_reported_on_its_issue(bin_dir: Path) -> None:
529673
digest = _run(bin_dir)
530674

531675
item = digest["items"][0]
532-
assert item["pr"] == 610
533-
assert item["pr_state"] == "CONFLICTING"
676+
assert [p["number"] for p in item["prs"]] == [610]
677+
assert item["prs"][0]["mergeable"] == "CONFLICTING"
534678
assert item["column"] == "In Review"
535679

536680

537-
def test_issue_matched_by_two_prs_emits_one_item_with_a_scalar_pr(
681+
def test_issue_matched_by_two_prs_emits_one_item_with_both_prs(
538682
bin_dir: Path,
539683
) -> None:
540684
"""Regression test for a real bug found while implementing this script:
@@ -544,10 +688,10 @@ def test_issue_matched_by_two_prs_emits_one_item_with_a_scalar_pr(
544688
matches `select(...)` is a stream and the whole expression becomes a
545689
stream of PR objects rather than a single scalar. Downstream that stream
546690
gets cross-multiplied into the `items[]` comprehension, silently emitting
547-
one duplicate item row per extra match instead of picking a single PR
548-
deterministically. This issue has two open PRs that both match it (one by
691+
one duplicate item row per extra match instead of joining all of them onto
692+
one item. This issue has two open PRs that both match it (one by
549693
`Fixes #N` in the body, one by headRefName pattern), which triggers the
550-
bug if `pr_for` regresses back to the `// null` idiom."""
694+
bug if `prs_for` regresses back to a `// null`-style single-scalar pick."""
551695
issue = {
552696
"number": 606,
553697
"title": "Two competing fix attempts",
@@ -582,10 +726,7 @@ def test_issue_matched_by_two_prs_emits_one_item_with_a_scalar_pr(
582726
)
583727
item = digest["items"][0]
584728
assert item["number"] == 606
585-
assert isinstance(item["pr"], int), (
586-
"pr must be a single scalar issue number, not a list — " f"got {item['pr']!r}"
587-
)
588-
assert item["pr"] == 620
729+
assert [p["number"] for p in item["prs"]] == [620, 621]
589730

590731

591732
def test_worktree_branch_without_issue_prefix_joins_by_delimited_number(
@@ -714,7 +855,9 @@ def test_pr_joined_only_by_headref_is_not_an_orphan(bin_dir: Path) -> None:
714855
digest = _run(bin_dir)
715856

716857
item = digest["items"][0]
717-
assert item["pr"] == 630, "PR must still join the issue via headRefName"
858+
assert [p["number"] for p in item["prs"]] == [
859+
630
860+
], "PR must still join the issue via headRefName"
718861
pr_orphans = [o for o in digest["orphans"] if o["kind"] == "pr_no_issue"]
719862
assert pr_orphans == [], (
720863
"a PR joined to an open issue by headRefName must not be reported as "
@@ -1006,3 +1149,108 @@ def test_issue_with_a_card_is_not_an_orphan(bin_dir: Path) -> None:
10061149
digest = _run(bin_dir)
10071150

10081151
assert [o for o in digest["orphans"] if o["kind"] == "issue_no_card"] == []
1152+
1153+
1154+
def test_resume_handoffs_are_counted(bin_dir: Path) -> None:
1155+
"""A session that died twice is telling you something -- but nothing
1156+
counted, so nothing could act on it. The marker is an HTML comment so the
1157+
handoff still reads as prose on GitHub."""
1158+
_write_shim(
1159+
bin_dir,
1160+
"gh",
1161+
_gh_shim(
1162+
[
1163+
_issue(
1164+
520,
1165+
labels=[{"name": "bug"}],
1166+
comments=[
1167+
_comment(
1168+
"bess-developer",
1169+
"Resuming implementation.\n<!-- resume-handoff -->",
1170+
),
1171+
_comment("johanzander", "thanks"),
1172+
_comment(
1173+
"bess-developer",
1174+
"Resuming implementation.\n<!-- resume-handoff -->",
1175+
),
1176+
],
1177+
)
1178+
],
1179+
[],
1180+
[],
1181+
),
1182+
)
1183+
1184+
assert _run(bin_dir)["items"][0]["resume_count"] == 2
1185+
1186+
1187+
def test_in_flight_files_map_paths_to_the_prs_touching_them(bin_dir: Path) -> None:
1188+
"""The collision gate needs to know what is already being edited. Half of
1189+
that is exact -- the changed-file set of every open PR -- and only the
1190+
candidate's own touch-set has to be predicted."""
1191+
_write_shim(
1192+
bin_dir,
1193+
"gh",
1194+
_gh_shim(
1195+
[_issue(530, labels=[{"name": "bug"}])],
1196+
[
1197+
_pr(531, body="Refs #530", headRefName="fix/a-530"),
1198+
_pr(532, body="Refs #530", headRefName="fix/b-530"),
1199+
],
1200+
[],
1201+
pr_files={
1202+
531: ["CLAUDE.md", "scripts/backlog-rhythm.sh"],
1203+
532: ["CLAUDE.md"],
1204+
},
1205+
),
1206+
)
1207+
1208+
in_flight = _run(bin_dir)["in_flight_files"]
1209+
assert in_flight["CLAUDE.md"] == [531, 532]
1210+
assert in_flight["scripts/backlog-rhythm.sh"] == [531]
1211+
1212+
1213+
def test_no_open_prs_gives_empty_in_flight_files(bin_dir: Path) -> None:
1214+
"""Pre-existing shims report no open PRs; the map must still be an empty
1215+
object, not null, so a caller can index into it unconditionally."""
1216+
issue = _issue(601, labels=[{"name": "bug"}])
1217+
_write_shim(bin_dir, "gh", _gh_shim([issue], [], []))
1218+
1219+
assert _run(bin_dir)["in_flight_files"] == {}
1220+
1221+
1222+
def test_undiffable_pr_is_recorded_not_a_crash(bin_dir: Path) -> None:
1223+
"""A PR whose diff cannot be read (deleted fork head, rate limit,
1224+
transient network error) must not abort the whole digest -- that would
1225+
take down issue triage and dispatch for everyone over one stale PR. It
1226+
also must not be silently treated as touching no files, which the
1227+
collision gate would read as safe to dispatch against. It is recorded as
1228+
data instead."""
1229+
_write_shim(
1230+
bin_dir,
1231+
"gh",
1232+
_gh_shim(
1233+
[_issue(530, labels=[{"name": "bug"}])],
1234+
[
1235+
_pr(531, body="Refs #530", headRefName="fix/a-530"),
1236+
_pr(532, body="Refs #530", headRefName="fix/b-530"),
1237+
],
1238+
[],
1239+
pr_files={532: ["CLAUDE.md"]},
1240+
fail_pr_diff=[531],
1241+
),
1242+
)
1243+
1244+
digest = _run(bin_dir)
1245+
1246+
assert digest["undiffable_prs"] == [531]
1247+
# The readable PR is still processed -- one bad PR does not blank the
1248+
# whole in-flight set.
1249+
assert digest["in_flight_files"]["CLAUDE.md"] == [532]
1250+
1251+
1252+
def test_no_undiffable_prs_gives_empty_list(bin_dir: Path) -> None:
1253+
issue = _issue(601, labels=[{"name": "bug"}])
1254+
_write_shim(bin_dir, "gh", _gh_shim([issue], [], []))
1255+
1256+
assert _run(bin_dir)["undiffable_prs"] == []

0 commit comments

Comments
 (0)