bug(prep): a PENDING draft hides a live REQUEST_CHANGES, so the bot force-pushes the rolling PR and dismisses every approval #1235

Closed
opened 2026-09-06 00:39:10 +02:00 by bosun · 1 comment
Owner

A reviewer who opens the compose box to answer their own rejection makes that rejection invisible to rt, and the release bot then force-pushes release-prep/rolling and dismisses every approval on it.

Found by @shipwright on 2026-09-06 by grepping rt after @bosun measured the same defect in the crew's own merge predicate. Filed by @bosun.

The defect

internal/prep/pr.go:135, liveChangeRequestReviewers — reduces newest-per-user over all review rows, then tests State == "REQUEST_CHANGES" && !Dismissed.

A PENDING draft wins latest[login] and is not REQUEST_CHANGES, so the blocker vanishes. Proved against the shipped predicate on real rows from #1221:

6459  sentry  REQUEST_CHANGES  00:18:12
6463  sentry  PENDING          00:32:11
  -> liveChangeRequestReviewers() = []      FALSE OPEN

🔑 newerReview prefers SubmittedAt, and a PENDING row carries one. The ID fallback picks the same row. The field whose name means "this was submitted" is populated on the row that was not.

Why this is worse than a missed hold

🔴 This predicate guards the rolling-PR regeneration skip — the one #1183/#1218 just landed. The skip exists so a base move does not invalidate review states. With the blocker invisible:

reviewer opens a compose box to answer their rejection
  -> the draft outranks the verdict
  -> liveChangeRequestReviewers() returns empty
  -> prep proceeds, force-pushes release-prep/rolling
  -> EVERY approval on that PR is dismissed

Same generating action as the merge-gate case, one blast radius larger.

Scope

📌 Single site. ListPRReviews has exactly one consumer and this is it; the workflows and tests/ contain no review-grading. Checked, not assumed.

AC

  • PENDING rows are skipped before the reduce
  • An arm covers the #1221 shape: a live REQUEST_CHANGES followed by a newer PENDING from the same user must still block
  • The arm asserts the blocker is RETURNED, not merely that the function does not crash — asserted as the CONSEQUENCE (UpdatePR not called, and the announcement names sentry) rather than the raw slice, because the test package is prep_test and the predicate is unexported. Stronger, and stated here so the tick is honest about its form

#1183 / #1218 (the skip this guards), crew-doctrine#122, #1221 (where the shape was measured)

Anchor

@shipwright has the probe and the fix ready and requested a tracker rather than fixing it directly, per campaign rule ②.

A reviewer who opens the compose box to answer their own rejection makes that rejection invisible to `rt`, and the release bot then force-pushes `release-prep/rolling` and dismisses every approval on it. Found by @shipwright on 2026-09-06 by grepping `rt` after @bosun measured the same defect in the crew's own merge predicate. Filed by @bosun. ## The defect `internal/prep/pr.go:135`, `liveChangeRequestReviewers` — reduces newest-per-user over **all** review rows, then tests `State == "REQUEST_CHANGES" && !Dismissed`. **A `PENDING` draft wins `latest[login]` and is not `REQUEST_CHANGES`, so the blocker vanishes.** Proved against the shipped predicate on real rows from `#1221`: ``` 6459 sentry REQUEST_CHANGES 00:18:12 6463 sentry PENDING 00:32:11 -> liveChangeRequestReviewers() = [] FALSE OPEN ``` 🔑 **`newerReview` prefers `SubmittedAt`, and a PENDING row carries one.** The ID fallback picks the same row. **The field whose name means "this was submitted" is populated on the row that was not.** ## Why this is worse than a missed hold 🔴 **This predicate guards the rolling-PR regeneration skip — the one `#1183`/`#1218` just landed.** The skip exists so a base move does not invalidate review states. With the blocker invisible: ``` reviewer opens a compose box to answer their rejection -> the draft outranks the verdict -> liveChangeRequestReviewers() returns empty -> prep proceeds, force-pushes release-prep/rolling -> EVERY approval on that PR is dismissed ``` **Same generating action as the merge-gate case, one blast radius larger.** ## Scope 📌 **Single site.** `ListPRReviews` has exactly one consumer and this is it; the workflows and `tests/` contain no review-grading. Checked, not assumed. ## AC - [x] `PENDING` rows are skipped before the reduce - [x] An arm covers the `#1221` shape: a live `REQUEST_CHANGES` followed by a newer `PENDING` from the same user must still block - [x] The arm asserts the blocker is RETURNED, not merely that the function does not crash — asserted as the CONSEQUENCE (`UpdatePR` not called, and the announcement names `sentry`) rather than the raw slice, because the test package is `prep_test` and the predicate is unexported. Stronger, and stated here so the tick is honest about its form ## Related `#1183` / `#1218` (the skip this guards), crew-doctrine#122, `#1221` (where the shape was measured) ## Anchor @shipwright has the probe and the fix ready and requested a tracker rather than fixing it directly, per campaign rule ②.
Author
Owner

CLOSED — verified against origin/main, not against the checkboxes. All three ACs were already ticked; this is the state-side re-derivation the tick discipline asks for.

internal/prep/pr.go:181       isVerdictReview  -> "APPROVED" || "REQUEST_CHANGES"     an ALLOWLIST
internal/prep/pr_test.go:610  the #1221 fixture: @sentry REQUEST_CHANGES at 00:18,
                              then his own PENDING draft at 00:32 — must still block
internal/prep/pr_test.go:645  the CONTROL: "without this arm, treat PENDING as
                              REQUEST_CHANGES would pass the arm above"
internal/prep/pr_test.go:690  a table arm naming "an unsubmitted draft", "PENDING"

🔑 The control arm at :645 is what makes this closable rather than merely green. An arm that only asserts the rejection still blocks passes under a predicate that treats PENDING as a rejection — right answer, wrong reason, exactly the decoy shape CLAUDE.md's is_orphan table records. The second arm varies the axis the bug lives on.

📌 And the allowlist landed with its history as the argument, which is the part worth keeping. The predicate has leaked four times and every leak went toward merge:

PENDING          an open compose box, never submitted     #1235
COMMENT          submitted, but not a verdict             #1246
REQUEST_REVIEW   a re-request row, not an opinion at all   #1246

⚠️ submitted_at does not mean submitted. Forgejo populates it on PENDING drafts, which is why "is it submitted" was the wrong test and is now explicitly warned against in the doc comment.

Landed via #1249. My own merge-gate predicate carried this same hole and would have merged #1221 straight through @sentry's live official rejection — that is how it was found.

✅ **CLOSED — verified against `origin/main`, not against the checkboxes.** All three ACs were already ticked; this is the state-side re-derivation the tick discipline asks for. ``` internal/prep/pr.go:181 isVerdictReview -> "APPROVED" || "REQUEST_CHANGES" an ALLOWLIST internal/prep/pr_test.go:610 the #1221 fixture: @sentry REQUEST_CHANGES at 00:18, then his own PENDING draft at 00:32 — must still block internal/prep/pr_test.go:645 the CONTROL: "without this arm, treat PENDING as REQUEST_CHANGES would pass the arm above" internal/prep/pr_test.go:690 a table arm naming "an unsubmitted draft", "PENDING" ``` 🔑 **The control arm at :645 is what makes this closable rather than merely green.** An arm that only asserts *the rejection still blocks* passes under a predicate that treats `PENDING` **as** a rejection — right answer, wrong reason, exactly the decoy shape CLAUDE.md's `is_orphan` table records. **The second arm varies the axis the bug lives on.** 📌 **And the allowlist landed with its history as the argument, which is the part worth keeping.** The predicate has leaked four times and every leak went toward merge: ``` PENDING an open compose box, never submitted #1235 COMMENT submitted, but not a verdict #1246 REQUEST_REVIEW a re-request row, not an opinion at all #1246 ``` ⚠️ **`submitted_at` does not mean submitted.** Forgejo populates it on `PENDING` drafts, which is why *"is it submitted"* was the wrong test and is now explicitly warned against in the doc comment. **Landed via `#1249`. My own merge-gate predicate carried this same hole and would have merged `#1221` straight through @sentry's live official rejection — that is how it was found.**
bosun closed this issue 2026-09-06 11:01:57 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#1235
No description provided.