diff --git a/config.example.toml b/config.example.toml index 474eb8a..3392f3a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -24,3 +24,12 @@ show_running = true # show a badge (alternating with the calendar) whil 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) + +# Account-wide watching (v1.5.1) -- off by default. When on, the watch list +# becomes auto-discovered account repos UNION `repos` above, MINUS +# repos_exclude below. See ci_status/README.md's "Account-wide watching" +# section for quota math and the active-window caveat before enabling. +watch_account_repos = false +# repos_exclude = ["your-user/archived-experiment"] # silence specific repos without leaving account mode +active_within_days = 30 # only repos pushed within this window are polled +repo_refresh_minutes = 60 # how often the repo list itself is re-enumerated (new repos picked up within this interval) 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 c143cbb..3a8c3d5 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 @@ -904,3 +904,220 @@ 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. + +## 2026-08-03 — v1.5.1 account-wide repo watching + +**Status:** Implemented, branch `dev/claude/account-wide-v1.5.1` off `main` +(which by this point already includes the full v1.5 revision above, merged +via PR #9). Not pushed. + +New feature: `ci_status` can watch every repo the operator owns instead of +a fixed, manually-maintained `repos` list, with automatic pickup of newly +created (or newly re-activated) repos -- no config edit or restart needed. + +### Config additions + +`[ci_status]` gains: `watch_account_repos = false` (default off -- the +operator enables it locally, not shipped as a default, since it changes +what gets polled/displayed without an explicit repo list), `repos_exclude += []`, `active_within_days = 30`, `repo_refresh_minutes = 60`. + +### Discovery (`RestPoller.fetch_account_repos`, `ci_status/github.py`) + +`GET /user/repos?affiliation=owner&sort=pushed&per_page=100`, paginated up +to a 1000-repo cap (10 pages). Only page 1 carries a conditional-request +(ETag) slot -- a deliberate choice: `sort=pushed` puts the most-recently- +active repos first, so the common case (a single-page account) gets a free +`304` whenever nothing relevant changed, while paying for pages 2+ on +every re-enumeration (itself only every `repo_refresh_minutes`, not every +poll) is an acceptable rare cost for >100-repo accounts. It also sidesteps +a correctness trap: reusing a cached page-2+ result on a page-1 304 could +miss a `pushed_at` update to a repo that moved within page 2 without +crossing into page 1 -- so pages 2+ are always fetched fresh when needed, +never cached across calls. + +Returns `None` (not an empty list) on a 304 or any failure at any page -- +an empty list is indistinguishable from "this account genuinely owns zero +repos," which would silently stop watching everything. The caller +(`main._refresh_account_repos`) treats `None` as "keep the previous +cached list," logging a warning only when there is no previous list to +fall back on yet. + +### List resolution (`resolve_repo_list`, `ci_status/logic.py`) + +Pure function: `repos` (explicit list, always included, never filtered by +recency) **union** auto-discovered account repos filtered to +non-archived + `pushed_at` within `active_within_days` **minus** +`repos_exclude` (applied unconditionally in both modes, always a no-op +when empty). Returns a sorted, deduplicated list -- the raw +`pushed`-sort order from discovery isn't meaningful to callers. + +Caveat, documented in the README too: `pushed_at` is a repo-level field +with no notion of schedule-triggered workflow runs. A repo whose CI only +fires on a cron schedule (no pushes) ages out of `active_within_days` and +stops being watched even while its scheduled runs keep firing, since +nothing about a scheduled run touches `pushed_at`. Such a repo must be +named explicitly in `repos` to stay watched indefinitely. + +### `run_once` integration (`ci_status/main.py`) + +New optional `repo_cache` parameter (same caller-owned-mutable-dict +pattern as `running_cache`/`overlay_state`/`quota_cache`; omitting it +skips account-wide discovery entirely and preserves the exact pre-v1.5.1 +behavior of polling `cfg["ci_status"]["repos"]` verbatim). Each poll +resolves the effective repo list fresh (cheap -- a set operation over +already-cached data, not a network call) via `resolve_repo_list`, calling +`_refresh_account_repos` (mirrors `_refresh_quota`'s freshness-cache +pattern) only when `watch_account_repos` is on and the cache is stale. + +Any repo present in `state_cache`/`running_cache` from a previous poll but +absent from the freshly-resolved list -- excluded, aged out, or deleted +upstream -- has its cached state dropped, and the poller's own per-repo +ETag slots forgotten (`RestPoller.forget_repo`, pops both `_etags` and +`_running_etags`), so a stale failure/stuck alert or running badge can't +linger for a repo no longer being watched, and a repo that's later +re-added starts with a clean conditional-request slate. + +Two related fixes needed for account-wide watching to actually work +end-to-end, not just compile: `main()`'s "no repos configured" validation +previously required a non-empty `repos` list unconditionally, which would +have rejected a valid `watch_account_repos = true` / `repos = []` +configuration outright -- now it only errors when *both* are empty/off. +`next_poll_seconds` previously iterated `cfg_ci["repos"]` to check for an +active run; since `running_cache`'s keys can now include auto-discovered +repos never present in `repos` at all, that would have silently failed to +shorten the poll interval for an active run on any auto-discovered repo, +defeating a chunk of the alternation feature for exactly the repos this +feature exists to add. Fixed to check `running_cache.values()` directly. + +### Quota math and the private-repo-name caveat + +See the README's "Account-wide watching" section for the full quota-cost +breakdown (N watched repos x `poll_seconds` cadence, all free in the +304 steady state; +1 request/`repo_refresh_minutes` for enumeration +itself, also ETag-cached on its first page) and the explicit note that +discovery includes private repos by design (no way to filter public vs. +private from the API call used, and it's fine for this integration's +local-only threat model) -- with the practical consequence that a private +repo's name can render on the physical display exactly like a public +one's. + +### Verification + +Tests: `resolve_repo_list` (union/exclude/active-window filtering, +explicit repos never filtered, empty/`None` account_repos, dedup + +deterministic sort), `fetch_account_repos` (single-page pass-through, +pagination across multiple pages, 304 handling, failure returns `None` +not `[]`, `forget_repo` clearing both ETag dicts), `_refresh_account_repos` +timing (stale cache re-fetches, fresh cache doesn't, failure keeps the +previous list), `state_cache`/`running_cache` pruning for repos dropped +from the effective list, `next_poll_seconds` checking auto-discovered +repos, config defaults. Full suite run verbatim in the implementation +report. + +Live check: `--once --dry-run` against a **local throwaway config** (not +the operator's real `config.toml`) with `watch_account_repos` temporarily +`true`, confirming real discovery + resolution end-to-end against the +live GitHub API. Discovered-repo count and public-repo names are in the +implementation report; private repo names are redacted there (``) +since reports may be quoted in public PRs, even though the display itself +has no such redaction (see the caveat above). + +## 2026-08-03 — v1.5.1 running-badge ETA label ("remain" / "left") + +**Status:** Implemented, same branch (`dev/claude/account-wide-v1.5.1`). +Additive, orthogonal to the account-wide watching feature above -- both +landed on this branch but touch unrelated code paths (this one is +entirely within the running badge's own render function). + +Next to the ETA numeral on the running badge, a small muted label is +appended when it fits: `remain` preferred, `left` as a fallback, omitted +entirely if neither fits. Grammar guard: applies only to remaining- +estimate ETA forms (`~4m`, `~1h05m`, `~10h`, ...) -- never `soon` or the +no-history elapsed form (`3m in`), since a label reads as nonsense or +outright wrong on either of those. The guard is a single prefix check +(`eta_text.startswith("~")`), reliable because every remaining-estimate +form `_format_eta_text` produces is `~`-prefixed by construction and +neither of the other two forms ever is. + +### Fit decision (`_eta_label`, `ci_status/logic.py`) + +Reuses `calendar_countdown.logic.GLYPH_ADVANCE_PX`/`_text_width_px` (the +existing large-font per-glyph width table, extended -- see below) to +measure `eta_text`'s own rendered width, then checks whether +`eta_width + RUNNING_LABEL_GAP_PX + width(label)` fits within +`RUNNING_LABEL_BUDGET_PX` (68px: `PANEL_WIDTH - RUNNING_NUMERAL_X - 2`, +the same 2px-right-margin convention as `OVERLAY_TITLE_WIDTH`), trying +`"remain"` first, then `"left"`. + +A deliberate, documented modeling choice: the label itself renders in the +**small** font, not `large`, but its width is estimated through the +large-font table anyway. On-device calibration: `"remain"` measures 22px +in its real small font vs. 35px as estimated via the large-font table; +`"left"` measures 11px vs. 20px. The large-font table always +overestimates a small-font string's width on this device, so reusing it +is safe for a fits/doesn't-fit decision (the failure mode is an +occasionally-omitted label that would have visually fit, never a clipped +one) -- and it means no second, small-font-specific glyph table was +needed. + +`GLYPH_ADVANCE_PX` gained nine entries for this feature: `~` (the +remaining-estimate prefix) plus every letter needed to measure `"remain"` +and `"left"` through the same table (`r`, `a`, `e`, `i`, `n`, `l`, `f`, +`t`; `m` already existed). Measured on-device via the same successive- +prefix rightmost-ink-column differencing technique the original digit +table's comment documents -- self-validated by re-measuring `"0"` through +this exact technique and reproducing the table's existing value (7px) +with no adjustment needed. + +### Geometry + +`RUNNING_LABEL_Y = 9` (small font) puts the label's own ink at rows +11-15, baseline-aligned with the large ETA numeral's ink (rows 7-15, +`OVERLAY_NUMERAL_Y = 5`) via the calendar's established "+2px ink-offset" +model (a small-font element at `y=Y` inks starting at row `Y+2`) -- +confirmed on-device, not just derived. `RUNNING_LABEL_COLOR = "#8FA3B3FF"`, +a muted gray-blue, deliberately desaturated and dimmer than +`RUNNING_NUMERAL_COLOR`, extending the same title/numeral brightness +hierarchy one step further; confirmed legible against +`RUNNING_BG_GRADIENT` on-device. + +### Shape tracking + +Adding `eta_label` changes the running badge's own element-id shape +(`{bg, title, track, track_fill, eta}` -> `{..., eta_label}`) whenever the +ETA text's width crosses a fit boundary between polls (e.g. counting down +from `~1h00m`, which only fits `left`, into `~59m`, which fits `remain`, +or losing the label entirely as the ETA widens). No new code was needed +in `main.py` for this: the unified shape tracker added in the prior +revision (a generic `frozenset(e["id"] for e in payload["elements"])` +comparison, not a hardcoded badge/quota mapping) already detects any +element-id-set change on any draw to `APP` and clears first -- this is +exactly the generalization that fix was for. + +### Verification + +Tests (`tests/test_ci_logic.py`): fit-decision boundaries using real +`_format_countdown` output at the exact measured widths (`"~59m"` = 29px +fits `remain`; `"~1h00m"` = 40px only fits `left`; a synthetic +`"~23h59m"` = 49px, wider than any real `_format_eta_text` output can +reach today, fits neither -- included specifically to exercise that +branch defensively), both exclusion forms (`"soon"`, `"3m in"` and a +longer no-history form), the label element's `x` position tying back to +the measured eta width, and both running-badge shape variants (with and +without the label) via `build_overlay_payload`. `test_glyph_advance_table_ +covers_every_countdown_glyph` (`test_calendar_logic.py`) relaxed from +exact-set equality to a subset check, since the table now carries more +than `_format_countdown` alone needs. + +On-device: both fit outcomes captured through the ink-overlap + buffer +gate (title rows 0-4, eta rows 7-15, label rows 11-15, no ink in the row-5 +buffer for any of the three text elements, no label ink in columns 0-1 or +column 71/the right edge -- confirming no clip). Drawn to `preview` at +priority 25, not the payload's own `PRIORITY_OVERLAY` (21): the live +`ci_status` LaunchAgent was genuinely active during this session (a +private repo's live agent activity) and draws at priority 21 too, so a +same-priority `preview` draw was observed being rejected outright (the +same equal-priority-different-`application_name` firmware behavior the +v1.5 probes found) until the priority was raised, matching the precedent +already set by the original v1.5 badge-variant verification script. diff --git a/integrations/calendar_countdown/logic.py b/integrations/calendar_countdown/logic.py index 8256e3b..f14b603 100644 --- a/integrations/calendar_countdown/logic.py +++ b/integrations/calendar_countdown/logic.py @@ -145,6 +145,17 @@ GLYPH_ADVANCE_PX = { "0": 7, "1": 5, "2": 7, "3": 7, "4": 7, "5": 7, "6": 7, "7": 7, "8": 7, "9": 7, "h": 6, "m": 8, + # v1.5.1: extended for ci_status's running-badge ETA label feature -- + # "~" (the remaining-estimate prefix, e.g. "~4m") plus every letter + # needed to also measure "remain"/"left" through this same table (see + # ci_status/logic.py's _eta_label docstring for why the label -- which + # actually renders in the *small* font -- is deliberately measured via + # this *large*-font table anyway: on-device calibration found it's a + # sizeable but safe overestimate of the label's real small-font width). + # Same on-device successive-prefix differencing methodology as the + # digits/h/m above; self-validated by re-measuring "0" via this + # technique and getting the same 7px already in this table. + "~": 7, "r": 5, "a": 6, "e": 6, "i": 4, "n": 6, "l": 4, "f": 5, "t": 5, } diff --git a/integrations/ci_status/README.md b/integrations/ci_status/README.md index b7ac68f..3fd1588 100644 --- a/integrations/ci_status/README.md +++ b/integrations/ci_status/README.md @@ -51,9 +51,11 @@ show_running = true # show a badge while a run is in progress (defaul 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) +watch_account_repos = false # auto-discover and watch every repo you own (default: false) -- + # see "Account-wide watching" below before enabling ``` -At minimum, set `repos` to the repositories you want to monitor (e.g., `["owner/repo1", "owner/repo2"]`). +At minimum, set `repos` to the repositories you want to monitor (e.g., `["owner/repo1", "owner/repo2"]`) -- unless you enable `watch_account_repos` instead (see below), in which case `repos` is optional and just adds always-included repos on top of whatever's auto-discovered. ### 3. Test in Foreground @@ -75,12 +77,26 @@ Once the foreground test completes, your `config.toml` is in place and GitHub au | Key | Type | Default | Purpose | |---|---|---|---| | `poll_seconds` | integer | 120 | Polling interval in seconds | -| `repos` | array of strings | — | GitHub repositories to monitor in `owner/repo` format (required) | +| `repos` | array of strings | — | GitHub repositories to monitor in `owner/repo` format. Required unless `watch_account_repos` is true, in which case these are always-included repos layered on top of auto-discovery (never filtered by `active_within_days`, since you named them explicitly). | | `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. | +| `watch_account_repos` | boolean | false | Auto-discover and watch every repo you own, in addition to `repos`. See "Account-wide watching" below. | +| `repos_exclude` | array of strings | `[]` | Repos to never watch, regardless of mode — silences a specific repo without leaving account mode (or, less commonly, without editing `repos`). Applied last, unconditionally; a no-op when empty. | +| `active_within_days` | integer | 30 | In account mode, only auto-discovered repos pushed within this many days are watched (caps request volume on large accounts). Repos in `repos` are never subject to this filter. | +| `repo_refresh_minutes` | integer | 60 | How often the account's repo list is re-enumerated. A newly created (or newly pushed-to, if previously outside the active window) repo is picked up within this interval, not instantly. | + +## Account-wide watching + +By default this integration watches exactly the repos listed in `repos`. Setting `watch_account_repos = true` switches to a broader mode: the watch list becomes every repo you own (`GET /user/repos?affiliation=owner`, so this does **not** pick up repos you merely have collaborator/org-member access to, only ones under your own account) that's been pushed to within `active_within_days` days, **union** `repos` (always included, never filtered by recency), **minus** `repos_exclude`. New repos are picked up automatically — no config edit needed — within `repo_refresh_minutes` of their creation or of a first push that puts them back inside the active window. + +**Private repos are included, and that's intentional.** Discovery has no way to filter private vs. public — it watches everything you own that's active. This is fine for this integration's threat model: both the resulting config state (the discovered list itself, cached in memory) and the physical display are local to your own device and your own account's token. But the practical consequence is real: **a private repo's name can render on the physical display** (in the running badge's title, or in a failure/stuck alert's `repo:workflow` text) exactly like a public one would. If the device sits somewhere visible to people who shouldn't know a private repo exists, either keep `watch_account_repos` off and list repos explicitly, or add sensitive ones to `repos_exclude`. + +**Quota math.** With N repos in the effective watch list, each poll cycle costs N REST requests to `.../actions/runs` (steady-state, these return `304` and cost nothing against your quota — see "Design: REST-only, Quota-Efficient" above) at `poll_seconds` cadence (default every 120s, so N requests every 2 minutes = up to `N * 30` requests/hour, all free in the steady state), plus N more to the running-runs endpoint whenever `show_running` is on, at `running_poll_seconds` cadence while any run is active. Account-wide discovery itself adds one more request per `repo_refresh_minutes` (default hourly = 1 request/hour, also ETag-cached on its first page — see `RestPoller.fetch_account_repos`'s docstring). None of this touches your real GitHub REST quota unless workflow state is actually changing, since 304s are free; the practical cap that matters is request *volume* (GitHub does rate-limit request rate, not just quota), which is why `active_within_days` exists — it keeps N bounded to your actually-active repos instead of every repo you've ever created. + +**Caveat: `active_within_days` filters on `pushed_at`, a repo-level field — it has no idea about *schedule*-triggered workflow runs.** A repo whose CI only ever runs on a cron schedule (no pushes) will fall out of the active window and stop being watched even while its scheduled runs keep firing, because nothing about a scheduled run touches `pushed_at`. If you rely on schedule-triggered CI on a repo that doesn't otherwise see regular pushes, add it to `repos` explicitly (explicit repos are never subject to the active-window filter) rather than relying on account-wide discovery to keep watching it. ## Display Priority Tiers @@ -122,6 +138,13 @@ per dwell slot (`OVERLAY_DWELL_SECONDS`, 10s), before repeating: 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). + When there's room, a small muted `remain` (or `left`, if `remain` + doesn't fit) is appended right after the ETA — e.g. `~57m remain` or + `~1h01m left` — never on `soon` (already imminent) or the no-history + `3m in` form (that's elapsed time, not a remaining estimate, so a + remaining-time label would be wrong, not just superfluous). Whether it + fits at all, and which word if so, is a width-based decision (see + `ci_status/logic.py`'s `_eta_label`); nothing to configure. 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`). diff --git a/integrations/ci_status/github.py b/integrations/ci_status/github.py index 8d12b79..a75a79d 100644 --- a/integrations/ci_status/github.py +++ b/integrations/ci_status/github.py @@ -37,6 +37,11 @@ def __init__(self, token: str): # 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] = {} + # Account-wide repo discovery (v1.5.1): only page 1 of + # /user/repos gets a conditional-request slot -- see + # fetch_account_repos's docstring for why pages 2+ deliberately + # don't get one. + self._account_repos_etag: str | None = None def _headers(self) -> dict: return { @@ -156,3 +161,127 @@ def fetch_rate_limit(self) -> dict | None: except ValueError as exc: log.warning("github returned non-JSON (rate_limit fetch): %s", exc) return None + + def fetch_account_repos(self) -> list[dict] | None: + """GET /user/repos?affiliation=owner&sort=pushed&per_page=100, + paginated (v1.5.1 account-wide watching). Includes private repos + -- that's intentional and fine, since both the resulting config + state and the physical display are local to the operator's own + device; see the README's "Account-wide watching" section for the + explicit note that private repo names can render on-screen. + + Only page 1 uses a conditional (ETag) request. This is a + deliberate tradeoff, not an oversight: `sort=pushed` puts the + most-recently-active repos first, so page 1's ETag is the cheap + common-case win (a single-page account -- the overwhelming + majority -- gets a 304 whenever nothing relevant changed, costing + zero quota); paying for pages 2+ on every re-enumeration (which + itself only runs every `repo_refresh_minutes`, not every poll) is + an acceptable, rare cost for >100-repo accounts, and it avoids a + subtler correctness trap: reusing a stale cached page 2+ result + on a page-1 304 could miss a `pushed_at` update to a repo that + moved within page 2 without ever crossing into page 1. + + Returns `None` on a page-1 304 (nothing changed) or any failure + (network, non-200, malformed JSON) at any page -- the caller + (`main._refresh_account_repos`) treats `None` as "keep whatever + was cached before" and logs a warning on a genuine failure, never + falling back to an empty list (an empty list would look + indistinguishable from "this account genuinely owns zero repos," + which would silently stop watching everything). + + The page-1 ETag is committed to `self._account_repos_etag` ONLY + after pagination completes in full -- not right after page 1's + own response, even though that response is where the ETag value + comes from. Committing it earlier would create a silent + lock-in bug for >100-repo accounts: if page 2+ then failed, this + call would still return the partial `repos` list collected so + far (a partial result being better than discarding everything), + but the page-1 ETag would already be cached -- so the *next* + call sends `If-None-Match` for a page-1 body that genuinely + hasn't changed, gets a 304, and this method returns `None`. The + caller reads `None` as "nothing changed, keep the cached list" -- + permanently freezing the account's watch list at that one + incomplete pagination run's partial subset, with no future call + ever able to recover the rest (every subsequent page-1 request + keeps matching the same cached ETag). On an incomplete pagination + run, `self._account_repos_etag` is simply left as whatever it + was (not overwritten) -- safe, not just "not actively wrong": + reaching this branch means page 1's *response this call* was a + fresh 200, not a 304, so page 1's content has already been + confirmed to differ from whatever the old cached ETag matched; + the next call sending that same old ETag will therefore get + another fresh 200 (never a wrongful 304), giving pagination + another full chance to complete rather than reusing the partial + result. See the regression test for the failure mode this + guards against. + """ + url = f"{API}/user/repos" + headers = self._headers() + if self._account_repos_etag is not None: + headers["If-None-Match"] = self._account_repos_etag + try: + resp = requests.get(url, headers=headers, + params={"affiliation": "owner", "sort": "pushed", "per_page": 100}, + timeout=(5, 15)) + except requests.RequestException as exc: + log.debug("github unreachable (account repo enumeration): %s", exc) + return None + if resp.status_code == 304: + return None + if resp.status_code != 200: + log.warning("github %s (account repo enumeration)", resp.status_code) + return None + new_etag = resp.headers.get("ETag") # not committed yet -- see the docstring above + try: + page_items = resp.json() + except ValueError as exc: + log.warning("github returned non-JSON (account repo enumeration): %s", exc) + return None + if not isinstance(page_items, list): + log.warning("github returned unexpected shape (account repo enumeration)") + return None + + repos = list(page_items) + page = 2 + complete = True + while len(page_items) == 100 and page <= 10: # cap: 1000 owned repos + try: + resp = requests.get(url, headers=self._headers(), + params={"affiliation": "owner", "sort": "pushed", + "per_page": 100, "page": page}, + timeout=(5, 15)) + except requests.RequestException as exc: + log.debug("github unreachable (account repo enumeration page %d): %s", page, exc) + complete = False + break # a partial result is still better than discarding everything + if resp.status_code != 200: + log.warning("github %s (account repo enumeration page %d)", resp.status_code, page) + complete = False + break + try: + page_items = resp.json() + except ValueError as exc: + log.warning("github returned non-JSON (account repo enumeration page %d): %s", page, exc) + complete = False + break + if not isinstance(page_items, list): + complete = False + break + repos.extend(page_items) + page += 1 + + if complete and new_etag is not None: + self._account_repos_etag = new_etag + return repos + + def forget_repo(self, repo: str) -> None: + """Drop cached conditional-request state (both ETag slots) for a + repo that has left the effective watch list -- excluded, aged out + of `active_within_days`, or deleted upstream (v1.5.1). Called by + `main.run_once` alongside `state_cache`/`running_cache` pruning so + a repo that's later re-added (e.g. it becomes active again) starts + with a clean conditional-request slate instead of an ETag from a + stale enumeration.""" + self._etags.pop(repo, None) + self._running_etags.pop(repo, None) diff --git a/integrations/ci_status/logic.py b/integrations/ci_status/logic.py index 46646b8..8b01a6f 100644 --- a/integrations/ci_status/logic.py +++ b/integrations/ci_status/logic.py @@ -1,6 +1,6 @@ import sys from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path # Reuse the calendar integration's generic text/formatting helpers rather @@ -9,15 +9,17 @@ # 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 +# _text_width_px (per-glyph width via GLYPH_ADVANCE_PX, v1.5.1: reused by +# the running badge's ETA "remain"/"left" label fit decision -- +# see _eta_label below), 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, + ascii_safe, _format_countdown, _title_fits, _text_width_px, SCROLL_RATE, SCROLL_DELAY_MS, PANEL_WIDTH, PANEL_HEIGHT, ) @@ -69,6 +71,64 @@ def _parse_ts(value: str) -> datetime: return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) +def resolve_repo_list(repos: list[str], repos_exclude: list[str], watch_account_repos: bool, + account_repos: list[dict] | None, active_within_days: int, + now: datetime) -> list[str]: + """The effective set of repos to poll this cycle (v1.5.1 account-wide + watching). + + In account mode (`watch_account_repos`), the watch list is the union + of auto-discovered account repos -- filtered to non-archived and + pushed within `active_within_days` -- and the explicit `repos` list. + An explicitly configured repo is always included regardless of its + own push recency: the user named it on purpose, so staleness + filtering shouldn't silently drop it. `account_repos` (raw dicts from + `RestPoller.fetch_account_repos`, or the caller's cached copy of an + earlier successful fetch) may be `None` or empty -- e.g. enumeration + hasn't succeeded yet, or `watch_account_repos` is off -- in which + case the result is just `repos` minus `repos_exclude`, same as + pre-v1.5.1 behavior. + + `repos_exclude` is applied last, unconditionally, in both modes -- + always a no-op when empty, not something that only matters in + account mode -- so a user can silence a noisy repo without leaving + account mode, or (less commonly) without editing an explicit `repos` + list either. + + Caveat (documented in the README too): a repo whose most recent push + predates `active_within_days` is excluded from account-mode + discovery even if it has a *schedule*-triggered workflow run more + recent than that -- `pushed_at` is a repo-level field, not aware of + scheduled/dispatched runs that don't touch the git history. Such a + repo is only observed if named explicitly in `repos`. + + Returns a sorted, deduplicated list -- the raw account-repo discovery + order (by `pushed_at`) isn't meaningful to callers, and a stable, + deterministic order makes both testing and reading `--dry-run` output + easier. + """ + names = set(repos) + if watch_account_repos and account_repos: + cutoff = now - timedelta(days=active_within_days) + for r in account_repos: + if r.get("archived"): + continue + pushed_at = r.get("pushed_at") + if not pushed_at: + continue + try: + pushed = _parse_ts(pushed_at) + except (ValueError, TypeError): + continue + if pushed < cutoff: + continue + full_name = r.get("full_name") + if full_name: + names.add(full_name) + names -= set(repos_exclude) + return sorted(names) + + def evaluate_runs(repo: str, runs: list[dict], now: datetime, stale_queued_minutes: int) -> RepoState: latest: dict[int, dict] = {} @@ -144,6 +204,24 @@ def evaluate_runs(repo: str, runs: list[dict], now: datetime, RUNNING_TRACK_FILL_COLOR = "#29B6F6FF" # spec: "solid cyan" -- one flat color, no gradient RUNNING_NUMERAL_COLOR = "#66E1FFFF" +# ETA label (v1.5.1): a muted gray-blue, deliberately desaturated and +# dimmer than the bright cyan numeral it sits beside -- a secondary-tier +# color, following the same hierarchy already established between +# RUNNING_TITLE_COLOR and RUNNING_NUMERAL_COLOR (the numeral is the +# brighter/more-saturated of the two), just pushed one step further. +# Confirmed legible on-device against RUNNING_BG_GRADIENT. +RUNNING_LABEL_COLOR = "#8FA3B3FF" +RUNNING_LABEL_GAP_PX = 3 # breathing room between the eta numeral and the label +# Same 2px right margin convention as OVERLAY_TITLE_WIDTH (68 = 72 - 2*2). +RUNNING_LABEL_BUDGET_PX = PANEL_WIDTH - RUNNING_NUMERAL_X - 2 +# ink rows 11-15 (small font, y=9) -- baseline-aligned with the large +# numeral's own ink rows 7-15 (OVERLAY_NUMERAL_Y=5): the calendar's +# established "+2px ink-offset" model means a small-font element at y=Y +# inks starting at Y+2, so y=9 -> ink starts at row 11, landing the +# label's bottom edge (row 15) flush with the numeral's own bottom edge +# rather than vertically centered against it. Verified on-device. +RUNNING_LABEL_Y = 9 + def _pr_or_branch(run: dict) -> str: """PR number ("#42") if the run belongs to a pull request, else the @@ -212,6 +290,42 @@ def _format_eta_text(run: dict, median_minutes: float | None, now: datetime) -> return f"~{_format_countdown(eta)}" +def _eta_label(eta_text: str) -> str | None: + """"remain" / "left" / None, appended after a remaining-estimate ETA + numeral (v1.5.1). + + Grammar guard: applies ONLY to the remaining-estimate forms + _format_eta_text produces ("~4m", "~1h05m", "~10h", ...) -- every one + of which is "~"-prefixed by construction. Never on "soon" (already + imminent -- a label would read as nonsense, "soon remain") and never + on the no-history elapsed form ("3m in" -- that's elapsed time, not a + remaining estimate, so "remain"/"left" would be actively wrong, not + just superfluous). The prefix check is the entire guard; there is no + other "~"-prefixed eta_text this function ever sees. + + Fit is decided via the (extended) large-font GLYPH_ADVANCE_PX table + -- used here as a deliberately conservative proxy for the label's + real width even though the label itself renders in the *small* font, + not `large`. On-device calibration: "remain" measures 22px in the + actual small font vs. 35px as estimated through this large-font + table; "left" measures 11px vs. 20px estimated. The large-font + table always overestimates a small-font string's width on this + device, so reusing it here is safe -- the failure mode is + occasionally omitting a label that would have visually fit, never + drawing one that clips. Prefers "remain"; falls back to "left" if + "remain" plus the eta numeral doesn't fit within + RUNNING_LABEL_BUDGET_PX; omits the label entirely if even "left" + doesn't fit. + """ + if not eta_text.startswith("~"): + return None + eta_width = _text_width_px(eta_text) + for label in ("remain", "left"): + if eta_width + RUNNING_LABEL_GAP_PX + _text_width_px(label) <= RUNNING_LABEL_BUDGET_PX: + return label + return None + + 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) @@ -282,7 +396,25 @@ def _build_running_elements(info: RunningInfo, timeout_s: int) -> list[dict]: "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] + elements = [bg_element, title_element, track_element, track_fill_element, numeral_element] + + # ETA label (v1.5.1): appended only when _eta_label decides it fits + # (grammar guard + width check -- see its docstring). This changes + # the badge's own element-id shape (adds "eta_label") between polls + # where the label appears/disappears as the ETA text's own width + # changes over the run's lifetime -- no special handling needed here + # for that: main.py's unified shape tracker (frozenset of element + # ids on whatever was last actually drawn) already detects any shape + # change and clears first, generically, not just at tier boundaries. + label = _eta_label(eta_text) + if label is not None: + elements.append({ + "id": "eta_label", "type": "text", "text": label, "font": "small", + "color": RUNNING_LABEL_COLOR, + "x": RUNNING_NUMERAL_X + _text_width_px(eta_text) + RUNNING_LABEL_GAP_PX, + "y": RUNNING_LABEL_Y, "timeout": timeout_s, + }) + return elements # --- API-quota overlay frames ----------------------------------------------------- diff --git a/integrations/ci_status/main.py b/integrations/ci_status/main.py index d6e1cfe..c3d9603 100644 --- a/integrations/ci_status/main.py +++ b/integrations/ci_status/main.py @@ -18,7 +18,7 @@ from .logic import ( RepoState, RunningInfo, QuotaInfo, build_ci_payload, build_overlay_payload, evaluate_runs, - overlay_frame_sequence, parse_rate_limit, select_running_run, + overlay_frame_sequence, parse_rate_limit, resolve_repo_list, select_running_run, ) APP = "ci_status" @@ -28,6 +28,31 @@ QUOTA_STALE_SECONDS = 300 # never show rate_limit data older than 5 minutes +def _refresh_account_repos(poller, repo_cache: dict | None, now: datetime, + repo_refresh_minutes: int) -> list[dict] | None: + """Re-enumerate the account's owned repos (v1.5.1) when `repo_cache` + is stale or has never been populated, keeping the previous list on an + enumeration failure -- never crash, never silently fall back to an + empty watch list (an empty result from `fetch_account_repos` is + indistinguishable from "genuinely zero repos," so `None` is the only + signal treated as "keep what we had"; see that method's docstring). + Mirrors `_refresh_quota`'s cache-freshness pattern. + """ + if repo_cache is None: + return None + fetched_at = repo_cache.get("fetched_at") + stale = fetched_at is None or (now - fetched_at).total_seconds() > repo_refresh_minutes * 60 + if stale: + fetched = poller.fetch_account_repos() + if fetched is not None: + repo_cache["repos"] = fetched + repo_cache["fetched_at"] = now + elif repo_cache.get("repos") is None: + log.warning("account repo enumeration failed and no previous list is " + "cached yet -- watch_account_repos contributes nothing this poll") + return repo_cache.get("repos") + + 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 @@ -61,13 +86,30 @@ def run_once(client, poller, cfg: dict, now: datetime, state_cache: dict[str, RepoState], dry_run: bool, running_cache: dict[str, list[dict]] | None = None, overlay_state: dict | None = None, - quota_cache: dict | None = None) -> 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. + quota_cache: dict | None = None, + repo_cache: dict | None = None) -> str: + """`running_cache`, `overlay_state`, `quota_cache`, and `repo_cache`, + when passed, are caller-owned dicts this function mutates in place + (mirroring `state_cache`'s existing pattern) so `main()` can hold one + 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; omitting `repo_cache` (the default) skips account-wide + discovery entirely and falls back to the pre-v1.5.1 behavior of + polling exactly `cfg["ci_status"]["repos"]` every cycle. + + Account-wide watching (v1.5.1): when `repo_cache` is given, the + effective repo list for this poll is resolved fresh each call via + `resolve_repo_list` (cheap -- it's a set operation over already-cached + data, not a network call) from `repos`/`repos_exclude`/ + `watch_account_repos`/`active_within_days`, re-enumerating the + account's repos via `_refresh_account_repos` only when that cache is + older than `repo_refresh_minutes` (or empty). Any repo present in + `state_cache`/`running_cache` but absent from the freshly-resolved + list -- excluded, aged out of the active window, or deleted upstream + -- has its cached state dropped (and the poller's own per-repo ETag + slots forgotten via `forget_repo`) so a stale failure/stuck alert or + running badge can't linger for a repo that's no longer being watched. Overlay rotation: while a run is active (and no failure/stuck alert preempts it), the overlay tier draws one frame per dwell slot, cycling @@ -109,7 +151,29 @@ class the v1.3.1 calendar transition-clear fix addressed, recurring at """ c = cfg["ci_status"] timeout_s = int(c["poll_seconds"] * 1.5) - for repo in c["repos"]: + + if repo_cache is not None: + account_repos = (_refresh_account_repos(poller, repo_cache, now, + c.get("repo_refresh_minutes", 60)) + if c.get("watch_account_repos") else None) + effective_repos = resolve_repo_list( + c["repos"], c.get("repos_exclude", []), bool(c.get("watch_account_repos")), + account_repos, c.get("active_within_days", 30), now) + else: + effective_repos = c["repos"] + + # Drop cached state for any repo that left the effective list this + # poll (excluded, aged out, deleted upstream) so a stale alert or + # running badge can't linger for a repo no longer being watched. + for repo in set(state_cache) - set(effective_repos): + state_cache.pop(repo, None) + poller.forget_repo(repo) + if running_cache is not None: + for repo in set(running_cache) - set(effective_repos): + running_cache.pop(repo, None) + poller.forget_repo(repo) + + for repo in effective_repos: runs = poller.fetch_runs(repo) if runs is not None: # None = 304/no-change/error -> keep cached state state_cache[repo] = evaluate_runs(repo, runs, now, @@ -126,7 +190,7 @@ class the v1.3.1 calendar transition-clear fix addressed, recurring at # 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"]: + for repo in effective_repos: running_runs = poller.fetch_running_runs(repo) if running_runs is not None: # None = 304/no-change/error -> keep cached running_cache[repo] = running_runs @@ -209,15 +273,36 @@ class the v1.3.1 calendar transition-clear fix addressed, recurring at 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`. + """Cadence switch: `running_poll_seconds` while any *currently + watched* repo has a running run, `poll_seconds` otherwise. Checks + every key `running_cache` actually holds (not `cfg_ci["repos"]`) -- + account-wide watching (v1.5.1) means the set of repos with entries in + `running_cache` can include auto-discovered repos that were never in + the explicit `repos` list at all; iterating `cfg_ci["repos"]` would + silently miss an active run on any of those and never shorten the + poll interval for it. 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"]) + any_running = any(running_cache.values()) return cfg_ci["running_poll_seconds"] if any_running else cfg_ci["poll_seconds"] +def config_requires_repos(cfg: dict) -> str | None: + """Validates that this config gives ci_status *something* to watch -- + either an explicit `repos` list, or `watch_account_repos = true` + (which discovers repos at runtime, so an empty `repos` list is valid + in that mode -- see v1.5.1's account-wide watching). Returns the + error message to log if neither is satisfied, else None. Pulled out + of main() as a pure function of `cfg` so this validation is testable + without exercising the rest of main()'s side effects (argparse, + logging setup, gh auth, device connection). + """ + if not cfg["ci_status"]["repos"] and not cfg["ci_status"].get("watch_account_repos"): + return ("No repos configured. Copy config.example.toml to config.toml " + "and set [ci_status] repos, or set watch_account_repos = true.") + return None + + def main() -> int: parser = argparse.ArgumentParser(description="BUSY Bar CI status") parser.add_argument("--once", action="store_true") @@ -226,9 +311,9 @@ def main() -> int: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") cfg = load_config() - if not cfg["ci_status"]["repos"]: - log.error("No repos configured. Copy config.example.toml to config.toml " - "and set [ci_status] repos.") + error = config_requires_repos(cfg) + if error is not None: + log.error(error) return 1 from .github import RestPoller, get_token try: @@ -243,11 +328,13 @@ def main() -> int: running_cache: dict[str, list[dict]] = {} overlay_state: dict = {} quota_cache: dict = {} + repo_cache: dict = {} backoff = 5 while True: summary = run_once(client, poller, cfg, datetime.now(timezone.utc), state_cache, args.dry_run, running_cache=running_cache, - overlay_state=overlay_state, quota_cache=quota_cache) + overlay_state=overlay_state, quota_cache=quota_cache, + repo_cache=repo_cache) log.info(summary) if args.once: return 0 diff --git a/src/busybar/config.py b/src/busybar/config.py index ab12bd0..b9dd609 100644 --- a/src/busybar/config.py +++ b/src/busybar/config.py @@ -34,6 +34,15 @@ "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) + # Account-wide watching (v1.5.1) -- off by default; the operator + # enables it locally per-machine, not as a shipped default, since + # it changes what gets polled/displayed without an explicit repo + # list. See ci_status/README.md's "Account-wide watching" section + # for quota math and the active-window caveat. + "watch_account_repos": False, + "repos_exclude": [], # silence specific repos without leaving account mode + "active_within_days": 30, # only repos pushed within this window are polled + "repo_refresh_minutes": 60, # how often the repo list itself is re-enumerated }, } diff --git a/tests/test_calendar_logic.py b/tests/test_calendar_logic.py index 0439f81..ab6069f 100644 --- a/tests/test_calendar_logic.py +++ b/tests/test_calendar_logic.py @@ -191,8 +191,11 @@ def test_countdown_full_form_overflow_is_a_real_per_string_measurement(): def test_glyph_advance_table_covers_every_countdown_glyph(): # Every character _format_countdown can ever emit (digits, 'h', 'm') # must have a table entry, or _text_width_px raises KeyError at draw - # time instead of failing a test. - assert set(GLYPH_ADVANCE_PX) == set("0123456789hm") + # time instead of failing a test. (v1.5.1 extended the table further, + # with "~" plus the letters ci_status's running-badge ETA label + # feature needs -- see GLYPH_ADVANCE_PX's own comment -- so this is a + # subset check now, not exact equality.) + assert set("0123456789hm").issubset(set(GLYPH_ADVANCE_PX)) # --- build_elements: upcoming event ------------------------------------------ diff --git a/tests/test_ci_github.py b/tests/test_ci_github.py index cb996e1..1adf36f 100644 --- a/tests/test_ci_github.py +++ b/tests/test_ci_github.py @@ -198,3 +198,153 @@ def test_fetch_rate_limit_none_on_malformed_json(mock_get): resp.json.side_effect = ValueError("Invalid JSON") mock_get.return_value = resp assert RestPoller("tok").fetch_rate_limit() is None + + +# --- fetch_account_repos: discovery, pagination, page-1-only ETag (v1.5.1) ------ + +def _list_response(status: int, body: list | None = None, etag: str | None = None) -> Mock: + resp = Mock() + resp.status_code = status + resp.json.return_value = [] if body is None else body + resp.headers = {"ETag": etag} if etag else {} + return resp + +def _repo(full_name: str, pushed_at: str = "2026-08-03T10:00:00Z", archived: bool = False) -> dict: + return {"full_name": full_name, "archived": archived, "pushed_at": pushed_at} + +@patch("ci_status.github.requests.get") +def test_fetch_account_repos_single_page_passthrough(mock_get): + repos = [_repo("o/a"), _repo("o/b")] + mock_get.return_value = _list_response(200, repos, etag='W/"page1"') + poller = RestPoller("tok") + assert poller.fetch_account_repos() == repos + assert mock_get.call_args.args[0] == "https://api.github.com/user/repos" + assert mock_get.call_args.kwargs["params"] == {"affiliation": "owner", "sort": "pushed", "per_page": 100} + assert "If-None-Match" not in mock_get.call_args.kwargs["headers"] + +@patch("ci_status.github.requests.get") +def test_fetch_account_repos_uses_cached_etag_on_next_call(mock_get): + mock_get.return_value = _list_response(200, [_repo("o/a")], etag='W/"page1"') + poller = RestPoller("tok") + poller.fetch_account_repos() + + mock_get.return_value = _list_response(304) + assert poller.fetch_account_repos() is None # 304 -> caller keeps its own cached list + assert mock_get.call_args.kwargs["headers"]["If-None-Match"] == 'W/"page1"' + +@patch("ci_status.github.requests.get") +def test_fetch_account_repos_paginates_across_multiple_pages(mock_get): + page1 = [_repo(f"o/r{i}") for i in range(100)] # exactly 100 -> triggers page 2 + page2 = [_repo("o/r100"), _repo("o/r101")] # < 100 -> stops here + mock_get.side_effect = [_list_response(200, page1), _list_response(200, page2)] + poller = RestPoller("tok") + result = poller.fetch_account_repos() + assert len(result) == 102 + assert result[-1]["full_name"] == "o/r101" + # Page 2 request carries no If-None-Match -- only page 1 gets a slot. + second_call = mock_get.call_args_list[1] + assert second_call.kwargs["params"]["page"] == 2 + assert "If-None-Match" not in second_call.kwargs["headers"] + +@patch("ci_status.github.requests.get") +def test_fetch_account_repos_partial_pagination_failure_keeps_earlier_pages(mock_get): + page1 = [_repo(f"o/r{i}") for i in range(100)] + mock_get.side_effect = [_list_response(200, page1), _list_response(500)] + poller = RestPoller("tok") + result = poller.fetch_account_repos() + assert len(result) == 100 # page 1 kept, page 2's failure just stops pagination + +@patch("ci_status.github.requests.get") +def test_fetch_account_repos_etag_not_locked_in_by_partial_pagination_failure(mock_get): + # Regression test: page 1 must NOT have its ETag committed until the + # WHOLE pagination run succeeds -- otherwise a transient page-2+ + # failure would "lock in" a truncated list forever (every future + # call sends the page-1 ETag, gets a 304 since page 1 itself is + # unchanged, and the caller reads that as "nothing changed, keep the + # stale partial list" -- with no way to ever recover the rest). + page1 = [_repo(f"o/r{i}") for i in range(100)] + page2_full = [_repo("o/r100"), _repo("o/r101")] + + # First call: page 1 succeeds (with an ETag), page 2 fails. + mock_get.side_effect = [_list_response(200, page1, etag='W/"page1-v1"'), _list_response(500)] + poller = RestPoller("tok") + first = poller.fetch_account_repos() + assert len(first) == 100 # partial result returned, as before + + # Second call: must NOT send If-None-Match for the page-1 ETag from + # the incomplete run above -- the ETag was never committed. Both + # pages now succeed, and the FULL list (102 repos) must be recovered. + mock_get.reset_mock() + mock_get.side_effect = [_list_response(200, page1, etag='W/"page1-v1"'), _list_response(200, page2_full)] + second = poller.fetch_account_repos() + first_call_headers = mock_get.call_args_list[0].kwargs["headers"] + assert "If-None-Match" not in first_call_headers + assert len(second) == 102 # full list recovered, not stuck at the earlier partial 100 + + # Third call, now that a FULL pagination run has succeeded: the ETag + # SHOULD be committed this time, and a genuinely-unchanged page 1 + # correctly short-circuits via 304. + mock_get.reset_mock() + mock_get.side_effect = [_list_response(304)] + third = poller.fetch_account_repos() + assert third is None + assert mock_get.call_args.kwargs["headers"]["If-None-Match"] == 'W/"page1-v1"' + +@patch("ci_status.github.requests.get") +def test_fetch_account_repos_swallows_network_errors(mock_get): + mock_get.side_effect = requests.ConnectionError() + assert RestPoller("tok").fetch_account_repos() is None + +@patch("ci_status.github.requests.get") +def test_fetch_account_repos_none_on_non_200(mock_get): + mock_get.return_value = _list_response(403) + assert RestPoller("tok").fetch_account_repos() is None + +@patch("ci_status.github.requests.get") +def test_fetch_account_repos_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_account_repos() is None + +@patch("ci_status.github.requests.get") +def test_fetch_account_repos_none_on_unexpected_shape(mock_get): + # A dict instead of a list would indicate something is very wrong + # (wrong endpoint, API change) -- must not silently misinterpret it. + mock_get.return_value = _response_dict_shape() + assert RestPoller("tok").fetch_account_repos() is None + +def _response_dict_shape() -> Mock: + resp = Mock() + resp.status_code = 200 + resp.headers = {} + resp.json.return_value = {"not": "a list"} + return resp + +@patch("ci_status.github.requests.get") +def test_fetch_account_repos_empty_account_returns_empty_list_not_none(mock_get): + # Zero owned repos is a legitimate (if unusual) real answer -- distinct + # from None, which means "treat as unknown, keep whatever was cached." + mock_get.return_value = _list_response(200, []) + assert RestPoller("tok").fetch_account_repos() == [] + + +# --- forget_repo: drops both ETag slots for a repo (v1.5.1) --------------------- + +@patch("ci_status.github.requests.get") +def test_forget_repo_clears_both_etag_slots(mock_get): + poller = RestPoller("tok") + mock_get.return_value = _response(200, {"workflow_runs": []}, etag='W/"a"') + poller.fetch_runs("o/r") + mock_get.return_value = _response(200, {"workflow_runs": []}, etag='W/"b"') + poller.fetch_running_runs("o/r") + assert "o/r" in poller._etags and "o/r" in poller._running_etags + + poller.forget_repo("o/r") + assert "o/r" not in poller._etags + assert "o/r" not in poller._running_etags + +def test_forget_repo_unknown_repo_is_a_no_op(): + RestPoller("tok").forget_repo("never/seen") # must not raise diff --git a/tests/test_ci_logic.py b/tests/test_ci_logic.py index e6780e5..c1fc3f9 100644 --- a/tests/test_ci_logic.py +++ b/tests/test_ci_logic.py @@ -11,8 +11,10 @@ _pr_or_branch, select_running_run, compute_median_duration_minutes, _format_eta_text, _progress_width, _build_running_title, parse_rate_limit, _quota_headroom, _quota_used_width, + resolve_repo_list, _eta_label, RUNNING_NUMERAL_X, RUNNING_LABEL_GAP_PX, ) from busybar.display import PRIORITY_OVERLAY, OVERLAY_DWELL_SECONDS, PRIORITY_ALERT +from calendar_countdown.logic import _text_width_px NOW = datetime(2026, 8, 3, 13, 37, tzinfo=timezone.utc) @@ -278,8 +280,13 @@ def test_overlay_ci_badge_shape(): 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"] + # v1.5.1: a fitting remain-estimate ETA ("~11m" here) picks up the + # "eta_label" element too -- see test_eta_label_* below for the full + # fit-decision and grammar-guard coverage. + assert set(by_id) == {"bg", "title", "track", "track_fill", "eta", "eta_label"} + assert [e["id"] for e in payload["elements"]] == \ + ["bg", "title", "track", "track_fill", "eta", "eta_label"] + assert by_id["eta_label"]["text"] == "remain" bg = by_id["bg"] assert bg["fill"] == "gradient_v" and bg["border_width"] == 0 @@ -300,6 +307,17 @@ def test_overlay_ci_badge_shape(): 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_shape_no_history_has_no_label(): + # No median history -> "3m in" (elapsed, not a remaining estimate) -- + # the label's grammar guard excludes this form, so the baseline + # 5-element shape (no "eta_label") is what actually draws. + run_ = running_run(name="tests", pr_number=42, started_min_ago=3) + info = running_info(run=run_, median_minutes=None) + payload = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, OVERLAY_DWELL_SECONDS, running=info) + by_id = _by_id(payload["elements"]) + assert set(by_id) == {"bg", "title", "track", "track_fill", "eta"} + assert by_id["eta"]["text"] == "3m in" + 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) @@ -464,3 +482,130 @@ def test_payload_no_overlay_falls_through_to_quiet_or_green_as_before(): assert build_ci_payload([RepoState("o/r", [], [])], False, 180, overlay=None) is None payload = build_ci_payload([RepoState("o/r", [], [])], True, 180, overlay=None) assert payload["priority"] == PRIORITY_ALERT + + +# --- resolve_repo_list (v1.5.1 account-wide watching) ----------------------------- + +def _account_repo(full_name, pushed_days_ago=1, archived=False): + pushed = (NOW - timedelta(days=pushed_days_ago)).strftime("%Y-%m-%dT%H:%M:%SZ") + return {"full_name": full_name, "archived": archived, "pushed_at": pushed} + +def test_resolve_repo_list_account_mode_off_is_just_repos_minus_exclude(): + result = resolve_repo_list(["o/a", "o/b"], ["o/b"], False, + [_account_repo("o/c")], 30, NOW) + assert result == ["o/a"] # account_repos ignored entirely when the mode is off + +def test_resolve_repo_list_unions_explicit_and_discovered(): + account = [_account_repo("o/discovered")] + result = resolve_repo_list(["o/explicit"], [], True, account, 30, NOW) + assert result == ["o/discovered", "o/explicit"] + +def test_resolve_repo_list_excludes_apply_in_account_mode(): + account = [_account_repo("o/a"), _account_repo("o/b")] + result = resolve_repo_list([], ["o/b"], True, account, 30, NOW) + assert result == ["o/a"] + +def test_resolve_repo_list_filters_out_stale_pushed_repos(): + account = [_account_repo("o/fresh", pushed_days_ago=5), + _account_repo("o/stale", pushed_days_ago=45)] + result = resolve_repo_list([], [], True, account, active_within_days=30, now=NOW) + assert result == ["o/fresh"] + +def test_resolve_repo_list_active_within_days_boundary_is_inclusive(): + # Exactly at the cutoff (pushed_at == now - active_within_days) IS + # included -- the exclusion test is `pushed < cutoff` (strict), so the + # boundary instant itself counts as "within the window." + account = [_account_repo("o/exact", pushed_days_ago=30)] + result = resolve_repo_list([], [], True, account, active_within_days=30, now=NOW) + assert result == ["o/exact"] + + # One second past the boundary is excluded. + just_over = {"full_name": "o/just_over", "archived": False, + "pushed_at": (NOW - timedelta(days=30, seconds=1)).strftime("%Y-%m-%dT%H:%M:%SZ")} + result2 = resolve_repo_list([], [], True, [just_over], active_within_days=30, now=NOW) + assert result2 == [] + +def test_resolve_repo_list_explicit_repos_never_filtered_by_staleness(): + # o/explicit was pushed 400 days ago -- would fail the active-window + # filter if it were subject to it, but it's in `repos`, not discovered. + account = [_account_repo("o/explicit", pushed_days_ago=400)] + result = resolve_repo_list(["o/explicit"], [], True, account, active_within_days=30, now=NOW) + assert result == ["o/explicit"] + +def test_resolve_repo_list_archived_repos_excluded(): + account = [_account_repo("o/live"), _account_repo("o/dead", archived=True)] + result = resolve_repo_list([], [], True, account, 30, NOW) + assert result == ["o/live"] + +def test_resolve_repo_list_none_account_repos_falls_back_to_repos_only(): + # e.g. discovery hasn't succeeded yet and there's no cached list. + result = resolve_repo_list(["o/a"], [], True, None, 30, NOW) + assert result == ["o/a"] + +def test_resolve_repo_list_malformed_pushed_at_skipped_not_crashed(): + account = [{"full_name": "o/bad", "archived": False, "pushed_at": "not-a-date"}, + _account_repo("o/good")] + result = resolve_repo_list([], [], True, account, 30, NOW) + assert result == ["o/good"] + +def test_resolve_repo_list_dedupes_explicit_and_discovered_overlap(): + account = [_account_repo("o/both")] + result = resolve_repo_list(["o/both"], [], True, account, 30, NOW) + assert result == ["o/both"] # not ["o/both", "o/both"] + +def test_resolve_repo_list_sorted_deterministic_order(): + account = [_account_repo("z/last"), _account_repo("a/first")] + result = resolve_repo_list(["m/middle"], [], True, account, 30, NOW) + assert result == ["a/first", "m/middle", "z/last"] + + +# --- _eta_label: fit decision + grammar guard (v1.5.1 ETA label) ----------------- + +def test_eta_label_remain_fits_short_eta(): + # "~59m" measures 29px; remain (35px) + 3px gap = 38 > budget(68) is + # false only relative to eta width, i.e. 29+3+35=67 <= 68 -- fits. + assert _text_width_px("~59m") == 29 + assert _eta_label("~59m") == "remain" + +def test_eta_label_falls_back_to_left_when_remain_does_not_fit(): + # "~1h00m" measures 40px -- remain would need 40+3+35=78 > 68 (doesn't + # fit), but left needs only 40+3+20=63 <= 68 (fits). + assert _text_width_px("~1h00m") == 40 + assert _eta_label("~1h00m") == "left" + +def test_eta_label_omitted_when_neither_fits(): + # Synthetic, deliberately-wide input -- _format_eta_text/_format_countdown + # never actually produce a string this wide in practice (the h+mm full + # form is itself capped by CD_TEXT_MAX_WIDTH and falls back to an + # hour-only form well before reaching 45px), but _eta_label is a pure + # function of its string argument and must degrade safely (omit, not + # crash or draw an overflowing label) if it's ever fed one anyway -- + # this is the defensive "neither fits" boundary the brief asked for. + synthetic = "~23h59m" + assert _text_width_px(synthetic) == 49 # > 45 (the "only left fits" ceiling) + assert _eta_label(synthetic) is None + +def test_eta_label_excluded_on_soon(): + assert _eta_label("soon") is None + +def test_eta_label_excluded_on_no_history_elapsed_form(): + assert _eta_label("3m in") is None + assert _eta_label("1h05m in") is None # longer elapsed form, still excluded + +def test_eta_label_x_position_follows_eta_text_width(): + run_ = running_run(name="tests", pr_number=42, started_min_ago=1) + info = running_info(run=run_, median_minutes=60) # eta = 59m -> "~59m", label fits + payload = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, OVERLAY_DWELL_SECONDS, running=info) + by_id = _by_id(payload["elements"]) + eta_text = by_id["eta"]["text"] + assert by_id["eta_label"]["x"] == RUNNING_NUMERAL_X + _text_width_px(eta_text) + RUNNING_LABEL_GAP_PX + assert by_id["eta_label"]["font"] == "small" + assert by_id["eta_label"]["y"] == 9 + +def test_eta_label_falls_back_to_left_end_to_end_through_build_overlay_payload(): + run_ = running_run(name="tests", pr_number=42, started_min_ago=0) + info = running_info(run=run_, median_minutes=60) # eta = 60m -> "~1h00m" + payload = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, OVERLAY_DWELL_SECONDS, running=info) + by_id = _by_id(payload["elements"]) + assert by_id["eta"]["text"] == "~1h00m" + assert by_id["eta_label"]["text"] == "left" diff --git a/tests/test_ci_loop.py b/tests/test_ci_loop.py index 8dfaf7c..46f1f0e 100644 --- a/tests/test_ci_loop.py +++ b/tests/test_ci_loop.py @@ -6,7 +6,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "integrations")) from busybar.client import DrawResult from busybar.display import PRIORITY_OVERLAY, OVERLAY_DWELL_SECONDS -from ci_status.main import run_once, next_poll_seconds +from ci_status.logic import RepoState +from ci_status.main import run_once, next_poll_seconds, config_requires_repos NOW = datetime(2026, 8, 3, 13, 37, tzinfo=timezone.utc) CFG = {"ci_status": {"poll_seconds": 120, "repos": ["o/r"], @@ -419,3 +420,178 @@ 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 + + +# --- account-wide repo watching (v1.5.1) ----------------------------------------- + +CFG_ACCOUNT = {"ci_status": {**CFG_RUNNING["ci_status"], "watch_account_repos": True, + "repos": [], "repos_exclude": [], "active_within_days": 30, + "repo_refresh_minutes": 60}} + +def _account_repo(full_name, pushed_days_ago=1, archived=False): + pushed = (NOW - timedelta(days=pushed_days_ago)).strftime("%Y-%m-%dT%H:%M:%SZ") + return {"full_name": full_name, "archived": archived, "pushed_at": pushed} + +def test_account_mode_polls_discovered_repos(): + client = Mock() + poller = Mock() + poller.fetch_account_repos.return_value = [_account_repo("o/discovered")] + poller.fetch_runs.return_value = [_run("success")] + repo_cache: dict = {} + run_once(client, poller, CFG_ACCOUNT, NOW, {}, dry_run=False, repo_cache=repo_cache) + poller.fetch_runs.assert_called_once_with("o/discovered") + +def test_account_mode_off_never_calls_discovery(): + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] + repo_cache: dict = {} + cfg = {"ci_status": {**CFG_RUNNING["ci_status"], "watch_account_repos": False}} + run_once(client, poller, cfg, NOW, {}, dry_run=False, repo_cache=repo_cache) + poller.fetch_account_repos.assert_not_called() + +def test_repo_cache_omitted_falls_back_to_pre_v1_5_1_behavior(): + # No repo_cache passed at all -- exactly cfg["ci_status"]["repos"] is + # polled, discovery never runs, even with watch_account_repos true. + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] + cfg = {"ci_status": {**CFG_ACCOUNT["ci_status"], "repos": ["o/explicit"]}} + run_once(client, poller, cfg, NOW, {}, dry_run=False) + poller.fetch_account_repos.assert_not_called() + poller.fetch_runs.assert_called_once_with("o/explicit") + + +# --- account repo list refresh timing --------------------------------------------- + +def test_stale_repo_cache_re_enumerates(): + client = Mock() + poller = Mock() + poller.fetch_account_repos.return_value = [_account_repo("o/a")] + poller.fetch_runs.return_value = [_run("success")] + repo_cache: dict = {"repos": [_account_repo("o/old")], + "fetched_at": NOW - timedelta(minutes=61)} # older than 60min default + run_once(client, poller, CFG_ACCOUNT, NOW, {}, dry_run=False, repo_cache=repo_cache) + poller.fetch_account_repos.assert_called_once() + assert repo_cache["fetched_at"] == NOW + +def test_fresh_repo_cache_does_not_re_enumerate(): + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] + repo_cache: dict = {"repos": [_account_repo("o/a")], "fetched_at": NOW - timedelta(minutes=5)} + run_once(client, poller, CFG_ACCOUNT, NOW, {}, dry_run=False, repo_cache=repo_cache) + poller.fetch_account_repos.assert_not_called() + poller.fetch_runs.assert_called_once_with("o/a") # still uses the cached list + +def test_enumeration_failure_keeps_previous_list(): + client = Mock() + poller = Mock() + poller.fetch_account_repos.return_value = None # enumeration fails this poll + poller.fetch_runs.return_value = [_run("success")] + repo_cache: dict = {"repos": [_account_repo("o/previously_known")], + "fetched_at": NOW - timedelta(minutes=61)} + run_once(client, poller, CFG_ACCOUNT, NOW, {}, dry_run=False, repo_cache=repo_cache) + # Still watching the previously-cached repo -- never fell back to empty. + poller.fetch_runs.assert_called_once_with("o/previously_known") + assert repo_cache["repos"] == [_account_repo("o/previously_known")] + +def test_enumeration_failure_with_no_prior_list_logs_warning_and_watches_nothing(caplog): + client = Mock() + poller = Mock() + poller.fetch_account_repos.return_value = None + repo_cache: dict = {} # never successfully populated + import logging + with caplog.at_level(logging.WARNING, logger="ci_status"): + run_once(client, poller, CFG_ACCOUNT, NOW, {}, dry_run=False, repo_cache=repo_cache) + poller.fetch_runs.assert_not_called() + assert any("enumeration failed" in rec.message for rec in caplog.records) + + +# --- state_cache / running_cache pruning for dropped repos ----------------------- + +def test_state_cache_pruned_when_repo_excluded(): + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] + cfg = {"ci_status": {**CFG_ACCOUNT["ci_status"], "repos_exclude": ["o/gone"]}} + poller.fetch_account_repos.return_value = [_account_repo("o/gone"), _account_repo("o/stays")] + state_cache = {"o/gone": RepoState("o/gone", ["tests"], [])} # stale alert from before + repo_cache: dict = {} + run_once(client, poller, cfg, NOW, state_cache, dry_run=False, repo_cache=repo_cache) + assert "o/gone" not in state_cache + assert "o/stays" in state_cache + +def test_running_cache_pruned_when_repo_ages_out(): + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] + poller.fetch_running_runs.return_value = [] + poller.fetch_median_eta.return_value = None + poller.fetch_account_repos.return_value = [_account_repo("o/fresh", pushed_days_ago=1)] + running_cache = {"o/aged_out": [_running_run()]} # from a repo no longer in the window + repo_cache: dict = {} + run_once(client, poller, CFG_ACCOUNT, NOW, {}, dry_run=False, + running_cache=running_cache, repo_cache=repo_cache) + assert "o/aged_out" not in running_cache + +def test_dropped_repo_forgets_poller_etag_state(): + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] + cfg = {"ci_status": {**CFG_ACCOUNT["ci_status"], "repos_exclude": ["o/gone"]}} + poller.fetch_account_repos.return_value = [_account_repo("o/stays")] + state_cache = {"o/gone": RepoState("o/gone", [], [])} + repo_cache: dict = {} + run_once(client, poller, cfg, NOW, state_cache, dry_run=False, repo_cache=repo_cache) + poller.forget_repo.assert_called_once_with("o/gone") + +def test_no_pruning_when_effective_list_is_unchanged(): + # Sanity check: the pruning step must not evict a repo that's still + # in the effective list just because it was cached from an earlier poll. + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("failure")] + state_cache = {"o/r": RepoState("o/r", ["tests"], [])} + run_once(client, poller, CFG_RUNNING, NOW, state_cache, dry_run=False) + assert "o/r" in state_cache + poller.forget_repo.assert_not_called() + + +# --- next_poll_seconds sees auto-discovered repos --------------------------------- + +def test_next_poll_seconds_shortens_for_auto_discovered_repo_not_in_explicit_repos(): + # o/discovered was never in cfg_ci["repos"] at all -- next_poll_seconds + # must still see it via running_cache's own keys, not cfg_ci["repos"]. + running_cache = {"o/discovered": [_running_run()]} + assert next_poll_seconds(CFG_ACCOUNT["ci_status"], running_cache) == 20 + +def test_next_poll_seconds_reverts_when_all_running_cache_entries_empty(): + running_cache = {"o/a": [], "o/b": []} + assert next_poll_seconds(CFG_RUNNING["ci_status"], running_cache) == 120 + + +# --- config_requires_repos: main()'s startup validation, extracted for testability + +def test_config_requires_repos_errors_when_empty_and_account_mode_off(): + cfg = {"ci_status": {"repos": [], "watch_account_repos": False}} + err = config_requires_repos(cfg) + assert err is not None + assert "No repos configured" in err + +def test_config_requires_repos_ok_when_empty_but_account_mode_on(): + cfg = {"ci_status": {"repos": [], "watch_account_repos": True}} + assert config_requires_repos(cfg) is None + +def test_config_requires_repos_ok_when_nonempty_and_account_mode_off(): + cfg = {"ci_status": {"repos": ["o/r"], "watch_account_repos": False}} + assert config_requires_repos(cfg) is None + +def test_config_requires_repos_ok_when_watch_account_repos_key_absent(): + # Backward compat: an old-style cfg dict without the v1.5.1 key at + # all (predates .get's default) must not crash, and behaves like + # watch_account_repos=False. + cfg = {"ci_status": {"repos": ["o/r"]}} + assert config_requires_repos(cfg) is None + cfg_empty = {"ci_status": {"repos": []}} + assert config_requires_repos(cfg_empty) is not None diff --git a/tests/test_config.py b/tests/test_config.py index f01d26c..3694799 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,6 +9,11 @@ def test_defaults_when_no_file(tmp_path): assert cfg["ci_status"]["show_running"] is True assert cfg["ci_status"]["running_poll_seconds"] == 20 assert cfg["ci_status"]["show_quota"] is True + # v1.5.1 account-wide watching defaults + assert cfg["ci_status"]["watch_account_repos"] is False + assert cfg["ci_status"]["repos_exclude"] == [] + assert cfg["ci_status"]["active_within_days"] == 30 + assert cfg["ci_status"]["repo_refresh_minutes"] == 60 def test_file_overrides_defaults(tmp_path): p = tmp_path / "config.toml"