From cebbbade7513d862f52237bef44bd7c219fade9b Mon Sep 17 00:00:00 2001 From: Shayan Date: Fri, 25 Sep 2026 12:34:50 -0400 Subject: [PATCH] fix: score CI from the latest run of each check name GitHub's statusCheckRollup keeps every check-run on the SHA, so a cancelled workflow's failed gate sits next to the later success and the rollup state stays FAILURE. The PR merge box and `gh pr checks` use the latest run of each name. Match that, and ignore the rollup state. --- CHANGELOG.md | 5 +++++ README.md | 2 +- devdash.py | 33 +++++++++++++++++++++++------- tests/test_devdash.py | 47 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d128130..eb3a2f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Fixed + +- CI badge uses the latest run of each check name, matching the PR merge box. A cancelled + workflow's failed `Verify / gate` no longer keeps a later green run red. + ### Added - Pace icons on weekly and monthly usage bars: `>` to `>>>` when you use a quota faster than diff --git a/README.md b/README.md index 71c571c..4b6fd78 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ devdash --help # all flags |Mark|Meaning| |---|---| -|`✓ci` `●ci2` `✗ci1`|Checks passed, 2 still running, 1 failed (the first failed check is named at the end of the row)| +|`✓ci` `●ci2` `✗ci1`|Latest run of each check passed, 2 still running, 1 failed (the first failed check is named at the end of the row)| |`approved` `changes` `needs review`|The PR's review decision| |`✓dana` `✗lee` `✎sam`|A reviewer's latest review: approved, requested changes, commented| |`bot✎2`|Reviews from 2 bots| diff --git a/devdash.py b/devdash.py index 18269b6..77e0a41 100755 --- a/devdash.py +++ b/devdash.py @@ -407,8 +407,8 @@ def render_usage(st, width, now): viewerLatestReview { state } commits(last: 1) { nodes { commit { statusCheckRollup { state contexts(first: 100) { nodes { __typename - ... on CheckRun { name status conclusion } - ... on StatusContext { context state } } } } } } } + ... on CheckRun { name status conclusion databaseId startedAt } + ... on StatusContext { context state createdAt } } } } } } } } """ # gh-stack's `stack` field is not in every GitHub schema (GitHub Enterprise Server, for one). @@ -449,6 +449,27 @@ def keep(nodes): return keep(d["mine"]["nodes"]), keep(d["review"]["nodes"]) if review_requested else [] +def latest_contexts(nodes): + """One entry per check name / status context: the newest run. + + GitHub's statusCheckRollup keeps every check-run on the SHA, so a + cancelled in-progress run's `Verify / gate` FAILURE sits next to the + later SUCCESS. The PR merge box and `gh pr checks` use the latest of + each name; the rollup `state` does not. + """ + latest = {} + for i, ctx in enumerate(nodes): + if ctx.get("__typename") == "CheckRun": + key = ("check", ctx["name"]) + sort = (ctx.get("databaseId") or 0, ctx.get("startedAt") or "", i) + else: + key = ("status", ctx.get("context")) + sort = (0, ctx.get("createdAt") or "", i) + if key not in latest or sort > latest[key][0]: + latest[key] = (sort, ctx) + return [item[1] for item in latest.values()] + + def ci_badge(pr): """(Text badge, name of the first failed check or '').""" nodes = pr["commits"]["nodes"] @@ -456,7 +477,7 @@ def ci_badge(pr): if not roll: return Text("○ci", style=META), "" failed, running = [], 0 - for ctx in roll["contexts"]["nodes"]: + for ctx in latest_contexts(roll["contexts"]["nodes"]): if ctx["__typename"] == "CheckRun": if ctx["status"] != "COMPLETED": running += 1 @@ -468,11 +489,9 @@ def ci_badge(pr): running += 1 if failed: return Text(f"✗ci{len(failed)}", style=f"bold {BAD}"), failed[0] - if running or roll["state"] in ("PENDING", "EXPECTED"): + if running: return Text(f"●ci{running or ''}", style=WARN), "" - if roll["state"] == "SUCCESS": - return Text("✓ci", style=OK), "" - return Text("✗ci", style=BAD), "" + return Text("✓ci", style=OK), "" DECISION = {"APPROVED": ("approved", f"bold {OK}"), diff --git a/tests/test_devdash.py b/tests/test_devdash.py index 9627ad9..04f90af 100644 --- a/tests/test_devdash.py +++ b/tests/test_devdash.py @@ -108,6 +108,53 @@ def fake_run(cmd, timeout=45): assert len(queries) == 3 # the fallback is remembered: no second failed attempt +def rollup(*nodes, state="FAILURE"): + return {"commits": {"nodes": [{"commit": {"statusCheckRollup": { + "state": state, "contexts": {"nodes": list(nodes)}}}}]}} + + +def check(name, conclusion, status="COMPLETED", database_id=1, started="2026-09-25T16:00:00Z"): + return {"__typename": "CheckRun", "name": name, "status": status, + "conclusion": conclusion, "databaseId": database_id, "startedAt": started} + + +def test_ci_badge_uses_the_latest_run_of_a_check_name(): + """A cancelled workflow posts Verify / gate FAILURE; the surviving run posts SUCCESS. + statusCheckRollup.state stays FAILURE because every run remains on the SHA.""" + pr = rollup( + check("Verify / gate", "FAILURE", database_id=1, started="2026-09-25T15:57:07Z"), + check("Verify / gate", "SUCCESS", database_id=2, started="2026-09-25T16:04:12Z"), + check("Infra / gate", "FAILURE", database_id=3, started="2026-09-25T15:57:07Z"), + check("Infra / gate", "SUCCESS", database_id=4, started="2026-09-25T15:57:32Z"), + check("Infra / plan (${{ matrix.env }})", "CANCELLED", database_id=5), + {"__typename": "StatusContext", "context": "CodeRabbit", "state": "SUCCESS", + "createdAt": "2026-09-25T16:00:00Z"}, + ) + badge, failed = devdash.ci_badge(pr) + assert badge.plain == "✓ci" + assert failed == "" + + +def test_ci_badge_a_later_failure_replaces_an_earlier_pass(): + pr = rollup( + check("Verify / gate", "SUCCESS", database_id=1), + check("Verify / gate", "FAILURE", database_id=2), + ) + badge, failed = devdash.ci_badge(pr) + assert badge.plain == "✗ci1" + assert failed == "Verify / gate" + + +def test_ci_badge_a_rerun_in_progress_is_pending_not_the_old_result(): + pr = rollup( + check("Verify / gate", "SUCCESS", database_id=1), + check("Verify / gate", None, status="IN_PROGRESS", database_id=2), + ) + badge, failed = devdash.ci_badge(pr) + assert badge.plain == "●ci1" + assert failed == "" + + def test_fetch_does_not_hide_other_errors(monkeypatch): monkeypatch.setattr(devdash, "HAS_STACK", True)