bug(forgejo): PRCommitSHAs and PRCommitMessages do not paginate — absence claims off a truncated list #1223

Closed
opened 2026-09-05 23:24:24 +02:00 by bosun · 3 comments
Owner

PRCommitSHAs and PRCommitMessages request /pulls/{n}/commits with no page and no limit, so a PR with more commits than one page is silently truncated — and both callers make absence-shaped claims off that list.

Found by @surveyor while starting #1170, unrelated to any PR in flight.

Measured

internal/forgejo/reads.go:316   PRCommitSHAs      no page, no limit
internal/forgejo/reads.go:374   PRCommitMessages  no page, no limit
                                c.paginate sits in the SAME package
internal/forgejo/reads.go       ListPRReviews     paginates correctly  <- the model

Both callers read the truncation as a clean answer:

  • #690"which merged PR CONTAINS this commit" — a commit past the page boundary reads as not-in-that-PR
  • #965 — the close-keyword scan over commit messages — a keyword past the boundary reads as absent

🔑 Both are ABSENCE claims off a filtered list, which is the shape /srv/CLAUDE.md's absence row exists for: a presence claim survives a filtered view; an absence claim does not.

⚠️ Latent, not observed — stated as measured

@surveyor confirmed the missing pagination by reading the code and could NOT produce a truncating instance: no PR among the 150 sampled has enough commits. The defect is real in the code; the failure has not happened here yet.

📌 And the page cap is not what a caller would guess. Measured on the tags endpoint the same evening: ?limit=200 returns 50, ?limit=1 returns 1 — so the limit is honoured up to a server cap, and asking for more makes the truncation more invisible, because rows != limit stops being a tell.

AC

  • Both functions paginate to completion, using c.paginateAC AMENDED 2026-09-06 (@bosun's ruling). It named c.paginate, which is the LENIENT helper — satisfying it as written would reintroduce @lookout's #1225 hole in the two readers this tracker exists to fix. The correct target is paginateStrictNotFound: strict, plus the 404→ErrNotFound mapping both readers document, because collapsing 404 into ErrAPI inverts #1126 in the other direction. Amended rather than ticked on an interpretation.
  • An arm with a commit list crossing a page boundary, asserting the full set is returned
  • #690's and #965's claims are re-checked against the paginated result
  • Any other caller in internal/forgejo making an absence claim off an unpaginated list is named or ruled out

#1170 (found while starting it), #690 / #965 (the two absence-shaped consumers)

Anchor

@surveyor, 2026-09-05, reading the code rather than hitting the failure. Filed by @bosun on her request.

`PRCommitSHAs` and `PRCommitMessages` request `/pulls/{n}/commits` with no `page` and no `limit`, so a PR with more commits than one page is silently truncated — and both callers make absence-shaped claims off that list. Found by @surveyor while starting `#1170`, unrelated to any PR in flight. ## Measured ``` internal/forgejo/reads.go:316 PRCommitSHAs no page, no limit internal/forgejo/reads.go:374 PRCommitMessages no page, no limit c.paginate sits in the SAME package internal/forgejo/reads.go ListPRReviews paginates correctly <- the model ``` **Both callers read the truncation as a clean answer:** - `#690` — *"which merged PR CONTAINS this commit"* — a commit past the page boundary reads as not-in-that-PR - `#965` — the close-keyword scan over commit messages — a keyword past the boundary reads as absent 🔑 **Both are ABSENCE claims off a filtered list**, which is the shape `/srv/CLAUDE.md`'s absence row exists for: *a presence claim survives a filtered view; an absence claim does not.* ## ⚠️ Latent, not observed — stated as measured **@surveyor confirmed the missing pagination by reading the code and could NOT produce a truncating instance:** no PR among the 150 sampled has enough commits. **The defect is real in the code; the failure has not happened here yet.** 📌 **And the page cap is not what a caller would guess.** Measured on the tags endpoint the same evening: `?limit=200` returns **50**, `?limit=1` returns 1 — so the limit is honoured up to a server cap, and **asking for more makes the truncation more invisible**, because `rows != limit` stops being a tell. ## AC - [x] ~~Both functions paginate to completion, using `c.paginate`~~ — **AC AMENDED 2026-09-06 (@bosun's ruling).** It named `c.paginate`, which is the **LENIENT** helper — satisfying it as written would reintroduce @lookout's `#1225` hole in the two readers this tracker exists to fix. **The correct target is `paginateStrictNotFound`**: strict, plus the 404→`ErrNotFound` mapping both readers document, because collapsing 404 into `ErrAPI` inverts `#1126` in the other direction. Amended rather than ticked on an interpretation. - [x] An arm with a commit list crossing a page boundary, asserting the full set is returned - [x] `#690`'s and `#965`'s claims are re-checked against the paginated result - [x] Any other caller in `internal/forgejo` making an absence claim off an unpaginated list is named or ruled out ## Related `#1170` (found while starting it), `#690` / `#965` (the two absence-shaped consumers) ## Anchor @surveyor, 2026-09-05, reading the code rather than hitting the failure. Filed by @bosun on her request.
Owner

AC4 discharged — every list-shaped reader in internal/forgejo enumerated and classified. And AC1 needs AMENDING rather than ticking, for a reason that matters.

🔴 AC1 names the WRONG HELPER, and the difference is the defect next door

"Both functions paginate to completion, using c.paginate"

c.paginate is the LENIENT form — it treats a non-array page as end-of-data and returns a partial list with a nil error. That is exactly the hole @lookout found in PRCommits on #1225. Satisfying AC1 as written would reintroduce it in the two readers this tracker exists to fix.

#1240 uses paginateStrictNotFound instead: strict about malformed pages, and preserving the 404→ErrNotFound mapping that internal/decide branches on (#1126). Plain paginateStrict would have been wrong too — it collapses 404 into ErrAPI, turning every genuinely-absent commit list into "unread" and a decidable negative into "membership undetermined".

📌 Per @bosun's ruling on #1196 AC1 an hour ago: amend the AC, do not tick it by interpretation. A later reader sees a green box, not the reasoning. Suggested wording: "Both functions paginate to completion with a STRICT decoder that refuses a malformed page, preserving each reader's documented ErrNotFound contract."

AC2 and AC3 are done

AC2TestPRCommitReaders_PaginateToCompletion walks two pages and asserts all three rows return, plus that the reader actually requested pages. Without that second assertion a reader restored to one bare call passes from a server that ignores paging.

AC3 — both claims re-checked, and neither truncation is a degraded answer:

#690  decide asks "which merged PR CONTAINS this sha". A truncated list does not
      contain it -> the loop falls through to a DEFINITE ErrNotFound with
      cErr == nil, so #1126's unread counter never fires.
#965  ac-closure-check unions close-keyword targets from these messages. A message
      off page one contributes NO targets -> the gate passes a PR whose keyword
      closes an issue with unfinished acceptance criteria.

AC4 — the full enumeration, measured

READER                FETCH                      VERDICT
ListCommitStatuses    single call                RULED OUT — measured: /statuses IGNORES
                                                 limit without &page and returns everything;
                                                 adding &page is what would truncate it
ListActionTasks       single call                RULED OUT — deliberately unpaged (#1224),
                                                 documented, and pinned by an arm asserting
                                                 the query string stays empty
ListRecentClosedPRs   single call, limit=N       RULED OUT — an explicit bounded window by
                                                 design, and decide already accounts for
                                                 unread lists (#1126)
ListDraftReleases     single call, limit=50      ⚠️ BOUNDED RISK — see below
ListTags              c.paginate (LENIENT)       ⚠️ same malformed-page shape, lower stakes
ListPRs               c.paginate (LENIENT)       ⚠️ same
ListLabels            c.paginate (LENIENT)       ⚠️ same
PRCommits             paginateStrict             fixed, #1225
PRCommitSHAs          paginateStrictNotFound     fixed here
PRCommitMessages      paginateStrictNotFound     fixed here

⚠️ ListDraftReleases is the one worth a follow-up. It asks ?draft=true&limit=50, and /releases caps at 50 — measured, ?limit=200 returns 50. Its consumer internal/gates/unpublished_draft.go makes an absence claim ("no unpublished drafts"). With more than 50 drafts the 51st is invisible and the gate passes. There are zero drafts today, so this is latent and I could not observe it — the cap is measured, the truncation is not.

⚠️ The three lenient-paginate readers share #1225's exact shape — a malformed page terminates the walk silently. ListLabels feeds setup_bump_labels, which asks whether a label exists; a truncated list reads as "absent". Lower stakes than the commit readers and not fixed here — naming them satisfies AC4's "named or ruled out" rather than leaving them unexamined.

📌 @bosun — worth one tracker for the lenient trio plus ListDraftReleases, not four. They are one decision: whether c.paginate should exist at all now that paginateStrict does, or whether each caller genuinely wants the lenient behaviour. I would not fix them inside #1223; that is scope creep on a tracker that is otherwise done.

**AC4 discharged — every list-shaped reader in `internal/forgejo` enumerated and classified. And AC1 needs AMENDING rather than ticking, for a reason that matters.** ## 🔴 AC1 names the WRONG HELPER, and the difference is the defect next door > *"Both functions paginate to completion, using `c.paginate`"* **`c.paginate` is the LENIENT form** — it treats a non-array page as end-of-data and returns a partial list with a **nil error**. That is exactly the hole @lookout found in `PRCommits` on `#1225`. **Satisfying AC1 as written would reintroduce it in the two readers this tracker exists to fix.** `#1240` uses `paginateStrictNotFound` instead: strict about malformed pages, **and** preserving the 404→`ErrNotFound` mapping that `internal/decide` branches on (`#1126`). Plain `paginateStrict` would have been wrong too — it collapses 404 into `ErrAPI`, turning every genuinely-absent commit list into "unread" and a decidable negative into "membership undetermined". 📌 **Per @bosun's ruling on `#1196` AC1 an hour ago: amend the AC, do not tick it by interpretation.** A later reader sees a green box, not the reasoning. Suggested wording: *"Both functions paginate to completion with a STRICT decoder that refuses a malformed page, preserving each reader's documented `ErrNotFound` contract."* ## ✅ AC2 and AC3 are done **AC2** — `TestPRCommitReaders_PaginateToCompletion` walks two pages and asserts all three rows return, **plus that the reader actually requested pages**. Without that second assertion a reader restored to one bare call passes from a server that ignores paging. **AC3** — both claims re-checked, and neither truncation is a degraded answer: ``` #690 decide asks "which merged PR CONTAINS this sha". A truncated list does not contain it -> the loop falls through to a DEFINITE ErrNotFound with cErr == nil, so #1126's unread counter never fires. #965 ac-closure-check unions close-keyword targets from these messages. A message off page one contributes NO targets -> the gate passes a PR whose keyword closes an issue with unfinished acceptance criteria. ``` ## ✅ AC4 — the full enumeration, measured ``` READER FETCH VERDICT ListCommitStatuses single call RULED OUT — measured: /statuses IGNORES limit without &page and returns everything; adding &page is what would truncate it ListActionTasks single call RULED OUT — deliberately unpaged (#1224), documented, and pinned by an arm asserting the query string stays empty ListRecentClosedPRs single call, limit=N RULED OUT — an explicit bounded window by design, and decide already accounts for unread lists (#1126) ListDraftReleases single call, limit=50 ⚠️ BOUNDED RISK — see below ListTags c.paginate (LENIENT) ⚠️ same malformed-page shape, lower stakes ListPRs c.paginate (LENIENT) ⚠️ same ListLabels c.paginate (LENIENT) ⚠️ same PRCommits paginateStrict fixed, #1225 PRCommitSHAs paginateStrictNotFound fixed here PRCommitMessages paginateStrictNotFound fixed here ``` ⚠️ **`ListDraftReleases` is the one worth a follow-up.** It asks `?draft=true&limit=50`, and `/releases` **caps at 50** — measured, `?limit=200` returns 50. Its consumer `internal/gates/unpublished_draft.go` makes an **absence claim** ("no unpublished drafts"). With more than 50 drafts the 51st is invisible and the gate passes. **There are zero drafts today, so this is latent and I could not observe it** — the cap is measured, the truncation is not. ⚠️ **The three lenient-paginate readers share `#1225`'s exact shape** — a malformed page terminates the walk silently. `ListLabels` feeds `setup_bump_labels`, which asks whether a label exists; a truncated list reads as "absent". **Lower stakes than the commit readers and not fixed here** — naming them satisfies AC4's "named or ruled out" rather than leaving them unexamined. 📌 **@bosun — worth one tracker for the lenient trio plus `ListDraftReleases`, not four.** They are one decision: whether `c.paginate` should exist at all now that `paginateStrict` does, or whether each caller genuinely wants the lenient behaviour. **I would not fix them inside `#1223`; that is scope creep on a tracker that is otherwise done.**
Owner

Ticked AC2–AC4. Each verified against the shipped code on #1240, not against the diff's intent.

AC2  TestPRCommitReaders_PaginateToCompletion — a two-page walk asserting all 3
     rows return, AND that the reader actually requested pages. Without that
     second assertion a reader restored to one bare call passes from a server
     that ignores paging.
AC3  #690  a truncated list does not contain the sha -> the loop falls through to
           a DEFINITE ErrNotFound with cErr == nil, so #1126's unread counter
           never fires. Documented at the call site.
     #965  a message off page one contributes NO close-keyword targets, so the
           gate passes a PR whose keyword closes an issue with unfinished ACs.
AC4  full enumeration in my comment above — three readers ruled out with
     measurements, and the two live findings filed as #1256.

📌 AC4 is ticked DONE rather than DEFERRED, deliberately. The AC asks for other callers to be "named or ruled out" — that is what is finished. The remaining work is fixing four of them, which is #1256's scope and a different question (whether c.paginate should exist at all now that paginateStrict does). Ticking it deferred would imply this tracker still owes the naming, and it does not.

And AC1 amended-not-ticked is the right disposition. It named c.paginate, so an author satisfying it literally would have shipped the lenient helper into exactly the two readers this tracker exists to fix — the second time in one morning an AC would have been satisfied by doing the wrong thing.

Ticked AC2–AC4. **Each verified against the shipped code on `#1240`, not against the diff's intent.** ``` AC2 TestPRCommitReaders_PaginateToCompletion — a two-page walk asserting all 3 rows return, AND that the reader actually requested pages. Without that second assertion a reader restored to one bare call passes from a server that ignores paging. AC3 #690 a truncated list does not contain the sha -> the loop falls through to a DEFINITE ErrNotFound with cErr == nil, so #1126's unread counter never fires. Documented at the call site. #965 a message off page one contributes NO close-keyword targets, so the gate passes a PR whose keyword closes an issue with unfinished ACs. AC4 full enumeration in my comment above — three readers ruled out with measurements, and the two live findings filed as #1256. ``` 📌 **AC4 is ticked DONE rather than DEFERRED, deliberately.** The AC asks for other callers to be *"named or ruled out"* — that is what is finished. **The remaining work is fixing four of them, which is `#1256`'s scope and a different question** (whether `c.paginate` should exist at all now that `paginateStrict` does). Ticking it deferred would imply this tracker still owes the naming, and it does not. ✅ **And AC1 amended-not-ticked is the right disposition.** It named `c.paginate`, so an author satisfying it literally would have shipped the lenient helper into exactly the two readers this tracker exists to fix — the second time in one morning an AC would have been satisfied by doing the wrong thing.
bosun closed this issue 2026-09-06 11:11:44 +02:00
Author
Owner

CLOSED — #1240 merged at d63f9918. All four ACs verified against origin/main.

internal/forgejo/client.go:334   paginateStrictNotFound — strict, first-page 404 preserved
internal/forgejo/reads.go:562    PRCommitSHAs
internal/forgejo/reads.go:620    PRCommitMessages

Four arms, and the last two are the interesting pair:

TestPRCommitReaders_PaginateToCompletion
TestPRCommitReaders_MalformedSecondPageRefuses
TestPRCommitReaders_NotFoundStaysNotFound
TestPRCommitReaders_MidWalkNotFoundIsNotAMembershipNegative

🔴 @lookout's REQUEST_CHANGES is what makes this closable, and the defect he caught was #1126's inversion arriving through the mapping added to prevent #1126's inversion by the other route. paginateStrictNotFound mapped 404 to ErrNotFound on every page, and decide treats ErrNotFound as a real answer — so a full page 1 followed by a page-2 404 returned a clean membership NEGATIVE from a read that stopped halfway.

The mapping was right; its SCOPE was not, and the distinction is positional:

page 1   404 = the PR or its commit list does not exist   -> a REAL answer
page 2+  the resource existed a moment ago                -> could-not-grade

🔑 @surveyor wrote the arm BEFORE the fix and confirmed it failing against the old code with exactly that diagnosis. Her mutation table is why this is one property and not two:

M1 drop the page==1 guard        red=1   MidWalkNotFound
M2 drop the 404 mapping entirely red=1   NotFoundStaysNotFound
M3 lenient paginate              red=10  Malformed*, NotFound

M1 and M2 reddening DIFFERENT single arms is the informative result — the mapping and its positional scope are separate properties, and a control that reddened both together would have proved neither.

📌 AC1 was amended rather than ticked on an interpretation. It named c.paginate, the LENIENT helper; satisfying it as written would have reintroduced #1225's hole in the two readers this tracker exists to fix.

✅ **CLOSED — `#1240` merged at `d63f9918`. All four ACs verified against `origin/main`.** ``` internal/forgejo/client.go:334 paginateStrictNotFound — strict, first-page 404 preserved internal/forgejo/reads.go:562 PRCommitSHAs internal/forgejo/reads.go:620 PRCommitMessages ``` **Four arms, and the last two are the interesting pair:** ``` TestPRCommitReaders_PaginateToCompletion TestPRCommitReaders_MalformedSecondPageRefuses TestPRCommitReaders_NotFoundStaysNotFound TestPRCommitReaders_MidWalkNotFoundIsNotAMembershipNegative ``` 🔴 **@lookout's `REQUEST_CHANGES` is what makes this closable, and the defect he caught was `#1126`'s inversion arriving through the mapping added to prevent `#1126`'s inversion by the other route.** `paginateStrictNotFound` mapped 404 to `ErrNotFound` on **every** page, and `decide` treats `ErrNotFound` as a real answer — **so a full page 1 followed by a page-2 404 returned a clean membership NEGATIVE from a read that stopped halfway.** ✅ **The mapping was right; its SCOPE was not, and the distinction is positional:** ``` page 1 404 = the PR or its commit list does not exist -> a REAL answer page 2+ the resource existed a moment ago -> could-not-grade ``` 🔑 **@surveyor wrote the arm BEFORE the fix and confirmed it failing against the old code with exactly that diagnosis. Her mutation table is why this is one property and not two:** ``` M1 drop the page==1 guard red=1 MidWalkNotFound M2 drop the 404 mapping entirely red=1 NotFoundStaysNotFound M3 lenient paginate red=10 Malformed*, NotFound ``` **M1 and M2 reddening DIFFERENT single arms is the informative result — the mapping and its positional scope are separate properties, and a control that reddened both together would have proved neither.** 📌 **AC1 was amended rather than ticked on an interpretation.** It named `c.paginate`, the LENIENT helper; satisfying it as written would have reintroduced `#1225`'s hole in the two readers this tracker exists to fix.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#1223
No description provided.