diff --git a/config.example.toml b/config.example.toml index e759d5d..474eb8a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -4,7 +4,9 @@ host = "10.0.4.20" # USB-Ethernet default; set your LAN IP for Wi-Fi [calendar_countdown] -poll_seconds = 60 +poll_seconds = 10 # ambient-tier redraw cadence (default: 10) -- matches the running-CI + # overlay's 10s dwell gap so this app's redraws reliably land inside + # it for near-true alternation (see busybar/display.py) lookahead_hours = 12 warn_minutes = 5 # bar/countdown turn red within N minutes of start notice_minutes = 15 # bar/countdown turn amber within N minutes of start @@ -17,4 +19,8 @@ auto_busy = false poll_seconds = 120 repos = ["your-user/your-repo"] show_green = false -# stale_queued_minutes = 15 # omit to disable stuck-queue detection +# stale_queued_minutes = 15 # omit to disable stuck-queue detection +show_running = true # show a badge (alternating with the calendar) while a run is in progress +running_poll_seconds = 20 # poll interval while a run is active (shortened from poll_seconds) +show_quota = true # GraphQL/REST quota frames join the overlay rotation while a run + # is active (no effect if show_running is false) diff --git a/docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md b/docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md index f5d8289..c143cbb 100644 --- a/docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md +++ b/docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md @@ -552,3 +552,355 @@ divider has at least one blank column on each side (checked directly: neither the neighboring text's ink nor the divider's own ink reaches the immediately adjacent column). See the implementation report for the verbatim check output and captures. + +## 2026-08-03 — v1.5 running-CI badge + +**Status:** Implemented (branch `dev/claude/ci-running-v1.5`); revised +in-branch (operator decision) to generalize the priority/dwell mechanics +into a shared framework, tune the alternation rhythm, and add a +GitHub-API-quota overlay -- see "Framework generalization, tuned +alternation, and quota frames" below, added after the original probe +findings and feature description but describing the code's final state. + +New feature: `ci_status` shows an actively-running CI job on the bar, +alternating with the calendar. The design brief assumed a "clean +alternation, zero coordination" mechanism based on the OpenAPI doc's +priority-arbitration description. **Two empirical probes done before any +implementation found the doc's description doesn't match firmware 1.1.1's +actual behavior**, which reshaped the whole approach -- see below before +the feature description, since it changes what "alternating" actually +means here. + +### Probe findings (read this first -- the design depends on it) + +**Finding 1: equal priority from a different `application_name` is +REJECTED, not an override.** The OpenAPI doc states: *"A draw request is +accepted when its priority is >= the priority of the currently running +system app. Equal-priority requests from a different application_name +override whatever is on screen."* Probed directly: with the live +`calendar_countdown` agent showing at priority 20, a draw from a different +`application_name` at priority 20 returned `409 {"error":"Not drawn due to +low priority"}` in every trial (19, 20 both rejected); only priority 21 +(strictly greater) succeeded. **Consequence: the running badge cannot use +priority 20 as the brief specified -- it must use priority 21 +(`RUNNING_PRIORITY` in `ci_status/logic.py`), a deliberate, evidence-backed +deviation from the literal brief.** + +**Finding 2: occluded elements are EVICTED, not restored.** Probed with +two test apps (`probe_a` at priority 21, long timeout; `probe_b` at +priority 22, occluding it) across two scenarios -- (a) `probe_b`'s own +timeout expiring, (b) `probe_b` being explicitly cleared. In **both** +cases, `probe_a`'s still-live element (well within its own 30s timeout) +did **not** reappear; the panel went black instead, and stayed black until +a fresh draw from any app. A follow-up probe confirmed the "currently +running app" baseline resets low enough after eviction that a plain +priority-20 draw from a third app then succeeds again. **Consequence: the +brief's "if occluded elements restore: clean alternation, zero +coordination" branch does not apply. This forces the "if evicted" fallback +design:** the badge occupies the screen for its own timeout, then the +panel goes blank until *some* app performs a fresh draw -- either the +badge's own next cycle, or the calendar's independent 60s redraw landing +in the gap by chance (no cross-process coordination exists, and none was +added). + +**Observed rhythm (on-device, ~130s window against the live +`calendar_countdown` agent, badge at priority 21/10s timeout/20s cadence, +sampled every 2s):** + +``` +sample counts: {'BADGE': 33, 'BLANK': 31} +``` + +The rhythm is **exactly** badge-visible-~10s / blank-~10s, repeating every +~20s cycle -- and the live calendar agent did **not** reclaim the screen +even once across six ~10s gaps spanning more than two of its own 60s +redraw cycles. This is a materially different experience than "badge +alternates with calendar": in practice, while a run is active, the panel +shows the badge roughly half the time and is **dark** the other half; the +calendar effectively does not get airtime back until the CI run finishes +(cadence reverts to `poll_seconds`) or the calendar's redraw happens to +land in a gap by chance, which was not observed in this test window. This +is exactly the kind of deviation the brief asked to be reported +prominently rather than silently redesigned around -- implemented as +specified (fixed ~10s badge timeout, `running_poll_seconds` cadence), not +adjusted to hide the gap, so the operator can judge whether the parameters +need tuning. + +### Feature: running-CI badge (`integrations/ci_status/`) + +Per configured repo, `GET .../actions/runs?status=in_progress&per_page=5` +(separate ETag slot from the existing failure/stuck poll -- different URL, +`RestPoller._running_etags` not `._etags`). Across all repos, the +most-recently-started `in_progress` run is selected (`select_running_run`); +if others are also running, a `+N` suffix is added to the title. + +**Title ribbon** (`small` font, `y=-2`, uppercase, scrolls if it doesn't +fit): `REPO #PR WORKFLOW` (PR number from `run.pull_requests[0].number`; +fork/push runs with an empty `pull_requests` array fall back to +`head_branch`), with a `+N` suffix when other runs are active. + +**Numeral row** (`large` font, `y=5`): ETA remaining, reusing +`calendar_countdown.logic._format_countdown` verbatim (imported across +integrations, along with `ascii_safe`, `_title_fits`, +`SCROLL_RATE`/`SCROLL_DELAY_MS`, `PANEL_WIDTH`/`PANEL_HEIGHT` -- see the +comment at the top of `ci_status/logic.py` for why these specific helpers +are shared while the layout geometry/palette constants are independently +declared per integration). + +- ETA = median duration of the last 5 successful runs of the *same* + workflow (`.../workflows/{workflow_id}/runs?status=success&per_page=5`, + duration = `updated_at - run_started_at`, cached per `workflow_id` for + the process's lifetime in `RestPoller._eta_cache` -- a successful fetch + is cached even if the history is confirmed empty; a network/HTTP error is + **not** cached, so the next encounter retries rather than permanently + locking the workflow into "no history") minus elapsed (`now - + run_started_at`), floored at 0. +- History exists: `"~" + _format_countdown(eta)` (e.g. `"~4m"`, + `"~1h05m"`), or `"soon"` once the estimate floors to under a minute + (covers both "past the median" and "a few seconds under a minute left" -- + neither reads sensibly as `"~0m"`). +- No history: `_format_countdown(elapsed) + " in"` (e.g. `"3m in"`). + +**Track row**: repurposed from the calendar's drain metaphor to a +fill-*up* progress bar: `elapsed / median`, clamped to `[1, PANEL_WIDTH]`, +full width when the median is unknown (same defensive shape as +`_track_fill_width`/`_progress_width`). Solid cyan fill (`RUNNING_TRACK_FILL_COLOR`, +`#29B6F6FF`) -- no gradient, per the brief. + +### Palette and geometry + +A distinct cyan/blue theme, following the same luminance-contrast +principle established in v1.4 (near-black gradient background + bright +saturated text; no mid-luminance "dim-mud" fills): + +| Element | Color | Notes | +|---|---|---| +| `bg` | `#031A2EFF` → `#00060DFF` gradient | deep blue night, distinct hue from every calendar state | +| `title` | `#7FDBFFFF` | bright sky-cyan | +| `track` (groove) | `#0F2A42FF` | decorative, not text-bearing -- same treatment as the calendar's `TRACK_COLOR` | +| `track_fill` | `#29B6F6FF` | solid, saturated cyan (spec: "solid cyan") | +| `eta` (numeral) | `#66E1FFFF` | bright cyan, distinguishable from the title for a small hierarchy | + +Geometry follows the v1.4 "airy" row template (title ribbon / blank buffer +row 5 / full-width horizon-line track at row 6 / numeral row) but is +independently declared in `ci_status/logic.py` (`RUNNING_TITLE_X`, +`RUNNING_TRACK_Y`, etc.) rather than importing the calendar's geometry +constants -- only the *algorithms* are shared across integrations, not the +layout objects, since the two badges are visually similar but structurally +distinct (no divider, no card, a single numeral). + +### Cadence and precedence + +`ci_status` shortens its poll interval to `running_poll_seconds` (new +config key, default 20) while any configured repo has an `in_progress` +run, reverting to `poll_seconds` when idle (`main.next_poll_seconds`, a +pure function of the post-poll `running_cache` so it's unit-testable +without mocking `time.sleep`). The running badge's own element `timeout` +is a fixed `RUNNING_BADGE_TIMEOUT_S = 10` regardless of +`running_poll_seconds`'s configured value -- per the brief ("timeout +~10s"), not derived from the cadence. A much larger or smaller +`running_poll_seconds` than the default would change the observed ratio of +badge-visible to dark time; this is a real configuration interaction, not +a bug, and is called out in the README. + +Precedence in `build_ci_payload`: **failure (60) > stuck (60) > running +(21) > quiet green (60) > nothing**. Failure and stuck are evaluated first +specifically so an active alert always wins over "just" a running-job +status update, even if both conditions are true in the same poll. + +### Config additions (`src/busybar/config.py`, `config.example.toml`) + +Added to `[ci_status]`: `show_running = true`, `running_poll_seconds = 20`. + +### Verification + +On-device: the two probes above; captured frames of all four badge content +variants (PR number, branch fallback, `"soon"`, `"3m in"`) passing the +same ink-overlap + buffer gate used for the v1.4 calendar work (title ⊆ +rows 0-4, `eta` ⊆ rows 7-15, row 5 has no foreground ink, columns 0-1 have +no text ink); the ~130s alternation-rhythm observation above. See the +implementation report for verbatim check output, the probe transcripts, +and captures. + +### Framework generalization, tuned alternation, and quota frames + +Operator decision after reviewing the probe findings above: (1) generalize +the priority/dwell mechanics discovered here into a reusable framework in +the shared package, so future integrations adopt the same pattern instead +of re-deriving it; (2) tune the alternation rhythm toward "near-true" +rather than shipping the ~50%-dark finding as-is; (3) add a rotating +GitHub-API-quota overlay (GraphQL + REST buckets) that joins the running +badge in the same dwell/gap rotation while a run is active. All three +landed together on this branch; this subsection documents the final +design, generalizing the language above (`RUNNING_PRIORITY`, the +running-badge-specific rhythm numbers) into the shared vocabulary below. + +#### Display tier framework (`src/busybar/display.py`) + +The two probe findings above are not specific to the running-CI badge -- +any integration that wants to time-share the screen with another +integration runs into the same firmware behavior. Rather than let each +integration re-derive its own priority number and dwell logic, the ladder +and the shared contracts now live in `src/busybar/display.py`: + +| Tier | Constant | Priority | Contract | +|---|---|---|---| +| Ambient | `PRIORITY_AMBIENT` | 20 | Persistent baseline apps (e.g. the calendar). Redraw at least every `AMBIENT_REDRAW_SECONDS` (10s); element `timeout` = `ambient_timeout(poll_seconds)` (1.5x poll, floored). Must tolerate eviction -- the contract does not promise the screen back, only that redrawing often enough gives it a fair chance to reclaim gaps. | +| Overlay | `PRIORITY_OVERLAY` | 21 | Short-dwell, time-shared frames (e.g. the running badge, quota frames). Draw with `timeout` = `OVERLAY_DWELL_SECONDS` (10s), then stay silent for >= one more dwell period before redrawing (`overlay_gap_elapsed(last_dwell_end, now) >= OVERLAY_DWELL_SECONDS`) -- this is what lets the ambient tier's own redraws land in the gap instead of the overlay tier hogging every cycle. | +| Alert | `PRIORITY_ALERT` | 60 | Urgent, preempting states (CI failure/stuck, calendar BUSY-adjacent alerts). Always strictly above the overlay tier so it preempts unconditionally. | +| Session | `PRIORITY_SESSION` | 90 | Reference only -- the firmware's own BUSY/CUSTOM tier, not something an integration draws at directly. | + +Every tier boundary is a **strictly greater** priority than the one below +it, never equal -- Finding 1 above (equal priority from a different +`application_name` is rejected, not an override) makes "greater-or-equal" +adjacency actively wrong, not just imprecise. `test_display.py` asserts +the ladder is strictly increasing and has no duplicate values, specifically +to catch a future addition that violates this. + +`ambient_timeout(poll_seconds)` and `overlay_gap_elapsed(last_dwell_end, +now)` are the only two helpers factored out; nothing more was built -- +both consumers (`calendar_countdown`, `ci_status`) needed exactly this +much and no more. + +**Join recipe for a future integration:** +- Persistent/background display, no urgency: draw at `PRIORITY_AMBIENT`, + poll at whatever cadence keeps `ambient_timeout(poll)` reasonable, redraw + at least every `AMBIENT_REDRAW_SECONDS` if you want a fair shot at + reclaiming gaps left by any overlay-tier apps sharing the device. +- Short-lived, time-shared status that should rotate with other overlay + content: draw at `PRIORITY_OVERLAY` with `timeout=OVERLAY_DWELL_SECONDS`, + and gate your own redraw on `overlay_gap_elapsed(...) >= + OVERLAY_DWELL_SECONDS` so you don't starve the ambient tier or other + overlay-tier frames sharing the rotation. +- Urgent/preempting: draw at `PRIORITY_ALERT`, and make sure your + precedence logic checks alert conditions before overlay/ambient ones (see + `build_ci_payload`'s failure > stuck > overlay > quiet green > nothing + ordering for the reference implementation). + +#### Tuned alternation: calendar ambient cadence + +`calendar_countdown`'s default `poll_seconds` moved from 60 to 15 and then +to **10**, on-device re-measured at each step (~130s window, 2s sampling, +against the live agent, classifying each sample as the overlay's own +BADGE ink, BLANK, or OTHER -- a genuine non-badge, non-black pixel matched +by exact hex against the calendar's own palette, e.g. `#160A2E` / +`#FFD166`): + +| `poll_seconds` | Dwell cycles recovered (of 6) | Sample counts (BADGE / BLANK / OTHER) | +|---|---|---| +| 60 (pre-v1.5 baseline) | 0 | 33 / 31 / 0 | +| 15 | 2 | 34 / 26 / 5 | +| 10 (shipped default) | 4 | 35 / 20 / 10 | + +10s was chosen because it exactly matches `OVERLAY_DWELL_SECONDS`, giving +the ambient tier's redraw the best mathematical chance of landing inside a +gap without the two independent timers needing any actual coordination. +This is "near-true alternation," not perfect alternation -- 2 of 6 sampled +cycles at 10s still showed no recovery at all, and recovery within a +recovered cycle happened anywhere from ~2s to ~8s into the 10s gap, +because the timers remain uncoordinated by design (no IPC was added; that +was explicitly out of scope). A confirmatory run exercising the full +3-frame overlay rotation (below) independently found the calendar +reclaiming 3 of 4 sampled gap windows at the same 10s/10s setting, +consistent with the standalone measurement. + +#### Quota frames (`show_quota`) + +Two additional overlay-tier frames join the running badge's rotation +while a run is active, cycling `ci_badge -> quota_gql -> quota_rest -> +repeat` (`overlay_frame_sequence`, `OVERLAY_FRAME_*` constants in +`ci_status/logic.py`) -- the running badge always leads, and is the only +frame at all when `show_quota` is off. + +**Data source:** `GET https://api.github.com/rate_limit` +(`RestPoller.fetch_rate_limit`), fetched once per `running_poll_seconds` +cycle while a run is active. This endpoint is documented as **exempt from +GitHub's own rate limiting**, so polling it every cycle costs nothing +against any quota pool -- it exists specifically so clients can check +their standing without spending it. No ETag caching is attempted (unlike +`fetch_median_eta`'s process-lifetime cache) since the values change +continuously and the endpoint is free regardless. `parse_rate_limit` +extracts the `core` (REST) and `graphql` buckets; a response with only one +usable bucket still yields that one rather than discarding both, and a +completely unusable response yields `None`. main.py layers a **5-minute +staleness window** (`QUOTA_STALE_SECONDS`) on top of the raw fetch: a +single failed fetch doesn't blank the frame immediately, but sustained +failures eventually do (`build_overlay_payload` returns `None` for a +frame whose data isn't available, and the caller must skip that dwell +slot entirely -- no draw, no clear, no stale numbers). + +**Frame layout** (v1.4 row template, numeral-floor rule -- every numeral +is `large` font, floored not rounded): title ribbon (`small`, uppercase) +reads `GITHUB GRAPHQL` or `GITHUB REST` -- these were originally +abbreviated `GH GRAPHQL`/`GH REST`, but the operator amended them to the +unabbreviated form; `GITHUB GRAPHQL` exceeds the 68px title ribbon width +at the `small` font and scrolls per the existing title-scroll rule +(`_title_fits`), same as any other overlay title that doesn't fit -- +`GITHUB REST` is short enough to sit static. Track row: fraction of the +bucket used, `width = round(PANEL_WIDTH * used / limit)` clamped to `[1, +PANEL_WIDTH]` (the same defensive clamp shape as every other track-fill +calculation in this codebase; verified against a real GitHub account +where the GraphQL bucket's reported `used` exceeded `limit` -- an observed +live-data quirk in GitHub's point-based GraphQL cost accounting, not a +parsing bug -- and the clamp correctly rendered a full track rather than +overflowing or crashing). Numeral row: percentage *remaining* on the left +(`pct`, e.g. `"18%"`) and reset-in on the right (`reset`, reusing +`_format_countdown`, e.g. `"42m"`), sharing the row the way no other +overlay frame currently does (the running badge has only one numeral). + +**Headroom theming** (background gradient, track-fill, title, and numeral +color all keyed off the same computed `remaining_pct`): + +| Headroom | Remaining | `bg` gradient | `title` | `track_fill` | numerals | +|---|---|---|---|---|---| +| High | > 50% | `#031F17` → `#000A08` | `#6FFFCF` | `#33FFC1` | `#7CFFE0` | +| Medium | 20-50% (inclusive both ends) | `#231400` → `#0A0400` | `#FFCB6B` | `#FFB300` | `#FFD98C` | +| Low | < 20% | `#2E0509` → `#0A0101` | `#FF6B7A` | `#FF3B4E` | `#FF8A96` | + +The 50% boundary belongs to "medium" and the 20% boundary also belongs to +"medium" -- i.e. "low" requires headroom to have genuinely dropped below +20%, not merely reached it. All three palettes follow the v1.4 +near-black-gradient + bright-saturated-text contrast principle used +throughout this codebase. + +**Round-robin and shape-change clears.** `OVERLAY_FRAME_SHAPE` maps +`ci_badge -> "badge"` and both quota frames `-> "quota"`: the badge's +element id set (`eta`) differs from the quota frames' (`pct`, `reset`), +so switching from the badge to either quota frame needs an explicit +`client.clear(APP)` first -- the same upsert-by-id firmware behavior that +required the v1.3.1 calendar transition-clear fix. `quota_gql` and +`quota_rest` share an identical id set, so switching between *those* two +needs no clear. `main.py` tracks the last-drawn shape and clears only on +an actual shape change, not on every dwell slot. + +**On-device verification (real data, no fabrication for the quota +frames):** both quota frames built via the real `RestPoller.fetch_rate_limit` +-> `parse_rate_limit` -> `QuotaInfo` -> `build_overlay_payload` pipeline +(not hand-written payloads), drawn to `preview`, and passed the same +ink-overlap + buffer gate used for the running badge (title ⊆ rows 0-4, +`pct`+`reset` ⊆ rows 7-15, row 5 has no foreground ink, columns 0-1 have no +title ink). Real fetched data at capture time: REST bucket ~97-98% +remaining (high headroom, teal theme); GraphQL bucket varied across +captures from fully exhausted (0% remaining, low/red theme) to ~94% +remaining (high/teal theme) depending on real account activity between +runs -- both headroom tiers were exercised on live data, not synthesized. +Every element's `text` field was enumerated and confirmed to contain only +the bucket label, a percentage, or a countdown string -- no token, +username, or other account-identifying value ever appears in a quota +frame, structurally, since neither field is ever populated from anything +but the numeric `remaining`/`limit`/`reset` values. A separate on-device +run exercised the full 3-frame rotation end to end against the live +calendar agent: draw sequence `ci_badge -> quota_gql -> quota_rest -> +ci_badge`, landing at t=0s, 20.2s, 40.4s, 60.5s (each ~20s apart, matching +10s dwell + 10s gap per frame), all draws returning `200`; the calendar +reclaimed 3 of 4 sampled gap windows in that run, consistent with the +standalone ambient-tuning measurement above. + +#### Config additions (final state) + +`[ci_status]` gained, cumulative with the original round: `show_running = +true`, `running_poll_seconds = 20`, and now `show_quota = true`. `show_quota` +has no effect when `show_running` is false, since the quota frames only +ever appear as part of the running badge's own rotation. `calendar_countdown`'s +`poll_seconds` default changed from 60 to 10 (see the tuning table above); +existing configs that set `poll_seconds` explicitly are unaffected. diff --git a/integrations/calendar_countdown/README.md b/integrations/calendar_countdown/README.md index d76d066..3a05647 100644 --- a/integrations/calendar_countdown/README.md +++ b/integrations/calendar_countdown/README.md @@ -11,7 +11,7 @@ This integration polls your macOS calendar for upcoming events and displays a li - **Countdown** — a large minutes-granular countdown, same size as the time/ends numeral (they always change together): `"54m"` under an hour, `"1h05m"` at/above an hour, falling back to hour-only (`"9h"`) whenever the combined form would run too wide for the space available — which font-width measurement shows can happen even for some single-digit-hour values, not just at 10+ hours. Counts to the event start while upcoming, or to its end once in progress; re-rendered each poll rather than ticking natively on-device, so it updates on the same cadence as the rest of the display (`poll_seconds`). - **Four states** — `normal`, `notice` (within `notice_minutes` of start), `warning` (within `warn_minutes` of start), and `in_progress`, each with its own background gradient, title color, drain-track gradient, divider color, and digit color. See the design spec (`docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md`) for the full palette table and row-budget diagram. -The integration looks ahead 12 hours by default and draws at priority 20 on the display. If an active BUSY session exists on the device, the calendar event display is suppressed in favor of the busy state (priority 90). +The integration looks ahead 12 hours by default and draws at the ambient tier (`busybar.display.PRIORITY_AMBIENT`, priority 20) on the display. If an active BUSY session exists on the device, the calendar event display is suppressed in favor of the busy state (priority 90). If the `ci_status` integration is also running with `show_running` and/or `show_quota` enabled, the calendar and the overlay rotation (running badge, GraphQL/REST quota frames) trade the screen back and forth for as long as a CI run is active — see `ci_status`'s README ("Display Priority Tiers" / alternation rhythm) for the measured numbers; the panel still goes fully dark for a few seconds in most cycles because the firmware never restores an occluded element on its own (see "Display Priority Tiers" below), but at the tuned 10s ambient poll the calendar now recovers the screen in roughly 3 of every 4 overlay dwell gaps rather than effectively never. ## Requirements @@ -55,7 +55,8 @@ Edit `config.toml` and configure the `[calendar_countdown]` section: ```toml [calendar_countdown] -poll_seconds = 60 # how often to check the calendar (default: 60) +poll_seconds = 10 # how often to check the calendar (default: 10 -- ambient-tier + # redraw cadence; see "Display Priority Tiers" below) lookahead_hours = 12 # how far ahead to scan (default: 12) warn_minutes = 5 # bar/countdown turn red within N minutes of start (default: 5) notice_minutes = 15 # bar/countdown turn amber within N minutes of start (default: 15) @@ -79,7 +80,7 @@ Verify that the output shows your next upcoming event with the correct countdown | Key | Type | Default | Purpose | |---|---|---|---| -| `poll_seconds` | integer | 60 | Polling interval in seconds | +| `poll_seconds` | integer | 10 | Polling interval in seconds (ambient-tier redraw cadence; raise it if 10s polling is more than your calendar setup needs, but see "Display Priority Tiers" below for the alternation tradeoff) | | `lookahead_hours` | integer | 12 | Hours into the future to scan for events | | `warn_minutes` | integer | 5 | Bar/countdown turn red when within N minutes of event start | | `notice_minutes` | integer | 15 | Bar/countdown turn amber when within N minutes of event start | @@ -120,3 +121,17 @@ Stdout and stderr are redirected to `~/Library/Logs/busybar/calendar.log`. View ```bash tail -f ~/Library/Logs/busybar/calendar.log ``` + +At the default 10s poll cadence, most polls redraw the same event unchanged. To avoid multiplying the log's line rate versus the old 60s cadence sixfold, draw summaries are logged at `INFO` only when the summary actually changes or every 10 minutes (a heartbeat line), and at `DEBUG` otherwise -- `DEBUG` isn't emitted by the default log level, so routine unchanged polls don't appear in `calendar.log` at all. Run with `python -m logging` verbosity raised, or check the process's own stdout in the foreground, if you need to see every single poll. + +## Display Priority Tiers + +This integration draws at the **ambient** tier (`busybar.display.PRIORITY_AMBIENT`, priority 20) -- see `src/busybar/display.py` for the full shared priority ladder used across every busybar integration, and the design spec's "Display tier framework" section for the two firmware facts (measured, not assumed) that shape it: a different app can only preempt this one with a strictly higher priority (equal priority is rejected outright), and once preempted, this app's elements are evicted rather than restored -- the calendar only gets the screen back via its own next scheduled redraw, never automatically. The 10s default poll interval exists specifically so those redraws happen often enough to reliably interleave with the `ci_status` overlay tier (priority 21, ~10s dwell gaps, now rotating through the running badge plus two quota frames -- see `ci_status`'s README) during an active CI run. On-device re-measurement across three poll settings, sampled every 2s for ~130s against the live agent: + +| Calendar `poll_seconds` | Dwell cycles recovered | Sample split (BADGE / BLANK / OTHER=calendar) | +|---|---|---| +| 60 (pre-v1.5 baseline) | 0 of 6 | 33 / 31 / 0 | +| 15 | 2 of 6 | 34 / 26 / 5 | +| 10 (current default) | 4 of 6 | 35 / 20 / 10 | + +Matching the poll to the dwell gap exactly (10s) did not eliminate the dark gaps entirely -- the two timers still run independently with no cross-process coordination, so recovery timing within a gap varies (observed roughly 2-8s into a given 10s gap) and 2 of the 6 sampled cycles still showed no recovery at all -- but it took the calendar from "never recovers" to "recovers in most cycles." A separate on-device run exercising the full 3-frame overlay rotation (running badge -> GraphQL quota -> REST quota -> repeat) at the same 10s dwell showed the same pattern: the calendar reclaimed 3 of the 4 gap windows sampled. If your setup still shows the panel dark for more than a few seconds at a stretch, that is consistent with this measurement, not a bug; lowering `poll_seconds` further has diminishing returns since the elements' own render/transmit latency puts a floor on how tightly the two timers can align. diff --git a/integrations/calendar_countdown/main.py b/integrations/calendar_countdown/main.py index 27732a3..68a8a73 100644 --- a/integrations/calendar_countdown/main.py +++ b/integrations/calendar_countdown/main.py @@ -13,12 +13,17 @@ from busybar.client import BusyBarClient, DrawResult from busybar.config import load_config +from busybar.display import PRIORITY_AMBIENT, ambient_timeout from .logic import (ascii_safe, build_elements, select_active_event, select_next_event) APP = "calendar_countdown" -PRIORITY = 20 +# Ambient-tier priority (see busybar.display for the full ladder contract +# and the two firmware facts it's built on). Was a local PRIORITY=20 +# constant before v1.5's shared display-tier framework. +PRIORITY = PRIORITY_AMBIENT +HEARTBEAT_SECONDS = 600 log = logging.getLogger(APP) @@ -40,7 +45,7 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, every poll. """ c = cfg["calendar_countdown"] - timeout_s = int(c["poll_seconds"] * 1.5) + timeout_s = ambient_timeout(c["poll_seconds"]) events = fetch(c["lookahead_hours"]) active = select_active_event(events, now) @@ -94,6 +99,21 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, return f"drew {label} -> {result.value}" +def should_log_info(summary: str, last_logged_summary: str | None, + seconds_since_heartbeat: float, + heartbeat_seconds: int = HEARTBEAT_SECONDS) -> bool: + """Log-noise control for the v1.5 poll-cadence drop (poll_seconds + 60 -> 10 as the ambient-tier default): at 10s polling, logging every + summary at INFO would sixfold the audit log's line rate versus the + old 60s cadence for no new information on most polls (the summary is + usually identical poll to poll). INFO only when the summary actually + changed since the last INFO line, or a heartbeat interval has elapsed + (so a long unchanging run still leaves a periodic "yes, I'm alive" + trail) -- DEBUG otherwise. + """ + return summary != last_logged_summary or seconds_since_heartbeat >= heartbeat_seconds + + def main() -> int: parser = argparse.ArgumentParser(description="BUSY Bar calendar countdown") parser.add_argument("--once", action="store_true") @@ -131,9 +151,17 @@ def main() -> int: backoff = 5 state: dict = {} + last_logged_summary: str | None = None + last_heartbeat = time.monotonic() while True: summary = run_once(client, fetch, cfg, datetime.now(timezone.utc), args.dry_run, state=state) - log.info(summary) + now_monotonic = time.monotonic() + if args.once or should_log_info(summary, last_logged_summary, now_monotonic - last_heartbeat): + log.info(summary) + last_logged_summary = summary + last_heartbeat = now_monotonic + else: + log.debug(summary) if args.once: return 0 if summary.endswith(DrawResult.UNREACHABLE.value): diff --git a/integrations/ci_status/README.md b/integrations/ci_status/README.md index 39e6541..b7ac68f 100644 --- a/integrations/ci_status/README.md +++ b/integrations/ci_status/README.md @@ -2,7 +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 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. 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. + +**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. ## Requirements @@ -45,6 +47,10 @@ poll_seconds = 120 # how often to check workflows (default: 120) repos = ["your-user/your-repo"] # list of repos to monitor show_green = false # display green builds (default: false) # stale_queued_minutes = 15 # optional: alert if runs stuck queued for N minutes +show_running = true # show a badge while a run is in progress (default: true) +running_poll_seconds = 20 # poll interval while a run is active (default: 20) +show_quota = true # GraphQL/REST quota frames join the overlay rotation while a + # run is active (default: true; no effect if show_running is false) ``` At minimum, set `repos` to the repositories you want to monitor (e.g., `["owner/repo1", "owner/repo2"]`). @@ -72,6 +78,93 @@ Once the foreground test completes, your `config.toml` is in place and GitHub au | `repos` | array of strings | — | GitHub repositories to monitor in `owner/repo` format (required) | | `show_green` | boolean | false | Display successful/green workflow status (default: off to reduce noise) | | `stale_queued_minutes` | integer | (disabled) | Alert if a workflow run has been queued for N minutes without starting (optional; useful to catch offline self-hosted runners) | +| `show_running` | boolean | true | Show the running-CI badge while a run is `in_progress` (across all configured repos; most-recently-started wins, `+N` if others are also running) | +| `running_poll_seconds` | integer | 20 | Poll interval while a run is active (shortened from `poll_seconds`) | +| `show_quota` | boolean | true | Join two GitHub API quota frames (GraphQL, REST) to the overlay rotation while a run is active. No effect if `show_running` is false — the quota frames only ever appear as part of that same rotation. | + +## 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): + +- **Equal priority from a different `application_name` is rejected + outright**, not treated as a hand-off, contrary to what the device's own + API documentation claims. This is why the overlay tier lives at its own + priority (`PRIORITY_OVERLAY`, 21) strictly above the calendar's ambient + tier (`PRIORITY_AMBIENT`, 20) rather than reusing it. +- **A preempted app's elements are evicted, not restored.** Once an + overlay-tier draw's own timeout expires, the panel goes dark; the + calendar's last draw does not silently reappear underneath. The calendar + only gets the screen back via its own next scheduled redraw landing in + 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). + +## 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: + +1. **Running badge** (always first, always present when `show_running` is + on): `REPO #PR WORKFLOW` (or `REPO branch-name WORKFLOW` for + fork/push-triggered runs, which don't have a PR number) across the top, + with `+N` appended if other runs are also active; an ETA below (`~4m`, + `~1h05m` — reusing the calendar countdown's own formatter — or `soon` + once the estimate is under a minute, or `3m in` when there's no + successful-run history yet to estimate from); and a thin progress line + tracking elapsed time against the workflow's typical duration (median + of its last 5 successful runs, cached for the life of the process). +2. **GraphQL quota** (`show_quota`): title ribbon `GITHUB GRAPHQL`, a track bar + showing the fraction of the bucket used, and two numerals — percentage + *remaining* on the left, reset-in on the right (e.g. `18%` / `42m`). +3. **REST quota** (`show_quota`): identical layout, title ribbon `GITHUB REST`. + +Each quota frame is built from a single `GET /rate_limit` call, fetched +fresh once per `running_poll_seconds` cycle while a run is active — this +endpoint is explicitly **exempt from GitHub's own rate limiting**, so +polling it does not consume any other quota pool. If that fetch fails, or +the last successful fetch is more than 5 minutes stale, the quota frames +are silently dropped from that cycle's rotation (never a crash, never +stale numbers on screen) — the running badge keeps rotating on its own. +Percentages and reset countdowns are the only numbers shown; no token, +username, or other account-identifying text ever appears in a quota +frame (both fields are computed purely from the numeric `remaining` / +`limit` / `reset` values in the API response). + +**Headroom theming.** Each quota frame's background gradient, track-fill +color, title color, and numeral color all key off remaining-quota +headroom, computed from the same fetch: + +| Headroom | Remaining | Background gradient | Title / numeral / track-fill | +|---|---|---|---| +| High | > 50% | `#031F17` → `#000A08` (teal-black) | `#6FFFCF` / `#7CFFE0` / `#33FFC1` | +| Medium | 20–50% | `#231400` → `#0A0400` (amber-black) | `#FFCB6B` / `#FFD98C` / `#FFB300` | +| Low | < 20% | `#2E0509` → `#0A0101` (red-black) | `#FF6B7A` / `#FF8A96` / `#FF3B4E` | + +**Important: none of this alternates cleanly with the calendar**, for the +same firmware reasons as the running badge alone did before quota frames +existed — see "Display Priority Tiers" above. In practice, while CI is +running, expect the panel to spend roughly half its time showing an +overlay-tier frame (running badge or a quota frame) and the rest either +dark or reclaimed by the calendar, not a clean three-way handoff. On-device +re-measurement after tuning the calendar's own poll interval to 10s (see +`calendar_countdown`'s README for the full three-round table) found the +calendar recovering 4 of 6 sampled dwell gaps in a standalone measurement, +and 3 of 4 gap windows in a separate run that exercised the full 3-frame +rotation end to end — draw sequence `ci_badge → quota_gql → quota_rest → +ci_badge`, each landing ~20s apart (10s dwell + 10s gap), confirmed +against the live device. This is a known limitation of the current +zero-cross-process-coordination design, not a bug; the fixed 10s dwell +(`OVERLAY_DWELL_SECONDS` in `src/busybar/display.py`) and the calendar's +own `poll_seconds` are the two knobs that shape the ratio. ### Stale Queued Detection diff --git a/integrations/ci_status/github.py b/integrations/ci_status/github.py index 4b2281b..8d12b79 100644 --- a/integrations/ci_status/github.py +++ b/integrations/ci_status/github.py @@ -26,14 +26,28 @@ class RestPoller: def __init__(self, token: str): self._token = token self._etags: dict[str, str] = {} + # Separate ETag slot for the running-runs poll: different URL query + # (status=in_progress&per_page=5 vs the unfiltered per_page=10 used + # by fetch_runs), so it gets its own conditional-request cache + # rather than sharing (and corrupting) fetch_runs's ETag. + self._running_etags: dict[str, str] = {} + # ETA history is cached per workflow_id for the process's lifetime + # (not per-poll): a workflow's recent successful-run durations don't + # change fast enough to be worth re-fetching every poll, unlike the + # running-runs check which must be fresh every cycle to track + # elapsed time. Populated lazily by fetch_median_eta. + self._eta_cache: dict[int, float | None] = {} - def fetch_runs(self, repo: str) -> list[dict] | None: - url = f"{API}/repos/{repo}/actions/runs" - headers = { + def _headers(self) -> dict: + return { "Authorization": f"Bearer {self._token}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", } + + def fetch_runs(self, repo: str) -> list[dict] | None: + url = f"{API}/repos/{repo}/actions/runs" + headers = self._headers() if repo in self._etags: headers["If-None-Match"] = self._etags[repo] try: @@ -54,3 +68,91 @@ def fetch_runs(self, repo: str) -> list[dict] | None: except ValueError as exc: log.warning("github returned non-JSON: %s", exc) return None + + def fetch_running_runs(self, repo: str) -> list[dict] | None: + """GET .../actions/runs?status=in_progress&per_page=5 -- a distinct + URL from fetch_runs, so it uses its own ETag slot (_running_etags, + not _etags).""" + url = f"{API}/repos/{repo}/actions/runs" + headers = self._headers() + if repo in self._running_etags: + headers["If-None-Match"] = self._running_etags[repo] + try: + resp = requests.get(url, headers=headers, + params={"status": "in_progress", "per_page": 5}, + timeout=(5, 15)) + except requests.RequestException as exc: + log.debug("github unreachable (running poll): %s", exc) + return None + if resp.status_code == 304: + return None + if resp.status_code != 200: + log.warning("github %s for %s (running poll)", resp.status_code, repo) + return None + if "ETag" in resp.headers: + self._running_etags[repo] = resp.headers["ETag"] + try: + return resp.json().get("workflow_runs", []) + except ValueError as exc: + log.warning("github returned non-JSON (running poll): %s", exc) + return None + + def fetch_median_eta(self, repo: str, workflow_id: int) -> float | None: + """Median duration (minutes) of the last 5 successful runs of + `workflow_id`, cached for the process's lifetime once successfully + fetched (see the cache comment in __init__). Returns None on a + confirmed empty history (a successful fetch with no matching runs + -- this IS cached, it's a real answer) as well as on a fetch + failure (NOT cached -- a transient network/HTTP error shouldn't + permanently lock the workflow into "no history" for the rest of + the process's life; the next encounter retries).""" + if workflow_id in self._eta_cache: + return self._eta_cache[workflow_id] + url = f"{API}/repos/{repo}/actions/workflows/{workflow_id}/runs" + try: + resp = requests.get(url, headers=self._headers(), + params={"status": "success", "per_page": 5}, + timeout=(5, 15)) + except requests.RequestException as exc: + log.debug("github unreachable (median fetch): %s", exc) + return None + if resp.status_code != 200: + log.warning("github %s for %s workflow %s (median fetch)", + resp.status_code, repo, workflow_id) + return None + try: + runs = resp.json().get("workflow_runs", []) + except ValueError as exc: + log.warning("github returned non-JSON (median fetch): %s", exc) + return None + from .logic import compute_median_duration_minutes + median = compute_median_duration_minutes(runs) + self._eta_cache[workflow_id] = median # cache even if None -- see docstring + return median + + def fetch_rate_limit(self) -> dict | None: + """GET /rate_limit -- explicitly EXEMPT from GitHub's own rate + limiting (checking your quota doesn't spend it), so this is a + plain fresh GET every call: no ETag/conditional-request caching + attempted (there's no quota cost to save) and no per-process cache + either (unlike fetch_median_eta's history, remaining quota changes + continuously and a cached value would go stale within the poll + interval). Returns the raw parsed response (`{"resources": + {"core": {...}, "graphql": {...}, ...}, ...}`) for `logic. + parse_rate_limit` to extract from -- unlike the other fetch_* + methods, no `workflow_runs` unwrapping happens here since the + caller needs the whole `resources` object, not one list. + """ + try: + resp = requests.get(f"{API}/rate_limit", headers=self._headers(), timeout=(5, 15)) + except requests.RequestException as exc: + log.debug("github unreachable (rate_limit fetch): %s", exc) + return None + if resp.status_code != 200: + log.warning("github %s (rate_limit fetch)", resp.status_code) + return None + try: + return resp.json() + except ValueError as exc: + log.warning("github returned non-JSON (rate_limit fetch): %s", exc) + return None diff --git a/integrations/ci_status/logic.py b/integrations/ci_status/logic.py index bcd5ef9..46646b8 100644 --- a/integrations/ci_status/logic.py +++ b/integrations/ci_status/logic.py @@ -1,5 +1,34 @@ +import sys from dataclasses import dataclass from datetime import datetime, timezone +from pathlib import Path + +# Reuse the calendar integration's generic text/formatting helpers rather +# than reimplementing them: ascii_safe (device text sanitization), +# _format_countdown (minutes-granular "~4m" / "~1h05m" formatting, reused +# verbatim by the running badge's ETA text and the quota frames' reset-in +# text per the v1.5 spec), _title_fits (scroll-vs-static decision, +# parameterized by width so it's not calendar-geometry-specific), +# SCROLL_RATE/SCROLL_DELAY_MS (the same scroll timing), and +# PANEL_WIDTH/PANEL_HEIGHT (hardware constants, not actually +# calendar-specific despite living in that module). Geometry and palette +# below are ci_status's own -- only the algorithms are shared, not the +# layout constants, since the two integrations' layouts are visually +# similar (v1.4 "airy" language) but structurally distinct. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from calendar_countdown.logic import ( # noqa: E402 + ascii_safe, _format_countdown, _title_fits, + SCROLL_RATE, SCROLL_DELAY_MS, PANEL_WIDTH, PANEL_HEIGHT, +) + +# Shared display priority ladder (v1.5) -- see busybar/display.py for the +# full contract and the two firmware facts it's built on (equal priority +# from a different app is rejected, not an override; occluded elements are +# evicted, not restored). Formerly local constants here (RUNNING_PRIORITY, +# 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 FAILING = {"failure", "timed_out", "startup_failure"} @@ -11,6 +40,31 @@ class RepoState: stuck: list[str] +@dataclass +class RunningInfo: + """Everything build_overlay_payload needs to render the running-CI + badge frame, pre-computed by the caller (main.run_once) so the render + logic here stays pure and testable without network mocking.""" + run: dict + repo: str + other_count: int + median_minutes: float | None + now: datetime + + +@dataclass +class QuotaInfo: + """Everything build_overlay_payload needs to render one quota frame + (GraphQL or REST bucket), pre-computed by the caller -- same pattern + and rationale as RunningInfo.""" + label: str # "GITHUB GRAPHQL" or "GITHUB REST" + limit: int + remaining: int + used: int + reset_epoch: int + now: datetime + + def _parse_ts(value: str) -> datetime: return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) @@ -31,6 +85,433 @@ def evaluate_runs(repo: str, runs: list[dict], now: datetime, return RepoState(repo=repo, failing=sorted(failing), stuck=sorted(stuck)) +# --- overlay tier: shared row template (v1.5) ----------------------------------- +# +# Firmware priority-arbitration finding that shapes this whole section +# (empirical probe, see the implementation report and spec doc's v1.5 +# section for the full write-up): the OpenAPI doc claims "equal-priority +# requests from a different application_name override whatever is on +# screen," but probing found this is FALSE on firmware 1.1.1 -- a +# different-application_name draw at the SAME priority as the currently +# showing app is REJECTED (409 "low priority"); only a STRICTLY GREATER +# priority succeeds. This is why PRIORITY_OVERLAY (21) is strictly greater +# than the calendar's PRIORITY_AMBIENT (20) -- see busybar/display.py. +# +# Second finding: occluded elements do NOT reappear once the occluder's +# elements expire or are cleared -- they are evicted, not merely hidden. +# There is no cross-process coordination between integrations; an ambient +# app can only win the screen back with its own fresh draw landing in a +# gap. `busybar.display.overlay_gap_elapsed` plus the dwell/silence +# contract exist to make that gap real and predictable (see main.py's +# rotation loop for how the gate is applied). +# +# Geometry: the v1.4 "airy" row template (title ribbon / blank buffer row / +# full-width horizon-line track / numeral row) shared by every overlay-tier +# frame (the running badge and both quota frames below) -- independently +# declared here rather than imported from the calendar, since the two +# integrations' layouts are visually similar but structurally distinct. +OVERLAY_TITLE_X = 2 +OVERLAY_TITLE_Y = -2 # ink rows 0-4 (small font); see calendar_countdown.logic + # for the underlying +2px text ink-offset model this assumes +OVERLAY_TITLE_WIDTH = 68 # 2px margin each side of a 72px panel +OVERLAY_TRACK_Y = 6 # row 5 is a deliberate blank buffer, same as v1.4 calendar +OVERLAY_TRACK_HEIGHT = 1 +OVERLAY_NUMERAL_Y = 5 # ink rows 7-15 (large font, 9px) -- every overlay-tier + # numeral uses this same y and the `large` font ("numerals + # track together": never independently resized) + +# Back-compat aliases (pre-quota-frame names) -- kept because they're +# reasonably self-documenting at their one remaining call site +# (_build_running_elements) and renaming them there too would be pure churn. +RUNNING_TITLE_X, RUNNING_TITLE_Y, RUNNING_TITLE_WIDTH = OVERLAY_TITLE_X, OVERLAY_TITLE_Y, OVERLAY_TITLE_WIDTH +RUNNING_TRACK_Y, RUNNING_TRACK_HEIGHT = OVERLAY_TRACK_Y, OVERLAY_TRACK_HEIGHT +RUNNING_NUMERAL_Y = OVERLAY_NUMERAL_Y +RUNNING_NUMERAL_X = OVERLAY_TITLE_X # the running badge's single numeral sits at the + # same left margin as the title/quota "pct" numeral + + +# --- running-job badge ----------------------------------------------------------- + +# Palette: a distinct cyan/blue "running" theme, following the same +# luminance-contrast principle established in the v1.4 calendar work (near- +# black surface + bright saturated text; no mid-luminance "dim-mud" fills). +# The track's own groove color is a decorative element, not a text-bearing +# surface, so it's allowed to be a touch brighter than the panel background +# without violating that rule -- same treatment as the calendar's TRACK_COLOR. +RUNNING_BG_GRADIENT = ["#031A2EFF", "#00060DFF"] +RUNNING_TITLE_COLOR = "#7FDBFFFF" +RUNNING_TRACK_COLOR = "#0F2A42FF" +RUNNING_TRACK_FILL_COLOR = "#29B6F6FF" # spec: "solid cyan" -- one flat color, no gradient +RUNNING_NUMERAL_COLOR = "#66E1FFFF" + + +def _pr_or_branch(run: dict) -> str: + """PR number ("#42") if the run belongs to a pull request, else the + branch it ran on (fork/push-triggered runs have an empty + pull_requests array).""" + prs = run.get("pull_requests") or [] + if prs: + return f"#{prs[0]['number']}" + return run.get("head_branch") or "" + + +def select_running_run(running_by_repo: dict[str, list[dict]]) -> tuple[dict, str, int] | None: + """Pick the most-recently-started in_progress run across every + configured repo. Returns (run, repo, other_running_count) or None if + nothing is running. `other_running_count` is every other currently- + running run (any repo, any workflow) besides the selected one -- the + "+N" the title badge shows.""" + candidates = [(r, repo) for repo, runs in running_by_repo.items() + for r in runs if r.get("status") == "in_progress"] + if not candidates: + return None + candidates.sort(key=lambda item: item[0].get("run_started_at", ""), reverse=True) + best_run, best_repo = candidates[0] + return best_run, best_repo, len(candidates) - 1 + + +def compute_median_duration_minutes(successful_runs: list[dict]) -> float | None: + """Median of (updated_at - run_started_at) in minutes across up to the + first 5 runs given (the caller already requests per_page=5, but this + stays defensive rather than trusting that). None if no run has both + timestamps -- "no history", not an error.""" + durations = [] + for r in successful_runs[:5]: + started, updated = r.get("run_started_at"), r.get("updated_at") + if not started or not updated: + continue + durations.append((_parse_ts(updated) - _parse_ts(started)).total_seconds() / 60) + if not durations: + return None + durations.sort() + n = len(durations) + mid = n // 2 + if n % 2 == 1: + return durations[mid] + return (durations[mid - 1] + durations[mid]) / 2 + + +def _elapsed_minutes(run: dict, now: datetime) -> float: + return (now - _parse_ts(run["run_started_at"])).total_seconds() / 60 + + +def _format_eta_text(run: dict, median_minutes: float | None, now: datetime) -> str: + """`~4m` / `~1h05m` (reuses _format_countdown verbatim, tilde-prefixed + to mark it as an estimate) when history exists; `soon` once the + estimate floors to under a minute remaining (covers both "exactly at + or past the median" and "a few seconds under a minute left" -- neither + reads sensibly as "~0m"); ` in` (e.g. "3m in") when there's no + history to estimate from at all. + """ + elapsed = _elapsed_minutes(run, now) + if median_minutes is None: + return f"{_format_countdown(elapsed)} in" + eta = max(0.0, median_minutes - elapsed) # spec: "floored at 0" + if int(eta) <= 0: + return "soon" + return f"~{_format_countdown(eta)}" + + +def _progress_width(elapsed_minutes: float, median_minutes: float | None) -> int: + """Track-fill width in px: elapsed/median of the panel width, clamped + to [1, PANEL_WIDTH]. Full width when the ratio is unknown (no history) + or the run has overrun its median -- same defensive shape as the + calendar's _track_fill_width (full-on-bad-denominator rather than + raising or dividing by zero).""" + if median_minutes is None or median_minutes <= 0: + return PANEL_WIDTH + if elapsed_minutes <= 0: + return 1 + width = round(PANEL_WIDTH * elapsed_minutes / median_minutes) + return max(1, min(PANEL_WIDTH, width)) + + +def _build_running_title(run: dict, repo: str, other_count: int) -> str: + """"REPO #PR WORKFLOW" (or "REPO branch-name WORKFLOW" for fork/push + runs), with a "+N" suffix when other runs are also active.""" + ref = _pr_or_branch(run) + workflow = run.get("name") or "" + parts = [p for p in (repo, ref, workflow) if p] + text = " ".join(parts) + if other_count > 0: + text = f"{text} +{other_count}" + return ascii_safe(text).upper() + + +def _build_running_elements(info: RunningInfo, timeout_s: int) -> list[dict]: + """v1.4-language badge: gradient bg, title ribbon (scrolls if it + doesn't fit), full-width horizon-line track repurposed as elapsed/ + median progress, and a large ETA numeral -- no card surfaces, no + native countdown element, same design lineage as the v1.4 calendar + layout. Draw order is z-order, first = behind. + """ + title_text = _build_running_title(info.run, info.repo, info.other_count) + eta_text = _format_eta_text(info.run, info.median_minutes, info.now) + elapsed = _elapsed_minutes(info.run, info.now) + track_width = _progress_width(elapsed, info.median_minutes) + + bg_element = { + "id": "bg", "type": "rectangle", "x": 0, "y": 0, + "width": PANEL_WIDTH, "height": PANEL_HEIGHT, + "fill": "gradient_v", "fill_colors": RUNNING_BG_GRADIENT, + "border_width": 0, "timeout": timeout_s, + } + title_element = { + "id": "title", "type": "text", "text": title_text, "font": "small", + "color": RUNNING_TITLE_COLOR, "x": RUNNING_TITLE_X, "y": RUNNING_TITLE_Y, + "width": RUNNING_TITLE_WIDTH, "timeout": timeout_s, + } + if not _title_fits(title_text, RUNNING_TITLE_WIDTH): + title_element.update({ + "scroll_rate": SCROLL_RATE, + "scroll_start_delay": SCROLL_DELAY_MS, + "scroll_repeat_delay": SCROLL_DELAY_MS, + }) + track_element = { + "id": "track", "type": "rectangle", "x": 0, "y": RUNNING_TRACK_Y, + "width": PANEL_WIDTH, "height": RUNNING_TRACK_HEIGHT, "fill": "solid", + "fill_colors": [RUNNING_TRACK_COLOR], "border_width": 0, "timeout": timeout_s, + } + track_fill_element = { + "id": "track_fill", "type": "rectangle", "x": 0, "y": RUNNING_TRACK_Y, + "width": track_width, "height": RUNNING_TRACK_HEIGHT, "fill": "solid", + "fill_colors": [RUNNING_TRACK_FILL_COLOR], "border_width": 0, "timeout": timeout_s, + } + numeral_element = { + "id": "eta", "type": "text", "text": eta_text, "font": "large", + "color": RUNNING_NUMERAL_COLOR, "x": RUNNING_NUMERAL_X, "y": RUNNING_NUMERAL_Y, + "timeout": timeout_s, + } + return [bg_element, title_element, track_element, track_fill_element, numeral_element] + + +# --- API-quota overlay frames ----------------------------------------------------- +# +# GET /rate_limit is exempt from GitHub's own rate limiting (free to call), +# so it's fetched fresh every poll while the overlay tier is active -- no +# ETag caching needed or attempted (see RestPoller.fetch_rate_limit). +# +# Palette: headroom-themed (not per-bucket -- the same three-tier scheme +# applies to both GraphQL and REST), following the same near-black-surface +# + bright-saturated-text contrast principle as everywhere else in this +# codebase. "high" (>50% remaining) reads as calm green-teal, "medium" +# (20-50%) as a caution amber, "low" (<20%) as an alert-adjacent red -- +# distinct hue families from RUNNING_*'s cyan/blue so a glance tells quota +# frames apart from the CI badge even before reading the title. Per the +# brief's "same structure as other states" framing, theming covers bg, +# track_fill (the moving/informative part of the track), and both +# numerals; the track's own groove stays a single fixed neutral color +# (QUOTA_TRACK_COLOR), matching the "groove is decorative, not themed" +# convention used for RUNNING_TRACK_COLOR and the calendar's TRACK_COLOR. +QUOTA_HEADROOM_HIGH = "high" +QUOTA_HEADROOM_MEDIUM = "medium" +QUOTA_HEADROOM_LOW = "low" + +QUOTA_TRACK_COLOR = "#12241EFF" + +QUOTA_BG_GRADIENT = { + QUOTA_HEADROOM_HIGH: ["#031F17FF", "#000A08FF"], + QUOTA_HEADROOM_MEDIUM: ["#231400FF", "#0A0400FF"], + QUOTA_HEADROOM_LOW: ["#2E0509FF", "#0A0101FF"], +} +QUOTA_TITLE_COLOR = { + QUOTA_HEADROOM_HIGH: "#6FFFCFFF", + QUOTA_HEADROOM_MEDIUM: "#FFCB6BFF", + QUOTA_HEADROOM_LOW: "#FF6B7AFF", +} +QUOTA_TRACK_FILL_COLOR = { + QUOTA_HEADROOM_HIGH: "#33FFC1FF", + QUOTA_HEADROOM_MEDIUM: "#FFB300FF", + QUOTA_HEADROOM_LOW: "#FF3B4EFF", +} +QUOTA_NUMERAL_COLOR = { + QUOTA_HEADROOM_HIGH: "#7CFFE0FF", + QUOTA_HEADROOM_MEDIUM: "#FFD98CFF", + QUOTA_HEADROOM_LOW: "#FF8A96FF", +} + +# Two numerals share the row (percentage remaining on the left, reset-in on +# the right) -- the only overlay frame that does, since the running badge +# has just one. No divider element between them (the brief didn't ask for +# one); RESET_X leaves both enough room for their respective worst cases +# ("100%" on the left, an hours-form countdown on the right). +QUOTA_PCT_X = OVERLAY_TITLE_X # 2 -- same left margin as every other overlay element +QUOTA_RESET_X = 40 + + +def _quota_headroom(remaining_pct: float) -> str: + """>50% remaining -> "high"; 20-50% inclusive -> "medium"; <20% -> + "low". The 50 boundary belongs to "medium" (a literal reading of the + brief's "20-50%" as an inclusive range); the 20 boundary also belongs + to "medium" (i.e. "low" is strictly less than 20, the tightest/most + urgent tier only firing once headroom has genuinely dropped below the + named threshold, not merely reached it).""" + if remaining_pct < 20: + return QUOTA_HEADROOM_LOW + if remaining_pct <= 50: + return QUOTA_HEADROOM_MEDIUM + return QUOTA_HEADROOM_HIGH + + +def _quota_used_width(used: float, limit: float) -> int: + """Track-fill width in px: used/limit of the panel width, clamped to + [1, PANEL_WIDTH]. Same defensive shape as _progress_width/ + _track_fill_width -- full width when the denominator is unusable.""" + if limit <= 0: + return PANEL_WIDTH + if used <= 0: + return 1 + width = round(PANEL_WIDTH * used / limit) + return max(1, min(PANEL_WIDTH, width)) + + +def parse_rate_limit(data: dict) -> dict[str, dict] | None: + """Extract the `core` (REST) and `graphql` buckets from a raw + `GET /rate_limit` response into `{"core": {...}, "graphql": {...}}` + (`used` is computed defensively as `limit - remaining` if the API + response doesn't include it directly). Only well-formed buckets are + included -- a response with one usable bucket and one malformed/absent + one still returns the usable one, rather than discarding both. `None` + if neither bucket could be parsed at all, signaling "this whole fetch + was unusable" to the caller (which should skip quota frames that + cycle, not crash or show stale data -- see RestPoller.fetch_rate_limit + and main.py's staleness handling). + """ + resources = (data or {}).get("resources") or {} + result: dict[str, dict] = {} + for key in ("core", "graphql"): + bucket = resources.get(key) + if not bucket or "limit" not in bucket or "remaining" not in bucket or "reset" not in bucket: + continue + limit, remaining = bucket["limit"], bucket["remaining"] + used = bucket.get("used") + if used is None: + used = max(0, limit - remaining) + result[key] = {"limit": limit, "remaining": remaining, "used": used, "reset": bucket["reset"]} + return result or None + + +def _build_quota_elements(info: QuotaInfo, timeout_s: int) -> list[dict]: + """Same v1.4-language row template as the running badge (gradient bg, + title ribbon, full-width track), but themed by remaining-quota + headroom instead of CI state, and with two numerals sharing the bottom + row (percentage remaining on the left, reset-in on the right) instead + of one. Draw order is z-order, first = behind. + """ + remaining_pct = (info.remaining / info.limit * 100) if info.limit > 0 else 0.0 + headroom = _quota_headroom(remaining_pct) + pct_text = f"{int(remaining_pct)}%" # floored, not rounded -- same numeral-floor + # convention as _format_countdown throughout + reset_in_minutes = (info.reset_epoch - info.now.timestamp()) / 60 + reset_text = _format_countdown(reset_in_minutes) + used_width = _quota_used_width(info.used, info.limit) + + bg_element = { + "id": "bg", "type": "rectangle", "x": 0, "y": 0, + "width": PANEL_WIDTH, "height": PANEL_HEIGHT, + "fill": "gradient_v", "fill_colors": QUOTA_BG_GRADIENT[headroom], + "border_width": 0, "timeout": timeout_s, + } + title_text = ascii_safe(info.label).upper() + title_element = { + "id": "title", "type": "text", "text": title_text, "font": "small", + "color": QUOTA_TITLE_COLOR[headroom], "x": OVERLAY_TITLE_X, "y": OVERLAY_TITLE_Y, + "width": OVERLAY_TITLE_WIDTH, "timeout": timeout_s, + } + if not _title_fits(title_text, OVERLAY_TITLE_WIDTH): + title_element.update({ + "scroll_rate": SCROLL_RATE, + "scroll_start_delay": SCROLL_DELAY_MS, + "scroll_repeat_delay": SCROLL_DELAY_MS, + }) + track_element = { + "id": "track", "type": "rectangle", "x": 0, "y": OVERLAY_TRACK_Y, + "width": PANEL_WIDTH, "height": OVERLAY_TRACK_HEIGHT, "fill": "solid", + "fill_colors": [QUOTA_TRACK_COLOR], "border_width": 0, "timeout": timeout_s, + } + track_fill_element = { + "id": "track_fill", "type": "rectangle", "x": 0, "y": OVERLAY_TRACK_Y, + "width": used_width, "height": OVERLAY_TRACK_HEIGHT, "fill": "solid", + "fill_colors": [QUOTA_TRACK_FILL_COLOR[headroom]], "border_width": 0, "timeout": timeout_s, + } + pct_element = { + "id": "pct", "type": "text", "text": pct_text, "font": "large", + "color": QUOTA_NUMERAL_COLOR[headroom], "x": QUOTA_PCT_X, "y": OVERLAY_NUMERAL_Y, + "timeout": timeout_s, + } + reset_element = { + "id": "reset", "type": "text", "text": reset_text, "font": "large", + "color": QUOTA_NUMERAL_COLOR[headroom], "x": QUOTA_RESET_X, "y": OVERLAY_NUMERAL_Y, + "timeout": timeout_s, + } + return [bg_element, title_element, track_element, track_fill_element, pct_element, reset_element] + + +# --- overlay rotation -------------------------------------------------------------- + +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", +} + + +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) -> 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). + """ + if frame_name == OVERLAY_FRAME_CI_BADGE: + if running is None: + return None + return {"elements": _build_running_elements(running, timeout_s), + "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" + 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} + return None + + 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, @@ -48,19 +529,31 @@ def _badge_elements(text: str, bg_color: str, text_color: str, timeout_s: int) - return [bg, _text_element(text, text_color, timeout_s, font="bold")] -def build_ci_payload(states: list[RepoState], show_green: bool, - timeout_s: int) -> dict | None: +def build_ci_payload(states: list[RepoState], show_green: bool, timeout_s: int, + overlay: dict | None = None) -> 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. + """ 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: text = "CI FAIL " + " ".join(f"{repo}:{name}" for repo, name in failures) return {"elements": _badge_elements(text, "#A32D2DFF", "#FFFFFFFF", timeout_s), - "priority": 60, "led": "#FF0000FF"} + "priority": PRIORITY_ALERT, "led": "#FF0000FF"} if stuck: text = "CI stuck " + " ".join(f"{repo}:{name}" for repo, name in stuck) return {"elements": _badge_elements(text, "#BA7517FF", "#0B0B0BFF", timeout_s), - "priority": 60, "led": None} + "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": 60, "led": None} + "priority": PRIORITY_ALERT, "led": None} return None diff --git a/integrations/ci_status/main.py b/integrations/ci_status/main.py index 7303004..d6e1cfe 100644 --- a/integrations/ci_status/main.py +++ b/integrations/ci_status/main.py @@ -9,19 +9,104 @@ import argparse import logging import time -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone -from busybar.client import BusyBarClient +from busybar.client import BusyBarClient, DrawResult from busybar.config import load_config +from busybar.display import OVERLAY_DWELL_SECONDS, overlay_gap_elapsed -from .logic import RepoState, build_ci_payload, evaluate_runs +from .logic import ( + RepoState, RunningInfo, QuotaInfo, + build_ci_payload, build_overlay_payload, evaluate_runs, + overlay_frame_sequence, parse_rate_limit, select_running_run, +) APP = "ci_status" log = logging.getLogger(APP) +QUOTA_LABELS = {"graphql": "GITHUB GRAPHQL", "core": "GITHUB REST"} +QUOTA_STALE_SECONDS = 300 # never show rate_limit data older than 5 minutes + + +def _refresh_quota(poller, quota_cache: dict | None, now: datetime) -> dict[str, QuotaInfo] | None: + """Fetch /rate_limit fresh (it's exempt from GitHub's own rate + limiting, so there's no cost to calling it every poll) and update + `quota_cache` on success. Returns the current bucket->QuotaInfo mapping + if `quota_cache` holds data no older than QUOTA_STALE_SECONDS (whether + from this fetch or an earlier one that succeeded when this one + didn't), else None -- callers must treat None as "no quota frames this + cycle," never fall back to stale numbers. + """ + if quota_cache is None: + return None + raw = poller.fetch_rate_limit() + if raw is not None: + parsed = parse_rate_limit(raw) + if parsed is not None: + quota_cache["buckets"] = parsed + quota_cache["fetched_at"] = now + fetched_at = quota_cache.get("fetched_at") + if fetched_at is None or (now - fetched_at).total_seconds() > QUOTA_STALE_SECONDS: + return None + buckets = quota_cache.get("buckets") or {} + return { + key: QuotaInfo(label=QUOTA_LABELS[key], limit=buckets[key]["limit"], + remaining=buckets[key]["remaining"], used=buckets[key]["used"], + reset_epoch=buckets[key]["reset"], now=now) + for key in buckets if key in QUOTA_LABELS + } + def run_once(client, poller, cfg: dict, now: datetime, - state_cache: dict[str, RepoState], dry_run: bool) -> str: + 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) -> str: + """`running_cache`, `overlay_state`, and `quota_cache`, when passed, are + caller-owned dicts this function mutates in place (mirroring + `state_cache`'s existing pattern) so `main()` can hold one instance of + each across loop iterations while `run_once` itself stays a pure + function of its arguments plus those dicts. Omitting `running_cache` + (the default) skips running-job/overlay detection entirely. + + 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_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 + doc's v1.5 section for why). `overlay_state`'s `frame_index` and + `last_dwell_end` only commit once `client.draw` actually returns + DRAWN, same discipline as calendar_countdown's transition-state fix: a + failed draw must not be mistaken for a completed dwell, or the + rotation would silently skip frames / wait a dwell for nothing. + + `overlay_state["last_shape"]` is a *unified* shape tracker, not + overlay-specific despite living in this dict: it records the element-id + set of whatever payload was last actually drawn to `APP`, across every + tier that can draw here -- an alert badge, the quiet-green text, or + either overlay frame kind -- and every draw path below checks it before + drawing and commits to it after DRAWN. The firmware upserts by element + id within an `application_name`, and each of these payload shapes has a + different id set (`{bg, ci}` for an alert, `{ci}` alone for quiet + green, `{bg, title, track, track_fill, eta}` for the running badge, + `{bg, title, track, track_fill, pct, reset}` for a quota frame) -- + switching shapes without a clear() first leaves the previous shape's + now-orphaned ids rendered until their own timeout elapses (up to 1.5x + `poll_seconds` for an alert/green draw), the same upsert-by-id bug + class the v1.3.1 calendar transition-clear fix addressed, recurring at + every seam a different payload shape can follow another -- not just + between the two overlay-frame shapes. Critically, resetting the + rotation bookkeeping (`frame_index`/`last_dwell_end`, e.g. when an + alert preempts the overlay or a run ends) must NOT also reset + `last_shape`: that field describes what is physically on the device + right now, which a bookkeeping reset does not change, and clearing it + prematurely was the root cause of a real bug where the clear-gate saw + "no shape on record" and wrongly concluded no clear was needed on the + next transition. + """ c = cfg["ci_status"] timeout_s = int(c["poll_seconds"] * 1.5) for repo in c["repos"]: @@ -29,18 +114,110 @@ def run_once(client, poller, cfg: dict, now: datetime, 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"]) - payload = build_ci_payload(list(state_cache.values()), c["show_green"], timeout_s) + states = list(state_cache.values()) + has_alert = any(s.failing or s.stuck for s in states) + + 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. + if running_cache is not None and c["show_running"]: + for repo in c["repos"]: + running_runs = poller.fetch_running_runs(repo) + if running_runs is not None: # None = 304/no-change/error -> keep cached + running_cache[repo] = running_runs + selected = select_running_run(running_cache) + + if selected is None or 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: + 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) + 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: + 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) 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" + + # Unified shape check (see docstring): applies to this draw regardless + # of which tier produced it -- alert, quiet-green, or an overlay frame. + 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"]) + + 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) + text = next(e["text"] for e in payload["elements"] if e["type"] == "text") return f"{text[:40]!r} -> {result.value}" +def next_poll_seconds(cfg_ci: dict, running_cache: dict[str, list[dict]]) -> int: + """Cadence switch: `running_poll_seconds` while any configured repo has + a currently-running run, `poll_seconds` otherwise. A pure function of + `running_cache`'s post-`run_once` state so it's testable without + mocking `time.sleep`. + """ + any_running = any(running_cache.get(repo) for repo in cfg_ci["repos"]) + return cfg_ci["running_poll_seconds"] if any_running else cfg_ci["poll_seconds"] + + def main() -> int: parser = argparse.ArgumentParser(description="BUSY Bar CI status") parser.add_argument("--once", action="store_true") @@ -63,10 +240,14 @@ def main() -> int: client.clear(APP) # drop any stale elements from a previous process (type collisions 400) state_cache: dict[str, RepoState] = {} + running_cache: dict[str, list[dict]] = {} + overlay_state: dict = {} + quota_cache: dict = {} backoff = 5 while True: summary = run_once(client, poller, cfg, datetime.now(timezone.utc), - state_cache, args.dry_run) + state_cache, args.dry_run, running_cache=running_cache, + overlay_state=overlay_state, quota_cache=quota_cache) log.info(summary) if args.once: return 0 @@ -75,7 +256,7 @@ def main() -> int: backoff = min(backoff * 2, 300) else: backoff = 5 - time.sleep(cfg["ci_status"]["poll_seconds"]) + time.sleep(next_poll_seconds(cfg["ci_status"], running_cache)) if __name__ == "__main__": diff --git a/src/busybar/config.py b/src/busybar/config.py index 126f471..ab12bd0 100644 --- a/src/busybar/config.py +++ b/src/busybar/config.py @@ -6,7 +6,16 @@ DEFAULTS: dict = { "device": {"host": "10.0.4.20"}, "calendar_countdown": { - "poll_seconds": 60, + # 10s matches busybar.display.AMBIENT_REDRAW_SECONDS -- the ambient + # tier's redraw contract, tuned (after on-device re-measurement + # showed a 15s poll only recovering the screen in 2 of 6 dwell + # cycles) to match the overlay's 10s dwell gap exactly, so this + # app's own redraws land inside those gaps far more often for + # near-true alternation (v1.5; see the spec doc's v1.5 section for + # both measurement rounds). Existing user configs that set + # poll_seconds explicitly are unaffected -- this only changes the + # out-of-the-box default. + "poll_seconds": 10, "lookahead_hours": 12, "warn_minutes": 5, "notice_minutes": 15, @@ -20,6 +29,11 @@ "repos": [], "show_green": False, "stale_queued_minutes": 0, # 0 = disabled + "show_running": True, + "running_poll_seconds": 20, + "show_quota": True, # GraphQL/REST quota frames join the overlay + # rotation while a run is active (no effect if + # show_running is false -- see ci_status/main.py) }, } diff --git a/src/busybar/display.py b/src/busybar/display.py new file mode 100644 index 0000000..1584b24 --- /dev/null +++ b/src/busybar/display.py @@ -0,0 +1,101 @@ +"""Shared display priority ladder and dwell/redraw contracts for every +busybar integration. + +## Two firmware facts, established empirically (not from the device's own +## OpenAPI documentation, which is wrong about the first one) during the +## v1.5 running-CI badge work. See the spec doc's "Probe findings" section +## (`docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md`, +## v1.5) for the full probe transcripts. + +1. **Equal priority from a different `application_name` is REJECTED, not + an override.** The OpenAPI doc claims "equal-priority requests from a + different application_name override whatever is on screen" -- probed + directly against a live app at priority 20 and found every equal- or + lower-priority draw from a different app_name returns `409 {"error": + "Not drawn due to low priority"}`. Only a STRICTLY GREATER priority + succeeds. Any two tiers that need to take turns on screen must be on + different priority numbers, not the same one. + +2. **Occluded elements are EVICTED, not restored.** When a higher-priority + app's elements expire (their own `timeout`) or are explicitly cleared, + a lower-priority app's still-live elements do NOT reappear -- the panel + goes black and stays black until *some* app performs a fresh draw. + Probed for both the timeout-expiry and explicit-clear cases; both + evict. There is no cross-process coordination between integrations (each + is an independent poller), so an ambient app can only "win back" the + screen by drawing again on its own schedule and getting lucky with the + timing -- it is never handed the screen back automatically. + +## The ladder + +Every integration draws under one of these tiers. Pick the tier that +matches your update pattern; don't invent a new priority number. +""" + +PRIORITY_AMBIENT = 20 +"""Persistent baseline apps (e.g. calendar_countdown). Contract: redraw at +least every `AMBIENT_REDRAW_SECONDS` seconds, with each element's `timeout` +set via `ambient_timeout(poll_seconds)` (1.5x the poll interval, so a dead +poller's display self-clears rather than sticking). Must tolerate being +evicted at any time by a higher-priority draw (fact 2 above) -- there is no +"resume where I left off"; the next scheduled redraw is the only way back +on screen. `AMBIENT_REDRAW_SECONDS` exists specifically so an overlay +tier's dwell gaps (see PRIORITY_OVERLAY) are short enough for an ambient +app to realistically land one of its redraws inside them. + +Tuning history (v1.5): started at 15s against a 10s overlay dwell gap; +on-device re-measurement (130s window, 6 dwell cycles) showed the ambient +app only recovering 2 of 6 gaps -- dark gaps were still ~10s in the other +4, exceeding the "~5s" target. Dropped to 10s (matching the dwell gap +exactly, so an ambient redraw firing anywhere in the gap window has a much +better chance of landing inside it) and re-measured; see the spec doc's +v1.5 section for both rounds' verbatim results. +""" +AMBIENT_REDRAW_SECONDS = 10 + + +def ambient_timeout(poll_seconds: float) -> int: + """Element timeout (seconds) for an ambient-tier draw at the given poll + interval: 1.5x poll, floored to an int (matches the existing + calendar_countdown convention).""" + return int(poll_seconds * 1.5) + + +PRIORITY_OVERLAY = 21 +"""Short-dwell time-shared overlays (e.g. the running-CI badge). Must be +strictly greater than PRIORITY_AMBIENT (fact 1 above) -- equal priority +against a different app_name is rejected, not a hand-off. Contract: draw +with element `timeout` = `OVERLAY_DWELL_SECONDS`, then stay silent (no +draw, no clear) for at least one more dwell period before redrawing again, +so an ambient app's own redraw has a real chance to land in the gap (fact +2 above means the ambient app is never automatically restored -- it can +only reclaim the screen with its own fresh draw). Use `overlay_gap_elapsed` +to decide whether enough silence has passed. +""" +OVERLAY_DWELL_SECONDS = 10 + + +def overlay_gap_elapsed(last_dwell_end, now) -> float: + """Seconds elapsed since an overlay's last dwell ended. `last_dwell_end` + is `None` (never drawn yet) treated as infinitely long ago, so the + first draw is never gated. Callers redraw when this returns + `>= OVERLAY_DWELL_SECONDS`. + """ + if last_dwell_end is None: + return float("inf") + return (now - last_dwell_end).total_seconds() + + +PRIORITY_ALERT = 60 +"""Urgent, preempting states (e.g. CI failure/stuck badges). Always wins +over PRIORITY_AMBIENT and PRIORITY_OVERLAY 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. +""" + +PRIORITY_SESSION = 90 +"""Reference only -- the firmware's own BUSY/CUSTOM work-session tier. +No integration in this repo draws at this priority; it's documented here +so the full ladder (including the firmware-owned ceiling) is visible in +one place. +""" diff --git a/tests/test_calendar_loop.py b/tests/test_calendar_loop.py index f7906cb..7fae8c1 100644 --- a/tests/test_calendar_loop.py +++ b/tests/test_calendar_loop.py @@ -5,8 +5,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "integrations")) from calendar_countdown.logic import CalEvent, _format_countdown -from calendar_countdown.main import run_once +from calendar_countdown.main import run_once, should_log_info from busybar.client import DrawResult +from busybar.display import PRIORITY_AMBIENT TZ = timezone.utc NOW = datetime(2026, 8, 3, 13, 37, tzinfo=TZ) @@ -29,7 +30,7 @@ def test_draws_countdown_for_upcoming_event(): summary = run_once(client, lambda hours: [event], CFG, NOW, dry_run=False) client.draw.assert_called_once() kwargs = client.draw.call_args.kwargs - assert kwargs["priority"] == 20 + assert kwargs["priority"] == PRIORITY_AMBIENT == 20 by_id = {el["id"]: el for el in kwargs["elements"]} # v1.4 "airy": no card elements. assert set(by_id) == {"bg", "title", "track", "track_fill", "time", "divider", "cd_text"} @@ -177,3 +178,26 @@ def test_state_reset_after_no_event_clear(): run_once(client, lambda hours: [make_event(23)], CFG, NOW, dry_run=False, state=state) # No stale elements remain after the "no event" clear -- no extra clear needed. client.clear.assert_not_called() + + +# --- log-noise control (v1.5: 10s default poll cadence) ------------------------ +# +# At the ambient tier's shortened default poll interval (60s -> 10s), the +# old "log every summary at INFO" behavior would sixfold calendar.log's +# line rate for no new information on most polls (the summary is usually +# unchanged poll to poll). should_log_info decides INFO vs DEBUG. + +def test_should_log_info_true_on_first_poll_no_prior_summary(): + assert should_log_info("drew X -> drawn", None, seconds_since_heartbeat=0) is True + +def test_should_log_info_true_when_summary_changes(): + assert should_log_info("drew Y -> drawn", "drew X -> drawn", seconds_since_heartbeat=0) is True + +def test_should_log_info_false_when_summary_unchanged_and_no_heartbeat_due(): + assert should_log_info("drew X -> drawn", "drew X -> drawn", seconds_since_heartbeat=1) is False + +def test_should_log_info_true_on_heartbeat_even_if_unchanged(): + assert should_log_info("drew X -> drawn", "drew X -> drawn", + seconds_since_heartbeat=600, heartbeat_seconds=600) is True + assert should_log_info("drew X -> drawn", "drew X -> drawn", + seconds_since_heartbeat=599, heartbeat_seconds=600) is False diff --git a/tests/test_ci_github.py b/tests/test_ci_github.py index 7b2052a..cb996e1 100644 --- a/tests/test_ci_github.py +++ b/tests/test_ci_github.py @@ -57,3 +57,144 @@ def test_fetch_returns_none_on_malformed_json(mock_get): resp.json.side_effect = ValueError("Invalid JSON") mock_get.return_value = resp assert RestPoller("tok").fetch_runs("o/r") is None + + +# --- fetch_running_runs: separate ETag slot from fetch_runs ------------------- + +@patch("ci_status.github.requests.get") +def test_fetch_running_runs_uses_status_filter_and_per_page_5(mock_get): + mock_get.return_value = _response(200, {"workflow_runs": [{"id": 9}]}, etag='W/"run1"') + poller = RestPoller("tok") + assert poller.fetch_running_runs("o/r") == [{"id": 9}] + assert mock_get.call_args.args[0] == "https://api.github.com/repos/o/r/actions/runs" + assert mock_get.call_args.kwargs["params"] == {"status": "in_progress", "per_page": 5} + + +@patch("ci_status.github.requests.get") +def test_fetch_running_runs_etag_is_independent_of_fetch_runs(mock_get): + poller = RestPoller("tok") + + # Prime fetch_runs's ETag slot. + mock_get.return_value = _response(200, {"workflow_runs": []}, etag='W/"failstuck-etag"') + poller.fetch_runs("o/r") + + # A fresh fetch_running_runs call must NOT send fetch_runs's ETag -- + # it's a different URL/query and gets its own slot (first call, no + # If-None-Match yet). + mock_get.return_value = _response(200, {"workflow_runs": [{"id": 1}]}, etag='W/"running-etag"') + poller.fetch_running_runs("o/r") + assert "If-None-Match" not in mock_get.call_args.kwargs["headers"] + + # The second fetch_running_runs call sends *its own* cached ETag, not + # fetch_runs's. + mock_get.return_value = _response(304) + poller.fetch_running_runs("o/r") + assert mock_get.call_args.kwargs["headers"]["If-None-Match"] == 'W/"running-etag"' + + +@patch("ci_status.github.requests.get") +def test_fetch_running_runs_swallows_network_errors(mock_get): + mock_get.side_effect = requests.ConnectionError() + assert RestPoller("tok").fetch_running_runs("o/r") is None + + +# --- fetch_median_eta: process-lifetime cache per workflow_id ----------------- + +def _run(started: str, updated: str) -> dict: + return {"run_started_at": started, "updated_at": updated} + + +@patch("ci_status.github.requests.get") +def test_fetch_median_eta_computes_and_caches(mock_get): + runs = [_run("2026-08-03T10:00:00Z", "2026-08-03T10:04:00Z"), # 4 min + _run("2026-08-03T09:00:00Z", "2026-08-03T09:06:00Z")] # 6 min + mock_get.return_value = _response(200, {"workflow_runs": runs}) + poller = RestPoller("tok") + + assert poller.fetch_median_eta("o/r", 42) == 5.0 # median of [4, 6] + assert mock_get.call_args.args[0] == "https://api.github.com/repos/o/r/actions/workflows/42/runs" + assert mock_get.call_args.kwargs["params"] == {"status": "success", "per_page": 5} + + # Second call for the same workflow_id must NOT hit the network again. + mock_get.reset_mock() + assert poller.fetch_median_eta("o/r", 42) == 5.0 + mock_get.assert_not_called() + + +@patch("ci_status.github.requests.get") +def test_fetch_median_eta_caches_confirmed_no_history(mock_get): + mock_get.return_value = _response(200, {"workflow_runs": []}) + poller = RestPoller("tok") + assert poller.fetch_median_eta("o/r", 7) is None + mock_get.reset_mock() + assert poller.fetch_median_eta("o/r", 7) is None + mock_get.assert_not_called() # a confirmed-empty history is cached too + + +@patch("ci_status.github.requests.get") +def test_fetch_median_eta_does_not_cache_on_error(mock_get): + poller = RestPoller("tok") + mock_get.return_value = _response(500) + assert poller.fetch_median_eta("o/r", 3) is None + + # A transient error must not lock in "no history" -- the next call + # retries the network rather than returning a cached None forever. + mock_get.return_value = _response(200, {"workflow_runs": [_run( + "2026-08-03T10:00:00Z", "2026-08-03T10:04:00Z")]}) + assert poller.fetch_median_eta("o/r", 3) == 4.0 + + +@patch("ci_status.github.requests.get") +def test_fetch_median_eta_does_not_cache_on_network_exception(mock_get): + poller = RestPoller("tok") + mock_get.side_effect = requests.ConnectionError() + assert poller.fetch_median_eta("o/r", 3) is None + + mock_get.side_effect = None + mock_get.return_value = _response(200, {"workflow_runs": [_run( + "2026-08-03T10:00:00Z", "2026-08-03T10:04:00Z")]}) + assert poller.fetch_median_eta("o/r", 3) == 4.0 + + +# --- fetch_rate_limit: free endpoint, no ETag/cache ----------------------------- + +@patch("ci_status.github.requests.get") +def test_fetch_rate_limit_returns_raw_response(mock_get): + body = {"resources": {"core": {"limit": 5000, "remaining": 4990, "reset": 1000, "used": 10}, + "graphql": {"limit": 5000, "remaining": 4800, "reset": 2000, "used": 200}}} + mock_get.return_value = _response(200, body) + poller = RestPoller("tok") + assert poller.fetch_rate_limit() == body + assert mock_get.call_args.args[0] == "https://api.github.com/rate_limit" + # No params (no status/per_page filter -- this isn't a workflow_runs + # endpoint) and no If-None-Match (no ETag caching attempted). + assert "If-None-Match" not in mock_get.call_args.kwargs["headers"] + +@patch("ci_status.github.requests.get") +def test_fetch_rate_limit_always_hits_network_even_when_called_twice(mock_get): + # Unlike fetch_median_eta, there's no process-lifetime cache here -- + # remaining quota changes continuously, so every call is a fresh GET. + mock_get.return_value = _response(200, {"resources": {}}) + poller = RestPoller("tok") + poller.fetch_rate_limit() + poller.fetch_rate_limit() + assert mock_get.call_count == 2 + +@patch("ci_status.github.requests.get") +def test_fetch_rate_limit_swallows_network_errors(mock_get): + mock_get.side_effect = requests.ConnectionError() + assert RestPoller("tok").fetch_rate_limit() is None + +@patch("ci_status.github.requests.get") +def test_fetch_rate_limit_none_on_non_200(mock_get): + mock_get.return_value = _response(403) + assert RestPoller("tok").fetch_rate_limit() is None + +@patch("ci_status.github.requests.get") +def test_fetch_rate_limit_none_on_malformed_json(mock_get): + resp = Mock() + resp.status_code = 200 + resp.headers = {} + resp.json.side_effect = ValueError("Invalid JSON") + mock_get.return_value = resp + assert RestPoller("tok").fetch_rate_limit() is None diff --git a/tests/test_ci_logic.py b/tests/test_ci_logic.py index c377863..e6780e5 100644 --- a/tests/test_ci_logic.py +++ b/tests/test_ci_logic.py @@ -3,7 +3,16 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "integrations")) -from ci_status.logic import RepoState, evaluate_runs, build_ci_payload +from ci_status.logic import ( + RepoState, RunningInfo, QuotaInfo, evaluate_runs, build_ci_payload, + build_overlay_payload, overlay_frame_sequence, + OVERLAY_FRAME_CI_BADGE, OVERLAY_FRAME_QUOTA_GQL, OVERLAY_FRAME_QUOTA_REST, + OVERLAY_FRAME_SHAPE, + _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, +) +from busybar.display import PRIORITY_OVERLAY, OVERLAY_DWELL_SECONDS, PRIORITY_ALERT NOW = datetime(2026, 8, 3, 13, 37, tzinfo=timezone.utc) @@ -23,6 +32,38 @@ def _bg_element(elements: list[dict]) -> dict: return next(e for e in elements if e["type"] == "rectangle") +def _by_id(elements: list[dict]) -> dict: + return {e["id"]: e for e in elements} + + +def running_run(workflow_id: int = 1, name: str = "tests", pr_number: int | None = 42, + head_branch: str = "main", started_min_ago: float = 3) -> dict: + started = (NOW - timedelta(minutes=started_min_ago)).strftime("%Y-%m-%dT%H:%M:%SZ") + return { + "workflow_id": workflow_id, "name": name, "status": "in_progress", + "run_started_at": started, "head_branch": head_branch, + "pull_requests": [{"number": pr_number}] if pr_number is not None else [], + } + + +def success_run(started: str, updated: str) -> dict: + return {"run_started_at": started, "updated_at": updated} + + +def running_info(**overrides) -> RunningInfo: + defaults = dict(run=running_run(), repo="acme/widgets", other_count=0, + median_minutes=None, now=NOW) + defaults.update(overrides) + return RunningInfo(**defaults) + + +def quota_info(**overrides) -> QuotaInfo: + defaults = dict(label="GITHUB REST", limit=5000, remaining=2500, used=2500, + reset_epoch=int(NOW.timestamp()) + 42 * 60, now=NOW) + defaults.update(overrides) + return QuotaInfo(**defaults) + + 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 @@ -44,7 +85,7 @@ def test_payload_none_when_green_and_quiet(): def test_payload_shows_green_glyph_when_enabled(): payload = build_ci_payload([RepoState("o/r", [], [])], True, 180) - assert payload["priority"] == 60 + 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 @@ -53,7 +94,7 @@ def test_payload_shows_green_glyph_when_enabled(): def test_payload_red_badge_on_failure(): payload = build_ci_payload([RepoState("o/r", ["tests"], [])], False, 180) - assert payload["priority"] == 60 and payload["led"] == "#FF0000FF" + 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 @@ -85,3 +126,341 @@ 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 + + +# --- PR number / branch fallback ---------------------------------------------- + +def test_pr_or_branch_uses_pr_number_when_present(): + assert _pr_or_branch({"pull_requests": [{"number": 42}], "head_branch": "feature-x"}) == "#42" + +def test_pr_or_branch_falls_back_to_head_branch_when_no_pr(): + # Fork/push-triggered runs have an empty pull_requests array. + assert _pr_or_branch({"pull_requests": [], "head_branch": "main"}) == "main" + assert _pr_or_branch({"head_branch": "main"}) == "main" # key absent entirely + +def test_pr_or_branch_uses_first_pr_when_multiple(): + assert _pr_or_branch({"pull_requests": [{"number": 7}, {"number": 8}]}) == "#7" + + +# --- select_running_run: multi-repo, most-recent, +N -------------------------- + +def test_select_running_run_none_when_nothing_running(): + assert select_running_run({}) is None + assert select_running_run({"o/r": []}) is None + +def test_select_running_run_single_candidate_no_others(): + run_ = running_run(started_min_ago=5) + result = select_running_run({"o/r": [run_]}) + assert result == (run_, "o/r", 0) + +def test_select_running_run_picks_most_recently_started_across_repos(): + older = running_run(workflow_id=1, started_min_ago=10) + newer = running_run(workflow_id=2, started_min_ago=2) + result = select_running_run({"o/r1": [older], "o/r2": [newer]}) + assert result[0] is newer and result[1] == "o/r2" + +def test_select_running_run_counts_others_across_all_repos(): + a = running_run(workflow_id=1, started_min_ago=1) # most recent -> selected + b = running_run(workflow_id=2, started_min_ago=5) + c = running_run(workflow_id=3, started_min_ago=8) + result = select_running_run({"o/r1": [a, b], "o/r2": [c]}) + assert result[0] is a and result[2] == 2 # +2 others + +def test_select_running_run_ignores_non_in_progress_entries(): + stale = {**running_run(), "status": "completed"} + live = running_run(started_min_ago=1) + result = select_running_run({"o/r": [stale, live]}) + assert result[0] is live and result[2] == 0 + + +# --- compute_median_duration_minutes ------------------------------------------- + +def test_median_duration_odd_count(): + runs = [success_run("2026-08-03T10:00:00Z", "2026-08-03T10:04:00Z"), # 4 min + success_run("2026-08-03T09:00:00Z", "2026-08-03T09:06:00Z"), # 6 min + success_run("2026-08-03T08:00:00Z", "2026-08-03T08:05:00Z")] # 5 min + assert compute_median_duration_minutes(runs) == 5.0 + +def test_median_duration_even_count_averages_middle_two(): + runs = [success_run("2026-08-03T10:00:00Z", "2026-08-03T10:04:00Z"), # 4 + success_run("2026-08-03T09:00:00Z", "2026-08-03T09:06:00Z")] # 6 + assert compute_median_duration_minutes(runs) == 5.0 # (4+6)/2 + +def test_median_duration_none_when_no_runs(): + assert compute_median_duration_minutes([]) is None + +def test_median_duration_skips_runs_missing_timestamps(): + runs = [{"run_started_at": None, "updated_at": None}, + success_run("2026-08-03T10:00:00Z", "2026-08-03T10:04:00Z")] + assert compute_median_duration_minutes(runs) == 4.0 + +def test_median_duration_caps_at_first_5(): + # 6 runs of varying duration; only the first 5 (per_page=5 upstream, + # but this stays defensive) should count. + runs = [success_run("2026-08-03T10:00:00Z", f"2026-08-03T10:{m:02d}:00Z") + for m in (1, 2, 3, 4, 5, 99)] + assert compute_median_duration_minutes(runs) == 3.0 # median of [1,2,3,4,5] + + +# --- ETA text formatting -------------------------------------------------------- + +def test_eta_text_with_history_uses_tilde_prefix(): + run_ = running_run(started_min_ago=10) + assert _format_eta_text(run_, median_minutes=14, now=NOW) == "~4m" + +def test_eta_text_reuses_format_countdown_for_hours(): + run_ = running_run(started_min_ago=5) + assert _format_eta_text(run_, median_minutes=70, now=NOW) == "~1h05m" + +def test_eta_text_shows_soon_when_floored_to_zero(): + run_ = running_run(started_min_ago=14) + assert _format_eta_text(run_, median_minutes=14, now=NOW) == "soon" # exactly at median + run_over = running_run(started_min_ago=20) + assert _format_eta_text(run_over, median_minutes=14, now=NOW) == "soon" # overrun + run_almost = running_run(started_min_ago=13.5) + assert _format_eta_text(run_almost, median_minutes=14, now=NOW) == "soon" # 0.5 min left + +def test_eta_text_no_history_shows_elapsed_with_in_suffix(): + run_ = running_run(started_min_ago=3) + assert _format_eta_text(run_, median_minutes=None, now=NOW) == "3m in" + +def test_eta_text_no_history_reuses_format_countdown_for_hours(): + run_ = running_run(started_min_ago=65) + assert _format_eta_text(run_, median_minutes=None, now=NOW) == "1h05m in" + + +# --- track progress width ------------------------------------------------------- + +def test_progress_width_full_when_median_unknown(): + assert _progress_width(elapsed_minutes=5, median_minutes=None) == 72 + +def test_progress_width_scales_with_elapsed_over_median(): + assert _progress_width(elapsed_minutes=7, median_minutes=14) == 36 # half -> half width + +def test_progress_width_clamps_at_full_when_overrun(): + assert _progress_width(elapsed_minutes=20, median_minutes=14) == 72 + +def test_progress_width_clamped_min_one(): + assert _progress_width(elapsed_minutes=0, median_minutes=14) == 1 + assert _progress_width(elapsed_minutes=-1, median_minutes=14) == 1 + +def test_progress_width_full_when_median_non_positive(): + assert _progress_width(elapsed_minutes=5, median_minutes=0) == 72 + + +# --- running badge title -------------------------------------------------------- + +def test_running_title_with_pr_number(): + run_ = running_run(name="tests", pr_number=42, head_branch="feature-x") + assert _build_running_title(run_, "acme/widgets", 0) == "ACME/WIDGETS #42 TESTS" + +def test_running_title_falls_back_to_branch(): + run_ = running_run(name="deploy", pr_number=None, head_branch="release-2.0") + assert _build_running_title(run_, "acme/widgets", 0) == "ACME/WIDGETS RELEASE-2.0 DEPLOY" + +def test_running_title_appends_plus_n_when_others_active(): + run_ = running_run(name="tests", pr_number=42) + assert _build_running_title(run_, "acme/widgets", 3) == "ACME/WIDGETS #42 TESTS +3" + +def test_running_title_no_suffix_when_alone(): + run_ = running_run(name="tests", pr_number=42) + assert "+0" not in _build_running_title(run_, "acme/widgets", 0) + + +# --- build_overlay_payload: running badge (ci_badge frame) --------------------- + +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) + + assert payload["priority"] == PRIORITY_OVERLAY == 21 + assert payload["led"] is None + + by_id = _by_id(payload["elements"]) + assert set(by_id) == {"bg", "title", "track", "track_fill", "eta"} + assert [e["id"] for e in payload["elements"]] == ["bg", "title", "track", "track_fill", "eta"] + + bg = by_id["bg"] + assert bg["fill"] == "gradient_v" and bg["border_width"] == 0 + assert bg["timeout"] == OVERLAY_DWELL_SECONDS == 10 + + title = by_id["title"] + assert title["text"] == "ACME/WIDGETS #42 TESTS" + assert title["font"] == "small" and title["y"] == -2 + + track = by_id["track"] + assert track["y"] == 6 and track["width"] == 72 and track["border_width"] == 0 + + track_fill = by_id["track_fill"] + assert track_fill["fill"] == "solid" # spec: "solid cyan", no gradient + assert track_fill["width"] == _progress_width(3, 14) + + eta = by_id["eta"] + assert eta["font"] == "large" and eta["y"] == 5 # numeral-floor rule: large font + assert eta["text"] == _format_eta_text(run_, 14, NOW) + +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) + 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 + + +# --- build_overlay_payload: quota frames ---------------------------------------- + +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, + quota_by_bucket={"graphql": info}) + assert payload["priority"] == PRIORITY_OVERLAY + + by_id = _by_id(payload["elements"]) + assert set(by_id) == {"bg", "title", "track", "track_fill", "pct", "reset"} + assert [e["id"] for e in payload["elements"]] == \ + ["bg", "title", "track", "track_fill", "pct", "reset"] + + assert by_id["title"]["text"] == "GITHUB GRAPHQL" + assert by_id["title"]["font"] == "small" + assert by_id["pct"]["text"] == "52%" # floor(2600/5000*100) = 52 + assert by_id["pct"]["font"] == "large" # numeral-floor rule + assert by_id["reset"]["text"] == "42m" + assert by_id["reset"]["font"] == "large" + assert by_id["track_fill"]["width"] == _quota_used_width(2400, 5000) + assert by_id["track"]["y"] == 6 and by_id["track"]["border_width"] == 0 + +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, + 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, + quota_by_bucket={}) is None + assert build_overlay_payload(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, + quota_by_bucket={"core": quota_info()}) is None + + +# --- headroom color thresholds (boundaries 50/20) ------------------------------- + +def test_quota_headroom_high_above_50(): + assert _quota_headroom(50.1) == "high" + assert _quota_headroom(100) == "high" + +def test_quota_headroom_medium_at_and_below_50_down_to_20(): + assert _quota_headroom(50) == "medium" # 50 itself is medium, not high + assert _quota_headroom(35) == "medium" + assert _quota_headroom(20) == "medium" # 20 itself is medium, not low + +def test_quota_headroom_low_below_20(): + assert _quota_headroom(19.9) == "low" + assert _quota_headroom(0) == "low" + + +# --- used-fraction clamps -------------------------------------------------------- + +def test_quota_used_width_scales(): + assert _quota_used_width(2500, 5000) == 36 # half -> half width + +def test_quota_used_width_clamped_min_one(): + assert _quota_used_width(0, 5000) == 1 + assert _quota_used_width(-1, 5000) == 1 + +def test_quota_used_width_full_when_limit_non_positive(): + assert _quota_used_width(10, 0) == 72 + +def test_quota_used_width_clamped_max_when_used_exceeds_limit(): + # Live-observed case (v1.5 on-device quota verification): a real + # GitHub account's GraphQL bucket reported used=5150 > limit=5000 -- + # GitHub's point-based GraphQL cost accounting can transiently exceed + # the nominal limit. round(72 * 5150 / 5000) == 74, which must clamp + # to the panel width rather than overflow the track. + assert _quota_used_width(5150, 5000) == 72 + + +# --- parse_rate_limit ------------------------------------------------------------ + +def test_parse_rate_limit_extracts_core_and_graphql(): + data = {"resources": { + "core": {"limit": 5000, "remaining": 4990, "reset": 1000, "used": 10}, + "graphql": {"limit": 5000, "remaining": 4800, "reset": 2000, "used": 200}, + "search": {"limit": 30, "remaining": 30, "reset": 3000}, # ignored bucket + }} + parsed = parse_rate_limit(data) + assert parsed["core"] == {"limit": 5000, "remaining": 4990, "used": 10, "reset": 1000} + assert parsed["graphql"] == {"limit": 5000, "remaining": 4800, "used": 200, "reset": 2000} + assert "search" not in parsed + +def test_parse_rate_limit_computes_used_when_absent(): + data = {"resources": {"core": {"limit": 5000, "remaining": 4990, "reset": 1000}}} + assert parse_rate_limit(data)["core"]["used"] == 10 + +def test_parse_rate_limit_none_when_no_usable_bucket(): + assert parse_rate_limit({"resources": {}}) is None + assert parse_rate_limit({}) is None + assert parse_rate_limit({"resources": {"core": {"limit": 5000}}}) is None # missing fields + +def test_parse_rate_limit_returns_partial_result(): + data = {"resources": {"core": {"limit": 5000, "remaining": 100, "reset": 1000}, + "graphql": {"limit": 5000}}} # malformed, dropped + parsed = parse_rate_limit(data) + 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 diff --git a/tests/test_ci_loop.py b/tests/test_ci_loop.py index 6b14ffc..8dfaf7c 100644 --- a/tests/test_ci_loop.py +++ b/tests/test_ci_loop.py @@ -1,15 +1,26 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import sys from pathlib import Path from unittest.mock import Mock sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "integrations")) from busybar.client import DrawResult -from ci_status.main import run_once +from busybar.display import PRIORITY_OVERLAY, OVERLAY_DWELL_SECONDS +from ci_status.main import run_once, next_poll_seconds NOW = datetime(2026, 8, 3, 13, 37, tzinfo=timezone.utc) CFG = {"ci_status": {"poll_seconds": 120, "repos": ["o/r"], "show_green": False, "stale_queued_minutes": 0}} +# Full config including the v1.5 running-badge keys, for tests that exercise +# that path (the bare CFG above deliberately predates those keys, to prove +# run_once stays backward compatible with callers/configs that omit them -- +# see test_running_detection_skipped_when_running_cache_omitted). +CFG_RUNNING = {"ci_status": {"poll_seconds": 120, "running_poll_seconds": 20, + "repos": ["o/r"], "show_green": False, + "stale_queued_minutes": 0, "show_running": True, + "show_quota": False}} +CFG_QUOTA = {"ci_status": {**CFG_RUNNING["ci_status"], "show_quota": True}} +CFG_GREEN = {"ci_status": {**CFG_RUNNING["ci_status"], "show_green": True}} def _run(conclusion: str) -> dict: @@ -51,3 +62,360 @@ def test_dry_run_touches_nothing(): summary = run_once(client, poller, CFG, NOW, {}, dry_run=True) client.draw.assert_not_called(); client.clear.assert_not_called() assert "DRY-RUN" in summary + + +# --- running badge wiring ------------------------------------------------------- + +def _running_run(started_min_ago: float = 3, workflow_id: int = 1, name: str = "tests") -> dict: + started = (NOW - timedelta(minutes=started_min_ago)).strftime("%Y-%m-%dT%H:%M:%SZ") + return {"workflow_id": workflow_id, "name": name, "status": "in_progress", + "run_started_at": started, "head_branch": "main", "pull_requests": []} + + +def test_draws_running_badge_at_priority_21_when_run_active(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] # no failure/stuck + poller.fetch_running_runs.return_value = [_running_run()] + poller.fetch_median_eta.return_value = 10.0 + running_cache: dict = {} + run_once(client, poller, CFG_RUNNING, NOW, {}, dry_run=False, running_cache=running_cache) + # 21 (PRIORITY_OVERLAY), not the literal priority=20 the feature brief + # specified -- see busybar/display.py's PRIORITY_OVERLAY docstring for + # the empirical (probe-verified) reason a strictly-higher priority is + # required. + assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY == 21 + assert running_cache["o/r"] == [_running_run()] + +def test_running_badge_fetches_median_for_selected_runs_workflow(): + 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(workflow_id=99)] + poller.fetch_median_eta.return_value = None + run_once(client, poller, CFG_RUNNING, NOW, {}, dry_run=False, running_cache={}) + poller.fetch_median_eta.assert_called_once_with("o/r", 99) + +def test_no_running_badge_when_show_running_false(): + cfg = {"ci_status": {**CFG_RUNNING["ci_status"], "show_running": False}} + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] + poller.fetch_running_runs.return_value = [_running_run()] + run_once(client, poller, cfg, NOW, {}, dry_run=False, running_cache={}) + poller.fetch_running_runs.assert_not_called() + client.clear.assert_called_once_with("ci_status") # falls through to "all green" + +def test_running_detection_skipped_when_running_cache_omitted(): + # Backward compatible: a caller (or an older-shaped cfg dict, like the + # bare CFG above) that doesn't pass running_cache never touches + # show_running/running_poll_seconds/show_quota -- no KeyError even + # though CFG predates those keys. + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] + summary = run_once(client, poller, CFG, NOW, {}, dry_run=False) + poller.fetch_running_runs.assert_not_called() + assert "cleared" in summary + +def test_without_overlay_state_gate_always_open_draws_every_poll(): + # overlay_state omitted entirely -- run_once can't remember a previous + # dwell, so the gate can't meaningfully close; every poll with an + # active run draws. (This is the pre-dwell-gate behavior, preserved + # for callers that don't care about the alternation mechanics.) + 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 + run_once(client, poller, CFG_RUNNING, NOW, {}, dry_run=False, running_cache={}) + run_once(client, poller, CFG_RUNNING, NOW, {}, dry_run=False, running_cache={}) + assert client.draw.call_count == 2 + + +# --- overlay dwell gate ---------------------------------------------------------- + +def test_first_overlay_draw_is_never_gated(): + 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 + summary = run_once(client, poller, CFG_RUNNING, NOW, {}, dry_run=False, + running_cache={}, overlay_state={}) + client.draw.assert_called_once() + assert "silent" not in summary + +def test_second_poll_within_dwell_stays_silent(): + 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 + overlay_state: dict = {} + run_once(client, poller, CFG_RUNNING, NOW, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state) + client.draw.reset_mock() + soon_after = NOW + timedelta(seconds=OVERLAY_DWELL_SECONDS - 1) + summary = run_once(client, poller, CFG_RUNNING, soon_after, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state) + client.draw.assert_not_called() + client.clear.assert_not_called() + assert "silent" in summary + +def test_poll_after_dwell_elapsed_draws_again(): + 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 + overlay_state: dict = {} + run_once(client, poller, CFG_RUNNING, NOW, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state) + client.draw.reset_mock() + 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.draw.assert_called_once() + +def test_dwell_state_does_not_commit_on_failed_draw(): + # If draw() doesn't land, overlay_state must not advance -- otherwise + # the next poll would wait a full dwell for a "dwell" that never + # actually rendered anything (same discipline as calendar_countdown's + # transition-state DRAWN gate). + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] + poller.fetch_running_runs.return_value = [_running_run()] + poller.fetch_median_eta.return_value = None + overlay_state: dict = {} + + client.draw.return_value = DrawResult.UNREACHABLE + run_once(client, poller, CFG_RUNNING, NOW, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state) + assert "last_dwell_end" not in overlay_state + + client.draw.return_value = DrawResult.DRAWN + soon_after = NOW + timedelta(seconds=1) + run_once(client, poller, CFG_RUNNING, soon_after, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state) + # No dwell was ever successfully committed, so this immediate retry + # (only 1s later) must still draw, not be gated. + assert client.draw.call_count == 2 + +def test_overlay_state_resets_when_run_ends(): + 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 + overlay_state: dict = {} + run_once(client, poller, CFG_RUNNING, NOW, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state) + assert overlay_state.get("last_dwell_end") is not None + + poller.fetch_running_runs.return_value = [] # run finished + run_once(client, poller, CFG_RUNNING, NOW, {}, dry_run=False, + running_cache={"o/r": [_running_run()]}, overlay_state=overlay_state) + # Only the rotation bookkeeping (frame_index/last_dwell_end) resets + # here -- the "run ended, nothing else to show" branch happens to + # also reach the explicit client.clear()/"all green" path this same + # poll (show_green is False in CFG_RUNNING), so last_shape correctly + # becomes None too: the device really is blank now, this poll. The + # critical-bug regression coverage -- last_shape surviving a + # bookkeeping-only reset that does NOT clear the device this same + # poll (e.g. an alert preempting the overlay without falling through + # to the "nothing to show" branch) -- lives in + # test_overlay_then_alert_clears_stale_overlay_shape below. + assert "frame_index" not in overlay_state + assert "last_dwell_end" not in overlay_state + assert overlay_state["last_shape"] is None + + +# --- unified shape tracking across alert / quiet-green / overlay tiers ---------- + +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. + 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 + overlay_state: dict = {} + run_once(client, poller, CFG_RUNNING, NOW, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state) + client.clear.assert_not_called() # nothing on screen before -- no clear needed yet + + poller.fetch_runs.return_value = [_run("failure")] + 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 + +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. + 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 + overlay_state: dict = {} + 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 + + poller.fetch_runs.return_value = [_run("success")] # alert resolves + later = NOW + timedelta(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") + +def test_quiet_green_then_overlay_clears_stale_green_shape(): + # Quiet "CI ok" text (shape {ci}, no bg) draws first when show_green + # is on and nothing is running; once a run starts, the badge's 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. + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] + poller.fetch_running_runs.return_value = [] # nothing running yet + overlay_state: dict = {} + summary = run_once(client, poller, CFG_GREEN, NOW, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state) + client.clear.assert_not_called() + assert overlay_state["last_shape"] == frozenset({"ci"}) + + 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, + running_cache={"o/r": []}, overlay_state=overlay_state) + client.clear.assert_called_once_with("ci_status") + + +# --- overlay rotation (quota frames) --------------------------------------------- + +def _quota_body(gql_remaining=2600, core_remaining=100): + return {"resources": { + "core": {"limit": 5000, "remaining": core_remaining, "reset": 2000000000, "used": 5000 - core_remaining}, + "graphql": {"limit": 5000, "remaining": gql_remaining, "reset": 2000000000, "used": 5000 - gql_remaining}, + }} + +def test_rotation_cycles_ci_badge_then_quota_frames(): + 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 = _quota_body() + overlay_state: dict = {} + quota_cache: dict = {} + seen = [] + t = NOW + for _ in range(3): + run_once(client, poller, CFG_QUOTA, t, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state, quota_cache=quota_cache) + elements = client.draw.call_args.args[1] # elements is positional, not a kwarg + by_id = {e["id"]: e for e in elements} + if "eta" in by_id: + seen.append("ci_badge") + else: + seen.append("quota_gql" if by_id["title"]["text"] == "GITHUB GRAPHQL" else "quota_rest") + t += timedelta(seconds=2 * OVERLAY_DWELL_SECONDS + 1) + assert seen == ["ci_badge", "quota_gql", "quota_rest"] + +def test_rotation_shape_change_clears_first(): + 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 = _quota_body() + overlay_state: dict = {} + quota_cache: dict = {} + # First dwell: ci_badge (shape "badge") -- no prior shape, no clear. + run_once(client, poller, CFG_QUOTA, NOW, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state, quota_cache=quota_cache) + client.clear.assert_not_called() + # Second dwell: quota_gql (shape "quota") -- shape changed, must clear first. + 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) + client.clear.assert_called_once_with("ci_status") + # Third dwell: quota_rest -- same shape as quota_gql, no clear needed. + client.clear.reset_mock() + later2 = later + timedelta(seconds=2 * OVERLAY_DWELL_SECONDS + 1) + run_once(client, poller, CFG_QUOTA, later2, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state, quota_cache=quota_cache) + client.clear.assert_not_called() + +def test_quota_frame_skipped_without_crashing_when_fetch_fails(): + 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 + 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 + 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 + +def test_quota_stale_data_not_shown_after_5_minutes(): + 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 + overlay_state: dict = {} + quota_cache: dict = {"buckets": {"graphql": {"limit": 5000, "remaining": 2600, "used": 2400, "reset": 2000000000}}, + "fetched_at": NOW - timedelta(minutes=6)} # stale + poller.fetch_rate_limit.return_value = None # this poll's fetch also fails + run_once(client, poller, CFG_QUOTA, NOW, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state, quota_cache=quota_cache) # ci_badge dwell + 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 + + +# --- cadence switch (next_poll_seconds) ----------------------------------------- + +def test_next_poll_seconds_shortens_while_a_run_is_active(): + running_cache = {"o/r": [_running_run()]} + assert next_poll_seconds(CFG_RUNNING["ci_status"], running_cache) == 20 + +def test_next_poll_seconds_reverts_when_idle(): + running_cache = {"o/r": []} + assert next_poll_seconds(CFG_RUNNING["ci_status"], running_cache) == 120 + +def test_next_poll_seconds_reverts_when_repo_never_polled(): + assert next_poll_seconds(CFG_RUNNING["ci_status"], {}) == 120 + +def test_next_poll_seconds_checks_across_all_configured_repos(): + cfg = {**CFG_RUNNING["ci_status"], "repos": ["o/r1", "o/r2"]} + running_cache = {"o/r1": [], "o/r2": [_running_run()]} + assert next_poll_seconds(cfg, running_cache) == 20 diff --git a/tests/test_config.py b/tests/test_config.py index c5786c8..f01d26c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,8 +4,11 @@ def test_defaults_when_no_file(tmp_path): cfg = load_config(tmp_path / "missing.toml") assert cfg["device"]["host"] == "10.0.4.20" - assert cfg["calendar_countdown"]["poll_seconds"] == 60 + assert cfg["calendar_countdown"]["poll_seconds"] == 10 # v1.5 ambient-tier default assert cfg["ci_status"]["repos"] == [] + assert cfg["ci_status"]["show_running"] is True + assert cfg["ci_status"]["running_poll_seconds"] == 20 + assert cfg["ci_status"]["show_quota"] is True def test_file_overrides_defaults(tmp_path): p = tmp_path / "config.toml" @@ -30,4 +33,4 @@ def test_returned_config_mutation_does_not_corrupt_defaults(tmp_path): # Load a fresh config and verify it is unaffected cfg2 = load_config(tmp_path / "missing.toml") assert cfg2["ci_status"]["repos"] == [] - assert cfg2["calendar_countdown"]["poll_seconds"] == 60 + assert cfg2["calendar_countdown"]["poll_seconds"] == 10 diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..08e9f19 --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,65 @@ +from datetime import datetime, timedelta, timezone + +from busybar.display import ( + PRIORITY_AMBIENT, PRIORITY_OVERLAY, PRIORITY_ALERT, PRIORITY_SESSION, + AMBIENT_REDRAW_SECONDS, OVERLAY_DWELL_SECONDS, + ambient_timeout, overlay_gap_elapsed, +) + +NOW = datetime(2026, 8, 3, 13, 37, tzinfo=timezone.utc) + + +def test_priority_ladder_values(): + assert PRIORITY_AMBIENT == 20 + assert PRIORITY_OVERLAY == 21 + assert PRIORITY_ALERT == 60 + assert PRIORITY_SESSION == 90 + +def test_priority_ladder_is_strictly_increasing(): + # Load-bearing: equal priority from a different application_name is + # REJECTED by the firmware (probed, contradicts the OpenAPI doc), so + # every tier that must be able to preempt the one below it needs a + # strictly greater number, not merely a "greater or equal" one. + ladder = [PRIORITY_AMBIENT, PRIORITY_OVERLAY, PRIORITY_ALERT, PRIORITY_SESSION] + assert ladder == sorted(set(ladder)) + assert len(ladder) == len(set(ladder)) + +def test_overlay_priority_strictly_exceeds_ambient(): + assert PRIORITY_OVERLAY > PRIORITY_AMBIENT + +def test_cadence_constants(): + # Tuned down from 15 to 10 after on-device re-measurement showed 15s + # only recovering the ambient app's screen time in 2 of 6 dwell cycles + # -- see busybar/display.py's AMBIENT_REDRAW_SECONDS docstring. + assert AMBIENT_REDRAW_SECONDS == 10 + assert OVERLAY_DWELL_SECONDS == 10 + + +# --- ambient_timeout ----------------------------------------------------------- + +def test_ambient_timeout_is_1_5x_poll(): + assert ambient_timeout(15) == 22 # int(15 * 1.5) == 22 (floors 22.5) + assert ambient_timeout(60) == 90 + assert ambient_timeout(10) == 15 + +def test_ambient_timeout_floors_not_rounds(): + assert ambient_timeout(11) == 16 # 16.5 floors to 16, not rounds to 17 + + +# --- overlay_gap_elapsed --------------------------------------------------------- + +def test_overlay_gap_elapsed_infinite_when_never_drawn(): + assert overlay_gap_elapsed(None, NOW) == float("inf") + +def test_overlay_gap_elapsed_computes_seconds_since_dwell_end(): + last_end = NOW - timedelta(seconds=12) + assert overlay_gap_elapsed(last_end, NOW) == 12.0 + +def test_overlay_gap_elapsed_zero_immediately_after_dwell_end(): + assert overlay_gap_elapsed(NOW, NOW) == 0.0 + +def test_overlay_gap_elapsed_matches_dwell_threshold_semantics(): + # The gate callers use is `>= OVERLAY_DWELL_SECONDS`; sanity-check the + # boundary value itself is exact, not off-by-a-rounding-error. + last_end = NOW - timedelta(seconds=OVERLAY_DWELL_SECONDS) + assert overlay_gap_elapsed(last_end, NOW) == OVERLAY_DWELL_SECONDS