Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
887e581
docs(roadmap): plan v0.13 Sprint summary
hazeliscoding Sep 29, 2026
be97d39
docs(agents): sprint numbers are the team's, and the standup reads th…
hazeliscoding Sep 29, 2026
ed95144
docs(roadmap): record the v0.13 paste test in Teams and Discord
hazeliscoding Sep 29, 2026
9774b20
feat(summary): the health strip tells the sprint's story, worked out …
hazeliscoding Sep 29, 2026
a8f20f0
feat(standup): build the standup in main and put it on the clipboard …
hazeliscoding Sep 29, 2026
fbd67c4
feat(ui): Copy standup in the health strip, with a confirmation of wh…
hazeliscoding Sep 29, 2026
09d5292
docs(roadmap): tick the v0.13 summary, strip, standup and Copy standup
hazeliscoding Sep 29, 2026
c0cda7c
docs(readme): the sprint summary and Copy standup, with new screenshots
hazeliscoding Sep 29, 2026
9050a59
docs(agents): UI scripts that copy a standup stub the clipboard
hazeliscoding Sep 29, 2026
246b011
docs(roadmap): tick the v0.13 screenshots, README, keyboard pass and …
hazeliscoding Sep 29, 2026
1d088d3
docs(roadmap): the portable exe's update notice joins v0.13
hazeliscoding Oct 7, 2026
8b44093
feat(update): the portable exe says when a new version is out
hazeliscoding Oct 7, 2026
4fade81
test(fixtures): an update-portable state for the screenshots
hazeliscoding Oct 7, 2026
9c5be6e
docs(readme): the portable exe tells you when a new version is out
hazeliscoding Oct 7, 2026
ce4edac
docs(roadmap): tick the portable exe's update notice
hazeliscoding Oct 7, 2026
e811388
docs(readme): status v0.13
hazeliscoding Oct 7, 2026
1b65efd
chore(release): v0.13.0
hazeliscoding Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ organization. It ships for Windows and Linux.

- 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.
included. The Sweep section, the tray line, the sprint summary and the standup read its
output; the renderer never decides on its own whether a PR needs attention.
- Sprint numbers are the team's: the health strip and the standup ignore author chips, search
and snoozes, which only shape the tables and the Sweep.
- Sprints are computed, never stored: a profile keeps a schedule (`sprints`) and a `period`
(`'current'`, a pinned `{ sprint }` or `'custom'`), and `core/sprints.ts` resolves them against
today through `period:resolve`. The renderer never does sprint date math itself.
Expand Down Expand Up @@ -133,7 +135,9 @@ Run these from the repo root unless noted.
`GH_TOKEN=$(gh auth token) PRSWEEP_ORG=<org> node e2e/screenshot.mjs` shoots the live board.
`node e2e/screenshot.mjs --readme` renders the README images (`docs/screenshots/`) from `busy`.
Every run uses a throwaway `--user-data-dir` and aborts unless the app really uses it; never
point a UI script at the real data folder (Chromium ignores `%APPDATA%` overrides).
point a UI script at the real data folder (Chromium ignores `%APPDATA%` overrides). A script
that presses Copy standup stubs `clipboard.write` in main first, so it never overwrites the
user's clipboard.
- `PRSWEEP_FIXTURE=<name>` makes an unpackaged build serve canned sweeps from
`desktop/e2e/fixtures/<name>.json` instead of GitHub, still judged by the real attention
engine. Fixture times are relative (`"-3d"`, `"+2d"`); rows list only what matters and the
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,22 @@ 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 your sprint schedule once. It refreshes every five minutes from the tray.

> **Status:** v0.12, 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
> **Status:** v0.13, in daily use. Windows and Linux builds are on [Releases](../../releases) and
> update themselves. Next is grouping PRs that span repos by their ticket key. See
> [ROADMAP.md](ROADMAP.md).

<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/board-dark.png">
<img alt="The board for a sample team in Sprint 24: a strip of counts for My queue, Needs review, Changes requested, Approved and Merged, author filter chips, the Sweep listing each stuck PR with its CI status, why it's stuck, for how long and a next-step button, then a table per section" src="docs/screenshots/board.png">
<img alt="The board for a sample team in Sprint 24: a strip with days left, open PRs, PRs needing attention, merged PRs, median time to merge and a Copy standup button, then author filter chips, the Sweep listing each stuck PR with its CI status, why it's stuck, for how long and a next-step button, then a table per section" src="docs/screenshots/board.png">
</picture>

## Install

Grab a build from [Releases](../../releases).

- **Windows:** the setup exe installs PR Sweep with a Start Menu entry and keeps it updated. The
portable exe runs without installing, but doesn't update itself. Builds are signed with Azure
portable exe runs without installing and tells you when a new version is out, but doesn't
update itself: its Download button opens the release page. Builds are signed with Azure
Trusted Signing. If SmartScreen still warns while the certificate builds reputation, choose
*More info → Run anyway*.
- **Linux:** the AppImage runs on any distro (`chmod +x pr-sweep-*.AppImage`, then run it) and
Expand Down Expand Up @@ -66,8 +67,14 @@ Grab a build from [Releases](../../releases).
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.
you can snooze a row until it changes or tomorrow.
- **Sums up the sprint** at the top: days left, what's open and not yet approved, what needs
attention and how much of it is blocked, what merged since yesterday, and the median time to
merge. It always counts the whole team, whatever you filter. In the sprint's last two days it
warns about PRs that aren't approved yet.
- **Copies a standup** in one click: what merged since the last working day, then what's
blocked, what needs attention and what's in review, each PR once, with links. It pastes
formatted into Teams and Slack, and as Markdown into Discord, without preview cards piling up.
- **Knows your sprint.** Give it the first sprint's start and the sprint length, and the board
opens on the current sprint, moves on when it ends, and steps back and forward with the arrows.
Rename a sprint or make one longer, and the sprints after it follow.
Expand Down
95 changes: 84 additions & 11 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ 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.
unapproved PR in the last two days, when the list should be shortest. *v0.13 moves the line into
the health strip.*
- **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
Expand All @@ -146,6 +147,50 @@ There are no new notifications.
- **Each roadmap release is built on its own branch** (`release/vX.Y`) and reaches `main`
through a pull request.

## v0.13 decisions (2026-09-29)

- **The health strip tells the sprint's story:** days left, open, needs attention, merged and
median time to merge, each with a one-line note. The per-status counts stay in the table
headings, and My queue keeps its own table and the tray.
- **Sprint numbers are the team's.** Author chips, search and snoozes never change the strip or
the standup; they only shape the tables and the Sweep. Otherwise one chip would turn time to
merge into an individual cycle time.
- **Needs attention counts every flagged PR except quiet ones**, so it equals the standup's
Blocked plus Needs attention.
- **The standup lists each open PR once**, in its most urgent group, all from the attention
engine: Blocked (CI failing, a merge conflict, or requested changes left for over a day), Needs
attention (the rest of the Sweep), and In review (everything else, quiet rows and same-day
change requests included). Merged since the last working day comes first. Drafts stay out.
- **Lines name the author's login as plain text**, with no @mention and no per-person counts.
- **Copy standup puts rich text and Markdown on the clipboard together.** Slack and Teams paste
the formatted version, with PR refs as links; plain-text places get the Markdown. Slack's
message box doesn't read Markdown links, so Markdown alone wouldn't paste cleanly there. A paste
test in real Slack and Teams comes before anything is built on the format.
- **Markdown links are written `[ref](<url>)`.** Discord pastes the Markdown version, and the
angle brackets stop it adding a preview card per link; other Markdown readers see an ordinary
link. The formatted version writes every non-ASCII character as an HTML entity, so no app can
misread the clipboard's encoding.
- **The last working day is the previous weekday**, so a Monday standup covers since Friday.
"Merged since" only covers what the board already fetched: on a sprint's first day it says
nothing has merged yet this sprint, rather than paying for another search.
- **Days left are calendar days**, as the sprint-end warning counted them: "Ends today" on the
last day, and "No end date" for an open custom range.
- **The sprint-end line moves from the Sweep header into the strip**, so it's said once.
- **Median time to merge runs from open to merge**, draft time included, so it needs no new
fields. It shows "–" until something has merged.
- **One click, no preview.** The button says what it copied, like "Copied: 3 merged, 3 blocked,
3 need attention, 5 in review". It only appears when the board's period includes today.
- **Sweep time is unchanged:** no new GitHub fields. The summary replaces `sprintRisk` in the
sweep result (schema 6), so the first refresh after updating is a full one.

## v0.13 decisions (2026-10-07)

- **The portable exe says when a new version is out**, and joins v0.13 at the owner's request. It
checks on the installed build's schedule, downloads nothing, and the top bar's "Download
v0.13.1" opens the release page. It doesn't update itself: electron-updater only installs
through the NSIS installer, and swapping a running portable exe fights Windows file locks.
Anyone who wants automatic updates uses the installer.

## Shipped

Ordering then: distribution first, because every later release gets cheaper once CI ships
Expand Down Expand Up @@ -353,17 +398,45 @@ the README shows the new screenshots.
## v0.13: Sprint summary

Theme: the board opens with how the sprint is going, and turns that into a standup in one click.
Built on the `release/v0.13` branch.

- [ ] The v0.12 health strip gains the sprint's story: merged, open, needs attention, and days
left in the sprint.
- [ ] Median time to merge for PRs merged in the sprint, from `mergedAt − createdAt`. No new
fields.
- [ ] **Copy standup as Markdown**: merged since the last working day, in review, blocked
(CI failing, changes requested, conflicts), needs attention.
- [ ] No per-person numbers anywhere.

**Done when:** you can run a standup from the app and paste the summary into Slack or Teams
without editing it.
- [x] Roadmap, `AGENTS.md` and a draft pull request for the milestone.
- [x] Paste test: a throwaway standup in rich text and Markdown, pasted into real Slack and
Teams, before anything is built on the format. *Teams keeps the bold headings, bullet lists
and PR links, shows a title's Markdown and HTML characters as typed, and previews only the
first link (its × removes the card before sending). Discord takes the Markdown, and stacked a
preview card per link until links became `<url>`. Slack wasn't tested: no workspace to hand.
To fix in the build: the title needs a gap before the first group in Teams.*
- [x] `core/summary.ts` with tests: days left, open and not yet approved, needs attention,
merged, merged since the last working day, and median time to merge. It replaces `sprintRisk`
in the sweep result (schema 6). *The Blocked / Needs attention / In review rule is
`standupGroup` in the attention engine, so the strip and the standup can't disagree.*
- [x] Health strip: the sprint's story, team-wide, with a note under each number. The Sweep
header loses its sprint line. *Checked on the busy fixture: an author chip, search and a
snooze each leave every strip number as it was.*
- [x] `core/standup.ts` with tests: the groups, the "since Friday" wording, and titles escaped
for both Markdown and HTML.
- [x] Copy standup: the button, both formats written to the clipboard by the main process, and a
"Copied" confirmation with the counts. Shown only when the period includes today. *It sits at
the end of the health strip. The screenshot script stubs the clipboard, so a run never
overwrites yours.*
- [x] Fixture screenshots in both themes, README, a keyboard pass and the renderer bundle size.
*30 screenshots (a new `busy-standup` state). Copy standup comes right after the top bar in
the Tab order, and the v0.11 snooze, quiet and tray checks still pass. The renderer is
988.5 KB, 1.6 KB more than v0.12.*
- [x] Portable exe: a "Download vX.Y.Z" button in the top bar when a new release is out, opening
its release page. *A local package versioned 0.11.0, run as the portable launcher runs it,
offered "Download v0.12.0" from the live releases and downloaded nothing. The portable exe finds
the release through the `app-update.yml` that electron-builder writes for the NSIS target, so
the two targets must keep building together. New fixture: `update-portable`.*
- [x] `chore(release): v0.13.0` with its notes in `docs/releases/v0.13.0.md`, then mark the pull
request ready, merge it, and tag `v0.13.0`. *The renderer is 989.0 KB, 2.1 KB more than v0.12.*
- [ ] Once a newer release is out, check that the v0.13.0 portable exe offers it and that
Download opens its release page.

**Done when:** you can run a standup from the app and paste it into Teams or Discord without
editing it (Slack, untested, gets the same formatted version as Teams), the strip's numbers agree with the standup's, and no number in the strip changes
when you pick an author chip.

## v0.14: Related work

Expand Down
9 changes: 9 additions & 0 deletions desktop/e2e/fixtures/update-portable.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "busy",
"update": [
{
"afterMs": 1500,
"state": { "status": "available", "version": "0.13.1", "percent": 0, "url": "https://github.com/hazeliscoding/pr-sweep/releases/tag/v0.13.1" }
}
]
}
11 changes: 11 additions & 0 deletions desktop/e2e/screenshot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ async function launch(config, env) {
rmSync(userData, { recursive: true, force: true });
throw new Error(`the app uses ${actual}, not the throwaway folder — aborting`);
}
// Copy standup writes the system clipboard; a screenshot run leaves yours alone.
await app.evaluate(({ clipboard }) => {
clipboard.write = () => undefined;
});
const win = await app.firstWindow();
// The app scrolls inside its main pane, so a full-page shot stops at the
// window's height: a tall viewport captures every section.
Expand Down Expand Up @@ -89,6 +93,9 @@ const STATES = {
async busy(win) {
await boardReady(win);
await shoot(win, 'busy');
await button(win, 'Copy standup').click();
await win.getByText(/^Copied: /).waitFor();
await shoot(win, 'busy-standup');
await button(win, /^Snooze /).first().click();
await button(win, /Show snoozed/).click();
await shoot(win, 'busy-snoozed');
Expand Down Expand Up @@ -135,6 +142,10 @@ const STATES = {
await button(win, /Restart/).waitFor();
await shoot(win, 'update-ready');
},
async 'update-portable'(win) {
await button(win, /Download v0\.13\.1/).waitFor();
await shoot(win, 'update-portable');
},
async loading(win) {
await win.waitForTimeout(1500);
await shoot(win, 'loading');
Expand Down
4 changes: 2 additions & 2 deletions desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"name": "pr-sweep-desktop",
"version": "0.12.0",
"version": "0.13.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 && node src/main/core/snapshot.store.test.mjs && node src/main/core/attention.test.mjs && node src/main/core/tray.text.test.mjs && node src/main/core/fixture.test.mjs && node src/main/core/sprints.test.mjs && node renderer/scripts/checks.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 && node src/main/core/fixture.test.mjs && node src/main/core/sprints.test.mjs && node src/main/core/summary.test.mjs && node src/main/core/standup.test.mjs && node renderer/scripts/checks.test.mjs",
"start": "electron .",
"package": "electron-builder",
"package:win": "electron-builder --win",
Expand Down
2 changes: 1 addition & 1 deletion desktop/renderer/scripts/check-contrast.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const PAIRS = [
// Board: tables and the health strip are bg-1 panels whose rows hover to bg-2.
...['healthy', 'warning', 'critical', 'running'].map((st) => [`${st}-text`, 'bg-1', 4.5]), // reasons, CI, Δ, stale ages
['warning-text', 'warning-subtle over bg-1', 4.5], // a review waiting too long
['warning-text', 'bg-0', 4.5], // the sprint risk line
['critical-text', 'bg-0', 4.5], // Copy standup's error line
['accent-text', 'bg-1', 4.5], // a focused PR title
// Settings: bg-1 cards holding bg-0 tables; the onboarding dialog is bg-2.
['text-1', 'accent-subtle over bg-0', 4.5], // a pressed filter chip, the "active" and "current" tags
Expand Down
11 changes: 10 additions & 1 deletion desktop/renderer/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,16 @@ const THEME_KEY = 'prsweep-theme';
}
<span class="spacer"></span>
@if (store.updateState(); as update) {
@if (update.status === 'downloading') {
@if (update.status === 'available') {
<button
class="q-btn q-btn--sm"
title="v{{ update.version }} is out. This portable copy doesn't update itself: download the new one from GitHub."
(click)="store.openUrl(update.url!)"
>
<q-icon name="download" [size]="12" />
Download v{{ update.version }}
</button>
} @else if (update.status === 'downloading') {
<span class="q-badge q-badge--info" title="A new version is downloading in the background">
<q-icon name="download" [size]="12" />
Downloading v{{ update.version }} · {{ update.percent }}%
Expand Down
10 changes: 9 additions & 1 deletion desktop/renderer/src/app/board.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ProfilePatch,
SprintPreview,
SprintSchedule,
StandupCounts,
SweepConfig,
SweepConfigPatch,
SweepResult,
Expand Down Expand Up @@ -93,7 +94,8 @@ export class BoardStore {
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);
/** The sprint's story, worked out in main: team-wide, so filters and snoozes never change it. */
readonly summary = computed(() => this.result()?.summary ?? null);

readonly needsReview = computed(() => this.slice('needs-review'));
readonly changesRequested = computed(() => this.slice('changes-requested'));
Expand Down Expand Up @@ -239,6 +241,12 @@ export class BoardStore {
void this.refresh();
}

/** The standup for the sweep on screen, built and put on the clipboard by main. */
copyStandup(): Promise<StandupCounts> {
const result = this.result();
return result ? this.api.copyStandup(result) : Promise.reject(new Error('Nothing has been swept yet.'));
}

previewSprints(schedule: SprintSchedule): Promise<SprintPreview> {
return this.api.previewSprints(schedule);
}
Expand Down
Loading
Loading