From 64650e1471f1dd49d143c4e3b1a429c52ac8223e Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Tue, 8 Sep 2026 15:21:45 +0200 Subject: [PATCH 1/2] feat: a review posted from diffity marks the pull request handled, and handled pull requests stay listed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What A pull request used to leave the inbox at the first poll after its review was posted: GitHub's `review-requested:@me` search drops it, the daemon retired the row as `hidden`, and nothing retired is listed. The inbox never learned that the review had happened. - `POST /api/github/create-review` — the one place diffity writes to the forge — records a handled mark (`pr_id`, `head_sha`, `event`, `review_url`, `at`) in the inbox store on a review GitHub accepted, via the new `recordHandledReview`. A store that cannot be written is a warning on the server's stderr, never a failed post, and an agent posting through the route does not count as the reader reviewing. - `inbox_handled`, an append-only log, with `PRAGMA busy_timeout = 5000` so the posting server and the daemon's poll do not fail each other. - New status `handled`, which is not retired: the row is re-polled every tick, never prepared. The reconcile's not-requested branch keeps it listed with what was said ("you approved") or what has happened since ("new commits since you approved"), while a dismissal, a bump's preparation in flight and a fresh review not yet posted all keep their own hold on the row. A re-request falls through to the queue. - The tick also adopts a handled pull request it has no row for — one reviewed from the reviewer's own clone — at one `gh pr view` each. - A **Handled** section on the page and in `diffity inbox status`, between Queue and Other: linked to the pull request itself (the worktree is reclaimed), badged `updated` in the attention colour once the author has pushed, with ↑ for a fresh review of the current head and × to set it aside. `BUMPABLE` gains `handled`. ## Testing The store's log and its cross-process visibility, every reconcile branch (dismissed-and-handled, bumped-and-not-yet-posted, posted-after-bump, re-request), the tick's re-poll, its one worktree reclaim, adoption and merge retirement, the view's section membership and ordering, the page's parse check, the create-review hook against a temp store, and the `inbox status` output. Closes #104 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Bwp5QefjsjMFeq6CK6cT6w Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Bwp5QefjsjMFeq6CK6cT6w --- README.md | 4 +- package-lock.json | 12 +- packages/api/package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/commands/inbox.ts | 6 +- packages/cli/src/inbox/handled.ts | 37 +++ packages/cli/src/inbox/open.ts | 8 +- packages/cli/src/inbox/page.ts | 44 +++- packages/cli/src/inbox/reconcile.ts | 46 +++- packages/cli/src/inbox/store.ts | 73 +++++- packages/cli/src/inbox/tick.ts | 37 ++- packages/cli/src/inbox/view.ts | 37 ++- packages/cli/src/server.ts | 28 +++ .../cli/tests/create-review-handled.test.ts | 142 +++++++++++ packages/cli/tests/inbox-handled.test.ts | 220 ++++++++++++++++++ packages/cli/tests/inbox-page.test.ts | 19 ++ packages/cli/tests/inbox-reconcile.test.ts | 79 ++++++- packages/cli/tests/inbox-tick.test.ts | 144 +++++++++++- packages/git/package.json | 2 +- packages/github/package.json | 2 +- packages/parser/package.json | 2 +- packages/ui/package.json | 2 +- 22 files changed, 909 insertions(+), 39 deletions(-) create mode 100644 packages/cli/src/inbox/handled.ts create mode 100644 packages/cli/tests/create-review-handled.test.ts create mode 100644 packages/cli/tests/inbox-handled.test.ts 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

+