Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 47 additions & 11 deletions devdash.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,19 +555,25 @@ 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 } } } } } } }
}
"""
# gh-stack's `stack` field is not in every GitHub schema (GitHub Enterprise Server, for one).
# 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)
Expand All @@ -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"]
Expand All @@ -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:
Expand Down
69 changes: 67 additions & 2 deletions tests/test_devdash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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():
Expand Down Expand Up @@ -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)

Expand Down
Loading