From b76859a7e004083ac88c829003d90fee63a57da1 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Tue, 8 Sep 2026 10:38:00 +0200 Subject: [PATCH] feat: a pull request whose title matches a skipTitles pattern is skipped before any agent runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `skipTitles` holds regular-expression sources matched against the pull request title. A match is decided in the daemon — no worktree, no diffity server, no agent run — where the reviewer's free-text `filter` costs an agent run for every pull request it skips; the filter stays for what a regex cannot say. ↑ overrides a title skip as it overrides the filter and the CI hold, the title is answered before the CI hold (a skipped title has nothing to wait for), and the match is re-decided at every poll, so a retitle brings the pull request back to the queue. A review already prepared for an older head stays openable as `stale`, as it does under the CI hold. Patterns are compiled at parse time, so a typo is a refused config rather than a lost skip. The page's prepared cap is relabelled "Auto-prepare from queue": the number caps what the daemon prepares from the queue on its own, and a bumped pull request is prepared past it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Bwp5QefjsjMFeq6CK6cT6w --- README.md | 7 +- package-lock.json | 12 ++-- packages/api/package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/inbox/config.ts | 28 +++++++- packages/cli/src/inbox/daemon.ts | 3 +- packages/cli/src/inbox/page.ts | 11 ++- packages/cli/src/inbox/reconcile.ts | 61 ++++++++++++++-- packages/cli/src/inbox/settings.ts | 2 +- packages/cli/src/inbox/store.ts | 2 +- packages/cli/src/inbox/tick.ts | 4 +- packages/cli/tests/inbox-daemon.test.ts | 2 +- packages/cli/tests/inbox-open.test.ts | 8 +-- packages/cli/tests/inbox-page.test.ts | 17 +++++ packages/cli/tests/inbox-prepare.test.ts | 2 +- packages/cli/tests/inbox-reconcile.test.ts | 81 +++++++++++++++++++++- packages/cli/tests/inbox-runtime.test.ts | 2 +- packages/cli/tests/inbox-tick.test.ts | 13 ++++ packages/cli/tests/inbox-units.test.ts | 31 ++++++++- packages/git/package.json | 2 +- packages/github/package.json | 2 +- packages/parser/package.json | 2 +- packages/ui/package.json | 2 +- 23 files changed, 259 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 95b7311..d55db2a 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. | @@ -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. | diff --git a/package-lock.json b/package-lock.json index 858c31d..ce6d3db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8457,7 +8457,7 @@ }, "packages/api": { "name": "@diffity/api", - "version": "0.10.26", + "version": "0.10.27", "dependencies": { "@diffity/parser": "*" }, @@ -8468,7 +8468,7 @@ }, "packages/cli": { "name": "@naturalcycles/diffity", - "version": "0.10.26", + "version": "0.10.27", "license": "MIT", "dependencies": { "commander": "^14.0.3", @@ -8492,7 +8492,7 @@ }, "packages/git": { "name": "@diffity/git", - "version": "0.10.26", + "version": "0.10.27", "devDependencies": { "@types/node": "^25.5.0", "typescript": "^5.9.3", @@ -8501,7 +8501,7 @@ }, "packages/github": { "name": "@diffity/github", - "version": "0.10.26", + "version": "0.10.27", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*" @@ -8514,7 +8514,7 @@ }, "packages/parser": { "name": "@diffity/parser", - "version": "0.10.26", + "version": "0.10.27", "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0" @@ -8522,7 +8522,7 @@ }, "packages/ui": { "name": "@diffity/ui", - "version": "0.10.26", + "version": "0.10.27", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*", diff --git a/packages/api/package.json b/packages/api/package.json index e9e73da..1bb0e88 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/api", - "version": "0.10.26", + "version": "0.10.27", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 3c25504..4421ceb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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": { diff --git a/packages/cli/src/inbox/config.ts b/packages/cli/src/inbox/config.ts index 76ad4a8..0de3a24 100644 --- a/packages/cli/src/inbox/config.ts +++ b/packages/cli/src/inbox/config.ts @@ -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; @@ -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 }, @@ -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; - 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`); @@ -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`); @@ -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____. */ @@ -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; +export type InboxSettings = Pick; /** * Writes the page-editable settings into the config file, leaving every other key as the reviewer diff --git a/packages/cli/src/inbox/daemon.ts b/packages/cli/src/inbox/daemon.ts index 12424eb..b888f91 100644 --- a/packages/cli/src/inbox/daemon.ts +++ b/packages/cli/src/inbox/daemon.ts @@ -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, @@ -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; }, diff --git a/packages/cli/src/inbox/page.ts b/packages/cli/src/inbox/page.ts index 77614fd..89af531 100644 --- a/packages/cli/src/inbox/page.ts +++ b/packages/cli/src/inbox/page.ts @@ -117,7 +117,10 @@ export function inboxPage(): string {
Settings -