Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ 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.
`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. At most `maxPrepared` reviews are kept prepared at a time — the rest wait in the queue, smallest first — and a prepared review leaves the inbox once you have posted it (GitHub withdraws the request) or dismissed it from the page.

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. 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.

Expand All @@ -346,6 +346,7 @@ On first run it writes `~/.diffity/inbox/config.json`:
| `filter` | Your own words on what does and doesn't need your attention, handed to the agent — it answers with a skip instead of reviewing when a PR matches (e.g. "Skip payments-focused PRs"). |
| `prepare` | The review agent, as a command and its arguments. It runs in the PR's worktree and reads its prompt on stdin. |
| `prepareTimeoutMinutes` | How long one preparation may take before it's abandoned. |
| `maxPrepared` | How many prepared reviews may wait for you at once (default 5). Each preparation is an agent run; the rest of the queue waits until a prepared review is posted or dismissed. |

> ⚠️ The `prepare` command runs inside a checkout the pull request's author controls, so it executes their repository scripts. The daemon runs it without the forge's credentials in its environment, but you should still only point `prepare` at an agent you're willing to run on untrusted code.

Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@diffity/api",
"version": "0.10.11",
"version": "0.10.12",
"private": true,
"type": "module",
"main": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@naturalcycles/diffity",
"version": "0.10.11",
"version": "0.10.12",
"description": "Agent-agnostic, GitHub-style diff viewer and code review tool with a live agent loop",
"type": "module",
"bin": {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ export function registerInboxCommand(program: Command): void {
section('Ready to review', view.ready.map(row =>
` ${sizeBadge(row)} ${pc.bold(`${row.repo}#${row.number}`)} ${row.title}${row.stale ? pc.yellow(' (stale — new commits)') : ''}`,
));
section('Preparing', view.working.map(row =>
` ${pc.dim(row.status.padEnd(9))} ${row.repo}#${row.number} ${row.title}`,
section('Queue', view.working.map(row =>
` ${pc.dim(row.status.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 ?? '')}`,
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/inbox/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export interface InboxConfig {
*/
prepare: string[];
prepareTimeoutMinutes: number;
/**
* How many prepared reviews may wait for the reviewer at once. Each preparation spends an agent
* run, so the queue beyond this waits for a prepared review to be posted or dismissed.
*/
maxPrepared: number;
}

export const DEFAULT_INBOX_CONFIG: InboxConfig = {
Expand All @@ -38,6 +43,7 @@ export const DEFAULT_INBOX_CONFIG: InboxConfig = {
'--disallowedTools', 'Bash(gh pr review:*)', 'Bash(gh pr comment:*)', 'Bash(gh pr merge:*)', 'Bash(gh api:*)',
],
prepareTimeoutMinutes: 30,
maxPrepared: 5,
};

/**
Expand Down Expand Up @@ -94,6 +100,9 @@ export function parseInboxConfig(raw: unknown, source = 'inbox config'): InboxCo
if (obj.prepareTimeoutMinutes !== undefined) {
config.prepareTimeoutMinutes = positive(obj.prepareTimeoutMinutes, 'prepareTimeoutMinutes', source);
}
if (obj.maxPrepared !== undefined) {
config.maxPrepared = positiveInteger(obj.maxPrepared, 'maxPrepared', source);
}
return config;
}

Expand All @@ -104,6 +113,13 @@ function positive(value: unknown, key: string, source: string): number {
return value;
}

function positiveInteger(value: unknown, key: string, source: string): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
throw new Error(`${source}: ${key} must be a positive integer`);
}
return value;
}

function port(value: unknown, source: string): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
throw new Error(`${source}: port must be an integer between 1 and 65535`);
Expand Down
85 changes: 68 additions & 17 deletions packages/cli/src/inbox/daemon.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createServer, type Server, type ServerResponse } from 'node:http';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { basename, join } from 'node:path';
import { getViewerLogin, searchReviewRequested, viewPr } from '@diffity/github';
Expand All @@ -7,10 +7,12 @@ import { inboxDir } from './paths.js';
import { preparePr, type PrepareDeps } from './prepare.js';
import { realPrepareDeps, type Inflight } from './runtime.js';
import { removeWorktree, cloneDir } from './worktree.js';
import { findInstanceForRepo, killInstance } from '../registry.js';
import { repoHash } from './open-session.js';
import { InboxStore } from './store.js';
import { runTick, type Forge } from './tick.js';
import { buildView } from './view.js';
import { resolveOpen } from './open.js';
import { resolveDismiss, resolveOpen } from './open.js';
import { openPreparedSession, realOpenSessionDeps, type OpenSessionDeps } from './open-session.js';
import { inboxPage } from './page.js';

Expand Down Expand Up @@ -60,10 +62,11 @@ export async function runDaemon(
const deps = {
forge: options.forge ?? realForge,
prepare: (snapshot: Parameters<typeof preparePr>[0]) => preparePr(snapshot, config, prepareDeps),
removeWorktree: (worktree: string, repo: string) => removeWorktree(cloneDir(config.reposDir, repo), worktree),
removeWorktree: (worktree: string, repo: string) => reclaimWorktree(config, worktree, repo),
log,
now: () => new Date().toISOString(),
shouldContinue: () => !stopping,
maxPrepared: config.maxPrepared,
};

const tick = async () => {
Expand Down Expand Up @@ -172,22 +175,17 @@ export function startInboxServer(store: InboxStore, config: InboxConfig, log: (m
return;
}
if (req.method === 'GET' && url.startsWith('/open/')) {
// A state-changing GET, so a cross-site fetch — a drive-by trying to spawn a session — is
// refused; a click from the inbox page itself is same-origin, and a direct navigation none.
if (req.headers['sec-fetch-site'] === 'cross-site') {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('forbidden');
return;
const id = stateChangingId(req, res, '/open/');
if (id !== null) {
void handleOpen(store, id, openDeps, log, res).catch(err => log(`open failed: ${err instanceof Error ? err.message : err}`));
}
let id: string;
try {
id = decodeURIComponent(url.slice('/open/'.length));
} catch {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('bad request');
return;
return;
}
if (req.method === 'POST' && url.startsWith('/dismiss/')) {
const id = stateChangingId(req, res, '/dismiss/');
if (id !== null) {
handleDismiss(store, config, id, log, res);
}
void handleOpen(store, id, openDeps, log, res).catch(err => log(`open failed: ${err instanceof Error ? err.message : err}`));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
Expand Down Expand Up @@ -237,6 +235,59 @@ async function handleOpen(store: InboxStore, id: string, openDeps: OpenSessionDe
}
}

/**
* The id a state-changing route was asked about, or null once the request has been answered: a
* cross-site fetch — a drive-by trying to spawn a session or dismiss a review — is refused, and a
* malformed escape is a bad request. A click from the inbox page is same-origin, a direct
* navigation has no site.
*/
function stateChangingId(req: IncomingMessage, res: ServerResponse, prefix: string): string | null {
if (req.headers['sec-fetch-site'] === 'cross-site') {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('forbidden');
return null;
}
try {
return decodeURIComponent((req.url ?? '').slice(prefix.length));
} catch {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('bad request');
return null;
}
}

/** Marks a pull request as one the reviewer will not review, and reclaims its worktree. */
function handleDismiss(store: InboxStore, config: InboxConfig, id: string, log: (message: string) => void, res: ServerResponse): void {
const resolution = resolveDismiss(store, id);
if (!resolution.ok) {
res.writeHead(resolution.status, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(resolution.message);
return;
}
const { pr } = resolution;
if (pr.worktreePath) {
reclaimWorktree(config, pr.worktreePath, pr.repo);
store.setPaths(pr.id, { worktreePath: null });
}
store.setStatus(pr.id, 'dismissed', 'dismissed by the reviewer');
log(`dismissed ${pr.id}`);
res.writeHead(204);
res.end();
}

/**
* Removes a pull request's worktree, first stopping any diffity server the reviewer opened on it.
* That session lives in the reviewer's own registry and would otherwise keep serving a directory
* that no longer exists.
*/
export function reclaimWorktree(config: InboxConfig, worktree: string, repo: string): void {
const instance = findInstanceForRepo(repoHash(worktree));
if (instance) {
killInstance(instance);
}
removeWorktree(cloneDir(config.reposDir, repo), worktree);
}

/** A request whose Host is this loopback server's own address (localhost or 127.0.0.1, right port). */
function isLocalHost(host: string | undefined, port: number | undefined): boolean {
return port != null && (host === `localhost:${port}` || host === `127.0.0.1:${port}`);
Expand Down
19 changes: 17 additions & 2 deletions packages/cli/src/inbox/open.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { InboxPr, InboxStore } from './store.js';

export type OpenResolution =
export type Resolution =
| { ok: true; pr: InboxPr }
| { ok: false; status: number; message: string };

Expand All @@ -9,7 +9,7 @@ export type OpenResolution =
* still prepared) review has a worktree and a bundle to open; a queued, skipped or failed one has
* nothing to show yet.
*/
export function resolveOpen(store: InboxStore, id: string): OpenResolution {
export function resolveOpen(store: InboxStore, id: string): Resolution {
const pr = store.get(id);
if (!pr) {
return { ok: false, status: 404, message: `No pull request ${id} in the inbox.` };
Expand All @@ -22,3 +22,18 @@ export function resolveOpen(store: InboxStore, id: string): OpenResolution {
}
return { ok: true, pr };
}

/**
* Whether a pull request can be dismissed right now. One being prepared cannot: the run in flight
* would finish and mark it prepared over the dismissal.
*/
export function resolveDismiss(store: InboxStore, id: string): Resolution {
const pr = store.get(id);
if (!pr) {
return { ok: false, status: 404, message: `No pull request ${id} in the inbox.` };
}
if (pr.status === 'preparing') {
return { ok: false, status: 409, message: `${id} is being prepared right now; dismiss it once that has finished.` };
}
return { ok: true, pr };
}
39 changes: 34 additions & 5 deletions packages/cli/src/inbox/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export function inboxPage(): string {
margin: 0 0 8px; font-weight: 600; }
.row { display: flex; align-items: center; gap: 12px; background: var(--panel);
border: 1px solid var(--line); border-radius: 10px; padding: 11px 14px; margin-bottom: 8px; }
.entry { display: flex; align-items: stretch; gap: 8px; margin-bottom: 8px; }
.entry .row { flex: 1; margin-bottom: 0; }
.dismiss { flex: none; width: 38px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel);
color: var(--muted); font-size: 16px; cursor: pointer; }
.dismiss:hover { color: var(--bad); border-color: var(--bad); }
.row.open { cursor: pointer; }
.row.open:hover { border-color: var(--accent); }
.size { font-variant-numeric: tabular-nums; color: var(--muted); font-size: 12px;
Expand Down Expand Up @@ -62,7 +67,7 @@ export function inboxPage(): string {
<div id="ready"></div>
</section>
<section id="working-section" hidden>
<h2>Preparing</h2>
<h2>Queue</h2>
<div id="working"></div>
</section>
<section id="other-section" hidden>
Expand Down Expand Up @@ -106,6 +111,30 @@ export function inboxPage(): string {
return row;
}

function withDismiss(row, r) {
if (!r.dismissUrl) return row;
const wrap = document.createElement('div');
wrap.className = 'entry';
const button = document.createElement('button');
button.type = 'button';
button.className = 'dismiss';
button.title = 'Dismiss: you will not review this one, and it will not come back';
button.textContent = '\\u00d7';
button.onclick = () => dismiss(r);
wrap.append(row, button);
return wrap;
}

async function dismiss(r) {
if (!confirm('Dismiss ' + r.repo + '#' + r.number + '? It leaves the inbox for good.')) return;
const res = await fetch(r.dismissUrl, { method: 'POST' });
if (!res.ok) {
el('status').textContent = 'could not dismiss ' + r.repo + '#' + r.number + ': ' + await res.text();
return;
}
refresh();
}

function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
Expand All @@ -120,15 +149,15 @@ export function inboxPage(): string {
try {
const res = await fetch('/api/inbox', { cache: 'no-store' });
const view = await res.json();
fill('ready-section', 'ready', view.ready, readyRow);
fill('working-section', 'working', view.working, r => plainRow(r, 'work', r.status));
fill('ready-section', 'ready', view.ready, r => withDismiss(readyRow(r), r));
fill('working-section', 'working', view.working, r => withDismiss(plainRow(r, 'work', r.status), r));
fill('other-section', 'other', view.other, r => {
const bad = r.status === 'failed';
return plainRow(r, bad ? 'bad' : 'work', r.status);
return withDismiss(plainRow(r, bad ? 'bad' : 'work', r.status), r);
});
const total = view.ready.length + view.working.length + view.other.length;
el('all-empty').hidden = total > 0;
el('status').textContent = view.ready.length + ' ready \\u00b7 ' + view.working.length + ' preparing';
el('status').textContent = view.ready.length + ' ready \\u00b7 ' + view.working.length + ' queued';
el('foot').textContent = 'Updated ' + new Date().toLocaleTimeString();
} catch (err) {
el('status').textContent = 'the inbox daemon is not responding';
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/inbox/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export interface ReconcileInput {
*
* Nothing prepares a draft, the reviewer's own pull request, or a bot's. A closed or merged one, or
* one no longer asking for the review, is retired but keeps whatever was prepared. A new commit
* makes a prepared review stale and worth redoing. Everything else asked of the reviewer is queued.
* makes a prepared review stale and worth redoing. One the reviewer dismissed stays dismissed,
* whatever the forge says next. Everything else asked of the reviewer is queued.
*/
export function reconcile(input: ReconcileInput): Transition | null {
const { existing, snapshot, requested, viewerLogin } = input;
Expand All @@ -38,6 +39,10 @@ export function reconcile(input: ReconcileInput): Transition | null {
return null;
}

if (existing?.status === 'dismissed') {
return null;
}

if (!requested) {
if (snapshot.state === 'MERGED') return settled('done', 'merged');
if (snapshot.state === 'CLOSED') return settled('done', 'closed');
Expand Down
Loading
Loading