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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,33 @@ Every command follows the running server's own session. `diffity agent --session
A comment's line range is trimmed to the file's length, and you are told when that happens: a range
running past the end would otherwise be counted and highlighted with nothing to show.

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

```bash
diffity inbox # run the watcher and a small status server
diffity inbox --once # run a single poll-and-prepare pass, then exit
diffity inbox status # print the current inbox without starting the daemon
diffity inbox status --json
```

On first run it writes `~/.diffity/inbox/config.json`:

| Key | Meaning |
|-----|---------|
| `pollMinutes` | How often GitHub is polled (default 5). |
| `port` | The status server's port (default 5390). |
| `reposDir` | Where your base clones live, one directory per repository name. |
| `worktreesDir` | Where each pull request gets its worktree. |
| `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. |

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

## Multiple projects

Diffity supports running multiple projects simultaneously. Each gets its own port automatically:
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.8",
"version": "0.10.9",
"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.8",
"version": "0.10.9",
"description": "Agent-agnostic, GitHub-style diff viewer and code review tool with a live agent loop",
"type": "module",
"bin": {
Expand Down
104 changes: 104 additions & 0 deletions packages/cli/src/commands/inbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { isCliInstalled, isAuthenticated } from '@diffity/github';
import { loadInboxConfig } from '../inbox/config.js';
import { inboxConfigPath, inboxStorePath } from '../inbox/paths.js';
import { InboxStore } from '../inbox/store.js';
import { runDaemon } from '../inbox/daemon.js';
import { buildView } from '../inbox/view.js';

export function registerInboxCommand(program: Command): void {
const inbox = program
.command('inbox')
.description('Watch the pull requests awaiting your review and prepare them ahead of time')
.option('--once', 'Run a single poll-and-prepare pass, then exit')
.option('--config <path>', 'Config file to use instead of the default')
.action(async (opts: { once?: boolean; config?: string }) => {
if (!isCliInstalled()) {
console.error(pc.red('Error: GitHub CLI (gh) is not installed.'));
process.exit(1);
}
if (!isAuthenticated()) {
console.error(pc.red('Error: Not authenticated with GitHub CLI. Run `gh auth login`.'));
process.exit(1);
}

const configPath = opts.config ?? inboxConfigPath();
let config;
try {
config = loadInboxConfig(configPath);
} catch (err) {
console.error(pc.red(`Error: ${err instanceof Error ? err.message : err}`));
process.exit(1);
}

const store = new InboxStore(inboxStorePath());
const entry = process.argv[1];
const log = (message: string) => console.log(`${pc.dim(new Date().toLocaleTimeString())} ${message}`);

if (opts.once) {
await runDaemon(store, config, process.execPath, entry, log, { once: true });
return;
}

// Armed before the first tick, which may be the longest one: Ctrl-C during it should stop
// cleanly rather than hard-exit and orphan a preparation.
let handleStop: (() => Promise<void>) | null = null;
const shutdown = () => {
(handleStop ? handleStop() : Promise.resolve()).then(() => process.exit(0));
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);

console.log(pc.green(`📋 diffity inbox on http://localhost:${config.port} — polling every ${config.pollMinutes} min. Ctrl-C to stop.`));
const handle = await runDaemon(store, config, process.execPath, entry, log);
handleStop = handle.stop;
});

inbox
.command('status')
.description('Print the current inbox without starting the daemon')
.option('--json', 'Output as JSON')
.option('--config <path>', 'Config file to use instead of the default')
.action((opts: { json?: boolean; config?: string }) => {
const config = loadInboxConfig(opts.config ?? inboxConfigPath());
const store = new InboxStore(inboxStorePath());
const view = buildView(store, `http://localhost:${config.port}`, new Date().toISOString());
store.close();

if (opts.json) {
console.log(JSON.stringify(view, null, 2));
return;
}

if (view.ready.length === 0 && view.working.length === 0 && view.other.length === 0) {
console.log(pc.dim('Nothing in the inbox yet. Run `diffity inbox` to start watching.'));
return;
}

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('Other', view.other.map(row =>
` ${pc.dim(row.status.padEnd(9))} ${row.repo}#${row.number} ${pc.dim(row.statusReason ?? '')}`,
));
});
}

function section(title: string, lines: string[]): void {
if (lines.length === 0) {
return;
}
console.log('');
console.log(pc.dim(title));
for (const line of lines) {
console.log(line);
}
}

function sizeBadge(row: { additions: number; deletions: number }): string {
return pc.dim(`+${row.additions}/-${row.deletions}`.padEnd(12));
}
137 changes: 137 additions & 0 deletions packages/cli/src/inbox/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join } from 'node:path';

export interface InboxConfig {
/** How often GitHub is asked; well inside its limits at a handful of calls per tick. */
pollMinutes: number;
port: number;
/** Where the base clones live, one directory per repository name. */
reposDir: string;
/** Where each pull request gets its own worktree. */
worktreesDir: string;
/**
* The reviewer's own words on what does and does not need their attention, handed to the
* preparing agent verbatim. Empty means everything asked of the reviewer is prepared.
*/
filter: string;
/**
* The agent, as argv; it runs in the pull request's worktree and reads its prompt on stdin. That
* worktree is code the pull request's author controls, so the daemon runs the agent without the
* forge's credentials in its environment — but the command itself still executes attacker-chosen
* repository scripts, so only point it at an agent you would run on an untrusted checkout.
*/
prepare: string[];
prepareTimeoutMinutes: number;
}

export const DEFAULT_INBOX_CONFIG: InboxConfig = {
pollMinutes: 5,
port: 5390,
reposDir: '~/repos',
worktreesDir: '~/.diffity/inbox/worktrees',
filter: '',
// Defence in depth on top of the stripped credentials: the agent is also denied the gh commands
// that could reach the pull request even if it tried.
prepare: [
'claude', '-p', '--dangerously-skip-permissions',
'--disallowedTools', 'Bash(gh pr review:*)', 'Bash(gh pr comment:*)', 'Bash(gh pr merge:*)', 'Bash(gh api:*)',
],
prepareTimeoutMinutes: 30,
};

/**
* Reads the config, writing the defaults first when there is none yet, so the reviewer finds a
* file to edit rather than a schema to guess. Missing keys take their defaults; wrong ones are
* refused by name.
*/
export function loadInboxConfig(path: string): InboxConfig {
if (!existsSync(path)) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(DEFAULT_INBOX_CONFIG, null, 2) + '\n');
return expandPaths(DEFAULT_INBOX_CONFIG);
}
let raw: unknown;
try {
raw = JSON.parse(readFileSync(path, 'utf-8'));
} catch (err) {
throw new Error(`${path} is not valid JSON: ${err instanceof Error ? err.message : err}`);
}
return expandPaths(parseInboxConfig(raw, path));
}

export function parseInboxConfig(raw: unknown, source = 'inbox config'): InboxConfig {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
throw new Error(`${source} must be a JSON object`);
}
const obj = raw as Record<string, unknown>;
const config: InboxConfig = { ...DEFAULT_INBOX_CONFIG };

if (obj.pollMinutes !== undefined) {
config.pollMinutes = positive(obj.pollMinutes, 'pollMinutes', source);
}
if (obj.port !== undefined) {
config.port = port(obj.port, source);
}
if (obj.reposDir !== undefined) {
config.reposDir = text(obj.reposDir, 'reposDir', source);
}
if (obj.worktreesDir !== undefined) {
config.worktreesDir = text(obj.worktreesDir, 'worktreesDir', source);
}
if (obj.filter !== undefined) {
if (typeof obj.filter !== 'string') {
throw new Error(`${source}: filter must be a string`);
}
config.filter = obj.filter;
}
if (obj.prepare !== undefined) {
if (!Array.isArray(obj.prepare) || obj.prepare.length === 0 || !obj.prepare.every(part => typeof part === 'string' && part !== '')) {
throw new Error(`${source}: prepare must be a non-empty array of strings (a command and its arguments)`);
}
config.prepare = obj.prepare as string[];
}
if (obj.prepareTimeoutMinutes !== undefined) {
config.prepareTimeoutMinutes = positive(obj.prepareTimeoutMinutes, 'prepareTimeoutMinutes', source);
}
return config;
}

function positive(value: unknown, key: string, source: string): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
throw new Error(`${source}: ${key} must be a positive number`);
}
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`);
}
return value;
}

function text(value: unknown, key: string, source: string): string {
if (typeof value !== 'string' || value === '') {
throw new Error(`${source}: ${key} must be a non-empty string`);
}
return value;
}

function expandPaths(config: InboxConfig): InboxConfig {
return {
...config,
reposDir: expandHome(config.reposDir),
worktreesDir: expandHome(config.worktreesDir),
};
}

export function expandHome(path: string): string {
if (path === '~') {
return homedir();
}
if (path.startsWith('~/')) {
return join(homedir(), path.slice(2));
}
return isAbsolute(path) ? path : join(process.cwd(), path);
}
Loading
Loading