Don't count cancelled checks as failures in pr status#12906
Don't count cancelled checks as failures in pr status#12906
pr status#12906Conversation
When a GitHub Actions workflow uses concurrency with cancel-in-progress, cancelled runs were counted as failures in `gh pr status` and `gh pr view`, even when a newer run for the same check name succeeded. The GitHub web UI and `gh pr checks` both handle this correctly. Three changes fix this: 1. Add a `cancelled` check status category. Cancelled runs are now excluded from all summary counts (passing/failing/pending) and subtracted from the total, matching the web UI behavior. 2. Move `eliminateDuplicates` from pkg/cmd/pr/checks to `api.EliminateDuplicateChecks` (exported). The function operates entirely on `api.CheckContext` and is now shared by both `pr checks` and `ChecksStatus()` (used by `pr status` and `pr view`). 3. Apply deduplication in the `ChecksStatus()` slow path, keeping only the most recent run per check name — consistent with `pr checks`. Fixes #12895 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This pull request updates PR check status summarization and deduplication by centralizing duplicate-check elimination in the api package and refining how cancelled checks are counted, alongside fixture/test updates to reflect correct GraphQL shapes.
Changes:
- Move check deduplication logic into
api.EliminateDuplicateChecksand reuse it across PR checks aggregation andPullRequest.ChecksStatus(). - Exclude cancelled check runs from check status totals (both in count-by-state and node-based summarization paths).
- Update fixtures/tests to use
__typenameand include required node fields (name/context), plus add new deduplication-focused tests.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/cmd/pr/status/fixtures/prStatusChecks.json | Fix fixture GraphQL discriminator field to __typename and add missing name/context fields used by dedup/summarization logic. |
| pkg/cmd/pr/checks/checks_test.go | Update tests to call api.EliminateDuplicateChecks after moving the helper out of the command package. |
| pkg/cmd/pr/checks/aggregate.go | Switch aggregation to use api.EliminateDuplicateChecks and remove the local duplicate-elimination implementation. |
| api/queries_pr.go | Change cancelled check handling in ChecksStatus() and introduce exported EliminateDuplicateChecks utility. |
| api/pull_request_test.go | Update expectations for cancelled checks, adjust node helpers to include name/context, and add new deduplication tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| func EliminateDuplicateChecks(checkContexts []CheckContext) []CheckContext { | ||
| sort.Slice(checkContexts, func(i, j int) bool { return checkContexts[i].StartedAt.After(checkContexts[j].StartedAt) }) |
There was a problem hiding this comment.
EliminateDuplicateChecks sorts/deduplicates using StartedAt for all nodes, but StatusContext objects from the GraphQL query only include createdAt (see api/query_builder.go StatusContext selection) and do not populate startedAt. This means duplicate StatusContext entries can be deduplicated in an arbitrary order, potentially keeping an older failure instead of a newer success. Consider sorting by StartedAt for CheckRun nodes and by CreatedAt for StatusContext nodes (or by max(StartedAt, CreatedAt)) before deduping.
| func EliminateDuplicateChecks(checkContexts []CheckContext) []CheckContext { | |
| sort.Slice(checkContexts, func(i, j int) bool { return checkContexts[i].StartedAt.After(checkContexts[j].StartedAt) }) | |
| func checkContextTime(ctx CheckContext) time.Time { | |
| if !ctx.StartedAt.IsZero() { | |
| return ctx.StartedAt | |
| } | |
| return ctx.CreatedAt | |
| } | |
| func EliminateDuplicateChecks(checkContexts []CheckContext) []CheckContext { | |
| sort.Slice(checkContexts, func(i, j int) bool { | |
| return checkContextTime(checkContexts[i]).After(checkContextTime(checkContexts[j])) | |
| }) |
| { | ||
| name: "COMPLETED / CANCELLED is treated as Failing", | ||
| name: "COMPLETED / CANCELLED is excluded from counts", | ||
| payload: singleCompletedCheckRunWithConclusion("CANCELLED"), | ||
| expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1}, | ||
| expectedChecksStatus: PullRequestChecksStatus{}, | ||
| }, | ||
| { | ||
| name: "COMPLETED / CANCELLED is treated as Failing", | ||
| name: "COMPLETED / CANCELLED is excluded from counts (duplicate)", | ||
| payload: singleCompletedCheckRunWithConclusion("CANCELLED"), | ||
| expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1}, | ||
| expectedChecksStatus: PullRequestChecksStatus{}, | ||
| }, |
There was a problem hiding this comment.
This table-driven test includes two identical cases for CANCELLED (same payload and same expected status). The duplicate doesn’t add coverage and makes the test suite noisier; it should either be removed or changed to cover a distinct scenario.
See below for a potential fix:
| TypeName: "StatusContext", | ||
| Context: "ci/test", | ||
| State: "SUCCESS", | ||
| StartedAt: time.Date(2022, 2, 2, 2, 2, 2, 2, time.UTC), | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "unique checks are preserved", | ||
| checkContexts: []CheckContext{ | ||
| { | ||
| TypeName: "CheckRun", | ||
| Name: "build", | ||
| Status: "COMPLETED", |
There was a problem hiding this comment.
The StatusContext deduplication test data uses StartedAt timestamps, but StatusContext nodes from the GraphQL query populate CreatedAt (not StartedAt). This test can pass even if StatusContext deduplication is wrong in real responses; it should set CreatedAt and assert the function uses it when StartedAt is zero.
| got := eliminateDuplicates(tt.checkContexts) | ||
| got := api.EliminateDuplicateChecks(tt.checkContexts) | ||
| if !reflect.DeepEqual(tt.want, got) { | ||
| t.Errorf("got eliminateDuplicates %+v, want %+v\n", got, tt.want) |
There was a problem hiding this comment.
The failure message still refers to the old function name "eliminateDuplicates" even though the test now calls api.EliminateDuplicateChecks. Updating the error text will make failures easier to interpret.
| t.Errorf("got eliminateDuplicates %+v, want %+v\n", got, tt.want) | |
| t.Errorf("got EliminateDuplicateChecks %+v, want %+v\n", got, tt.want) |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
williammartin
left a comment
There was a problem hiding this comment.
Beyond copilot's comments which I will let you address, this looks good to me.
Fixes #12895
Description
gh pr statusandgh pr viewcount cancelled check runs as failures, even when a newer successful run exists for the same check name. The GitHub web UI shows "All checks passed" andgh pr checksshows the check as passing, butpr statusgets it wrong.This affects any scenario that produces cancelled runs: workflows with
concurrency: { cancel-in-progress: true }, manual cancellations, API-triggered cancellations, etc.Changes Overview
Add a
cancelledcheck status category. Cancelled runs are now excluded from all summary counts (passing/failing/pending) and subtracted from the total, matching the web UI behavior. This fixes both the fast path (aggregated counts by state) and the slow path (individual node iteration).Move
eliminateDuplicatestoapi.EliminateDuplicateChecks(exported). The function operates entirely onapi.CheckContextand was previously buried inpkg/cmd/pr/checks. It is now shared by bothpr checksandChecksStatus()(used bypr statusandpr view).Apply deduplication in the
ChecksStatus()slow path, keeping only the most recent run per check name, consistent with whatpr checksalready does.Notes to Reviewers
prStatusChecks.jsonhad__typeName(capital N) instead of__typename. This was harmless before dedup was added but caused nodes to lose their type identity. Fixed alongside adding unique name/context fields so dedup works correctly in tests.checkRunCount/checkRunCountsByState(the fast path) is available on all currently supported GHES versions (3.16 through 3.20). Removing the slow path entirely could be a follow-up cleanup.Acceptance Testing Results
Tested against a real PR with cancelled + successful runs for the same check on the same commit:
pr statusshows "Checks passing"pr checksstill shows check as passing (regression)pr view --json statusCheckRollupconfirms API returns both CANCELLED and SUCCESSFull acceptance test results