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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,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. 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 — 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 next, ahead of the queue and past the limit, with the filter set aside; the daemon picks it up at once. 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 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 next, ahead of the queue and past the limit, with the title patterns and the filter set aside; the daemon picks it up at once. 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.

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.

Expand All @@ -353,7 +353,8 @@ On first run it writes `~/.diffity/inbox/config.json`:
| `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"). Editable from the page's Settings panel. |
| `skipTitles` | Regular expressions matched against the title, one per line in the page — `\(payments\)`, `Release$`. A match skips the pull request before any agent runs, and ↑ overrides it; the match is re-decided at every poll, so a retitle brings the pull request back to the queue. For what a regex cannot express, `filter`. Editable from the page's Settings panel. |
| `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"). The agent has to load the skill and read the diff to decide, so every skip costs an agent run; `skipTitles` above costs nothing. Editable from the page's Settings panel. |
| `alertWhen` | Your own words on what needs you *now*. The agent judges each prepared review against them and flags the ones that match; the page notifies for those only — empty means every prepared review. Editable from the page's Settings panel. |
| `alertPaths` | Globs against the pull request's changed paths, one per line in the page — `packages/shared/src/model/**`, `**/dbref/**`. A changed file matching one marks the review as needing you now, whatever the agent made of `alertWhen`. Editable from the page's Settings panel. |
| `agent.model` | `--model` for the review agent; `null` leaves its own default. Editable from the page. |
Expand All @@ -366,7 +367,7 @@ On first run it writes `~/.diffity/inbox/config.json`:
| `validate.maxBudgetUsd` | `--max-budget-usd` for the checking run; `null` leaves it uncapped. Editable from the page. |
| `waitForCi` | Whether a pull request waits for green CI before an agent is spent on it (default false). Editable from the page. |
| `prepareTimeoutMinutes` | How long one preparation may take before it's abandoned. Editable from the page. |
| `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. Editable from the page. |
| `maxPrepared` | How many the daemon prepares from the queue on its own (default 5); a bumped pull request is prepared past it. Each preparation is an agent run; the rest of the queue waits until a prepared review is posted or dismissed. Editable from the page, as "Auto-prepare from queue". |
| `live` | Whether opening a prepared review also parks a live agent on it (default true). Questions asked in the page — the Ask button on a finding — each run the agent once to answer; the agent may answer and amend findings, never edit code, and never reaches GitHub. Editable from the page. |
| `liveTimeoutMinutes` | How long one answer may take before the agent is stopped (default 10). Editable from the page. |

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.26",
"version": "0.10.27",
"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.26",
"version": "0.10.27",
"description": "Agent-agnostic, GitHub-style diff viewer and code review tool with a live agent loop",
"type": "module",
"bin": {
Expand Down
28 changes: 26 additions & 2 deletions packages/cli/src/inbox/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export interface InboxConfig {
* preparing agent verbatim. Empty means everything asked of the reviewer is prepared.
*/
filter: string;
/**
* Regular-expression sources matched against the pull request title; a match skips the pull
* request before an agent is spent on it.
*/
skipTitles: string[];
/**
* The reviewer's own words on what needs their attention now. The preparing agent judges each
* review against them and marks the ones that match, and the inbox page notifies for those only;
Expand Down Expand Up @@ -86,6 +91,7 @@ export const DEFAULT_INBOX_CONFIG: InboxConfig = {
reposDir: '~/repos',
worktreesDir: '~/.diffity/inbox/worktrees',
filter: '',
skipTitles: [],
alertWhen: '',
alertPaths: [],
agent: { model: null, effort: null, mcpAllow: [], extraArgs: [], maxBudgetUsd: null },
Expand Down Expand Up @@ -122,7 +128,7 @@ export function parseInboxConfig(raw: unknown, source = 'inbox config'): InboxCo
throw new Error(`${source} must be a JSON object`);
}
const obj = raw as Record<string, unknown>;
const config: InboxConfig = { ...DEFAULT_INBOX_CONFIG, alertPaths: [], agent: defaultAgent(), validate: defaultValidate() };
const config: InboxConfig = { ...DEFAULT_INBOX_CONFIG, alertPaths: [], skipTitles: [], agent: defaultAgent(), validate: defaultValidate() };

if (obj.prepare !== undefined) {
throw new Error(`${source}: "prepare" was replaced by the "agent" block — delete it (the built-in command applies) and put extra flags in agent.extraArgs`);
Expand All @@ -145,6 +151,9 @@ export function parseInboxConfig(raw: unknown, source = 'inbox config'): InboxCo
}
config.filter = obj.filter;
}
if (obj.skipTitles !== undefined) {
config.skipTitles = parseSkipTitles(obj.skipTitles, source);
}
if (obj.alertWhen !== undefined) {
if (typeof obj.alertWhen !== 'string') {
throw new Error(`${source}: alertWhen must be a string`);
Expand Down Expand Up @@ -187,6 +196,21 @@ export function parseInboxConfig(raw: unknown, source = 'inbox config'): InboxCo
return config;
}

/** Compiled here rather than at the first poll, so a typo is a refused config and not a lost skip. */
function parseSkipTitles(raw: unknown, source: string): string[] {
if (!Array.isArray(raw) || !raw.every(pattern => typeof pattern === 'string' && pattern.trim() !== '')) {
throw new Error(`${source}: skipTitles must be an array of non-empty regular expressions`);
}
return (raw as string[]).map((pattern, index) => {
try {
new RegExp(pattern);
} catch (err) {
throw new Error(`${source}: skipTitles[${index}] is not a valid regular expression: ${err instanceof Error ? err.message : err}`);
}
return pattern;
});
}

const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];

/** An exact MCP tool name, as the reviewer's client reports it: mcp__<server>__<tool>. */
Expand Down Expand Up @@ -255,7 +279,7 @@ function parseValidateConfig(raw: unknown, source: string): ValidateConfig {
}

/** The settings the inbox page edits, kept in the config file beside the keys only the file holds. */
export type InboxSettings = Pick<InboxConfig, 'filter' | 'alertWhen' | 'alertPaths' | 'maxPrepared' | 'pollMinutes' | 'live' | 'liveTimeoutMinutes' | 'prepareTimeoutMinutes' | 'waitForCi' | 'agent' | 'validate'>;
export type InboxSettings = Pick<InboxConfig, 'filter' | 'skipTitles' | 'alertWhen' | 'alertPaths' | 'maxPrepared' | 'pollMinutes' | 'live' | 'liveTimeoutMinutes' | 'prepareTimeoutMinutes' | 'waitForCi' | 'agent' | 'validate'>;

/**
* Writes the page-editable settings into the config file, leaving every other key as the reviewer
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/inbox/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export interface SettingsHost {
export function settingsHost(config: InboxConfig, configPath: string | undefined, onPollChanged: () => void = () => {}): SettingsHost {
return {
get: () => ({
filter: config.filter, alertWhen: config.alertWhen, alertPaths: config.alertPaths,
filter: config.filter, skipTitles: config.skipTitles, alertWhen: config.alertWhen, alertPaths: config.alertPaths,
maxPrepared: config.maxPrepared, pollMinutes: config.pollMinutes,
live: config.live, liveTimeoutMinutes: config.liveTimeoutMinutes, prepareTimeoutMinutes: config.prepareTimeoutMinutes,
waitForCi: config.waitForCi, agent: config.agent, validate: config.validate,
Expand Down Expand Up @@ -133,6 +133,7 @@ export async function runDaemon(
// Read at each tick, not copied: the page can change it while the daemon runs.
get maxPrepared() { return config.maxPrepared; },
get waitForCi() { return config.waitForCi; },
get skipTitles() { return config.skipTitles; },
get alertPaths() { return config.alertPaths; },
get agentModel() { return config.agent.model; },
get validateModel() { return config.validate.model; },
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/inbox/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,10 @@ export function inboxPage(): string {
<div id="all-empty" class="empty" hidden>Nothing waiting for your review right now.</div>
<details class="settings" id="settings">
<summary>Settings</summary>
<label>Skip PRs if:
<label>Skip pull requests whose title matches (one regular expression per line):
<textarea id="skipTitles" rows="3" placeholder="e.g. \\(payments\\) or Release$"></textarea>
</label>
<label>Skip PRs if (judged by the review agent, which spends a run on every pull request it skips; the title patterns above cost nothing):
<textarea id="filter" rows="3" placeholder="e.g. the change is payments-focused, or only touches translations"></textarea>
</label>
<label>Notify me if:
Expand All @@ -130,7 +133,7 @@ export function inboxPage(): string {
<textarea id="agentMcpAllow" rows="3" placeholder="e.g. mcp__claude_ai_Atlassian__getJiraIssue (empty: no MCP servers at all)"></textarea>
</label>
<div class="settings-grid">
<label>Max prepared at once<input id="maxPrepared" type="number" min="1" step="1"></label>
<label>Auto-prepare from queue<input id="maxPrepared" type="number" min="1" step="1"></label>
<label>Poll every (minutes)<input id="pollMinutes" type="number" min="1" step="1"></label>
<label>Preparation timeout (minutes)<input id="prepareTimeoutMinutes" type="number" min="1" step="1"></label>
<label>Answer timeout (minutes)<input id="liveTimeoutMinutes" type="number" min="1" step="1"></label>
Expand Down Expand Up @@ -255,7 +258,7 @@ export function inboxPage(): string {
const up = document.createElement('button');
up.type = 'button';
up.className = 'bump';
up.title = 'Prepare this one next: ahead of the queue, past the limit, filter set aside';
up.title = 'Prepare this one next: ahead of the queue, past the limit, the skips set aside';
up.textContent = '\\u2191';
up.onclick = () => bump(r);
wrap.append(up);
Expand Down Expand Up @@ -358,6 +361,7 @@ export function inboxPage(): string {
if (!res.ok) return;
settings = await res.json();
el('filter').value = settings.filter;
el('skipTitles').value = (settings.skipTitles || []).join('\\n');
el('alertWhen').value = settings.alertWhen;
el('alertPaths').value = (settings.alertPaths || []).join('\\n');
for (const key of ['maxPrepared', 'pollMinutes', 'prepareTimeoutMinutes', 'liveTimeoutMinutes']) el(key).value = settings[key];
Expand All @@ -382,6 +386,7 @@ export function inboxPage(): string {
const checkBudget = el('validateMaxBudgetUsd').value.trim();
const next = {
filter: el('filter').value,
skipTitles: el('skipTitles').value.split('\\n').map(line => line.trim()).filter(Boolean),
alertWhen: el('alertWhen').value,
alertPaths: el('alertPaths').value.split('\\n').map(line => line.trim()).filter(Boolean),
maxPrepared: Number(el('maxPrepared').value),
Expand Down
61 changes: 56 additions & 5 deletions packages/cli/src/inbox/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,19 @@ export interface ReconcileInput {
* config's default is.
*/
waitForCi?: boolean;
/**
* Regular-expression sources the reviewer wants skipped by title; absent skips nothing, as the
* config's default does.
*/
skipTitles?: string[];
}

/** The mark of a CI hold on a row, so the next poll re-decides it instead of leaving it settled. */
const CI_FAILED = 'CI failed:';

/** The same for a title skip: a retitled pull request is re-decided rather than left settled. */
const TITLE_MATCHES = 'title matches';

/**
* The status a pull request should move to, given what the forge now says and what the inbox
* already did — the whole decision in one pure function, so every branch is a plain test.
Expand All @@ -38,11 +46,53 @@ const CI_FAILED = 'CI failed:';
* 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. One the reviewer dismissed stays dismissed until
* it gets new commits; one they bumped is prepared whatever else would have held it back, drafts
* apart. Everything else asked of the reviewer is queued.
* apart. A title matching one of the reviewer's patterns is skipped before any agent is spent on
* it. Everything else asked of the reviewer is queued.
*/
export function reconcile(input: ReconcileInput): Transition | null {
const transition = decide(input);
return transition && input.waitForCi ? heldForCi(transition, input) : transition;
if (!transition) {
return null;
}
// A title the reviewer said to skip is answered before CI: there is nothing to wait for.
return skippedByTitle(transition, input)
?? (input.waitForCi ? heldForCi(transition, input) : transition);
}

/** Which pattern a title matches, as the reason on the row, or null when none does. */
export function titleSkipReason(title: string, skipTitles: string[]): string | null {
for (const pattern of skipTitles) {
let regex: RegExp;
try {
regex = new RegExp(pattern);
} catch {
// The config refuses a pattern that does not compile; one that got here anyway is not worth
// a failed poll.
continue;
}
if (regex.test(title)) {
return `${TITLE_MATCHES} /${pattern}/`;
}
}
return null;
}

/**
* What the reviewer's title patterns do to a transition that was about to spend an agent: nothing
* is spent on a title they said to skip. As with the CI hold, a bumped pull request is prepared
* regardless, and a review already prepared for an older head stays openable.
*/
function skippedByTitle(transition: Transition, input: ReconcileInput): Transition | null {
const { existing, snapshot } = input;
if (!transition.prepare || !snapshot || existing?.bumpedAt) {
return null;
}
const reason = titleSkipReason(snapshot.title, input.skipTitles ?? []);
if (!reason) {
return null;
}
const refresh = existing?.status === 'prepared' || existing?.status === 'stale';
return { status: refresh ? 'stale' : 'skipped', reason, prepare: false };
}

/**
Expand Down Expand Up @@ -127,10 +177,11 @@ function decide(input: ReconcileInput): Transition | null {
}

// A settled skip stays settled until its head moves; re-running the filter on every poll would
// just spend the same tokens on the same answer. A CI failure is not the reviewer's verdict on
// the pull request, though: it is re-decided every poll, and green checks put it back in the queue.
// just spend the same tokens on the same answer. A CI failure and a title match cost nothing to
// decide, though: both are re-decided every poll, so green checks and a retitle put the pull
// request back in the queue.
if (existing && existing.status === 'skipped' && existing.headSha === snapshot.headSha
&& !existing.statusReason?.startsWith(CI_FAILED)) {
&& !existing.statusReason?.startsWith(CI_FAILED) && !existing.statusReason?.startsWith(TITLE_MATCHES)) {
return null;
}

Expand Down
Loading
Loading