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: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
33 changes: 26 additions & 7 deletions devdash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -449,14 +449,35 @@ 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"]
roll = nodes[0]["commit"]["statusCheckRollup"] if nodes else None
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
Expand All @@ -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}"),
Expand Down
47 changes: 47 additions & 0 deletions tests/test_devdash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading