Skip to content

Commit 8043155

Browse files
johanzanderclaude
andauthored
feat: put PRs on the board so a decision about one is recorded, not re-argued (#636)
* feat: put PRs on the board so a decision about one is recorded, not re-argued The board held issues only, so every judgement about a PR had nowhere to live. "#167 and #354 are blocked", "#437 and #490 are lower priority, later" were real decisions the maintainer had already made, and the rhythm pass re-reported all four as due on every tick because nothing recorded them. The same conversation happened every 30 minutes. Projects v2 takes PRs as items with the identical field set, so the fix is membership rather than a parallel mechanism. A PR card carries the same `Priority` and `Awaiting` an issue card does; the digest emits them as `pr_board`, and `backlog-rhythm.sh` joins by number and suppresses on them: an `Awaiting` means parked on someone, `P4` means later-not-never. `content.type` is what separates PR cards from issue ones, confirmed against a real card rather than assumed — an added PR reports "PullRequest" with number/title/url/repository alongside it. Numbers are unique across issues and PRs in one repository, so this cannot collide with the existing issue lookup. Suppressed PRs are COUNTED AND LISTED, never dropped: the pass ends with `deferred: 4 (#490 priority P4; #167 awaiting discussion; ...)`. Silently vanishing would trade one failure for another — the goal is to stop re-asking about a settled decision, not to lose the item. CONTRACT CHANGE: an APPROVED, green, still-draft PR is now its own action, `mark_ready`, and it is the one thing no board decision can defer. It used to hand back to `implement-issue` like any other unfinished draft, on the principle that this pass must not grow a second review loop. That principle still holds, but it is what left #629 sitting approved, green and draft: the remedy on offer was a whole `implement-issue` session, and nobody spends one of those to run a single command. `gh pr ready` is a terminal action, not a loop, so naming it here duplicates nothing. `awaiting_maintainer` is deliberately NOT carved out the same way. An approved PR waiting on a merge is not broken; it is the maintainers call when to take it, and P4 is exactly how they say later. #490 sat approved for a day and was reported every tick as though that were news. Live effect: 31 actions -> 27, with one `deferred: 4` line in place of four recurring items, and #631 correctly still reported as merge-ready. No CHANGELOG entry: agent tooling, no user-visible effect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR * fix: mark_ready needs green checks, not just a clean merge Found by running the rule against the live fleet on its first pass, which is the only reason it was caught before the PR merged. `mergeable` reports whether the branch merges cleanly and nothing else, so it reads MERGEABLE while CI is still running or has failed outright. #633 was APPROVED, MERGEABLE and had Algorithm tests and E2E still IN_PROGRESS, and the rule duly reported "gh pr ready 633 — then it is the maintainers to merge". GitHub itself disagreed: `mergeStateStatus` was BLOCKED. Flipping a red or pending PR out of draft is worse than leaving it there. `ready` is supposed to mean the maintainer can merge without checking anything else, and that claim is the only thing making the flag worth setting. So `mark_ready` now also requires every check to have concluded SUCCESS, SKIPPED or NEUTRAL. SKIPPED is green on purpose: this repo path-filters Algorithm tests and Docker build, so every backend-only PR skips them and treating that as not-green would withhold the action from almost everything. An empty rollup is green too — a PR with no checks configured has nothing failing. The deferred list mirrors the same condition, so an approved-but-pending PR with a P4 card appears in exactly one place rather than both. Live effect: #633 falls back to resume_implementation until its CI settles, which is the correct answer and the one the first version got wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fc08944 commit 8043155

5 files changed

Lines changed: 425 additions & 14 deletions

File tree

.claude/skills/backlog/SKILL.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ Actions, and who does what:
204204
| Action | Do |
205205
|---|---|
206206
| `resume_implementation` | `/implement-issue <n>`. **The action that produces a ready PR** — Step 11 requests the review, acts on the verdict and runs `gh pr ready`. Covers a draft needing a first review, a rework, an approved PR that never got flipped, *and* a worktree whose session died |
207+
| `mark_ready` | `gh pr ready <n>`, then report it. APPROVED, green, still a draft — the loop stopped one command short. **The one action no board decision can defer**, because it is a pipeline failure rather than a priority |
207208
| `awaiting_maintainer` | nothing; report it. Out of draft **and carrying an APPROVED review** is the finish line |
208209
| `request_review` | out of draft but Stage 4 never ran. `scripts/request-pr-review.sh <n>`. **Never report an unreviewed PR as ready to merge** — the draft flag is not a review, and a maintainer who flips it because a PR looks stuck routes around the one gate the pipeline is built on |
209210
| `rework_review` | out of draft with changes requested: `/implement-issue <n>` to address them, then a fresh review |
@@ -217,11 +218,40 @@ Actions, and who does what:
217218

218219
**This pass does not drive the review loop, and must not learn to.**
219220
`implement-issue` owns a PR from its first commit to `gh pr ready`; Step 11
220-
already requests the review, acts on the verdict and flips the PR. So every
221+
already requests the review, acts on the verdict and flips the PR. So an
221222
unfinished draft resolves to one action — hand it back — and a second copy of
222223
that loop is never built here. It is the same argument that put resume in Step 0
223224
instead of a separate skill: two copies of one loop means one of them goes stale.
224225

226+
**The one carve-out is `mark_ready`, and it earned it.** An APPROVED, green,
227+
still-draft PR used to hand back like any other, and that is exactly why #629
228+
sat finished-but-draft: the remedy on offer was a whole `implement-issue`
229+
session, and nobody spends one of those to run a single command. `gh pr ready`
230+
is a terminal action, not a loop, so naming it here duplicates nothing.
231+
232+
## Deferring a PR — how a decision gets recorded once
233+
234+
**PRs go on the board too, and carry the same `Priority` and `Awaiting` fields
235+
issues do.** Before that, a judgement about a PR had nowhere to live: "#167 and
236+
#354 are blocked", "#437 and #490 are lower priority, later" were real
237+
decisions, and every pass re-reported all four as due because nothing recorded
238+
them. The same conversation happened every 30 minutes.
239+
240+
| Decision | Set on the PR card | Effect |
241+
|---|---|---|
242+
| blocked / parked on a call | `Awaiting: discussion` (or `upstream`) | suppressed from actions |
243+
| later, not never | `Priority: P4` | suppressed from actions |
244+
| actively being driven | no `Awaiting`, `P1``P3` | reported every tick |
245+
246+
Suppressed PRs are **counted and listed**, never dropped — the pass ends with
247+
`deferred: 4 (#490 priority P4; #167 awaiting discussion; …)` so the item stays
248+
findable and the reason travels with it. Losing the item would trade one
249+
failure for another.
250+
251+
`mark_ready` ignores all of this, per the carve-out above. `awaiting_maintainer`
252+
does not: an approved PR waiting on a merge is not broken, it is the
253+
maintainer's call when to take it, and `P4` is how they say "later".
254+
225255
**Restarting a stalled issue is always a resume, never a fresh start.** Step 0
226256
re-enters at the earliest incomplete step. A restart runs Step 4, which branches
227257
from `origin/main` and deletes commits that exist nowhere else — an audit found 8

backend/tests/test_backlog_digest.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,63 @@ def test_issue_with_no_card_reports_null_status_and_is_an_orphan(
901901
assert [o["ref"] for o in orphans] == ["624"]
902902

903903

904+
def test_pr_cards_are_emitted_separately_from_issue_cards(bin_dir: Path) -> None:
905+
"""`content.type` is the load-bearing bit, and it is confirmed against a
906+
real card rather than assumed: an added PR reports "PullRequest" with
907+
number/title/url/repository alongside it.
908+
909+
Board membership for PRs is what gives a decision about a PR somewhere to
910+
live -- without it "#437 is lower priority" had nowhere to be recorded, so
911+
every rhythm pass re-reported it as due.
912+
"""
913+
issue = _issue(601)
914+
cards = [
915+
{
916+
"content": {"number": 601, "type": "Issue"},
917+
"status": "Backlog",
918+
"priority": "P2",
919+
},
920+
{
921+
"content": {"number": 437, "type": "PullRequest"},
922+
"status": "In Review",
923+
"priority": "P4",
924+
},
925+
{
926+
"content": {"number": 167, "type": "PullRequest"},
927+
"status": "Backlog",
928+
"awaiting": "discussion",
929+
},
930+
]
931+
_write_shim(bin_dir, "gh", _gh_shim([issue], [], cards))
932+
933+
digest = _run(bin_dir)
934+
935+
assert digest["pr_board"] == [
936+
{
937+
"number": 437,
938+
"board_status": "In Review",
939+
"priority": "P4",
940+
"awaiting": None,
941+
},
942+
{
943+
"number": 167,
944+
"board_status": "Backlog",
945+
"priority": None,
946+
"awaiting": "discussion",
947+
},
948+
]
949+
# The issue card is untouched by the split and still drives the item.
950+
assert digest["items"][0]["priority"] == "P2"
951+
952+
953+
def test_pr_board_is_empty_when_no_prs_are_carded(bin_dir: Path) -> None:
954+
issue = _issue(601)
955+
card = {"content": {"number": 601, "type": "Issue"}, "status": "Backlog"}
956+
_write_shim(bin_dir, "gh", _gh_shim([issue], [], [card]))
957+
958+
assert _run(bin_dir)["pr_board"] == []
959+
960+
904961
def test_a_locked_worktree_is_reported_as_locked(bin_dir: Path) -> None:
905962
"""The lock is the liveness signal `claude agents` cannot provide: it lists
906963
background agents only, so a foreground `/implement-issue` is invisible and

backend/tests/test_backlog_rhythm.py

Lines changed: 201 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,27 @@ def _comment(days: int, *, is_reporter: bool = False, is_bot: bool = False) -> d
6464
}
6565

6666

67-
def _run(tmp_path: Path, items: list, prs: list | None = None, **env: str) -> dict:
67+
def _run(
68+
tmp_path: Path,
69+
items: list,
70+
prs: list | None = None,
71+
pr_board: list | None = None,
72+
**env: str,
73+
) -> dict:
6874
digest = tmp_path / "digest.json"
69-
digest.write_text(json.dumps({"counts": {}, "items": items, "orphans": []}))
75+
digest.write_text(
76+
json.dumps(
77+
{
78+
"counts": {},
79+
"items": items,
80+
"orphans": [],
81+
# Board cards for PRs. Defaults to empty, which is also what an
82+
# older digest produces -- the script tolerates its absence, so
83+
# every pre-existing test exercises the no-deferral path.
84+
"pr_board": pr_board or [],
85+
}
86+
)
87+
)
7088
prs_file = tmp_path / "prs.json"
7189
prs_file.write_text(json.dumps(prs or []))
7290

@@ -346,6 +364,10 @@ def _pr(number: int, **over: object) -> dict:
346364
"reviewDecision": None,
347365
"reviews": [],
348366
"author": {"login": "johanzander"},
367+
# Defaults to green, so a test that cares about checks says so. An
368+
# empty rollup also reads as green, which is correct: a PR with no
369+
# checks configured has nothing failing.
370+
"statusCheckRollup": [{"name": "CI", "conclusion": "SUCCESS"}],
349371
}
350372
pr.update(over)
351373
return pr
@@ -356,19 +378,24 @@ def test_every_unfinished_draft_resolves_to_one_handoff(tmp_path: Path) -> None:
356378
through to `gh pr ready`, and its Step 11 already requests the review, acts
357379
on the verdict and flips the PR.
358380
359-
So a draft needing a FIRST review, a draft needing REWORK, and a draft that
360-
is already APPROVED but never got flipped all resolve to the same action:
361-
hand it back to the skill that owns it. Step 0 re-enters at the right step.
362-
Re-implementing any of that here would be a second copy of one loop, which
363-
is how one of them goes stale.
381+
So a draft needing a FIRST review and a draft needing REWORK both resolve
382+
to the same action: hand it back to the skill that owns it. Step 0 re-enters
383+
at the right step. Re-implementing any of that here would be a second copy
384+
of one loop, which is how one of them goes stale.
385+
386+
THE APPROVED-BUT-DRAFT CASE IS NOW CARVED OUT, deliberately — see
387+
`test_an_approved_draft_is_never_deferred`. It used to route here too, and
388+
that is precisely why #629 sat approved, green and draft: the remedy on
389+
offer was a whole `implement-issue` session, and nobody spends one of those
390+
to run a single command. `gh pr ready` is not a review loop, so naming it
391+
directly does not duplicate one.
364392
"""
365-
approved_but_draft = _pr(615, reviews=[{"state": "APPROVED"}])
366393
never_reviewed = _pr(619, reviews=[])
367394
changes_requested = _pr(
368395
614, reviews=[{"state": "APPROVED"}, {"state": "CHANGES_REQUESTED"}]
369396
)
370397

371-
for pr in (approved_but_draft, never_reviewed, changes_requested):
398+
for pr in (never_reviewed, changes_requested):
372399
actions = _actions_for(_run(tmp_path, [], [pr]), pr["number"])
373400
assert actions == {"resume_implementation"}, pr["number"]
374401

@@ -377,7 +404,9 @@ def test_the_handoff_names_the_issue_to_resume(tmp_path: Path) -> None:
377404
"""`/implement-issue <n>` takes an issue number, so the action has to carry
378405
one — otherwise the loop reports work nobody can pick up."""
379406
item = _item(592, pr=615, column="In Review", last_comment=_comment(1))
380-
pr = _pr(615, reviews=[{"state": "APPROVED"}])
407+
# Not an approved draft: that case is `mark_ready` now and carries no
408+
# issue, because `gh pr ready <n>` needs only the PR number.
409+
pr = _pr(615, reviews=[{"state": "CHANGES_REQUESTED"}])
381410

382411
action = next(
383412
a for a in _run(tmp_path, [item], [pr])["actions"] if a.get("pr") == 615
@@ -526,3 +555,165 @@ def test_conflicting_pr_is_flagged_over_its_review_state(tmp_path: Path) -> None
526555
never fired" and nobody investigates."""
527556
pr = _pr(437, mergeable="CONFLICTING", reviews=[])
528557
assert "resolve_conflict" in _actions_for(_run(tmp_path, [], [pr]), 437)
558+
559+
560+
def _card(number: int, **over: object) -> dict:
561+
card: dict = {
562+
"number": number,
563+
"board_status": "Backlog",
564+
"priority": None,
565+
"awaiting": None,
566+
}
567+
card.update(over)
568+
return card
569+
570+
571+
def test_a_pr_awaiting_something_is_deferred_not_reported(tmp_path: Path) -> None:
572+
"""The reason the board holds PRs at all. "#167 and #354 are blocked" was a
573+
real decision with nowhere to live, so every pass re-reported them as
574+
conflicts needing action and the same conversation happened every tick."""
575+
pr = _pr(167, mergeable="CONFLICTING")
576+
result = _run(tmp_path, [], [pr], pr_board=[_card(167, awaiting="discussion")])
577+
578+
assert _actions_for(result, 167) == set()
579+
assert [d["pr"] for d in result["deferred"]] == [167]
580+
assert result["deferred"][0]["why"] == "awaiting discussion"
581+
582+
583+
def test_a_p4_pr_is_deferred(tmp_path: Path) -> None:
584+
""" "Lower priority, I intend to get to it later" — #437 and #490."""
585+
pr = _pr(437, mergeable="CONFLICTING")
586+
result = _run(tmp_path, [], [pr], pr_board=[_card(437, priority="P4")])
587+
588+
assert _actions_for(result, 437) == set()
589+
assert result["deferred"][0]["why"] == "priority P4"
590+
591+
592+
def test_an_approved_pr_can_be_deferred(tmp_path: Path) -> None:
593+
"""An approved PR waiting on a merge is not broken — it is the maintainers
594+
call when to take it, and P4 is how they say later. #490 sat approved for a
595+
day and was reported every tick as though that were news."""
596+
pr = _pr(490, isDraft=False, reviews=[{"state": "APPROVED"}])
597+
result = _run(tmp_path, [], [pr], pr_board=[_card(490, priority="P4")])
598+
599+
assert "awaiting_maintainer" not in _actions_for(result, 490)
600+
assert [d["pr"] for d in result["deferred"]] == [490]
601+
602+
603+
def test_an_approved_draft_is_never_deferred(tmp_path: Path) -> None:
604+
"""The one carve-out. APPROVED-and-still-draft is a pipeline failure, not a
605+
priority: the loop stopped one command short of finishing. #629 sat in
606+
exactly this state while every pass reported it as ordinary unfinished
607+
work, because the session that owned it went idle before gh pr ready."""
608+
pr = _pr(629, isDraft=True, reviews=[{"state": "APPROVED"}])
609+
result = _run(tmp_path, [], [pr], pr_board=[_card(629, priority="P4")])
610+
611+
assert "mark_ready" in _actions_for(result, 629)
612+
assert result["deferred"] == []
613+
614+
615+
def test_approved_draft_is_reported_even_with_no_card(tmp_path: Path) -> None:
616+
pr = _pr(629, isDraft=True, reviews=[{"state": "APPROVED"}])
617+
assert "mark_ready" in _actions_for(_run(tmp_path, [], [pr]), 629)
618+
619+
620+
def test_a_stale_approval_does_not_earn_mark_ready(tmp_path: Path) -> None:
621+
"""The same staleness trap the non-draft rules already guard: an
622+
approved-then-reworked draft keeps its APPROVED entry forever."""
623+
pr = _pr(
624+
700,
625+
isDraft=True,
626+
reviewDecision="CHANGES_REQUESTED",
627+
reviews=[{"state": "APPROVED"}, {"state": "CHANGES_REQUESTED"}],
628+
)
629+
actions = _actions_for(_run(tmp_path, [], [pr]), 700)
630+
assert "mark_ready" not in actions
631+
assert "resume_implementation" in actions
632+
633+
634+
def test_a_conflicting_approved_draft_is_not_mark_ready(tmp_path: Path) -> None:
635+
"""A conflicted PR cannot be merged and gets no CI run, so flipping it
636+
ready would hand over something unmergeable."""
637+
pr = _pr(
638+
701, isDraft=True, mergeable="CONFLICTING", reviews=[{"state": "APPROVED"}]
639+
)
640+
actions = _actions_for(_run(tmp_path, [], [pr]), 701)
641+
assert "mark_ready" not in actions
642+
assert "resolve_conflict" in actions
643+
644+
645+
def test_a_pr_with_no_card_is_reported_as_before(tmp_path: Path) -> None:
646+
"""Deferral is opt-in: absent a card, nothing changes."""
647+
pr = _pr(614, mergeable="CONFLICTING")
648+
result = _run(tmp_path, [], [pr], pr_board=[])
649+
650+
assert "resolve_conflict" in _actions_for(result, 614)
651+
assert result["deferred"] == []
652+
653+
654+
def test_mark_ready_needs_green_checks_not_just_a_clean_merge(
655+
tmp_path: Path,
656+
) -> None:
657+
"""`mergeable` reports only whether the branch merges cleanly, so it reads
658+
MERGEABLE while checks are still running. #633 was APPROVED and MERGEABLE
659+
with Algorithm tests and E2E in progress, and the first version of this
660+
rule duly said to flip it — which would hand the maintainer a PR marked
661+
ready whose CI had not finished."""
662+
pr = _pr(
663+
633,
664+
isDraft=True,
665+
reviews=[{"state": "APPROVED"}],
666+
statusCheckRollup=[
667+
{"name": "Fast tests", "conclusion": "SUCCESS"},
668+
{"name": "Algorithm tests", "conclusion": ""},
669+
],
670+
)
671+
actions = _actions_for(_run(tmp_path, [], [pr]), 633)
672+
assert "mark_ready" not in actions
673+
assert "resume_implementation" in actions
674+
675+
676+
def test_mark_ready_is_withheld_on_a_failing_check(tmp_path: Path) -> None:
677+
pr = _pr(
678+
634,
679+
isDraft=True,
680+
reviews=[{"state": "APPROVED"}],
681+
statusCheckRollup=[
682+
{"name": "Fast tests", "conclusion": "SUCCESS"},
683+
{"name": "E2E tests", "conclusion": "FAILURE"},
684+
],
685+
)
686+
assert "mark_ready" not in _actions_for(_run(tmp_path, [], [pr]), 634)
687+
688+
689+
def test_a_skipped_check_still_counts_as_green(tmp_path: Path) -> None:
690+
"""Path-filtered jobs correctly do not run — every backend-only PR in this
691+
repo skips Algorithm tests and Docker build, so treating SKIPPED as
692+
not-green would withhold mark_ready from almost every PR."""
693+
pr = _pr(
694+
635,
695+
isDraft=True,
696+
reviews=[{"state": "APPROVED"}],
697+
statusCheckRollup=[
698+
{"name": "Fast tests", "conclusion": "SUCCESS"},
699+
{"name": "Algorithm tests", "conclusion": "SKIPPED"},
700+
],
701+
)
702+
assert "mark_ready" in _actions_for(_run(tmp_path, [], [pr]), 635)
703+
704+
705+
def test_a_pending_approved_draft_is_not_counted_as_deferred(
706+
tmp_path: Path,
707+
) -> None:
708+
"""The deferred list mirrors the mark_ready carve-out, so a card-deferred
709+
PR that is approved-but-pending must appear in exactly one place."""
710+
pr = _pr(
711+
636,
712+
isDraft=True,
713+
reviews=[{"state": "APPROVED"}],
714+
statusCheckRollup=[{"name": "CI", "conclusion": ""}],
715+
)
716+
result = _run(tmp_path, [], [pr], pr_board=[_card(636, priority="P4")])
717+
718+
assert _actions_for(result, 636) == set()
719+
assert [d["pr"] for d in result["deferred"]] == [636]

0 commit comments

Comments
 (0)