diff --git a/README.md b/README.md index b3235aa..3ff0271 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,9 @@ running past the end would otherwise be counted and highlighted with nothing to ## The review inbox -`diffity inbox` watches the pull requests awaiting your review and prepares each one ahead of time, so the review is ready the moment you look. It polls GitHub (`gh search prs --review-requested=@me`), and for each pull request worth your attention it cuts a worktree at the PR head, runs a diffity session over the diff, has an agent prepare a review with a walkthrough, and saves the result as a bundle. New commits redo a stale review; a merged, closed, or no-longer-requested PR is retired. The daemon prepares up to `maxPrepared` of them from the queue on its own — the rest wait, smallest first — and a prepared review leaves the inbox once you have posted it (GitHub withdraws the request) or dismissed it from the page — a dismissal holds until the pull request gets new commits, and dismissed pull requests stay listed at the bottom so one can be brought back. A queued, skipped, failed or dismissed pull request has a ↑ button: prepare this one now — at once and in parallel with whatever the daemon is preparing, past the auto-prepare count, with the filter, the title patterns and the CI hold set aside. Each bump is its own agent run, and the row says `preparing` while it runs. Each prepared review shows its findings by severity ("1 P1 · 2 P2"), and the page can notify you when one is ready — click "Turn on notifications" once to allow it, and ⟳ in the header polls GitHub now instead of at the next interval; `localhost` counts as a secure context, so this works from the pinned tab with nothing else set up. +`diffity inbox` watches the pull requests awaiting your review and prepares each one ahead of time, so the review is ready the moment you look. It polls GitHub (`gh search prs --review-requested=@me`), and for each pull request worth your attention it cuts a worktree at the PR head, runs a diffity session over the diff, has an agent prepare a review with a walkthrough, and saves the result as a bundle. New commits redo a stale review; a merged, closed, or no-longer-requested PR is retired. The daemon prepares up to `maxPrepared` of them from the queue on its own — the rest wait, smallest first — and a prepared review leaves the Ready list once you have posted it (GitHub withdraws the request, and the pull request moves to Handled, below) or dismissed it from the page — a dismissal holds until the pull request gets new commits, and dismissed pull requests stay listed at the bottom so one can be brought back. A queued, skipped, failed or dismissed pull request has a ↑ button: prepare this one now — at once and in parallel with whatever the daemon is preparing, past the auto-prepare count, with the filter, the title patterns and the CI hold set aside. Each bump is its own agent run, and the row says `preparing` while it runs. Each prepared review shows its findings by severity ("1 P1 · 2 P2"), and the page can notify you when one is ready — click "Turn on notifications" once to allow it, and ⟳ in the header polls GitHub now instead of at the next interval; `localhost` counts as a secure context, so this works from the pinned tab with nothing else set up. + +Every review you post from diffity is noted against its pull request, with the commit it was posted against, and the pull request then stays under **Handled** instead of vanishing when GitHub withdraws the review request. The card links to the pull request itself — the worktree is reclaimed once the review is out — and says what you said: "you approved", "you requested changes", "you commented", and when. When the author pushes after your review, the card moves to the top of the list, reads "new commits since you approved" and is badged `updated`, so a pull request that has come back to you is not something you have to remember; ↑ prepares a fresh review of the current head, re-request or not, and × sets the row aside until the next push. A pull request leaves the list when the author asks for a new review — the search lists it again and it goes back in the queue like anything else — or when it is merged or closed. This counts reviews posted from any diffity, so one you posted from your own clone brings its pull request into the list at the next poll, at the cost of one `gh pr view`. `diffity inbox status` prints the same list. The daemon never posts your prepared reviews to GitHub — they are local drafts you open and submit yourself — and it runs the review agent with your GitHub credentials stripped from its environment. Its own git calls run with hooks disabled, so a checkout's hook scripts — the author's code — never run with your credentials. That said, the agent executes the pull request's own repository code (see the warning below), so treat the "never posts" behaviour as the daemon's design, not a sandbox. diff --git a/package-lock.json b/package-lock.json index ba13e7b..cf63643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8457,7 +8457,7 @@ }, "packages/api": { "name": "@diffity/api", - "version": "0.10.28", + "version": "0.10.29", "dependencies": { "@diffity/parser": "*" }, @@ -8468,7 +8468,7 @@ }, "packages/cli": { "name": "@naturalcycles/diffity", - "version": "0.10.28", + "version": "0.10.29", "license": "MIT", "dependencies": { "commander": "^14.0.3", @@ -8492,7 +8492,7 @@ }, "packages/git": { "name": "@diffity/git", - "version": "0.10.28", + "version": "0.10.29", "devDependencies": { "@types/node": "^25.5.0", "typescript": "^5.9.3", @@ -8501,7 +8501,7 @@ }, "packages/github": { "name": "@diffity/github", - "version": "0.10.28", + "version": "0.10.29", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*" @@ -8514,7 +8514,7 @@ }, "packages/parser": { "name": "@diffity/parser", - "version": "0.10.28", + "version": "0.10.29", "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0" @@ -8522,7 +8522,7 @@ }, "packages/ui": { "name": "@diffity/ui", - "version": "0.10.28", + "version": "0.10.29", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*", diff --git a/packages/api/package.json b/packages/api/package.json index 0f7a86a..639dfbd 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/api", - "version": "0.10.28", + "version": "0.10.29", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 3155bd6..8fa4dc4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@naturalcycles/diffity", - "version": "0.10.28", + "version": "0.10.29", "description": "Agent-agnostic, GitHub-style diff viewer and code review tool with a live agent loop", "type": "module", "bin": { diff --git a/packages/cli/src/commands/inbox.ts b/packages/cli/src/commands/inbox.ts index 1d97653..f099e4e 100644 --- a/packages/cli/src/commands/inbox.ts +++ b/packages/cli/src/commands/inbox.ts @@ -87,7 +87,8 @@ export function registerInboxCommand(program: Command): void { return; } - if (view.ready.length === 0 && view.working.length === 0 && view.other.length === 0 && view.dismissed.length === 0) { + if (view.ready.length === 0 && view.working.length === 0 && view.handled.length === 0 + && view.other.length === 0 && view.dismissed.length === 0) { console.log(pc.dim('Nothing in the inbox yet. Run `diffity inbox` to start watching.')); spent(view); return; @@ -99,6 +100,9 @@ export function registerInboxCommand(program: Command): void { section('Queue', view.working.map(row => ` ${pc.dim(row.status.padEnd(9))} ${row.repo}#${row.number} ${row.title} ${pc.dim(row.statusReason ?? '')}`, )); + section('Handled', view.handled.map(row => + ` ${pc.dim((row.handled?.updated ? 'updated' : 'handled').padEnd(9))} ${row.repo}#${row.number} ${row.title} ${pc.dim(row.statusReason ?? '')}`, + )); section('Other', view.other.map(row => ` ${pc.dim(row.status.padEnd(9))} ${row.repo}#${row.number} ${pc.dim(row.statusReason ?? '')}`, )); diff --git a/packages/cli/src/inbox/handled.ts b/packages/cli/src/inbox/handled.ts new file mode 100644 index 0000000..bca20be --- /dev/null +++ b/packages/cli/src/inbox/handled.ts @@ -0,0 +1,37 @@ +import type { ReviewEvent } from '@diffity/api'; +import { inboxStorePath } from './paths.js'; +import { InboxStore, prId } from './store.js'; + +/** + * Notes that a review reached the forge, so the inbox keeps listing the pull request instead of + * losing it the moment GitHub withdraws the review request. + * + * The store is opened and closed around the one write: this runs in whichever diffity posted the + * review — a session the inbox prepared, or one the reviewer started on their own clone — and none + * of those hold the inbox open otherwise. + */ +export function recordHandledReview(input: { + owner: string; + repo: string; + number: number; + /** The head the review was posted against. */ + headSha: string; + event: ReviewEvent; + reviewUrl: string | null; + now: string; + /** The reviewer's own inbox unless a test says otherwise. */ + storePath?: string; +}): void { + const store = new InboxStore(input.storePath ?? inboxStorePath()); + try { + store.recordHandled({ + prId: prId(input), + headSha: input.headSha, + event: input.event, + reviewUrl: input.reviewUrl, + at: input.now, + }); + } finally { + store.close(); + } +} diff --git a/packages/cli/src/inbox/open.ts b/packages/cli/src/inbox/open.ts index f76bc0a..db54455 100644 --- a/packages/cli/src/inbox/open.ts +++ b/packages/cli/src/inbox/open.ts @@ -1,6 +1,6 @@ import type { InboxPr, InboxStore } from './store.js'; -export const BUMPABLE: ReadonlySet = new Set(['queued', 'skipped', 'failed', 'dismissed']); +export const BUMPABLE: ReadonlySet = new Set(['queued', 'skipped', 'failed', 'dismissed', 'handled']); export type Resolution = | { ok: true; pr: InboxPr } @@ -42,8 +42,8 @@ export function resolveDismiss(store: InboxStore, id: string): Resolution { /** * Whether a pull request can be bumped to the front of the queue: one that is waiting, one a verdict - * or a failure set aside, or one the reviewer dismissed and wants back. A prepared, stale or - * in-flight one has nothing to gain. + * or a failure set aside, one the reviewer dismissed and wants back, or one already reviewed whose + * current head they want a fresh review of. A prepared, stale or in-flight one has nothing to gain. */ export function resolveBump(store: InboxStore, id: string): Resolution { const pr = store.get(id); @@ -51,7 +51,7 @@ export function resolveBump(store: InboxStore, id: string): Resolution { return { ok: false, status: 404, message: `No pull request ${id} in the inbox.` }; } if (!BUMPABLE.has(pr.status)) { - return { ok: false, status: 409, message: `${id} is ${pr.status}; only a queued, skipped, failed or dismissed pull request can be bumped.` }; + return { ok: false, status: 409, message: `${id} is ${pr.status}; only a queued, skipped, failed, dismissed or handled pull request can be bumped.` }; } return { ok: true, pr }; } diff --git a/packages/cli/src/inbox/page.ts b/packages/cli/src/inbox/page.ts index 3778721..c7d880c 100644 --- a/packages/cli/src/inbox/page.ts +++ b/packages/cli/src/inbox/page.ts @@ -1,6 +1,6 @@ /** * The inbox page, served at `/`. Self-contained (no build step, no external requests): it polls - * `/api/inbox` and renders the three groups, opening a prepared review in a new tab via `/open/:id`. + * `/api/inbox` and renders its groups, opening a prepared review in a new tab via `/open/:id`. */ export function inboxPage(): string { return ` @@ -121,6 +121,10 @@ export function inboxPage(): string {

Queue

+