Skip to content

fix(list): count only the newest run of each check - #332

Merged
lararosekelley merged 7 commits into
mainfrom
fix/331-superseded-check-runs
Aug 30, 2026
Merged

fix(list): count only the newest run of each check#332
lararosekelley merged 7 commits into
mainfrom
fix/331-superseded-check-runs

Conversation

@lararosekelley

@lararosekelley lararosekelley commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Fixes #331, on both paths — the report's diagnosis fits one of them and I want to be clear about the other.

list was not aggregating anything. The batched GraphQL query asked for statusCheckRollup { state } — GitHub's own aggregate — so git-stk was faithfully rendering a value GitHub computed from every run on the head commit, cancelled ones included. There was no list to group. Fixed by asking for contexts instead and aggregating ourselves.

There is a comment near open_reviews warning that statusCheckRollup "would fetch every open PR's full check list (huge and slow)". That is about the all-open-PRs listing, not the batched annotate, which is already scoped to the branches being shown. Measured on this repo: 2.3 KiB and 13 contexts for one branch, so about 17 KiB for an eight-branch stack, in the one query list already makes.

check_status was aggregating, and had the bug as described. aggregate_rollup walked the array with no deduplication and listed CANCELLED among the failing conclusions. It now keeps the newest entry per check — name for a CheckRun, context for a StatusContext, with completedAt/startedAt/createdAt as the timestamp, since the two node types differ — and aggregates those. An entry with no identity of its own stands alone rather than colliding with its neighbours.

On the secondary question. CANCELLED no longer reads as red. Nothing failed; the run was stopped. But green would be wrong too — a required check in that state still blocks the merge — so there is a new CheckStatus::Inconclusive, rendered . ACTION_REQUIRED moves there too: it is waiting on a person, not broken. TIMED_OUT and STARTUP_FAILURE stay red. Anything still running outranks it, since it may yet resolve.

Six unit cases cover the reported shape in both orders, a real failure still winning over a superseded cancellation, a current cancellation, ACTION_REQUIRED, the pending precedence, legacy status contexts, and anonymous entries. One end-to-end case through list asserts 🟢 for the superseded pair and for a current cancellation. The dots are now documented in the command reference, which never described them.

Worth noting this repo reproduces the trigger: stacked PRs get two CI runs, one cancelled by the concurrency group, which is why they have been showing red in the UI while gh pr checks said pass.

Closes #331


Stack managed by git-stk

@lararosekelley

Copy link
Copy Markdown
Owner Author

@bot review

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

Claude finished @lararosekelley's task in 6m 11s —— View job


Code Review - Confidence: 72/100

Stops a superseded (cancelled) check run from pinning a review red, by deduplicating GitHub's rollup to the newest run per check and adding a CheckStatus::Inconclusive (⚪) for cancelled / action-required.

  • Bug - src/providers/github.rs:1218: gh pr view --json statusCheckRollup emits completedAt: "0001-01-01T00:00:00Z" (Go zero time, not null) for a run in flight, so the completedAtstartedAt fallback never fires and an in-flight re-run always sorts below the older completed run of the same name — list/status go 🟢 while checks are still running, and ⚪ instead of 🟡 in exactly the list: a superseded CANCELLED check pins a review red even though a later run of the same check passed #331 window. (inline comment) · Fix this →

  • Bug - src/providers/github.rs:1215: the dedup key is the bare CheckRun name, which is the job name — two workflows that both define a job called lint/build/test produce two unrelated check runs with the same name on one commit. Grouping them keeps only the newer, so a failure in one workflow is hidden by a pass in another and list paints a red PR 🟢. Both old paths caught this. (inline comment) · Fix this →

  • Partial upgrade - src/providers/gitlab.rs:419: CheckStatus::Inconclusive lands on the shared enum in src/providers/mod.rs with a stated semantic — "a cancelled run is not a failure" — but only GitHub produces it. GitLab still maps "canceled" => CheckStatus::Failing, so the same condition is 🔴 on one forge and ⚪ on another, and docs/COMMANDS.md:69 now documents the dots without qualifying which forge. Either map GitLab's canceled (and, if the reasoning holds, manual, which is currently Passing and is the ACTION_REQUIRED analogue) to Inconclusive, or say in the doc comment why the forges differ. Gitea's map_gitea_state has no cancelled state to map, so it's unaffected. Fix this →

Both bugs turn red or yellow into green — the one direction a CI dot must not get wrong — and neither is caught by the new tests, because every fixture uses startedAt/createdAt only and every same-name pair really is the same check. Neither reaches merge gating (wait_for_checks reads gh pr checks exit codes, not aggregate_rollup), so the blast radius is display; that is what keeps the score where it is rather than lower.

Not findings, but checked: contexts(last:100) matches gh's own pr checks query so the pagination direction is not a guess; the batched query stays bounded by the branches being annotated and still falls back to the per-branch path on error; Inconclusive is display-only and no match on CheckStatus outside dot() needed a new arm; the \u{0}{index} anonymous key cannot collide; SKIPPED/NEUTRAL keep their old pass-through behavior. CI is green (lint, commitlint, plan, test on ubuntu/macos; windows still running at review time) and nothing failing relates to this change.

The diagnosis in the description is right and the correction to the open_reviews comment — with a measured number attached rather than an assertion — is the kind of thing that stops a stale caveat from outliving its reason.


  • Read repo guidelines (docs/CODE_REVIEW_BOT.md)
  • Gather PR context (diff, files, CI checks)
  • Requirements gate + boundary/pattern check
  • Read for correctness, safety, surface area
  • Coverage / output review
  • Post findings
    · branch fix/331-superseded-check-runs

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

Claude finished @lararosekelley's task in 6m 22s —— View job


Code Review - Confidence: 78/100

Stops a superseded (cancelled) check run from pinning a review's CI dot red, by fetching the rollup's contexts instead of GitHub's aggregate state and keeping only the newest run per check.

  • Bug - src/providers/github.rs:1218: preferring completedAt over startedAt compares one entry's completion against another's start, so a cancelled run ranks above the in-flight re-run that replaced it and the dot reads while CI is actually running. (inline comment)
  • Partial Upgrade - src/providers/mod.rs:275: CheckStatus::Inconclusive is a shared display state landed in one provider only - gitlab.rs:416 still maps canceled to Failing and manual to Passing, so the same situation shows on GitHub and 🔴/🟢 on GitLab, and the new docs/COMMANDS.md paragraph describes with no forge qualifier. (inline comment)

Both are confined to the display dot. I traced the merge gate separately: wait_for_checks reads gh pr checks and never touches aggregate_rollup, so nothing here can let merge proceed past a red check. The other paths check out - parse_annotation_batch degrades to CheckStatus::None when statusCheckRollup is null, gh pr view --json statusCheckRollup emits the same name/context/startedAt/completedAt/createdAt keys the batched query selects so check_status and list agree, no caller of the graphql_batch test helper passed a state that changed meaning under the new shape, and the fallback in annotate_branches covers a host that rejects the query.

CI is still in progress (CI and this job); Release passed. Nothing failed at review time, and the two findings above are semantic - the test suite as written passes with both present.

The diagnosis is the genuinely good part here: correcting the report's premise for list rather than accepting it, and measuring the payload cost against the open_reviews comment that appeared to forbid the change, is the reason this fixes the bug instead of the symptom.
| Branch: fix/331-superseded-check-runs

Comment thread src/providers/github.rs Outdated
Comment on lines +1218 to +1222
let stamp = field("completedAt")
.or_else(|| field("startedAt"))
.or_else(|| field("createdAt"))
.unwrap_or("")
.to_owned();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug]

gh pr view --json statusCheckRollup renders an unset DateTime as Go's zero time, not null. Captured from this very PR:

name                 status       conclusion  startedAt             completedAt
bot-code-review      IN_PROGRESS  ""          2026-08-30T03:05:04Z  0001-01-01T00:00:00Z
lint                 COMPLETED    SUCCESS     2026-08-30T03:05:04Z  2026-08-30T03:05:31Z
test (windows-...)   IN_PROGRESS  ""          2026-08-30T03:05:04Z  0001-01-01T00:00:00Z

field("completedAt") therefore returns Some("0001-01-01T00:00:00Z") — not None — for anything still running, so the fallback to startedAt never fires and the stamp sorts below every real 20xx timestamp. On the check_status path (rollup_status → here, reached via generic_annotate / generic_annotate_review whenever the batched GraphQL query fails — GHES, rate limit, a network hiccup) an in-flight re-run always loses to the older completed run of the same name:

The GraphQL path escapes the sentinel (a real null there), but has a milder version of the same problem: completedAt on one entry is compared against startedAt on another, which is not a common clock. With cancel-in-progress, the superseding run's startedAt routinely precedes the cancelled run's completedAt — the cancel has to propagate to the runner — so the cancelled entry wins there too.

Order by when a run started, and reject the sentinel:

Suggested change
let stamp = field("completedAt")
.or_else(|| field("startedAt"))
.or_else(|| field("createdAt"))
.unwrap_or("")
.to_owned();
// `gh pr view --json statusCheckRollup` renders an unset DateTime as
// Go's zero time rather than null, so a run still in flight would
// sort below every finished one. Order on when a run started - the
// only field the two node types can be compared on.
let real = |name| field(name).filter(|at| !at.starts_with("0001-"));
let stamp = real("startedAt")
.or_else(|| real("createdAt"))
.or_else(|| real("completedAt"))
.unwrap_or("")
.to_owned();

That still leaves a queued re-run (startedAt also zeroed) losing to the old completed run. If you want that covered too, rank on (status != "COMPLETED", stamp) so an unfinished run outranks a finished one of the same name regardless of clock.

Note none of the new unit cases exercise completedAt — every fixture uses startedAt or createdAt only — which is why the tests pass with the field that misorders as the first preference. Worth a case with the literal "0001-01-01T00:00:00Z" gh emits.

Comment thread src/providers/github.rs Outdated
Comment on lines +1215 to +1217
let key = field("name")
.or_else(|| field("context"))
.map_or_else(|| format!("\u{0}{index}"), str::to_owned);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug]

A CheckRun's name is the job name, not a globally unique check identity — two workflows that both define a job called lint (or build, or test) produce two check runs named lint on the same head commit, neither superseding the other. gh's own output carries workflowName precisely because name does not disambiguate:

name    workflowName
lint    CI
plan    Release

Grouping on the bare name collapses them, and the loser is discarded silently:

  • {name: "lint", conclusion: "FAILURE", startedAt: "10:00"} (workflow docs)
  • {name: "lint", conclusion: "SUCCESS", startedAt: "10:05"} (workflow ci)

newest keeps only the SUCCESS, so aggregate_rollup returns Passing and list shows 🟢 on a PR whose checks are red. Before this PR both paths caught it: the rollup's own state was FAILURE, and check_status walked every entry. This is the one direction a status dot must never get wrong.

Two ways out:

  1. Put the workflow identity in the key. gh pr view already gives workflowName; the batched query would need checkSuite{workflowRun{workflow{name}}} added to the CheckRun fragment on line 598, and the key becomes that plus name. A concurrency-cancelled run and its re-run share a workflow name, so list: a superseded CANCELLED check pins a review red even though a later run of the same check passed #331 still collapses correctly.
  2. If you'd rather not grow the query, narrow the collapse instead: drop an entry only when its conclusion is CANCELLED and a strictly newer entry shares its name. That fixes the reported shape exactly and cannot discard a failure, at the cost of not deduplicating a superseded failure after a green re-run.

Either way, please add a case for two same-named runs where the older one failed — the current tests only cover same-name pairs that really are the same check.

Comment thread src/providers/github.rs Outdated
Comment on lines +1218 to +1222
let stamp = field("completedAt")
.or_else(|| field("startedAt"))
.or_else(|| field("createdAt"))
.unwrap_or("")
.to_owned();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug]

Preferring completedAt compares two different events across entries, so "newest" can select the older run — in the exact scenario this PR targets.

Take the superseded pair while the re-run is still in flight:

{"name":"plan","status":"COMPLETED","conclusion":"CANCELLED","startedAt":"T1","completedAt":"T3"}
{"name":"plan","status":"IN_PROGRESS","startedAt":"T2"}

The in-flight entry has no completedAt, so its stamp is T2; the cancelled one's is T3. A concurrency group queues the new run first and the cancelled run's teardown finalises after that, so T2 < T3 is the ordinary shape, not a corner case. The cancelled entry wins the key and the newest run is dropped from the map entirely — so pending is never set for plan, and a rollup with CI actively running renders (settled, no verdict) instead of 🟡.

Start time is the field that orders runs: a re-run always starts after the run it supersedes, and both node types carry one (startedAt / createdAt). completedAt only makes the comparison incoherent when one candidate has not finished.

Suggested change
let stamp = field("completedAt")
.or_else(|| field("startedAt"))
.or_else(|| field("createdAt"))
.unwrap_or("")
.to_owned();
// Order on start time: a re-run always starts after the run it
// supersedes, while `completedAt` is a different event - comparing one
// entry's completion against another's start ranks a finished run
// above the in-flight run that replaced it.
let stamp = field("startedAt")
.or_else(|| field("createdAt"))
.or_else(|| field("completedAt"))
.unwrap_or("")
.to_owned();

That leaves one residual gap: a queued CheckRun with a null startedAt stamps "" and still loses. If you want the invariant to hold outright, rank on (status != "COMPLETED", stamp) so an unfinished run always outranks a finished one of the same name — which is also what makes pending reliable.

The existing cases all use startedAt/createdAt, so they pass either way; worth adding the mixed pair above as a case, since it is the state a user sees during the re-run window.

Comment thread src/providers/mod.rs
Comment on lines +272 to +275
/// Finished without a verdict - a run that was cancelled, or one waiting
/// on a human. Nothing is wrong, but not everything is green, so folding
/// it into either would say something untrue.
Inconclusive,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Partial Upgrade]

CheckStatus is the shared vocabulary all four providers render through, and this variant's contract — "a run that was cancelled, or one waiting on a human" — is contradicted by the provider next door. gitlab.rs:416 still maps:

Some("success" | "skipped" | "manual") => CheckStatus::Passing,
Some("failed" | "canceled") => CheckStatus::Failing,

So after this PR the same real-world state renders two different dots depending on the forge: a cancelled pipeline is on GitHub and 🔴 on GitLab, and a pipeline blocked on a manual job — GitLab's exact analogue of ACTION_REQUIRED — is on GitHub and 🟢 on GitLab. The docs/COMMANDS.md paragraph this PR adds describes as a property of list/status, with no forge qualifier, so it documents behaviour GitLab users will not get.

Minimal fix: move "canceled" to CheckStatus::Inconclusive in map_pipeline_status, with a unit case beside the existing map_pipeline_status tests. "manual" reading green is the ACTION_REQUIRED analogue and looks like the same call, though it is a pre-existing mapping rather than something this PR moved — your judgement whether it belongs here.

Gitea needs nothing: its combined-status states (pending/success/error/failure/warning) have no cancelled analogue, and map_gitea_state's _ => Pending already covers the rest.

@lararosekelley

Copy link
Copy Markdown
Owner Author

@bot review

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

Claude finished @lararosekelley's task in 5m 46s —— View job


Code Review - Confidence: 76/100 (+4 vs 72 on the first commit)

Stops a superseded (cancelled) check run from pinning a review red, by fetching the rollup's contexts instead of GitHub's aggregate state, keeping only the newest run per check, and adding a CheckStatus::Inconclusive (⚪) for cancelled / waiting-on-a-human across GitHub and GitLab.

  • Bug - src/providers/github.rs:1215: the dedup key is the bare CheckRun name, which is only the job name — two workflows that both define build/test/lint, or an Actions job beside a same-named check run from a third-party CI App, produce two unrelated check runs with one name on one commit. Grouping them keeps only the newer, so a FAILURE is discarded behind a SUCCESS and list paints 🟢 on a red PR. Both old paths caught this. (inline comment) · Fix this →

  • Partial upgrade - src/providers/gitlab.rs:426: map_pipeline_status and wait_for_checks encode the same status strings and agreed exactly before this PR; only the dot moved. manual is now ⚪ "the merge is held" while wait_for_checks:206 still returns WaitOutcome::Passed, so stk merge merges an MR the dot says is blocked. The doc comment's "still holds the merge" is contradicted one screen away. (inline comment) · Fix this →

The remaining bug is display-only — I re-traced the merge gate and wait_for_checks reads gh pr checks exit codes via interpret_checks, never aggregate_rollup — which is what keeps the score here rather than lower. Neither finding is caught by the new tests: every fixture is a same-name pair that really is the same check.

Resolved since the first commit

Both verified against real gh output from this PR's head commit rather than taken on trust:

  • The completedAt sentinel. gh pr view --json statusCheckRollup does emit "completedAt":"0001-01-01T00:00:00Z" for an in-flight run (confirmed: lint, test (macos-latest), test (windows-latest) all carry it right now). Dropping completedAt from the stamp chain and ranking on (in_flight, stamp) defuses it — a QUEUED re-run with a zeroed startedAt now outranks the completed run it supersedes on the boolean, before the stamps are compared at all. Two in-flight entries tying on the sentinel both read Pending, so the tie is inert.
  • Forge consistency. GitLab's canceled/manual now land on Inconclusive with the mapping tests updated, so docs/COMMANDS.md describing ⚪ without a forge qualifier is now true.

Also checked and clear: CheckStatus derives only Debug, Clone, Copy, Eq, PartialEq — no Ord, no serde — so inserting a variant mid-enum changes no ordering and nothing that round-trips through src/notes/ or .git/stack-state; dot() is the only match on it, and commands/status.rs:84 and providers/mod.rs:622 pass it through. gh's exporter omits context/state/createdAt entirely on CheckRun nodes rather than emitting empty strings, so the namecontext key fallback and the !status.is_empty() guard in the aggregate loop both behave on that path. SKIPPED/NEUTRAL keep their pass-through behavior; parse_annotation_batch degrades to CheckStatus::None on a null rollup; annotate_branches still falls back per-branch when the query fails; Gitea has no cancelled state to map.

CI at review time: lint, commitlint, test (ubuntu-latest), plan pass; macOS and Windows still running; nothing failing.

Correcting the report's premise for list — that there was no aggregation to fix, only GitHub's own — and measuring the payload against the open_reviews comment that appeared to forbid the change is why this fixes the bug rather than the symptom.


  • Read repo guidelines (docs/CODE_REVIEW_BOT.md)
  • Gather PR context (diff, files, CI checks)
  • Requirements gate + boundary/pattern check
  • Read for correctness, safety, surface area
  • Coverage / output review
  • Post findings
    · branch fix/331-superseded-check-runs

@lararosekelley
lararosekelley force-pushed the fix/331-superseded-check-runs branch from 4e33460 to 23fb9b2 Compare August 30, 2026 03:16
Comment thread src/providers/github.rs Outdated
Comment on lines +1215 to +1217
let key = field("name")
.or_else(|| field("context"))
.map_or_else(|| format!("\u{0}{index}"), str::to_owned);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug]

The dedup key for a CheckRun is the bare name, which is the job name — not an identity unique to one check on the commit. gh carries a separate workflowName field precisely because name does not disambiguate. From this PR's own head commit (gh pr checks 332 --json name,workflow):

name                  workflow
lint                  CI
plan                  Release
test (ubuntu-latest)  CI

Two workflows in one repo that both define a job called build (or test, or lint), or an Actions job named build beside a build check run from a third-party CI App, produce two unrelated check runs with the same name on the same head commit. Neither supersedes the other — but this groups them, and newest discards the loser silently:

[{"name":"build","status":"COMPLETED","conclusion":"FAILURE","startedAt":"2026-08-29T10:00:00Z"},
 {"name":"build","status":"COMPLETED","conclusion":"SUCCESS","startedAt":"2026-08-29T10:05:00Z"}]

Both are COMPLETED, so in_flight ties and the stamp decides. The SUCCESS wins the key, the FAILURE never reaches the aggregate loop, and aggregate_rollup returns Passing. list paints 🟢 on a PR whose checks are red.

Both old paths caught this: list read the rollup's own state, which was FAILURE, and check_status walked every entry without grouping. Turning red into green is the one direction a status dot must not get wrong.

Two ways out:

  1. Widen the key. Put the workflow identity in it — gh pr view already returns workflowName, and the batched query needs checkSuite{workflowRun{workflow{name}}} added to the CheckRun fragment on line 598. A concurrency-cancelled run and its re-run share a workflow name, so list: a superseded CANCELLED check pins a review red even though a later run of the same check passed #331 still collapses correctly. (checkSuite{app{slug}} covers the non-Actions case, where workflowRun is null.)
  2. Narrow the collapse. Drop an entry only when its conclusion is CANCELLED and a strictly newer entry shares its name. That fixes the reported shape exactly and cannot discard a failure, at the cost of not collapsing a superseded failure after a green re-run.

Either way, please add a case for two same-named runs where the older one failed — every current fixture is a same-name pair that really is the same check, so nothing here fails today.

Comment thread src/providers/gitlab.rs
Some("failed" | "canceled") => CheckStatus::Failing,
Some("success" | "skipped") => CheckStatus::Passing,
Some("failed") => CheckStatus::Failing,
Some("canceled" | "manual") => CheckStatus::Inconclusive,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Partial Upgrade]

gitlab.rs encodes these same status strings in two places, and before this PR they agreed exactly. Only the dot moved:

pipeline status map_pipeline_status (the dot) wait_for_checks (the merge gate)
success / skipped Passing Passed
manual PassingInconclusive Passed (line 206)
canceled FailingInconclusive Failed (line 209)

So the doc comment six lines up — "a pipeline in that state still holds the merge" — is contradicted by line 206. On an MR whose pipeline is manual, stk merge returns WaitOutcome::Passed and merges. The user reads ⚪, documented in docs/COMMANDS.md as "finished without a verdict… waiting on a person", runs stk merge, and it merges anyway. The canceled half is the milder mirror: the dot says nothing failed, merge reports a check failure.

Note the claim in the comment is right about GitLab itself — a pipeline only reports manual when a blocking manual job (allow_failure: false) is waiting, and GitLab holds the merge for it. It is wait_for_checks line 206 that is out of step. But changing it makes stk merge wait to checkTimeout on every repo with a blocking manual job, which is a behavior change well outside #331.

Minimal fix for this PR: narrow the comment (and the matching CHANGELOG.md sentence) so it describes the dot rather than asserting a gate git-stk does not apply. If the claim is meant to hold, route wait_for_checks through map_pipeline_status so there is one mapping instead of two — but that belongs in its own change, with an integration test for the merge path.

@lararosekelley

Copy link
Copy Markdown
Owner Author

@bot review

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

Claude finished @lararosekelley's task in 6m 22s —— View job


Code Review - Confidence: 74/100 (-2 vs 76 on the second commit)

Stops a superseded (cancelled) check run from pinning a review red, by fetching the rollup's contexts instead of GitHub's aggregate state, keeping only the newest run per check (workflow + job), and adding an Inconclusive (⚪) dot for cancelled / waiting-on-a-human across GitHub and GitLab.

  • Bug - src/providers/github.rs:1223: the cross-workflow guard added in 41cda67 reads the workflow only out of checkSuite, which the GraphQL query selects but gh pr view --json statusCheckRollup does not emit — gh flattens it to workflowName. I captured the payload from this PR's own head commit to check: no checkSuite key anywhere, so workflow is "" and the key falls back to the bare job name on the whole check_status path. Two same-named jobs in different workflows collapse there and a FAILURE is discarded behind a SUCCESS. (inline comment) · Fix this →

  • Partial upgrade - src/providers/mod.rs:372: the new doc comment on WaitOutcome::Inconclusive states an obligation GitHub cannot meet. Its dot comes from aggregate_rollup; its gate comes from gh pr checks exit codes via interpret_checks, which has no cancelled code — gitlab.rs:212 is the only construction site of the variant in the tree. So a current cancellation reads and then merge --all --wait bails with "checks failed". Narrow the comment, or move the gate onto the rollup in its own change. (inline comment) · Fix this →

The score moves down two rather than up because the first finding is the same bug the previous round raised, fixed on one of its two paths — the guard, the comment explaining it, and the test asserting it all exist, and none of them apply to check_status. That path is not exotic: github.rs:397 routes every merged or closed layer through it, which is status's ordinary case, and github.rs:415 routes list through it whenever the GraphQL call fails. Blast radius stays display-only — I re-traced wait_for_checks and it never touches aggregate_rollup — so merge cannot be walked past a red check by either finding.

Resolved since the second commit

  • The dedup key on the GraphQL path. checkSuite{workflowRun{workflow{name}} app{slug}} is in the fragment, the key pairs it with name, app{slug} covers the non-Actions suite where workflowRun is null, and aggregate_rollup_does_not_merge_the_same_job_name_across_workflows asserts all three shapes including the negative case (same job, same workflow, still one check). Correct for the path list normally takes.
  • The GitLab gate. canceled/manual now return WaitOutcome::Inconclusive instead of disagreeing with the dot, merge.rs:199 bails with a message that names the state, and the CHANGELOG records the behavior change. manual no longer merges an MR GitLab is holding.

Also checked and clear: the batched query selects startedAt/createdAt and never completedAt, so the Go zero-time sentinel that broke the first commit cannot re-enter — and (in_flight, stamp) ranking means a QUEUED re-run with no start time still outranks the completed run it supersedes, which the replaced fixture pins. WaitOutcome gained a variant with no _ arm anywhere, so exhaustiveness is doing its job; CheckStatus derives no Ord and no serde, so inserting Inconclusive mid-enum reorders nothing and touches nothing that round-trips through src/notes/ or .git/stack-state. SKIPPED/NEUTRAL keep pass-through behavior, parse_annotation_batch degrades to CheckStatus::None on a null rollup, and Gitea has no cancelled state to map. The two older fixtures in tests/list_status.rs:195,246 still carry the retired statusCheckRollup{state} shape, but neither test asserts on a dot, so nothing is silently weakened.

CI at review time: Release green, CI still running (lint, commitlint, plan, test (ubuntu-latest) already passed; macOS and Windows in flight). Nothing failing.

Arriving independently at gh's own eliminateDuplicates rule — key on workflow plus name, order on startedAt — is a good sign the model of the problem is right rather than fitted to the one reported symptom.


  • Read repo guidelines (docs/CODE_REVIEW_BOT.md)
  • Gather PR context (diff, files, CI checks)
  • Requirements gate + boundary/pattern check
  • Read for correctness, safety, surface area
  • Coverage / output review
  • Post findings
    · branch fix/331-superseded-check-runs

Comment thread src/providers/github.rs
Comment on lines +1223 to +1227
let workflow = item
.pointer("/checkSuite/workflowRun/workflow/name")
.or_else(|| item.pointer("/checkSuite/app/slug"))
.and_then(serde_json::Value::as_str)
.unwrap_or("");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug]

The workflow identity is read only out of checkSuite, which the batched GraphQL query now selects — but check_status does not use that query. It runs gh pr view --json statusCheckRollup, and gh's exporter emits a flat workflowName with no checkSuite object at all. Captured just now from this PR's own head commit:

{"__typename":"CheckRun","name":"lint","status":"COMPLETED","conclusion":"SUCCESS",
 "startedAt":"2026-08-30T12:53:18Z","completedAt":"2026-08-30T12:53:46Z","workflowName":"CI"}
{"__typename":"CheckRun","name":"plan","status":"COMPLETED","conclusion":"SUCCESS",
 "startedAt":"2026-08-30T12:53:18Z","completedAt":"2026-08-30T12:53:39Z","workflowName":"Release"}

No checkSuite key anywhere in that payload, so both pointer lookups return None, workflow is "", and the key collapses to \u{1}{name} — the bare job name this commit set out to stop using. On that path two same-named jobs in different workflows group, newest keeps one, and the other's FAILURE never reaches the aggregate loop:

[{"name":"build","status":"COMPLETED","conclusion":"FAILURE","startedAt":"...:17Z","workflowName":"Release"},
 {"name":"build","status":"COMPLETED","conclusion":"SUCCESS","startedAt":"...:18Z","workflowName":"CI"}]

Passing. Before this PR aggregate_rollup walked every entry, so the same input returned Failing. Red into green, on a path this PR regresses.

check_status is not a rare fallback. github.rs:397 routes there for any review the batched query cannot see — every merged or closed layer, which is status's ordinary case — and github.rs:415 routes list there whenever the GraphQL call fails (GHES rejecting a field, rate limit, a network hiccup).

aggregate_rollup_does_not_merge_the_same_job_name_across_workflows does not catch it because every fixture in it is the checkSuite shape; nothing in the suite feeds the flat shape gh actually returns.

Add the flat field to the chain:

Suggested change
let workflow = item
.pointer("/checkSuite/workflowRun/workflow/name")
.or_else(|| item.pointer("/checkSuite/app/slug"))
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let workflow = item
.pointer("/checkSuite/workflowRun/workflow/name")
.or_else(|| item.pointer("/checkSuite/app/slug"))
// `gh pr view --json statusCheckRollup` flattens the workflow onto
// the node instead of nesting a checkSuite, so the two shapes name
// the same thing differently.
.or_else(|| item.get("workflowName"))
.and_then(serde_json::Value::as_str)
.unwrap_or("");

and a case in the gh shape beside the existing one — workflowName rather than checkSuite, older run FAILURE, asserting CheckStatus::Failing.

Worth noting the design here matches gh's own eliminateDuplicates, which keys on workflow/name and orders on StartedAt — the same two decisions this PR arrived at independently. That is the reason gh pr checks already said pass where the web UI said red.

Comment thread src/providers/mod.rs
Comment on lines +367 to +374
/// Checks stopped without a verdict - a cancelled run, or one waiting on a
/// person. Nothing failed, but nothing passed either, and the platform
/// still holds the merge, so the run stops and says which it was.
///
/// The same distinction [`CheckStatus::Inconclusive`] draws for the dot.
/// A provider that encodes check states in both places must map them the
/// same way, or the dot and the merge disagree about the same pipeline.
Inconclusive,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Partial Upgrade]

"A provider that encodes check states in both places must map them the same way" is an obligation GitHub cannot meet as written, and this PR is what makes that visible.

GitLab does encode them in both places, and gitlab.rs:206-212 now maps from the same string table as map_pipeline_status — the fix from the last round, and it holds. GitHub does not: check_status reduces the rollup through aggregate_rollup, while wait_for_checks (github.rs:307) reduces gh pr checks exit codes through interpret_checks, which only knows 0Passed, 8Pending, anything else → Failed/Errored. There is no exit code for "cancelled", so no GitHub path can return WaitOutcome::Inconclusivegrep confirms it: the only construction site in the tree is gitlab.rs:212.

The consequence is one screen of output contradicting the next. A current cancellation on GitHub now renders , documented in docs/COMMANDS.md:70 as "finished without a verdict"; the same PR under merge --all --wait resolves through the exit code to Failed and bails with "checks failed for #N; fix them and rerun". Before this PR the two agreed — CANCELLED was in the failing list on both sides. Gitea and demo are in the same position for the same reason.

Nothing unsafe follows from it: every divergence lands on the refuse-to-merge side, and merge.rs:199 handles the new variant. The ask is that the comment stop asserting something the tree does not do. Either narrow it —

Suggested change
/// Checks stopped without a verdict - a cancelled run, or one waiting on a
/// person. Nothing failed, but nothing passed either, and the platform
/// still holds the merge, so the run stops and says which it was.
///
/// The same distinction [`CheckStatus::Inconclusive`] draws for the dot.
/// A provider that encodes check states in both places must map them the
/// same way, or the dot and the merge disagree about the same pipeline.
Inconclusive,
/// Checks stopped without a verdict - a cancelled run, or one waiting on a
/// person. Nothing failed, but nothing passed either, and the platform
/// still holds the merge, so the run stops and says which it was.
///
/// The same distinction [`CheckStatus::Inconclusive`] draws for the dot.
/// Only GitLab reaches it: its dot and its gate read one status string, so
/// they can agree. GitHub's gate reads `gh pr checks` exit codes, which
/// have no cancelled code, so there the dot may read `⚪` where the gate
/// reports a failure.
Inconclusive,

— or make GitHub's gate read the rollup it already fetches elsewhere, which is a larger change than #331 and wants its own integration test on the merge path.

@lararosekelley

Copy link
Copy Markdown
Owner Author

@bot review

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

Claude finished @lararosekelley's task in 13m 35s —— View job


Code Review - Confidence: 82/100 (+8 vs 74 on the third commit)

Stops a superseded (cancelled) check run from pinning a review red, by fetching the rollup's contexts instead of GitHub's aggregate state, keeping only the newest run per workflow+job, and adding an Inconclusive (⚪) dot - now with a merge gate behind it - for cancelled / waiting-on-a-human.

  • Bug - src/providers/github.rs:312: the rollup consult is on the Passed arm, and that is the one arm ACTION_REQUIRED cannot reach. gh pr checks has five buckets - pass, fail, pending, skipping, cancel (its own --help, read off the gh in this runner) - and no action-required one, so it lands in fail (exit 1 → "checks failed for #N" for a check list just painted ⚪) or pending (exit 8 → poll to stk.checkTimeout and error). Whether the cancelled half fires at all depends on gh's cancel bucket exiting 0, which nothing verifies: the new test tells the fake to exit 0, and the e2e --wait cell runs a repo with no checks. Consulting the rollup on the Failed arm too settles both, whichever way gh buckets. (inline comment) · Fix this →

  • Bug - src/providers/github.rs:607: contexts(last:100) caps what the aggregate can see; statusCheckRollup{state} did not. The cap counts runs, not checks - and keeping every run is this PR's own premise - so a 40-job matrix restacked three times is 120 contexts and the oldest 20 fall off. Usually those are the superseded ones. But a check that failed and was never re-run while noisier checks were falls off with them, and a check with no entry is not a failure: 🟢 on a red PR. totalCount (or pageInfo{hasNextPage}) says when it happened; falling back to the aggregate state there is the safe direction. (inline comment) · Fix this →

Neither can merge past a red check - aggregate_rollup returns Inconclusive only when no newest run failed, and merge_all bails on Failed and Inconclusive alike - so the blast radius is a wrong dot and a wrong message, which is what keeps the score here rather than lower.

Resolved since the third commit

Checked against a real payload rather than taken on trust. gh pr view 332 --json statusCheckRollup --jq '.[0] | keys' returns exactly:

["__typename","completedAt","conclusion","detailsUrl","name","startedAt","status","workflowName"]
  • The flat gh shape. workflowName is there and checkSuite is not, so the .or_else(|| item.get("workflowName")) in the chain is the right key and check_status now groups by workflow+job like the GraphQL path does. The flattened fixture uses the shape gh actually sends.
  • The per-typename export. That key list also settles the question the namecontext fallback depends on: gh builds a separate map per node type, so a CheckRun carries no context key and a StatusContext will carry no name - the match arms cannot pick the wrong one, and legacy contexts cannot collapse under an empty name.
  • The zero-time sentinel. completedAt is still exported (0001-01-01T00:00:00Z while running) and no longer read anywhere, so it cannot re-enter the ordering.

Also checked and clear: the new query is brace-balanced and closes the same two outer scopes the old one did; checkSuite{workflowRun{workflow{name}} app{slug}} is valid against the schema and the node cost stays trivial (13 contexts on this PR's head, ×1 per branch). dot() is still the only match on CheckStatus, which derives no Ord and no serde, so the new variant reorders nothing and touches nothing in src/notes/ or .git/stack-state. WaitOutcome gained a variant with no _ arm anywhere. GitLab's gate and dot now read one string table, and Gitea's map_gitea_state has no cancelled state to map. parse_annotation_batch still degrades to CheckStatus::None on a null rollup, and annotate_branches still falls back per branch. 0.12.3 is the right CHANGELOG heading against Cargo.toml's 0.12.2, and git-stk-bump does not touch that file.

CI at review time (gh pr checks 332): lint, commitlint, plan, test (ubuntu-latest), test (macos-latest) pass; test (windows-latest) in flight; the Release jobs skipping. Nothing failing.

Reaching for the real payload to decide the workflowName question - rather than adding both lookups and hoping - is why that finding is closed for good instead of half-fixed a second time.


  • Read repo guidelines (docs/CODE_REVIEW_BOT.md)
  • Gather PR context (diff, files, CI checks)
  • Requirements gate + boundary/pattern check
  • Read for correctness, safety, surface area
  • Coverage / output review
  • Post findings
    · branch fix/331-superseded-check-runs

Comment thread src/providers/github.rs Outdated
Comment on lines 307 to 318
// `gh pr checks` reports a verdict per check but has no exit
// code for "stopped without one", so a cancelled newest run
// can land in the green code. Ask the rollup, which is what
// the dot reads - the gate and the dot must not disagree
// about the same commit.
ChecksState::Passed => {
return Ok(match self.check_status(review) {
Ok(CheckStatus::Inconclusive) => WaitOutcome::Inconclusive,
_ => WaitOutcome::Passed,
});
}
ChecksState::Failed => return Ok(WaitOutcome::Failed),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug]

The consult is on one arm, and it is the arm the other half of Inconclusive cannot reach.

gh pr checks sorts every check into one of five buckets - its own help, from the gh in this runner, says so:

When the `--json` flag is used, it includes a `bucket` field, which categorizes
the `state` field into `pass`, `fail`, `pending`, `skipping`, or `cancel`.

There is no bucket for ACTION_REQUIRED, so it lands in fail (where it has always been) or in pending. Neither reaches the arm you hooked:

  • fail → exit 1 → ChecksState::FailedWaitOutcome::Failed, and merge_all bails with "checks failed for #N" - for a check list just painted and docs/COMMANDS.md:70 describes as "waiting on a person". Nothing failed; the gate says it did.
  • pending → exit 8 → the poll loop keeps going until stk.checkTimeout and the run ends in checks_timed_out, for a state that will never move on its own.

So WaitOutcome::Inconclusive's doc comment - "Where a provider's gate cannot see the difference on its own … it asks the same rollup the dot came from" - is true of GitHub only on the green path, and the dot/gate contradiction this commit set out to close is still there for ACTION_REQUIRED.

That leaves the cancelled half resting on an unverified premise: whether gh's cancel bucket exits 0. Nothing in the tree checks it. merge_wait_stops_on_a_github_check_that_was_cancelled tells the fake to exit 0 for pr checks, which is the repo's own point about fakes (docs/CODE_REVIEW_BOT.md: they prove git-stk would run the command, "they cannot prove gh accepts that flag"), and the e2e --wait cell runs against a repo with no checks at all. If gh counts cancel toward the failing exit, this arm never fires and the CHANGELOG's third bullet describes behavior no user gets.

Asking the rollup on the red side too settles both, whichever bucket gh chose:

Suggested change
// `gh pr checks` reports a verdict per check but has no exit
// code for "stopped without one", so a cancelled newest run
// can land in the green code. Ask the rollup, which is what
// the dot reads - the gate and the dot must not disagree
// about the same commit.
ChecksState::Passed => {
return Ok(match self.check_status(review) {
Ok(CheckStatus::Inconclusive) => WaitOutcome::Inconclusive,
_ => WaitOutcome::Passed,
});
}
ChecksState::Failed => return Ok(WaitOutcome::Failed),
// `gh pr checks` reports a verdict per check but has no exit
// code for "stopped without one", so a cancelled or
// action-required newest run lands in whichever code its
// bucket maps to. Ask the rollup on both sides - the gate and
// the dot must not disagree about the same commit.
ChecksState::Passed => {
return Ok(match self.check_status(review) {
Ok(CheckStatus::Inconclusive) => WaitOutcome::Inconclusive,
_ => WaitOutcome::Passed,
});
}
ChecksState::Failed => {
return Ok(match self.check_status(review) {
Ok(CheckStatus::Inconclusive) => WaitOutcome::Inconclusive,
_ => WaitOutcome::Failed,
});
}

That cannot walk a merge past a red check: aggregate_rollup returns Inconclusive only when no newest run failed, and merge_all bails on both variants either way - only the message changes. The pending case needs no change if gh buckets ACTION_REQUIRED as fail; worth confirming which, since that also tells you whether the Passed arm above is live.

Comment thread src/providers/github.rs Outdated
Comment on lines +606 to +611
aliases.push_str(
"commits(last:1){nodes{commit{statusCheckRollup{contexts(last:100){nodes{\
__typename ... on CheckRun{name status conclusion startedAt \
checkSuite{workflowRun{workflow{name}} app{slug}}} \
... on StatusContext{context state createdAt}}}}}}}}",
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug]

contexts(last:100) caps what the aggregate can see. statusCheckRollup{state} did not - GitHub computed it over the whole list, so it could be stale about superseded runs but never blind.

The cap is not "100 checks", it is 100 runs, and this PR's own premise is that the rollup keeps every run recorded against the head commit. A 40-job matrix on a branch restacked three times is 120 contexts; the response carries the newest 100 and the rest are gone. Usually the ones that fall off are the superseded runs, which is exactly right. But a check whose only entry is old - one that failed and was never re-run, while the noisy checks around it were - falls off with them. aggregate_rollup never sees it, no entry means no failure, and list paints 🟢 on a PR whose checks are red. Same direction as the two bugs the earlier rounds caught, on a path only this PR opens.

check_status has always had this cap (gh selects last:100 too), so it is the batched path that changes: the branch list normally takes traded an exact aggregate for a truncatable one.

StatusCheckRollupContextConnection exposes totalCount, which says when it happened. Select it alongside the nodes, keep state in the selection, and when totalCount > 100 fall back to the aggregate - a stale red for a repo that large is the safe direction, and it is not a repo #331 can currently be fixed for anyway. pageInfo{hasNextPage} works as well if you prefer it. Either way, please say in the comment that 100 is a cap and what happens past it, rather than leaving the number to look like a generous limit.

@lararosekelley

Copy link
Copy Markdown
Owner Author

@bot review

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

Claude finished @lararosekelley's task in 9m 48s —— View job


Code Review - Confidence: 88/100 (+6 vs 82 on the fourth commit)

Stops a superseded (cancelled) check run from pinning a review red, by fetching the rollup's contexts instead of GitHub's aggregate state, keeping only the newest run per workflow+job, and adding an Inconclusive (⚪) dot — with a merge gate behind it — for cancelled / waiting-on-a-human.

No correctness bug survives this round. Both findings below are about the code staying right rather than being right today, which is why the score moves up rather than down.

  • Complexity - src/providers/github.rs:688: ROLLUP_CONTEXTS = 100 and the contexts(last:100) literal on line 615 must agree for the truncation guard to fire at the right point, and nothing makes them — the const's doc comment asserts the coupling that the compiler cannot check. Drop the query to last:50 and the fallback silently stops firing for entries 51..100; raise it to last:200 and the fallback fires on a commit that was read whole, handing list: a superseded CANCELLED check pins a review red even though a later run of the same check passed #331's own bug back to the repos most likely to hit it. The format! on line 599 is right there. (inline comment) · Fix this →

  • Missing Coverage - src/providers/github.rs:614-620: the selection set grew from one scalar to five levels of nesting, a paginated connection, two inline fragments and totalCount, and nothing proves GitHub accepts it — the fakes replay canned JSON, and src/bin/git-stk-e2e.rs never runs list or status (grep 'stk(work, &\["list"' → nothing). A rejection is silent at every verbosity: the retry at :525 only drops the stack fields so it fails identically, :528 returns the original error, the --verbose notice at :542 is on the success path, and annotate_branches:433 swallows it. The result is list quietly paying two rejected GraphQL calls plus one gh pr view per branch forever — and the truncation guard added in this same commit lives only on the path that just died, so the >100 case returns to answering from a slice. One assertion on in the existing stacked_pull_requests cell would catch it. (inline comment) · Fix this →

Resolved since the fourth commit

  • The unreachable arm. gh pr checks --help on the gh in this runner confirms the five buckets, and ACTION_REQUIRED lands in fail alongside ERROR/FAILURE/TIMED_OUT/STARTUP_FAILURE. Consulting the rollup on ChecksState::Failed (:321) as well as Passed (:315) reaches it, and merge_wait_names_an_action_required_check_for_what_it_is pins the message. The move also retires the open question rather than answering it: whichever bucket cancel exits into, both settled arms now ask the same rollup, so the gate cannot land somewhere the dot did not. Neither arm loosens anything — aggregate_rollup returns Inconclusive only after no newest run failed and none is in flight, and merge.rs:195/:199 bail on Failed and Inconclusive alike; only the sentence changes.
  • The truncation cap. totalCount is a real field on StatusCheckRollupContextConnection, > 100 is exactly where last:100 starts dropping entries, and a "statusCheckRollup":null still lands on CheckStatus::Nonepointer returns Some(Value::Null) and /contexts/nodes beneath it is None, so the Some(rollup) arm degrades the same way the old code did.

Also checked and clear: rollup_state_to_status covers GitHub's whole StatusState enum and the field is non-null, so _ => None is unreachable in practice — though its dedicated unit test went with the old shape, and only the FAILURE arm is now exercised, via the new truncation test. gh's exporter writes a StatusContext's createdAt under the key startedAt, and the stamp chain reads startedAt first, so legacy contexts order correctly on both payload shapes. (U+26AA) is East-Asian Wide like the other three dots and nothing measures the dot — nav.rs:484 and status.rs:85 concatenate — so the tree does not shift. Gitea's check_status reads the combined-commit-status endpoint (gitea.rs:202), whose state has no cancelled analogue, and gitea.rs:171 maps from the same table. check_status still has no truncation guard of its own (gh pr view exposes no totalCount), and it is now the gate consult — but both possible outcomes there refuse the merge, so that stays a wrong message rather than a wrong merge. CheckStatus derives no Ord and no serde; WaitOutcome has no _ arm anywhere. The two fixtures at tests/list_status.rs:195,246 still carry the retired {state} shape and now parse to CheckStatus::None, but neither asserts a dot.

CI is fully green: lint, commitlint, plan, and test on ubuntu/macOS/Windows all pass; the Release jobs skip.

Answering "which bucket does gh put this in?" by covering both instead of guessing is the right shape of fix — it closes the finding and the uncertainty behind it at the same time.


  • Read repo guidelines (docs/CODE_REVIEW_BOT.md)
  • Gather PR context (diff, files, CI checks)
  • Requirements gate + boundary/pattern check
  • Read for correctness, safety, surface area
  • Coverage / output review
  • Post findings
    · branch fix/331-superseded-check-runs

Comment thread src/providers/github.rs
/// How many rollup entries the annotate query asks for. Past this the oldest
/// are dropped, and [`parse_annotation_batch`] falls back to GitHub's own
/// aggregate rather than answering from a slice.
const ROLLUP_CONTEXTS: u64 = 100;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Complexity]

The guard is correct only while two numbers written 73 lines apart agree, and nothing makes them. Line 615 hardcodes the cap in a string literal:

"commits(last:1){nodes{commit{statusCheckRollup{state contexts(last:100){\

and this const restates it in Rust, with a doc comment that asserts the coupling ("how many rollup entries the annotate query asks for") rather than establishing it. The compiler cannot check a claim about a string.

The drift is silent and one-directional in the wrong way. Lower the query to last:50 — for cost, for a GHES node budget, for anything — and truncated keeps testing > 100: entries 51..100 are gone from the response and the fallback never fires. That is precisely the state this commit added the const to prevent, and it would present as the same symptom (🟢 on a red commit), from a change whose author had no reason to look here. Raise it to last:200 and the fallback fires for a commit that was read whole, downgrading an accurate dot to GitHub's superseded-run-counting aggregate — the #331 bug, reintroduced for the repos most likely to hit it.

The format! on line 599 is right next door, so interpolating costs nothing:

aliases.push_str(&format!(
    "commits(last:1){{nodes{{commit{{statusCheckRollup{{state \
     contexts(last:{ROLLUP_CONTEXTS}){{totalCount nodes{{\
     __typename ... on CheckRun{{name status conclusion startedAt \
     checkSuite{{workflowRun{{workflow{{name}}}} app{{slug}}}}}} \
     ... on StatusContext{{context state createdAt}}}}}}}}}}}}}}"
));

The escaping is noisy; const ROLLUP_CONTEXTS: &str = "100" with a parse() at the comparison, or a debug_assert!(query.contains(&format!("last:{ROLLUP_CONTEXTS}"))) in the existing build_annotation_query test, would do as well. What matters is that one edit cannot leave the two disagreeing.

Comment thread src/providers/github.rs Outdated
Comment on lines +614 to +620
aliases.push_str(
"commits(last:1){nodes{commit{statusCheckRollup{state contexts(last:100){\
totalCount nodes{\
__typename ... on CheckRun{name status conclusion startedAt \
checkSuite{workflowRun{workflow{name}} app{slug}}} \
... on StatusContext{context state createdAt}}}}}}}}",
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Missing Coverage]

The selection set went from one scalar to five levels of nesting, a paginated connection, two inline fragments and totalCount — and nothing in the tree proves GitHub accepts it. The fakes replay canned JSON keyed on api graphql, which exercises parse_annotation_batch and never gh. The live suite would, but it never runs the command:

$ grep -n 'stk(work, &\["list"' src/bin/git-stk-e2e.rs   # nothing
$ grep -n 'stk(work, &\["status"' src/bin/git-stk-e2e.rs # nothing

That is the repo's own rule about fakes (docs/CODE_REVIEW_BOT.md: they prove git-stk would run the command, "they cannot prove gh accepts that flag"), and this is the surface it was written for.

What makes it worth a change rather than a note is that a rejected query says nothing, at any verbosity:

  • batched_annotate:519 gets the Err and retries at :525 — but the retry only drops stack/stackEntry, so it carries this same selection and fails identically.
  • :528 sees retried.is_err(), resets the flag, returns the original error. The --verbose notice at :542 is on the success path, so it never prints.
  • annotate_branches:433 swallows it: Err(_) => generic_annotate(...).

So the observable result of a malformed field path is list quietly paying two rejected GraphQL calls plus one gh pr view per branch, forever, on every invocation — and the truncation guard added in this same commit lives only on the path that just died, so the >100 case silently returns to answering from a slice. Green on a red commit, by the route this commit closed.

The cheap catch already exists. is rendered from stack{number size} stackEntry{position} in this query, and a rejection of any field here kills both attempts, so the marker disappears. The stacked_pull_requests cell has two open PRs in a GitHub stack by the time it reaches expect_open_stack (line 174) — one assertion on stk list output there transitively proves the whole selection set parses against the live schema:

let listed = stk(work, &["list"])?;
if !listed.contains('⛁') {
    return Err(format!("list did not mark the stack layers - the annotate query \
                        was rejected and fell back per branch:\n{listed}"));
}

Worth adding regardless of this PR, but this PR is what makes the query big enough to get wrong.

@lararosekelley

Copy link
Copy Markdown
Owner Author

@bot review

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

Claude finished @lararosekelley's task in 9m 16s —— View job


Code Review - Confidence: 90/100 (+2 vs 88 on the fifth commit)

Stops a superseded (cancelled) check run from pinning a review red, by fetching the rollup's contexts instead of GitHub's aggregate state, keeping only the newest run per workflow+job, and adding an Inconclusive (⚪) dot — with a merge gate behind it — for cancelled / waiting-on-a-human.

No correctness bug survives this round either. The one finding that matters is that the coverage added to prove the fix works cannot fail for the reason it names — which is load-bearing this time, because the query really was broken and two review rounds said it wasn't.

  • Missing Coverage - src/bin/git-stk-e2e.rs:308: the new live assertion checks the tree contains #, but the per-branch fallback produces the same #N ids — generic_annotate sets id: review.id from gh pr list, and github_review_from formats it as #{number}. So it passes identically whether GitHub accepted the query or rejected it. The comment and the CHANGELOG's Internal entry both claim a rejection leaves the tree "without review numbers"; it does not, and it keeps the CI dots too. The one thing only the batched query can produce is generic_annotate sets stack: None unconditionally (mod.rs:696). (inline comment) · Fix this →

  • Complexity - src/providers/github.rs:617: the doc comment at :578 still says the query is "kept out of format! to dodge brace-escaping" — it is now built with format! and doubled braces. Stale would be minor; the problem is that the stated rationale is exactly the one that shipped the unbalanced literal this commit repairs. (inline comment) · Fix this →

The query was rejected, from 41cda67 until this commit

Counted, not inferred — braces in the commits(last:1) literal at each commit, where −2 is the balanced value (the {nodes{number headRefName mergeQueueEntry{state} prefix contributes +2, and the format! wrapper closes itself):

commit { } net
afcd08e 8 10 −2 ✅
41cda67 (adds checkSuite) 12 13 −1
8b717e3 (adds totalCount) 12 13 −1
cf42e1d balanced ✅

One unclosed scope is a GraphQL syntax error, so batched_annotate failed on both attempts, annotate_branches:433 swallowed it, and every list/status on GitHub silently ran the per-branch path. Which means the contexts selection, the checkSuite dedup key, and the truncation guard have never executed against GitHub — and rounds four and five each stated the query was fine. Catching that from the outside, and generalizing it into a brace-balance assertion rather than a one-off correction, is the substantive work in this commit.

What keeps it at a coverage finding rather than a bug: when the batch is rejected, list falls through to check_statusrollup_statusaggregate_rollup, which carries the same dedup fix. #331 stays fixed on the fallback; what is lost is , the truncation guard, and 1 call in place of 1 + N. Degraded, not wrong.

Resolved since the fifth commit

  • The const/literal coupling. ROLLUP_CONTEXTS is interpolated into the query, and the unit test pins both contexts(last:{ROLLUP_CONTEXTS}) and totalCount, so the guard and the cap cannot drift apart.
  • Brace balance as a class. The {/} count assertion is the right generalization of the defect above — it would have failed on 41cda67.

Also checked and clear: the workflow chain reads /checkSuite/workflowRun/workflow/nameworkflowName/checkSuite/app/slug, and the two payload shapes never overlap (GraphQL has no workflowName, gh emits no checkSuite), so the reordering relative to the earlier suggestion is inert. Truncation on the gh pr view path drops only oldest entries, so it can never promote a stale run over the newest — the check_status gate consult cannot be made to say Inconclusive by truncation alone. rollup_state_to_status still degrades a null rollup to CheckStatus::None. CheckStatus derives no Ord/serde and dot() remains its only match; WaitOutcome has no _ arm. core_lifecycle runs for all three providers, so the #/! pair is the right generic shape for the id check even though it proves less than intended.

CI at review time: lint, commitlint, test (ubuntu-latest), test (macos-latest) pass; test (windows-latest) in flight; Release green. Nothing failing, and lint passing settles that rustfmt accepts the long interpolated literal.


  • Read repo guidelines (docs/CODE_REVIEW_BOT.md)
  • Gather PR context (diff, files, CI checks)
  • Requirements gate + boundary/pattern check
  • Read for correctness, safety, surface area
  • Coverage / output review
  • Post findings
    · branch fix/331-superseded-check-runs

Comment thread src/bin/git-stk-e2e.rs
return Err(format!("list did not show {branch}:\n{listed}"));
}
}
if !listed.contains('#') && !listed.contains('!') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Missing Coverage]

This assertion cannot fail for the reason it names. When the annotate query is rejected, annotate_branches (github.rs:433) falls back to generic_annotate, which lists reviews with gh pr list and sets id: review.id — and github_review_from (github.rs:1222) builds that as format!("#{}", number). #1/#2 are in the tree on both paths, so listed.contains('#') is true whether the batch was accepted or died.

The comment above it, and the CHANGELOG's new Internal entry, state the opposite: "the tree still prints, just without review numbers". The numbers are there. So are the CI dots — the fallback calls check_status per branch (mod.rs:680). What a rejection actually costs is , plus 1 + N extra calls on every invocation.

That matters more than usual here, because the query really was rejected, and static review missed it twice. Counting braces in the literal at each commit:

commit { } net
afcd08e 8 10 −2 ✅
41cda67 (adds checkSuite) 12 13 −1
8b717e3 12 13 −1
cf42e1d balanced ✅

The prefix {nodes{number headRefName mergeQueueEntry{state} contributes +2 and the format! wrapper closes itself, so −2 is the balanced value. From 41cda67 until this commit the query was a GraphQL syntax error: both attempts in batched_annotate failed, annotate_branches swallowed it, and every list/status on GitHub ran the per-branch path. The contexts/checkSuite/totalCount selection — and the truncation guard added in 8b717e3 — have never run against GitHub. The new brace-balance unit test closes that exact class, but a balanced query is not an accepted one; nothing yet proves checkSuite{workflowRun{workflow{name}} app{slug}} and contexts(last:100){totalCount …} validate against the schema. (I could not run gh api graphql from this runner to settle it.)

The signal that does separate the two paths is . It renders from StackPosition, which only the batched query fills in and which generic_annotate sets to None unconditionally (mod.rs:696). github_native_stack is already behind the stacks_enabled guard, so it is not flaky there — after expect_open_stack at line 174:

    // The marker comes from `stack`/`stackEntry` in the batched annotate query,
    // and the per-branch fallback has no way to fill it in - so a marker here is
    // proof the host accepted the whole selection, contexts and all.
    let listed = stk(work, &["list"])?;
    if !listed.contains('⛁') {
        return Err(format!(
            "list did not mark the stack layers - the annotate query was rejected:\n{listed}"
        ));
    }

One caveat worth naming rather than hiding: that cell is skipped when the preview is off for the runner's account, so it would not cover every run. The durable version is to make the fallback observable — the --verbose notice at github.rs:542 fires only on the stack-fields retry, and nothing prints when the query fails outright — then assert on its absence here, for all three providers.

Keep the branch-name assertions; those earn their place. It is the review-id one that proves nothing.

Comment thread src/providers/github.rs
// The cap comes from the constant the truncation guard reads, so the
// two cannot drift: ask for a different number and the guard fires at
// the new one.
aliases.push_str(&format!("commits(last:1){{nodes{{commit{{statusCheckRollup{{state contexts(last:{ROLLUP_CONTEXTS}){{totalCount nodes{{__typename ... on CheckRun{{name status conclusion startedAt checkSuite{{workflowRun{{workflow{{name}}}} app{{slug}}}}}} ... on StatusContext{{context state createdAt}}}}}}}}}}}}}}}}}}"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Complexity]

The doc comment on build_annotation_query (github.rs:578) now says the opposite of what this line does:

/// Build the aliased GraphQL query for [`batched_annotate`]. Kept out of
/// `format!` to dodge brace-escaping; the fields are exactly what
/// [`ReviewAnnotation`] needs, no more.

The selection is built with format! now, and every brace in it is doubled. That would be merely stale, except the rationale it states is the one that produced the bug this commit repairs: a hand-balanced brace run in a literal that nothing could check. A maintainer who reads it and moves the next field back out of format! "to dodge escaping" walks into it again.

Minimal fix: state what is now true — the braces are escaped so the cap comes from ROLLUP_CONTEXTS rather than being restated, and the balance is asserted in build_annotation_query_includes_reviews_only_with_detail.

@lararosekelley

Copy link
Copy Markdown
Owner Author

@bot review

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

Claude finished @lararosekelley's task in 9m 48s —— View job


Code Review - Confidence: 92/100 (+2 vs 90 on the sixth commit)

Stops a superseded (cancelled) check run from pinning a review red, by fetching the rollup's contexts instead of GitHub's aggregate state, keeping only the newest run per workflow+job, and adding an Inconclusive (⚪) dot — with a merge gate behind it — for cancelled / waiting-on-a-human.

No correctness bug survives this round. The one finding is a coverage removal, not a defect.

  • Missing Coverage - src/providers/github.rs:695: rollup_state_to_status gained a new job in this PR — it is now the entire answer for a commit past ROLLUP_CONTEXTS — and lost its only test in the same diff. rollup_state_to_status_maps_the_graphql_aggregate was replaced in place by a test for a different function, and the one branch that still reaches it exercises a single arm, FAILURE, incidentally. PENDING/EXPECTED — the two arms that keep a 120-context commit from reading settled while its checks run — are now uncovered, on the path that by construction never runs on a small repo and never runs under the fakes. (inline comment) · Fix this →

Resolved since the sixth commit

Both closed, and I verified the mechanism rather than the claim:

  • The e2e assertion now discriminates. renders only from review.stack (nav.rs:489), and generic_annotate sets stack: None unconditionally (mod.rs:696) — so on list, which routes through annotate_branchesgeneric_annotate, the marker is present on exactly one of the two paths. The cell is gated on stacks_enabled (git-stk-e2e.rs:144), so the one other way to lose the marker — the STACK_FIELDS_UNSUPPORTED retry succeeding without the preview fields — cannot fire there and produce a false accusation. core_lifecycle's #/! check is correctly demoted to what it actually proves, and the CHANGELOG's Internal entry no longer claims a rejection drops review numbers.
  • The stale rationale. The doc comment on build_annotation_query now describes the escaping and names the test that guards it, instead of recommending the practice that shipped the unbalanced literal.

The query itself

I could not reach gh api graphql from this runner, so I settled it two ways instead of asserting it. Structurally: the trailing run after createdAt is exactly 18 } characters (grep on the literal), which is the balanced value — nine closes for StatusContext, contexts-nodes, contexts, statusCheckRollup, commit, commits-nodes, commits, pr-nodes, pullRequests, the last two opened by the {nodes{number … prefix at :607. By schema: every field is real and correctly placed — Commit.statusCheckRollupStatusCheckRollup.contextsStatusCheckRollupContextConnection.totalCount/.nodes, a union whose members are selected through inline fragments with only __typename outside them, CheckSuite.workflowRun.workflow.name and CheckSuite.app.slug both non-invented, StatusContext.context/.state/.createdAt all present.

One thing to name rather than leave implied: custom-e2e / e2e is skipping on this PR, so the live assertion this commit exists to add will not run until a release. The unit-level brace count runs now; the schema check does not.

Also checked and clear: WaitOutcome has exactly one match in the tree (merge.rs:194), exhaustive with the new arm — no _ anywhere to swallow it; CheckStatus's only match is dot(), and it derives no Ord and no serde, so the mid-enum insert reorders nothing and touches nothing in src/notes/ or .git/stack-state. Every graphql_batch caller passes SUCCESS/FAILURE, so no fixture changed meaning when the helper moved from rollup state to a CheckRun conclusion — a PENDING caller would have flipped 🟡 to 🟢 silently, and there is none. The two fixtures at tests/list_status.rs:195,246 still carry the retired {state} shape and now parse to CheckStatus::None; neither asserts a dot. rollup as Some(Value::Null) degrades through both new arms to CheckStatus::None. Tie-breaking in aggregate_rollup is inert where it can occur: two queued runs of one check both read Pending either way, and a completed run with a null startedAt loses to one with a real stamp, which is the safe direction. SKIPPED/NEUTRAL/STALE keep their pass-through behavior unchanged. docs/COMMANDS.md:69 names the forges it speaks for, and README documents no dots, so there is nothing left un-migrated there.

CI is fully green on this commit: lint, commitlint, plan, and test on ubuntu, macOS and Windows all pass.

Moving the proof from the review ids to — after establishing that the ids appear on both paths — is the difference between a test that runs and a test that discriminates, and it is the second time in this PR that a coverage question was settled by finding the one signal only one path can produce.


  • Read repo guidelines (docs/CODE_REVIEW_BOT.md)
  • Gather PR context (diff, files, CI checks)
  • Requirements gate + boundary/pattern check
  • Read for correctness, safety, surface area
  • Coverage / output review
  • Post findings
    · branch fix/331-superseded-check-runs

@lararosekelley
lararosekelley merged commit 43bfec1 into main Aug 30, 2026
13 checks passed
Comment thread src/providers/github.rs
/// GitHub's own rollup verdict, for the commits whose check list is too long
/// to read whole. Coarser than [`aggregate_rollup`] - it counts superseded
/// runs, which is the bug this all started with - but it sees every entry,
/// and reporting a stale red beats reporting a green that is not there.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Missing Coverage]

This function gained a new job in this PR and lost its only test in the same diff.

Before, it was the whole of list's answer, and rollup_state_to_status_maps_the_graphql_aggregate pinned every arm — SUCCESS, FAILURE, ERROR, PENDING, EXPECTED, None. That test was replaced in place by aggregate_rollup_keeps_only_the_newest_run_of_each_check, which tests a different function. Nothing calls rollup_state_to_status directly from the suite any more.

What still reaches it is one branch: parse_annotation_batch when totalCount > ROLLUP_CONTEXTS. parse_annotation_batch_falls_back_to_the_aggregate_when_truncated exercises exactly one arm of it, FAILURE, incidentally. So the four remaining arms — including PENDING/EXPECTED, the two whose whole purpose is to keep a 120-context commit from reading settled while its checks run — have no coverage at all, on the one path that by construction never runs on a small repo and never runs under the fakes.

That is the wrong direction for a function this PR just made harder to reach. A stale red is the safe answer only if the mapping is right, and the mapping is now the sole answer for the repos most likely to hit #331 in the first place.

The deleted asserts are still correct and still cheap:

    #[test]
    fn rollup_state_to_status_maps_the_graphql_aggregate() {
        assert_eq!(rollup_state_to_status(Some("SUCCESS")), CheckStatus::Passing);
        assert_eq!(rollup_state_to_status(Some("FAILURE")), CheckStatus::Failing);
        assert_eq!(rollup_state_to_status(Some("ERROR")), CheckStatus::Failing);
        assert_eq!(rollup_state_to_status(Some("PENDING")), CheckStatus::Pending);
        assert_eq!(rollup_state_to_status(Some("EXPECTED")), CheckStatus::Pending);
        assert_eq!(rollup_state_to_status(None), CheckStatus::None);
    }

Worth a sentence in its doc comment too, since the reason it survives is no longer obvious from its call graph: it covers StatusState, GitHub's own aggregate, which is a closed enum the field is non-null on — so _ => None is unreachable in practice rather than a swallow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

list: a superseded CANCELLED check pins a review red even though a later run of the same check passed

1 participant