diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f44f2e..688cc40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ name: Release # AppImage, then publishes one GitHub release with all of them. latest.yml and # latest-linux.yml (+ blockmaps) are what electron-updater reads to self-update # installed copies — they must ship with every release. +# +# The release body is the hand-written docs/releases/.md. A tag without +# one fails before anything builds: every release says what changed. on: push: tags: ['v*'] @@ -12,7 +15,20 @@ permissions: contents: write jobs: + notes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Release notes exist + run: | + f="docs/releases/${{ github.ref_name }}.md" + if [ ! -s "$f" ]; then + echo "::error file=$f::Missing $f. Every release needs hand-written notes (see AGENTS.md > Releases)." + exit 1 + fi + build: + needs: notes strategy: fail-fast: false matrix: @@ -60,6 +76,9 @@ jobs: needs: build runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: docs/releases - uses: actions/download-artifact@v4 with: path: dist @@ -70,5 +89,5 @@ jobs: run: | gh release create "${{ github.ref_name }}" dist/* \ --repo "${{ github.repository }}" \ - --title "${{ github.ref_name }}" \ - --generate-notes + --title "PR Sweep ${{ github.ref_name }}" \ + --notes-file "docs/releases/${{ github.ref_name }}.md" diff --git a/AGENTS.md b/AGENTS.md index 1d056f7..1582a7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,18 +38,24 @@ 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. ## 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`. - 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 @@ -58,8 +64,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. @@ -92,13 +100,32 @@ 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 - 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. 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)`. +- **Every release has hand-written notes** in `docs/releases/vX.Y.Z.md`, committed with the + version bump. The release workflow publishes that file as the release body and fails before + building if it's missing. Write them for a reader skimming on a phone: + - Open with one bold **TL;DR:** line saying what changed and why it matters. + - Then short sections, in this order, only when they have something: `## ✨ New`, + `## ⚡ Faster`, `## 🐛 Fixed`, `## 👀 Heads up` (anything a user might trip over), and + `## ⬆️ Getting it`. + - One line per bullet, starting with a **bold** phrase. Plain words, user-visible effects, + real numbers when there are some. No commit hashes, no internals, no paragraphs. - 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. diff --git a/README.md b/README.md index 8ff2e32..053a948 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ own dashboard shows what's waiting on *you*, not on the team. PR Sweep puts the on one board, sorted by GitHub's own review state, so nobody maintains labels or a project board. Set the org, the team and the sprint's dates once. It refreshes every five minutes from the tray. -> **Status:** v0.10, in daily use. Windows and Linux builds are on [Releases](../../releases) and -> update themselves. Next is a Sweep list that says why each stuck PR needs attention and what to -> do about it. See [ROADMAP.md](ROADMAP.md). +> **Status:** v0.11, in daily use. Windows and Linux builds are on [Releases](../../releases) and +> update themselves. Next is a sprint summary with a standup you can paste into chat. See +> [ROADMAP.md](ROADMAP.md). @@ -60,14 +60,22 @@ 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. +- **Sweeps** the team's open PRs for the ones that need a human: failing CI, merge conflicts, + feedback nobody has addressed, pushes waiting on a re-review, approvals nobody merged, and PRs + nobody was asked to review. Each row says how long it has been that way, with a one-click next + step. PRs that are only waiting or stale, or untouched for a month, sit behind a toggle, and + you can snooze a row until it changes or tomorrow. In a sprint's last two days, it also says how + many open PRs aren't approved yet. +- **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. Drafts stay hidden unless you show them. - **Notifies** from the tray when a PR lands in your queue, or when one of yours is approved, gets - changes requested or starts failing CI. Closing the window keeps it watching. + changes requested or starts failing CI. The tray menu also counts what the Sweep has for the + team. Closing the window keeps it watching. - **Shares** a setup. Save org, team and date-range profiles, then export them as JSON for teammates to import. Tokens are never exported. - **Opens instantly.** The last sweep is cached on disk, so the board appears at once and @@ -113,6 +121,12 @@ GitHub search has quirks, all checked against the live API. PR Sweep works aroun reliable check is whether the org's repositories are visible at all. - Search returns at most 1000 results per query, however you paginate. When a busy range would pass that, PR Sweep splits the date window in half and searches the halves. +- Paging through one long search is slow, so merged PRs are searched a week at a time, four + weeks in parallel. +- Heavy searches can time out, as a 502 or as a 200 with a cut-off body. Those pages are re-sent + at half the size, down to 25 rows, instead of being repeated as-is. +- Mergeability and review times are too costly to ask for on every searched PR. They come from + one follow-up query, only for the PRs whose attention depends on them. - Auto-refreshes ask only for PRs updated since the last sweep and patch the cached result. A manual Refresh always sweeps in full. diff --git a/ROADMAP.md b/ROADMAP.md index c57b673..4157ab5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,6 +41,81 @@ 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. + +## Decisions (2026-09-28) + +- **Every release has hand-written notes** in `docs/releases/vX.Y.Z.md`: a bold TL;DR line, then + short one-line bullets under New, Faster, Fixed, Heads up and Getting it. The release workflow + publishes the file as the release body and fails before building if it's missing. GitHub's + generated notes were just a compare link. + +## 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. +- **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. 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. +- **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, + 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 + 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. + 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 +185,79 @@ 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. + +- [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. +- [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. +- [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.* +- [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.* +- [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: [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/package.json b/desktop/package.json index 1c2c0ab..5e00b03 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,13 +1,13 @@ { "name": "pr-sweep-desktop", - "version": "0.10.4", + "version": "0.11.0", "private": true, "author": "hazeliscoding", "description": "PR Sweep — a desktop PR dashboard for teams working across a GitHub organization.", "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 && 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 9ce1b47..6ffc1ba 100644 --- a/desktop/renderer/src/app/board.store.ts +++ b/desktop/renderer/src/app/board.store.ts @@ -64,6 +64,26 @@ 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); + /** 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 + * filter applied. Most severe first, then the longest-standing. + */ + private readonly flagged = computed(() => + this.applyFilters((this.result()?.open ?? []).filter((r) => r.attention.length > 0)).sort(bySeverityThenAge), + ); + 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); + readonly needsReview = computed(() => this.slice('needs-review')); readonly changesRequested = computed(() => this.slice('changes-requested')); readonly approved = computed(() => this.slice('approved')); @@ -133,14 +153,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. @@ -150,6 +163,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 }); @@ -297,6 +320,70 @@ 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); + 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 { + /* storage unavailable: snoozes last until the app restarts */ + } + } + + /** + * 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 quiet rows and 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 && !r.quiet && !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); + } + installUpdate(): void { void this.api.installUpdate(); } @@ -311,6 +398,38 @@ 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); +} + 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/models.ts b/desktop/renderer/src/app/models.ts index 1200c11..45bf698 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; @@ -60,15 +86,25 @@ 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; + attention: Attention[]; + quiet: boolean; } export interface SweepResult { + schema: number; fetchedAt: string; org: string; range: DateRange; open: PrRow[]; merged: PrRow[]; queue: PrRow[]; + sprintRisk: SprintRisk | null; } /** Auto-update progress pushed from main; null = nothing in flight. */ @@ -101,7 +137,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/board.component.ts b/desktop/renderer/src/app/pages/board.component.ts index 1a22739..21fa1f5 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,121 @@ interface BoardSection { /> +
+
+

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

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

{{ sprintLine(risk) }}

+ } + @if (store.quiet().length > 0) { + + } + @if (store.snoozed().length > 0) { + + } +
+ @if (sweepRows().length > 0) { + + + + + + + + + + + + + + @for (pr of sweepRows(); 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 }} + + @if (pr.quiet) { + + } @else if (store.isSnoozed(pr)) { + + } @else { + + } +
+ } @else { +

+ Nothing needs attention. + @if (hiddenNote(); as note) { + ({{ note }}) + } +

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

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

@@ -193,6 +322,22 @@ export class BoardComponent { }, ]); + /** Active Sweep rows, then the snoozed and quiet ones when they're revealed. */ + readonly sweepRows = computed(() => [ + ...this.store.sweep(), + ...(this.store.showSnoozed() ? this.store.snoozed() : []), + ...(this.store.showQuiet() ? this.store.quiet() : []), + ]); + + /** "2 snoozed, 12 quiet" for the empty state; null when nothing is hidden. */ + readonly hiddenNote = computed(() => { + const parts = [ + this.store.snoozed().length ? `${this.store.snoozed().length} snoozed` : '', + this.store.quiet().length ? `${this.store.quiet().length} quiet` : '', + ].filter(Boolean); + return parts.length ? parts.join(', ') : null; + }); + authors(): string[] { return this.store.activeProfile()?.authors ?? []; } @@ -215,6 +360,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/app/pages/settings.component.ts b/desktop/renderer/src/app/pages/settings.component.ts index 4a1091e..fcf7234 100644 --- a/desktop/renderer/src/app/pages/settings.component.ts +++ b/desktop/renderer/src/app/pages/settings.component.ts @@ -90,8 +90,9 @@ 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)" /> + 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 1c18934..09c9a6a 100644 --- a/desktop/renderer/src/styles.css +++ b/desktop/renderer/src/styles.css @@ -295,6 +295,43 @@ 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; } +td.next .snooze-btn { margin-left: var(--space-2); color: var(--color-text-secondary); } +tr.snoozed td:not(.next), tr.quiet td:not(.next) { opacity: 0.55; } +.quiet-toggle { margin-left: auto; } +.quiet-toggle + .snooze-toggle { margin-left: 0; } +.snooze-toggle { margin-left: auto; } +.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); } @@ -343,6 +380,7 @@ label { font-size: var(--fs-dense); color: var(--color-text-secondary); } .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/attention.test.mjs b/desktop/src/main/core/attention.test.mjs new file mode 100644 index 0000000..f951787 --- /dev/null +++ b/desktop/src/main/core/attention.test.mjs @@ -0,0 +1,212 @@ +/** + * 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, isQuiet, 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: [], + quiet: false, + ...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']); +} + +// --- quiet: the worst reason is a slow one, or nobody touched the PR in 30+ days --- +{ + const quiet = (patch) => { + const row = pr(patch); + return isQuiet(row, attention(row, ctx()), ctx()); + }; + assert.equal(quiet({ createdAt: ago(6 * 24) }), true, 'only waiting for review'); + assert.equal(quiet({ updatedAt: ago(6 * 24), ci: 'pending' }), true, 'only stale'); + assert.equal(quiet({ isDraft: true, createdAt: ago(6 * 24) }), true, 'old draft'); + assert.equal(quiet({ ci: 'failure', updatedAt: ago(31 * 24) }), true, 'failing, but untouched for a month'); + assert.equal(quiet({ ci: 'failure', updatedAt: ago(29 * 24) }), false, 'failing and touched within the month'); + assert.equal(quiet({ ci: 'failure', updatedAt: ago(1) }), false); + assert.equal(quiet({}), false, 'nothing flagged, nothing quiet'); +} + +// --- 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.open.map((r) => r.quiet), [false, false]); + 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..7a4a5c3 --- /dev/null +++ b/desktop/src/main/core/attention.ts @@ -0,0 +1,124 @@ +/** + * 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; +/** Waiting, stale and old draft: reasons that only say time has passed. */ +const SLOW_SEVERITY = ORDER.indexOf('WAITING_FOR_REVIEW') + 1; +/** Untouched this long, a PR is unlikely to get action this sprint, whatever its reasons. */ +const QUIET_IDLE_MS = 30 * DAY; + +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); +} + +/** + * Quiet rows sit behind a toggle in the Sweep and stay out of the tray count, so + * the main list stays short: the worst reason is a slow one, or nobody has + * touched the PR in a month. + */ +export function isQuiet(row: PrRow, reasons: Attention[], ctx: AttentionContext): boolean { + if (reasons.length === 0) return false; + return reasons[0].severity >= SLOW_SEVERITY || ctx.now - Date.parse(row.updatedAt) > QUIET_IDLE_MS; +} + +/** 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) => { + const reasons = attention(row, ctx); + return { ...row, attention: reasons, quiet: isQuiet(row, reasons, ctx) }; + }), + sprintRisk: sprintRisk(result.open, ctx), + }; +} diff --git a/desktop/src/main/core/github.service.test.mjs b/desktop/src/main/core/github.service.test.mjs index cfdf618..a57cb8b 100644 --- a/desktop/src/main/core/github.service.test.mjs +++ b/desktop/src/main/core/github.service.test.mjs @@ -6,9 +6,11 @@ */ 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 { + id: `n${number}`, number, title: `pr ${number}`, url: `https://github.com/o/r/pull/${number}`, @@ -89,10 +91,11 @@ 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; + if (q.includes('updated:<')) queries.carried = q; + else queries.open = q; return json( page([ node(1, 'APPROVED'), @@ -117,7 +120,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; @@ -138,10 +143,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 --- @@ -216,7 +231,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) => { @@ -226,27 +241,48 @@ 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 = { 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 +314,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 +340,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 +365,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 +467,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,4 +500,413 @@ 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') || 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 () => ({}) }; + 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') || 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 () => ({}) }; + 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') || body.variables.q.includes('updated:<')) 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'); +} + +// --- 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') || body.variables.q.includes('updated:<')) 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'); +} + +// --- 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 }); + 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; + 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(), + schema: SWEEP_SCHEMA, + 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..82a22fd 100644 --- a/desktop/src/main/core/github.service.ts +++ b/desktop/src/main/core/github.service.ts @@ -16,21 +16,44 @@ * 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 + * 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 * 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'; // 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; +/** + * 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; @@ -45,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 = ` @@ -70,8 +94,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 { @@ -87,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; @@ -101,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; }; }>; @@ -114,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; @@ -122,6 +179,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 +233,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 +244,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,29 +274,47 @@ 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); 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), + // 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), ]); return { + schema: SWEEP_SCHEMA, 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)), + sprintRisk: null, }; } 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; @@ -232,8 +333,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`), @@ -251,7 +351,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), ]); @@ -261,12 +361,14 @@ 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, - 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))), + sprintRisk: null, }; } @@ -293,27 +395,62 @@ 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]); + } + + /** 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]) => + 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 }> { 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. @@ -324,9 +461,17 @@ 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++; + if (attempt > 0) this.counters.retries++; let res: { ok: boolean; status: number; @@ -346,21 +491,35 @@ 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)); - return this.graphql(query, variables, attempt + 1); + 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.'); if (!res.ok) { + 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. 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); + 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}`); } - 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 this.pause('truncated response', 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); @@ -369,8 +528,8 @@ 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)); - return this.graphql(query, variables, attempt + 1); + 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}`); } @@ -391,11 +550,20 @@ 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; } } +/** GitHub couldn't answer a search page in time; the page should be re-sent smaller. */ +class GithubTimeout extends Error {} + function bucketOf(n: SearchNode): ReviewBucket { switch (n.reviewDecision) { case 'APPROVED': @@ -409,7 +577,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, @@ -426,10 +624,19 @@ 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, + // Judged after the sweep by the attention engine (see annotate). + attention: [], + quiet: false, }; } @@ -463,6 +670,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; } 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/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/ipc.ts b/desktop/src/main/ipc.ts index d29b8eb..065535a 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -8,8 +8,9 @@ */ 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 } 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 +60,19 @@ 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 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 { + if (process.env.PRSWEEP_DEBUG) logSweep(mode ?? 'full', services.github.lastSweep); + } }); ipcMain.handle('prs:latest', () => services.snapshots.get()); @@ -105,6 +116,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; 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 1c044fd..7c24e7a 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; @@ -73,15 +103,38 @@ 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; + /** The attention engine's reasons, most severe first. Always empty on merged and queue rows. */ + attention: Attention[]; + /** Flagged, but only for slow reasons or after a month untouched: shown behind a toggle. */ + quiet: boolean; } +/** + * 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 = 5; + 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; @@ -90,6 +143,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. */ @@ -129,10 +183,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; diff --git a/docs/releases/v0.11.0.md b/docs/releases/v0.11.0.md new file mode 100644 index 0000000..e23ea06 --- /dev/null +++ b/docs/releases/v0.11.0.md @@ -0,0 +1,33 @@ +**TL;DR:** A new **Sweep** list at the top of the board shows which PRs need a human, why, and what to click next. Full refreshes are **about 3× faster**, and whole-org views on big orgs load instead of failing. + +## ✨ New + +- **Sweep list**: every stuck PR, with the reason and how long it's been stuck. +- **One-click next step** on each row: Fix CI, Resolve conflict, Re-review, Merge, Request reviewers, Nudge. +- **Snooze** a row until the PR changes, gets worse, or tomorrow. +- **Quiet rows**: PRs that are only waiting or stale, or untouched for a month, sit behind **Show quiet**. +- **Sprint-end warning** in a sprint's last 2 days: "N open PRs aren't approved yet." +- **Tray menu** counts what needs the team's attention. +- **Open means open**: PRs carried over from before your date range now show up. +- **Team review requests** show in the Awaiting column, like `acme/platform`. + +## ⚡ Faster + +- Full refresh on a 5-person team: **28.9 s → 9.1 s**. +- Merged PRs load a week at a time, 4 weeks in parallel. + +## 🐛 Fixed + +- Whole-org views on big orgs no longer fail with `HTTP 502`. +- A cut-off response from GitHub is retried instead of breaking the refresh. + +## 👀 Heads up + +- **First refresh after updating is a full one**: the cache format changed. A few seconds longer, once. +- **Counts may go up**: open PRs from before your date range are included now. +- **Whole-org views on very large orgs are still slow** (a couple of minutes): GitHub rate-limits the searches. +- **The sprint-end warning needs a To date** in the header. + +## ⬆️ Getting it + +Installed copies update themselves. When the header says the update is ready, click **Restart**.