From 66008e9b1b25068d422660b53f937ef47820641a Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Fri, 25 Sep 2026 12:38:08 -0500 Subject: [PATCH 01/26] docs(agents): build each roadmap release on its own branch --- AGENTS.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d056f7..6574970 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,8 +97,12 @@ Run these from the repo root unless noted. ## Releases - The app version lives in `desktop/package.json`. The root `package.json` version isn't used. -- Bump the version in the same commit as the change and end the subject with it: - `fix: single-instance lock … (v0.10.4)`. +- **Each roadmap release gets its own branch**, `release/vX.Y`, cut from `main`. All of that + milestone's commits go there. When its "Done when" holds, open a pull request to `main` (CI + runs on it), merge, then tag `vX.Y.0` on `main`. +- A roadmap release bumps the version once, in the last commit on its branch: + `chore(release): v0.11.0`. A patch outside a milestone bumps it in the fix commit and ends the + subject with it: `fix: single-instance lock … (v0.10.4)`. - Pushing a `v*` tag runs `.github/workflows/release.yml`. It builds the signed Windows installer, the portable exe and the Linux AppImage, then publishes one GitHub release. The `latest*.yml` files and blockmaps must ship with every release, because the auto-updater reads them. From 143630f28a3fa2571c9298520fb9805393f847d3 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Fri, 25 Sep 2026 12:39:34 -0500 Subject: [PATCH 02/26] docs(roadmap): plan v0.11 Sweep with measured query costs --- ROADMAP.md | 128 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 96 insertions(+), 32 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c57b673..c851770 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,6 +41,50 @@ what to do next. lockup layout of logo design 1b. Design 1b's own mark was considered and dropped. The assets are in `docs/brand/`, with `-dark` files for dark backgrounds. +## v0.11 decisions (2026-09-25) + +- **The board shows all of the team's open PRs**, not only the ones updated in the range, so PRs + carried over from an earlier sprint appear. Merged stays "merged in range". +- **The Sweep covers team PRs only.** Your review queue keeps its own section. +- **The engine runs in main after every sweep, over every row.** Incremental refreshes keep + untouched rows cached for days, so reasons saved once on a row would freeze. +- **Cost was measured before choosing.** A read-only probe ran the v0.10.4 queries and each new + field against the electron org (30-day range, median of 5 runs, with the app's retry policy): + +| Query | Team profile (5 authors) | +|---|---| +| Open, v0.10.4 | 3.9 s, 29 rows | +| Open + free fields (`id`, request count, team slugs, commit date) | 4.1 s | +| Open + `latestReviews` | 4.0 s | +| Open + `mergeable` | 4.7 s (+21%) | +| Carried over (open, updated before the range) | 1.9 s, 6 rows | +| Detail query, approved + changes-requested rows | 0.6 s, 13 rows | +| Merged, v0.10.4 | 51 s, 357 rows in 4 sequential pages | +| Queue, v0.10.4 | 0.7 s | + + Runs of the same query varied by about ±20%, so small differences are noise. For the whole org + with no authors set, every open query failed with 502 on every attempt, including v0.10.4's. +- **Expensive fields go in a detail query.** `mergeable` and `latestReviews` are fetched with + `nodes(ids:)` only for approved, changes-requested and unrequested needs-review rows. It runs + while the much slower merged search is still in flight, so it adds no wall time. +- **v0.11 also fixes two performance problems from v0.10.4:** the merged search fetches its pages + one at a time, and whole-org views of big orgs fail with 502. +- **Budget:** the full sweep on the electron team profile is no slower than v0.10.4, auto-refresh + time is unchanged, and the electron whole-org view completes. +- **`NEEDS_RE_REVIEW` is a reason of its own.** "Changes requested" has two next steps: the + author's (address the feedback) and the reviewer's (re-review after the push). +- **Sprint-end risk is a header line, not a row reason.** As a reason it would flag nearly every + unapproved PR in the last two days, when the list should be shortest. +- **Drafts only get `DRAFT_TOO_LONG`.** There are no new settings: fixed thresholds are + constants, and the slow ones use the profile's `staleDays`. +- **Snooze** hides a row until the PR updates, gains a more severe reason, or the next day comes. + It's stored per machine in `localStorage` and never exported. It has no unit test, because the + renderer has no test harness; it's checked by hand. +- **The tray badge keeps counting your review queue.** The menu gains a team attention line. + There are no new notifications. +- **Each roadmap release is built on its own branch** (`release/vX.Y`) and reaches `main` + through a pull request. + ## Shipped Ordering then: distribution first, because every later release gets cheaper once CI ships @@ -110,40 +154,60 @@ Theme: the board tells you what actually needs *action*, not just what exists. ## v0.11: Sweep -Theme: every PR that needs a human shows up once, with the reason and the next step. - -- [ ] Snapshot schema version. A sweep never patches a cached snapshot written by an older - schema. Today the boot refresh patches the cache incrementally, so PRs nobody touched - would never get new row fields after an update. -- [ ] The open-PR query gains `mergeable`, the last commit's date, and the latest reviews - (state and time). Measure sweep time on a large org before and after, because v0.10.2 - trimmed fields for a reason. -- [ ] Attention engine: a pure function in `desktop/src/main/core/` with a unit test per - reason. Each reason carries how long it has held and a next action (Review, Merge, - Fix CI, Rebase, Nudge). - -| Reason | Fires when | Data | -|---|---|---| -| `CI_FAILING` | The latest commit's checks fail | Fetched today | -| `MERGE_CONFLICT` | `mergeable` is `CONFLICTING` (`UNKNOWN` never fires) | New | -| `APPROVED_NOT_MERGED` | Approved, CI passing or absent, for more than a day | New: approval time | -| `CHANGES_NOT_ADDRESSED` | Changes requested and no commit since | New: last commit and review times | -| `NO_REVIEWERS` | Not a draft, no reviews, no pending requests | New: latest reviews | -| `WAITING_FOR_REVIEW` | Needs review past the stale threshold. Uses request time on queue rows, creation time elsewhere | Fetched today | -| `STALE` | No update in `staleDays` | Fetched today | -| `DRAFT_TOO_LONG` | A draft past the stale threshold, when drafts are shown | Fetched today | -| `SPRINT_END_RISK` | Open, not approved, and the range ends within 2 days | Fetched today; needs an end date | - -- [ ] "Sweep" section at the top of the board. It lists only PRs with a reason, most severe - first, one line each with the reason, its age and a next-action link. -- [ ] Decide: a local "snooze until this PR changes" per row. *Recommended: yes, because - without it the list becomes a static nag.* -- [ ] Decide: whether the tray badge counts Sweep items or keeps counting your review queue. - *Recommended: keep the queue, because Sweep items are team-wide.* +Theme: every PR that needs a human shows up once, with the reason and the next step. Built on +the `release/v0.11` branch. + +- [ ] Sweep timing line under `PRSWEEP_DEBUG`: full or auto, duration, searches and retries. + Record the v0.10.4 baseline with it on the electron team and whole-org profiles, 5 full and + 5 auto refreshes each, taking the median. +- [ ] Snapshot schema version (`schema: 2`). `SnapshotStore.get` returns null for an older + schema and a sweep never patches one, so the first refresh after an update is a full one. +- [ ] Merged search in weekly windows, fetched in parallel with at most 4 in flight. Each window + still splits itself past 1000 results. +- [ ] Page-size fallback: a page that still fails with 502 or 504 after the normal retries is + retried at 50, then 25, before the sweep reports an error. +- [ ] Data, then measure against the budget: + - the open search gains `id`, `reviewRequests.totalCount`, team slugs and the last commit's + `committedDate`. Review requests to teams now count, and the "Awaiting" column shows them. + - a carried-over search (`updated: Date: Fri, 25 Sep 2026 12:46:10 -0500 Subject: [PATCH 03/26] chore(perf): log sweep timing under PRSWEEP_DEBUG and add a sweep benchmark --- AGENTS.md | 7 +- desktop/e2e/bench-sweep.mjs | 74 +++++++++++++++++++ desktop/src/main/core/github.service.test.mjs | 64 ++++++++++++++++ desktop/src/main/core/github.service.ts | 33 ++++++++- desktop/src/main/ipc.ts | 21 +++++- 5 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 desktop/e2e/bench-sweep.mjs diff --git a/AGENTS.md b/AGENTS.md index 6574970..4d72143 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,12 @@ Run these from the repo root unless noted. - From `desktop/` after a build, `GH_TOKEN=$(gh auth token) node e2e/screenshot.mjs` writes screenshots to `desktop/e2e/shots/`. `PRSWEEP_DEMO=1` points it at a public org for README images. -- `PRSWEEP_DEBUG=1` makes the main process log GraphQL variables and response bodies. +- `PRSWEEP_DEBUG=1` makes the main process log GraphQL variables and response bodies, plus one + `[sweep]` line per sweep with its mode, duration, requests and retries. +- From `desktop/` after `npm run build:main`, + `GH_TOKEN=$(gh auth token) node e2e/bench-sweep.mjs [login,login,…] [runs]` times full + sweeps and auto-refreshes against the live API. The performance budget in `ROADMAP.md` is + measured with it. ## Releases diff --git a/desktop/e2e/bench-sweep.mjs b/desktop/e2e/bench-sweep.mjs new file mode 100644 index 0000000..446f6b2 --- /dev/null +++ b/desktop/e2e/bench-sweep.mjs @@ -0,0 +1,74 @@ +/** + * Times sweeps against the live API with the app's own GithubService, so the + * numbers match the PRSWEEP_DEBUG line. Used for the performance budget in + * ROADMAP.md. Read-only; your config and snapshot are never touched. + * + * Usage (from desktop/, after `npm run build:main`): + * GH_TOKEN=$(gh auth token) node e2e/bench-sweep.mjs [login,login,…] [runs] + * + * Each run is a full sweep, then an auto-refresh patched onto its result. The + * range is the app's default: the last 30 days, open-ended. + */ +import { GithubService } from '../dist/main/main/core/github.service.js'; + +const [org, authorsArg = '', runsArg = '5'] = process.argv.slice(2); +if (!org || !process.env.GH_TOKEN) { + console.error('Usage: GH_TOKEN=$(gh auth token) node e2e/bench-sweep.mjs [login,login,…] [runs]'); + process.exit(1); +} +const authors = authorsArg ? authorsArg.split(',') : []; +const runs = Number(runsArg); +const range = { start: new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10), end: null }; +const config = { + profiles: [{ id: 'bench', name: 'bench', org, authors, range, includeDrafts: false, staleDays: 5 }], + activeProfileId: 'bench', + autoRefreshMinutes: 5, + notifications: false, + closeToTray: false, + oauthClientId: '', +}; + +const github = new GithubService(() => process.env.GH_TOKEN); +const results = { full: [], auto: [] }; + +function record(kind, run, error) { + const s = github.lastSweep; + results[kind].push(s); + console.log( + `${kind} ${run}: ${s.mode}${s.ok ? '' : ` FAILED (${error})`} · ${(s.ms / 1000).toFixed(1)} s · ` + + `${s.requests} requests · ${s.retries} retries`, + ); +} + +console.log(`${org} · ${authors.length ? authors.join(', ') : 'whole org'} · ${range.start}.. · ${runs} runs`); +for (let run = 1; run <= runs; run++) { + let base = null; + try { + base = await github.sweep(config, range); + record('full', run); + } catch (e) { + record('full', run, e.message); + continue; + } + try { + await github.sweep(config, range, base); + record('auto', run); + } catch (e) { + record('auto', run, e.message); + } +} + +const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]; +for (const [kind, list] of Object.entries(results)) { + const ok = list.filter((s) => s.ok); + if (!list.length) continue; + if (!ok.length) { + console.log(`${kind}: failed ${list.length} of ${list.length}`); + continue; + } + console.log( + `${kind}: median ${(median(ok.map((s) => s.ms)) / 1000).toFixed(1)} s · ` + + `${median(ok.map((s) => s.requests))} requests · ${ok.reduce((n, s) => n + s.retries, 0)} retries total · ` + + `${ok.length} of ${list.length} ok`, + ); +} diff --git a/desktop/src/main/core/github.service.test.mjs b/desktop/src/main/core/github.service.test.mjs index cfdf618..2add14b 100644 --- a/desktop/src/main/core/github.service.test.mjs +++ b/desktop/src/main/core/github.service.test.mjs @@ -460,4 +460,68 @@ await assert.rejects( assert.ok(!docs.probe.includes('timelineItems'), 'changed probe stays bare'); } +// --- lastSweep records mode, duration, round trips and retries (the PRSWEEP_DEBUG line) --- +{ + let calls = 0; + let failedOnce = false; + globalThis.fetch = async (_url, opts) => { + calls++; + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + if (!failedOnce) { + failedOnce = true; + return { ok: false, status: 502, headers: headers(), json: async () => ({}) }; + } + return json(page([])); + }; + const svc = new GithubService(() => 'tok', { retryBaseMs: 1 }); + assert.strictEqual(svc.lastSweep, null, 'no stats before the first sweep'); + await svc.sweep(makeConfig(), { start: '2026-08-01', end: null }); + const s = svc.lastSweep; + assert.equal(s.mode, 'full'); + assert.equal(s.ok, true); + assert.equal(s.requests, calls, 'every HTTP round trip counts, re-sends included'); + assert.equal(s.retries, 1, 'the 502 re-send counts as a retry'); + assert.ok(Number.isFinite(s.ms) && s.ms >= 0, 'duration in ms'); +} + +// --- an incremental patch reports its mode, and counters reset per sweep --- +{ + const range = { start: '2026-08-01', end: null }; + const base = () => ({ + fetchedAt: new Date(Date.now() - 10 * 60_000).toISOString(), + org: 'acme', + range, + open: [row(1, 'needs-review')], + merged: [], + queue: [], + }); + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + return json(page([])); + }; + const svc = new GithubService(() => 'tok'); + await svc.sweep(makeConfig(), range, base()); + assert.equal(svc.lastSweep.mode, 'incremental'); + assert.equal(svc.lastSweep.requests, 2, 'viewer lookup + one probe'); + await svc.sweep(makeConfig(), range, base()); + assert.equal(svc.lastSweep.requests, 1, 'counters reset per sweep (viewer is memoized)'); + assert.equal(svc.lastSweep.retries, 0); +} + +// --- a failed sweep still records its stats --- +{ + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + return { ok: false, status: 502, headers: headers(), json: async () => ({}) }; + }; + const svc = new GithubService(() => 'tok', { retryBaseMs: 1 }); + await assert.rejects(() => svc.sweep(makeConfig(), { start: '2026-08-01', end: null }), /HTTP 502/); + assert.equal(svc.lastSweep.ok, false); + assert.equal(svc.lastSweep.mode, 'full'); + assert.ok(svc.lastSweep.retries >= 3, 'the failing search exhausted its retries'); +} + console.log('github.service: query construction, bucketing, retry, windowing, incremental + CI cases pass'); diff --git a/desktop/src/main/core/github.service.ts b/desktop/src/main/core/github.service.ts index 477c3f9..c33a24a 100644 --- a/desktop/src/main/core/github.service.ts +++ b/desktop/src/main/core/github.service.ts @@ -122,6 +122,16 @@ interface SearchPage { }; } +/** What the last sweep cost — logged under PRSWEEP_DEBUG and read by e2e/bench-sweep.mjs. */ +export interface SweepStats { + mode: 'full' | 'incremental'; + ok: boolean; + ms: number; + /** HTTP round trips, re-sends included. */ + requests: number; + retries: number; +} + export class GithubService { constructor( private readonly token: () => string | null, @@ -166,6 +176,9 @@ export class GithubService { } } + lastSweep: SweepStats | null = null; + private counters = { mode: 'full' as SweepStats['mode'], requests: 0, retries: 0 }; + /** * Runs the sweep. When `base` (the previous sweep) is fresh and matches the * org + range, only PRs updated since it are fetched and patched in — for a @@ -174,6 +187,19 @@ export class GithubService { * more changes than search can enumerate) falls back to a full sweep. */ async sweep(config: SweepConfig, range: DateRange, base: SweepResult | null = null): Promise { + const started = Date.now(); + this.counters = { mode: 'full', requests: 0, retries: 0 }; + let ok = false; + try { + const result = await this.sweepOnce(config, range, base); + ok = true; + return result; + } finally { + this.lastSweep = { ...this.counters, ok, ms: Date.now() - started }; + } + } + + private async sweepOnce(config: SweepConfig, range: DateRange, base: SweepResult | null): Promise { const profile = activeProfile(config); if (!profile.org) throw new Error('No GitHub organization configured — set one in Settings.'); const authors = profile.authors.length @@ -191,7 +217,10 @@ export class GithubService { if (this.canPatch(base, profile.org, range)) { const patched = await this.incrementalSweep(base, parts, range, login); - if (patched) return patched; + if (patched) { + this.counters.mode = 'incremental'; + return patched; + } } const today = new Date().toISOString().slice(0, 10); @@ -327,6 +356,8 @@ export class GithubService { private async graphql(query: string, variables: Record, attempt = 0): Promise { const token = this.token(); if (!token) throw new Error('No GitHub token configured.'); + this.counters.requests++; + if (attempt > 0) this.counters.retries++; let res: { ok: boolean; status: number; diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index d29b8eb..63824ed 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -9,7 +9,7 @@ import { dialog, ipcMain, shell } from 'electron'; import { readFileSync, writeFileSync } from 'fs'; import { activeProfile, ConfigService } from './core/config.service'; -import { GithubService } from './core/github.service'; +import { GithubService, SweepStats } from './core/github.service'; import { DEFAULT_OAUTH_CLIENT_ID } from './core/oauth.constants'; import { pollForToken, requestDeviceCode } from './core/oauth.service'; import { SnapshotStore } from './core/snapshot.store'; @@ -59,9 +59,13 @@ export function registerIpc(services: Services): void { // Auto-refreshes may patch the cached snapshot incrementally; manual // refreshes always resweep in full so the user has a recovery lever. const base = mode === 'auto' ? services.snapshots.get() : null; - const result = await services.github.sweep(services.config.get(), range, base); - services.snapshots.set(result); - return result; + try { + const result = await services.github.sweep(services.config.get(), range, base); + services.snapshots.set(result); + return result; + } finally { + if (process.env.PRSWEEP_DEBUG) logSweep(mode ?? 'full', services.github.lastSweep); + } }); ipcMain.handle('prs:latest', () => services.snapshots.get()); @@ -105,6 +109,15 @@ export function registerIpc(services: Services): void { }); } +/** One line per sweep: "[sweep] auto → incremental · 1.2 s · 3 requests · 0 retries". */ +function logSweep(requested: string, s: SweepStats | null): void { + if (!s) return; + console.log( + `[sweep] ${requested} → ${s.mode}${s.ok ? '' : ' (failed)'} · ${(s.ms / 1000).toFixed(1)} s · ` + + `${s.requests} requests · ${s.retries} retries`, + ); +} + /** Per-install override wins over the baked-in default (either may be empty). */ function oauthClientId(services: Services): string { return services.config.get().oauthClientId?.trim() || DEFAULT_OAUTH_CLIENT_ID; From c62048ecb01cd9783632385bdda5b3eba5b8f2c0 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Fri, 25 Sep 2026 12:59:21 -0500 Subject: [PATCH 04/26] docs(roadmap): record the v0.10.4 sweep baseline --- ROADMAP.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index c851770..a4dc98e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -71,6 +71,9 @@ what to do next. one at a time, and whole-org views of big orgs fail with 502. - **Budget:** the full sweep on the electron team profile is no slower than v0.10.4, auto-refresh time is unchanged, and the electron whole-org view completes. +- **Baseline** (v0.10.4, `e2e/bench-sweep.mjs`, electron, last 30 days, 5 runs): the team + profile's full sweep takes 28.9 s median (25.1–38.3 s, 6–7 requests) and its auto-refresh 0.7 s + (1 request). The whole-org view failed with 502 in 5 of 5 runs, after 49–96 s. - **`NEEDS_RE_REVIEW` is a reason of its own.** "Changes requested" has two next steps: the author's (address the feedback) and the reviewer's (re-review after the push). - **Sprint-end risk is a header line, not a row reason.** As a reason it would flag nearly every @@ -157,7 +160,7 @@ Theme: the board tells you what actually needs *action*, not just what exists. Theme: every PR that needs a human shows up once, with the reason and the next step. Built on the `release/v0.11` branch. -- [ ] Sweep timing line under `PRSWEEP_DEBUG`: full or auto, duration, searches and retries. +- [x] Sweep timing line under `PRSWEEP_DEBUG`: full or auto, duration, searches and retries. Record the v0.10.4 baseline with it on the electron team and whole-org profiles, 5 full and 5 auto refreshes each, taking the median. - [ ] Snapshot schema version (`schema: 2`). `SnapshotStore.get` returns null for an older From d383c9e4afd5c99b27f8eeb4f2afa5b2247e3c32 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Fri, 25 Sep 2026 13:04:07 -0500 Subject: [PATCH 05/26] feat(snapshot): version cached sweeps so an update resweeps in full --- AGENTS.md | 5 +- ROADMAP.md | 2 +- desktop/package.json | 2 +- desktop/renderer/src/app/models.ts | 1 + desktop/src/main/core/github.service.test.mjs | 37 +++++++++++++ desktop/src/main/core/github.service.ts | 6 ++- desktop/src/main/core/snapshot.store.test.mjs | 54 +++++++++++++++++++ desktop/src/main/core/snapshot.store.ts | 6 ++- desktop/src/shared/types.ts | 9 ++++ 9 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 desktop/src/main/core/snapshot.store.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 4d72143..3cd77ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,8 +44,9 @@ organization. It ships for Windows and Linux. ## Cached data and config - The boot refresh and auto-refreshes patch `snapshot.json` incrementally, so a PR nobody touched - on GitHub keeps its cached row. When `PrRow` gains a field, old snapshots must get a full - re-sweep. v0.11 adds a snapshot schema version for this; bump it with every row change. + on GitHub keeps its cached row. When `PrRow` or `SweepResult` changes shape, bump + `SWEEP_SCHEMA` in `desktop/src/shared/types.ts`. Snapshots from another schema are then + never painted or patched, and the first refresh after an update is a full one. - `ConfigService` migrates `config.json` on read (`normalizeProfile`). A new setting needs a default there, must survive profile export/import, and needs a case in `config.service.test.mjs`. diff --git a/ROADMAP.md b/ROADMAP.md index a4dc98e..a976a2c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -163,7 +163,7 @@ the `release/v0.11` branch. - [x] Sweep timing line under `PRSWEEP_DEBUG`: full or auto, duration, searches and retries. Record the v0.10.4 baseline with it on the electron team and whole-org profiles, 5 full and 5 auto refreshes each, taking the median. -- [ ] Snapshot schema version (`schema: 2`). `SnapshotStore.get` returns null for an older +- [x] Snapshot schema version (`schema: 2`). `SnapshotStore.get` returns null for an older schema and a sweep never patches one, so the first refresh after an update is a full one. - [ ] Merged search in weekly windows, fetched in parallel with at most 4 in flight. Each window still splits itself past 1000 results. diff --git a/desktop/package.json b/desktop/package.json index 1c2c0ab..5b361ae 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -7,7 +7,7 @@ "main": "dist/main/main/main.js", "scripts": { "build:main": "tsc -p tsconfig.json", - "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs", + "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs && node src/main/core/snapshot.store.test.mjs", "start": "electron .", "package": "electron-builder", "package:win": "electron-builder --win", diff --git a/desktop/renderer/src/app/models.ts b/desktop/renderer/src/app/models.ts index 1200c11..da9284d 100644 --- a/desktop/renderer/src/app/models.ts +++ b/desktop/renderer/src/app/models.ts @@ -63,6 +63,7 @@ export interface PrRow { } export interface SweepResult { + schema: number; fetchedAt: string; org: string; range: DateRange; diff --git a/desktop/src/main/core/github.service.test.mjs b/desktop/src/main/core/github.service.test.mjs index 2add14b..850093a 100644 --- a/desktop/src/main/core/github.service.test.mjs +++ b/desktop/src/main/core/github.service.test.mjs @@ -6,6 +6,7 @@ */ import assert from 'node:assert'; import { GithubService } from '../../../dist/main/main/core/github.service.js'; +import { SWEEP_SCHEMA } from '../../../dist/main/shared/types.js'; function node(number, reviewDecision, extra = {}) { return { @@ -247,6 +248,7 @@ await assert.rejects( { const base = { fetchedAt: new Date(Date.now() - 10 * 60_000).toISOString(), + schema: SWEEP_SCHEMA, org: 'acme', range: { start: '2026-08-01', end: null }, open: [row(1, 'needs-review'), row(2, 'needs-review')], @@ -278,6 +280,7 @@ await assert.rejects( { const base = { fetchedAt: new Date(Date.now() - 10 * 60_000).toISOString(), + schema: SWEEP_SCHEMA, org: 'acme', range: { start: '2026-08-01', end: null }, open: [row(1, 'needs-review'), row(2, 'approved')], @@ -303,6 +306,7 @@ await assert.rejects( { const base = { fetchedAt: new Date(Date.now() - 10 * 60_000).toISOString(), + schema: SWEEP_SCHEMA, org: 'acme', range: { start: '2026-08-01', end: null }, open: [row(1, 'needs-review')], @@ -327,6 +331,7 @@ await assert.rejects( { const base = { fetchedAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(), + schema: SWEEP_SCHEMA, org: 'acme', range: { start: '2026-08-01', end: null }, open: [], @@ -428,6 +433,7 @@ await assert.rejects( await svc.sweep(makeConfig(), { start: '2026-08-01', end: null }); const base = { fetchedAt: new Date(Date.now() - 10 * 60_000).toISOString(), + schema: SWEEP_SCHEMA, org: 'acme', range: { start: '2026-08-01', end: null }, open: [row(1, 'needs-review')], @@ -460,6 +466,36 @@ await assert.rejects( assert.ok(!docs.probe.includes('timelineItems'), 'changed probe stays bare'); } +// --- results carry the snapshot schema --- +{ + const { result } = await runSweep(makeConfig(), { start: '2026-08-01', end: null }); + assert.strictEqual(result.schema, SWEEP_SCHEMA, 'full sweep result is stamped'); +} + +// --- a base from an older schema is never patched: full sweep instead --- +{ + const queries = []; + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + queries.push(body.variables.q); + return json(page([])); + }; + const base = { + fetchedAt: new Date(Date.now() - 10 * 60_000).toISOString(), + org: 'acme', + range: { start: '2026-08-01', end: null }, + open: [row(1, 'needs-review')], + merged: [], + queue: [], + }; + const svc = new GithubService(() => 'tok'); + const result = await svc.sweep(makeConfig(), { start: '2026-08-01', end: null }, base); + assert.ok(!queries.some((q) => q.includes('updated:>=')), 'no incremental cutoff used'); + assert.strictEqual(svc.lastSweep.mode, 'full'); + assert.strictEqual(result.schema, SWEEP_SCHEMA, 'the replacement is stamped'); +} + // --- lastSweep records mode, duration, round trips and retries (the PRSWEEP_DEBUG line) --- { let calls = 0; @@ -490,6 +526,7 @@ await assert.rejects( const range = { start: '2026-08-01', end: null }; const base = () => ({ fetchedAt: new Date(Date.now() - 10 * 60_000).toISOString(), + schema: SWEEP_SCHEMA, org: 'acme', range, open: [row(1, 'needs-review')], diff --git a/desktop/src/main/core/github.service.ts b/desktop/src/main/core/github.service.ts index c33a24a..bb60976 100644 --- a/desktop/src/main/core/github.service.ts +++ b/desktop/src/main/core/github.service.ts @@ -22,7 +22,7 @@ * org-wide "what changed since last time" probe plus per-bucket deltas, * instead of re-fetching every PR in the range (see sweep()'s `base` param). */ -import { DateRange, PrRow, ReviewBucket, SweepConfig, SweepResult } from '../../shared/types'; +import { DateRange, PrRow, ReviewBucket, SWEEP_SCHEMA, SweepConfig, SweepResult } from '../../shared/types'; import { activeProfile } from './config.service'; const GRAPHQL_URL = 'https://api.github.com/graphql'; @@ -231,6 +231,7 @@ export class GithubService { this.searchAll(parts.queue, QUERY_QUEUE).then((r) => r.nodes), ]); return { + schema: SWEEP_SCHEMA, fetchedAt: new Date().toISOString(), org: profile.org, range, @@ -242,7 +243,7 @@ export class GithubService { } private canPatch(base: SweepResult | null, org: string, range: DateRange): base is SweepResult { - if (!base || base.org !== org) return false; + if (!base || base.schema !== SWEEP_SCHEMA || base.org !== org) return false; if (base.range.start !== range.start || (base.range.end ?? null) !== (range.end ?? null)) return false; const age = Date.now() - Date.parse(base.fetchedAt); return age >= 0 && age < INCREMENTAL_MAX_AGE_MS; @@ -290,6 +291,7 @@ export class GithubService { return [...fresh, ...rows.filter((r) => !touched.has(rowKey(r)) && !freshKeys.has(rowKey(r)))]; }; return { + schema: SWEEP_SCHEMA, fetchedAt, org: base.org, range, diff --git a/desktop/src/main/core/snapshot.store.test.mjs b/desktop/src/main/core/snapshot.store.test.mjs new file mode 100644 index 0000000..6c74b53 --- /dev/null +++ b/desktop/src/main/core/snapshot.store.test.mjs @@ -0,0 +1,54 @@ +/** + * Verifies the snapshot cache only hands back sweeps in the current schema. + * Run after `npm run build:main`: node src/main/core/snapshot.store.test.mjs + */ +import assert from 'node:assert'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SnapshotStore } from '../../../dist/main/main/core/snapshot.store.js'; +import { SWEEP_SCHEMA } from '../../../dist/main/shared/types.js'; + +const dir = mkdtempSync(join(tmpdir(), 'prsweep-snap-')); +const sweep = (extra) => ({ + fetchedAt: '2026-09-25T00:00:00Z', + org: 'acme', + range: { start: '2026-09-01', end: null }, + open: [], + merged: [], + queue: [], + ...extra, +}); + +// A snapshot written by v0.10.x has no schema — its rows lack newer fields, so +// it must not be painted or patched. +{ + const file = join(dir, 'old.json'); + writeFileSync(file, JSON.stringify(sweep())); + assert.strictEqual(new SnapshotStore(file).get(), null, 'unversioned snapshot is ignored'); +} + +// An older schema number is ignored too. +{ + const file = join(dir, 'older.json'); + writeFileSync(file, JSON.stringify(sweep({ schema: SWEEP_SCHEMA - 1 }))); + assert.strictEqual(new SnapshotStore(file).get(), null, 'older schema is ignored'); +} + +// The current schema round-trips. +{ + const store = new SnapshotStore(join(dir, 'current.json')); + const current = sweep({ schema: SWEEP_SCHEMA }); + store.set(current); + assert.deepStrictEqual(store.get(), current); +} + +// Missing and corrupt files stay a quiet cache miss. +{ + assert.strictEqual(new SnapshotStore(join(dir, 'missing.json')).get(), null); + const file = join(dir, 'corrupt.json'); + writeFileSync(file, '{not json'); + assert.strictEqual(new SnapshotStore(file).get(), null); +} + +console.log('snapshot.store: schema gating and cache-miss cases pass'); diff --git a/desktop/src/main/core/snapshot.store.ts b/desktop/src/main/core/snapshot.store.ts index b3bff1e..01c2455 100644 --- a/desktop/src/main/core/snapshot.store.ts +++ b/desktop/src/main/core/snapshot.store.ts @@ -4,15 +4,17 @@ * Purely a cache: failures to read or write are never worth surfacing. */ import { existsSync, readFileSync, writeFileSync } from 'fs'; -import { SweepResult } from '../../shared/types'; +import { SWEEP_SCHEMA, SweepResult } from '../../shared/types'; export class SnapshotStore { constructor(private readonly file: string) {} + /** The cached sweep, or null when there is none or it's from an older schema. */ get(): SweepResult | null { if (!existsSync(this.file)) return null; try { - return JSON.parse(readFileSync(this.file, 'utf8')); + const snapshot = JSON.parse(readFileSync(this.file, 'utf8')); + return snapshot?.schema === SWEEP_SCHEMA ? snapshot : null; } catch { return null; } diff --git a/desktop/src/shared/types.ts b/desktop/src/shared/types.ts index 1c044fd..8358c71 100644 --- a/desktop/src/shared/types.ts +++ b/desktop/src/shared/types.ts @@ -81,7 +81,16 @@ export interface PrRow { reviewRequestedAt: string | null; } +/** + * Version of the SweepResult / PrRow shape. Bump it whenever either changes: + * incremental refreshes keep cached rows until each PR changes on GitHub, so a + * snapshot from an older build must be swept afresh, never painted or patched. + */ +export const SWEEP_SCHEMA = 2; + export interface SweepResult { + /** SWEEP_SCHEMA when this was written; absent in snapshots from before v0.11. */ + schema: number; fetchedAt: string; /** Org the sweep ran against — lets a cached snapshot prove it's still relevant. */ org: string; From c544c863aa20b06a9d545302d7956953212c95bd Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Fri, 25 Sep 2026 13:12:00 -0500 Subject: [PATCH 06/26] perf(sweep): search merged PRs in parallel weekly windows --- desktop/src/main/core/github.service.test.mjs | 60 +++++++++++---- desktop/src/main/core/github.service.ts | 73 +++++++++++++++---- 2 files changed, 106 insertions(+), 27 deletions(-) diff --git a/desktop/src/main/core/github.service.test.mjs b/desktop/src/main/core/github.service.test.mjs index 850093a..3790d4e 100644 --- a/desktop/src/main/core/github.service.test.mjs +++ b/desktop/src/main/core/github.service.test.mjs @@ -90,7 +90,7 @@ async function runSweep(config, range) { return json(page([node(10, 'REVIEW_REQUIRED')])); } if (q.includes('is:merged')) { - queries.merged = q; + (queries.merged ??= []).push(q); return json(page([node(20, null, { mergedAt: '2026-08-05T00:00:00Z' })])); } queries.open = q; @@ -118,7 +118,9 @@ async function runSweep(config, range) { assert.match(queries.open, /draft:false/, 'hides drafts by default'); assert.match(queries.open, /\(author:alice OR author:bob\)/, 'ORs the authors'); assert.match(queries.open, /updated:2026-08-01\.\.\d{4}-\d{2}-\d{2}/, 'open-ended range closes at today'); - assert.match(queries.merged, /merged:2026-08-01\.\.\d{4}-\d{2}-\d{2}/, 'open-ended merged closes at today'); + const today = new Date().toISOString().slice(0, 10); + assert.ok(queries.merged.some((q) => q.includes('merged:2026-08-01..2026-08-07')), 'first week starts at the range start'); + assert.ok(queries.merged.some((q) => q.endsWith(`..${today}`)), 'open-ended merged closes at today'); assert.match(queries.queue, /review-requested:me/, 'queue uses the viewer login'); const bucket = (n) => result.open.find((r) => r.number === n)?.bucket; @@ -139,10 +141,20 @@ async function runSweep(config, range) { assert.ok(!queries.open.includes('draft:false'), 'includeDrafts omits draft:false'); } -// --- a closed range uses merged:start..end --- +// --- a closed range is swept one week per merged search, the last week cut at the end --- { - const { queries } = await runSweep(makeConfig(), { start: '2026-08-01', end: '2026-09-01' }); - assert.match(queries.merged, /merged:2026-08-01\.\.2026-09-01/); + const { result, queries } = await runSweep(makeConfig(), { start: '2026-08-01', end: '2026-09-01' }); + assert.deepEqual( + queries.merged.map((q) => q.match(/merged:(\S+)/)[1]).sort(), + [ + '2026-08-01..2026-08-07', + '2026-08-08..2026-08-14', + '2026-08-15..2026-08-21', + '2026-08-22..2026-08-28', + '2026-08-29..2026-09-01', + ], + ); + assert.equal(result.merged.length, 1, 'the same PR from every window is kept once'); } // --- no authors → no OR clause --- @@ -217,7 +229,7 @@ await assert.rejects( assert.equal(calls, 4, 'initial call + 3 retries'); } -// --- a window over the 1000-result cap splits by date and dedupes --- +// --- a week over the 1000-result cap splits by date; other weeks are kept --- { const merged = []; globalThis.fetch = async (_url, opts) => { @@ -227,23 +239,43 @@ await assert.rejects( if (q.includes('review-requested')) return json(page([])); if (q.includes('is:merged')) { merged.push(q); - if (q.includes('merged:2026-08-01..2026-08-31')) return json(page([node(100, null)], 1500)); - if (q.includes('merged:2026-08-01..2026-08-16')) return json(page([node(101, null)], 800)); - if (q.includes('merged:2026-08-17..2026-08-31')) return json(page([node(102, null)], 700)); - assert.fail(`unexpected merged window: ${q}`); + if (q.includes('merged:2026-08-01..2026-08-07')) return json(page([node(100, null)], 1500)); + if (q.includes('merged:2026-08-01..2026-08-04')) return json(page([node(101, null)], 800)); + if (q.includes('merged:2026-08-05..2026-08-07')) return json(page([node(102, null)], 700)); + // Every other week: one PR numbered after its first day. + return json(page([node(200 + Number(q.match(/merged:\d{4}-\d{2}-(\d{2})/)[1]), null)])); } return json(page([node(1, 'APPROVED')])); }; const svc = new GithubService(() => 'tok'); const result = await svc.sweep(makeConfig(), { start: '2026-08-01', end: '2026-08-31' }); - assert.equal(merged.length, 3, 'full window + two halves'); + assert.equal(merged.length, 7, 'five weeks + two halves of the capped one'); assert.deepEqual( - result.merged.map((r) => r.number).sort(), - [101, 102], - 'capped window replaced by its halves', + result.merged.map((r) => r.number).sort((a, b) => a - b), + [101, 102, 208, 215, 222, 229], + 'capped week replaced by its halves, other weeks kept', ); } +// --- merged weeks run in parallel, at most 4 at a time --- +{ + let inFlight = 0; + let peak = 0; + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + if (!body.variables.q.includes('is:merged')) return json(page([])); + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight--; + return json(page([])); + }; + const svc = new GithubService(() => 'tok'); + await svc.sweep(makeConfig(), { start: '2026-06-01', end: '2026-08-31' }); + assert.equal(peak, 4, 'thirteen weeks, four in flight'); +} + // --- incremental patch: a changed PR moves buckets, untouched rows survive --- { const base = { diff --git a/desktop/src/main/core/github.service.ts b/desktop/src/main/core/github.service.ts index bb60976..c2f9d46 100644 --- a/desktop/src/main/core/github.service.ts +++ b/desktop/src/main/core/github.service.ts @@ -16,6 +16,8 @@ * Big-org behavior: * - date-windowed queries split themselves when they'd hit the 1000-result cap, * so a busy quarter still sweeps completely instead of silently truncating. + * - the full sweep's merged list runs one week per search, a few in flight, + * instead of paging through the whole range one request at a time. * - rate limits (primary and secondary) and transient 5xx are retried with the * server-stated wait when GitHub provides one, exponential backoff otherwise. * - auto-refreshes can run incrementally against the previous sweep: one cheap @@ -31,6 +33,15 @@ const GRAPHQL_URL = 'https://api.github.com/graphql'; const PAGE_SIZE = 100; /** GitHub search returns at most this many results per query, full stop. */ const SEARCH_CAP = 1000; +/** + * The full sweep's merged search runs one week per query, a few at a time. A + * single query over the range pages through it one request after another, and + * that made up most of a full sweep (28.9 s on a 5-author electron team in + * v0.10.4). The cap keeps concurrent searches clear of GitHub's secondary rate + * limits. + */ +const MERGED_WINDOW_DAYS = 7; +const MERGED_CONCURRENCY = 4; const MAX_RETRIES = 3; /** Never sleep longer than this on a rate limit — surface the error instead. */ const MAX_RETRY_WAIT_MS = 120_000; @@ -227,7 +238,7 @@ export class GithubService { const end = range.end ?? today; const [open, merged, queue] = await Promise.all([ this.searchWindowed((a, b) => `${parts.open} updated:${a}..${b}`, range.start, end, QUERY_OPEN), - this.searchWindowed((a, b) => `${parts.merged} merged:${a}..${b}`, range.start, end, QUERY_BARE), + this.searchMerged(parts.merged, range.start, end), this.searchAll(parts.queue, QUERY_QUEUE).then((r) => r.nodes), ]); return { @@ -324,17 +335,17 @@ export class GithubService { const mid = midDate(from, to); const [a, b] = await Promise.all([ this.searchWindowed(build, from, mid, doc, depth + 1), - this.searchWindowed(build, nextDay(mid), to, doc, depth + 1), + this.searchWindowed(build, addDays(mid, 1), to, doc, depth + 1), ]); - // Day-granular halves can't overlap for a single date field, but dedupe - // defensively — a duplicate row is worse than a wasted comparison. - const seen = new Set(); - return [...a, ...b].filter((n) => { - const k = nodeKey(n); - if (seen.has(k)) return false; - seen.add(k); - return true; - }); + return uniqueNodes([...a, ...b]); + } + + /** Merged PRs in from..to, one week per search with MERGED_CONCURRENCY in flight. */ + private async searchMerged(q: string, from: string, to: string): Promise { + const weeks = await mapLimit(windows(from, to, MERGED_WINDOW_DAYS), MERGED_CONCURRENCY, ([a, b]) => + this.searchWindowed((x, y) => `${q} merged:${x}..${y}`, a, b, QUERY_BARE), + ); + return uniqueNodes(weeks.flat()); } private async searchAll(q: string, doc: string = QUERY_BARE): Promise<{ nodes: SearchNode[]; total: number }> { @@ -496,6 +507,42 @@ function midDate(from: string, to: string): string { return mid.toISOString().slice(0, 10); } -function nextDay(date: string): string { - return new Date(Date.parse(`${date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10); +function addDays(date: string, days: number): string { + return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); +} + +/** Consecutive `days`-long windows covering from..to, the last one cut short at `to`. */ +function windows(from: string, to: string, days: number): Array<[string, string]> { + const out: Array<[string, string]> = []; + for (let start = from; start <= to; start = addDays(start, days)) { + const end = addDays(start, days - 1); + out.push([start, end < to ? end : to]); + } + return out; +} + +/** Windows can't overlap for a single date field, but dedupe defensively — a + duplicate row is worse than a wasted comparison. */ +function uniqueNodes(nodes: SearchNode[]): SearchNode[] { + const seen = new Set(); + return nodes.filter((n) => { + const k = nodeKey(n); + if (seen.has(k)) return false; + seen.add(k); + return true; + }); +} + +/** Like Promise.all over `items.map(fn)`, but with at most `limit` calls in flight. */ +async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const out: R[] = new Array(items.length); + let next = 0; + const worker = async (): Promise => { + while (next < items.length) { + const i = next++; + out[i] = await fn(items[i]); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return out; } From 43f83eb8cc78d48f0296c06186abe1929390b4bc Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Fri, 25 Sep 2026 13:14:35 -0500 Subject: [PATCH 07/26] docs(roadmap): record the merged-window speedup --- ROADMAP.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a976a2c..052697b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,7 +73,10 @@ what to do next. time is unchanged, and the electron whole-org view completes. - **Baseline** (v0.10.4, `e2e/bench-sweep.mjs`, electron, last 30 days, 5 runs): the team profile's full sweep takes 28.9 s median (25.1–38.3 s, 6–7 requests) and its auto-refresh 0.7 s - (1 request). The whole-org view failed with 502 in 5 of 5 runs, after 49–96 s. + (1 request). The whole-org view failed with 502 in 5 of 5 runs, after 49–96 s. Auto-refresh + time depends on org activity: when anything in the org changed since the last sweep, the + refresh runs three more searches (4 requests, about 2 s). Compare auto-refreshes with the same + request count. - **`NEEDS_RE_REVIEW` is a reason of its own.** "Changes requested" has two next steps: the author's (address the feedback) and the reviewer's (re-review after the push). - **Sprint-end risk is a header line, not a row reason.** As a reason it would flag nearly every @@ -165,8 +168,9 @@ the `release/v0.11` branch. 5 auto refreshes each, taking the median. - [x] Snapshot schema version (`schema: 2`). `SnapshotStore.get` returns null for an older schema and a sweep never patches one, so the first refresh after an update is a full one. -- [ ] Merged search in weekly windows, fetched in parallel with at most 4 in flight. Each window - still splits itself past 1000 results. +- [x] Merged search in weekly windows, fetched in parallel with at most 4 in flight. Each window + still splits itself past 1000 results. *Measured: the team profile's full sweep dropped to + 10.8 s median (9.7–13.4 s, plus one 69.9 s run that hit a retry), from 28.9 s.* - [ ] Page-size fallback: a page that still fails with 502 or 504 after the normal retries is retried at 50, then 25, before the sweep reports an error. - [ ] Data, then measure against the budget: From 914dacb02542391fb2ae598a7760818dc97d21f7 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Fri, 25 Sep 2026 13:26:48 -0500 Subject: [PATCH 08/26] fix(sweep): re-send timed-out search pages at half the size --- desktop/src/main/core/github.service.test.mjs | 63 +++++++++++++++++++ desktop/src/main/core/github.service.ts | 52 ++++++++++++--- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/desktop/src/main/core/github.service.test.mjs b/desktop/src/main/core/github.service.test.mjs index 3790d4e..954b7ff 100644 --- a/desktop/src/main/core/github.service.test.mjs +++ b/desktop/src/main/core/github.service.test.mjs @@ -498,6 +498,69 @@ await assert.rejects( assert.ok(!docs.probe.includes('timelineItems'), 'changed probe stays bare'); } +// --- a search page that times out is re-sent at 50, then 25, not at the same size --- +{ + const sizes = []; + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + if (!body.variables.q.includes('is:open') || body.variables.q.includes('review-requested')) return json(page([])); + sizes.push(body.variables.first); + if (body.variables.first === 100) return { ok: false, status: 502, headers: headers(), json: async () => ({}) }; + if (body.variables.first === 50) return { ok: false, status: 504, headers: headers(), json: async () => ({}) }; + return json(page([node(1, 'APPROVED')])); + }; + const svc = new GithubService(() => 'tok', { retryBaseMs: 1 }); + const result = await svc.sweep(makeConfig(), { start: '2026-08-01', end: null }); + assert.deepEqual(sizes, [100, 50, 25], 'each timeout halves the page instead of repeating it'); + assert.equal(result.open.length, 1, 'the smaller page still delivers the rows'); + assert.equal(svc.lastSweep.retries, 2, 'both re-sends count as retries'); +} + +// --- once a search shrinks its pages, later pages stay small --- +{ + const calls = []; + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + if (!body.variables.q.includes('is:open') || body.variables.q.includes('review-requested')) return json(page([])); + const { first, after } = body.variables; + calls.push({ first, after }); + if (first === 100) return { ok: false, status: 502, headers: headers(), json: async () => ({}) }; + const next = after === null; + return json({ + search: { + issueCount: 2, + pageInfo: { hasNextPage: next, endCursor: next ? 'c1' : null }, + nodes: [node(after === null ? 1 : 2, 'APPROVED')], + }, + }); + }; + const svc = new GithubService(() => 'tok', { retryBaseMs: 1 }); + const result = await svc.sweep(makeConfig(), { start: '2026-08-01', end: null }); + assert.deepEqual(calls, [ + { first: 100, after: null }, + { first: 50, after: null }, + { first: 50, after: 'c1' }, + ]); + assert.deepEqual(result.open.map((r) => r.number), [1, 2]); +} + +// --- at 25 the normal retries apply, then the sweep reports the error --- +{ + const sizes = []; + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + if (!body.variables.q.includes('is:open') || body.variables.q.includes('review-requested')) return json(page([])); + sizes.push(body.variables.first); + return { ok: false, status: 502, headers: headers(), json: async () => ({}) }; + }; + const svc = new GithubService(() => 'tok', { retryBaseMs: 1 }); + await assert.rejects(() => svc.sweep(makeConfig(), { start: '2026-08-01', end: null }), /HTTP 502/); + assert.deepEqual(sizes, [100, 50, 25, 25, 25, 25], 'halve twice, then the usual 3 retries'); +} + // --- results carry the snapshot schema --- { const { result } = await runSweep(makeConfig(), { start: '2026-08-01', end: null }); diff --git a/desktop/src/main/core/github.service.ts b/desktop/src/main/core/github.service.ts index c2f9d46..8f37e5d 100644 --- a/desktop/src/main/core/github.service.ts +++ b/desktop/src/main/core/github.service.ts @@ -18,6 +18,9 @@ * so a busy quarter still sweeps completely instead of silently truncating. * - the full sweep's merged list runs one week per search, a few in flight, * instead of paging through the whole range one request at a time. + * - a search page that times out (502/504) is re-sent at half the size, down + * to 25, before the usual retries — whole-org searches on big orgs time out + * at 100 every time. * - rate limits (primary and secondary) and transient 5xx are retried with the * server-stated wait when GitHub provides one, exponential backoff otherwise. * - auto-refreshes can run incrementally against the previous sweep: one cheap @@ -31,6 +34,12 @@ const GRAPHQL_URL = 'https://api.github.com/graphql'; // GraphQL search's max page size — fewer round trips is the single biggest // lever on sweep latency for busy ranges. const PAGE_SIZE = 100; +/** + * A search page GitHub can't answer in time (502/504) is re-sent at half the + * size, down to this. Whole-org searches on big orgs time out at 100 every time, + * and repeating the same request only adds the backoff. + */ +const MIN_PAGE_SIZE = 25; /** GitHub search returns at most this many results per query, full stop. */ const SEARCH_CAP = 1000; /** @@ -81,8 +90,8 @@ const TIMELINE_FIELD = ` function buildSearchQuery(extras: { ci?: boolean; timeline?: boolean }): string { return ` - query ($q: String!, $after: String) { - search(query: $q, type: ISSUE_ADVANCED, first: ${PAGE_SIZE}, after: $after) { + query ($q: String!, $after: String, $first: Int!) { + search(query: $q, type: ISSUE_ADVANCED, first: $first, after: $after) { issueCount pageInfo { hasNextPage endCursor } nodes { @@ -352,10 +361,19 @@ export class GithubService { const nodes: SearchNode[] = []; let after: string | null = null; let total = 0; - // The search cap is SEARCH_CAP results = SEARCH_CAP / PAGE_SIZE pages; the - // guard also keeps a backend pagination bug from spinning forever. - for (let page = 0; page < SEARCH_CAP / PAGE_SIZE; page++) { - const data: SearchPage = await this.graphql(doc, { q, after }); + let first = PAGE_SIZE; + // Search stops at SEARCH_CAP results; the guard (smallest pages, plus the two + // halvings) also keeps a backend pagination bug from spinning forever. + for (let request = 0; request < SEARCH_CAP / MIN_PAGE_SIZE + 2 && nodes.length < SEARCH_CAP; request++) { + let data: SearchPage; + try { + data = await this.graphql(doc, { q, after, first }, 0, first > MIN_PAGE_SIZE); + } catch (e) { + if (!(e instanceof GithubTimeout)) throw e; + first = Math.max(MIN_PAGE_SIZE, first / 2); + this.counters.retries++; + continue; + } total = data.search.issueCount ?? 0; // Non-PR results (the search type is issue-shaped) come back as empty // objects from the inline fragment — drop them. @@ -366,7 +384,13 @@ export class GithubService { return { nodes, total }; } - private async graphql(query: string, variables: Record, attempt = 0): Promise { + /** `shrinkable`: a 502/504 throws GithubTimeout at once so the caller can ask for less. */ + private async graphql( + query: string, + variables: Record, + attempt = 0, + shrinkable = false, + ): Promise { const token = this.token(); if (!token) throw new Error('No GitHub token configured.'); this.counters.requests++; @@ -391,16 +415,17 @@ export class GithubService { // Network blip — same treatment as a transient server error. if (attempt >= MAX_RETRIES) throw e; await sleep(this.backoff(attempt)); - return this.graphql(query, variables, attempt + 1); + return this.graphql(query, variables, attempt + 1, shrinkable); } if (res.status === 401) throw new Error('GitHub rejected the token (401). Replace it in Settings.'); if (!res.ok) { + if (shrinkable && (res.status === 502 || res.status === 504)) throw new GithubTimeout(res.status); // 403/429 are the primary/secondary rate limits; 5xx is GitHub having a // moment (big GraphQL queries 502 more than they should). Honor the // server-stated wait when there is one, back off exponentially otherwise. if (attempt < MAX_RETRIES && [403, 429, 502, 503, 504].includes(res.status)) { await sleep(this.retryAfter(res) ?? this.backoff(attempt)); - return this.graphql(query, variables, attempt + 1); + return this.graphql(query, variables, attempt + 1, shrinkable); } throw new Error(`GitHub API error: HTTP ${res.status}`); } @@ -414,7 +439,7 @@ export class GithubService { // GraphQL rate limiting arrives as an HTTP 200 with a typed error. if (attempt < MAX_RETRIES && body.errors.some((e) => e.type === 'RATE_LIMITED')) { await sleep(this.retryAfter(res) ?? this.backoff(attempt)); - return this.graphql(query, variables, attempt + 1); + return this.graphql(query, variables, attempt + 1, shrinkable); } throw new Error(`GitHub API error: ${body.errors[0].message}`); } @@ -440,6 +465,13 @@ export class GithubService { } } +/** GitHub couldn't answer a search page in time; the page should be re-sent smaller. */ +class GithubTimeout extends Error { + constructor(status: number) { + super(`GitHub API error: HTTP ${status}`); + } +} + function bucketOf(n: SearchNode): ReviewBucket { switch (n.reviewDecision) { case 'APPROVED': From 95c2d5e8b091697c516a65119dfecddeb1d90bc7 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Fri, 25 Sep 2026 13:42:55 -0500 Subject: [PATCH 09/26] fix(sweep): treat a cut-off response body like a timeout --- desktop/src/main/core/github.service.test.mjs | 30 +++++++++++++++++++ desktop/src/main/core/github.service.ts | 29 +++++++++++------- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/desktop/src/main/core/github.service.test.mjs b/desktop/src/main/core/github.service.test.mjs index 954b7ff..9c67fcf 100644 --- a/desktop/src/main/core/github.service.test.mjs +++ b/desktop/src/main/core/github.service.test.mjs @@ -561,6 +561,36 @@ await assert.rejects( assert.deepEqual(sizes, [100, 50, 25, 25, 25, 25], 'halve twice, then the usual 3 retries'); } +// --- a search page that arrives as a cut-off 200 body is re-sent smaller --- +{ + const sizes = []; + const truncated = { ok: true, status: 200, headers: headers(), json: async () => JSON.parse('{"data":') }; + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + if (!body.variables.q.includes('is:open') || body.variables.q.includes('review-requested')) return json(page([])); + sizes.push(body.variables.first); + return body.variables.first === 100 ? truncated : json(page([node(1, 'APPROVED')])); + }; + const svc = new GithubService(() => 'tok', { retryBaseMs: 1 }); + const result = await svc.sweep(makeConfig(), { start: '2026-08-01', end: null }); + assert.deepEqual(sizes, [100, 50], 'treated like a timeout'); + assert.equal(result.open.length, 1); +} + +// --- any other request with a cut-off body is retried like a 5xx --- +{ + let calls = 0; + globalThis.fetch = async () => { + calls++; + if (calls === 1) return { ok: true, status: 200, headers: headers(), json: async () => JSON.parse('{"data":') }; + return json({ viewer: { login: 'me' } }); + }; + const svc = new GithubService(() => 'tok', { retryBaseMs: 1 }); + assert.equal(await svc.viewer(), 'me'); + assert.equal(calls, 2, 'retried once'); +} + // --- results carry the snapshot schema --- { const { result } = await runSweep(makeConfig(), { start: '2026-08-01', end: null }); diff --git a/desktop/src/main/core/github.service.ts b/desktop/src/main/core/github.service.ts index 8f37e5d..78fc489 100644 --- a/desktop/src/main/core/github.service.ts +++ b/desktop/src/main/core/github.service.ts @@ -18,9 +18,9 @@ * so a busy quarter still sweeps completely instead of silently truncating. * - the full sweep's merged list runs one week per search, a few in flight, * instead of paging through the whole range one request at a time. - * - a search page that times out (502/504) is re-sent at half the size, down - * to 25, before the usual retries — whole-org searches on big orgs time out - * at 100 every time. + * - a search page that times out (502/504, or a 200 with a cut-off body) is + * re-sent at half the size, down to 25, before the usual retries — + * whole-org searches on big orgs time out at 100 every time. * - rate limits (primary and secondary) and transient 5xx are retried with the * server-stated wait when GitHub provides one, exponential backoff otherwise. * - auto-refreshes can run incrementally against the previous sweep: one cheap @@ -419,7 +419,9 @@ export class GithubService { } if (res.status === 401) throw new Error('GitHub rejected the token (401). Replace it in Settings.'); if (!res.ok) { - if (shrinkable && (res.status === 502 || res.status === 504)) throw new GithubTimeout(res.status); + if (shrinkable && (res.status === 502 || res.status === 504)) { + throw new GithubTimeout(`GitHub API error: HTTP ${res.status}`); + } // 403/429 are the primary/secondary rate limits; 5xx is GitHub having a // moment (big GraphQL queries 502 more than they should). Honor the // server-stated wait when there is one, back off exponentially otherwise. @@ -429,7 +431,18 @@ export class GithubService { } throw new Error(`GitHub API error: HTTP ${res.status}`); } - const body = (await res.json()) as { data?: T; errors?: Array<{ message: string; type?: string }> }; + let body: { data?: T; errors?: Array<{ message: string; type?: string }> }; + try { + body = (await res.json()) as typeof body; + } catch { + // Queries GitHub gives up on can also arrive as a 200 with a cut-off body. + if (shrinkable) throw new GithubTimeout('GitHub API returned a truncated response.'); + if (attempt < MAX_RETRIES) { + await sleep(this.backoff(attempt)); + return this.graphql(query, variables, attempt + 1, shrinkable); + } + throw new Error('GitHub API returned a truncated response.'); + } if (process.env.PRSWEEP_DEBUG) { console.log('[github] vars:', JSON.stringify(variables).slice(0, 300)); console.log('[github] scopes:', res.headers.get('x-oauth-scopes'), '| sso:', res.headers.get('x-github-sso'), '| token:', (token ?? '').slice(0, 12) + '…' + (token ?? '').length); @@ -466,11 +479,7 @@ export class GithubService { } /** GitHub couldn't answer a search page in time; the page should be re-sent smaller. */ -class GithubTimeout extends Error { - constructor(status: number) { - super(`GitHub API error: HTTP ${status}`); - } -} +class GithubTimeout extends Error {} function bucketOf(n: SearchNode): ReviewBucket { switch (n.reviewDecision) { From cecc16325ca639df118c4b344770628b094e13f1 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Fri, 25 Sep 2026 13:43:17 -0500 Subject: [PATCH 10/26] chore(debug): log why and how long each GitHub retry waits --- desktop/src/main/core/github.service.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/core/github.service.ts b/desktop/src/main/core/github.service.ts index 78fc489..2dbeb3e 100644 --- a/desktop/src/main/core/github.service.ts +++ b/desktop/src/main/core/github.service.ts @@ -414,7 +414,7 @@ export class GithubService { } catch (e) { // Network blip — same treatment as a transient server error. if (attempt >= MAX_RETRIES) throw e; - await sleep(this.backoff(attempt)); + await this.pause('network error', this.backoff(attempt)); return this.graphql(query, variables, attempt + 1, shrinkable); } if (res.status === 401) throw new Error('GitHub rejected the token (401). Replace it in Settings.'); @@ -426,7 +426,7 @@ export class GithubService { // moment (big GraphQL queries 502 more than they should). Honor the // server-stated wait when there is one, back off exponentially otherwise. if (attempt < MAX_RETRIES && [403, 429, 502, 503, 504].includes(res.status)) { - await sleep(this.retryAfter(res) ?? this.backoff(attempt)); + await this.pause(`HTTP ${res.status}`, this.retryAfter(res) ?? this.backoff(attempt)); return this.graphql(query, variables, attempt + 1, shrinkable); } throw new Error(`GitHub API error: HTTP ${res.status}`); @@ -438,7 +438,7 @@ export class GithubService { // Queries GitHub gives up on can also arrive as a 200 with a cut-off body. if (shrinkable) throw new GithubTimeout('GitHub API returned a truncated response.'); if (attempt < MAX_RETRIES) { - await sleep(this.backoff(attempt)); + await this.pause('truncated response', this.backoff(attempt)); return this.graphql(query, variables, attempt + 1, shrinkable); } throw new Error('GitHub API returned a truncated response.'); @@ -451,7 +451,7 @@ export class GithubService { if (body.errors?.length) { // GraphQL rate limiting arrives as an HTTP 200 with a typed error. if (attempt < MAX_RETRIES && body.errors.some((e) => e.type === 'RATE_LIMITED')) { - await sleep(this.retryAfter(res) ?? this.backoff(attempt)); + await this.pause('RATE_LIMITED', this.retryAfter(res) ?? this.backoff(attempt)); return this.graphql(query, variables, attempt + 1, shrinkable); } throw new Error(`GitHub API error: ${body.errors[0].message}`); @@ -473,6 +473,12 @@ export class GithubService { return null; } + /** Sleep before a retry; PRSWEEP_DEBUG says why and for how long. */ + private async pause(reason: string, ms: number): Promise { + if (process.env.PRSWEEP_DEBUG) console.log(`[github] retry after ${reason}, waiting ${(ms / 1000).toFixed(1)} s`); + await sleep(ms); + } + private backoff(attempt: number): number { return (this.opts.retryBaseMs ?? 1000) * 2 ** attempt; } From 66d5c25407c2c12858464a12a7e56a3e3bf19dbf Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Fri, 25 Sep 2026 13:57:50 -0500 Subject: [PATCH 11/26] docs(roadmap): record the page-size fallback results --- ROADMAP.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 052697b..445c1df 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -171,8 +171,12 @@ the `release/v0.11` branch. - [x] Merged search in weekly windows, fetched in parallel with at most 4 in flight. Each window still splits itself past 1000 results. *Measured: the team profile's full sweep dropped to 10.8 s median (9.7–13.4 s, plus one 69.9 s run that hit a retry), from 28.9 s.* -- [ ] Page-size fallback: a page that still fails with 502 or 504 after the normal retries is - retried at 50, then 25, before the sweep reports an error. +- [x] Page-size fallback: a search page that fails with 502 or 504, or arrives as a 200 with a + cut-off body, is re-sent at 50, then 25, before the normal retries. Re-sending the same + request first would only add backoff: those are timeouts on queries too heavy to answer. + *Measured: the team profile's full sweep takes 8.9 s median, and the whole-org view + completes in 5 of 5 runs (109 s median, 93–173 s). Most of that time is 60 s waits on + GitHub's secondary rate limit (403 with Retry-After), 4–9 per whole-org sweep.* - [ ] Data, then measure against the budget: - the open search gains `id`, `reviewRequests.totalCount`, team slugs and the last commit's `committedDate`. Review requests to teams now count, and the "Awaiting" column shows them. From 18aeb14d7ad4c06b44ae8084634661f8d96fb0ab Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Fri, 25 Sep 2026 14:10:04 -0500 Subject: [PATCH 12/26] perf(sweep): reuse a shrunken page size per kind of search for an hour --- desktop/src/main/core/github.service.test.mjs | 56 +++++++++++++++++++ desktop/src/main/core/github.service.ts | 44 +++++++++++++-- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/core/github.service.test.mjs b/desktop/src/main/core/github.service.test.mjs index 9c67fcf..1aad4b6 100644 --- a/desktop/src/main/core/github.service.test.mjs +++ b/desktop/src/main/core/github.service.test.mjs @@ -561,6 +561,62 @@ await assert.rejects( assert.deepEqual(sizes, [100, 50, 25, 25, 25, 25], 'halve twice, then the usual 3 retries'); } +// --- a shrunken page size carries over to the next sweep, per kind of search, for an hour --- +{ + let clock = Date.parse('2026-09-25T12:00:00Z'); + const sizes = { open: [], queue: [] }; + let timeouts = true; + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + const { q, first } = body.variables; + if (q.includes('review-requested')) { + sizes.queue.push(first); + return json(page([])); + } + if (!q.includes('is:open')) return json(page([])); + sizes.open.push(first); + if (timeouts && first > 25) return { ok: false, status: 502, headers: headers(), json: async () => ({}) }; + return json(page([])); + }; + const svc = new GithubService(() => 'tok', { retryBaseMs: 1, now: () => clock }); + const range = { start: '2026-08-01', end: null }; + await svc.sweep(makeConfig(), range); + assert.deepEqual(sizes.open, [100, 50, 25]); + + sizes.open = []; + sizes.queue = []; + clock += 30 * 60_000; + await svc.sweep(makeConfig(), range); + assert.deepEqual(sizes.open, [25], 'the next sweep starts where the last one ended up'); + assert.deepEqual(sizes.queue, [100], 'other kinds of search keep the full size'); + + sizes.open = []; + timeouts = false; + clock += 61 * 60_000; + await svc.sweep(makeConfig(), range); + assert.deepEqual(sizes.open, [100], 'after an hour it tries the full size again'); +} + +// --- merged weeks queued behind the pool start at the size the first weeks found --- +{ + const mergedSizes = []; + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + const { q, first } = body.variables; + if (!q.includes('is:merged')) return json(page([])); + mergedSizes.push(first); + await new Promise((r) => setTimeout(r, 2)); + if (first > 25) return { ok: false, status: 502, headers: headers(), json: async () => ({}) }; + return json(page([])); + }; + const svc = new GithubService(() => 'tok', { retryBaseMs: 1 }); + await svc.sweep(makeConfig(), { start: '2026-08-01', end: '2026-08-31' }); + assert.equal(mergedSizes.filter((s) => s === 100).length, 4, 'only the first four weeks try 100'); + assert.equal(mergedSizes.filter((s) => s === 25).length, 5, 'every week ends at 25'); +} + // --- a search page that arrives as a cut-off 200 body is re-sent smaller --- { const sizes = []; diff --git a/desktop/src/main/core/github.service.ts b/desktop/src/main/core/github.service.ts index 2dbeb3e..f66b605 100644 --- a/desktop/src/main/core/github.service.ts +++ b/desktop/src/main/core/github.service.ts @@ -20,7 +20,9 @@ * instead of paging through the whole range one request at a time. * - a search page that times out (502/504, or a 200 with a cut-off body) is * re-sent at half the size, down to 25, before the usual retries — - * whole-org searches on big orgs time out at 100 every time. + * whole-org searches on big orgs time out at 100 every time. Later searches + * of the same kind start at the smaller size for an hour, so the timeouts + * (and the secondary rate limits they trigger) aren't paid again and again. * - rate limits (primary and secondary) and transient 5xx are retried with the * server-stated wait when GitHub provides one, exponential backoff otherwise. * - auto-refreshes can run incrementally against the previous sweep: one cheap @@ -40,6 +42,12 @@ const PAGE_SIZE = 100; * and repeating the same request only adds the backoff. */ const MIN_PAGE_SIZE = 25; +/** + * A shrunken page size is reused by later searches of the same kind for this + * long, so a sweep doesn't pay the same timeouts search after search. It then + * expires, and a one-off 502 can't keep a small profile's pages small. + */ +const PAGE_SIZE_MEMORY_MS = 60 * 60_000; /** GitHub search returns at most this many results per query, full stop. */ const SEARCH_CAP = 1000; /** @@ -155,8 +163,8 @@ export interface SweepStats { export class GithubService { constructor( private readonly token: () => string | null, - /** retryBaseMs shrinks the backoff for tests; production uses the default. */ - private readonly opts: { retryBaseMs?: number } = {}, + /** retryBaseMs and now let tests shrink the backoff and move the clock. */ + private readonly opts: { retryBaseMs?: number; now?: () => number } = {}, ) {} /** @@ -361,16 +369,19 @@ export class GithubService { const nodes: SearchNode[] = []; let after: string | null = null; let total = 0; - let first = PAGE_SIZE; + const kind = searchKind(q, doc); + let first = this.startingPageSize(kind); // Search stops at SEARCH_CAP results; the guard (smallest pages, plus the two // halvings) also keeps a backend pagination bug from spinning forever. for (let request = 0; request < SEARCH_CAP / MIN_PAGE_SIZE + 2 && nodes.length < SEARCH_CAP; request++) { let data: SearchPage; try { + first = Math.min(first, this.startingPageSize(kind)); data = await this.graphql(doc, { q, after, first }, 0, first > MIN_PAGE_SIZE); } catch (e) { if (!(e instanceof GithubTimeout)) throw e; first = Math.max(MIN_PAGE_SIZE, first / 2); + this.rememberPageSize(kind, first); this.counters.retries++; continue; } @@ -473,6 +484,23 @@ export class GithubService { return null; } + private pageSizes = new Map(); + + private startingPageSize(kind: string): number { + const memory = this.pageSizes.get(kind); + return memory && memory.until > this.now() ? memory.size : PAGE_SIZE; + } + + private rememberPageSize(kind: string, size: number): void { + if (size < this.startingPageSize(kind)) { + this.pageSizes.set(kind, { size, until: this.now() + PAGE_SIZE_MEMORY_MS }); + } + } + + private now(): number { + return (this.opts.now ?? Date.now)(); + } + /** Sleep before a retry; PRSWEEP_DEBUG says why and for how long. */ private async pause(reason: string, ms: number): Promise { if (process.env.PRSWEEP_DEBUG) console.log(`[github] retry after ${reason}, waiting ${(ms / 1000).toFixed(1)} s`); @@ -544,6 +572,14 @@ function requestedAtFor(n: SearchNode, viewer: string): string | null { return null; } +/** + * Searches that differ only in their date window (merged weeks, the halves of a + * capped window, an incremental delta) are the same kind for page sizing. + */ +function searchKind(q: string, doc: string): string { + return `${doc}\n${q.replace(/\s(updated|merged|created):\S+/g, '')}`; +} + const nodeKey = (n: SearchNode): string => `${n.repository.name}#${n.number}`; const rowKey = (r: PrRow): string => `${r.repo}#${r.number}`; From dcc7e8ac1636f0f72be4ef6066b93f54207290f5 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Fri, 25 Sep 2026 14:26:17 -0500 Subject: [PATCH 13/26] revert: reuse a shrunken page size per kind of search for an hour Remembering page sizes doubled whole-org search requests and made the electron whole-org sweep slower (172 s median vs 109 s). The 403 waits follow the number of search requests, not timeouts, so smaller pages only add to them. --- desktop/src/main/core/github.service.test.mjs | 56 ------------------- desktop/src/main/core/github.service.ts | 44 ++------------- 2 files changed, 4 insertions(+), 96 deletions(-) diff --git a/desktop/src/main/core/github.service.test.mjs b/desktop/src/main/core/github.service.test.mjs index 1aad4b6..9c67fcf 100644 --- a/desktop/src/main/core/github.service.test.mjs +++ b/desktop/src/main/core/github.service.test.mjs @@ -561,62 +561,6 @@ await assert.rejects( assert.deepEqual(sizes, [100, 50, 25, 25, 25, 25], 'halve twice, then the usual 3 retries'); } -// --- a shrunken page size carries over to the next sweep, per kind of search, for an hour --- -{ - let clock = Date.parse('2026-09-25T12:00:00Z'); - const sizes = { open: [], queue: [] }; - let timeouts = true; - globalThis.fetch = async (_url, opts) => { - const body = JSON.parse(opts.body); - if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); - const { q, first } = body.variables; - if (q.includes('review-requested')) { - sizes.queue.push(first); - return json(page([])); - } - if (!q.includes('is:open')) return json(page([])); - sizes.open.push(first); - if (timeouts && first > 25) return { ok: false, status: 502, headers: headers(), json: async () => ({}) }; - return json(page([])); - }; - const svc = new GithubService(() => 'tok', { retryBaseMs: 1, now: () => clock }); - const range = { start: '2026-08-01', end: null }; - await svc.sweep(makeConfig(), range); - assert.deepEqual(sizes.open, [100, 50, 25]); - - sizes.open = []; - sizes.queue = []; - clock += 30 * 60_000; - await svc.sweep(makeConfig(), range); - assert.deepEqual(sizes.open, [25], 'the next sweep starts where the last one ended up'); - assert.deepEqual(sizes.queue, [100], 'other kinds of search keep the full size'); - - sizes.open = []; - timeouts = false; - clock += 61 * 60_000; - await svc.sweep(makeConfig(), range); - assert.deepEqual(sizes.open, [100], 'after an hour it tries the full size again'); -} - -// --- merged weeks queued behind the pool start at the size the first weeks found --- -{ - const mergedSizes = []; - globalThis.fetch = async (_url, opts) => { - const body = JSON.parse(opts.body); - if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); - const { q, first } = body.variables; - if (!q.includes('is:merged')) return json(page([])); - mergedSizes.push(first); - await new Promise((r) => setTimeout(r, 2)); - if (first > 25) return { ok: false, status: 502, headers: headers(), json: async () => ({}) }; - return json(page([])); - }; - const svc = new GithubService(() => 'tok', { retryBaseMs: 1 }); - await svc.sweep(makeConfig(), { start: '2026-08-01', end: '2026-08-31' }); - assert.equal(mergedSizes.filter((s) => s === 100).length, 4, 'only the first four weeks try 100'); - assert.equal(mergedSizes.filter((s) => s === 25).length, 5, 'every week ends at 25'); -} - // --- a search page that arrives as a cut-off 200 body is re-sent smaller --- { const sizes = []; diff --git a/desktop/src/main/core/github.service.ts b/desktop/src/main/core/github.service.ts index f66b605..2dbeb3e 100644 --- a/desktop/src/main/core/github.service.ts +++ b/desktop/src/main/core/github.service.ts @@ -20,9 +20,7 @@ * instead of paging through the whole range one request at a time. * - a search page that times out (502/504, or a 200 with a cut-off body) is * re-sent at half the size, down to 25, before the usual retries — - * whole-org searches on big orgs time out at 100 every time. Later searches - * of the same kind start at the smaller size for an hour, so the timeouts - * (and the secondary rate limits they trigger) aren't paid again and again. + * whole-org searches on big orgs time out at 100 every time. * - rate limits (primary and secondary) and transient 5xx are retried with the * server-stated wait when GitHub provides one, exponential backoff otherwise. * - auto-refreshes can run incrementally against the previous sweep: one cheap @@ -42,12 +40,6 @@ const PAGE_SIZE = 100; * and repeating the same request only adds the backoff. */ const MIN_PAGE_SIZE = 25; -/** - * A shrunken page size is reused by later searches of the same kind for this - * long, so a sweep doesn't pay the same timeouts search after search. It then - * expires, and a one-off 502 can't keep a small profile's pages small. - */ -const PAGE_SIZE_MEMORY_MS = 60 * 60_000; /** GitHub search returns at most this many results per query, full stop. */ const SEARCH_CAP = 1000; /** @@ -163,8 +155,8 @@ export interface SweepStats { export class GithubService { constructor( private readonly token: () => string | null, - /** retryBaseMs and now let tests shrink the backoff and move the clock. */ - private readonly opts: { retryBaseMs?: number; now?: () => number } = {}, + /** retryBaseMs shrinks the backoff for tests; production uses the default. */ + private readonly opts: { retryBaseMs?: number } = {}, ) {} /** @@ -369,19 +361,16 @@ export class GithubService { const nodes: SearchNode[] = []; let after: string | null = null; let total = 0; - const kind = searchKind(q, doc); - let first = this.startingPageSize(kind); + let first = PAGE_SIZE; // Search stops at SEARCH_CAP results; the guard (smallest pages, plus the two // halvings) also keeps a backend pagination bug from spinning forever. for (let request = 0; request < SEARCH_CAP / MIN_PAGE_SIZE + 2 && nodes.length < SEARCH_CAP; request++) { let data: SearchPage; try { - first = Math.min(first, this.startingPageSize(kind)); data = await this.graphql(doc, { q, after, first }, 0, first > MIN_PAGE_SIZE); } catch (e) { if (!(e instanceof GithubTimeout)) throw e; first = Math.max(MIN_PAGE_SIZE, first / 2); - this.rememberPageSize(kind, first); this.counters.retries++; continue; } @@ -484,23 +473,6 @@ export class GithubService { return null; } - private pageSizes = new Map(); - - private startingPageSize(kind: string): number { - const memory = this.pageSizes.get(kind); - return memory && memory.until > this.now() ? memory.size : PAGE_SIZE; - } - - private rememberPageSize(kind: string, size: number): void { - if (size < this.startingPageSize(kind)) { - this.pageSizes.set(kind, { size, until: this.now() + PAGE_SIZE_MEMORY_MS }); - } - } - - private now(): number { - return (this.opts.now ?? Date.now)(); - } - /** Sleep before a retry; PRSWEEP_DEBUG says why and for how long. */ private async pause(reason: string, ms: number): Promise { if (process.env.PRSWEEP_DEBUG) console.log(`[github] retry after ${reason}, waiting ${(ms / 1000).toFixed(1)} s`); @@ -572,14 +544,6 @@ function requestedAtFor(n: SearchNode, viewer: string): string | null { return null; } -/** - * Searches that differ only in their date window (merged weeks, the halves of a - * capped window, an incremental delta) are the same kind for page sizing. - */ -function searchKind(q: string, doc: string): string { - return `${doc}\n${q.replace(/\s(updated|merged|created):\S+/g, '')}`; -} - const nodeKey = (n: SearchNode): string => `${n.repository.name}#${n.number}`; const rowKey = (r: PrRow): string => `${r.repo}#${r.number}`; From 7f35ba7fdea6f49c996ea881fd1513e0d5cede0f Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Fri, 25 Sep 2026 14:26:46 -0500 Subject: [PATCH 14/26] docs(roadmap): record why whole-org views stay at about 110 s --- ROADMAP.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 445c1df..75f5e9f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -77,6 +77,11 @@ what to do next. time depends on org activity: when anything in the org changed since the last sweep, the refresh runs three more searches (4 requests, about 2 s). Compare auto-refreshes with the same request count. +- **Whole-org views on big orgs stay at about 110 s for v0.11.** Their time goes to 60 s waits + on GitHub's secondary rate limit (403 with Retry-After). The waits track the number of search + requests: the team profile (8–9 requests per sweep) almost never hits one, and whole-org + (30–70) always does. Remembering shrunken page sizes across searches was tried and reverted, + because smaller pages meant more requests (172 s median instead of 109 s). - **`NEEDS_RE_REVIEW` is a reason of its own.** "Changes requested" has two next steps: the author's (address the feedback) and the reviewer's (re-review after the push). - **Sprint-end risk is a header line, not a row reason.** As a reason it would flag nearly every @@ -273,6 +278,8 @@ Theme: nothing new. Make what exists boringly reliable. ## Later +- Faster whole-org views on very large orgs: pace searches under GitHub's secondary rate + limit instead of waiting out 403s - AI "explain this sprint", built on the attention engine - Grouping by linked issues (`closingIssuesReferences`) and GitHub Projects - Review-wait metrics for merged PRs (needs review timestamps on every merged PR, which is From b92f73386791cf0d3e798a0f13e2dea5ea7ebc43 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 16:51:51 -0500 Subject: [PATCH 15/26] docs(agents): open release pull requests as drafts early --- AGENTS.md | 5 +++-- ROADMAP.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3cd77ca..bffe7ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,8 +104,9 @@ Run these from the repo root unless noted. - The app version lives in `desktop/package.json`. The root `package.json` version isn't used. - **Each roadmap release gets its own branch**, `release/vX.Y`, cut from `main`. All of that - milestone's commits go there. When its "Done when" holds, open a pull request to `main` (CI - runs on it), merge, then tag `vX.Y.0` on `main`. + milestone's commits go there. Open a draft pull request to `main` early, so CI runs on every + push, and keep its testing steps current. When the milestone's "Done when" holds, mark it + ready, merge, then tag `vX.Y.0` on `main`. - A roadmap release bumps the version once, in the last commit on its branch: `chore(release): v0.11.0`. A patch outside a milestone bumps it in the fix commit and ends the subject with it: `fix: single-instance lock … (v0.10.4)`. diff --git a/ROADMAP.md b/ROADMAP.md index 75f5e9f..6b30d15 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -217,7 +217,7 @@ the `release/v0.11` branch. - [ ] Snooze per row, plus "Show snoozed" to reveal and unsnooze. - [ ] Tray: a "N need attention (team)" menu line and tooltip fallback, with `attentionCount` added to `syncTray`. Settings: the stale-threshold help text mentions the Sweep. -- [ ] `chore(release): v0.11.0`, then a pull request to `main`. Once it ships, update an installed +- [ ] `chore(release): v0.11.0`, then mark the pull request ready and merge it. Once it ships, update an installed v0.10.4 through the auto-updater and check that the first refresh fills every row without a manual Refresh. From 0bcf26623ec4cbf1cb10ee899c3a664430006dbb Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:07:35 -0500 Subject: [PATCH 16/26] docs(roadmap): no Tauri rewrite before 1.0; list the port under Later --- ROADMAP.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 6b30d15..225ca34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,6 +41,13 @@ what to do next. lockup layout of logo design 1b. Design 1b's own mark was considered and dropped. The assets are in `docs/brand/`, with `-dark` files for dark backgrounds. +## Decisions (2026-09-26) + +- **No Tauri rewrite before 1.0.** Sweep time is GitHub's, not the app's: after warm-up, the + app's own CPU is 77–173 ms of a 7–8 s team sweep (1–2%), so a Rust client can't make sweeps + meaningfully faster. What Tauri would change is installer size and idle memory, which + haven't been measured yet. + ## v0.11 decisions (2026-09-25) - **The board shows all of the team's open PRs**, not only the ones updated in the range, so PRs @@ -280,6 +287,11 @@ Theme: nothing new. Make what exists boringly reliable. - Faster whole-org views on very large orgs: pace searches under GitHub's secondary rate limit instead of waiting out 403s +- Tauri port, decided after 1.0 from measured idle memory and installer size. A port means + rewriting the main process in Rust, moving to Tauri's updater through a bridge release, and + every user signing in again (Electron `safeStorage` tokens can't be read). If memory matters + sooner, a cheaper first step: move the refresh loop into the main process and close the + window, instead of hiding it, when the app goes to the tray. - AI "explain this sprint", built on the attention engine - Grouping by linked issues (`closingIssuesReferences`) and GitHub Projects - Review-wait metrics for merged PRs (needs review timestamps on every merged PR, which is From e1fc4e8c7add0243c43c6c01e7c7b489e41b5f19 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:21:31 -0500 Subject: [PATCH 17/26] feat(sweep): carried-over PRs, team review requests, and a detail query for mergeability and reviews --- AGENTS.md | 3 + README.md | 5 +- desktop/renderer/src/app/models.ts | 6 + desktop/src/main/core/github.service.test.mjs | 233 +++++++++++++++++- desktop/src/main/core/github.service.ts | 137 +++++++++- desktop/src/shared/types.ts | 14 +- 6 files changed, 376 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bffe7ae..c14ff00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,9 @@ organization. It ships for Windows and Linux. - `statusCheckRollup` and `timelineItems` dominate sweep latency. Request an expensive field only in the query whose rows display it (`QUERY_BARE`, `QUERY_OPEN`, `QUERY_QUEUE`). Measure sweep time on a large org before and after adding fields. +- A field only some rows need (`mergeable`, review times) goes in `DETAIL_QUERY`, fetched with + `nodes(ids:)` for just those rows, never in a search. Details are optional: if that query + fails, the sweep still succeeds and those fields stay null. - Verify API behavior against the live API before encoding it. Record each verified quirk in the header comment of `github.service.ts` and in the README. diff --git a/README.md b/README.md index 8ff2e32..e7efb4d 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,9 @@ Grab a build from [Releases](../../releases). ## What it does -- **Sorts** every PR your team has open or merged in the date range into Needs review, Changes - requested, Approved and Merged, from GitHub's `reviewDecision`. There are no labels to keep up. +- **Sorts** every open PR your team has, plus what merged in the date range, into Needs review, + Changes requested, Approved and Merged, from GitHub's `reviewDecision`. There are no labels to + keep up. - **Queues** the open PRs anywhere in the org that are waiting on *your* review, with how long each has waited. - **Flags** failing CI on every open PR, and PRs untouched for longer than a threshold you set. diff --git a/desktop/renderer/src/app/models.ts b/desktop/renderer/src/app/models.ts index da9284d..3abc69e 100644 --- a/desktop/renderer/src/app/models.ts +++ b/desktop/renderer/src/app/models.ts @@ -60,6 +60,12 @@ export interface PrRow { ci: 'success' | 'failure' | 'pending' | null; /** When the signed-in user's review was requested — set on queue rows, null elsewhere. */ reviewRequestedAt: string | null; + requestCount: number; + lastCommitAt: string | null; + mergeable: 'mergeable' | 'conflicting' | 'unknown' | null; + approvedAt: string | null; + changesRequestedAt: string | null; + reviewCount: number | null; } export interface SweepResult { diff --git a/desktop/src/main/core/github.service.test.mjs b/desktop/src/main/core/github.service.test.mjs index 9c67fcf..a57cb8b 100644 --- a/desktop/src/main/core/github.service.test.mjs +++ b/desktop/src/main/core/github.service.test.mjs @@ -10,6 +10,7 @@ import { SWEEP_SCHEMA } from '../../../dist/main/shared/types.js'; function node(number, reviewDecision, extra = {}) { return { + id: `n${number}`, number, title: `pr ${number}`, url: `https://github.com/o/r/pull/${number}`, @@ -93,7 +94,8 @@ async function runSweep(config, range) { (queries.merged ??= []).push(q); return json(page([node(20, null, { mergedAt: '2026-08-05T00:00:00Z' })])); } - queries.open = q; + if (q.includes('updated:<')) queries.carried = q; + else queries.open = q; return json( page([ node(1, 'APPROVED'), @@ -504,7 +506,7 @@ await assert.rejects( globalThis.fetch = async (_url, opts) => { const body = JSON.parse(opts.body); if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); - if (!body.variables.q.includes('is:open') || body.variables.q.includes('review-requested')) return json(page([])); + if (!body.variables.q.includes('is:open') || body.variables.q.includes('review-requested') || body.variables.q.includes('updated:<')) return json(page([])); sizes.push(body.variables.first); if (body.variables.first === 100) return { ok: false, status: 502, headers: headers(), json: async () => ({}) }; if (body.variables.first === 50) return { ok: false, status: 504, headers: headers(), json: async () => ({}) }; @@ -523,7 +525,7 @@ await assert.rejects( globalThis.fetch = async (_url, opts) => { const body = JSON.parse(opts.body); if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); - if (!body.variables.q.includes('is:open') || body.variables.q.includes('review-requested')) return json(page([])); + if (!body.variables.q.includes('is:open') || body.variables.q.includes('review-requested') || body.variables.q.includes('updated:<')) return json(page([])); const { first, after } = body.variables; calls.push({ first, after }); if (first === 100) return { ok: false, status: 502, headers: headers(), json: async () => ({}) }; @@ -552,7 +554,7 @@ await assert.rejects( globalThis.fetch = async (_url, opts) => { const body = JSON.parse(opts.body); if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); - if (!body.variables.q.includes('is:open') || body.variables.q.includes('review-requested')) return json(page([])); + if (!body.variables.q.includes('is:open') || body.variables.q.includes('review-requested') || body.variables.q.includes('updated:<')) return json(page([])); sizes.push(body.variables.first); return { ok: false, status: 502, headers: headers(), json: async () => ({}) }; }; @@ -568,7 +570,7 @@ await assert.rejects( globalThis.fetch = async (_url, opts) => { const body = JSON.parse(opts.body); if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); - if (!body.variables.q.includes('is:open') || body.variables.q.includes('review-requested')) return json(page([])); + if (!body.variables.q.includes('is:open') || body.variables.q.includes('review-requested') || body.variables.q.includes('updated:<')) return json(page([])); sizes.push(body.variables.first); return body.variables.first === 100 ? truncated : json(page([node(1, 'APPROVED')])); }; @@ -591,6 +593,227 @@ await assert.rejects( assert.equal(calls, 2, 'retried once'); } +// --- open PRs come from two searches: updated since the range start, and carried over --- +{ + const today = new Date().toISOString().slice(0, 10); + const { result, queries } = await runSweep(makeConfig(), { start: '2026-08-01', end: '2026-08-15' }); + assert.match(queries.open, new RegExp(`updated:2026-08-01\\.\\.${today}`), 'open ignores the range end'); + assert.match(queries.carried, /is:open/); + assert.match(queries.carried, /draft:false/); + assert.match(queries.carried, /\(author:alice OR author:bob\)/); + assert.match(queries.carried, /updated:<2026-08-01/, 'carried over = last updated before the range'); + assert.match(queries.carried, new RegExp(`created:2008-01-01\\.\\.${today}`), 'windowed on created'); + assert.equal(result.open.length, 5, 'the same PR from both searches is kept once'); +} + +// --- carried-over PRs join the open rows --- +{ + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + const q = body.variables.q; + if (!q.includes('is:open') || q.includes('review-requested')) return json(page([])); + return json(page([q.includes('updated:<') ? node(9, 'REVIEW_REQUIRED') : node(1, 'APPROVED')])); + }; + const svc = new GithubService(() => 'tok'); + const result = await svc.sweep(makeConfig(), { start: '2026-08-01', end: null }); + assert.deepEqual(result.open.map((r) => r.number).sort(), [1, 9]); +} + +// --- review requests to teams count, and show as org/slug --- +{ + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + const q = body.variables.q; + if (!q.includes('is:open') || q.includes('review-requested') || q.includes('updated:<')) return json(page([])); + return json( + page([ + node(1, 'REVIEW_REQUIRED', { + reviewRequests: { + totalCount: 2, + nodes: [{ requestedReviewer: { login: 'dana' } }, { requestedReviewer: { combinedSlug: 'acme/platform' } }], + }, + }), + node(2, 'REVIEW_REQUIRED'), + ]), + ); + }; + const svc = new GithubService(() => 'tok'); + const result = await svc.sweep(makeConfig(), { start: '2026-08-01', end: null }); + const pr = (n) => result.open.find((r) => r.number === n); + assert.deepEqual(pr(1).requestedReviewers, ['dana', 'acme/platform']); + assert.equal(pr(1).requestCount, 2); + assert.equal(pr(2).requestCount, 0, 'no requests at all'); +} + +// --- the last commit's time maps; merged rows have none --- +{ + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + const q = body.variables.q; + if (q.includes('is:merged')) return json(page([node(20, null, { mergedAt: '2026-08-05T00:00:00Z' })])); + if (!q.includes('is:open') || q.includes('review-requested') || q.includes('updated:<')) return json(page([])); + return json( + page([ + node(1, 'REVIEW_REQUIRED', { + commits: { + nodes: [{ commit: { committedDate: '2026-08-03T10:00:00Z', statusCheckRollup: { state: 'SUCCESS' } } }], + }, + }), + ]), + ); + }; + const svc = new GithubService(() => 'tok'); + const result = await svc.sweep(makeConfig(), { start: '2026-08-01', end: '2026-08-07' }); + assert.equal(result.open[0].lastCommitAt, '2026-08-03T10:00:00Z'); + assert.strictEqual(result.merged[0].lastCommitAt, null); +} + +// --- details are fetched only for approved, changes-requested and unrequested needs-review PRs --- +{ + const detailIds = []; + const detail = { + n1: { + id: 'n1', + mergeable: 'CONFLICTING', + latestReviews: { + totalCount: 3, + nodes: [ + { state: 'COMMENTED', submittedAt: '2026-08-02T00:00:00Z' }, + { state: 'APPROVED', submittedAt: '2026-08-04T00:00:00Z' }, + { state: 'APPROVED', submittedAt: '2026-08-03T00:00:00Z' }, + ], + }, + }, + n2: { + id: 'n2', + mergeable: 'MERGEABLE', + latestReviews: { totalCount: 1, nodes: [{ state: 'CHANGES_REQUESTED', submittedAt: '2026-08-05T00:00:00Z' }] }, + }, + n4: { id: 'n4', mergeable: 'UNKNOWN', latestReviews: { totalCount: 0, nodes: [] } }, + }; + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (body.query.includes('nodes(ids:')) { + detailIds.push(...body.variables.ids); + return json({ nodes: body.variables.ids.map((id) => detail[id]) }); + } + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + const q = body.variables.q; + if (!q.includes('is:open') || q.includes('review-requested') || q.includes('updated:<')) return json(page([])); + const requested = { totalCount: 1, nodes: [{ requestedReviewer: { login: 'dana' } }] }; + return json( + page([ + node(1, 'APPROVED', { id: 'n1' }), + node(2, 'CHANGES_REQUESTED', { id: 'n2' }), + node(3, 'REVIEW_REQUIRED', { id: 'n3', reviewRequests: requested }), + node(4, null, { id: 'n4' }), + node(5, 'REVIEW_REQUIRED', { id: 'n5', isDraft: true }), + node(6, 'APPROVED', { id: 'n6', isDraft: true }), + ]), + ); + }; + const svc = new GithubService(() => 'tok'); + const result = await svc.sweep(makeConfig({ includeDrafts: true }), { start: '2026-08-01', end: null }); + assert.deepEqual(detailIds.sort(), ['n1', 'n2', 'n4'], 'never requested PRs with requests, or drafts'); + const pr = (n) => result.open.find((r) => r.number === n); + assert.equal(pr(1).mergeable, 'conflicting'); + assert.equal(pr(1).approvedAt, '2026-08-04T00:00:00Z', 'newest approval, whatever the order'); + assert.strictEqual(pr(1).changesRequestedAt, null); + assert.equal(pr(1).reviewCount, 3); + assert.equal(pr(2).mergeable, 'mergeable'); + assert.equal(pr(2).changesRequestedAt, '2026-08-05T00:00:00Z'); + assert.equal(pr(4).mergeable, 'unknown'); + assert.equal(pr(4).reviewCount, 0); + for (const n of [3, 5, 6]) { + assert.strictEqual(pr(n).mergeable, null, `#${n} has no details`); + assert.strictEqual(pr(n).reviewCount, null, `#${n} review count unknown`); + } +} + +// --- details go out in batches of 100 --- +{ + const batches = []; + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (body.query.includes('nodes(ids:')) { + batches.push(body.variables.ids.length); + return json({ + nodes: body.variables.ids.map((id) => ({ id, mergeable: 'MERGEABLE', latestReviews: { totalCount: 1, nodes: [] } })), + }); + } + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + const q = body.variables.q; + if (!q.includes('is:open') || q.includes('review-requested') || q.includes('updated:<')) return json(page([])); + return json(page(Array.from({ length: 150 }, (_, i) => node(i + 1, 'APPROVED', { id: `n${i + 1}` })))); + }; + const svc = new GithubService(() => 'tok'); + const result = await svc.sweep(makeConfig(), { start: '2026-08-01', end: null }); + assert.deepEqual(batches.sort((a, b) => b - a), [100, 50]); + assert.ok(result.open.every((r) => r.mergeable === 'mergeable')); +} + +// --- a failed detail query still gives a board, just without the details --- +{ + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (body.query.includes('nodes(ids:')) return { ok: false, status: 502, headers: headers(), json: async () => ({}) }; + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + const q = body.variables.q; + if (!q.includes('is:open') || q.includes('review-requested') || q.includes('updated:<')) return json(page([])); + return json(page([node(1, 'APPROVED', { id: 'n1' })])); + }; + const svc = new GithubService(() => 'tok', { retryBaseMs: 1 }); + const result = await svc.sweep(makeConfig(), { start: '2026-08-01', end: null }); + assert.equal(result.open.length, 1); + assert.strictEqual(result.open[0].mergeable, null); + assert.strictEqual(result.open[0].approvedAt, null); + assert.equal(svc.lastSweep.ok, true); +} + +// --- incremental: only changed PRs get new details; cached rows keep theirs --- +{ + const cached = { + ...row(1, 'approved'), + mergeable: 'conflicting', + approvedAt: '2026-08-02T00:00:00Z', + changesRequestedAt: null, + reviewCount: 1, + }; + const base = { + fetchedAt: new Date(Date.now() - 10 * 60_000).toISOString(), + schema: SWEEP_SCHEMA, + org: 'acme', + range: { start: '2026-08-01', end: null }, + open: [cached], + merged: [], + queue: [], + }; + const detailIds = []; + globalThis.fetch = async (_url, opts) => { + const body = JSON.parse(opts.body); + if (body.query.includes('nodes(ids:')) { + detailIds.push(...body.variables.ids); + return json({ + nodes: body.variables.ids.map((id) => ({ id, mergeable: 'MERGEABLE', latestReviews: { totalCount: 1, nodes: [] } })), + }); + } + if (!body.query.includes('search(')) return json({ viewer: { login: 'me' } }); + const q = body.variables.q; + if (q.includes('review-requested') || q.includes('is:merged')) return json(page([])); + if (q.includes('is:open')) return json(page([node(2, 'APPROVED', { id: 'n2' })])); + return json(page([node(2, 'APPROVED')])); // the org-wide probe: only #2 changed + }; + const svc = new GithubService(() => 'tok'); + const result = await svc.sweep(makeConfig(), { start: '2026-08-01', end: null }, base); + assert.deepEqual(detailIds, ['n2'], 'details only for the changed PR'); + const pr = (n) => result.open.find((r) => r.number === n); + assert.equal(pr(1).mergeable, 'conflicting', 'untouched row keeps its cached details'); + assert.equal(pr(2).mergeable, 'mergeable'); +} + // --- results carry the snapshot schema --- { const { result } = await runSweep(makeConfig(), { start: '2026-08-01', end: null }); diff --git a/desktop/src/main/core/github.service.ts b/desktop/src/main/core/github.service.ts index 2dbeb3e..6e66e50 100644 --- a/desktop/src/main/core/github.service.ts +++ b/desktop/src/main/core/github.service.ts @@ -16,6 +16,9 @@ * Big-org behavior: * - date-windowed queries split themselves when they'd hit the 1000-result cap, * so a busy quarter still sweeps completely instead of silently truncating. + * - open PRs come from two searches side by side: updated since the range + * start, and carried over from before it. Mergeability and review times + * come after, only for the rows that need them (DETAIL_QUERY). * - the full sweep's merged list runs one week per search, a few in flight, * instead of paging through the whole range one request at a time. * - a search page that times out (502/504, or a 200 with a cut-off body) is @@ -65,17 +68,18 @@ const INCREMENTAL_MAX_AGE_MS = 60 * 60_000; // merged rows show neither, open rows show the CI dot, queue rows also show // the review-wait badge. The incremental probe needs only keys → bare. const NODE_FIELDS = ` - number title url isDraft createdAt updatedAt mergedAt + id number title url isDraft createdAt updatedAt mergedAt reviewDecision totalCommentsCount additions deletions repository { name } author { login avatarUrl } reviewRequests(first: 10) { - nodes { requestedReviewer { ... on User { login } } } + totalCount + nodes { requestedReviewer { ... on User { login } ... on Team { combinedSlug } } } }`; const CI_FIELD = ` commits(last: 1) { - nodes { commit { statusCheckRollup { state } } } + nodes { commit { committedDate statusCheckRollup { state } } } }`; const TIMELINE_FIELD = ` @@ -107,7 +111,28 @@ const QUERY_BARE = buildSearchQuery({}); const QUERY_OPEN = buildSearchQuery({ ci: true }); const QUERY_QUEUE = buildSearchQuery({ ci: true, timeline: true }); +/** + * Mergeability and review times, fetched after the searches only for the rows + * whose attention depends on them (see needsDetails). On every open row they'd + * cost ~20% of the open search's time; mergeable is the expensive one. + */ +const DETAIL_QUERY = ` + query ($ids: [ID!]!) { + nodes(ids: $ids) { + ... on PullRequest { + id mergeable + latestReviews(first: 10) { totalCount nodes { state submittedAt } } + } + } + } +`; +/** nodes(ids:) takes at most 100 ids. */ +const DETAIL_BATCH = 100; +/** Carried-over PRs are split by creation date from here: nothing on GitHub is older. */ +const CARRIED_FROM = '2008-01-01'; + interface SearchNode { + id: string; number: number; title: string; url: string; @@ -121,10 +146,14 @@ interface SearchNode { deletions: number; repository: { name: string }; author: { login: string; avatarUrl: string } | null; - reviewRequests: { nodes: Array<{ requestedReviewer: { login?: string } | null }> }; + reviewRequests: { + totalCount?: number; + nodes: Array<{ requestedReviewer: { login?: string; combinedSlug?: string } | null }>; + }; commits?: { nodes: Array<{ commit: { + committedDate?: string; statusCheckRollup: { state: 'SUCCESS' | 'FAILURE' | 'ERROR' | 'PENDING' | 'EXPECTED' } | null; }; }>; @@ -134,6 +163,14 @@ interface SearchNode { }; } +interface DetailNode { + id: string; + mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; + latestReviews: { totalCount: number; nodes: Array<{ state: string; submittedAt: string | null }> }; +} + +type Details = Pick; + interface SearchPage { search: { issueCount: number; @@ -245,8 +282,21 @@ export class GithubService { const today = new Date().toISOString().slice(0, 10); const end = range.end ?? today; - const [open, merged, queue] = await Promise.all([ - this.searchWindowed((a, b) => `${parts.open} updated:${a}..${b}`, range.start, end, QUERY_OPEN), + // Open means open now, whatever the range's end: everything updated since + // the range start, plus PRs carried over from before it. Two searches, so + // they run side by side. Details follow as soon as both land, while the + // merged weeks are still in flight. + const open = Promise.all([ + this.searchWindowed((a, b) => `${parts.open} updated:${a}..${b}`, range.start, today, QUERY_OPEN), + this.searchWindowed( + (a, b) => `${parts.open} updated:<${range.start} created:${a}..${b}`, + CARRIED_FROM, + today, + QUERY_OPEN, + ), + ]).then(([recent, carried]) => this.openRows(uniqueNodes([...recent, ...carried]))); + const [openRows, merged, queue] = await Promise.all([ + open, this.searchMerged(parts.merged, range.start, end), this.searchAll(parts.queue, QUERY_QUEUE).then((r) => r.nodes), ]); @@ -255,7 +305,7 @@ export class GithubService { fetchedAt: new Date().toISOString(), org: profile.org, range, - open: open.map((n) => toRow(n, bucketOf(n))), + open: openRows, merged: merged.map((n) => toRow(n, 'merged')), // Queue rows resolve "when was *my* review requested" from the timeline. queue: queue.map((n) => toRow(n, bucketOf(n), login)), @@ -282,8 +332,7 @@ export class GithubService { range: DateRange, login: string, ): Promise { - // `since` never reaches before the range start, so the looser updated:>= - // filter on the open delta can't smuggle in rows the range would exclude. + // `since` never reaches before the range start. const sinceMs = Math.max( Date.parse(base.fetchedAt) - INCREMENTAL_SKEW_MS, Date.parse(`${range.start}T00:00:00Z`), @@ -301,7 +350,7 @@ export class GithubService { const mergedRange = range.end ? `merged:${range.start}..${range.end}` : `merged:>=${range.start}`; const [open, merged, queue] = await Promise.all([ - this.searchAll(`${parts.open} updated:>=${since}`, QUERY_OPEN).then((r) => r.nodes), + this.searchAll(`${parts.open} updated:>=${since}`, QUERY_OPEN).then((r) => this.openRows(r.nodes)), this.searchAll(`${parts.merged} ${mergedRange} updated:>=${since}`, QUERY_BARE).then((r) => r.nodes), this.searchAll(`${parts.queue} updated:>=${since}`, QUERY_QUEUE).then((r) => r.nodes), ]); @@ -315,7 +364,7 @@ export class GithubService { fetchedAt, org: base.org, range, - open: patch(base.open, open.map((n) => toRow(n, bucketOf(n)))), + open: patch(base.open, open), merged: patch(base.merged, merged.map((n) => toRow(n, 'merged'))), queue: patch(base.queue, queue.map((n) => toRow(n, bucketOf(n), login))), }; @@ -349,6 +398,32 @@ export class GithubService { return uniqueNodes([...a, ...b]); } + /** Open rows, with details for the ones that need them. */ + private async openRows(nodes: SearchNode[]): Promise { + const details = await this.fetchDetails(nodes.filter(needsDetails).map((n) => n.id)); + return nodes.map((n) => toRow(n, bucketOf(n), undefined, details.get(n.id))); + } + + /** + * DETAIL_QUERY for `ids`, a batch at a time. The details are optional: if + * GitHub won't answer, the board still shows, and those rows just lack them + * until the next sweep. + */ + private async fetchDetails(ids: string[]): Promise> { + const details = new Map(); + try { + for (let i = 0; i < ids.length; i += DETAIL_BATCH) { + const data = await this.graphql<{ nodes: Array }>(DETAIL_QUERY, { + ids: ids.slice(i, i + DETAIL_BATCH), + }); + for (const d of data.nodes ?? []) if (d?.id) details.set(d.id, detailsOf(d)); + } + } catch (e) { + if (process.env.PRSWEEP_DEBUG) console.log(`[github] details skipped: ${(e as Error).message}`); + } + return details; + } + /** Merged PRs in from..to, one week per search with MERGED_CONCURRENCY in flight. */ private async searchMerged(q: string, from: string, to: string): Promise { const weeks = await mapLimit(windows(from, to, MERGED_WINDOW_DAYS), MERGED_CONCURRENCY, ([a, b]) => @@ -500,7 +575,37 @@ function bucketOf(n: SearchNode): ReviewBucket { } } -function toRow(n: SearchNode, bucket: ReviewBucket, viewer?: string): PrRow { +/** + * Rows whose attention reasons need DETAIL_QUERY: approved (merge conflicts, + * approval age), changes requested (review time vs. last commit), and needs + * review with nobody asked (whether anyone reviewed anyway). Drafts only ever + * get the old-draft reason. + */ +function needsDetails(n: SearchNode): boolean { + if (n.isDraft) return false; + return bucketOf(n) !== 'needs-review' || requestCountOf(n) === 0; +} + +function requestCountOf(n: SearchNode): number { + return n.reviewRequests.totalCount ?? n.reviewRequests.nodes.length; +} + +function detailsOf(d: DetailNode): Details { + const newest = (state: string): string | null => + d.latestReviews.nodes + .filter((r) => r.state === state && r.submittedAt) + .map((r) => r.submittedAt as string) + .sort() + .pop() ?? null; + return { + mergeable: d.mergeable === 'CONFLICTING' ? 'conflicting' : d.mergeable === 'MERGEABLE' ? 'mergeable' : 'unknown', + approvedAt: newest('APPROVED'), + changesRequestedAt: newest('CHANGES_REQUESTED'), + reviewCount: d.latestReviews.totalCount, + }; +} + +function toRow(n: SearchNode, bucket: ReviewBucket, viewer?: string, details?: Details): PrRow { return { repo: n.repository.name, number: n.number, @@ -517,10 +622,16 @@ function toRow(n: SearchNode, bucket: ReviewBucket, viewer?: string): PrRow { additions: n.additions, deletions: n.deletions, requestedReviewers: n.reviewRequests.nodes - .map((r) => r.requestedReviewer?.login) + .map((r) => r.requestedReviewer?.login ?? r.requestedReviewer?.combinedSlug) .filter((l): l is string => !!l), + requestCount: requestCountOf(n), ci: ciOf(n), + lastCommitAt: n.commits?.nodes?.[0]?.commit?.committedDate ?? null, reviewRequestedAt: viewer ? requestedAtFor(n, viewer) : null, + mergeable: details?.mergeable ?? null, + approvedAt: details?.approvedAt ?? null, + changesRequestedAt: details?.changesRequestedAt ?? null, + reviewCount: details?.reviewCount ?? null, }; } diff --git a/desktop/src/shared/types.ts b/desktop/src/shared/types.ts index 8358c71..7ea1966 100644 --- a/desktop/src/shared/types.ts +++ b/desktop/src/shared/types.ts @@ -73,12 +73,22 @@ export interface PrRow { comments: number; additions: number; deletions: number; - /** Logins with an outstanding review request. */ + /** Logins (and org/team slugs) with an outstanding review request. */ requestedReviewers: string[]; /** Latest commit's check rollup as a traffic light; null = no checks configured. */ ci: 'success' | 'failure' | 'pending' | null; /** When the signed-in user's review was requested — set on queue rows, null elsewhere. */ reviewRequestedAt: string | null; + /** Review requests to people and teams (requestedReviewers lists at most 10). */ + requestCount: number; + /** When the latest commit was made; null on merged rows. */ + lastCommitAt: string | null; + // Fetched only for approved, changes-requested and unrequested needs-review + // rows (see needsDetails in github.service.ts); null elsewhere. + mergeable: 'mergeable' | 'conflicting' | 'unknown' | null; + approvedAt: string | null; + changesRequestedAt: string | null; + reviewCount: number | null; } /** @@ -86,7 +96,7 @@ export interface PrRow { * incremental refreshes keep cached rows until each PR changes on GitHub, so a * snapshot from an older build must be swept afresh, never painted or patched. */ -export const SWEEP_SCHEMA = 2; +export const SWEEP_SCHEMA = 3; export interface SweepResult { /** SWEEP_SCHEMA when this was written; absent in snapshots from before v0.11. */ From 29d7e1117e12b7bb805c7e550dd2df048bd6e625 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:40:32 -0500 Subject: [PATCH 18/26] docs(roadmap): record the step 5 data measurements --- ROADMAP.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 225ca34..a02608d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -84,7 +84,8 @@ what to do next. time depends on org activity: when anything in the org changed since the last sweep, the refresh runs three more searches (4 requests, about 2 s). Compare auto-refreshes with the same request count. -- **Whole-org views on big orgs stay at about 110 s for v0.11.** Their time goes to 60 s waits +- **Whole-org views on big orgs stay slow for v0.11:** about 110 s after the page-size fallback, + 170 s once carried-over PRs joined the board. Their time goes to 60 s waits on GitHub's secondary rate limit (403 with Retry-After). The waits track the number of search requests: the team profile (8–9 requests per sweep) almost never hits one, and whole-org (30–70) always does. Remembering shrunken page sizes across searches was tried and reverted, @@ -189,7 +190,7 @@ the `release/v0.11` branch. *Measured: the team profile's full sweep takes 8.9 s median, and the whole-org view completes in 5 of 5 runs (109 s median, 93–173 s). Most of that time is 60 s waits on GitHub's secondary rate limit (403 with Retry-After), 4–9 per whole-org sweep.* -- [ ] Data, then measure against the budget: +- [x] Data, then measure against the budget: - the open search gains `id`, `reviewRequests.totalCount`, team slugs and the last commit's `committedDate`. Review requests to teams now count, and the "Awaiting" column shows them. - a carried-over search (`updated: Date: Sat, 26 Sep 2026 23:36:11 -0500 Subject: [PATCH 19/26] feat(attention): judge every open PR after each sweep --- AGENTS.md | 6 +- ROADMAP.md | 7 +- desktop/package.json | 2 +- desktop/renderer/src/app/board.store.ts | 10 + desktop/renderer/src/app/models.ts | 28 +++ .../src/app/pages/settings.component.ts | 2 +- desktop/src/main/core/attention.test.mjs | 195 ++++++++++++++++++ desktop/src/main/core/attention.ts | 107 ++++++++++ desktop/src/main/core/github.service.ts | 4 + desktop/src/main/ipc.ts | 9 +- desktop/src/shared/types.ts | 35 +++- 11 files changed, 397 insertions(+), 8 deletions(-) create mode 100644 desktop/src/main/core/attention.test.mjs create mode 100644 desktop/src/main/core/attention.ts diff --git a/AGENTS.md b/AGENTS.md index c14ff00..d1e3b32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,8 +62,10 @@ organization. It ships for Windows and Linux. ## Product rules -- The attention engine is the single definition of "needs attention". The Sweep section, the - sprint summary and the standup all read from it. +- The attention engine (`desktop/src/main/core/attention.ts`) is the single definition of + "needs attention". `prs:fetch` runs it over every open row after every sweep, cached rows + included. The Sweep section, the tray line and the sprint summary read its output; the + renderer never decides on its own whether a PR needs attention. - Workflow health, not human performance: no per-person counts, leaderboards or review stats. - No AI features before 1.0. - Windows and Linux only. macOS is not planned. diff --git a/ROADMAP.md b/ROADMAP.md index a02608d..c8333df 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -205,9 +205,12 @@ the `release/v0.11` branch. need them and found 6 merge conflicts. The whole-org view still completes in 5 of 5 runs, but at 170 s median: its carried-over PRs add search requests, and with them more rate-limit waits.* -- [ ] Attention engine: `desktop/src/main/core/attention.ts`, a pure function with a test for each +- [x] Attention engine: `desktop/src/main/core/attention.ts`, a pure function with a test for each reason at its boundary. `prs:fetch` runs it over the open rows after every sweep, full or - incremental. It also returns `sprintRisk`. Editing `staleDays` triggers a refresh. + incremental. It also returns `sprintRisk`. Editing `staleDays` triggers a refresh (an + incremental one, so it's usually a single request). Schema 4. *On a live electron team + sweep, with drafts shown, 31 of 39 open PRs got a reason: 6 CI failures, 4 conflicts, + 6 old drafts, and many long-lived PRs that are also stale.* | # | Reason | Fires when | `since` | Next step | |---|---|---|---|---| diff --git a/desktop/package.json b/desktop/package.json index 5b361ae..9713924 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -7,7 +7,7 @@ "main": "dist/main/main/main.js", "scripts": { "build:main": "tsc -p tsconfig.json", - "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs && node src/main/core/snapshot.store.test.mjs", + "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs && node src/main/core/snapshot.store.test.mjs && node src/main/core/attention.test.mjs", "start": "electron .", "package": "electron-builder", "package:win": "electron-builder --win", diff --git a/desktop/renderer/src/app/board.store.ts b/desktop/renderer/src/app/board.store.ts index 9ce1b47..c8a3102 100644 --- a/desktop/renderer/src/app/board.store.ts +++ b/desktop/renderer/src/app/board.store.ts @@ -150,6 +150,16 @@ export class BoardStore { } } + /** + * The stale threshold feeds the attention engine, which runs in main after a + * sweep. An auto refresh patches the cached sweep (usually one request) and + * re-judges every row with the new threshold. + */ + setStaleDays(days: number): void { + this.patchProfile({ staleDays: days }); + void this.refresh({ auto: true }); + } + /** Drafts visibility is part of the search queries, so toggling refetches. */ toggleDrafts(): void { this.patchProfile({ includeDrafts: !this.activeProfile()?.includeDrafts }); diff --git a/desktop/renderer/src/app/models.ts b/desktop/renderer/src/app/models.ts index 3abc69e..a02250e 100644 --- a/desktop/renderer/src/app/models.ts +++ b/desktop/renderer/src/app/models.ts @@ -40,6 +40,32 @@ export type SweepConfigPatch = Partial; export type ReviewBucket = 'needs-review' | 'changes-requested' | 'approved' | 'merged'; +export type AttentionReason = + | 'CI_FAILING' + | 'MERGE_CONFLICT' + | 'CHANGES_NOT_ADDRESSED' + | 'NEEDS_RE_REVIEW' + | 'APPROVED_NOT_MERGED' + | 'NO_REVIEWERS' + | 'WAITING_FOR_REVIEW' + | 'STALE' + | 'DRAFT_TOO_LONG'; + +export interface Attention { + reason: AttentionReason; + /** 1 is the most severe; a row's attention list is sorted by it. */ + severity: number; + since: string | null; + action: string; + href: string; +} + +export interface SprintRisk { + /** 0 = the range ends today. */ + endsInDays: number; + notApproved: number; +} + export interface PrRow { repo: string; number: number; @@ -66,6 +92,7 @@ export interface PrRow { approvedAt: string | null; changesRequestedAt: string | null; reviewCount: number | null; + attention: Attention[]; } export interface SweepResult { @@ -76,6 +103,7 @@ export interface SweepResult { open: PrRow[]; merged: PrRow[]; queue: PrRow[]; + sprintRisk: SprintRisk | null; } /** Auto-update progress pushed from main; null = nothing in flight. */ diff --git a/desktop/renderer/src/app/pages/settings.component.ts b/desktop/renderer/src/app/pages/settings.component.ts index 4a1091e..d3d802e 100644 --- a/desktop/renderer/src/app/pages/settings.component.ts +++ b/desktop/renderer/src/app/pages/settings.component.ts @@ -90,7 +90,7 @@ import { BoardStore } from '../board.store'; type="number" min="0" [value]="store.activeProfile()?.staleDays ?? 5" - (change)="store.patchProfile({ staleDays: +$any($event.target).value })" + (change)="store.setStaleDays(+$any($event.target).value)" /> diff --git a/desktop/src/main/core/attention.test.mjs b/desktop/src/main/core/attention.test.mjs new file mode 100644 index 0000000..201db8d --- /dev/null +++ b/desktop/src/main/core/attention.test.mjs @@ -0,0 +1,195 @@ +/** + * Verifies the attention engine: every reason fires on its side of each + * boundary and not on the other, drafts and the stale threshold behave, and + * reasons come out most severe first. Run after `npm run build:main`: + * node src/main/core/attention.test.mjs + */ +import assert from 'node:assert'; +import { annotate, attention, sprintRisk } from '../../../dist/main/main/core/attention.js'; + +const NOW = Date.parse('2026-09-26T12:00:00Z'); +const HOUR = 3_600_000; +const ago = (hours) => new Date(NOW - hours * HOUR).toISOString(); +const ctx = (patch = {}) => ({ now: NOW, staleDays: 5, rangeEnd: null, ...patch }); + +/** A healthy open PR: asked for review two hours ago, CI green. */ +function pr(patch = {}) { + return { + repo: 'api', + number: 7, + title: 'Add a thing', + url: 'https://github.com/acme/api/pull/7', + isDraft: false, + author: 'dana', + authorAvatarUrl: '', + bucket: 'needs-review', + createdAt: ago(2), + updatedAt: ago(1), + mergedAt: null, + comments: 0, + additions: 1, + deletions: 0, + requestedReviewers: ['lee'], + requestCount: 1, + ci: 'success', + lastCommitAt: ago(2), + reviewRequestedAt: null, + mergeable: null, + approvedAt: null, + changesRequestedAt: null, + reviewCount: null, + attention: [], + ...patch, + }; +} +const reasons = (row, c = ctx()) => attention(row, c).map((a) => a.reason); + +// --- a healthy PR needs nothing --- +assert.deepEqual(reasons(pr()), []); + +// --- CI_FAILING: since the failing commit, next step opens the checks --- +{ + const [a] = attention(pr({ ci: 'failure', lastCommitAt: ago(3) }), ctx()); + assert.equal(a.reason, 'CI_FAILING'); + assert.equal(a.since, ago(3)); + assert.equal(a.action, 'Fix CI'); + assert.equal(a.href, 'https://github.com/acme/api/pull/7/checks'); + assert.deepEqual(reasons(pr({ ci: 'pending' })), [], 'pending is not failing'); +} + +// --- MERGE_CONFLICT: only a known conflict, and with no age --- +{ + const [a] = attention(pr({ bucket: 'approved', mergeable: 'conflicting', approvedAt: ago(30) }), ctx()); + assert.equal(a.reason, 'MERGE_CONFLICT'); + assert.strictEqual(a.since, null); + assert.equal(a.action, 'Resolve conflict'); + assert.deepEqual(reasons(pr({ mergeable: 'unknown' })), [], 'UNKNOWN never fires'); +} + +// --- CHANGES_NOT_ADDRESSED: changes requested, no commit since, for more than a day --- +{ + const cr = (reviewHours, commitHours) => + pr({ bucket: 'changes-requested', changesRequestedAt: ago(reviewHours), lastCommitAt: ago(commitHours) }); + const [a] = attention(cr(25, 30), ctx()); + assert.equal(a.reason, 'CHANGES_NOT_ADDRESSED'); + assert.equal(a.since, ago(25)); + assert.equal(a.action, 'Address feedback'); + assert.deepEqual(reasons(cr(23, 30)), [], 'under a day is still fresh'); + assert.deepEqual(reasons(cr(25, 25)), ['CHANGES_NOT_ADDRESSED'], 'a commit at the review time is not after it'); + assert.deepEqual(reasons(pr({ bucket: 'changes-requested' })), [], 'no review time, no verdict'); +} + +// --- NEEDS_RE_REVIEW: the author pushed after the review, no re-review for more than a day --- +{ + const cr = (reviewHours, commitHours) => + pr({ bucket: 'changes-requested', changesRequestedAt: ago(reviewHours), lastCommitAt: ago(commitHours) }); + const [a] = attention(cr(50, 25), ctx()); + assert.equal(a.reason, 'NEEDS_RE_REVIEW'); + assert.equal(a.since, ago(25)); + assert.equal(a.action, 'Re-review'); + assert.equal(a.href, 'https://github.com/acme/api/pull/7/files'); + assert.deepEqual(reasons(cr(50, 23)), [], 'a push under a day ago is still fresh'); +} + +// --- APPROVED_NOT_MERGED: approved for more than a day, CI green or absent, no conflict --- +{ + const ok = (patch) => pr({ bucket: 'approved', approvedAt: ago(25), ...patch }); + const [a] = attention(ok(), ctx()); + assert.equal(a.reason, 'APPROVED_NOT_MERGED'); + assert.equal(a.since, ago(25)); + assert.equal(a.action, 'Merge'); + assert.deepEqual(reasons(ok({ ci: null })), ['APPROVED_NOT_MERGED'], 'no checks configured'); + assert.deepEqual(reasons(ok({ ci: 'pending' })), [], 'pending CI blocks it'); + assert.deepEqual(reasons(ok({ mergeable: 'conflicting' })), ['MERGE_CONFLICT'], 'a conflict replaces it'); + assert.deepEqual(reasons(ok({ approvedAt: ago(23) })), [], 'under a day'); + assert.deepEqual(reasons(ok({ approvedAt: null })), [], 'no approval time, no verdict'); +} + +// --- NO_REVIEWERS: nobody asked, nobody reviewed, open more than an hour --- +{ + const none = (patch) => pr({ requestCount: 0, requestedReviewers: [], reviewCount: 0, ...patch }); + const [a] = attention(none(), ctx()); + assert.equal(a.reason, 'NO_REVIEWERS'); + assert.equal(a.since, ago(2)); + assert.equal(a.action, 'Request reviewers'); + assert.deepEqual(reasons(none({ createdAt: ago(0.5) })), [], 'give the author an hour'); + assert.deepEqual(reasons(none({ reviewCount: 1 })), [], 'someone reviewed anyway'); + assert.deepEqual(reasons(none({ reviewCount: null })), [], 'unknown review count, no verdict'); + assert.deepEqual(reasons(pr({ reviewCount: 0 })), [], 'a request is pending'); +} + +// --- WAITING_FOR_REVIEW: requested, open longer than the stale threshold --- +{ + const [a] = attention(pr({ createdAt: ago(6 * 24) }), ctx()); + assert.equal(a.reason, 'WAITING_FOR_REVIEW'); + assert.equal(a.since, ago(6 * 24)); + assert.equal(a.action, 'Nudge reviewers'); + assert.deepEqual(reasons(pr({ createdAt: ago(4 * 24) })), []); + assert.deepEqual(reasons(pr({ createdAt: ago(6 * 24) }), ctx({ staleDays: 0 })), [], '0 turns it off'); +} + +// --- STALE: no update in the stale threshold --- +{ + const [a] = attention(pr({ bucket: 'approved', createdAt: ago(9 * 24), updatedAt: ago(6 * 24), ci: 'pending' }), ctx()); + assert.equal(a.reason, 'STALE'); + assert.equal(a.since, ago(6 * 24)); + assert.equal(a.action, 'Nudge'); + assert.deepEqual(reasons(pr({ updatedAt: ago(4 * 24) })), []); + assert.deepEqual(reasons(pr({ bucket: 'approved', updatedAt: ago(6 * 24), ci: 'pending' }), ctx({ staleDays: 0 })), []); +} + +// --- DRAFT_TOO_LONG: a draft older than the threshold, and drafts get nothing else --- +{ + const draft = (patch) => pr({ isDraft: true, ...patch }); + const [a] = attention(draft({ createdAt: ago(6 * 24), updatedAt: ago(6 * 24), ci: 'failure' }), ctx()); + assert.equal(a.reason, 'DRAFT_TOO_LONG'); + assert.equal(a.since, ago(6 * 24)); + assert.equal(a.action, 'Ready or close'); + assert.equal(attention(draft({ createdAt: ago(6 * 24), ci: 'failure' }), ctx()).length, 1, 'only the draft reason'); + assert.deepEqual(reasons(draft({ ci: 'failure', mergeable: 'conflicting' })), [], 'a young draft is work in progress'); + assert.deepEqual(reasons(draft({ createdAt: ago(6 * 24) }), ctx({ staleDays: 0 })), []); +} + +// --- reasons come out most severe first --- +{ + const row = pr({ ci: 'failure', mergeable: 'conflicting', bucket: 'approved', approvedAt: ago(48), updatedAt: ago(6 * 24) }); + assert.deepEqual(reasons(row), ['CI_FAILING', 'MERGE_CONFLICT', 'STALE']); +} + +// --- sprintRisk: only in the last two days of a range with an end --- +{ + const rows = [ + pr({ bucket: 'approved' }), + pr({ bucket: 'needs-review' }), + pr({ bucket: 'changes-requested' }), + pr({ bucket: 'needs-review', isDraft: true }), + ]; + assert.strictEqual(sprintRisk(rows, ctx()), null, 'open-ended range'); + assert.strictEqual(sprintRisk(rows, ctx({ rangeEnd: '2026-10-01' })), null, 'five days out'); + assert.deepEqual(sprintRisk(rows, ctx({ rangeEnd: '2026-09-28' })), { endsInDays: 2, notApproved: 2 }, 'drafts excluded'); + assert.deepEqual(sprintRisk(rows, ctx({ rangeEnd: '2026-09-26' })), { endsInDays: 0, notApproved: 2 }, 'ends today'); + assert.strictEqual(sprintRisk(rows, ctx({ rangeEnd: '2026-09-25' })), null, 'already over'); +} + +// --- annotate: open rows get reasons, merged and queue rows never do --- +{ + const failing = pr({ ci: 'failure' }); + const result = { + schema: 4, + fetchedAt: ago(0), + org: 'acme', + range: { start: '2026-09-15', end: '2026-09-27' }, + open: [failing, pr({ number: 8 })], + merged: [pr({ number: 9, bucket: 'merged', ci: 'failure' })], + queue: [pr({ number: 10, ci: 'failure' })], + sprintRisk: null, + }; + const out = annotate(result, ctx({ rangeEnd: '2026-09-27' })); + assert.deepEqual(out.open.map((r) => r.attention.map((a) => a.reason)), [['CI_FAILING'], []]); + assert.deepEqual(out.merged[0].attention, []); + assert.deepEqual(out.queue[0].attention, []); + assert.deepEqual(out.sprintRisk, { endsInDays: 1, notApproved: 2 }); + assert.deepEqual(failing.attention, [], 'the input rows are left alone'); +} + +console.log('attention: every reason, drafts, thresholds, ordering and sprint risk pass'); diff --git a/desktop/src/main/core/attention.ts b/desktop/src/main/core/attention.ts new file mode 100644 index 0000000..47e3512 --- /dev/null +++ b/desktop/src/main/core/attention.ts @@ -0,0 +1,107 @@ +/** + * The attention engine: the single definition of "this PR needs a human". The + * Sweep section, the tray line and (in v0.12) the sprint summary all read its + * output. It runs in the main process after every sweep, over every open row, + * cached ones included: reasons depend on the clock, so a verdict saved once on + * a row would go stale while the row sat unchanged in the snapshot. + * + * Pure: no I/O, and the clock comes in through the context. + */ +import { Attention, AttentionReason, PrRow, SprintRisk, SweepResult } from '../../shared/types'; + +export interface AttentionContext { + now: number; + /** The profile's stale threshold. 0 turns off the slow reasons: waiting, stale, old draft. */ + staleDays: number; + /** The range's end date. Sprint risk only exists when there is one. */ + rangeEnd: string | null; +} + +/** Most severe first. An Attention's severity is its 1-based place here. */ +const ORDER: AttentionReason[] = [ + 'CI_FAILING', + 'MERGE_CONFLICT', + 'CHANGES_NOT_ADDRESSED', + 'NEEDS_RE_REVIEW', + 'APPROVED_NOT_MERGED', + 'NO_REVIEWERS', + 'WAITING_FOR_REVIEW', + 'STALE', + 'DRAFT_TOO_LONG', +]; + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; +/** A fresh review, push or approval gets a day before it counts as stuck. */ +const SETTLE_MS = DAY; +/** Time for the author to add reviewers before a PR counts as unassigned. */ +const NO_REVIEWERS_GRACE_MS = HOUR; +/** Sprint risk shows in the range's last days: in the last two, the end day included. */ +const SPRINT_RISK_DAYS = 2; + +export function attention(row: PrRow, ctx: AttentionContext): Attention[] { + const age = (ts: string): number => ctx.now - Date.parse(ts); + const slow = ctx.staleDays > 0 ? ctx.staleDays * DAY : Infinity; + const out: Attention[] = []; + const add = (reason: AttentionReason, since: string | null, action: string, path = ''): void => { + out.push({ reason, severity: ORDER.indexOf(reason) + 1, since, action, href: row.url + path }); + }; + + // A draft is work in progress: failing CI or a conflict there is expected. + // Only its age is worth a nudge. + if (row.isDraft) { + if (age(row.createdAt) > slow) add('DRAFT_TOO_LONG', row.createdAt, 'Ready or close'); + return out; + } + + if (row.ci === 'failure') add('CI_FAILING', row.lastCommitAt, 'Fix CI', '/checks'); + if (row.mergeable === 'conflicting') add('MERGE_CONFLICT', null, 'Resolve conflict'); + + // Changes requested has two next steps: the author's until they push, the + // reviewer's after. + if (row.bucket === 'changes-requested' && row.changesRequestedAt) { + const pushed = row.lastCommitAt !== null && Date.parse(row.lastCommitAt) > Date.parse(row.changesRequestedAt); + if (!pushed && age(row.changesRequestedAt) > SETTLE_MS) { + add('CHANGES_NOT_ADDRESSED', row.changesRequestedAt, 'Address feedback'); + } + if (pushed && age(row.lastCommitAt as string) > SETTLE_MS) { + add('NEEDS_RE_REVIEW', row.lastCommitAt, 'Re-review', '/files'); + } + } + + const mergeable = (row.ci === 'success' || row.ci === null) && row.mergeable !== 'conflicting'; + if (row.bucket === 'approved' && row.approvedAt && mergeable && age(row.approvedAt) > SETTLE_MS) { + add('APPROVED_NOT_MERGED', row.approvedAt, 'Merge'); + } + + if (row.bucket === 'needs-review') { + // reviewCount is null when the details weren't fetched: no verdict then. + if (row.requestCount === 0 && row.reviewCount === 0 && age(row.createdAt) > NO_REVIEWERS_GRACE_MS) { + add('NO_REVIEWERS', row.createdAt, 'Request reviewers'); + } + if (row.requestCount > 0 && age(row.createdAt) > slow) add('WAITING_FOR_REVIEW', row.createdAt, 'Nudge reviewers'); + } + + if (age(row.updatedAt) > slow) add('STALE', row.updatedAt, 'Nudge'); + return out.sort((a, b) => a.severity - b.severity); +} + +/** How many open PRs aren't approved yet, in the range's last days; null otherwise. */ +export function sprintRisk(rows: PrRow[], ctx: AttentionContext): SprintRisk | null { + if (!ctx.rangeEnd) return null; + // Whole days from the local date today to the end date, both as UTC midnights. + const now = new Date(ctx.now); + const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); + const endsInDays = Math.round((Date.parse(ctx.rangeEnd) - today) / DAY); + if (endsInDays < 0 || endsInDays > SPRINT_RISK_DAYS) return null; + return { endsInDays, notApproved: rows.filter((r) => !r.isDraft && r.bucket !== 'approved').length }; +} + +/** The sweep with every open row judged afresh. Merged and queue rows never need attention. */ +export function annotate(result: SweepResult, ctx: AttentionContext): SweepResult { + return { + ...result, + open: result.open.map((row) => ({ ...row, attention: attention(row, ctx) })), + sprintRisk: sprintRisk(result.open, ctx), + }; +} diff --git a/desktop/src/main/core/github.service.ts b/desktop/src/main/core/github.service.ts index 6e66e50..0318dc8 100644 --- a/desktop/src/main/core/github.service.ts +++ b/desktop/src/main/core/github.service.ts @@ -309,6 +309,7 @@ export class GithubService { merged: merged.map((n) => toRow(n, 'merged')), // Queue rows resolve "when was *my* review requested" from the timeline. queue: queue.map((n) => toRow(n, bucketOf(n), login)), + sprintRisk: null, }; } @@ -367,6 +368,7 @@ export class GithubService { open: patch(base.open, open), merged: patch(base.merged, merged.map((n) => toRow(n, 'merged'))), queue: patch(base.queue, queue.map((n) => toRow(n, bucketOf(n), login))), + sprintRisk: null, }; } @@ -632,6 +634,8 @@ function toRow(n: SearchNode, bucket: ReviewBucket, viewer?: string, details?: D approvedAt: details?.approvedAt ?? null, changesRequestedAt: details?.changesRequestedAt ?? null, reviewCount: details?.reviewCount ?? null, + // Judged after the sweep by the attention engine (see annotate). + attention: [], }; } diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 63824ed..065535a 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -8,6 +8,7 @@ */ import { dialog, ipcMain, shell } from 'electron'; import { readFileSync, writeFileSync } from 'fs'; +import { annotate } from './core/attention'; import { activeProfile, ConfigService } from './core/config.service'; import { GithubService, SweepStats } from './core/github.service'; import { DEFAULT_OAUTH_CLIENT_ID } from './core/oauth.constants'; @@ -60,7 +61,13 @@ export function registerIpc(services: Services): void { // refreshes always resweep in full so the user has a recovery lever. const base = mode === 'auto' ? services.snapshots.get() : null; try { - const result = await services.github.sweep(services.config.get(), range, base); + const config = services.config.get(); + // Every sweep re-judges every open row, cached ones included. + const result = annotate(await services.github.sweep(config, range, base), { + now: Date.now(), + staleDays: activeProfile(config).staleDays, + rangeEnd: range.end, + }); services.snapshots.set(result); return result; } finally { diff --git a/desktop/src/shared/types.ts b/desktop/src/shared/types.ts index 7ea1966..a757ba9 100644 --- a/desktop/src/shared/types.ts +++ b/desktop/src/shared/types.ts @@ -58,6 +58,36 @@ export type SweepConfigPatch = Partial; export type ReviewBucket = 'needs-review' | 'changes-requested' | 'approved' | 'merged'; +export type AttentionReason = + | 'CI_FAILING' + | 'MERGE_CONFLICT' + | 'CHANGES_NOT_ADDRESSED' + | 'NEEDS_RE_REVIEW' + | 'APPROVED_NOT_MERGED' + | 'NO_REVIEWERS' + | 'WAITING_FOR_REVIEW' + | 'STALE' + | 'DRAFT_TOO_LONG'; + +/** Why an open PR needs a human, from the attention engine (core/attention.ts). */ +export interface Attention { + reason: AttentionReason; + /** 1 is the most severe; a row's attention list is sorted by it. */ + severity: number; + /** When the reason started to hold, as far as GitHub says. Null for merge conflicts. */ + since: string | null; + /** The next step's label, and the page it opens. */ + action: string; + href: string; +} + +/** Open PRs not yet approved, in the range's last days. */ +export interface SprintRisk { + /** 0 = the range ends today. */ + endsInDays: number; + notApproved: number; +} + export interface PrRow { repo: string; number: number; @@ -89,6 +119,8 @@ export interface PrRow { approvedAt: string | null; changesRequestedAt: string | null; reviewCount: number | null; + /** The attention engine's reasons, most severe first. Always empty on merged and queue rows. */ + attention: Attention[]; } /** @@ -96,7 +128,7 @@ export interface PrRow { * incremental refreshes keep cached rows until each PR changes on GitHub, so a * snapshot from an older build must be swept afresh, never painted or patched. */ -export const SWEEP_SCHEMA = 3; +export const SWEEP_SCHEMA = 4; export interface SweepResult { /** SWEEP_SCHEMA when this was written; absent in snapshots from before v0.11. */ @@ -109,6 +141,7 @@ export interface SweepResult { merged: PrRow[]; /** Open PRs org-wide with the signed-in user's review requested — any author, any age. */ queue: PrRow[]; + sprintRisk: SprintRisk | null; } /** Auto-update progress pushed from main; null = nothing in flight. */ From 21a58d1d17378e025738d687870a49f96ff73984 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 23:47:57 -0500 Subject: [PATCH 20/26] feat(board): Sweep section with each PR's reason, age and next step --- ROADMAP.md | 2 +- desktop/renderer/src/app/board.store.ts | 20 +++ .../renderer/src/app/pages/board.component.ts | 116 +++++++++++++++++- desktop/renderer/src/styles.css | 32 +++++ 4 files changed, 165 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c8333df..b0a9f5d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -227,7 +227,7 @@ the `release/v0.11` branch. Drafts only ever get reason 9, and `staleDays = 0` turns off reasons 7–9. The list is ordered by most severe reason, then longest-standing first. -- [ ] Sweep section at the top of the board: PR, CI, title (a link), why (the most severe reason +- [x] Sweep section at the top of the board: PR, CI, title (a link), why (the most severe reason and its age, plus chips for the others), author, next step and snooze. Rows aren't clickable as a whole. Author chips and the text filter apply. When the range ends within 2 days, the header says how many open PRs aren't approved yet. Empty state: "Nothing needs attention." diff --git a/desktop/renderer/src/app/board.store.ts b/desktop/renderer/src/app/board.store.ts index c8a3102..933ab83 100644 --- a/desktop/renderer/src/app/board.store.ts +++ b/desktop/renderer/src/app/board.store.ts @@ -64,6 +64,16 @@ export class BoardStore { .sort(byNewest((r) => r.updatedAt)); }); + /** + * Open PRs the attention engine flagged, with the author chips and text + * filter applied. Most severe first, then the longest-standing. + */ + readonly sweep = computed(() => + this.applyFilters((this.result()?.open ?? []).filter((r) => r.attention.length > 0)).sort(bySeverityThenAge), + ); + + readonly sprintRisk = computed(() => this.result()?.sprintRisk ?? null); + readonly needsReview = computed(() => this.slice('needs-review')); readonly changesRequested = computed(() => this.slice('changes-requested')); readonly approved = computed(() => this.slice('approved')); @@ -307,6 +317,11 @@ export class BoardStore { void this.api.openExternal(row.url); } + /** A next-step link: the PR itself or one of its tabs (checks, files). */ + openUrl(url: string): void { + void this.api.openExternal(url); + } + installUpdate(): void { void this.api.installUpdate(); } @@ -321,6 +336,11 @@ export class BoardStore { } } +function bySeverityThenAge(a: PrRow, b: PrRow): number { + const [x, y] = [a.attention[0], b.attention[0]]; + return x.severity - y.severity || (x.since ?? a.updatedAt).localeCompare(y.since ?? b.updatedAt); +} + function byNewest(key: (r: PrRow) => string): (a: PrRow, b: PrRow) => number { return (a, b) => key(b).localeCompare(key(a)); } diff --git a/desktop/renderer/src/app/pages/board.component.ts b/desktop/renderer/src/app/pages/board.component.ts index 1a22739..88f0d68 100644 --- a/desktop/renderer/src/app/pages/board.component.ts +++ b/desktop/renderer/src/app/pages/board.component.ts @@ -1,6 +1,18 @@ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { BoardStore } from '../board.store'; -import { PrRow } from '../models'; +import { Attention, AttentionReason, PrRow, SprintRisk } from '../models'; + +const REASON_LABELS: Record = { + CI_FAILING: 'CI failing', + MERGE_CONFLICT: 'Merge conflict', + CHANGES_NOT_ADDRESSED: 'Changes not addressed', + NEEDS_RE_REVIEW: 'Needs re-review', + APPROVED_NOT_MERGED: 'Approved, not merged', + NO_REVIEWERS: 'No reviewers', + WAITING_FOR_REVIEW: 'Waiting for review', + STALE: 'Stale', + DRAFT_TOO_LONG: 'Old draft', +}; interface BoardSection { title: string; @@ -14,9 +26,11 @@ interface BoardSection { /** * The dashboard: KPI counts, a filter toolbar (author toggles + free text), - * and one dense table per status — needs review / changes requested / - * approved / merged this sprint. All slicing is client-side over the store's - * fetched result; clicking a row opens the PR in the default browser. + * the Sweep (open PRs the attention engine flagged, each with its reason and + * next step), then one dense table per status — needs review / changes + * requested / approved / merged this sprint. All slicing is client-side over + * the store's fetched result; clicking a status row opens the PR in the + * default browser. */ @Component({ selector: 'app-board', @@ -75,6 +89,75 @@ interface BoardSection { /> +
+
+

Sweep ({{ store.sweep().length }})

+ @if (store.sprintRisk(); as risk) { +

{{ sprintLine(risk) }}

+ } +
+ @if (store.sweep().length > 0) { + + + + + + + + + + + + + + @for (pr of store.sweep(); track pr.url) { + + + + + + + + + } + +
PRCITitleWhyAuthorNext step
{{ pr.repo }}#{{ pr.number }} + @if (pr.ci; as ci) { + + } + + + @if (pr.isDraft) { + draft + } + + {{ label(pr.attention[0]) }} + @if (pr.attention[0].since; as since) { + · {{ age(since) }} + } + @for (other of pr.attention.slice(1); track other.reason) { + {{ label(other) }} + } + {{ pr.author }} + +
+ } @else { +

Nothing needs attention.

+ } +
+ @for (section of sections(); track section.title) {

{{ section.title }} ({{ section.rows.length }})

@@ -215,6 +298,31 @@ export class BoardComponent { return days > 0 && this.waitingDays(pr) >= days; } + label(a: Attention): string { + return REASON_LABELS[a.reason]; + } + + /** Color tier: red for CI and conflicts, amber for stuck reviews and merges, muted for waiting. */ + tier(a: Attention): 'hot' | 'warm' | 'cool' { + return a.severity <= 2 ? 'hot' : a.severity <= 5 ? 'warm' : 'cool'; + } + + /** How long a reason has held: "12m", "5h", "3d". */ + age(since: string): string { + const min = Math.max(0, Math.round((Date.now() - Date.parse(since)) / 60000)); + if (min < 60) return `${min}m`; + const h = Math.floor(min / 60); + return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`; + } + + sprintLine(risk: SprintRisk): string { + const when = + risk.endsInDays === 0 ? 'today' : risk.endsInDays === 1 ? 'tomorrow' : `in ${risk.endsInDays} days`; + if (risk.notApproved === 0) return `Sprint ends ${when}. Every open PR is approved.`; + const prs = risk.notApproved === 1 ? '1 open PR isn\'t' : `${risk.notApproved} open PRs aren't`; + return `Sprint ends ${when}: ${prs} approved yet.`; + } + ago(pr: PrRow): string { const min = Math.max(0, Math.round((Date.now() - Date.parse(pr.mergedAt ?? pr.updatedAt)) / 60000)); if (min < 1) return 'just now'; diff --git a/desktop/renderer/src/styles.css b/desktop/renderer/src/styles.css index 1c18934..941a10c 100644 --- a/desktop/renderer/src/styles.css +++ b/desktop/renderer/src/styles.css @@ -295,6 +295,38 @@ label { font-size: var(--fs-dense); color: var(--color-text-secondary); } .section h2 .muted { font-weight: var(--fw-normal); font-size: var(--fs-base); } .empty-note { font-size: var(--fs-dense); color: var(--color-text-muted); margin: var(--space-3) 0; } +/* ===== Sweep: open PRs the attention engine flagged ===== */ +.sweep-head { display: flex; align-items: baseline; gap: var(--space-5); flex-wrap: wrap; } +.sprint-risk { font-size: var(--fs-dense); font-weight: var(--fw-bold); color: var(--color-warning); margin: 0 0 var(--space-4) 0; } +td.reason { line-height: var(--lh-dense); } +.reason-label { font-weight: var(--fw-bold); } +.tier-hot { color: var(--color-error); } +.tier-warm { color: var(--color-warning); } +.tier-cool { color: var(--color-text-secondary); } +.reason-age { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; } +.reason-chip { + font-size: var(--fs-caption); + color: var(--color-text-secondary); + border: 1px solid var(--color-border-strong); + background: var(--color-bg-panel); + padding: 0 var(--space-2); + margin-left: var(--space-2); + white-space: nowrap; +} +th.next, td.next { text-align: right; white-space: nowrap; } +td.next button { font-size: var(--fs-dense); padding: 2px 8px; } +.link-button { + border: none; + background: transparent; + padding: 0; + font: inherit; + color: var(--color-link); + text-align: left; + cursor: pointer; +} +.link-button:hover { background: transparent; text-decoration: underline; } +.link-button:focus-visible { outline: 2px solid var(--color-link); outline-offset: 2px; } + .meta { font-size: var(--fs-dense); color: var(--color-text-secondary); margin: var(--space-4) 0; } .meta strong { color: var(--color-text); } From a7da585072ac136235af121f6048df921579370c Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 23:52:32 -0500 Subject: [PATCH 21/26] feat(board): snooze Sweep rows until they change, get worse, or the day ends --- AGENTS.md | 2 + ROADMAP.md | 4 +- desktop/renderer/src/app/board.store.ts | 75 ++++++++++++++++++- .../renderer/src/app/pages/board.component.ts | 46 +++++++++++- desktop/renderer/src/styles.css | 3 + 5 files changed, 124 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d1e3b32..5eddf02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,8 @@ organization. It ships for Windows and Linux. default there, must survive profile export/import, and needs a case in `config.service.test.mjs`. - Profile export never includes tokens or machine-level preferences. +- Sweep snoozes live in the renderer's `localStorage` (`prsweep-snoozes`), per machine, next to + the theme choice. They're never exported, and entries from earlier days are pruned. - Tokens are encrypted at rest with Electron `safeStorage` in `token.bin`. Never log a full token. - Don't rename the userData folder (`app.setName('pr-sweep')`) or the executable diff --git a/ROADMAP.md b/ROADMAP.md index b0a9f5d..5e756e3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -231,7 +231,9 @@ the `release/v0.11` branch. and its age, plus chips for the others), author, next step and snooze. Rows aren't clickable as a whole. Author chips and the text filter apply. When the range ends within 2 days, the header says how many open PRs aren't approved yet. Empty state: "Nothing needs attention." -- [ ] Snooze per row, plus "Show snoozed" to reveal and unsnooze. +- [x] Snooze per row, plus "Show snoozed" to reveal and unsnooze. *Checked through the built + app: hiding, surviving a relaunch, reveal and unsnooze, and each way a snooze ends (the PR + changes, a worse reason, a new day).* - [ ] Tray: a "N need attention (team)" menu line and tooltip fallback, with `attentionCount` added to `syncTray`. Settings: the stale-threshold help text mentions the Sweep. - [ ] `chore(release): v0.11.0`, then mark the pull request ready and merge it. Once it ships, update an installed diff --git a/desktop/renderer/src/app/board.store.ts b/desktop/renderer/src/app/board.store.ts index 933ab83..069d358 100644 --- a/desktop/renderer/src/app/board.store.ts +++ b/desktop/renderer/src/app/board.store.ts @@ -64,13 +64,20 @@ export class BoardStore { .sort(byNewest((r) => r.updatedAt)); }); + /** Snoozed Sweep rows by PR key. Per machine, never exported (see loadSnoozes). */ + private readonly snoozes = signal>(loadSnoozes()); + /** Reveal snoozed rows in the Sweep, muted, so they can be unsnoozed. */ + readonly showSnoozed = signal(false); + /** * Open PRs the attention engine flagged, with the author chips and text * filter applied. Most severe first, then the longest-standing. */ - readonly sweep = computed(() => + private readonly flagged = computed(() => this.applyFilters((this.result()?.open ?? []).filter((r) => r.attention.length > 0)).sort(bySeverityThenAge), ); + readonly sweep = computed(() => this.flagged().filter((r) => !this.isSnoozed(r))); + readonly snoozed = computed(() => this.flagged().filter((r) => this.isSnoozed(r))); readonly sprintRisk = computed(() => this.result()?.sprintRisk ?? null); @@ -317,6 +324,45 @@ export class BoardStore { void this.api.openExternal(row.url); } + /** + * A snooze holds while the PR is unchanged on GitHub, its worst reason is no + * worse than when it was snoozed, and it's still the same local day — so + * nothing stays hidden past the next standup. + */ + isSnoozed(row: PrRow): boolean { + const s = this.snoozes()[this.snoozeKey(row)]; + return ( + !!s && s.day === localDay() && s.updatedAt === row.updatedAt && row.attention[0]?.severity >= s.severity + ); + } + + snooze(row: PrRow): void { + const entry: Snooze = { updatedAt: row.updatedAt, severity: row.attention[0].severity, day: localDay() }; + this.saveSnoozes({ ...this.snoozes(), [this.snoozeKey(row)]: entry }); + } + + unsnooze(row: PrRow): void { + const next = { ...this.snoozes() }; + delete next[this.snoozeKey(row)]; + this.saveSnoozes(next); + } + + private snoozeKey(row: PrRow): string { + return `${this.result()?.org ?? ''}/${row.repo}#${row.number}`; + } + + /** Persist, dropping entries from earlier days: those can never hold again. */ + private saveSnoozes(all: Record): void { + const today = localDay(); + const kept = Object.fromEntries(Object.entries(all).filter(([, s]) => s.day === today)); + this.snoozes.set(kept); + try { + localStorage.setItem(SNOOZE_KEY, JSON.stringify(kept)); + } catch { + /* storage unavailable: snoozes last until the app restarts */ + } + } + /** A next-step link: the PR itself or one of its tabs (checks, files). */ openUrl(url: string): void { void this.api.openExternal(url); @@ -336,6 +382,33 @@ export class BoardStore { } } +interface Snooze { + /** The PR's updatedAt when snoozed; any change on GitHub ends the snooze. */ + updatedAt: string; + /** Its worst reason's severity when snoozed; a worse reason ends the snooze. */ + severity: number; + /** The local day it was snoozed; the snooze ends with it. */ + day: string; +} + +const SNOOZE_KEY = 'prsweep-snoozes'; + +function loadSnoozes(): Record { + try { + const all = JSON.parse(localStorage.getItem(SNOOZE_KEY) ?? '{}') as Record; + const today = localDay(); + return Object.fromEntries(Object.entries(all).filter(([, s]) => s?.day === today)); + } catch { + return {}; + } +} + +/** yyyy-mm-dd in local time: a snooze ends at local midnight, not UTC's. */ +function localDay(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + function bySeverityThenAge(a: PrRow, b: PrRow): number { const [x, y] = [a.attention[0], b.attention[0]]; return x.severity - y.severity || (x.since ?? a.updatedAt).localeCompare(y.since ?? b.updatedAt); diff --git a/desktop/renderer/src/app/pages/board.component.ts b/desktop/renderer/src/app/pages/board.component.ts index 88f0d68..24f9515 100644 --- a/desktop/renderer/src/app/pages/board.component.ts +++ b/desktop/renderer/src/app/pages/board.component.ts @@ -95,8 +95,18 @@ interface BoardSection { @if (store.sprintRisk(); as risk) {

{{ sprintLine(risk) }}

} + @if (store.snoozed().length > 0) { + + } - @if (store.sweep().length > 0) { + @if (sweepRows().length > 0) { @@ -110,8 +120,8 @@ interface BoardSection { - @for (pr of store.sweep(); track pr.url) { - + @for (pr of sweepRows(); track pr.url) { + }
{{ pr.repo }}#{{ pr.number }} @if (pr.ci; as ci) { @@ -148,13 +158,36 @@ interface BoardSection { > {{ pr.attention[0].action }} + @if (store.isSnoozed(pr)) { + + } @else { + + }
} @else { -

Nothing needs attention.

+

+ Nothing needs attention. + @if (store.snoozed().length > 0) { + ({{ store.snoozed().length }} snoozed) + } +

}
@@ -276,6 +309,11 @@ export class BoardComponent { }, ]); + /** Active Sweep rows, then the snoozed ones when they're revealed. */ + readonly sweepRows = computed(() => + this.store.showSnoozed() ? [...this.store.sweep(), ...this.store.snoozed()] : this.store.sweep(), + ); + authors(): string[] { return this.store.activeProfile()?.authors ?? []; } diff --git a/desktop/renderer/src/styles.css b/desktop/renderer/src/styles.css index 941a10c..dcbe259 100644 --- a/desktop/renderer/src/styles.css +++ b/desktop/renderer/src/styles.css @@ -315,6 +315,9 @@ td.reason { line-height: var(--lh-dense); } } th.next, td.next { text-align: right; white-space: nowrap; } td.next button { font-size: var(--fs-dense); padding: 2px 8px; } +td.next .snooze-btn { margin-left: var(--space-2); color: var(--color-text-secondary); } +tr.snoozed td:not(.next) { opacity: 0.55; } +.snooze-toggle { margin-left: auto; } .link-button { border: none; background: transparent; From be228e694e6bbc1a2234f7cb637b52f99072e75d Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 23:56:27 -0500 Subject: [PATCH 22/26] feat(tray): team attention line, tooltip fallback, and the Sweep in Settings help --- ROADMAP.md | 2 +- desktop/package.json | 2 +- desktop/renderer/src/app/board.store.ts | 28 ++++++++++++----- desktop/renderer/src/app/models.ts | 2 +- .../src/app/pages/settings.component.ts | 1 + desktop/renderer/src/styles.css | 1 + desktop/src/main/core/tray.text.test.mjs | 30 ++++++++++++++++++ desktop/src/main/core/tray.text.ts | 31 +++++++++++++++++++ desktop/src/main/tray.ts | 31 +++++++++---------- desktop/src/shared/types.ts | 6 ++-- 10 files changed, 103 insertions(+), 31 deletions(-) create mode 100644 desktop/src/main/core/tray.text.test.mjs create mode 100644 desktop/src/main/core/tray.text.ts diff --git a/ROADMAP.md b/ROADMAP.md index 5e756e3..5e356f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -234,7 +234,7 @@ the `release/v0.11` branch. - [x] Snooze per row, plus "Show snoozed" to reveal and unsnooze. *Checked through the built app: hiding, surviving a relaunch, reveal and unsnooze, and each way a snooze ends (the PR changes, a worse reason, a new day).* -- [ ] Tray: a "N need attention (team)" menu line and tooltip fallback, with `attentionCount` +- [x] Tray: a "N need attention (team)" menu line and tooltip fallback, with `attentionCount` added to `syncTray`. Settings: the stale-threshold help text mentions the Sweep. - [ ] `chore(release): v0.11.0`, then mark the pull request ready and merge it. Once it ships, update an installed v0.10.4 through the auto-updater and check that the first refresh fills every row without a diff --git a/desktop/package.json b/desktop/package.json index 9713924..585782e 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -7,7 +7,7 @@ "main": "dist/main/main/main.js", "scripts": { "build:main": "tsc -p tsconfig.json", - "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs && node src/main/core/snapshot.store.test.mjs && node src/main/core/attention.test.mjs", + "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs && node src/main/core/snapshot.store.test.mjs && node src/main/core/attention.test.mjs && node src/main/core/tray.text.test.mjs", "start": "electron .", "package": "electron-builder", "package:win": "electron-builder --win", diff --git a/desktop/renderer/src/app/board.store.ts b/desktop/renderer/src/app/board.store.ts index 069d358..612efe7 100644 --- a/desktop/renderer/src/app/board.store.ts +++ b/desktop/renderer/src/app/board.store.ts @@ -150,14 +150,7 @@ export class BoardStore { const result = await this.api.fetchPrs(range, opts.auto ? 'auto' : 'full'); this.result.set(result); this.error.set(null); - // Hand the tray its slices: the queue (counts + review-request toasts) - // and my own open PRs (approval / changes-requested / CI-failure toasts). - // Both use the raw result, not the filtered view, so background toasts - // don't depend on whatever author/text filter is active. - const needsReview = result.open.filter((r) => r.bucket === 'needs-review').length; - const login = this.auth()?.login; - const mine = login ? result.open.filter((r) => r.author === login) : []; - void this.api.syncTray({ queue: result.queue, mine, needsReviewCount: needsReview }); + this.syncTray(result); } catch (e) { // A background refresh failing (laptop offline) shouldn't blank a board // that's already showing data — surface quietly only for manual actions. @@ -356,6 +349,8 @@ export class BoardStore { const today = localDay(); const kept = Object.fromEntries(Object.entries(all).filter(([, s]) => s.day === today)); this.snoozes.set(kept); + const result = this.result(); + if (result) this.syncTray(result); // the tray's Sweep count leaves snoozed rows out try { localStorage.setItem(SNOOZE_KEY, JSON.stringify(kept)); } catch { @@ -363,6 +358,23 @@ export class BoardStore { } } + /** + * Hand the tray its slices: the queue (counts + review-request toasts), my + * own open PRs (approval / changes-requested / CI-failure toasts) and the + * counts behind its menu. All from the raw result, not the filtered view, so + * background toasts and counts don't depend on whatever author/text filter is + * active. Only snoozes, the user saying "not now", lower the Sweep count. + */ + private syncTray(result: SweepResult): void { + const login = this.auth()?.login; + void this.api.syncTray({ + queue: result.queue, + mine: login ? result.open.filter((r) => r.author === login) : [], + needsReviewCount: result.open.filter((r) => r.bucket === 'needs-review').length, + attentionCount: result.open.filter((r) => r.attention.length > 0 && !this.isSnoozed(r)).length, + }); + } + /** A next-step link: the PR itself or one of its tabs (checks, files). */ openUrl(url: string): void { void this.api.openExternal(url); diff --git a/desktop/renderer/src/app/models.ts b/desktop/renderer/src/app/models.ts index a02250e..48f1688 100644 --- a/desktop/renderer/src/app/models.ts +++ b/desktop/renderer/src/app/models.ts @@ -136,7 +136,7 @@ export interface PrSweepApi { */ fetchPrs(range: DateRange, mode?: 'full' | 'auto'): Promise; latestSweep(): Promise; - syncTray(sync: { queue: PrRow[]; mine: PrRow[]; needsReviewCount: number }): Promise; + syncTray(sync: { queue: PrRow[]; mine: PrRow[]; needsReviewCount: number; attentionCount: number }): Promise; openExternal(url: string): Promise; onUpdateState(cb: (state: UpdateState | null) => void): void; installUpdate(): Promise; diff --git a/desktop/renderer/src/app/pages/settings.component.ts b/desktop/renderer/src/app/pages/settings.component.ts index d3d802e..fcf7234 100644 --- a/desktop/renderer/src/app/pages/settings.component.ts +++ b/desktop/renderer/src/app/pages/settings.component.ts @@ -92,6 +92,7 @@ import { BoardStore } from '../board.store'; [value]="store.activeProfile()?.staleDays ?? 5" (change)="store.setStaleDays(+$any($event.target).value)" /> + Also sets when the Sweep flags PRs waiting for review, stale PRs and old drafts. diff --git a/desktop/renderer/src/styles.css b/desktop/renderer/src/styles.css index dcbe259..cf5f0b6 100644 --- a/desktop/renderer/src/styles.css +++ b/desktop/renderer/src/styles.css @@ -378,6 +378,7 @@ tr.snoozed td:not(.next) { opacity: 0.55; } .settings h2 { font-size: var(--fs-h3); } .field { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-4); } .field input { max-width: 260px; } +.field .hint { font-size: var(--fs-caption); color: var(--color-text-muted); } .check { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-3); font-size: var(--fs-dense); color: var(--color-text-secondary); } .check input { width: auto; } .tag-list { list-style: none; display: flex; gap: var(--space-3); flex-wrap: wrap; padding: 0; margin: var(--space-4) 0; } diff --git a/desktop/src/main/core/tray.text.test.mjs b/desktop/src/main/core/tray.text.test.mjs new file mode 100644 index 0000000..0156436 --- /dev/null +++ b/desktop/src/main/core/tray.text.test.mjs @@ -0,0 +1,30 @@ +/** + * Verifies the tray's wording: the tooltip says the most urgent thing, and the + * menu lists every count. Run after `npm run build:main`: + * node src/main/core/tray.text.test.mjs + */ +import assert from 'node:assert'; +import { trayMenuLines, trayTooltip } from '../../../dist/main/main/core/tray.text.js'; + +const counts = (patch) => ({ queue: 0, attention: 0, needsReview: 0, ...patch }); + +// --- the tooltip leads with what waits on you, then the team's Sweep, then reviews --- +assert.equal(trayTooltip(counts({ queue: 2, attention: 4, needsReview: 3 })), 'PR Sweep — 2 awaiting your review'); +assert.equal(trayTooltip(counts({ attention: 4, needsReview: 3 })), 'PR Sweep — 4 need attention'); +assert.equal(trayTooltip(counts({ attention: 1 })), 'PR Sweep — 1 needs attention'); +assert.equal(trayTooltip(counts({ needsReview: 3 })), 'PR Sweep — 3 need review'); +assert.equal(trayTooltip(counts({})), 'PR Sweep — nothing waiting'); + +// --- the menu lists all three, the team-wide ones labelled as such --- +assert.deepEqual(trayMenuLines(counts({ queue: 2, attention: 4, needsReview: 3 })), [ + '2 awaiting your review', + '4 need attention (team)', + '3 need review (team)', +]); +assert.deepEqual(trayMenuLines(counts({ attention: 1 })), [ + '0 awaiting your review', + '1 needs attention (team)', + '0 need review (team)', +]); + +console.log('tray.text: tooltip priority and menu lines pass'); diff --git a/desktop/src/main/core/tray.text.ts b/desktop/src/main/core/tray.text.ts new file mode 100644 index 0000000..3237db0 --- /dev/null +++ b/desktop/src/main/core/tray.text.ts @@ -0,0 +1,31 @@ +/** + * The tray's wording, kept apart from tray.ts (which needs Electron) so it's + * testable. The tooltip names the most urgent count: your review queue, then + * the team's Sweep, then PRs waiting for review. + */ + +export interface TrayCounts { + /** Open PRs org-wide with your review requested. */ + queue: number; + /** Sweep rows the user hasn't snoozed. */ + attention: number; + needsReview: number; +} + +const needAttention = (n: number): string => `${n} ${n === 1 ? 'needs' : 'need'} attention`; + +export function trayTooltip(c: TrayCounts): string { + const line = + c.queue > 0 + ? `${c.queue} awaiting your review` + : c.attention > 0 + ? needAttention(c.attention) + : c.needsReview > 0 + ? `${c.needsReview} need review` + : 'nothing waiting'; + return `PR Sweep — ${line}`; +} + +export function trayMenuLines(c: TrayCounts): string[] { + return [`${c.queue} awaiting your review`, `${needAttention(c.attention)} (team)`, `${c.needsReview} need review (team)`]; +} diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 06762ef..e4a1abe 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -9,6 +9,7 @@ import { app, Menu, nativeImage, Notification, shell, Tray } from 'electron'; import * as path from 'path'; import { ConfigService } from './core/config.service'; +import { TrayCounts, trayMenuLines, trayTooltip } from './core/tray.text'; import { PrRow } from '../shared/types'; export interface TraySync { @@ -16,6 +17,8 @@ export interface TraySync { /** The signed-in user's own open PRs — the author-side notification source. */ mine: PrRow[]; needsReviewCount: number; + /** Sweep rows the user hasn't snoozed. The badge ignores it: those are team-wide. */ + attentionCount: number; } const key = (pr: PrRow): string => `${pr.repo}#${pr.number}`; @@ -31,7 +34,7 @@ export class TrayController { private mineKnown: Map | null = null; private updateReady: { version: string; install: () => void } | null = null; /** Last synced counts so a menu rebuild outside sync() keeps them current. */ - private counts = { queue: 0, needsReview: 0 }; + private counts: TrayCounts = { queue: 0, attention: 0, needsReview: 0 }; constructor( private readonly config: ConfigService, @@ -48,10 +51,10 @@ export class TrayController { this.tray = new Tray(this.iconIdle.resize({ width: 16, height: 16 })); this.tray.setToolTip('PR Sweep'); this.tray.on('click', this.showWindow); - this.render(0, 0); + this.render(this.counts); } - sync({ queue, mine, needsReviewCount }: TraySync): void { + sync({ queue, mine, needsReviewCount, attentionCount }: TraySync): void { const toastable = this.config.get().notifications && Notification.isSupported(); const keys = new Set(queue.map(key)); if (this.known && toastable) { @@ -78,7 +81,7 @@ export class TrayController { } this.mineKnown = new Map(rows.map((pr) => [key(pr), { bucket: pr.bucket, ci: pr.ci }])); - this.render(queue.length, needsReviewCount); + this.render({ queue: queue.length, attention: attentionCount ?? 0, needsReview: needsReviewCount }); } private notify(title: string, pr: PrRow): void { @@ -97,22 +100,17 @@ export class TrayController { the only surface a close-to-tray user reliably sees. */ setUpdateReady(version: string, install: () => void): void { this.updateReady = { version, install }; - this.render(this.counts.queue, this.counts.needsReview); + this.render(this.counts); } - private render(queueCount: number, needsReviewCount: number): void { + private render(counts: TrayCounts): void { if (!this.tray) return; - this.counts = { queue: queueCount, needsReview: needsReviewCount }; + this.counts = counts; + // The badge stays yours: it lights up for your review queue only. this.tray.setImage( - (queueCount > 0 ? this.iconAlert : this.iconIdle).resize({ width: 16, height: 16 }), + (counts.queue > 0 ? this.iconAlert : this.iconIdle).resize({ width: 16, height: 16 }), ); - const line = - queueCount > 0 - ? `${queueCount} awaiting your review` - : needsReviewCount > 0 - ? `${needsReviewCount} need review` - : 'nothing waiting'; - this.tray.setToolTip(`PR Sweep — ${line}`); + this.tray.setToolTip(trayTooltip(counts)); const update: Electron.MenuItemConstructorOptions[] = this.updateReady ? [ { label: `Restart to update (v${this.updateReady.version})`, click: this.updateReady.install }, @@ -122,8 +120,7 @@ export class TrayController { this.tray.setContextMenu( Menu.buildFromTemplate([ ...update, - { label: `${queueCount} awaiting your review`, enabled: false }, - { label: `${needsReviewCount} need review (team)`, enabled: false }, + ...trayMenuLines(counts).map((label) => ({ label, enabled: false })), { type: 'separator' }, { label: 'Open PR Sweep', click: this.showWindow }, { label: 'Quit', click: this.quit }, diff --git a/desktop/src/shared/types.ts b/desktop/src/shared/types.ts index a757ba9..db189a3 100644 --- a/desktop/src/shared/types.ts +++ b/desktop/src/shared/types.ts @@ -181,10 +181,10 @@ export interface PrSweepApi { latestSweep(): Promise; /** * Push the latest sweep's tray-relevant slices: the review queue (counts + - * review-request toasts) and the viewer's own open PRs (approval / changes- - * requested / CI-failure toasts). + * review-request toasts), the viewer's own open PRs (approval / changes- + * requested / CI-failure toasts), and the counts behind the menu lines. */ - syncTray(sync: { queue: PrRow[]; mine: PrRow[]; needsReviewCount: number }): Promise; + syncTray(sync: { queue: PrRow[]; mine: PrRow[]; needsReviewCount: number; attentionCount: number }): Promise; openExternal(url: string): Promise; /** Subscribe to auto-update state pushes (download progress, ready-to-restart). */ onUpdateState(cb: (state: UpdateState | null) => void): void; From c221643eaa9704f9f4e2f4ae35f36693486e63e6 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sun, 27 Sep 2026 00:02:00 -0500 Subject: [PATCH 23/26] feat(attention): quiet rows keep the Sweep short --- ROADMAP.md | 7 ++++ desktop/renderer/src/app/board.store.ts | 12 ++++-- desktop/renderer/src/app/models.ts | 1 + .../renderer/src/app/pages/board.component.ts | 40 +++++++++++++++---- desktop/renderer/src/styles.css | 4 +- desktop/src/main/core/attention.test.mjs | 19 ++++++++- desktop/src/main/core/attention.ts | 19 ++++++++- desktop/src/main/core/github.service.ts | 1 + desktop/src/shared/types.ts | 4 +- 9 files changed, 91 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5e356f8..68d982d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -94,6 +94,11 @@ what to do next. author's (address the feedback) and the reviewer's (re-review after the push). - **Sprint-end risk is a header line, not a row reason.** As a reason it would flag nearly every unapproved PR in the last two days, when the list should be shortest. +- **Quiet rows keep the Sweep short** (decided 2026-09-27). A flagged row goes quiet when its + worst reason is a slow one (waiting, stale, old draft) or nobody has touched the PR in 30+ + days. Quiet rows sit behind "Show quiet", muted, and the tray count leaves them out. On the + electron team profile the Sweep went from 25 rows to 13. Built after a live sweep showed 25 + of 32 open PRs flagged, with abandoned PRs burying the ones someone would act on this sprint. - **Drafts only get `DRAFT_TOO_LONG`.** There are no new settings: fixed thresholds are constants, and the slow ones use the profile's `staleDays`. - **Snooze** hides a row until the PR updates, gains a more severe reason, or the next day comes. @@ -234,6 +239,8 @@ the `release/v0.11` branch. - [x] Snooze per row, plus "Show snoozed" to reveal and unsnooze. *Checked through the built app: hiding, surviving a relaunch, reveal and unsnooze, and each way a snooze ends (the PR changes, a worse reason, a new day).* +- [x] Quiet rows: `isQuiet` in the engine, `quiet` on each row (schema 5), and a "Show quiet" + toggle. The tray count leaves them out. - [x] Tray: a "N need attention (team)" menu line and tooltip fallback, with `attentionCount` added to `syncTray`. Settings: the stale-threshold help text mentions the Sweep. - [ ] `chore(release): v0.11.0`, then mark the pull request ready and merge it. Once it ships, update an installed diff --git a/desktop/renderer/src/app/board.store.ts b/desktop/renderer/src/app/board.store.ts index 612efe7..6ffc1ba 100644 --- a/desktop/renderer/src/app/board.store.ts +++ b/desktop/renderer/src/app/board.store.ts @@ -68,6 +68,8 @@ export class BoardStore { private readonly snoozes = signal>(loadSnoozes()); /** Reveal snoozed rows in the Sweep, muted, so they can be unsnoozed. */ readonly showSnoozed = signal(false); + /** Reveal quiet rows (see isQuiet in core/attention.ts), muted. */ + readonly showQuiet = signal(false); /** * Open PRs the attention engine flagged, with the author chips and text @@ -76,8 +78,9 @@ export class BoardStore { private readonly flagged = computed(() => this.applyFilters((this.result()?.open ?? []).filter((r) => r.attention.length > 0)).sort(bySeverityThenAge), ); - readonly sweep = computed(() => this.flagged().filter((r) => !this.isSnoozed(r))); - readonly snoozed = computed(() => this.flagged().filter((r) => this.isSnoozed(r))); + readonly sweep = computed(() => this.flagged().filter((r) => !r.quiet && !this.isSnoozed(r))); + readonly snoozed = computed(() => this.flagged().filter((r) => !r.quiet && this.isSnoozed(r))); + readonly quiet = computed(() => this.flagged().filter((r) => r.quiet)); readonly sprintRisk = computed(() => this.result()?.sprintRisk ?? null); @@ -363,7 +366,8 @@ export class BoardStore { * own open PRs (approval / changes-requested / CI-failure toasts) and the * counts behind its menu. All from the raw result, not the filtered view, so * background toasts and counts don't depend on whatever author/text filter is - * active. Only snoozes, the user saying "not now", lower the Sweep count. + * active. Only quiet rows and snoozes, the user saying "not now", lower the + * Sweep count. */ private syncTray(result: SweepResult): void { const login = this.auth()?.login; @@ -371,7 +375,7 @@ export class BoardStore { queue: result.queue, mine: login ? result.open.filter((r) => r.author === login) : [], needsReviewCount: result.open.filter((r) => r.bucket === 'needs-review').length, - attentionCount: result.open.filter((r) => r.attention.length > 0 && !this.isSnoozed(r)).length, + attentionCount: result.open.filter((r) => r.attention.length > 0 && !r.quiet && !this.isSnoozed(r)).length, }); } diff --git a/desktop/renderer/src/app/models.ts b/desktop/renderer/src/app/models.ts index 48f1688..45bf698 100644 --- a/desktop/renderer/src/app/models.ts +++ b/desktop/renderer/src/app/models.ts @@ -93,6 +93,7 @@ export interface PrRow { changesRequestedAt: string | null; reviewCount: number | null; attention: Attention[]; + quiet: boolean; } export interface SweepResult { diff --git a/desktop/renderer/src/app/pages/board.component.ts b/desktop/renderer/src/app/pages/board.component.ts index 24f9515..21fa1f5 100644 --- a/desktop/renderer/src/app/pages/board.component.ts +++ b/desktop/renderer/src/app/pages/board.component.ts @@ -95,6 +95,17 @@ interface BoardSection { @if (store.sprintRisk(); as risk) {

{{ sprintLine(risk) }}

} + @if (store.quiet().length > 0) { + + } @if (store.snoozed().length > 0) { - @if (store.isSnoozed(pr)) { + @if (pr.quiet) { + + } @else if (store.isSnoozed(pr)) {