diff --git a/CHANGELOG.md b/CHANGELOG.md index 527e613..3453a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,9 @@ All notable changes to this project are documented here. The format follows ### 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. +- CI badge uses the latest Actions run of each workflow, then the latest check of each + name, matching the PR merge box. A cancelled workflow's failed `Verify / gate` no + longer keeps a later green run red, or a still-running rerun that has not posted gate yet. ## [0.1.0] - 2026-09-23 diff --git a/devdash.py b/devdash.py index 5aee6f7..bb393e2 100755 --- a/devdash.py +++ b/devdash.py @@ -555,7 +555,7 @@ 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 databaseId startedAt } + {check_run} ... on StatusContext { context state createdAt } } } } } } } } """ @@ -563,11 +563,17 @@ def render_usage(st, width, now): # Without it, stacks are rebuilt from base-branch -> head-branch chains instead. STACK_FIELD = ("stack { number size baseRefName " "entries(first: 30) { nodes { position pullRequest { number state title url } } } }") +CHECK_RUN_CORE = "... on CheckRun { name status conclusion databaseId startedAt }" +CHECK_RUN_SUITE = ( + CHECK_RUN_CORE[:-2] + + " checkSuite { workflowRun { databaseId createdAt workflow { name } } } }" +) HAS_STACK = True +HAS_WORKFLOW_RUN = True def fetch_prs(excluded, review_requested, account=None): - global HAS_STACK + global HAS_STACK, HAS_WORKFLOW_RUN # Search qualifiers keep excluded repos from eating the 50-result pages; # is_excluded below is the backstop for anything the search still returns. skip = " ".join(f"-repo:{r}" if "/" in r else f"-user:{r}" for r in excluded) @@ -579,15 +585,24 @@ def fetch_prs(excluded, review_requested, account=None): {review} }}""" while True: - query = PR_FIELDS.replace("{stack}", STACK_FIELD if HAS_STACK else "") + body + query = (PR_FIELDS + .replace("{stack}", STACK_FIELD if HAS_STACK else "") + .replace("{check_run}", CHECK_RUN_SUITE if HAS_WORKFLOW_RUN else CHECK_RUN_CORE) + + body) try: data = json.loads(run(["gh", "api", "graphql", "-f", f"query={query}"], env=github_env(account))) break except RuntimeError as e: - if not (HAS_STACK and "Field 'stack' doesn't exist" in str(e)): - raise - HAS_STACK = False + msg = str(e) + if HAS_STACK and "Field 'stack' doesn't exist" in msg: + HAS_STACK = False + continue + if HAS_WORKFLOW_RUN and ("Field 'checkSuite' doesn't exist" in msg + or "Field 'workflowRun' doesn't exist" in msg): + HAS_WORKFLOW_RUN = False + continue + raise if data.get("errors") and not data.get("data"): raise RuntimeError(data["errors"][0].get("message", "graphql error")) d = data["data"] @@ -598,17 +613,38 @@ def keep(nodes): return keep(d["mine"]["nodes"]), keep(d["review"]["nodes"]) if review_requested else [] +def workflow_run(ctx): + run = ((ctx.get("checkSuite") or {}).get("workflowRun") or {}) + name = (run.get("workflow") or {}).get("name") or "" + return name, run.get("databaseId") or 0, run.get("createdAt") or "" + + def latest_contexts(nodes): - """One entry per check name / status context: the newest run. + """Latest Actions run per workflow, then the newest check of each name. - 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. + GitHub's statusCheckRollup keeps every check-run on the SHA. A cancelled + workflow's `Verify / gate` FAILURE stays listed after a new run starts, + even before that new run has posted gate. The merge box follows the + latest workflow run; the rollup `state` does not. """ + latest_run = {} + for i, ctx in enumerate(nodes): + if ctx.get("__typename") != "CheckRun": + continue + name, rid, created = workflow_run(ctx) + if not (name and rid): + continue + sort = (rid, created, i) + if name not in latest_run or sort > latest_run[name]: + latest_run[name] = sort latest = {} for i, ctx in enumerate(nodes): if ctx.get("__typename") == "CheckRun": + wname, rid, created = workflow_run(ctx) + if wname and rid: + keep = latest_run[wname] + if (rid, created) != (keep[0], keep[1]): + continue key = ("check", ctx["name"]) sort = (ctx.get("databaseId") or 0, ctx.get("startedAt") or "", i) else: diff --git a/tests/test_devdash.py b/tests/test_devdash.py index 717952c..b6501e1 100644 --- a/tests/test_devdash.py +++ b/tests/test_devdash.py @@ -252,6 +252,7 @@ def test_native_stack_includes_layers_that_are_not_mine(): def test_fetch_retries_without_stack_field_when_schema_lacks_it(monkeypatch): monkeypatch.setattr(devdash, "HAS_STACK", True) + monkeypatch.setattr(devdash, "HAS_WORKFLOW_RUN", True) queries = [] def fake_run(cmd, timeout=45, env=None): @@ -277,9 +278,14 @@ def rollup(*nodes, state="FAILURE"): "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, +def check(name, conclusion, status="COMPLETED", database_id=1, started="2026-09-25T16:00:00Z", + workflow=None, run=None, created="2026-09-25T16:00:00Z"): + node = {"__typename": "CheckRun", "name": name, "status": status, "conclusion": conclusion, "databaseId": database_id, "startedAt": started} + if workflow and run: + node["checkSuite"] = {"workflowRun": { + "databaseId": run, "createdAt": created, "workflow": {"name": workflow}}} + return node def test_ci_badge_uses_the_latest_run_of_a_check_name(): @@ -319,6 +325,65 @@ def test_ci_badge_a_rerun_in_progress_is_pending_not_the_old_result(): assert failed == "" +def test_ci_badge_ignores_gate_failure_from_an_older_workflow_run(): + """Cancelled Verify run posts gate FAILURE; the new Verify run is still in + progress and has not posted gate yet.""" + pr = rollup( + check("Verify / gate", "FAILURE", database_id=1, started="2026-09-25T20:38:05Z", + workflow="Verify", run=10, created="2026-09-25T20:38:00Z"), + check("Verify / clients", "CANCELLED", database_id=2, started="2026-09-25T20:38:01Z", + workflow="Verify", run=10, created="2026-09-25T20:38:00Z"), + check("Verify / clients", None, status="IN_PROGRESS", database_id=3, + started="2026-09-25T20:38:29Z", + workflow="Verify", run=20, created="2026-09-25T20:38:14Z"), + check("Verify / ios / compile", None, status="IN_PROGRESS", database_id=4, + started="2026-09-25T20:38:32Z", + workflow="Verify", run=20, created="2026-09-25T20:38:14Z"), + check("Infra / gate", "FAILURE", database_id=5, started="2026-09-25T20:38:04Z", + workflow="Infra", run=11, created="2026-09-25T20:38:00Z"), + check("Infra / gate", "SUCCESS", database_id=6, started="2026-09-25T20:38:37Z", + workflow="Infra", run=21, created="2026-09-25T20:38:14Z"), + {"__typename": "StatusContext", "context": "CodeRabbit", "state": "SUCCESS", + "createdAt": "2026-09-25T20:38:23Z"}, + ) + badge, failed = devdash.ci_badge(pr) + assert badge.plain == "●ci2" + assert failed == "" + + +def test_ci_badge_same_workflow_run_failure_is_still_failure(): + pr = rollup( + check("Verify / gate", "FAILURE", database_id=1, workflow="Verify", run=20), + check("Verify / clients", None, status="IN_PROGRESS", database_id=2, + workflow="Verify", run=20), + ) + badge, failed = devdash.ci_badge(pr) + assert badge.plain == "✗ci1" + assert failed == "Verify / gate" + + +def test_fetch_retries_without_workflow_run_when_schema_lacks_it(monkeypatch): + monkeypatch.setattr(devdash, "HAS_STACK", False) + monkeypatch.setattr(devdash, "HAS_WORKFLOW_RUN", True) + queries = [] + + def fake_run(cmd, timeout=45, env=None): + query = cmd[-1] + queries.append(query) + if "workflowRun" in query: + raise RuntimeError("gh: Field 'workflowRun' doesn't exist on type 'CheckSuite'") + nodes = [pr(1, "a", "main")] + return json.dumps({"data": {"mine": {"nodes": nodes}}}) + + monkeypatch.setattr(devdash, "run", fake_run) + mine, review = devdash.fetch_prs([], review_requested=False) + assert [p["number"] for p in mine] == [1] + assert "workflowRun" not in queries[-1] + assert "... on CheckRun { name status conclusion databaseId startedAt }" in queries[-1] + devdash.fetch_prs([], review_requested=False) + assert len(queries) == 3 + + def test_fetch_does_not_hide_other_errors(monkeypatch): monkeypatch.setattr(devdash, "HAS_STACK", True)