diff --git a/README.md b/README.md index cadd169..b2adf0f 100644 --- a/README.md +++ b/README.md @@ -51,19 +51,17 @@ The display is a shared 72×16 canvas. Each integration publishes text, shapes, | Priority | Tier | Occupied by | |---|---|---| | 20 | `PRIORITY_AMBIENT` | `calendar_countdown`'s baseline countdown (normal and in-progress) | -| 21 | `PRIORITY_OVERLAY` | `ci_status`'s short-dwell running badge and GitHub GraphQL/REST quota gauges | +| 21 | `PRIORITY_OVERLAY` | `ci_status`'s rotation — running badge, GitHub GraphQL/REST quota gauges, and failure/stuck/quiet-green frames | | 25 | `PRIORITY_AMBIENT_RAISED` | `calendar_countdown` inside `approach_minutes`, outside `notice_minutes` — no longer interruptible by the overlay tier | -| 60 | `PRIORITY_ALERT` | `ci_status`'s failure/stuck-queue alert badges | -| 65 | `PRIORITY_AMBIENT_URGENT` | `calendar_countdown` inside `notice_minutes`/`warn_minutes` — outranks even a live alert | +| 60 | `PRIORITY_ALERT` | Reserved/unused — no in-repo integration currently draws here | +| 65 | `PRIORITY_AMBIENT_URGENT` | `calendar_countdown` inside `notice_minutes`/`warn_minutes` | | 90 | `PRIORITY_SESSION` | An authenticated BUSY/CUSTOM work session on the device — outranks everything else | Two firmware facts shape all of the above: equal priority from a different `application_name` is **rejected** (`409`), not a hand-off — only a strictly higher number preempts; and a preempted app's elements are **evicted, not restored** — the lower-priority app only reclaims the screen via its own next scheduled redraw, never automatically. Each element carries an optional `timeout`; if its source doesn't refresh within that window, the element self-clears rather than sticking on screen indefinitely. -**Overlay dwell/rotation.** `ci_status`'s overlay-tier frames (running badge, then the GraphQL and REST quota gauges) each draw for one `OVERLAY_DWELL_SECONDS` (10s) dwell slot, then stay silent for at least one more dwell period before redrawing — giving `calendar_countdown`'s own ambient-tier redraws (also tuned to a 10s cadence) a real chance to land in the resulting gap. Because eviction is one-way, the two integrations trade the panel back and forth rather than alternating cleanly; see each integration's README for the measured recovery rates. +**Overlay dwell/rotation.** `ci_status`'s overlay-tier frames (failure/stuck/quiet-green frames, the running badge, and the GraphQL and REST quota gauges) each draw for one `OVERLAY_DWELL_SECONDS` (10s) dwell slot, then stay silent for at least one more dwell period before redrawing — giving `calendar_countdown`'s own ambient-tier redraws (also tuned to a 10s cadence) a real chance to land in the resulting gap. Because eviction is one-way, the two integrations trade the panel back and forth rather than alternating cleanly; see each integration's README for the measured recovery rates. -**Escalation beats alerts.** As an upcoming calendar event gets closer, `calendar_countdown` climbs from `PRIORITY_AMBIENT` (20) through `PRIORITY_AMBIENT_RAISED` (25, inside `approach_minutes`) to `PRIORITY_AMBIENT_URGENT` (65, inside `notice_minutes`/`warn_minutes`) — strictly above `ci_status`'s own `PRIORITY_ALERT` (60), so a persistent CI failure can no longer permanently bury an imminent event. `ci_status` needs no special handling for this: its alert draw gets a `409` while the calendar holds the higher tier, treats that as an expected rejection, and reappears on its own next poll once the calendar drops back to baseline. - -**Snooze by acknowledgment.** `ci_status` alerts can be snoozed entirely through the device's native **start** button — no separate UI or config edit. Starting a BUSY/CUSTOM session while an alert is showing, then ending it, snoozes that exact failure/stuck fingerprint for `snooze_minutes`; any change to the fingerprint (a new failure, or the original resolving and a new one appearing) re-alerts immediately, even mid-snooze. +**No alert takeover.** `ci_status` no longer draws at `PRIORITY_ALERT`; failure and stuck-queue frames now rotate at `PRIORITY_OVERLAY` (21) alongside the running badge and quota gauges, under the calendar's ambient tiers. As an upcoming calendar event gets closer, `calendar_countdown` climbs from `PRIORITY_AMBIENT` (20) through `PRIORITY_AMBIENT_RAISED` (25, inside `approach_minutes`) to `PRIORITY_AMBIENT_URGENT` (65, inside `notice_minutes`/`warn_minutes`), which already sits strictly above the overlay tier — so an imminent event naturally outranks a CI failure, and the failure frame alternates with the calendar's own redraws rather than camping the panel. The `application_name` field tags each draw's source, letting the display track ownership and multi-instance behavior. @@ -160,4 +158,4 @@ token, with these results: | Integration | Description | |---|---| | [`calendar_countdown`](integrations/calendar_countdown/) | Live countdown to your next macOS Calendar event. Four-stage escalation as an event approaches — `approach_minutes` (30m default), `notice_minutes` (15m, amber), `warn_minutes` (5m, red), and a final-minute LED blink — plus one audio chirp precisely at event start. The countdown itself turns teal while the event is in progress. Optional `auto_busy` starts a BUSY session automatically for the event's duration. | -| [`ci_status`](integrations/ci_status/) | GitHub Actions status via the REST API with ETag caching (near-zero steady-state quota cost). Red alert badges on failure, amber on stale-queued runs, either snoozable via the device's native start button. While a run is active, an overlay-tier rotation shows a running badge (ETA plus a "remain"/"left" label) alongside GitHub GraphQL/REST quota gauges. Optional account-wide watching auto-discovers and monitors every repo you own, not just an explicit list. | +| [`ci_status`](integrations/ci_status/) | GitHub Actions status via the REST API with ETag caching (near-zero steady-state quota cost). Failure and stuck-queue frames rotate calmly at the overlay tier — `CI FAIL owner/repo #42 · workflow` (PR number, or branch when there's no PR) — with a gentle red LED while a workflow is failing. While a run is active, the same rotation adds a running badge (ETA plus a "remain"/"left" label) alongside GitHub GraphQL/REST quota gauges. Optional account-wide watching auto-discovers and monitors every repo you own, not just an explicit list. | diff --git a/config.example.toml b/config.example.toml index 1ee7c62..1f8f368 100644 --- a/config.example.toml +++ b/config.example.toml @@ -38,9 +38,11 @@ auto_busy = false # v1.5.2 escalation ladder -- as an upcoming event gets closer, the calendar # climbs busybar/display.py's shared priority ladder so it can no longer be -# silently buried, first by the overlay-tier CI badge/quota rotation, then -# by a persistent CI failure/stuck alert. See calendar_countdown/README.md's -# "Escalation ladder" section before tuning. ASSUMED ORDERING (logged as a +# silently buried by ci_status's overlay-tier rotation (running badge, quota +# frames, and -- as of v1.7 -- CI failure/stuck frames too, since those now +# share the same PRIORITY_OVERLAY tier instead of a separate alert tier). +# See calendar_countdown/README.md's "Escalation ladder" section before +# tuning. ASSUMED ORDERING (logged as a # one-time startup warning if violated -- see check_threshold_ordering): # approach_minutes > notice_minutes > warn_minutes >= imminent_minutes. approach_minutes = 30 # inside this (outside notice_minutes): priority rises above the @@ -71,13 +73,6 @@ watch_account_repos = false active_within_days = 30 # only repos pushed within this window are polled repo_refresh_minutes = 60 # how often the repo list itself is re-enumerated (new repos picked up within this interval) -# Alert snooze via the device's native start button (v1.5.2). Starting a -# BUSY session on the device while a CI alert is showing snoozes that exact -# failure/stuck fingerprint for snooze_minutes once the session ends -- any -# change (new failure, different workflow, resolved-then-new) re-alerts -# immediately. See ci_status/README.md's "Snoozing alerts" section. -snooze_minutes = 30 # 0 disables the feature - [nyan_filler] enabled = true # set false to disable the animation without uninstalling the agent poll_seconds = 1 # how quickly a dark gap is reclaimed (the draw is tiny; see README) diff --git a/docs/superpowers/plans/2026-08-08-ci-failure-rotation-frame.md b/docs/superpowers/plans/2026-08-08-ci-failure-rotation-frame.md new file mode 100644 index 0000000..173411b --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-ci-failure-rotation-frame.md @@ -0,0 +1,852 @@ +# CI Failure as a Calm Rotation Frame — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the CI failure/stuck screen show the repo + PR number and rotate calmly at the overlay tier (alternating with the calendar, running badge, and quota) instead of camping the panel at priority 60, keeping a gentle red LED while anything fails and retiring the now-unneeded snooze subsystem. + +**Architecture:** Enrich `evaluate_runs` to keep a `FailingRun(workflow, ref)` per failing/stuck run; add fail/stuck/green frame builders and a descriptor-based sequence builder to `ci_status.logic`; rewrite `ci_status.main.run_once` so the existing overlay dwell/silence rotation is driven by a dynamic sequence that includes failures (even with no job running) and a centralized failure-driven LED lifecycle mirroring `calendar_countdown`'s; then delete the old priority-60 `build_ci_payload` path and the snooze state machine. + +**Tech Stack:** Python 3.12, pytest, stdlib + existing `BusyBarClient`. No new dependencies. + +## Global Constraints + +- **Python floor:** 3.12+ (matches repo). +- **No new dependencies.** stdlib + existing `busybar` package only. +- **Priority tiers come from `busybar.display`** — reuse `PRIORITY_OVERLAY` (21); do **not** invent a new tier. `PRIORITY_ALERT` (60) becomes unused by `ci_status`. +- **Dwell/silence contract:** overlay-tier frames draw with element `timeout = OVERLAY_DWELL_SECONDS` (10) and commit `frame_index`/`last_dwell_end` **only after `client.draw` returns `DrawResult.DRAWN`** (the existing discipline — never commit on a failed draw). +- **LED off is explicit:** send `#00000000` on the failing→clear transition (never rely on omission to clear a lit LED), mirroring `calendar_countdown.logic`. +- **Run tests with:** `uv run pytest -q` from the repo root. Full suite: `uv run pytest -q`. +- **Badge colors (unchanged):** failure bg `#A32D2DFF` / text `#FFFFFFFF`; stuck bg `#BA7517FF` / text `#0B0B0BFF`; green text `#00FF00FF`. +- **pyproject `version` stays `0.1.0`** — this repo's feature versions are doc labels (README prose), not the package version. + +--- + +## File Structure + +- `integrations/ci_status/logic.py` — **modify.** Add `FailingRun`; change `RepoState.failing`/`.stuck` to `list[FailingRun]`; enrich `evaluate_runs`; add `_fail_line`, fail/stuck/green frame builders, `build_overlay_sequence`, and the LED helper (`resolve_ci_led_value`, `CI_LED_COLOR`, `LED_OFF_COLOR`, `LED_OFF_ELEMENTS`). Later remove `build_ci_payload`, `overlay_frame_sequence`, `OVERLAY_FRAME_SHAPE`, `compute_alert_fingerprint`, `update_snooze`, and the `PRIORITY_ALERT` import. +- `integrations/ci_status/main.py` — **modify.** Rewrite `run_once`'s draw path to the sequence+LED model; run the rotation even with no active run; drop the snooze subsystem (`snooze_state` param, the `get_busy()` poll, `suppress_alert`/`suppress_led`). +- `tests/test_ci_logic.py` — **modify.** Update evaluate/state tests to `FailingRun`; add ref tests; add overlay fail/stuck/green frame + sequence tests + LED-helper tests; later delete `build_ci_payload`/snooze tests. +- `tests/test_ci_loop.py` — **modify.** Update failure/green/rotation tests to overlay-tier + LED behavior; add "failure rotates with no run" + LED-lifecycle tests; later delete snooze/alert-tier tests. +- `config.example.toml` — **modify.** Remove the `snooze_minutes` block. +- `README.md` (root) — **modify.** Ladder table + escalation/snooze prose. +- `integrations/ci_status/README.md` — **modify.** Failure-frame model; drop snooze section; priority-tier section. +- `src/busybar/display.py` — **modify.** Docstring rationale for `PRIORITY_ALERT` and `PRIORITY_AMBIENT_URGENT` (no behavior change). + +--- + +## Task 1: Failure/stuck text carries the PR/branch ref + +Delivers Request A while the failure is still a priority-60 alert (behavior otherwise unchanged). `evaluate_runs` stops discarding the run dict; the badge text gains the ref. + +**Files:** +- Modify: `integrations/ci_status/logic.py` (`RepoState`, `evaluate_runs`, `build_ci_payload`; add `FailingRun`, `_fail_line`) +- Test: `tests/test_ci_logic.py` + +**Interfaces:** +- Produces: `FailingRun` dataclass with fields `workflow: str`, `ref: str`. `RepoState.failing: list[FailingRun]`, `RepoState.stuck: list[FailingRun]`. `_fail_line(repo: str, fr: FailingRun) -> str` returning `"{repo} {ref} · {workflow}"` (ref omitted when empty). + +- [ ] **Step 1: Write failing tests for `FailingRun` + enriched `evaluate_runs`** + +In `tests/test_ci_logic.py`, first extend the `run()` fixture to optionally carry PR/branch, then rewrite the two evaluate tests. Replace the existing `run(...)` helper (lines 23-27) with: + +```python +def run(workflow_id: int, name: str, status: str, conclusion: str | None, + created_min_ago: int = 5, pr_number: int | None = None, + head_branch: str | None = None) -> dict: + created = (NOW - timedelta(minutes=created_min_ago)).strftime("%Y-%m-%dT%H:%M:%SZ") + d = {"workflow_id": workflow_id, "name": name, "status": status, + "conclusion": conclusion, "created_at": created} + if pr_number is not None: + d["pull_requests"] = [{"number": pr_number}] + if head_branch is not None: + d["head_branch"] = head_branch + return d +``` + +Add `FailingRun` to the import block (line 6-16) and update the two tests: + +```python +def test_failure_detected_on_latest_run_only(): + runs = [run(1, "tests", "completed", "success"), + run(1, "tests", "completed", "failure", 60), + run(2, "lint", "completed", "failure", pr_number=42)] + state = evaluate_runs("o/r", runs, NOW, 0) + assert state.failing == [FailingRun("lint", "#42")] and state.stuck == [] + +def test_stuck_queued_detection_respects_threshold(): + runs = [run(1, "tests", "queued", None, created_min_ago=20, head_branch="main")] + assert evaluate_runs("o/r", runs, NOW, 15).stuck == [FailingRun("tests", "main")] + assert evaluate_runs("o/r", runs, NOW, 0).stuck == [] + assert evaluate_runs("o/r", runs, NOW, 30).stuck == [] + +def test_failing_run_ref_empty_when_no_pr_or_branch(): + state = evaluate_runs("o/r", [run(1, "tests", "completed", "failure")], NOW, 0) + assert state.failing == [FailingRun("tests", "")] + +def test_evaluate_sorts_failing_by_workflow_then_ref(): + runs = [run(2, "zeta", "completed", "failure", pr_number=9), + run(1, "alpha", "completed", "failure", pr_number=3)] + state = evaluate_runs("o/r", runs, NOW, 0) + assert state.failing == [FailingRun("alpha", "#3"), FailingRun("zeta", "#9")] +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_ci_logic.py -q -k "evaluate or failing_run_ref"` +Expected: FAIL — `FailingRun` undefined / `evaluate_runs` returns strings. + +- [ ] **Step 3: Add `FailingRun`, enrich `evaluate_runs`, add `_fail_line`** + +In `integrations/ci_status/logic.py`, change the `RepoState` dataclass (lines 38-42) and add `FailingRun` above it: + +```python +@dataclass(frozen=True) +class FailingRun: + workflow: str # r["name"] + ref: str # _pr_or_branch(r): "#42", the branch, or "" + + +@dataclass +class RepoState: + repo: str + failing: list[FailingRun] + stuck: list[FailingRun] +``` + +Rewrite `evaluate_runs` (lines 132-145). Note `_pr_or_branch` is defined later in the file (line ~236) — module-level function, so calling it here is fine at runtime: + +```python +def evaluate_runs(repo: str, runs: list[dict], now: datetime, + stale_queued_minutes: int) -> RepoState: + latest: dict[int, dict] = {} + for r in runs: # API returns newest first; keep first seen per workflow + latest.setdefault(r["workflow_id"], r) + failing, stuck = [], [] + for r in latest.values(): + if r.get("conclusion") in FAILING: + failing.append(FailingRun(r["name"], _pr_or_branch(r))) + elif r.get("status") == "queued" and stale_queued_minutes > 0: + age_min = (now - _parse_ts(r["created_at"])).total_seconds() / 60 + if age_min >= stale_queued_minutes: + stuck.append(FailingRun(r["name"], _pr_or_branch(r))) + key = lambda f: (f.workflow, f.ref) + return RepoState(repo=repo, failing=sorted(failing, key=key), + stuck=sorted(stuck, key=key)) +``` + +Add `_fail_line` next to `_badge_elements` (near line 681): + +```python +def _fail_line(repo: str, fr: FailingRun) -> str: + """"owner/repo #42 · workflow" (the ref is dropped when empty).""" + ref = f" {fr.ref}" if fr.ref else "" + return f"{repo}{ref} · {fr.workflow}" +``` + +- [ ] **Step 4: Update `build_ci_payload` to render the ref (still priority 60)** + +In `build_ci_payload` (lines 720-730), change the failure/stuck comprehensions to iterate `FailingRun` and use `_fail_line`: + +```python + failures = [(s.repo, fr) for s in states for fr in s.failing] + stuck = [(s.repo, fr) for s in states for fr in s.stuck] + if failures and not suppress_alert: + text = "CI FAIL " + " ".join(_fail_line(repo, fr) for repo, fr in failures) + led = None if suppress_led else "#FF0000FF" + return {"elements": _badge_elements(text, "#A32D2DFF", "#FFFFFFFF", timeout_s), + "priority": PRIORITY_ALERT, "led": led} + if stuck and not suppress_alert: + text = "CI stuck " + " ".join(_fail_line(repo, fr) for repo, fr in stuck) + return {"elements": _badge_elements(text, "#BA7517FF", "#0B0B0BFF", timeout_s), + "priority": PRIORITY_ALERT, "led": None} +``` + +- [ ] **Step 5: Update the existing `build_ci_payload` tests to `FailingRun`** + +In `tests/test_ci_logic.py`, update the state constructors and add a ref assertion: + +```python +def test_payload_red_badge_on_failure(): + payload = build_ci_payload([RepoState("o/r", [FailingRun("tests", "#42")], [])], False, 180) + assert payload["priority"] == PRIORITY_ALERT and payload["led"] == "#FF0000FF" + bg = _bg_element(payload["elements"]) + assert bg["fill_colors"] == ["#A32D2DFF"] and bg["border_width"] == 0 + text_el = _text_element(payload["elements"]) + assert "o/r" in text_el["text"] and "#42" in text_el["text"] and "tests" in text_el["text"] + assert text_el["color"] == "#FFFFFFFF" and text_el["font"] == "bold" + +def test_payload_amber_badge_on_stuck_only(): + payload = build_ci_payload([RepoState("o/r", [], [FailingRun("tests", "main")])], False, 180) + assert payload["led"] is None + assert _bg_element(payload["elements"])["fill_colors"] == ["#BA7517FF"] + text_el = _text_element(payload["elements"]) + assert "stuck" in text_el["text"] and "main" in text_el["text"] + +def test_failure_badge_takes_priority_over_stuck(): + payload = build_ci_payload([RepoState("o/r", [FailingRun("tests", "")], [FailingRun("lint", "")])], False, 180) + assert _bg_element(payload["elements"])["fill_colors"] == ["#A32D2DFF"] +``` + +`test_payload_none_when_green_and_quiet` and `test_payload_shows_green_glyph_when_enabled` use empty lists (`RepoState("o/r", [], [])`) and need no change. + +- [ ] **Step 6: Run the ci_logic tests to verify they pass** + +Run: `uv run pytest tests/test_ci_logic.py -q` +Expected: PASS. + +- [ ] **Step 7: Run the loop tests to confirm the alert path still works with the ref** + +The loop's `_run` fixture has no PR/branch, so failure text ref is empty — `test_draws_red_on_failure` still passes (priority 60, red LED). +Run: `uv run pytest tests/test_ci_loop.py -q` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add integrations/ci_status/logic.py tests/test_ci_logic.py +git commit -m "ci: carry PR/branch ref into failure and stuck badge text" +``` + +--- + +## Task 2: Overlay-tier fail/stuck/green frames + sequence builder + +Additive logic only — nothing consumes these yet. They render the same badges at `PRIORITY_OVERLAY` (21) and assemble the dynamic rotation order. + +**Files:** +- Modify: `integrations/ci_status/logic.py` (add frame-kind constants, extend `build_overlay_payload`, add `build_overlay_sequence`) +- Test: `tests/test_ci_logic.py` + +**Interfaces:** +- Consumes: `FailingRun`, `_fail_line`, `_badge_elements`, `_text_element` (Task 1 / existing). +- Produces: + - Constants `OVERLAY_FRAME_FAIL = "fail"`, `OVERLAY_FRAME_STUCK = "stuck"`, `OVERLAY_FRAME_GREEN = "green"` (alongside existing `OVERLAY_FRAME_CI_BADGE`/`_QUOTA_GQL`/`_QUOTA_REST`). + - `build_overlay_payload(descriptor: dict, timeout_s: int, *, running=None, quota_by_bucket=None, show_spinner=False) -> dict | None` — now dispatches on `descriptor["kind"]`. A descriptor is `{"kind": ...}` plus, for fail/stuck, `"repo": str` and `"run": FailingRun`. + - `build_overlay_sequence(states: list[RepoState], *, running_present: bool, quota_frames: list[str], show_green: bool) -> list[dict]` — ordered descriptors: all failures, then all stuck, then the CI badge (if `running_present`), then each quota frame in `quota_frames`, then a single green frame **only if the sequence is otherwise empty** and `show_green`. + +- [ ] **Step 1: Write failing tests for the frame builders + sequence** + +Add to `tests/test_ci_logic.py` (extend the import block with `OVERLAY_FRAME_FAIL, OVERLAY_FRAME_STUCK, OVERLAY_FRAME_GREEN, build_overlay_sequence`): + +```python +def test_fail_frame_is_red_badge_at_overlay_tier(): + d = {"kind": OVERLAY_FRAME_FAIL, "repo": "o/r", "run": FailingRun("tests", "#42")} + payload = build_overlay_payload(d, OVERLAY_DWELL_SECONDS) + assert payload["priority"] == PRIORITY_OVERLAY + assert _bg_element(payload["elements"])["fill_colors"] == ["#A32D2DFF"] + t = _text_element(payload["elements"]) + assert t["text"] == "CI FAIL o/r #42 · tests" and t["color"] == "#FFFFFFFF" + +def test_fail_frame_drops_ref_when_empty(): + d = {"kind": OVERLAY_FRAME_FAIL, "repo": "o/r", "run": FailingRun("tests", "")} + assert _text_element(build_overlay_payload(d, 10)["elements"])["text"] == "CI FAIL o/r · tests" + +def test_stuck_frame_is_amber_badge_at_overlay_tier(): + d = {"kind": OVERLAY_FRAME_STUCK, "repo": "o/r", "run": FailingRun("deploy", "#7")} + payload = build_overlay_payload(d, 10) + assert payload["priority"] == PRIORITY_OVERLAY + assert _bg_element(payload["elements"])["fill_colors"] == ["#BA7517FF"] + assert _text_element(payload["elements"])["text"] == "CI stuck o/r #7 · deploy" + +def test_green_frame_is_quiet_text_at_overlay_tier(): + payload = build_overlay_payload({"kind": OVERLAY_FRAME_GREEN}, 10) + assert payload["priority"] == PRIORITY_OVERLAY + assert _text_element(payload["elements"])["color"] == "#00FF00FF" + assert not any(e["type"] == "rectangle" for e in payload["elements"]) + +def test_sequence_orders_fail_then_stuck_then_badge_then_quota(): + states = [RepoState("o/r", [FailingRun("a", "#1")], [FailingRun("b", "#2")])] + seq = build_overlay_sequence(states, running_present=True, + quota_frames=[OVERLAY_FRAME_QUOTA_GQL], show_green=False) + assert [d["kind"] for d in seq] == [ + OVERLAY_FRAME_FAIL, OVERLAY_FRAME_STUCK, OVERLAY_FRAME_CI_BADGE, OVERLAY_FRAME_QUOTA_GQL] + assert seq[0]["run"] == FailingRun("a", "#1") and seq[0]["repo"] == "o/r" + +def test_sequence_one_frame_per_failing_run(): + states = [RepoState("o/r", [FailingRun("a", ""), FailingRun("b", "")], [])] + seq = build_overlay_sequence(states, running_present=False, quota_frames=[], show_green=False) + assert [d["kind"] for d in seq] == [OVERLAY_FRAME_FAIL, OVERLAY_FRAME_FAIL] + +def test_sequence_green_only_when_otherwise_empty(): + empty = [RepoState("o/r", [], [])] + assert [d["kind"] for d in build_overlay_sequence(empty, running_present=False, quota_frames=[], show_green=True)] == [OVERLAY_FRAME_GREEN] + # green suppressed when other content exists + busy = [RepoState("o/r", [FailingRun("a", "")], [])] + assert OVERLAY_FRAME_GREEN not in [d["kind"] for d in build_overlay_sequence(busy, running_present=False, quota_frames=[], show_green=True)] + +def test_sequence_empty_when_nothing_and_green_off(): + assert build_overlay_sequence([RepoState("o/r", [], [])], running_present=False, quota_frames=[], show_green=False) == [] +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest tests/test_ci_logic.py -q -k "frame or sequence"` +Expected: FAIL — new constants / `build_overlay_sequence` undefined, `build_overlay_payload` doesn't accept a dict. + +- [ ] **Step 3: Add constants and extend `build_overlay_payload`** + +In `integrations/ci_status/logic.py`, add near the existing frame constants (line 606-608): + +```python +OVERLAY_FRAME_FAIL = "fail" +OVERLAY_FRAME_STUCK = "stuck" +OVERLAY_FRAME_GREEN = "green" +``` + +Rewrite `build_overlay_payload` (lines 642-671) to dispatch on a descriptor dict. Keep the existing CI-badge / quota bodies; add fail/stuck/green. All frames set `"led": None` — the LED is decided centrally in `main.run_once` (Task 3/4): + +```python +def build_overlay_payload(descriptor: dict, timeout_s: int, *, + running: "RunningInfo | None" = None, + quota_by_bucket: dict[str, "QuotaInfo"] | None = None, + show_spinner: bool = False) -> dict | None: + """Build the {"elements", "priority", "led"} payload for one overlay-tier + dwell slot from a frame descriptor ({"kind": ...} plus kind-specific + fields). Returns None only if a frame's data isn't available this cycle; + build_overlay_sequence never emits a descriptor whose data is missing, so + in practice callers get a payload. `led` is always None here -- the + failure-driven LED is resolved by the caller (see resolve_ci_led_value).""" + kind = descriptor["kind"] + if kind == OVERLAY_FRAME_CI_BADGE: + if running is None: + return None + return {"elements": _build_running_elements(running, timeout_s, show_spinner=show_spinner), + "priority": PRIORITY_OVERLAY, "led": None} + if kind in (OVERLAY_FRAME_QUOTA_GQL, OVERLAY_FRAME_QUOTA_REST): + bucket_key = "graphql" if kind == OVERLAY_FRAME_QUOTA_GQL else "core" + info = (quota_by_bucket or {}).get(bucket_key) + if info is None: + return None + return {"elements": _build_quota_elements(info, timeout_s), + "priority": PRIORITY_OVERLAY, "led": None} + if kind == OVERLAY_FRAME_FAIL: + text = "CI FAIL " + _fail_line(descriptor["repo"], descriptor["run"]) + return {"elements": _badge_elements(text, "#A32D2DFF", "#FFFFFFFF", timeout_s), + "priority": PRIORITY_OVERLAY, "led": None} + if kind == OVERLAY_FRAME_STUCK: + text = "CI stuck " + _fail_line(descriptor["repo"], descriptor["run"]) + return {"elements": _badge_elements(text, "#BA7517FF", "#0B0B0BFF", timeout_s), + "priority": PRIORITY_OVERLAY, "led": None} + if kind == OVERLAY_FRAME_GREEN: + return {"elements": [_text_element("CI ok", "#00FF00FF", timeout_s)], + "priority": PRIORITY_OVERLAY, "led": None} + return None +``` + +- [ ] **Step 4: Add `build_overlay_sequence`** + +Add below `build_overlay_payload`: + +```python +def build_overlay_sequence(states: list[RepoState], *, running_present: bool, + quota_frames: list[str], show_green: bool) -> list[dict]: + """The ordered overlay-tier rotation for this poll: one frame per failing + run, then one per stuck run, then the running CI badge (if a run is + active), then each available quota frame, then a single quiet-green frame + ONLY when nothing else is present and show_green is on. Each fail/stuck + descriptor carries its repo and FailingRun so the renderer needs no extra + lookup.""" + seq: list[dict] = [] + for s in states: + for fr in s.failing: + seq.append({"kind": OVERLAY_FRAME_FAIL, "repo": s.repo, "run": fr}) + for s in states: + for fr in s.stuck: + seq.append({"kind": OVERLAY_FRAME_STUCK, "repo": s.repo, "run": fr}) + if running_present: + seq.append({"kind": OVERLAY_FRAME_CI_BADGE}) + for frame in quota_frames: + seq.append({"kind": frame}) + if not seq and show_green: + seq.append({"kind": OVERLAY_FRAME_GREEN}) + return seq +``` + +- [ ] **Step 5: Migrate existing direct `build_overlay_payload` call sites to the descriptor form** + +`tests/test_ci_logic.py` already has ~16 direct calls that pass a **string frame name** as the first positional arg (the running-badge and quota tests). The new signature takes a **descriptor dict**, so wrap each frame name in `{"kind": ...}`. Apply this transform to every direct call site (running-badge tests around lines 278, 317, 325, 330, 462, 468, 599, 609, 840, 849; quota tests around lines 338, 358, 365, 367, 371, 475): + +``` +build_overlay_payload(OVERLAY_FRAME_CI_BADGE, → build_overlay_payload({"kind": OVERLAY_FRAME_CI_BADGE}, +build_overlay_payload(OVERLAY_FRAME_QUOTA_GQL, → build_overlay_payload({"kind": OVERLAY_FRAME_QUOTA_GQL}, +build_overlay_payload(OVERLAY_FRAME_QUOTA_REST,→ build_overlay_payload({"kind": OVERLAY_FRAME_QUOTA_REST}, +``` + +Semantics are unchanged (e.g. the `running=None → None` case at line 330 still returns `None`). Do **not** touch `overlay_frame_sequence`/`OVERLAY_FRAME_SHAPE` here — those are removed in Task 5. + +- [ ] **Step 6: Run to verify pass** + +Run: `uv run pytest tests/test_ci_logic.py -q` +Expected: PASS (new frame/sequence tests plus every migrated running-badge/quota call site). + +- [ ] **Step 7: Commit** + +```bash +git add integrations/ci_status/logic.py tests/test_ci_logic.py +git commit -m "ci: add overlay-tier fail/stuck/green frames and dynamic sequence builder" +``` + +--- + +## Task 3: Failure-driven LED lifecycle helper + +Mirror `calendar_countdown`'s LED discipline so the red LED stays lit while any failure exists and turns off (explicitly) when it clears — decoupled from which frame is on screen. + +**Files:** +- Modify: `integrations/ci_status/logic.py` (add LED constants + `resolve_ci_led_value`) +- Test: `tests/test_ci_logic.py` + +**Interfaces:** +- Produces: `CI_LED_COLOR = "#FF0000FF"`, `LED_OFF_COLOR = "#00000000"`, `LED_OFF_ELEMENTS: list[dict]` (a single 1×1 transparent, 5 s self-expiring element), and `resolve_ci_led_value(led_should_be_on: bool, led_was_on: bool) -> str | None`. + +- [ ] **Step 1: Write failing tests** + +Add to `tests/test_ci_logic.py` (import `resolve_ci_led_value, CI_LED_COLOR, LED_OFF_COLOR, LED_OFF_ELEMENTS`): + +```python +def test_led_value_red_while_failing(): + assert resolve_ci_led_value(True, False) == CI_LED_COLOR + assert resolve_ci_led_value(True, True) == CI_LED_COLOR + +def test_led_value_explicit_off_on_transition(): + assert resolve_ci_led_value(False, True) == LED_OFF_COLOR + +def test_led_value_omitted_once_already_off(): + assert resolve_ci_led_value(False, False) is None + +def test_led_off_elements_is_single_expiring_placeholder(): + assert len(LED_OFF_ELEMENTS) == 1 + el = LED_OFF_ELEMENTS[0] + assert el["type"] == "rectangle" and el["width"] == 1 and el["height"] == 1 + assert el["fill_colors"] == ["#00000000"] and el["timeout"] == 5 +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest tests/test_ci_logic.py -q -k led` +Expected: FAIL — names undefined. + +- [ ] **Step 3: Implement the LED helper** + +Add to `integrations/ci_status/logic.py` (near the top of the overlay section, after the frame constants): + +```python +CI_LED_COLOR = "#FF0000FF" +LED_OFF_COLOR = "#00000000" +# ^ Explicit LED-off (zero alpha). Whether omitting led_notification_color +# turns a lit LED off is not observable through this device's API, so the +# on->off transition sends this value explicitly -- same hypothesis-agnostic +# choice calendar_countdown makes (see its resolve_led_value / LED_OFF_COLOR). +LED_OFF_ELEMENTS = [{ + "id": "ci_led_off_flush", "type": "rectangle", "x": 0, "y": 0, + "width": 1, "height": 1, "fill": "solid", "fill_colors": ["#00000000"], + "border_width": 0, "timeout": 5, +}] +# ^ Minimal 1x1 transparent self-expiring element -- the draw endpoint requires +# >=1 element, so a bare led_notification_color with no element is impossible. +# Used on the "nothing else to draw but the LED must go off" path (main.run_once). + + +def resolve_ci_led_value(led_should_be_on: bool, led_was_on: bool) -> str | None: + """The led_notification_color to send THIS poll: CI_LED_COLOR while any + failure exists; LED_OFF_COLOR (explicit) on the exact failing->clear poll; + None (omit) once already off. main.run_once tracks `led_was_on` in its + caller-owned overlay_state, committed only after a confirmed DRAWN send.""" + if led_should_be_on: + return CI_LED_COLOR + if led_was_on: + return LED_OFF_COLOR + return None +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `uv run pytest tests/test_ci_logic.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add integrations/ci_status/logic.py tests/test_ci_logic.py +git commit -m "ci: add failure-driven LED lifecycle helper (mirrors calendar)" +``` + +--- + +## Task 4: Rewire `run_once` — calm rotation + gentle LED, retire snooze + +The core change (Request B). `run_once` now drives the dwell/silence rotation from `build_overlay_sequence` (so failures rotate even with no running job), centralizes the LED via `resolve_ci_led_value`, and drops the snooze subsystem. + +**Files:** +- Modify: `integrations/ci_status/main.py` (`run_once`, `main`; imports) +- Test: `tests/test_ci_loop.py` + +**Interfaces:** +- Consumes: `evaluate_runs`, `select_running_run`, `build_overlay_sequence`, `build_overlay_payload`, `resolve_ci_led_value`, `LED_OFF_COLOR`, `LED_OFF_ELEMENTS`, `OVERLAY_FRAME_QUOTA_GQL`, `OVERLAY_FRAME_QUOTA_REST` (logic); `PRIORITY_OVERLAY`, `OVERLAY_DWELL_SECONDS`, `overlay_gap_elapsed` (display). +- Produces: `run_once(client, poller, cfg, now, state_cache, dry_run, running_cache=None, overlay_state=None, quota_cache=None, repo_cache=None) -> str` — **`snooze_state` parameter removed.** `overlay_state` gains a `"led_was_on": bool` key. + +- [ ] **Step 1: Write failing loop tests for the new behavior** + +In `tests/test_ci_loop.py`, update `_run` is unchanged. Change `test_draws_red_on_failure` to expect the overlay tier, and add new tests. (Import `FailingRun` and `RepoState` are already imported for `RepoState`; add `FailingRun`.) + +```python +def test_draws_red_on_failure(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock(); poller.fetch_runs.return_value = [_run("failure")] + summary = run_once(client, poller, CFG, NOW, {}, dry_run=False) + assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY # 21, not 60 + assert client.draw.call_args.kwargs["led_notification_color"] == "#FF0000FF" + assert "FAIL" in summary + +def test_failure_rotates_with_no_running_job(): + # No running_cache at all: a failure must still draw (Request B) -- the old + # code drew a priority-60 alert here; now it's an overlay-tier frame. + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock(); poller.fetch_runs.return_value = [_run("failure")] + run_once(client, poller, CFG, NOW, {}, dry_run=False, overlay_state={}) + assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY + +def test_failure_frame_respects_dwell_silence(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock(); poller.fetch_runs.return_value = [_run("failure")] + overlay_state: dict = {} + run_once(client, poller, CFG, NOW, {}, dry_run=False, overlay_state=overlay_state) + client.draw.assert_called_once() + client.reset_mock() + soon = NOW + timedelta(seconds=OVERLAY_DWELL_SECONDS - 1) + run_once(client, poller, CFG, soon, {}, dry_run=False, overlay_state=overlay_state) + client.draw.assert_not_called(); client.clear.assert_not_called() + +def test_led_turns_off_explicitly_when_failure_clears(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock() + overlay_state: dict = {} + # Poll 1: failing -> LED red, led_was_on committed True + poller.fetch_runs.return_value = [_run("failure")] + run_once(client, poller, CFG, NOW, {}, dry_run=False, overlay_state=overlay_state) + assert overlay_state["led_was_on"] is True + client.reset_mock() + # Poll 2: now green, nothing else to draw -> explicit off via LED_OFF_ELEMENTS + poller.fetch_runs.return_value = [_run("success")] + later = NOW + timedelta(seconds=OVERLAY_DWELL_SECONDS + 1) + run_once(client, poller, CFG, later, {}, dry_run=False, overlay_state=overlay_state) + assert client.draw.call_args.kwargs["led_notification_color"] == "#00000000" + assert overlay_state["led_was_on"] is False + +def test_led_stays_off_omitted_when_already_clear(): + client = Mock() + poller = Mock(); poller.fetch_runs.return_value = [_run("success")] + run_once(client, poller, CFG, NOW, {}, dry_run=False, overlay_state={}) + client.clear.assert_called_once_with("ci_status") # nothing to draw, LED never was on + client.draw.assert_not_called() + +def test_green_folds_into_rotation_at_overlay_tier(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock(); poller.fetch_runs.return_value = [_run("success")] + cfg = {"ci_status": {**CFG["ci_status"], "show_green": True}} + run_once(client, poller, cfg, NOW, {}, dry_run=False, overlay_state={}) + assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY + assert "ok" in _drawn_text(client) +``` + +Add a tiny helper near the top of the test module (after `_run`): + +```python +def _drawn_text(client) -> str: + els = client.draw.call_args.kwargs.get("elements") or client.draw.call_args.args[1] + return next(e["text"] for e in els if e.get("type") == "text") +``` + +Note `client.draw` is called positionally as `client.draw(APP, payload["elements"], priority=..., led_notification_color=...)`, so `elements` is `call_args.args[1]`; `_drawn_text` handles both. + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest tests/test_ci_loop.py -q -k "failure or led or green_folds"` +Expected: FAIL — failure still draws at 60 / `snooze_state`-free path not yet implemented / `led_was_on` absent. + +- [ ] **Step 3: Rewrite `run_once`'s draw path** + +In `integrations/ci_status/main.py`, update imports (lines 16-23): drop `compute_alert_fingerprint`, `update_snooze`, `build_ci_payload`, `overlay_frame_sequence` from the logic import; add `build_overlay_sequence`, `resolve_ci_led_value`, `LED_OFF_COLOR`, `LED_OFF_ELEMENTS`, `OVERLAY_FRAME_QUOTA_GQL`, `OVERLAY_FRAME_QUOTA_REST`. Keep `RepoState, RunningInfo, QuotaInfo, build_overlay_payload, evaluate_runs, parse_rate_limit, resolve_repo_list, select_running_run`. + +Change the `run_once` signature (line 86-92) to drop `snooze_state`: + +```python +def run_once(client, poller, cfg: dict, now: datetime, + state_cache: dict[str, RepoState], dry_run: bool, + running_cache: dict[str, list[dict]] | None = None, + overlay_state: dict | None = None, + quota_cache: dict | None = None, + repo_cache: dict | None = None) -> str: +``` + +Keep the repo-resolution + pruning + `evaluate_runs` block (lines 162-191) unchanged. Then **replace everything from line 192 (`has_alert = ...`) through the end of the function** with: + +```python + states = list(state_cache.values()) + + # Running detection (unchanged gating): a live run enables the CI badge and, + # with show_quota, the quota frames. Independent of failures, which rotate + # regardless of whether anything is running. + running_info = None + running_present = False + quota_by_bucket = None + quota_frames: list[str] = [] + if running_cache is not None and c["show_running"]: + for repo in effective_repos: + running_runs = poller.fetch_running_runs(repo) + if running_runs is not None: + running_cache[repo] = running_runs + selected = select_running_run(running_cache) + if selected is not None: + run, repo, other_count = selected + median = poller.fetch_median_eta(repo, run["workflow_id"]) + running_info = RunningInfo(run=run, repo=repo, other_count=other_count, + median_minutes=median, now=now) + running_present = True + if c["show_quota"]: + quota_by_bucket = _refresh_quota(poller, quota_cache, now) + if quota_by_bucket: + if "graphql" in quota_by_bucket: + quota_frames.append(OVERLAY_FRAME_QUOTA_GQL) + if "core" in quota_by_bucket: + quota_frames.append(OVERLAY_FRAME_QUOTA_REST) + + sequence = build_overlay_sequence(states, running_present=running_present, + quota_frames=quota_frames, show_green=c["show_green"]) + + # Failure-driven LED (Request B keeps a gentle cue). led_should_be_on is + # driven by FAILURES only -- stuck keeps its historical LED-None behavior. + led_should_be_on = any(s.failing for s in states) + led_was_on = bool(overlay_state.get("led_was_on")) if overlay_state is not None else False + led_value = resolve_ci_led_value(led_should_be_on, led_was_on) + + # Empty sequence -> nothing to show. Honor an explicit LED-off transition + # (there is no failure now, so led_value is either LED_OFF_COLOR or None). + if not sequence: + if overlay_state is not None: + overlay_state.pop("frame_index", None) + overlay_state.pop("last_dwell_end", None) + if dry_run: + return "DRY-RUN: nothing to show" + if led_value == LED_OFF_COLOR: + result = client.draw(APP, LED_OFF_ELEMENTS, priority=PRIORITY_OVERLAY, + led_notification_color=LED_OFF_COLOR) + if result == DrawResult.DRAWN and overlay_state is not None: + overlay_state["led_was_on"] = False + overlay_state["last_shape"] = frozenset(e["id"] for e in LED_OFF_ELEMENTS) + return f"led off; {result.value}" + client.clear(APP) + if overlay_state is not None: + overlay_state["last_shape"] = None + return "nothing to show; cleared" + + # Dwell gate: one frame per dwell, then silent one dwell so the ambient + # calendar can reclaim the gap. frame_index/last_dwell_end commit only on DRAWN. + seq_len = len(sequence) + frame_index = (overlay_state.get("frame_index", 0) if overlay_state is not None else 0) % seq_len + last_dwell_end = overlay_state.get("last_dwell_end") if overlay_state is not None else None + if overlay_gap_elapsed(last_dwell_end, now) < OVERLAY_DWELL_SECONDS: + return "overlay dwell gap; staying silent (letting the ambient app reclaim the screen)" + + payload = build_overlay_payload(sequence[frame_index], OVERLAY_DWELL_SECONDS, + running=running_info, quota_by_bucket=quota_by_bucket, + show_spinner=c.get("running_spinner", False)) + + if dry_run: + return f"DRY-RUN payload: {payload!r} led={led_value}" + + # Unified shape-clear gate (see the original docstring): the firmware upserts + # by element id within an application_name, so a shape change needs a clear + # first. Spans every frame kind that can draw here. + shape = frozenset(e["id"] for e in payload["elements"]) + if overlay_state is not None: + last_shape = overlay_state.get("last_shape") + if last_shape is not None and last_shape != shape: + client.clear(APP) + + result = client.draw(APP, payload["elements"], priority=payload["priority"], + led_notification_color=led_value) + if result == DrawResult.DRAWN and overlay_state is not None: + overlay_state["last_shape"] = shape + overlay_state["led_was_on"] = led_should_be_on + overlay_state["frame_index"] = frame_index + 1 + overlay_state["last_dwell_end"] = now + timedelta(seconds=OVERLAY_DWELL_SECONDS) + + text = next(e["text"] for e in payload["elements"] if e["type"] == "text") + return f"{text[:40]!r} -> {result.value}" +``` + +Update `run_once`'s docstring: remove the "Alert snooze" paragraph; note that the rotation now includes failure/stuck/green frames and runs even without an active run, and that `overlay_state["led_was_on"]` tracks the failure LED. + +- [ ] **Step 4: Drop snooze from `main()`** + +In `main()` (lines 373-384) remove the `snooze_state: dict = {}` line and the `snooze_state=snooze_state` kwarg from the `run_once` call. + +- [ ] **Step 5: Update the loop tests that assumed the alert tier / snooze** + +Update these existing tests in `tests/test_ci_loop.py` (do **not** delete the snooze block yet — that's Task 5): +- `test_304_keeps_previous_state`, `test_dry_run_touches_nothing`: unchanged assertions still hold (draw once / DRY-RUN, no priority assumption). Leave as-is. +- `test_overlay_then_alert_clears_stale_overlay_shape` and `test_alert_then_overlay_clears_stale_alert_shape`: these drive a failure via `state_cache` seeded with a failing run and assert a `clear()` precedes the shape change. They still exercise a real fail↔running seam; keep them but pass `overlay_state` and expect the failure frame at `PRIORITY_OVERLAY`. If they assert priority 60 anywhere, change to `PRIORITY_OVERLAY`. +- `test_quiet_green_then_overlay_clears_stale_green_shape`: green now draws at the overlay tier; the shape-clear assertion is unchanged. Verify it still passes; adjust any priority assertion to `PRIORITY_OVERLAY`. + +Run the seam tests: `uv run pytest tests/test_ci_loop.py -q -k "clears_stale or shape"` and fix any priority/led assertions to the overlay-tier values. + +- [ ] **Step 6: Run to verify pass (new + adapted tests; snooze tests still present)** + +Run: `uv run pytest tests/test_ci_loop.py -q -k "not snooze"` +Expected: PASS. (Snooze tests still import a removed `snooze_state` path and will error — they are deleted in Task 5. Run with `-k "not snooze"` here.) + +- [ ] **Step 7: Commit** + +```bash +git add integrations/ci_status/main.py tests/test_ci_loop.py +git commit -m "ci: rotate failure/stuck/green at overlay tier with gentle LED; drop snooze from run_once" +``` + +--- + +## Task 5: Delete dead code + obsolete tests + +Remove the now-unreachable alert-tier and snooze code and their tests, so the module has one model. + +**Files:** +- Modify: `integrations/ci_status/logic.py` (remove `build_ci_payload`, `overlay_frame_sequence`, `OVERLAY_FRAME_SHAPE`, `compute_alert_fingerprint`, `update_snooze`, the `PRIORITY_ALERT` import, and the snooze narrative comment block) +- Modify: `tests/test_ci_logic.py`, `tests/test_ci_loop.py` (delete obsolete tests + imports) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `ci_status.logic` no longer exports `build_ci_payload`, `overlay_frame_sequence`, `OVERLAY_FRAME_SHAPE`, `compute_alert_fingerprint`, `update_snooze`. + +- [ ] **Step 1: Delete obsolete tests first (so the suite defines the target surface)** + +In `tests/test_ci_logic.py`: +- Remove `build_ci_payload`, `compute_alert_fingerprint`, `update_snooze`, `overlay_frame_sequence`, `OVERLAY_FRAME_SHAPE`, `PRIORITY_ALERT` from the imports (lines 6-17). +- Delete the `build_ci_payload` tests: `test_payload_none_when_green_and_quiet`, `test_payload_shows_green_glyph_when_enabled`, `test_payload_red_badge_on_failure`, `test_payload_amber_badge_on_stuck_only`, `test_failure_badge_takes_priority_over_stuck`, and the entire `# --- build_ci_payload: overlay precedence ---` section (starting ~line 459). Their coverage now lives in Task 2's `build_overlay_payload`/`build_overlay_sequence` tests. +- Delete the `overlay_frame_sequence`/`OVERLAY_FRAME_SHAPE` tests (the `# --- overlay_frame_sequence ---` section ~lines 441-456): `test_overlay_frame_sequence_badge_only_when_quota_disabled`, `test_overlay_frame_sequence_includes_quota_frames_when_enabled`, `test_overlay_frame_shape_distinguishes_badge_from_quota`. Their replacement is Task 2's `build_overlay_sequence` tests. +- Delete the entire snooze section (every `update_snooze`/`compute_alert_fingerprint` test). + +In `tests/test_ci_loop.py`: +- Delete the snooze section (from the `--- alert snooze ...` comment / `CFG_SNOOZE` through the last `test_snooze_*`), including the `_busy` helper and `CFG_SNOOZE`. +- Delete `test_alert_rejected_during_calendar_elevation_does_not_commit_then_recovers` (alert-tier-specific; the overlay-tier equivalent `test_overlay_dwell_rejected_during_calendar_elevation_resumes_after` remains and now covers failures too). + +- [ ] **Step 2: Run to verify the deletions fail against still-present code** + +Run: `uv run pytest tests/test_ci_logic.py tests/test_ci_loop.py -q` +Expected: PASS (nothing references the doomed symbols anymore). If any collection error, it's a missed import — fix it. + +- [ ] **Step 3: Remove the dead logic** + +In `integrations/ci_status/logic.py`: +- Change the import (line 33) from `from busybar.display import PRIORITY_OVERLAY, PRIORITY_ALERT` to `from busybar.display import PRIORITY_OVERLAY`. +- Delete `build_ci_payload` (the whole function, ~lines 691-736) and its precedence docstring. +- Delete `overlay_frame_sequence` (lines 632-639) and the `OVERLAY_FRAME_SHAPE` dict + its reference comment (lines 610-629). +- Delete `compute_alert_fingerprint` and `update_snooze` and the `--- alert snooze ...` narrative comment block (from ~line 739 to the end of the snooze section). + +- [ ] **Step 4: Run the full ci suite + a grep guard** + +Run: `uv run pytest tests/test_ci_logic.py tests/test_ci_loop.py -q` +Expected: PASS. +Run: `grep -rnE "build_ci_payload|update_snooze|compute_alert_fingerprint|overlay_frame_sequence|OVERLAY_FRAME_SHAPE|PRIORITY_ALERT" integrations/ci_status tests` +Expected: no matches (empty output). + +- [ ] **Step 5: Commit** + +```bash +git add integrations/ci_status/logic.py tests/test_ci_logic.py tests/test_ci_loop.py +git commit -m "ci: remove dead alert-tier payload and snooze subsystem" +``` + +--- + +## Task 6: Config + docs + version label + +Remove the snooze config and refresh the docs to the calm-rotation model. + +**Files:** +- Modify: `config.example.toml`, `README.md`, `integrations/ci_status/README.md`, `src/busybar/display.py` + +**Interfaces:** none (docs/config only). + +- [ ] **Step 1: Remove `snooze_minutes` from `config.example.toml`** + +Delete the snooze comment + key (lines 74-79: the `# Alert snooze ...` block and `snooze_minutes = 30`). + +- [ ] **Step 2: Update the root `README.md`** + +- Ladder table (line 56): change the `60 | PRIORITY_ALERT` row to note it is **reserved/unused** now, and update the `21 | PRIORITY_OVERLAY` row to read: `ci_status`'s rotation — running badge, GraphQL/REST quota gauges, **and failure/stuck/quiet-green frames**. +- Replace the **"Escalation beats alerts."** paragraph (line 64): `ci_status` no longer draws at `PRIORITY_ALERT`; failure/stuck now rotate at `PRIORITY_OVERLAY` (21) under the calendar's ambient tiers, so an imminent event naturally outranks them and the failure alternates with the calendar rather than camping the panel. +- Delete the **"Snooze by acknowledgment."** paragraph (line 66). + +- [ ] **Step 3: Update `integrations/ci_status/README.md`** + +- Rewrite the "What It Does" failure/stuck sentence (line 5): a failure/stuck now shows as a **calm rotation frame at the overlay tier** reading `CI FAIL owner/repo #42 · workflow` (PR number, or the branch when there's no PR), alternating with the calendar, running badge, and quota — not a priority-60 takeover. A gentle red LED stays lit while anything is failing. +- Remove the `snooze_minutes` row from the Config Reference table (line 91). +- Delete the entire **"## Snoozing alerts"** section (lines 103-113). +- Update the **"## Display Priority Tiers"** section (lines 115-143): failure/stuck/green draw at `PRIORITY_OVERLAY` (21) in the rotation; there is no longer an alert-tier preemption or a `build_ci_payload` precedence chain. Update the account-wide privacy note (line 97) `repo:workflow` phrasing to `repo #PR · workflow`. + +- [ ] **Step 4: Update `src/busybar/display.py` docstrings (no behavior change)** + +- `PRIORITY_ALERT` docstring (line 119): note no in-repo integration currently draws here — it remains defined as the ladder's alert slot for reference (ci_status moved its failure/stuck frames down to `PRIORITY_OVERLAY`). +- `PRIORITY_AMBIENT_URGENT` docstring (line 127) and `PRIORITY_AMBIENT_RAISED` (line 102): soften the "a persistent CI failure alert was permanently evicting the calendar" rationale to past tense / historical, since ci_status no longer camps at 60. Keep the tiers and numbers exactly as they are. + +- [ ] **Step 5: Version label** + +The spec labels this **v1.7**. Update the ci_status README's status/feature references that carry a version label to v1.7 where such labels appear (do **not** change `pyproject.toml` `version`). Update the spec doc's Status line if desired. + +- [ ] **Step 6: Commit** + +```bash +git add config.example.toml README.md integrations/ci_status/README.md src/busybar/display.py docs/superpowers/specs/2026-08-08-ci-failure-rotation-frame-design.md +git commit -m "docs+config: calm-rotation CI failure model; drop snooze; ladder/tier updates" +``` + +--- + +## Task 7: Full-suite verification + +**Files:** none (verification). + +- [ ] **Step 1: Run the entire test suite** + +Run: `uv run pytest -q` +Expected: PASS (all modules — calendar, nyan, client, config, display, ci). + +- [ ] **Step 2: Lint / import sanity** + +Run: `uv run python -c "import sys; sys.path.insert(0,'integrations'); import ci_status.logic, ci_status.main; print('import ok')"` +Expected: `import ok` (no ImportError from removed symbols). + +- [ ] **Step 3: Dry-run the integration end-to-end** + +Run: `cd integrations && uv run python -m ci_status.main --once --dry-run` +Expected: a `DRY-RUN ...` summary line, no traceback. (Real device/GitHub not required for `--dry-run`.) + +- [ ] **Step 4: Record the on-device verification checklist (not automatable here)** + +The following require the physical device (fw 1.1.1) and are called out in the spec §9 — note them in the PR description for the operator to confirm: (a) a failure frame alternates with the calendar and running badge with visible dwell gaps; (b) the red LED stays lit across a non-failure frame while a failure coexists, and turns off within one dwell after the failure clears; (c) an imminent calendar event still outranks the failure frame. + +- [ ] **Step 5: Commit any final fixes (if Steps 1-3 surfaced issues)** + +```bash +git add -A +git commit -m "ci: fixups from full-suite verification" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Request A (repo + PR): Task 1 (`FailingRun` + `evaluate_runs` + `_fail_line`), rendered by Task 2 frames. ✓ +- Request B (calm rotation, no takeover): Task 2 (overlay frames + sequence) + Task 4 (`run_once` rewrite, rotation runs with no active run). ✓ +- Gentle LED: Task 3 (helper) + Task 4 (central LED, off-transition + `LED_OFF_ELEMENTS`). ✓ +- Move both failure + stuck: sequence includes both (Task 2); LED is failure-only per spec §4c. ✓ +- One frame per failing run: `build_overlay_sequence` emits one descriptor per `FailingRun` (Task 2, `test_sequence_one_frame_per_failing_run`). ✓ +- Green folded into rotation: Task 2 green frame + Task 4 (`test_green_folds_into_rotation_at_overlay_tier`). ✓ +- Retire snooze: Task 4 (main/run_once) + Task 5 (logic + tests). ✓ +- Config/docs/ripple: Task 6. ✓ +- Testing (spec §7): ref rendering (T1), no-active-run rotation (T4), dwell/silence (T4), green fold (T4), LED lifecycle (T3+T4), shape-clear seams (T4 adapted), snooze removed (T5). ✓ + +**Placeholder scan:** No TBD/TODO; every code step carries real code. The on-device checks (Task 7 Step 4) are explicitly non-automatable and documented, not a placeholder. ✓ + +**Type consistency:** `FailingRun(workflow, ref)` used identically in T1/T2/T4. `build_overlay_payload(descriptor: dict, ...)` (T2) matches its `run_once` call site (T4). `build_overlay_sequence(states, *, running_present, quota_frames, show_green)` signature matches T4's call. `resolve_ci_led_value(led_should_be_on, led_was_on)` and `overlay_state["led_was_on"]` consistent across T3/T4. `run_once` loses `snooze_state` in T4 and no later task references it. ✓ diff --git a/docs/superpowers/specs/2026-08-08-ci-failure-rotation-frame-design.md b/docs/superpowers/specs/2026-08-08-ci-failure-rotation-frame-design.md new file mode 100644 index 0000000..7a4b08c --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-ci-failure-rotation-frame-design.md @@ -0,0 +1,252 @@ +# CI Failure as a Calm Rotation Frame — Design Spec + +**Date:** 2026-08-08 +**Status:** approved design; not yet on-device verified (see §9) +**Depends on:** `busybar.display` priority ladder, `src/busybar/client.py`, the existing `ci_status` overlay-rotation loop and `calendar_countdown`'s LED lifecycle (as a pattern to mirror) + +--- + +## 1. Goal + +Make the CI **failure** (and stuck-queue) screen do two things it doesn't today: + +1. **Say what actually failed.** Show the **repo and PR number** (falling back to + the branch when a run has no PR), not just the workflow name — so a glance + tells you where to look. +2. **Stop hijacking the panel.** Instead of a persistent priority-60 takeover + that evicts the calendar, a failure becomes one **calm frame in the existing + overlay rotation**, alternating with the upcoming calendar event, the running + badge, and the quota gauges. + +A **gentle red LED stays lit** while anything is failing, so the loss of the +screen takeover doesn't mean the failure goes unnoticed. + +## 2. Scope & non-goals + +**In scope** +- `integrations/ci_status/logic.py`: enrich failure/stuck rendering with the + PR/branch ref; demote failure/stuck/green from `PRIORITY_ALERT` (60) to the + overlay tier (21); generalize the overlay-frame sequence to include failure, + stuck, and green frames; add the LED-lifecycle helper. +- `integrations/ci_status/main.py`: restructure `run_once` so the overlay + rotation runs whenever there is *any* content (a failure, stuck, running run, + quota, or green), not only while a job is running; retire the snooze + subsystem and its per-poll `get_busy()` call. +- Config: drop `snooze_minutes`. +- Docs: root `README.md` ladder table, `integrations/ci_status/README.md`, and + the now-stale rationale comment on `PRIORITY_AMBIENT_URGENT` in + `src/busybar/display.py`. +- Tests: rework the ci_status logic/main tests to the new frame model; delete + the snooze tests; add ref-rendering, no-active-run rotation, dwell/silence, + and LED on→off coverage. + +**Non-goals** +- **No calendar behavior change.** `calendar_countdown`'s tiers, LED, and chirp + are untouched. Its `PRIORITY_AMBIENT_URGENT` (65) still outranks everything + ci_status draws; only the *reason* that tier was originally introduced (a CI + failure camping the calendar) goes away — a doc-comment update, not a code + change. +- **No new priority tier.** Failure/stuck reuse `PRIORITY_OVERLAY` (21). + `PRIORITY_ALERT` (60) simply becomes unused by ci_status; it stays defined as + ladder documentation. +- **No polling-cadence change.** `next_poll_seconds` still switches on *running* + runs only; a failure does not shorten the poll interval. +- **No new dependencies.** + +## 3. Background: how a failure behaves today + +`evaluate_runs` ([`logic.py:132`](../../../integrations/ci_status/logic.py)) reduces +each failing run to its workflow **name** (`r["name"]`) and throws the rest of +the run dict away — including the `pull_requests` array and `head_branch` that +`_pr_or_branch` ([`logic.py:236`](../../../integrations/ci_status/logic.py)) already +knows how to turn into `#42`-or-branch for the running badge. + +`build_ci_payload` ([`logic.py:691`](../../../integrations/ci_status/logic.py)) then +renders a full-panel badge at **`PRIORITY_ALERT` (60)** with a red LED: + +``` +CI FAIL owner/repo:workflow (red bg #A32D2DFF, white text, LED #FF0000FF) +CI stuck owner/repo:workflow (amber bg #BA7517FF, dark text, LED None) +CI ok (green text, priority 60, LED None) +``` + +Priority 60 is a no-dwell takeover: it redraws every poll and **evicts** the +ambient calendar (20). An entire extra tier — `PRIORITY_AMBIENT_URGENT` (65) — +exists only so an *imminent* calendar event can claw the screen back from it +(see the docstring in [`display.py:127`](../../../src/busybar/display.py)). + +The **overlay rotation** — running badge → quota(gql) → quota(rest) at tier 21, +each drawn for a 10 s dwell then **silent for one dwell** so the calendar can +reclaim the gap — lives in `run_once` +([`main.py:236`](../../../integrations/ci_status/main.py)). It runs only while a job +is actively running, and an alert explicitly resets/preempts it. + +## 4. Design + +### 4a. Failure/stuck content — the ref (Request A) + +Introduce a small record and carry it out of `evaluate_runs`: + +```python +@dataclass +class FailingRun: + workflow: str # r["name"] + ref: str # _pr_or_branch(r): "#42", else the branch, else "" +``` + +`RepoState.failing` and `RepoState.stuck` change from `list[str]` to +`list[FailingRun]`, sorted deterministically by `(workflow, ref)`. `evaluate_runs` +builds a `FailingRun` per latest-per-workflow run in the `FAILING` set (and per +stale-queued run for stuck), calling the existing `_pr_or_branch` on the run dict +it already has in hand. + +Frame text (badge colors unchanged — red for fail, amber for stuck): + +``` +CI FAIL owner/repo #42 · ci.yml # PR present +CI FAIL owner/repo main · nightly.yml # no PR → branch +CI stuck owner/repo #7 · deploy.yml +``` + +The `·` separator and `CI FAIL`/`CI stuck` prefixes are the proposed wording; +trivially tunable. The badge still scrolls, so length is not a hard constraint. + +### 4b. Demotion into the rotation (Request B) + +**Priority.** Failure, stuck, and green all draw at `PRIORITY_OVERLAY` (21) with a +`OVERLAY_DWELL_SECONDS` timeout — the same dwell/silence contract the running +badge and quota frames already follow. Nothing ci_status draws sits above the +ambient calendar's own raised/urgent tiers anymore, so the calendar reclaims the +gap between every ci_status frame, and an imminent event still wins outright. + +**One frame per failing run.** Each `(repo, FailingRun)` is its own rotation slot +(operator's choice), so a burst of N failures produces N slots cycling one per +dwell. Same for stuck. + +**Dynamic sequence.** Generalize the frame sequence from a list of frame-name +strings to a list of **frame descriptors**, built fresh each poll from whatever +exists this cycle, in this order: + +``` +[ fail(repo, run) for each failing run ] # most important → leads ++ [ stuck(repo, run) for each stuck run ] ++ [ ci_badge ] if show_running and a run is active ++ [ quota(graphql), quota(core) ] if show_quota and fresh data ++ [ green ] if show_green and the sequence is otherwise empty +``` + +`build_overlay_payload` dispatches on the descriptor kind (it already builds +`ci_badge` and both `quota` kinds; add `fail`, `stuck`, `green`, reusing +`_badge_elements`/`_text_element`). Green only appears when nothing else does, so +it never competes for a slot — it just becomes a dwelling frame instead of a +priority-60 camp, letting the calendar show through even in the all-clear state. + +**Rotation mechanics are unchanged.** The existing `frame_index` (mod sequence +length), `overlay_gap_elapsed` dwell gate, DRAWN-gated commit of +`frame_index`/`last_dwell_end`, and the **unified `last_shape` clear-gate** all +carry over verbatim — the clear-gate already spans "every tier that can draw to +APP," so the new fail/stuck/green shapes slot in without special-casing. Because +the set of failing runs can change between polls, the index→frame mapping is not +a stable identity across polls; that is acceptable for a rotation (it already is +for quota frames appearing/disappearing) and is called out in §9. + +**`build_ci_payload` dissolves.** Its failure>stuck>overlay>green precedence is +replaced by "draw the descriptor the rotation picked this poll." The function is +removed (or reduced to the LED-attaching wrapper in §4c). + +### 4c. The gentle LED (operator's choice) + +The LED must be red whenever *anything is failing*, independent of which frame is +currently on screen (a running-badge frame while a failure also exists must still +show red). Mirror `calendar_countdown`'s proven lifecycle +([`logic.py:319`](../../../integrations/calendar_countdown/logic.py)): + +- Compute `led_should_be_on = any(state.failing for state in states)` once per + poll (failure only — stuck keeps today's LED-`None` behavior). +- `resolve_led_value(led_should_be_on, led_was_on)` returns the red color while + on, an **explicit `#00000000`** on the on→off transition poll (the + hypothesis-agnostic off, since "omit = off" is unverifiable on this device), + and `None` (omit) once already off. +- Attach the resolved value to whatever payload is drawn this poll. +- Track `led_was_on` in the caller-owned state dict, committing **only after a + confirmed successful draw** — same DRAWN-gated discipline as everywhere else. +- On a poll where failures just cleared but there is **nothing else to draw** + (rotation empty, green off), emit the calendar's `LED_OFF_ELEMENTS` 1×1 + transparent placeholder to carry the explicit off, since the draw endpoint + requires ≥1 element. +- During a dwell **silence gap**, ci_status draws nothing; the LED stays sticky + from its last assertion, which is the desired "still red while failing." + +This also closes a latent gap: today nothing reliably turns the CI red LED *off* +when a failure resolves — the calendar solved this and ci_status never did. + +### 4d. Retiring snooze (operator's choice) + +Delete `compute_alert_fingerprint`, `update_snooze`, the `snooze_state` plumbing +through `run_once`/`main`, the `suppress_alert`/`suppress_led` parameters, and the +per-poll `client.get_busy()` call ci_status made *only* to drive snooze. A calm +rotating frame needs no acknowledge-to-quiet mechanism. This removes a large, +subtle state machine and its tests. + +## 5. Config changes + +- Remove `snooze_minutes` from `config.example.toml` and its README entry. + Loading is already `.get`-based, so an old `config.toml` that still sets it is + harmless — the key simply goes unread. +- No new keys. `show_green`, `show_running`, `show_quota`, `running_spinner`, + `poll_seconds`, `running_poll_seconds`, and the account-wide keys are unchanged. + +## 6. Ripple / docs + +- **Root `README.md`** priority-ladder table: the `PRIORITY_ALERT` (60) row no + longer belongs to ci_status; note it as unused / reserved, and note that + ci_status failure/stuck/green now share `PRIORITY_OVERLAY` (21). +- **`display.py`**: update the `PRIORITY_AMBIENT_URGENT` docstring rationale (it + cites a persistent CI failure evicting the calendar — which no longer happens) + and the `PRIORITY_ALERT` docstring (no current in-repo drawer). Behavior of + both constants is unchanged. +- **`integrations/ci_status/README.md`**: replace the "failure/stuck alert + badge" description with the calm-rotation-frame model; drop the snooze section. + +## 7. Testing + +- **Ref rendering:** PR present → `#N`; no PR → `head_branch`; neither → empty. +- **Rotation with no active run:** a failure alone produces a drawing rotation + (today this path drew nothing but a priority-60 badge). +- **One frame per failing run:** N failing runs → N descriptors/slots. +- **Dwell/silence:** a failure frame draws for a dwell, then stays silent one + dwell (calendar-reclaim gap), same as the running badge. +- **Green folded in:** `show_green` with nothing else failing/running draws + `CI ok` at tier 21 and dwells (no longer a priority-60 camp). +- **LED lifecycle:** red while failing (including during a non-failure frame when + a failure coexists); explicit `#00000000` on the failing→clear transition, via + both the piggybacked path and the standalone `LED_OFF_ELEMENTS` path; omitted + once already off. +- **Shape clear-gate:** transitions across the new seams (fail→ci_badge, + fail→green, fail→quota, and back) still clear the prior shape before drawing. +- **Snooze removed:** delete the snooze tests; assert `run_once` never calls + `get_busy()`. + +## 8. Rollout + +- Version bump per repo convention (recent tag is v1.6; this is a user-visible + ci_status behavior change — suggest **v1.7**, operator to confirm). +- Single branch/PR; no migration. An operator on an old config needs no action + (`snooze_minutes` just stops mattering). + +## 9. Open questions / risks + +- **Rotation length under many failures.** Per-run frames mean a failure burst + lengthens the cycle (each failure waits longer to reappear). Chosen knowingly; + revisit only if it bites in practice (a possible future cap: collapse to one + combined frame past K failures). +- **Dynamic sequence identity.** The failing set changing between polls reshuffles + which `frame_index` maps to which frame. Acceptable for a rotation and already + true for quota frames; no stable-identity guarantee is intended. +- **LED device semantics unverified.** Whether omitting `led_notification_color` + turns a lit LED off is not observable through this API; the explicit-`#00000000` + approach is correct either way (same caveat `calendar_countdown` documents). +- **On-device verification pending.** The dwell/silence timing, the LED staying + red across non-failure frames and turning off on clear, and the calendar + reclaiming gaps around a failure frame should be confirmed on the physical + device (fw 1.1.1), matching how prior specs recorded live spike results. diff --git a/integrations/ci_status/README.md b/integrations/ci_status/README.md index 0670fa5..a5e4cc8 100644 --- a/integrations/ci_status/README.md +++ b/integrations/ci_status/README.md @@ -2,9 +2,9 @@ ## What It Does -This integration monitors GitHub Actions workflows across your repositories and displays CI status on the busybar device. When workflows fail, the device shows a full-panel red badge (rounded background + bold white text) listing the affected `repo:workflow` pairs. When queued runs become stale (stuck due to offline runners or capacity), the device shows a full-panel amber badge with black text instead. Long lists scroll. The integration displays at the **alert** tier (`busybar.display.PRIORITY_ALERT`, priority 60), but an active BUSY session (priority 90) will override the display to show a blinking red status LED instead. +This integration monitors GitHub Actions workflows across your repositories and displays CI status on the busybar device. **Failure/stuck frames (v1.7):** when a workflow fails or a queued run goes stale (stuck due to offline runners or capacity), the device doesn't take over the panel — instead, a full-panel badge for it joins the same calm **overlay-tier** rotation described below: a red badge (rounded background + bold white text) reading `CI FAIL owner/repo #42 · workflow` for a failure, or an amber badge with black text reading `CI stuck owner/repo #42 · workflow` for a stale-queued run (the `#42` is the PR number, falling back to the branch name when a run has no PR, and dropped entirely when neither is available). One frame per failing/stuck run, alternating with the calendar, the running badge, and the quota frames — not a priority-60 takeover. A gentle red LED stays lit while a workflow is failing (stuck-only states don't light it), and turns off on the poll where the last failure clears. -**While a run is actively in progress** (and nothing is failing or stuck), the device shows a rotating set of **overlay-tier** frames instead: a cyan/blue "running" badge (repo, PR number or branch, and workflow name across the top; an ETA countdown below; a thin progress line tracking elapsed time against the workflow's typical duration), followed by two GitHub API quota frames (`show_quota`) if enabled. These three frames share one dwell/gap rotation with the ambient-tier `calendar_countdown` integration — see "Display Priority Tiers" below for the shared framework this is built on, and "Overlay Rotation: Running Badge + Quota Frames" for content, config, and the measured alternation rhythm. +**While a run is actively in progress** (and nothing is failing or stuck), the device shows a rotating set of **overlay-tier** frames instead: a cyan/blue "running" badge (repo, PR number or branch, and workflow name across the top; an ETA countdown below; a thin progress line tracking elapsed time against the workflow's typical duration), followed by two GitHub API quota frames (`show_quota`) if enabled. All of these frames share one dwell/gap rotation with the ambient-tier `calendar_countdown` integration — see "Display Priority Tiers" below for the shared framework this is built on, and "Overlay Rotation: Failure, Stuck, Running Badge, and Quota Frames" for content, config, and the measured alternation rhythm. ## Requirements @@ -88,37 +88,24 @@ Once the foreground test completes, your `config.toml` is in place and GitHub au | `repos_exclude` | array of strings | `[]` | Repos to never watch, regardless of mode — silences a specific repo without leaving account mode (or, less commonly, without editing `repos`). Applied last, unconditionally; a no-op when empty. | | `active_within_days` | integer | 30 | In account mode, only auto-discovered repos pushed within this many days are watched (caps request volume on large accounts). Repos in `repos` are never subject to this filter. | | `repo_refresh_minutes` | integer | 60 | How often the account's repo list is re-enumerated. A newly created (or newly pushed-to, if previously outside the active window) repo is picked up within this interval, not instantly. | -| `snooze_minutes` | integer | 30 | v1.5.2: how long an alert stays snoozed after you start-then-end a BUSY session on the device while it's showing. `0` disables the feature. See "Snoozing alerts" below. | ## Account-wide watching By default this integration watches exactly the repos listed in `repos`. Setting `watch_account_repos = true` switches to a broader mode: the watch list becomes every repo you own (`GET /user/repos?affiliation=owner`, so this does **not** pick up repos you merely have collaborator/org-member access to, only ones under your own account) that's been pushed to within `active_within_days` days, **union** `repos` (always included, never filtered by recency), **minus** `repos_exclude`. New repos are picked up automatically — no config edit needed — within `repo_refresh_minutes` of their creation or of a first push that puts them back inside the active window. -**Private repos are included, and that's intentional.** Discovery has no way to filter private vs. public — it watches everything you own that's active. This is fine for this integration's threat model: both the resulting config state (the discovered list itself, cached in memory) and the physical display are local to your own device and your own account's token. But the practical consequence is real: **a private repo's name can render on the physical display** (in the running badge's title, or in a failure/stuck alert's `repo:workflow` text) exactly like a public one would. If the device sits somewhere visible to people who shouldn't know a private repo exists, either keep `watch_account_repos` off and list repos explicitly, or add sensitive ones to `repos_exclude`. +**Private repos are included, and that's intentional.** Discovery has no way to filter private vs. public — it watches everything you own that's active. This is fine for this integration's threat model: both the resulting config state (the discovered list itself, cached in memory) and the physical display are local to your own device and your own account's token. But the practical consequence is real: **a private repo's name can render on the physical display** (in the running badge's title, or in a failure/stuck frame's `repo #PR · workflow` text) exactly like a public one would. If the device sits somewhere visible to people who shouldn't know a private repo exists, either keep `watch_account_repos` off and list repos explicitly, or add sensitive ones to `repos_exclude`. **Quota math.** With N repos in the effective watch list, each poll cycle costs N REST requests to `.../actions/runs` (steady-state, these return `304` and cost nothing against your quota — see "Design: REST-only, Quota-Efficient" above) at `poll_seconds` cadence (default every 120s, so N requests every 2 minutes = up to `N * 30` requests/hour, all free in the steady state), plus N more to the running-runs endpoint whenever `show_running` is on, at `running_poll_seconds` cadence while any run is active. Account-wide discovery itself adds one more request per `repo_refresh_minutes` (default hourly = 1 request/hour, also ETag-cached on its first page — see `RestPoller.fetch_account_repos`'s docstring). None of this touches your real GitHub REST quota unless workflow state is actually changing, since 304s are free; the practical cap that matters is request *volume* (GitHub does rate-limit request rate, not just quota), which is why `active_within_days` exists — it keeps N bounded to your actually-active repos instead of every repo you've ever created. **Caveat: `active_within_days` filters on `pushed_at`, a repo-level field — it has no idea about *schedule*-triggered workflow runs.** A repo whose CI only ever runs on a cron schedule (no pushes) will fall out of the active window and stop being watched even while its scheduled runs keep firing, because nothing about a scheduled run touches `pushed_at`. If you rely on schedule-triggered CI on a repo that doesn't otherwise see regular pushes, add it to `repos` explicitly (explicit repos are never subject to the active-window filter) rather than relying on account-wide discovery to keep watching it. -## Snoozing alerts - -**From the button's perspective:** you're looking at a persistent CI failure or stuck-queue alert on the device, and you already know about it — you don't want to keep seeing it right now. Press the device's native **start** button (the same one that begins a BUSY/CUSTOM session), then press it again to end the session whenever you're ready. Once that session ends, this exact failure stays off the panel for `snooze_minutes` (default 30) — no config edit, no separate acknowledgement step, just the button you were already going to press anyway. - -**Why a session, not a dedicated gesture.** Raw physical button presses aren't observable through the device's API at all (confirmed: the status WebSocket only reports what's currently on screen, not button events) — but the BUSY/CUSTOM session the button starts *is*, via `client.get_busy()`. The snooze rule rides on that signal rather than needing a new one: an alert showing at the moment a session starts is treated as "you saw it and pressed the button." While the session runs, the alert is naturally hidden anyway (`PRIORITY_SESSION`, 90, outranks the alert's 60) — the snooze rule's actual work happens once the session *ends*. - -**What exactly gets remembered.** The snooze is scoped to the precise set of currently-failing/stuck `repo:workflow` pairs (their "fingerprint"), not "alerts in general." If anything about that set changes while snoozed — a new repo starts failing, a different workflow in the same repo fails, or the original failure resolves and a new one appears — the snooze is dropped immediately and the (new) alert shows right away, even mid-snooze. The snooze also only ever suppresses the alert badge itself: the running-CI badge/quota overlay and the quiet-green "CI ok" text (if enabled) behave exactly as if nothing were snoozed at all. - -**During the session itself** (before you've ended it), the alert's own LED — which normally blinks even through a BUSY session, since LED is a separate channel from the panel's own priority arbitration — is suppressed too, the moment the session starts. You pressed the button; the LED doesn't need to keep insisting. - -**Restart edge case.** This state is in-memory only, like every other cache in this codebase — a process restart loses any pending or active snooze. A restart that happens to land while a session is *already* active is deliberately treated conservatively: rather than risk assuming a session that predates this process's own observation was "a button press for the alert," a fresh process requires an actually-observed inactive → active transition before it will start a new pending snooze. Practically: if you restart the integration mid-session, you may need to end and (if still needed) re-acknowledge via a fresh session press. - ## Display Priority Tiers -This integration's alert badges (failure/stuck) and its overlay-tier -frames (running badge, quota frames) both draw through the shared -priority ladder in `src/busybar/display.py`, along with two firmware -facts (measured, not assumed — see the design spec's "Display tier -framework" section for the probe that found them): +This integration's failure/stuck/quiet-green frames and its running-badge +and quota frames all draw through the same shared overlay tier in +`src/busybar/display.py`, along with two firmware facts (measured, not +assumed — see the design spec's "Display tier framework" section for the +probe that found them): - **Equal priority from a different `application_name` is rejected outright**, not treated as a hand-off, contrary to what the device's own @@ -132,29 +119,59 @@ framework" section for the probe that found them): that dark gap — see `calendar_countdown`'s README ("Display Priority Tiers") for the tuning history and measured recovery rates. -The alert tier (`PRIORITY_ALERT`, 60) sits above the overlay tier and -preempts it unconditionally — a failure or stuck-queue badge always wins -over the running badge or a quota frame, per the precedence in -`build_ci_payload` (failure > stuck > overlay > quiet green > nothing). - -**v1.5.2: the alert tier is no longer the ceiling.** `calendar_countdown` -can elevate to `PRIORITY_AMBIENT_URGENT` (65, above `PRIORITY_ALERT`) when -one of its own events is imminent (see its README's "Escalation ladder") -— closing an operator-reported gap where a persistent CI failure alert -permanently buried an imminent calendar event with no way for the -calendar to ever reclaim the screen. When that happens, this integration's -own alert draw gets a `409` (`DrawResult.REJECTED`) exactly like it always -has against the overlay tier's own dwell gaps — `run_once` treats that as -expected and silent (no state committed, no crash), and the alert -reappears on its own next poll once the calendar drops back down. See -`calendar_countdown`'s README for the full eviction/409 interplay and the -priority table. - -## Overlay Rotation: Running Badge + Quota Frames - -While any configured repo has an `in_progress` run (and nothing is failing -or stuck), the device rotates through up to three overlay-tier frames, one -per dwell slot (`OVERLAY_DWELL_SECONDS`, 10s), before repeating. See the design spec (`docs/superpowers/specs/2026-08-06-animation-accents-design.md`) for the running spinner implementation details. +**v1.7: no more alert-tier preemption.** A failure or stuck-queue run no longer +draws at a separate, higher `PRIORITY_ALERT` (60) tier and no longer +unconditionally wins the panel — that tier and its `build_ci_payload` +failure > stuck > overlay > quiet green > nothing precedence chain are +gone. Instead, one frame per failing run, then one per stuck run, is +prepended to the same ordered overlay sequence as the running badge and +quota frames (see `build_overlay_sequence` in `logic.py`), and the whole +sequence shares the overlay tier's usual dwell/silence rotation with +`calendar_countdown`. A CI failure therefore behaves like any other +overlay-tier frame: it takes its turn in the rotation rather than camping +the panel, and `calendar_countdown`'s escalation into +`PRIORITY_AMBIENT_RAISED` (25) or `PRIORITY_AMBIENT_URGENT` (65) already +sits strictly above it, so an approaching or imminent event naturally +outranks a CI failure with no special-case handling on this integration's +side. See `calendar_countdown`'s README for the full eviction interplay +and the priority table. + +## Overlay Rotation: Failure, Stuck, Running Badge, and Quota Frames + +The device rotates through the overlay-tier frames in play this cycle, one +per dwell slot (`OVERLAY_DWELL_SECONDS`, 10s), before repeating: one frame +per currently-failing run, then one per currently-stuck run, then the +running badge (if a run is `in_progress`), then each available quota +frame, then — only when nothing else is present and `show_green` is +on — a single quiet "CI ok" frame. See the design spec +(`docs/superpowers/specs/2026-08-06-animation-accents-design.md`) for the +running spinner implementation details. + +### Failure and Stuck Frames (v1.7) + +A failing run draws a full-panel red badge (rounded background + bold +white text) reading `CI FAIL owner/repo #42 · workflow`; a stale-queued +run draws the same layout in amber with black text, reading `CI stuck +owner/repo #42 · workflow`. `#42` is the run's PR number where one +exists, the branch name when it doesn't (push/fork-triggered runs), or +dropped entirely (along with its leading space) when neither is +available. Long text scrolls, same as the running badge. Each failing or +stuck run gets its own frame in the rotation — with several failures or +stuck runs across repos, expect several red/amber frames in a row before +the rotation reaches the running badge or quota frames. + +A gentle red LED (`led_notification_color`) stays lit for the whole poll +cycle while any run is failing — it is **not** tied to whether a +failure/stuck frame happens to be the one currently on screen, and it is +**not** raised by a stuck-only state (no failures, only stale-queued +runs). The LED turns off explicitly on the exact poll where the last +failure clears, then stops being sent once already off. + +### Running Badge and Quota Frames + +While any configured repo has an `in_progress` run, the running badge and +(if enabled) two quota frames join the rotation after the failure/stuck +frames, if any: 1. **Running badge** (always first, always present when `show_running` is on): `REPO #PR WORKFLOW` (or `REPO branch-name WORKFLOW` for diff --git a/integrations/ci_status/logic.py b/integrations/ci_status/logic.py index 5fc3c1b..b4bce5e 100644 --- a/integrations/ci_status/logic.py +++ b/integrations/ci_status/logic.py @@ -30,16 +30,22 @@ # RUNNING_BADGE_TIMEOUT_S); now PRIORITY_OVERLAY (and, in main.py, # OVERLAY_DWELL_SECONDS) from the shared module so future integrations # inherit the same contract instead of re-deriving it. -from busybar.display import PRIORITY_OVERLAY, PRIORITY_ALERT # noqa: E402 +from busybar.display import PRIORITY_OVERLAY # noqa: E402 FAILING = {"failure", "timed_out", "startup_failure"} +@dataclass(frozen=True) +class FailingRun: + workflow: str # r["name"] + ref: str # _pr_or_branch(r): "#42", the branch, or "" + + @dataclass class RepoState: repo: str - failing: list[str] - stuck: list[str] + failing: list[FailingRun] + stuck: list[FailingRun] @dataclass @@ -137,12 +143,14 @@ def evaluate_runs(repo: str, runs: list[dict], now: datetime, failing, stuck = [], [] for r in latest.values(): if r.get("conclusion") in FAILING: - failing.append(r["name"]) + failing.append(FailingRun(r["name"], _pr_or_branch(r))) elif r.get("status") == "queued" and stale_queued_minutes > 0: age_min = (now - _parse_ts(r["created_at"])).total_seconds() / 60 if age_min >= stale_queued_minutes: - stuck.append(r["name"]) - return RepoState(repo=repo, failing=sorted(failing), stuck=sorted(stuck)) + stuck.append(FailingRun(r["name"], _pr_or_branch(r))) + key = lambda f: (f.workflow, f.ref) + return RepoState(repo=repo, failing=sorted(failing, key=key), + stuck=sorted(stuck, key=key)) # --- overlay tier: shared row template (v1.5) ----------------------------------- @@ -606,71 +614,111 @@ def _build_quota_elements(info: QuotaInfo, timeout_s: int) -> list[dict]: OVERLAY_FRAME_CI_BADGE = "ci_badge" OVERLAY_FRAME_QUOTA_GQL = "quota_gql" OVERLAY_FRAME_QUOTA_REST = "quota_rest" - -# Element id sets differ between the CI badge ("eta") and either quota frame -# ("pct", "reset") -- the draw endpoint upserts by id within an -# application_name (the same firmware behavior that required the v1.3.1 -# calendar transition-clear fix), so switching between these two *shapes* -# without an explicit clear would leave a stale numeral element from the -# previous shape rendered alongside the new one. quota_gql and quota_rest -# share an identical id set, so switching between *those* needs no clear. -# NOTE: this dict is documentation/reference only -- main.py's actual -# clear-gate does NOT consult it. It instead compares the *literal* -# frozenset of element ids on each drawn payload (frozenset(e["id"] for e -# in payload["elements"])), unified across every tier that can draw to -# APP (alert, quiet-green, and both overlay frame kinds), not just these -# two overlay shapes -- see run_once's docstring in ci_status/main.py for -# why a badge/quota-only mapping here wasn't enough (it missed the -# alert<->overlay and green<->overlay seams entirely). -OVERLAY_FRAME_SHAPE = { - OVERLAY_FRAME_CI_BADGE: "badge", - OVERLAY_FRAME_QUOTA_GQL: "quota", - OVERLAY_FRAME_QUOTA_REST: "quota", -} +OVERLAY_FRAME_FAIL = "fail" +OVERLAY_FRAME_STUCK = "stuck" +OVERLAY_FRAME_GREEN = "green" + +CI_LED_COLOR = "#FF0000FF" +LED_OFF_COLOR = "#00000000" +# ^ Explicit LED-off (zero alpha). Whether omitting led_notification_color +# turns a lit LED off is not observable through this device's API, so the +# on->off transition sends this value explicitly -- same hypothesis-agnostic +# choice calendar_countdown makes (see its resolve_led_value / LED_OFF_COLOR). +LED_OFF_ELEMENTS = [{ + "id": "ci_led_off_flush", "type": "rectangle", "x": 0, "y": 0, + "width": 1, "height": 1, "fill": "solid", "fill_colors": ["#00000000"], + "border_width": 0, "timeout": 5, +}] +# ^ Minimal 1x1 transparent self-expiring element -- the draw endpoint requires +# >=1 element, so a bare led_notification_color with no element is impossible. +# Used on the "nothing else to draw but the LED must go off" path (main.run_once). + + +def resolve_ci_led_value(led_should_be_on: bool, led_was_on: bool) -> str | None: + """The led_notification_color to send THIS poll: CI_LED_COLOR while any + failure exists; LED_OFF_COLOR (explicit) on the exact failing->clear poll; + None (omit) once already off. main.run_once tracks `led_was_on` in its + caller-owned overlay_state, committed only after a confirmed DRAWN send.""" + if led_should_be_on: + return CI_LED_COLOR + if led_was_on: + return LED_OFF_COLOR + return None -def overlay_frame_sequence(show_quota: bool) -> list[str]: - """The rotation order for the overlay tier's dwell slots. The running - badge always leads (and is the only frame at all when show_quota is - off), so a run's very first overlay draw is always the CI badge, never - a quota frame.""" - if show_quota: - return [OVERLAY_FRAME_CI_BADGE, OVERLAY_FRAME_QUOTA_GQL, OVERLAY_FRAME_QUOTA_REST] - return [OVERLAY_FRAME_CI_BADGE] - - -def build_overlay_payload(frame_name: str, timeout_s: int, *, - running: RunningInfo | None = None, - quota_by_bucket: dict[str, QuotaInfo] | None = None, - show_spinner: bool = False) -> dict | None: - """Build the {"elements", "priority", "led"} payload for one overlay- - tier dwell slot, or `None` if this frame's data isn't available this - cycle -- the caller must treat `None` as "skip this dwell slot - entirely" (no draw, no clear), never substitute stale or placeholder - content. This is what lets rate_limit fetch failures silently drop a - quota frame from rotation for a cycle instead of crashing or showing - minutes-old numbers (see main.py's 5-minute staleness check, which - is what actually keeps `quota_by_bucket` fresh enough to trust here). +def build_overlay_payload(descriptor: dict, timeout_s: int, *, + running: RunningInfo | None = None, + quota_by_bucket: dict[str, QuotaInfo] | None = None, + show_spinner: bool = False) -> dict | None: + """Build the {"elements", "priority", "led"} payload for one overlay-tier + dwell slot from a frame descriptor ({"kind": ...} plus kind-specific + fields). Returns None only if a frame's data isn't available this cycle; + build_overlay_sequence never emits a descriptor whose data is missing, so + in practice callers get a payload. `led` is always None here -- the + failure-driven LED is resolved by the caller (see resolve_ci_led_value). + + A `None` return means "skip this dwell slot entirely" (no draw, no + clear), never substitute stale or placeholder content. This is what lets + rate_limit fetch failures silently drop a quota frame from rotation for + a cycle instead of crashing or showing minutes-old numbers (see main.py's + 5-minute staleness check, which is what actually keeps `quota_by_bucket` + fresh enough to trust here). `show_spinner` (v1.6) is threaded through ONLY on the CI-badge branch -- quota frames never get a spinner, regardless of this flag. Defaults to False so existing callers are unaffected. """ - if frame_name == OVERLAY_FRAME_CI_BADGE: + kind = descriptor["kind"] + if kind == OVERLAY_FRAME_CI_BADGE: if running is None: return None return {"elements": _build_running_elements(running, timeout_s, show_spinner=show_spinner), "priority": PRIORITY_OVERLAY, "led": None} - if frame_name in (OVERLAY_FRAME_QUOTA_GQL, OVERLAY_FRAME_QUOTA_REST): - bucket_key = "graphql" if frame_name == OVERLAY_FRAME_QUOTA_GQL else "core" + if kind in (OVERLAY_FRAME_QUOTA_GQL, OVERLAY_FRAME_QUOTA_REST): + bucket_key = "graphql" if kind == OVERLAY_FRAME_QUOTA_GQL else "core" info = (quota_by_bucket or {}).get(bucket_key) if info is None: return None return {"elements": _build_quota_elements(info, timeout_s), "priority": PRIORITY_OVERLAY, "led": None} + if kind == OVERLAY_FRAME_FAIL: + text = "CI FAIL " + _fail_line(descriptor["repo"], descriptor["run"]) + return {"elements": _badge_elements(text, "#A32D2DFF", "#FFFFFFFF", timeout_s), + "priority": PRIORITY_OVERLAY, "led": None} + if kind == OVERLAY_FRAME_STUCK: + text = "CI stuck " + _fail_line(descriptor["repo"], descriptor["run"]) + return {"elements": _badge_elements(text, "#BA7517FF", "#0B0B0BFF", timeout_s), + "priority": PRIORITY_OVERLAY, "led": None} + if kind == OVERLAY_FRAME_GREEN: + return {"elements": [_text_element("CI ok", "#00FF00FF", timeout_s)], + "priority": PRIORITY_OVERLAY, "led": None} return None +def build_overlay_sequence(states: list[RepoState], *, running_present: bool, + quota_frames: list[str], show_green: bool) -> list[dict]: + """The ordered overlay-tier rotation for this poll: one frame per failing + run, then one per stuck run, then the running CI badge (if a run is + active), then each available quota frame, then a single quiet-green frame + ONLY when nothing else is present and show_green is on. Each fail/stuck + descriptor carries its repo and FailingRun so the renderer needs no extra + lookup.""" + seq: list[dict] = [] + for s in states: + for fr in s.failing: + seq.append({"kind": OVERLAY_FRAME_FAIL, "repo": s.repo, "run": fr}) + for s in states: + for fr in s.stuck: + seq.append({"kind": OVERLAY_FRAME_STUCK, "repo": s.repo, "run": fr}) + if running_present: + seq.append({"kind": OVERLAY_FRAME_CI_BADGE}) + for frame in quota_frames: + seq.append({"kind": frame}) + if not seq and show_green: + seq.append({"kind": OVERLAY_FRAME_GREEN}) + return seq + + def _text_element(text: str, color: str, timeout_s: int, font: str = "normal") -> dict: return {"id": "ci", "type": "text", "text": text, "font": font, "x": 0, "y": 4, "width": 72, "color": color, @@ -678,6 +726,12 @@ def _text_element(text: str, color: str, timeout_s: int, font: str = "normal") - "scroll_repeat_delay": 2000, "timeout": timeout_s} +def _fail_line(repo: str, fr: FailingRun) -> str: + """"owner/repo #42 · workflow" (the ref is dropped when empty).""" + ref = f" {fr.ref}" if fr.ref else "" + return f"{repo}{ref} · {fr.workflow}" + + def _badge_elements(text: str, bg_color: str, text_color: str, timeout_s: int) -> list[dict]: """Full-panel rounded-rect background + bold scrolling text over it.""" # border_width=0: RectangleElement defaults to a 1px white border, which @@ -687,217 +741,3 @@ def _badge_elements(text: str, bg_color: str, text_color: str, timeout_s: int) - "timeout": timeout_s} return [bg, _text_element(text, text_color, timeout_s, font="bold")] - -def build_ci_payload(states: list[RepoState], show_green: bool, timeout_s: int, - overlay: dict | None = None, suppress_alert: bool = False, - suppress_led: bool = False) -> dict | None: - """Precedence: failure > stuck > overlay (whichever frame the caller's - rotation picked -- the running badge or a quota frame) > quiet green > - nothing. Failure and stuck stay at PRIORITY_ALERT (60, unchanged) and - are evaluated first specifically so they always win even if an overlay - condition is also true in the same poll -- an active alert must never - be preempted by "just" a status update. `overlay`, when given, is a - fully pre-built payload dict from `build_overlay_payload` (already - carrying its own `priority`/`elements`/`led`) so this function's job is - purely precedence, not rendering. - - `suppress_alert` (v1.5.2 snooze) skips the failure/stuck branches - entirely when true -- the caller (main.run_once, via update_snooze) - has decided this exact alert fingerprint is currently snoozed, so - precedence falls through to overlay/quiet-green/nothing exactly as if - nothing were failing: "running/quota rotation and green behavior - unaffected" per the snooze design. `suppress_led` (also v1.5.2) blanks - the failure branch's LED specifically, without suppressing the alert - draw itself -- used during the snooze-PENDING phase (a BUSY session is - active but hasn't ended yet): the alert's own element draw still - proceeds as normal (and gets naturally rejected by the session's - higher priority, same as always), but the LED -- a separate channel - that is NOT gated by the same priority arbitration and would otherwise - keep blinking through the session -- is silenced once the operator has - visibly acknowledged the alert by starting a session. `suppress_led` - has no effect on the stuck branch (its LED is already always `None`). - """ - failures = [(s.repo, name) for s in states for name in s.failing] - stuck = [(s.repo, name) for s in states for name in s.stuck] - if failures and not suppress_alert: - text = "CI FAIL " + " ".join(f"{repo}:{name}" for repo, name in failures) - led = None if suppress_led else "#FF0000FF" - return {"elements": _badge_elements(text, "#A32D2DFF", "#FFFFFFFF", timeout_s), - "priority": PRIORITY_ALERT, "led": led} - if stuck and not suppress_alert: - text = "CI stuck " + " ".join(f"{repo}:{name}" for repo, name in stuck) - return {"elements": _badge_elements(text, "#BA7517FF", "#0B0B0BFF", timeout_s), - "priority": PRIORITY_ALERT, "led": None} - if overlay is not None: - return overlay - if show_green: - return {"elements": [_text_element("CI ok", "#00FF00FF", timeout_s)], - "priority": PRIORITY_ALERT, "led": None} - return None - - -# --- alert snooze via the device's native start button (v1.5.2) ----------------- -# -# Raw physical button events aren't API-observable (confirmed: the status -# WebSocket is screen-only), but the BUSY session it starts is, via -# client.get_busy(). The snooze rule rides on that: an alert showing at the -# moment a session starts is treated as "the operator saw it and pressed -# the button" -- once the session ends, that exact failure/stuck fingerprint -# is suppressed for `snooze_minutes`. Any change to the fingerprint (a new -# failure, a different workflow, resolved-then-new) immediately clears the -# snooze and re-alerts; so does the snooze's own expiry if the same -# fingerprint is still failing. - -def compute_alert_fingerprint(states: list[RepoState]) -> frozenset: - """The identity of "what's currently alerting" -- a frozenset of - `(repo, workflow, category)` triples, category being "failing" or - "stuck" so a repo:workflow pair moving between the two categories - counts as a fingerprint change (not silently treated as "the same - alert"), matching the snooze rule's "ANY fingerprint change... clear - snooze, alert immediately." Empty (falsy) when nothing is failing or - stuck. - """ - return (frozenset((s.repo, name, "failing") for s in states for name in s.failing) - | frozenset((s.repo, name, "stuck") for s in states for name in s.stuck)) - - -def update_snooze(alert_fingerprint: frozenset, busy_active: bool | None, now: datetime, - snooze_minutes: int, snooze_state: dict) -> tuple[bool, bool]: - """Advances the snooze state machine by one poll and returns - `(suppress_alert, suppress_led)` for THIS poll. `snooze_state` is a - caller-owned dict (same pattern as every other cache in this codebase), - mutated in place, with up to three keys: `"session_was_active"` - (tracked every call, for edge-detecting the inactive->active - transition -- see below), `"fingerprint"` (the alert fingerprint a - pending-or-active snooze applies to), and `"snooze_until"` (absent - while pending -- session still running -- set to a datetime once the - session ends and the timed snooze begins). - - State machine: - 1. **Pending** starts only on a genuine inactive -> active transition - (edge, not level -- see below) while an alert is currently showing: - records `fingerprint`, no `snooze_until` yet. Returns - `(False, True)` -- the alert draw itself still proceeds as normal - (and will be naturally rejected by the session's own higher - priority, same as always), but its LED is suppressed, since LED is - a separate channel not gated by that same priority arbitration and - would otherwise keep blinking through the session the operator just - acknowledged. - 2. While still **pending** (fingerprint unchanged, session still - active): keeps returning `(False, True)`. - 3. The session **ends** (busy_active goes false) while still pending, - same fingerprint: sets `snooze_until = now + snooze_minutes` and - returns `(True, False)` -- the timed snooze begins this exact poll. - 4. While **timed** and `now < snooze_until`, same fingerprint: - `(True, False)` -- alert suppressed entirely (falls through to - overlay/quiet-green/nothing in build_ci_payload), no LED question - even arises since the alert branch never runs. - 5. **Expiry** (`now >= snooze_until`): state clears, `(False, False)` - -- back to alerting normally if still failing. - 6. **Any fingerprint change** at any pending/timed point: immediately - clears the fingerprint/snooze_until (but not the - `session_was_active` tracking -- see below), falling through to - step 1's logic fresh for the new fingerprint (or `(False, False)` - if nothing is failing/stuck anymore, or if a session isn't already - active for a fresh pending to start against). - 7. `snooze_minutes <= 0` disables the feature outright: any existing - fingerprint/snooze_until is cleared and `(False, False)` always. - - **Edge, not level, and why the default matters.** Requirement 1 is a - TRANSITION ("busy snapshot transitions from inactive... to active"), - not a level condition ("an alert is showing and a session happens to - be active") -- otherwise a session that was ALREADY running before an - alert appeared (or before a fingerprint changed mid-session) would be - wrongly treated as "you just pressed the button for this," silently - snoozing something the operator never actually acknowledged. Detecting - the edge needs to know what the PREVIOUS poll observed, tracked via - `session_was_active` -- but polling is deliberately gated (see - main.run_once) to skip `get_busy()` entirely when idle (no alert, no - snooze state), which means there can be gaps where `session_was_active` - wasn't being updated. - - **Critical correctness point (fixed after an initial version got this - wrong -- see the regression tests): `session_was_active` is committed - ONLY on a poll where `get_busy()` was ACTUALLY called this cycle**, - signaled by `busy_active` being a real `bool` rather than `None`. - `main.run_once` passes `None` for `busy_active` whenever polling was - gated off (idle: no alert, no existing snooze state). An earlier - version unconditionally wrote `busy_active` every call, including a - dummy `False` for gated-off polls -- which meant a sequence of idle - polls (no alert yet) would stamp `session_was_active = False` - regardless of the device's ACTUAL state; if a session then started - while STILL idle (unobserved, since nothing was polling), and only - THEN did an alert appear (triggering the first real `get_busy()` call, - correctly observing `busy_active=True`), the stale `False` from the - dummy writes would read as "was NOT active a moment ago, now IS - active" -- a spurious transition -- and silently start a pending - snooze for a failure the operator never acknowledged. Committing only - on an actual poll (leaving `session_was_active` untouched otherwise) - closes this: an unobserved period leaves the value at whatever it was - (or its default) rather than being corrupted by an unpolled guess. - - On the first EVER poll of a fresh process, or the first poll after any - gap where `session_was_active` was never committed, it defaults to - `True` -- not `False` -- so an as-yet-unobserved busy session is - assumed to possibly PRE-DATE the alert rather than assumed absent: - the conservative direction is to require an actually-OBSERVED - inactive->active transition before granting pending, at the cost of - occasionally missing a legitimate fresh session-start that happens to - coincide with polling just resuming (a minor inconvenience -- the - operator presses the button again -- versus the alternative of a - silent, unintended auto-snooze). - - **Restart safety**: a process restart gets a fresh empty - `snooze_state`, so `session_was_active` defaults to `True` on the very - first poll regardless of the device's actual state -- the same - conservative default above, which also happens to correctly prevent a - restart-during-an-already-active-session from being misattributed as - a fresh button press. In-memory only; documented as a known limitation - (a snooze in effect at restart is lost, same as every other cache in - this codebase). - """ - was_active = snooze_state.get("session_was_active", True) - if busy_active is not None: - snooze_state["session_was_active"] = busy_active - - if snooze_minutes <= 0: - snooze_state.pop("fingerprint", None) - snooze_state.pop("snooze_until", None) - return False, False - - pending_fp = snooze_state.get("fingerprint") - snooze_until = snooze_state.get("snooze_until") - - if pending_fp is not None and alert_fingerprint != pending_fp: - snooze_state.pop("fingerprint", None) - snooze_state.pop("snooze_until", None) - pending_fp = None - snooze_until = None - - if pending_fp is None: - if alert_fingerprint and busy_active and not was_active: - snooze_state["fingerprint"] = alert_fingerprint - snooze_state.pop("snooze_until", None) - return False, True - return False, False - - if snooze_until is None: - # Defensive: main.run_once's polling gate guarantees busy_active - # is a real bool (not None) whenever pending_fp is set (a pending - # snooze always keeps polling -- see should_poll_busy), so this - # should never actually see None here. If it somehow did anyway, - # treat "unknown" the same as "still active" (stay pending rather - # than prematurely starting the timed snooze on an unpolled - # guess) -- the same conservative direction as everywhere else in - # this function. - if busy_active is None or busy_active: - return False, True - snooze_state["snooze_until"] = now + timedelta(minutes=snooze_minutes) - return True, False - - if now < snooze_until: - return True, False - - snooze_state.pop("fingerprint", None) - snooze_state.pop("snooze_until", None) - return False, False diff --git a/integrations/ci_status/main.py b/integrations/ci_status/main.py index ab5e64a..5a7f909 100644 --- a/integrations/ci_status/main.py +++ b/integrations/ci_status/main.py @@ -13,13 +13,13 @@ from busybar.client import BusyBarClient, DrawResult from busybar.config import device_kwargs, load_config -from busybar.display import OVERLAY_DWELL_SECONDS, overlay_gap_elapsed +from busybar.display import OVERLAY_DWELL_SECONDS, PRIORITY_OVERLAY, overlay_gap_elapsed from .logic import ( RepoState, RunningInfo, QuotaInfo, - build_ci_payload, build_overlay_payload, compute_alert_fingerprint, evaluate_runs, - overlay_frame_sequence, parse_rate_limit, resolve_repo_list, select_running_run, - update_snooze, + build_overlay_payload, build_overlay_sequence, evaluate_runs, + LED_OFF_COLOR, LED_OFF_ELEMENTS, OVERLAY_FRAME_QUOTA_GQL, OVERLAY_FRAME_QUOTA_REST, + parse_rate_limit, resolve_ci_led_value, resolve_repo_list, select_running_run, ) APP = "ci_status" @@ -88,8 +88,7 @@ def run_once(client, poller, cfg: dict, now: datetime, running_cache: dict[str, list[dict]] | None = None, overlay_state: dict | None = None, quota_cache: dict | None = None, - repo_cache: dict | None = None, - snooze_state: dict | None = None) -> str: + repo_cache: dict | None = None) -> str: """`running_cache`, `overlay_state`, `quota_cache`, and `repo_cache`, when passed, are caller-owned dicts this function mutates in place (mirroring `state_cache`'s existing pattern) so `main()` can hold one @@ -113,11 +112,10 @@ def run_once(client, poller, cfg: dict, now: datetime, slots forgotten via `forget_repo`) so a stale failure/stuck alert or running badge can't linger for a repo that's no longer being watched. - Overlay rotation: while a run is active (and no failure/stuck alert - preempts it), the overlay tier draws one frame per dwell slot, cycling - through `overlay_frame_sequence(show_quota)` (the running badge, then - -- if `show_quota` -- the GraphQL and REST quota frames). A dwell slot - only fires once `overlay_gap_elapsed(last_dwell_end, now) >= + Overlay rotation: the overlay tier draws one frame per dwell slot, + cycling through `build_overlay_sequence(...)`'s ordered frame list (see + the "Failure/stuck/green rotation" paragraph below for what populates + it). A dwell slot only fires once `overlay_gap_elapsed(last_dwell_end, now) >= OVERLAY_DWELL_SECONDS` (busybar.display's contract: stay silent at least one full dwell so the ambient calendar has a real chance to reclaim the screen in between -- see busybar/display.py and the spec @@ -151,16 +149,21 @@ class the v1.3.1 calendar transition-clear fix addressed, recurring at "no shape on record" and wrongly concluded no clear was needed on the next transition. - Alert snooze (v1.5.2, `snooze_state`): omitting it (the default) skips - the snooze subsystem entirely -- every poll behaves exactly as if - nothing were ever snoozed. When given, see - `ci_status.logic.update_snooze`'s docstring for the full state - machine; `client.get_busy()` is polled only while there's something - to track (an alert currently showing, or an existing pending/timed - snooze), never on a fully idle poll. + Failure/stuck/green rotation (v1.6, Request B): the overlay tier's + rotation is no longer gated on a run being active -- `build_overlay_sequence` + folds in one frame per failing run, one per stuck run, the running CI + badge (only while a run is active), each available quota frame, and a + single quiet-green frame (only when nothing else is present and + `show_green` is on), and the dwell/silence gate above applies uniformly + across all of them. This means a failure rotates into view and keeps + rotating even with no CI run currently in progress, instead of only + showing while `running_cache`/`show_running` happened to have something + active. `overlay_state["led_was_on"]` tracks the failure-driven LED + across polls (see `resolve_ci_led_value`): it commits to + `led_should_be_on` only once a draw actually lands (DRAWN), same DRAWN + discipline as `frame_index`/`last_dwell_end`. """ c = cfg["ci_status"] - timeout_s = int(c["poll_seconds"] * 1.5) if repo_cache is not None: account_repos = (_refresh_account_repos(poller, repo_cache, now, @@ -188,131 +191,96 @@ class the v1.3.1 calendar transition-clear fix addressed, recurring at if runs is not None: # None = 304/no-change/error -> keep cached state state_cache[repo] = evaluate_runs(repo, runs, now, c["stale_queued_minutes"]) + states = list(state_cache.values()) - has_alert = any(s.failing or s.stuck for s in states) - - # Alert snooze via the device's native start button (v1.5.2) -- see - # ci_status.logic.update_snooze's docstring for the full state - # machine. get_busy() is polled only while there's something to - # track (an alert showing, or an existing pending/timed snooze), - # never on a fully idle poll, to keep idle cycles lean. - suppress_alert = False - suppress_led = False - if snooze_state is not None: - snooze_minutes = c.get("snooze_minutes", 0) - alert_fingerprint = compute_alert_fingerprint(states) - should_poll_busy = not dry_run and snooze_minutes > 0 and ( - bool(alert_fingerprint) or bool(snooze_state.get("fingerprint"))) - if should_poll_busy: - busy = client.get_busy() or {} - busy_active = busy.get("type") not in (None, "NOT_STARTED") - else: - # None, not False -- "not polled this cycle," distinct from a - # confirmed-inactive observation. update_snooze only commits - # its session_was_active tracking when given a real bool; see - # its docstring for the exact bug a dummy False here caused - # (a false-positive auto-snooze for a session that predated - # the alert). - busy_active = None - suppress_alert, suppress_led = update_snooze( - alert_fingerprint, busy_active, now, snooze_minutes, snooze_state) - - # While snoozed, ci_status behaves as if nothing is failing/stuck at - # all for every OTHER precedence purpose too -- "running/quota - # rotation and green behavior unaffected" is the explicit design - # intent, not just the alert badge's own rendering (build_ci_payload, - # below, gets the same suppress_alert). The overlay rotation's own - # "an alert takes precedence" check needs the identical view. - effective_has_alert = has_alert and not suppress_alert - - overlay_payload = None - frame_index = 0 - stay_silent = False - frame_data_unavailable = False - - # running_cache check first: short-circuits before touching c["show_running"], - # so callers/tests using an older, fully-spelled-out cfg dict that predates - # this key (and never pass running_cache) don't KeyError. + + # Running detection (unchanged gating): a live run enables the CI badge and, + # with show_quota, the quota frames. Independent of failures, which rotate + # regardless of whether anything is running. + running_info = None + running_present = False + quota_by_bucket = None + quota_frames: list[str] = [] if running_cache is not None and c["show_running"]: for repo in effective_repos: running_runs = poller.fetch_running_runs(repo) - if running_runs is not None: # None = 304/no-change/error -> keep cached + if running_runs is not None: running_cache[repo] = running_runs selected = select_running_run(running_cache) - - if selected is None or effective_has_alert: - # Nothing running, or an alert takes precedence this poll -- - # reset only the ROTATION bookkeeping, so the next run to start - # always begins at the CI badge. Deliberately do NOT touch - # last_shape here -- see the docstring above. - if overlay_state is not None: - overlay_state.pop("frame_index", None) - overlay_state.pop("last_dwell_end", None) - else: + if selected is not None: run, repo, other_count = selected median = poller.fetch_median_eta(repo, run["workflow_id"]) running_info = RunningInfo(run=run, repo=repo, other_count=other_count, median_minutes=median, now=now) - quota_by_bucket = _refresh_quota(poller, quota_cache, now) if c["show_quota"] else None - - sequence = overlay_frame_sequence(c["show_quota"]) - frame_index = (overlay_state.get("frame_index", 0) if overlay_state is not None else 0) % len(sequence) - last_dwell_end = overlay_state.get("last_dwell_end") if overlay_state is not None else None - - if overlay_gap_elapsed(last_dwell_end, now) >= OVERLAY_DWELL_SECONDS: - frame_name = sequence[frame_index] - overlay_payload = build_overlay_payload( - frame_name, OVERLAY_DWELL_SECONDS, - running=running_info, quota_by_bucket=quota_by_bucket, - show_spinner=c.get("running_spinner", False)) - if overlay_payload is None: - # This frame's data wasn't available this cycle (e.g. a - # quota frame with no fresh rate_limit data). Advance - # past it without consuming a dwell -- nothing was - # shown, so there's no gap to protect -- and skip this - # poll's draw entirely (no draw, no clear): whatever was - # already on screen is still within its own dwell - # timeout and is left exactly as it is. - if overlay_state is not None: - overlay_state["frame_index"] = frame_index + 1 - frame_data_unavailable = True - else: - stay_silent = True - - if stay_silent: + running_present = True + if c["show_quota"]: + quota_by_bucket = _refresh_quota(poller, quota_cache, now) + if quota_by_bucket: + if "graphql" in quota_by_bucket: + quota_frames.append(OVERLAY_FRAME_QUOTA_GQL) + if "core" in quota_by_bucket: + quota_frames.append(OVERLAY_FRAME_QUOTA_REST) + + sequence = build_overlay_sequence(states, running_present=running_present, + quota_frames=quota_frames, show_green=c["show_green"]) + + # Failure-driven LED (Request B keeps a gentle cue). led_should_be_on is + # driven by FAILURES only -- stuck keeps its historical LED-None behavior. + led_should_be_on = any(s.failing for s in states) + led_was_on = bool(overlay_state.get("led_was_on")) if overlay_state is not None else False + led_value = resolve_ci_led_value(led_should_be_on, led_was_on) + + # Empty sequence -> nothing to show. Honor an explicit LED-off transition + # (there is no failure now, so led_value is either LED_OFF_COLOR or None). + if not sequence: + if overlay_state is not None: + overlay_state.pop("frame_index", None) + overlay_state.pop("last_dwell_end", None) + if dry_run: + return "DRY-RUN: nothing to show" + if led_value == LED_OFF_COLOR: + result = client.draw(APP, LED_OFF_ELEMENTS, priority=PRIORITY_OVERLAY, + led_notification_color=LED_OFF_COLOR) + if result == DrawResult.DRAWN and overlay_state is not None: + overlay_state["led_was_on"] = False + overlay_state["last_shape"] = frozenset(e["id"] for e in LED_OFF_ELEMENTS) + return f"led off; {result.value}" + client.clear(APP) + if overlay_state is not None: + overlay_state["last_shape"] = None + return "nothing to show; cleared" + + # Dwell gate: one frame per dwell, then silent one dwell so the ambient + # calendar can reclaim the gap. frame_index/last_dwell_end commit only on DRAWN. + seq_len = len(sequence) + frame_index = (overlay_state.get("frame_index", 0) if overlay_state is not None else 0) % seq_len + last_dwell_end = overlay_state.get("last_dwell_end") if overlay_state is not None else None + if overlay_gap_elapsed(last_dwell_end, now) < OVERLAY_DWELL_SECONDS: return "overlay dwell gap; staying silent (letting the ambient app reclaim the screen)" - if frame_data_unavailable: - return "overlay frame data unavailable this cycle; skipping (no draw, no clear)" - payload = build_ci_payload(states, c["show_green"], timeout_s, overlay=overlay_payload, - suppress_alert=suppress_alert, suppress_led=suppress_led) + payload = build_overlay_payload(sequence[frame_index], OVERLAY_DWELL_SECONDS, + running=running_info, quota_by_bucket=quota_by_bucket, + show_spinner=c.get("running_spinner", False)) + if dry_run: - return f"DRY-RUN payload: {payload!r}" - if payload is None: - client.clear(APP) - if overlay_state is not None: - overlay_state["last_shape"] = None # device is now genuinely blank - return "all green; cleared" + return f"DRY-RUN payload: {payload!r} led={led_value}" - # Unified shape check (see docstring): applies to this draw regardless - # of which tier produced it -- alert, quiet-green, or an overlay frame. + # Unified shape-clear gate (see the original docstring): the firmware upserts + # by element id within an application_name, so a shape change needs a clear + # first. Spans every frame kind that can draw here. shape = frozenset(e["id"] for e in payload["elements"]) if overlay_state is not None: last_shape = overlay_state.get("last_shape") if last_shape is not None and last_shape != shape: - # clear()'s own success/failure is intentionally not checked - # here, same reasoning as calendar_countdown's transition-clear: - # only draw()'s result below gates the state commit. client.clear(APP) result = client.draw(APP, payload["elements"], priority=payload["priority"], - led_notification_color=payload["led"]) - + led_notification_color=led_value) if result == DrawResult.DRAWN and overlay_state is not None: overlay_state["last_shape"] = shape - if overlay_payload is not None: - overlay_state["frame_index"] = frame_index + 1 - overlay_state["last_dwell_end"] = now + timedelta(seconds=OVERLAY_DWELL_SECONDS) + overlay_state["led_was_on"] = led_should_be_on + overlay_state["frame_index"] = frame_index + 1 + overlay_state["last_dwell_end"] = now + timedelta(seconds=OVERLAY_DWELL_SECONDS) text = next(e["text"] for e in payload["elements"] if e["type"] == "text") return f"{text[:40]!r} -> {result.value}" @@ -375,13 +343,12 @@ def main() -> int: overlay_state: dict = {} quota_cache: dict = {} repo_cache: dict = {} - snooze_state: dict = {} backoff = 5 while True: summary = run_once(client, poller, cfg, datetime.now(timezone.utc), state_cache, args.dry_run, running_cache=running_cache, overlay_state=overlay_state, quota_cache=quota_cache, - repo_cache=repo_cache, snooze_state=snooze_state) + repo_cache=repo_cache) log.info(summary) if args.once: return 0 diff --git a/src/busybar/config.py b/src/busybar/config.py index f132bf2..c3b617d 100644 --- a/src/busybar/config.py +++ b/src/busybar/config.py @@ -74,9 +74,6 @@ "repos_exclude": [], # silence specific repos without leaving account mode "active_within_days": 30, # only repos pushed within this window are polled "repo_refresh_minutes": 60, # how often the repo list itself is re-enumerated - # Alert snooze via the device's native start button (v1.5.2) -- see - # ci_status/README.md's "Snoozing alerts" section. 0 disables. - "snooze_minutes": 30, "running_spinner": True, # animated 8x8 spinner on the running badge }, "nyan_filler": { diff --git a/src/busybar/display.py b/src/busybar/display.py index cd0dbcf..cf9e804 100644 --- a/src/busybar/display.py +++ b/src/busybar/display.py @@ -105,7 +105,9 @@ def overlay_gap_elapsed(last_dwell_end, now) -> float: above PRIORITY_OVERLAY (21) -- so a raised-tier ambient draw can no longer be silently interrupted by an overlay-tier dwell rotation (e.g. the running-CI badge/quota frames) -- and strictly below PRIORITY_ALERT (60) --- a genuine alert still wins over a merely-approaching event. This tier +-- were anything to draw at that tier, it would still win over a +merely-approaching event, though as of v1.7 nothing in this repo does +(see PRIORITY_ALERT's own docstring). This tier exists for the "approach" window: calendar_countdown uses it once an event is within `approach_minutes` of starting but still outside its `notice_minutes` window (see calendar_countdown.logic.select_priority). @@ -117,33 +119,47 @@ def overlay_gap_elapsed(last_dwell_end, now) -> float: """ PRIORITY_ALERT = 60 -"""Urgent, preempting states (e.g. CI failure/stuck badges). Always wins -over PRIORITY_AMBIENT, PRIORITY_OVERLAY, and PRIORITY_AMBIENT_RAISED by -virtue of being a strictly higher number (fact 1 above) -- no dwell/ -silence contract; draw immediately and keep redrawing every poll while -the condition holds. +"""Urgent, preempting states. Always wins over PRIORITY_AMBIENT, +PRIORITY_OVERLAY, and PRIORITY_AMBIENT_RAISED by virtue of being a +strictly higher number (fact 1 above) -- no dwell/silence contract; draw +immediately and keep redrawing every poll while the condition holds. + +No integration in this repo currently draws here -- ci_status originally +drew its failure/stuck badges at this tier (through v1.6) but moved them +down to PRIORITY_OVERLAY (21) in v1.7, joining the calendar's calm +dwell/rotation model instead of unconditionally preempting it. This tier +remains defined as the ladder's alert slot for reference, and for any +future integration whose update pattern genuinely warrants an +unconditional preempt. """ PRIORITY_AMBIENT_URGENT = 65 """An ambient app carrying IMMINENT user-critical information may draw here instead of PRIORITY_AMBIENT (v1.5.2) -- strictly above -PRIORITY_ALERT (60), so it can preempt even a genuine, currently-active -alert (fact 2 means that alert's elements are evicted, not merely -occluded-and-later-restored -- see the eviction/409 interplay in the spec -doc's v1.5.2 section for why this is safe: the alert's own app keeps -trying to redraw every poll per its no-dwell contract, gets a `409` -REJECTED response while this tier holds the screen, treats that as -expected and silent, and re-asserts itself the moment this tier drops -back down -- no cross-process coordination needed). Strictly below -PRIORITY_SESSION (90) -- a real BUSY/CUSTOM work session still wins. - -This tier exists specifically to close an operator-reported UX gap: a -persistent CI failure alert was permanently evicting the calendar, -hiding imminent events with no way for the calendar to ever reclaim the -screen (an ambient app has no dwell/silence contract of its own to fall -back on the way the overlay tier does). calendar_countdown elevates here -once an event enters its `notice_minutes` window and stays here through -`warn_minutes`, reverting to PRIORITY_AMBIENT once the event starts (see +PRIORITY_ALERT (60), so it would preempt even a genuine, currently-active +draw at that tier were anything drawing there (fact 2 means that draw's +elements are evicted, not merely occluded-and-later-restored -- see the +eviction/409 interplay in the spec doc's v1.5.2 section for why this was +designed to be safe: a no-dwell-contract app at PRIORITY_ALERT keeps +trying to redraw every poll, gets a `409` REJECTED response while this +tier holds the screen, treats that as expected and silent, and +re-asserts itself the moment this tier drops back down -- no +cross-process coordination needed). Strictly below PRIORITY_SESSION (90) +-- a real BUSY/CUSTOM work session still wins. + +This tier was originally added to close an operator-reported UX gap: at +the time (v1.5.2), ci_status's persistent CI failure alert -- then drawn +at PRIORITY_ALERT (60) -- was permanently evicting the calendar, hiding +imminent events with no way for the calendar to ever reclaim the screen +(an ambient app has no dwell/silence contract of its own to fall back on +the way the overlay tier does). As of v1.7, ci_status no longer draws at +PRIORITY_ALERT at all -- its failure/stuck frames moved down to +PRIORITY_OVERLAY (21), where they take turns in the same calm dwell/ +rotation as everything else -- so this specific gap no longer arises in +practice. The tier itself, and calendar_countdown's use of it, are +unchanged: calendar_countdown elevates here once an event enters its +`notice_minutes` window and stays here through `warn_minutes`, reverting +to PRIORITY_AMBIENT once the event starts (see calendar_countdown.logic.select_priority) -- deliberately NOT while merely in_progress, since once a meeting has started you already know about it; the elevation exists to catch your attention BEFORE it starts. diff --git a/tests/test_ci_logic.py b/tests/test_ci_logic.py index bb98b87..6337711 100644 --- a/tests/test_ci_logic.py +++ b/tests/test_ci_logic.py @@ -4,27 +4,34 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "integrations")) from ci_status.logic import ( - RepoState, RunningInfo, QuotaInfo, evaluate_runs, build_ci_payload, - build_overlay_payload, overlay_frame_sequence, + RepoState, RunningInfo, QuotaInfo, FailingRun, evaluate_runs, + build_overlay_payload, build_overlay_sequence, OVERLAY_FRAME_CI_BADGE, OVERLAY_FRAME_QUOTA_GQL, OVERLAY_FRAME_QUOTA_REST, - OVERLAY_FRAME_SHAPE, + OVERLAY_FRAME_FAIL, OVERLAY_FRAME_STUCK, OVERLAY_FRAME_GREEN, _pr_or_branch, select_running_run, compute_median_duration_minutes, _format_eta_text, _progress_width, _build_running_title, parse_rate_limit, _quota_headroom, _quota_used_width, resolve_repo_list, _eta_label, RUNNING_NUMERAL_X, RUNNING_LABEL_GAP_PX, - compute_alert_fingerprint, update_snooze, RUN_SPINNER_ID, + RUN_SPINNER_ID, + resolve_ci_led_value, CI_LED_COLOR, LED_OFF_COLOR, LED_OFF_ELEMENTS, ) -from busybar.display import PRIORITY_OVERLAY, OVERLAY_DWELL_SECONDS, PRIORITY_ALERT +from busybar.display import PRIORITY_OVERLAY, OVERLAY_DWELL_SECONDS from calendar_countdown.logic import _text_width_px NOW = datetime(2026, 8, 3, 13, 37, tzinfo=timezone.utc) def run(workflow_id: int, name: str, status: str, conclusion: str | None, - created_min_ago: int = 5) -> dict: + created_min_ago: int = 5, pr_number: int | None = None, + head_branch: str | None = None) -> dict: created = (NOW - timedelta(minutes=created_min_ago)).strftime("%Y-%m-%dT%H:%M:%SZ") - return {"workflow_id": workflow_id, "name": name, "status": status, - "conclusion": conclusion, "created_at": created} + d = {"workflow_id": workflow_id, "name": name, "status": status, + "conclusion": conclusion, "created_at": created} + if pr_number is not None: + d["pull_requests"] = [{"number": pr_number}] + if head_branch is not None: + d["head_branch"] = head_branch + return d def _text_element(elements: list[dict]) -> dict: @@ -70,65 +77,28 @@ def quota_info(**overrides) -> QuotaInfo: def test_failure_detected_on_latest_run_only(): runs = [run(1, "tests", "completed", "success"), # newest for wf 1 run(1, "tests", "completed", "failure", 60), # older failure — ignore - run(2, "lint", "completed", "failure")] + run(2, "lint", "completed", "failure", pr_number=42)] state = evaluate_runs("o/r", runs, NOW, 0) - assert state.failing == ["lint"] and state.stuck == [] + assert state.failing == [FailingRun("lint", "#42")] and state.stuck == [] def test_stuck_queued_detection_respects_threshold(): - runs = [run(1, "tests", "queued", None, created_min_ago=20)] - assert evaluate_runs("o/r", runs, NOW, 15).stuck == ["tests"] + runs = [run(1, "tests", "queued", None, created_min_ago=20, head_branch="main")] + assert evaluate_runs("o/r", runs, NOW, 15).stuck == [FailingRun("tests", "main")] assert evaluate_runs("o/r", runs, NOW, 0).stuck == [] # disabled assert evaluate_runs("o/r", runs, NOW, 30).stuck == [] # under threshold -def test_payload_none_when_green_and_quiet(): - assert build_ci_payload([RepoState("o/r", [], [])], False, 180) is None +def test_failing_run_ref_empty_when_no_pr_or_branch(): + state = evaluate_runs("o/r", [run(1, "tests", "completed", "failure")], NOW, 0) + assert state.failing == [FailingRun("tests", "")] -def test_payload_shows_green_glyph_when_enabled(): - payload = build_ci_payload([RepoState("o/r", [], [])], True, 180) - assert payload["priority"] == PRIORITY_ALERT == 60 - text_el = _text_element(payload["elements"]) - assert text_el["color"] == "#00FF00FF" - # quiet green case has no full-panel background badge - assert not any(e["type"] == "rectangle" for e in payload["elements"]) - - -def test_payload_red_badge_on_failure(): - payload = build_ci_payload([RepoState("o/r", ["tests"], [])], False, 180) - assert payload["priority"] == PRIORITY_ALERT and payload["led"] == "#FF0000FF" - - bg = _bg_element(payload["elements"]) - assert bg["x"] == 0 and bg["y"] == 0 and bg["width"] == 72 and bg["height"] == 16 - assert bg["radius"] == 2 and bg["fill"] == "solid" - assert bg["fill_colors"] == ["#A32D2DFF"] - # default 1px white border would outline the badge; must be disabled - assert bg["border_width"] == 0 - - text_el = _text_element(payload["elements"]) - assert "o/r" in text_el["text"] and "tests" in text_el["text"] - assert text_el["color"] == "#FFFFFFFF" - assert text_el["font"] == "bold" - - -def test_payload_amber_badge_on_stuck_only(): - payload = build_ci_payload([RepoState("o/r", [], ["tests"])], False, 180) - assert payload["led"] is None - - bg = _bg_element(payload["elements"]) - assert bg["fill_colors"] == ["#BA7517FF"] - - text_el = _text_element(payload["elements"]) - assert "stuck" in text_el["text"] - assert text_el["color"] == "#0B0B0BFF" - assert text_el["font"] == "bold" - - -def test_failure_badge_takes_priority_over_stuck(): - payload = build_ci_payload([RepoState("o/r", ["tests"], ["lint"])], False, 180) - bg = _bg_element(payload["elements"]) - assert bg["fill_colors"] == ["#A32D2DFF"] # red failure badge wins +def test_evaluate_sorts_failing_by_workflow_then_ref(): + runs = [run(2, "zeta", "completed", "failure", pr_number=9), + run(1, "alpha", "completed", "failure", pr_number=3)] + state = evaluate_runs("o/r", runs, NOW, 0) + assert state.failing == [FailingRun("alpha", "#3"), FailingRun("zeta", "#9")] # --- PR number / branch fallback ---------------------------------------------- @@ -275,7 +245,7 @@ def test_running_title_no_suffix_when_alone(): def test_overlay_ci_badge_shape(): run_ = running_run(name="tests", pr_number=42, started_min_ago=3) info = running_info(run=run_, median_minutes=14) - payload = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, OVERLAY_DWELL_SECONDS, running=info) + payload = build_overlay_payload({"kind": OVERLAY_FRAME_CI_BADGE}, OVERLAY_DWELL_SECONDS, running=info) assert payload["priority"] == PRIORITY_OVERLAY == 21 assert payload["led"] is None @@ -314,7 +284,7 @@ def test_overlay_ci_badge_shape_no_history_has_no_label(): # 5-element shape (no "eta_label") is what actually draws. run_ = running_run(name="tests", pr_number=42, started_min_ago=3) info = running_info(run=run_, median_minutes=None) - payload = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, OVERLAY_DWELL_SECONDS, running=info) + payload = build_overlay_payload({"kind": OVERLAY_FRAME_CI_BADGE}, OVERLAY_DWELL_SECONDS, running=info) by_id = _by_id(payload["elements"]) assert set(by_id) == {"bg", "title", "track", "track_fill", "eta"} assert by_id["eta"]["text"] == "3m in" @@ -322,12 +292,12 @@ def test_overlay_ci_badge_shape_no_history_has_no_label(): def test_overlay_ci_badge_title_scrolls_when_long(): run_ = running_run(name="a-very-long-workflow-name-that-will-not-fit", pr_number=12345, started_min_ago=1) info = running_info(run=run_, repo="acme/some-long-widgets-repo-name", median_minutes=None) - payload = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, OVERLAY_DWELL_SECONDS, running=info) + payload = build_overlay_payload({"kind": OVERLAY_FRAME_CI_BADGE}, OVERLAY_DWELL_SECONDS, running=info) title = _by_id(payload["elements"])["title"] assert title.get("scroll_rate") == 2000 def test_overlay_ci_badge_none_when_no_running_info(): - assert build_overlay_payload(OVERLAY_FRAME_CI_BADGE, OVERLAY_DWELL_SECONDS, running=None) is None + assert build_overlay_payload({"kind": OVERLAY_FRAME_CI_BADGE}, OVERLAY_DWELL_SECONDS, running=None) is None # --- build_overlay_payload: quota frames ---------------------------------------- @@ -335,7 +305,7 @@ def test_overlay_ci_badge_none_when_no_running_info(): def test_overlay_quota_gql_shape(): info = quota_info(label="GITHUB GRAPHQL", limit=5000, remaining=2600, used=2400, reset_epoch=int(NOW.timestamp()) + 42 * 60) - payload = build_overlay_payload(OVERLAY_FRAME_QUOTA_GQL, OVERLAY_DWELL_SECONDS, + payload = build_overlay_payload({"kind": OVERLAY_FRAME_QUOTA_GQL}, OVERLAY_DWELL_SECONDS, quota_by_bucket={"graphql": info}) assert payload["priority"] == PRIORITY_OVERLAY @@ -355,23 +325,86 @@ def test_overlay_quota_gql_shape(): def test_overlay_quota_rest_uses_core_bucket(): info = quota_info(label="GITHUB REST", limit=5000, remaining=100, used=4900) - payload = build_overlay_payload(OVERLAY_FRAME_QUOTA_REST, OVERLAY_DWELL_SECONDS, + payload = build_overlay_payload({"kind": OVERLAY_FRAME_QUOTA_REST}, OVERLAY_DWELL_SECONDS, quota_by_bucket={"core": info}) by_id = _by_id(payload["elements"]) assert by_id["title"]["text"] == "GITHUB REST" assert by_id["pct"]["text"] == "2%" def test_overlay_quota_none_when_bucket_missing(): - assert build_overlay_payload(OVERLAY_FRAME_QUOTA_GQL, OVERLAY_DWELL_SECONDS, + assert build_overlay_payload({"kind": OVERLAY_FRAME_QUOTA_GQL}, OVERLAY_DWELL_SECONDS, quota_by_bucket={}) is None - assert build_overlay_payload(OVERLAY_FRAME_QUOTA_GQL, OVERLAY_DWELL_SECONDS, + assert build_overlay_payload({"kind": OVERLAY_FRAME_QUOTA_GQL}, OVERLAY_DWELL_SECONDS, quota_by_bucket=None) is None # Wrong bucket present (core but not graphql) -- still None, not a # silent fallback to the wrong data. - assert build_overlay_payload(OVERLAY_FRAME_QUOTA_GQL, OVERLAY_DWELL_SECONDS, + assert build_overlay_payload({"kind": OVERLAY_FRAME_QUOTA_GQL}, OVERLAY_DWELL_SECONDS, quota_by_bucket={"core": quota_info()}) is None +# --- build_overlay_payload: fail/stuck/green frames (descriptor dispatch) ------- + +def test_fail_frame_is_red_badge_at_overlay_tier(): + d = {"kind": OVERLAY_FRAME_FAIL, "repo": "o/r", "run": FailingRun("tests", "#42")} + payload = build_overlay_payload(d, OVERLAY_DWELL_SECONDS) + assert payload["priority"] == PRIORITY_OVERLAY + assert _bg_element(payload["elements"])["fill_colors"] == ["#A32D2DFF"] + t = _text_element(payload["elements"]) + assert t["text"] == "CI FAIL o/r #42 · tests" and t["color"] == "#FFFFFFFF" + +def test_fail_frame_drops_ref_when_empty(): + d = {"kind": OVERLAY_FRAME_FAIL, "repo": "o/r", "run": FailingRun("tests", "")} + assert _text_element(build_overlay_payload(d, 10)["elements"])["text"] == "CI FAIL o/r · tests" + +def test_stuck_frame_is_amber_badge_at_overlay_tier(): + d = {"kind": OVERLAY_FRAME_STUCK, "repo": "o/r", "run": FailingRun("deploy", "#7")} + payload = build_overlay_payload(d, 10) + assert payload["priority"] == PRIORITY_OVERLAY + assert _bg_element(payload["elements"])["fill_colors"] == ["#BA7517FF"] + assert _text_element(payload["elements"])["text"] == "CI stuck o/r #7 · deploy" + +def test_green_frame_is_quiet_text_at_overlay_tier(): + payload = build_overlay_payload({"kind": OVERLAY_FRAME_GREEN}, 10) + assert payload["priority"] == PRIORITY_OVERLAY + assert _text_element(payload["elements"])["color"] == "#00FF00FF" + assert not any(e["type"] == "rectangle" for e in payload["elements"]) + +def test_sequence_orders_fail_then_stuck_then_badge_then_quota(): + states = [RepoState("o/r", [FailingRun("a", "#1")], [FailingRun("b", "#2")])] + seq = build_overlay_sequence(states, running_present=True, + quota_frames=[OVERLAY_FRAME_QUOTA_GQL], show_green=False) + assert [d["kind"] for d in seq] == [ + OVERLAY_FRAME_FAIL, OVERLAY_FRAME_STUCK, OVERLAY_FRAME_CI_BADGE, OVERLAY_FRAME_QUOTA_GQL] + assert seq[0]["run"] == FailingRun("a", "#1") and seq[0]["repo"] == "o/r" + +def test_sequence_one_frame_per_failing_run(): + states = [RepoState("o/r", [FailingRun("a", ""), FailingRun("b", "")], [])] + seq = build_overlay_sequence(states, running_present=False, quota_frames=[], show_green=False) + assert [d["kind"] for d in seq] == [OVERLAY_FRAME_FAIL, OVERLAY_FRAME_FAIL] + +def test_sequence_green_only_when_otherwise_empty(): + empty = [RepoState("o/r", [], [])] + assert [d["kind"] for d in build_overlay_sequence(empty, running_present=False, quota_frames=[], show_green=True)] == [OVERLAY_FRAME_GREEN] + # green suppressed when other content exists + busy = [RepoState("o/r", [FailingRun("a", "")], [])] + assert OVERLAY_FRAME_GREEN not in [d["kind"] for d in build_overlay_sequence(busy, running_present=False, quota_frames=[], show_green=True)] + +def test_sequence_empty_when_nothing_and_green_off(): + assert build_overlay_sequence([RepoState("o/r", [], [])], running_present=False, quota_frames=[], show_green=False) == [] + +def test_sequence_orders_all_failures_before_all_stuck_across_repos(): + states = [ + RepoState("o/a", [FailingRun("fa", "#1")], [FailingRun("sa", "#2")]), + RepoState("o/b", [FailingRun("fb", "#3")], [FailingRun("sb", "#4")]), + ] + seq = build_overlay_sequence(states, running_present=False, quota_frames=[], show_green=False) + assert [d["kind"] for d in seq] == [ + OVERLAY_FRAME_FAIL, OVERLAY_FRAME_FAIL, OVERLAY_FRAME_STUCK, OVERLAY_FRAME_STUCK] + # both failures (both repos) precede both stuck (both repos) + assert [(d["repo"], d["run"].workflow) for d in seq] == [ + ("o/a", "fa"), ("o/b", "fb"), ("o/a", "sa"), ("o/b", "sb")] + + # --- headroom color thresholds (boundaries 50/20) ------------------------------- def test_quota_headroom_high_above_50(): @@ -438,53 +471,6 @@ def test_parse_rate_limit_returns_partial_result(): assert "core" in parsed and "graphql" not in parsed -# --- overlay_frame_sequence: round-robin sequencing ----------------------------- - -def test_overlay_frame_sequence_badge_only_when_quota_disabled(): - assert overlay_frame_sequence(False) == [OVERLAY_FRAME_CI_BADGE] - -def test_overlay_frame_sequence_includes_quota_frames_when_enabled(): - assert overlay_frame_sequence(True) == \ - [OVERLAY_FRAME_CI_BADGE, OVERLAY_FRAME_QUOTA_GQL, OVERLAY_FRAME_QUOTA_REST] - -def test_overlay_frame_shape_distinguishes_badge_from_quota(): - assert OVERLAY_FRAME_SHAPE[OVERLAY_FRAME_CI_BADGE] == "badge" - assert OVERLAY_FRAME_SHAPE[OVERLAY_FRAME_QUOTA_GQL] == "quota" - assert OVERLAY_FRAME_SHAPE[OVERLAY_FRAME_QUOTA_REST] == "quota" - # The two quota frames share a shape (identical element id sets) -- - # only badge<->quota transitions need the id-shape-change clear. - assert OVERLAY_FRAME_SHAPE[OVERLAY_FRAME_QUOTA_GQL] == OVERLAY_FRAME_SHAPE[OVERLAY_FRAME_QUOTA_REST] - - -# --- build_ci_payload: overlay precedence --------------------------------------- - -def test_payload_overlay_takes_priority_over_quiet_green(): - overlay = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, OVERLAY_DWELL_SECONDS, running=running_info()) - payload = build_ci_payload([RepoState("o/r", [], [])], True, 180, overlay=overlay) - assert payload["priority"] == PRIORITY_OVERLAY # overlay beats show_green - assert payload is overlay - -def test_payload_failure_takes_priority_over_overlay(): - overlay = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, OVERLAY_DWELL_SECONDS, running=running_info()) - payload = build_ci_payload([RepoState("o/r", ["tests"], [])], False, 180, overlay=overlay) - assert payload["priority"] == PRIORITY_ALERT # failure wins, not the overlay - bg = _bg_element(payload["elements"]) - assert bg["fill_colors"] == ["#A32D2DFF"] - -def test_payload_stuck_takes_priority_over_overlay(): - overlay = build_overlay_payload(OVERLAY_FRAME_QUOTA_GQL, OVERLAY_DWELL_SECONDS, - quota_by_bucket={"graphql": quota_info()}) - payload = build_ci_payload([RepoState("o/r", [], ["tests"])], False, 180, overlay=overlay) - assert payload["priority"] == PRIORITY_ALERT - bg = _bg_element(payload["elements"]) - assert bg["fill_colors"] == ["#BA7517FF"] - -def test_payload_no_overlay_falls_through_to_quiet_or_green_as_before(): - assert build_ci_payload([RepoState("o/r", [], [])], False, 180, overlay=None) is None - payload = build_ci_payload([RepoState("o/r", [], [])], True, 180, overlay=None) - assert payload["priority"] == PRIORITY_ALERT - - # --- resolve_repo_list (v1.5.1 account-wide watching) ----------------------------- def _account_repo(full_name, pushed_days_ago=1, archived=False): @@ -596,7 +582,7 @@ def test_eta_label_excluded_on_no_history_elapsed_form(): def test_eta_label_x_position_follows_eta_text_width(): run_ = running_run(name="tests", pr_number=42, started_min_ago=1) info = running_info(run=run_, median_minutes=60) # eta = 59m -> "~59m", label fits - payload = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, OVERLAY_DWELL_SECONDS, running=info) + payload = build_overlay_payload({"kind": OVERLAY_FRAME_CI_BADGE}, OVERLAY_DWELL_SECONDS, running=info) by_id = _by_id(payload["elements"]) eta_text = by_id["eta"]["text"] assert by_id["eta_label"]["x"] == RUNNING_NUMERAL_X + _text_width_px(eta_text) + RUNNING_LABEL_GAP_PX @@ -606,219 +592,12 @@ def test_eta_label_x_position_follows_eta_text_width(): def test_eta_label_falls_back_to_left_end_to_end_through_build_overlay_payload(): run_ = running_run(name="tests", pr_number=42, started_min_ago=0) info = running_info(run=run_, median_minutes=60) # eta = 60m -> "~1h00m" - payload = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, OVERLAY_DWELL_SECONDS, running=info) + payload = build_overlay_payload({"kind": OVERLAY_FRAME_CI_BADGE}, OVERLAY_DWELL_SECONDS, running=info) by_id = _by_id(payload["elements"]) assert by_id["eta"]["text"] == "~1h00m" assert by_id["eta_label"]["text"] == "left" -# --- alert snooze via the device's native start button (v1.5.2) ----------------- - -def test_compute_alert_fingerprint_empty_when_all_green(): - assert compute_alert_fingerprint([RepoState("o/r", [], [])]) == frozenset() - -def test_compute_alert_fingerprint_covers_failing_and_stuck(): - states = [RepoState("o/r", ["tests"], ["lint"])] - fp = compute_alert_fingerprint(states) - assert fp == frozenset({("o/r", "tests", "failing"), ("o/r", "lint", "stuck")}) - -def test_compute_alert_fingerprint_category_change_is_a_different_fingerprint(): - failing_fp = compute_alert_fingerprint([RepoState("o/r", ["tests"], [])]) - stuck_fp = compute_alert_fingerprint([RepoState("o/r", [], ["tests"])]) - assert failing_fp != stuck_fp - -def test_compute_alert_fingerprint_multi_repo(): - states = [RepoState("o/a", ["tests"], []), RepoState("o/b", ["build"], [])] - fp = compute_alert_fingerprint(states) - assert fp == frozenset({("o/a", "tests", "failing"), ("o/b", "build", "failing")}) - - -# --- update_snooze: the full state machine --------------------------------------- - -FP_A = frozenset({("o/r", "tests", "failing")}) -FP_B = frozenset({("o/r", "build", "failing")}) # a different fingerprint -EMPTY_FP = frozenset() - -def test_update_snooze_disabled_always_passthrough(): - state = {} - assert update_snooze(FP_A, True, NOW, 0, state) == (False, False) - assert state.get("fingerprint") is None - -def test_update_snooze_no_alert_no_session_is_a_noop(): - state = {} - assert update_snooze(EMPTY_FP, False, NOW, 30, state) == (False, False) - assert "fingerprint" not in state - -def test_update_snooze_alert_alone_no_session_no_pending(): - state = {} - assert update_snooze(FP_A, False, NOW, 30, state) == (False, False) - assert "fingerprint" not in state - -def test_update_snooze_first_ever_poll_with_session_already_active_does_not_pend(): - # Conservative default: session_was_active defaults to True on a - # fresh/never-observed state, so the very first poll (even if - # busy_active happens to be True) is never mistaken for a fresh - # inactive->active transition -- see update_snooze's docstring. - state = {} - assert update_snooze(FP_A, True, NOW, 30, state) == (False, False) - assert "fingerprint" not in state - assert state["session_was_active"] is True - -def test_update_snooze_genuine_transition_establishes_pending(): - state = {} - update_snooze(FP_A, False, NOW, 30, state) # observe inactive first - result = update_snooze(FP_A, True, NOW, 30, state) # now transitions to active - assert result == (False, True) # draw proceeds, LED suppressed - assert state["fingerprint"] == FP_A - assert "snooze_until" not in state - -# --- Critical regression: unpolled (None) cycles must never corrupt -# session_was_active -- reviewer-reproduced bug. An earlier version wrote -# busy_active unconditionally every call, including a dummy False for -# gated-off (idle) polls; a run of idle polls would stamp -# session_was_active=False regardless of the device's real state, so a -# session that started (unobserved) during that idle stretch and was -# already running by the time an alert first appeared would be -# misread as a fresh transition and silently snoozed -- an -# unacknowledged failure. Fixed: busy_active=None means "not polled this -# cycle" and must NOT be committed. - -def test_update_snooze_unpolled_cycles_do_not_corrupt_session_was_active(): - state = {} - # Simulates several idle polls where get_busy() was never called - # (main.run_once passes None in this case). - update_snooze(EMPTY_FP, None, NOW, 30, state) - update_snooze(EMPTY_FP, None, NOW, 30, state) - update_snooze(EMPTY_FP, None, NOW, 30, state) - assert state.get("session_was_active", True) is True # untouched, still the conservative default - -def test_update_snooze_session_predating_alert_does_not_snooze_reviewer_scenario(): - # The exact reviewer-reported scenario: idle polls (unpolled, None) -> - # a session starts DURING that unpolled stretch (never observed) -> - # an alert appears, triggering the first real poll, which correctly - # observes busy_active=True -- but this must NOT be read as a fresh - # transition, since the session predates the alert and the operator - # never acknowledged it. - state = {} - update_snooze(EMPTY_FP, None, NOW, 30, state) # idle poll 1, unpolled - update_snooze(EMPTY_FP, None, NOW, 30, state) # idle poll 2, unpolled - # Session starts here, still unobserved (no alert yet, still not polling). - t1 = NOW + timedelta(seconds=30) - result = update_snooze(FP_A, True, t1, 30, state) # alert appears -- first real poll - assert result == (False, False) # must NOT pend -- no acknowledged transition observed - assert "fingerprint" not in state - -def test_update_snooze_mirror_alert_first_then_session_starts_while_polled(): - # The mirror case (still correct, unaffected by the fix): the alert - # appears FIRST (triggering real polling immediately), observes - # inactive, and only THEN does the session start while polling - # continues -- a genuine, fully-observed transition, so pending - # DOES start, exactly as designed. - state = {} - update_snooze(FP_A, False, NOW, 30, state) # alert showing, polled, session inactive - t1 = NOW + timedelta(seconds=10) - result = update_snooze(FP_A, True, t1, 30, state) # still polled -- session starts - assert result == (False, True) - assert state["fingerprint"] == FP_A - -def test_update_snooze_none_after_established_pending_defensive_stays_pending(): - # Defensive case documented in update_snooze: main.run_once's own - # gating guarantees busy_active is never None while a fingerprint is - # pending (a pending snooze always keeps polling), but the function - # itself treats an (unexpected) None here as "stay pending" rather - # than risk prematurely starting the timed snooze on an unpolled guess. - state = {} - update_snooze(FP_A, False, NOW, 30, state) - update_snooze(FP_A, True, NOW, 30, state) # now pending on FP_A - assert state.get("snooze_until") is None - result = update_snooze(FP_A, None, NOW, 30, state) - assert result == (False, True) # still pending, not prematurely timed - assert "snooze_until" not in state - -def test_update_snooze_stays_pending_while_session_continues(): - state = {} - update_snooze(FP_A, False, NOW, 30, state) - update_snooze(FP_A, True, NOW, 30, state) - later = NOW + timedelta(minutes=2) - assert update_snooze(FP_A, True, later, 30, state) == (False, True) - assert "snooze_until" not in state - -def test_update_snooze_session_end_starts_timed_snooze(): - state = {} - update_snooze(FP_A, False, NOW, 30, state) - update_snooze(FP_A, True, NOW, 30, state) - session_end = NOW + timedelta(minutes=5) - result = update_snooze(FP_A, False, session_end, 30, state) - assert result == (True, False) - assert state["snooze_until"] == session_end + timedelta(minutes=30) - -def test_update_snooze_suppresses_through_the_timed_window(): - state = {} - update_snooze(FP_A, False, NOW, 30, state) - update_snooze(FP_A, True, NOW, 30, state) - session_end = NOW + timedelta(minutes=5) - update_snooze(FP_A, False, session_end, 30, state) - mid_snooze = session_end + timedelta(minutes=10) - assert update_snooze(FP_A, False, mid_snooze, 30, state) == (True, False) - -def test_update_snooze_expires_and_realerts_if_still_failing(): - state = {} - update_snooze(FP_A, False, NOW, 30, state) - update_snooze(FP_A, True, NOW, 30, state) - session_end = NOW + timedelta(minutes=5) - update_snooze(FP_A, False, session_end, 30, state) - after_expiry = session_end + timedelta(minutes=31) - assert update_snooze(FP_A, False, after_expiry, 30, state) == (False, False) - assert "fingerprint" not in state - -def test_update_snooze_fingerprint_change_during_pending_reelerts(): - state = {} - update_snooze(FP_A, False, NOW, 30, state) - update_snooze(FP_A, True, NOW, 30, state) # pending on FP_A - later = NOW + timedelta(minutes=1) - # A different fingerprint appears while still pending on FP_A -- - # clears the old pending. Session is still active (level, not a fresh - # edge for FP_B), so FP_B does NOT get a fresh pending either. - result = update_snooze(FP_B, True, later, 30, state) - assert result == (False, False) - assert "fingerprint" not in state - -def test_update_snooze_fingerprint_change_during_timed_snooze_realerts_immediately(): - state = {} - update_snooze(FP_A, False, NOW, 30, state) - update_snooze(FP_A, True, NOW, 30, state) - session_end = NOW + timedelta(minutes=5) - update_snooze(FP_A, False, session_end, 30, state) # timed snooze on FP_A - mid_snooze = session_end + timedelta(minutes=10) - # A DIFFERENT failure shows up while FP_A is still timed-snoozed and - # no session is running -- must alert immediately, not stay suppressed. - result = update_snooze(FP_B, False, mid_snooze, 30, state) - assert result == (False, False) - assert "fingerprint" not in state - -def test_update_snooze_resolved_then_new_clears_snooze(): - state = {} - update_snooze(FP_A, False, NOW, 30, state) - update_snooze(FP_A, True, NOW, 30, state) - session_end = NOW + timedelta(minutes=5) - update_snooze(FP_A, False, session_end, 30, state) # timed on FP_A - mid_snooze = session_end + timedelta(minutes=10) - # FP_A resolved entirely (nothing failing) -- also a fingerprint change. - result = update_snooze(EMPTY_FP, False, mid_snooze, 30, state) - assert result == (False, False) - assert "fingerprint" not in state - -def test_update_snooze_session_starting_mid_alert_after_continuous_polling(): - # The "normal" full flow, polled continuously (no gating gaps): alert - # appears while no session is running, then a session starts. - state = {} - assert update_snooze(FP_A, False, NOW, 30, state) == (False, False) - t1 = NOW + timedelta(seconds=10) - assert update_snooze(FP_A, False, t1, 30, state) == (False, False) # still no session - t2 = t1 + timedelta(seconds=10) - assert update_snooze(FP_A, True, t2, 30, state) == (False, True) # session starts -- pending - - # --- CI running-badge spinner (v1.6, task 4) --------------------------------------- # # NOTE: uses SPINNER_NOW (not the module-level NOW) -- a distinct name is @@ -837,7 +616,7 @@ def _running(): def test_spinner_present_and_title_reserved_when_on(): - p = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, 10, running=_running(), show_spinner=True) + p = build_overlay_payload({"kind": OVERLAY_FRAME_CI_BADGE}, 10, running=_running(), show_spinner=True) els = p["elements"] spin = next(e for e in els if e["id"] == RUN_SPINNER_ID) assert spin["type"] == "animation" and spin["stock_path"] == "shared/spinner_front_8x8.anim" @@ -846,7 +625,27 @@ def test_spinner_present_and_title_reserved_when_on(): assert title["width"] == 60 # reserved so the scrolling title never runs under the spinner def test_no_spinner_and_full_title_when_off(): - p = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, 10, running=_running(), show_spinner=False) + p = build_overlay_payload({"kind": OVERLAY_FRAME_CI_BADGE}, 10, running=_running(), show_spinner=False) els = p["elements"] assert not any(e["id"] == RUN_SPINNER_ID for e in els) assert next(e for e in els if e["id"] == "title")["width"] == 68 # unchanged + + +# --- failure-driven LED lifecycle (v1.6, task 3) ----------------------------------- + +def test_led_value_red_while_failing(): + assert resolve_ci_led_value(True, False) == CI_LED_COLOR + assert resolve_ci_led_value(True, True) == CI_LED_COLOR + +def test_led_value_explicit_off_on_transition(): + assert resolve_ci_led_value(False, True) == LED_OFF_COLOR + +def test_led_value_omitted_once_already_off(): + assert resolve_ci_led_value(False, False) is None + +def test_led_off_elements_is_single_expiring_placeholder(): + assert len(LED_OFF_ELEMENTS) == 1 + el = LED_OFF_ELEMENTS[0] + assert el["type"] == "rectangle" and el["width"] == 1 and el["height"] == 1 + assert el["fill_colors"] == ["#00000000"] and el["timeout"] == 5 + assert el["border_width"] == 0 diff --git a/tests/test_ci_loop.py b/tests/test_ci_loop.py index d350a3b..8d98990 100644 --- a/tests/test_ci_loop.py +++ b/tests/test_ci_loop.py @@ -29,15 +29,75 @@ def _run(conclusion: str) -> dict: "conclusion": conclusion, "created_at": "2026-08-03T13:30:00Z"} +def _drawn_text(client) -> str: + els = client.draw.call_args.kwargs.get("elements") or client.draw.call_args.args[1] + return next(e["text"] for e in els if e.get("type") == "text") + + def test_draws_red_on_failure(): client = Mock(); client.draw.return_value = DrawResult.DRAWN poller = Mock(); poller.fetch_runs.return_value = [_run("failure")] summary = run_once(client, poller, CFG, NOW, {}, dry_run=False) - assert client.draw.call_args.kwargs["priority"] == 60 + assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY # 21, not 60 assert client.draw.call_args.kwargs["led_notification_color"] == "#FF0000FF" assert "FAIL" in summary +def test_failure_rotates_with_no_running_job(): + # No running_cache at all: a failure must still draw (Request B) -- the old + # code drew a priority-60 alert here; now it's an overlay-tier frame. + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock(); poller.fetch_runs.return_value = [_run("failure")] + run_once(client, poller, CFG, NOW, {}, dry_run=False, overlay_state={}) + assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY + + +def test_failure_frame_respects_dwell_silence(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock(); poller.fetch_runs.return_value = [_run("failure")] + overlay_state: dict = {} + run_once(client, poller, CFG, NOW, {}, dry_run=False, overlay_state=overlay_state) + client.draw.assert_called_once() + client.reset_mock() + soon = NOW + timedelta(seconds=OVERLAY_DWELL_SECONDS - 1) + run_once(client, poller, CFG, soon, {}, dry_run=False, overlay_state=overlay_state) + client.draw.assert_not_called(); client.clear.assert_not_called() + + +def test_led_turns_off_explicitly_when_failure_clears(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock() + overlay_state: dict = {} + # Poll 1: failing -> LED red, led_was_on committed True + poller.fetch_runs.return_value = [_run("failure")] + run_once(client, poller, CFG, NOW, {}, dry_run=False, overlay_state=overlay_state) + assert overlay_state["led_was_on"] is True + client.reset_mock() + # Poll 2: now green, nothing else to draw -> explicit off via LED_OFF_ELEMENTS + poller.fetch_runs.return_value = [_run("success")] + later = NOW + timedelta(seconds=OVERLAY_DWELL_SECONDS + 1) + run_once(client, poller, CFG, later, {}, dry_run=False, overlay_state=overlay_state) + assert client.draw.call_args.kwargs["led_notification_color"] == "#00000000" + assert overlay_state["led_was_on"] is False + + +def test_led_stays_off_omitted_when_already_clear(): + client = Mock() + poller = Mock(); poller.fetch_runs.return_value = [_run("success")] + run_once(client, poller, CFG, NOW, {}, dry_run=False, overlay_state={}) + client.clear.assert_called_once_with("ci_status") # nothing to draw, LED never was on + client.draw.assert_not_called() + + +def test_green_folds_into_rotation_at_overlay_tier(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock(); poller.fetch_runs.return_value = [_run("success")] + cfg = {"ci_status": {**CFG["ci_status"], "show_green": True}} + run_once(client, poller, cfg, NOW, {}, dry_run=False, overlay_state={}) + assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY + assert "ok" in _drawn_text(client) + + def test_clears_when_green(): client = Mock() poller = Mock(); poller.fetch_runs.return_value = [_run("success")] @@ -237,10 +297,14 @@ def test_overlay_state_resets_when_run_ends(): def test_overlay_then_alert_clears_stale_overlay_shape(): # Running badge draws first (shape {bg,title,track,track_fill,eta}); - # the next poll turns up a failure. The alert payload's shape - # ({bg,ci}) differs, so the stale title/track/track_fill/eta ink from - # the badge must be cleared before the alert draws -- not left to - # linger until its own ~1.5x-poll timeout. + # the next poll turns up a failure. Once the rotation's next dwell slot + # lands on the failure frame (shape {bg,ci}), the stale + # title/track/track_fill/eta ink from the badge must be cleared first -- + # not left to linger until its own ~1.5x-poll timeout. Both the failure + # frame and the running badge are now part of the SAME dwell-gated + # rotation (Request B), so `frame_index` is pinned to 0 to deterministically + # land on the (newly added) failure slot rather than depend on where the + # rotation's cursor happens to sit after the composition changed. client = Mock(); client.draw.return_value = DrawResult.DRAWN poller = Mock() poller.fetch_runs.return_value = [_run("success")] @@ -252,17 +316,21 @@ def test_overlay_then_alert_clears_stale_overlay_shape(): client.clear.assert_not_called() # nothing on screen before -- no clear needed yet poller.fetch_runs.return_value = [_run("failure")] + overlay_state["frame_index"] = 0 later = NOW + timedelta(seconds=2 * OVERLAY_DWELL_SECONDS + 1) summary = run_once(client, poller, CFG_RUNNING, later, {}, dry_run=False, running_cache={}, overlay_state=overlay_state) client.clear.assert_called_once_with("ci_status") assert "FAIL" in summary + assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY def test_alert_then_overlay_clears_stale_alert_shape(): # Symmetric direction: an alert draws first (shape {bg,ci}); once it # resolves and a run is active, the running badge's shape ({bg,title, # track,track_fill,eta}) differs and must clear the alert's stale - # elements first. + # elements first. The second poll must land at or beyond a full dwell + # (Request B: the failure frame is dwell-gated like every other overlay + # frame now, unlike the old unconditionally-drawing alert-tier path). client = Mock(); client.draw.return_value = DrawResult.DRAWN poller = Mock() poller.fetch_runs.return_value = [_run("failure")] @@ -272,9 +340,10 @@ def test_alert_then_overlay_clears_stale_alert_shape(): run_once(client, poller, CFG_RUNNING, NOW, {}, dry_run=False, running_cache={}, overlay_state=overlay_state) client.clear.assert_not_called() # first-ever draw -- nothing to clear yet + assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY poller.fetch_runs.return_value = [_run("success")] # alert resolves - later = NOW + timedelta(seconds=1) + later = NOW + timedelta(seconds=2 * OVERLAY_DWELL_SECONDS + 1) run_once(client, poller, CFG_RUNNING, later, {}, dry_run=False, running_cache={}, overlay_state=overlay_state) client.clear.assert_called_once_with("ci_status") @@ -285,6 +354,8 @@ def test_quiet_green_then_overlay_clears_stale_green_shape(): # differs (it has a bg + several more ids) and must clear first, or # the old green text -- drawn with a ~1.5x-poll timeout, e.g. 180s at # the default -- would linger behind/around the badge for minutes. + # Green now draws at the overlay tier too, so the second poll must + # land at or beyond a full dwell for its draw to even be attempted. client = Mock(); client.draw.return_value = DrawResult.DRAWN poller = Mock() poller.fetch_runs.return_value = [_run("success")] @@ -294,10 +365,12 @@ def test_quiet_green_then_overlay_clears_stale_green_shape(): running_cache={}, overlay_state=overlay_state) client.clear.assert_not_called() assert overlay_state["last_shape"] == frozenset({"ci"}) + assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY poller.fetch_running_runs.return_value = [_running_run()] poller.fetch_median_eta.return_value = None - run_once(client, poller, CFG_GREEN, NOW, {}, dry_run=False, + later = NOW + timedelta(seconds=2 * OVERLAY_DWELL_SECONDS + 1) + run_once(client, poller, CFG_GREEN, later, {}, dry_run=False, running_cache={"o/r": []}, overlay_state=overlay_state) client.clear.assert_called_once_with("ci_status") @@ -359,30 +432,33 @@ def test_rotation_shape_change_clears_first(): client.clear.assert_not_called() def test_quota_frame_skipped_without_crashing_when_fetch_fails(): + # No successful rate_limit fetch ever (and quota_cache starts empty), + # so _refresh_quota keeps returning None -- build_overlay_sequence never + # includes a quota frame in the rotation at all (it only folds in frames + # whose data is already on hand), so the CI badge just keeps redrawing + # in its place, no crash, no stale/placeholder quota numbers ever shown. client = Mock(); client.draw.return_value = DrawResult.DRAWN poller = Mock() poller.fetch_runs.return_value = [_run("success")] poller.fetch_running_runs.return_value = [_running_run()] poller.fetch_median_eta.return_value = None - poller.fetch_rate_limit.return_value = None # fetch fails + poller.fetch_rate_limit.return_value = None # fetch fails, no cached data ever overlay_state: dict = {} quota_cache: dict = {} run_once(client, poller, CFG_QUOTA, NOW, {}, dry_run=False, - running_cache={}, overlay_state=overlay_state, quota_cache=quota_cache) # ci_badge, fine + running_cache={}, overlay_state=overlay_state, quota_cache=quota_cache) # ci_badge later = NOW + timedelta(seconds=2 * OVERLAY_DWELL_SECONDS + 1) summary = run_once(client, poller, CFG_QUOTA, later, {}, dry_run=False, running_cache={}, overlay_state=overlay_state, quota_cache=quota_cache) - # quota_gql's turn, but no data -- must not crash, must not draw stale - # data, and must advance so the next call doesn't wait a dwell. The - # skip contract is "no draw, no clear": the previously-drawn ci_badge - # is still within its own dwell timeout and must be left exactly as - # it is, not evicted by an unnecessary clear() call. - assert client.draw.call_count == 1 # only the earlier ci_badge draw - assert client.clear.call_count == 0 # skip path never clears - assert overlay_state["frame_index"] == 2 # advanced past quota_gql - assert "no draw, no clear" in summary + assert client.draw.call_count == 2 # ci_badge drawn again -- no quota frame ever entered rotation + assert "eta" in {e["id"] for e in client.draw.call_args.args[1]} + assert "drawn" in summary def test_quota_stale_data_not_shown_after_5_minutes(): + # Cached quota data older than QUOTA_STALE_SECONDS is treated as + # unavailable by _refresh_quota -- same as a fetch failure -- so it + # never surfaces in the rotation; the CI badge draws again instead of + # showing minutes-old numbers. client = Mock(); client.draw.return_value = DrawResult.DRAWN poller = Mock() poller.fetch_runs.return_value = [_run("success")] @@ -397,10 +473,8 @@ def test_quota_stale_data_not_shown_after_5_minutes(): later = NOW + timedelta(seconds=2 * OVERLAY_DWELL_SECONDS + 1) run_once(client, poller, CFG_QUOTA, later, {}, dry_run=False, running_cache={}, overlay_state=overlay_state, quota_cache=quota_cache) - # quota_gql's turn: cached data exists but is 6 minutes old -- must be - # treated as unavailable, not shown, and (skip contract) not cleared. - assert client.draw.call_count == 1 # only the ci_badge draw landed - assert client.clear.call_count == 0 + assert client.draw.call_count == 2 # ci_badge drawn again -- stale quota data never shown + assert "eta" in {e["id"] for e in client.draw.call_args.args[1]} # --- cadence switch (next_poll_seconds) ----------------------------------------- @@ -601,42 +675,13 @@ def test_config_requires_repos_ok_when_watch_account_repos_key_absent(): # # calendar_countdown can now draw at PRIORITY_AMBIENT_RAISED (25, inside its # approach window) or PRIORITY_AMBIENT_URGENT (65, inside its notice/warn -# window and beyond PRIORITY_ALERT), evicting ci_status's own elements. -# ci_status's own next redraw attempt at its own priority then gets a 409 -# (DrawResult.REJECTED) while the calendar holds the higher tier -- expected -# and silent per busybar.client's own DrawResult.REJECTED docstring. These -# tests confirm ci_status's run_once tolerates that cleanly: no crash, no -# state/shape committed on a REJECTED draw, and a full recovery once the -# calendar drops back down and the next draw actually lands. - -def test_alert_rejected_during_calendar_elevation_does_not_commit_then_recovers(): - client = Mock() - poller = Mock() - poller.fetch_runs.return_value = [_run("failure")] - overlay_state: dict = {} - - # Poll 1: calendar is elevated (PRIORITY_AMBIENT_URGENT=65 > alert's 60) - # -- the alert draw is rejected. - client.draw.return_value = DrawResult.REJECTED - summary1 = run_once(client, poller, CFG, NOW, {}, dry_run=False, overlay_state=overlay_state) - assert "rejected" in summary1 - assert "last_shape" not in overlay_state # nothing committed on a rejected draw - assert client.clear.call_count == 0 # no clear attempted for a first-ever draw attempt - - # Poll 2: still elevated -- same shape, still rejected. Must not crash, - # must not attempt a clear (no shape change on record to clear from). - later = NOW + timedelta(seconds=10) - summary2 = run_once(client, poller, CFG, later, {}, dry_run=False, overlay_state=overlay_state) - assert "rejected" in summary2 - assert "last_shape" not in overlay_state - assert client.clear.call_count == 0 - - # Poll 3: calendar has dropped back down -- the alert draw finally lands. - client.draw.return_value = DrawResult.DRAWN - later2 = later + timedelta(seconds=10) - summary3 = run_once(client, poller, CFG, later2, {}, dry_run=False, overlay_state=overlay_state) - assert "drawn" in summary3 - assert overlay_state["last_shape"] == frozenset({"bg", "ci"}) +# window, above the top alert-priority tier (60)), evicting ci_status's own +# elements. ci_status's own next redraw attempt at its own priority then gets +# a 409 (DrawResult.REJECTED) while the calendar holds the higher tier -- +# expected and silent per busybar.client's own DrawResult.REJECTED +# docstring. This test confirms ci_status's run_once tolerates that cleanly: +# no crash, no state/shape committed on a REJECTED draw, and a full recovery +# once the calendar drops back down and the next draw actually lands. def test_overlay_dwell_rejected_during_calendar_elevation_resumes_after(): client = Mock() @@ -668,215 +713,3 @@ def test_overlay_dwell_rejected_during_calendar_elevation_resumes_after(): assert overlay_state.get("last_dwell_end") is not None assert overlay_state["last_shape"] == frozenset({"bg", "title", "track", "track_fill", "eta"}) assert client.draw.call_count == 2 # both attempts drew (1st rejected, 2nd landed) -- no crash anywhere - - -# --- alert snooze via the device's native start button (v1.5.2) ----------------- - -CFG_SNOOZE = {"ci_status": {**CFG["ci_status"], "snooze_minutes": 30}} - -def _busy(active: bool) -> dict: - return {"type": "SIMPLE" if active else "NOT_STARTED"} - -def test_snooze_get_busy_not_called_when_idle(): - client = Mock() - poller = Mock() - poller.fetch_runs.return_value = [_run("success")] # all green -- no alert - snooze_state: dict = {} - run_once(client, poller, CFG_SNOOZE, NOW, {}, dry_run=False, snooze_state=snooze_state) - client.get_busy.assert_not_called() - -def test_snooze_reviewer_reproduced_scenario_session_predates_alert_no_snooze(): - # Critical regression, full loop level: idle polls (green, get_busy - # gated off -> None passed to update_snooze) while a session is - # ALREADY active (unobserved, since nothing is polling yet) -- then a - # failure appears, triggering the first real get_busy() poll, which - # correctly observes the session as active. This must NOT be read as - # a fresh transition (the session predates the alert; the operator - # never acknowledged this specific failure) -- no pending, no - # suppression, the alert draws normally with its normal LED. - client = Mock(); client.draw.return_value = DrawResult.DRAWN - # If get_busy() were ever (wrongly) called during the idle polls, this - # would make it look like a fresh transition -- it must simply never - # be consulted during those polls at all (see should_poll_busy gating). - client.get_busy.return_value = _busy(True) - poller = Mock() - poller.fetch_runs.return_value = [_run("success")] # green -- idle - state_cache: dict = {} - snooze_state: dict = {} - - run_once(client, poller, CFG_SNOOZE, NOW, state_cache, dry_run=False, snooze_state=snooze_state) - t1 = NOW + timedelta(seconds=10) - run_once(client, poller, CFG_SNOOZE, t1, state_cache, dry_run=False, snooze_state=snooze_state) - client.get_busy.assert_not_called() # confirmed never polled while idle - - # A session is "already active" the whole time (per client.get_busy's - # mocked return value) -- unobserved so far. Now a failure appears. - poller.fetch_runs.return_value = [_run("failure")] - t2 = t1 + timedelta(seconds=10) - s3 = run_once(client, poller, CFG_SNOOZE, t2, state_cache, dry_run=False, snooze_state=snooze_state) - client.get_busy.assert_called_once() # first real poll, triggered by the alert appearing - assert "FAIL" in s3 - assert "fingerprint" not in snooze_state # must NOT have pended - assert client.draw.call_args.kwargs["led_notification_color"] == "#FF0000FF" # normal LED, not suppressed - -def test_snooze_mirror_alert_first_then_session_starts_while_polled_loop_level(): - # Mirror case, full loop level: alert appears first (polling begins - # immediately, observes inactive), then a session starts while still - # polling -- a genuinely observed transition, so pending DOES start. - client = Mock(); client.draw.return_value = DrawResult.DRAWN - poller = Mock() - poller.fetch_runs.return_value = [_run("failure")] - state_cache: dict = {} - snooze_state: dict = {} - - client.get_busy.return_value = _busy(False) - run_once(client, poller, CFG_SNOOZE, NOW, state_cache, dry_run=False, snooze_state=snooze_state) - assert "fingerprint" not in snooze_state - - client.get_busy.return_value = _busy(True) - t1 = NOW + timedelta(seconds=10) - run_once(client, poller, CFG_SNOOZE, t1, state_cache, dry_run=False, snooze_state=snooze_state) - assert snooze_state.get("fingerprint") is not None # pending established, as designed - assert client.draw.call_args.kwargs["led_notification_color"] is None # LED suppressed while pending - -def test_snooze_get_busy_called_when_alert_showing(): - client = Mock(); client.draw.return_value = DrawResult.DRAWN - client.get_busy.return_value = _busy(False) - poller = Mock() - poller.fetch_runs.return_value = [_run("failure")] - snooze_state: dict = {} - run_once(client, poller, CFG_SNOOZE, NOW, {}, dry_run=False, snooze_state=snooze_state) - client.get_busy.assert_called_once() - -def test_snooze_get_busy_skipped_when_snooze_minutes_zero(): - client = Mock(); client.draw.return_value = DrawResult.DRAWN - poller = Mock() - poller.fetch_runs.return_value = [_run("failure")] - snooze_state: dict = {} - run_once(client, poller, CFG, NOW, {}, dry_run=False, snooze_state=snooze_state) # CFG has no snooze_minutes -> 0 - client.get_busy.assert_not_called() - -def test_snooze_full_state_machine_end_to_end_through_run_once(): - client = Mock(); client.draw.return_value = DrawResult.DRAWN - poller = Mock() - poller.fetch_runs.return_value = [_run("failure")] - snooze_state: dict = {} - state_cache: dict = {} - - # Poll 1: alert showing, no session yet -- observe inactive. - client.get_busy.return_value = _busy(False) - s1 = run_once(client, poller, CFG_SNOOZE, NOW, state_cache, dry_run=False, snooze_state=snooze_state) - assert client.draw.call_args.kwargs["led_notification_color"] == "#FF0000FF" - assert "FAIL" in s1 - - # Poll 2: session starts -- pending. Draw still proceeds (elements as - # normal) but LED must be suppressed now. - client.get_busy.return_value = _busy(True) - t1 = NOW + timedelta(seconds=10) - run_once(client, poller, CFG_SNOOZE, t1, state_cache, dry_run=False, snooze_state=snooze_state) - assert client.draw.call_args.kwargs["led_notification_color"] is None - assert "FAIL" in client.draw.call_args.args[1][1]["text"] # still the real alert badge - - # Poll 3: session ends -- timed snooze begins. Alert suppressed entirely - # (falls through to "all green; cleared" since show_green is False and - # no overlay). - client.get_busy.return_value = _busy(False) - t2 = t1 + timedelta(minutes=2) - s3 = run_once(client, poller, CFG_SNOOZE, t2, state_cache, dry_run=False, snooze_state=snooze_state) - assert "cleared" in s3 - - # Poll 4: still within the 30-minute snooze window, same fingerprint -- - # stays suppressed. - t3 = t2 + timedelta(minutes=10) - s4 = run_once(client, poller, CFG_SNOOZE, t3, state_cache, dry_run=False, snooze_state=snooze_state) - assert "cleared" in s4 - - # Poll 5: snooze expired -- alert resumes (still the same failure). - t4 = t2 + timedelta(minutes=31) - client.draw.reset_mock() - s5 = run_once(client, poller, CFG_SNOOZE, t4, state_cache, dry_run=False, snooze_state=snooze_state) - assert "FAIL" in s5 - assert client.draw.call_args.kwargs["led_notification_color"] == "#FF0000FF" - -def test_snooze_fingerprint_change_realerts_during_timed_window(): - client = Mock(); client.draw.return_value = DrawResult.DRAWN - poller = Mock() - state_cache: dict = {} - snooze_state: dict = {} - - poller.fetch_runs.return_value = [_run("failure")] - client.get_busy.return_value = _busy(False) - run_once(client, poller, CFG_SNOOZE, NOW, state_cache, dry_run=False, snooze_state=snooze_state) - client.get_busy.return_value = _busy(True) - t1 = NOW + timedelta(seconds=10) - run_once(client, poller, CFG_SNOOZE, t1, state_cache, dry_run=False, snooze_state=snooze_state) - client.get_busy.return_value = _busy(False) - t2 = t1 + timedelta(minutes=2) - run_once(client, poller, CFG_SNOOZE, t2, state_cache, dry_run=False, snooze_state=snooze_state) - assert "fingerprint" in snooze_state # timed snooze now active for "o/r:tests" - - # A DIFFERENT workflow starts failing while still within the timed - # snooze window -- must alert immediately, not stay suppressed. - def different_failure(repo): - return [{"workflow_id": 2, "name": "lint", "status": "completed", - "conclusion": "failure", "created_at": "2026-08-03T13:30:00Z"}] - poller.fetch_runs.side_effect = different_failure - t3 = t2 + timedelta(minutes=5) - s = run_once(client, poller, CFG_SNOOZE, t3, state_cache, dry_run=False, snooze_state=snooze_state) - assert "FAIL" in s - assert "lint" in s - -def test_snooze_running_and_green_behavior_unaffected_while_suppressed(): - # While an alert is timed-snoozed, the overlay (running badge) - # rotation and quiet-green precedence must behave exactly as if - # nothing were failing at all. - client = Mock(); client.draw.return_value = DrawResult.DRAWN - poller = Mock() - poller.fetch_runs.return_value = [_run("failure")] - poller.fetch_running_runs.return_value = [_running_run()] - poller.fetch_median_eta.return_value = None - state_cache: dict = {} - snooze_state: dict = {} - running_cache: dict = {} - overlay_state: dict = {} - cfg = {"ci_status": {**CFG_RUNNING["ci_status"], "snooze_minutes": 30}} - - client.get_busy.return_value = _busy(False) - run_once(client, poller, cfg, NOW, state_cache, dry_run=False, - running_cache=running_cache, overlay_state=overlay_state, snooze_state=snooze_state) - client.get_busy.return_value = _busy(True) - t1 = NOW + timedelta(seconds=10) - run_once(client, poller, cfg, t1, state_cache, dry_run=False, - running_cache=running_cache, overlay_state=overlay_state, snooze_state=snooze_state) - client.get_busy.return_value = _busy(False) - t2 = t1 + timedelta(minutes=2) - run_once(client, poller, cfg, t2, state_cache, dry_run=False, - running_cache=running_cache, overlay_state=overlay_state, snooze_state=snooze_state) - - # Now timed-snoozed. Poll again after the overlay dwell gap: the - # running badge should draw normally (not suppressed by the snoozed - # alert) since a run is still active. - t3 = t2 + timedelta(seconds=2 * OVERLAY_DWELL_SECONDS + 1) - s = run_once(client, poller, cfg, t3, state_cache, dry_run=False, - running_cache=running_cache, overlay_state=overlay_state, snooze_state=snooze_state) - by_id = {e["id"]: e for e in client.draw.call_args.args[1]} - assert "eta" in by_id # the running badge shape, not the alert's {bg, ci} - assert "drawn" in s - -def test_snooze_state_omitted_is_fully_backward_compatible(): - # Omitting snooze_state entirely (the default) must behave exactly as - # before this feature existed -- no get_busy call, no suppression. - client = Mock(); client.draw.return_value = DrawResult.DRAWN - poller = Mock() - poller.fetch_runs.return_value = [_run("failure")] - run_once(client, poller, CFG_SNOOZE, NOW, {}, dry_run=False) - client.get_busy.assert_not_called() - assert client.draw.call_args.kwargs["led_notification_color"] == "#FF0000FF" - -def test_snooze_dry_run_never_calls_get_busy(): - client = Mock() - poller = Mock() - poller.fetch_runs.return_value = [_run("failure")] - snooze_state: dict = {} - run_once(client, poller, CFG_SNOOZE, NOW, {}, dry_run=True, snooze_state=snooze_state) - client.get_busy.assert_not_called()