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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ On first run it writes `~/.diffity/inbox/config.json`:
| `worktreesDir` | Where each pull request gets its worktree. |
| `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. |
| `alertWhen` | Your own words on what needs you *now*. The agent judges each prepared review against them and flags the ones that match, naming the findings behind the flag; the flagged ones are listed under **Alerted**, above Ready, and 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. |
| `agent.effort` | `--effort`: `low`, `medium`, `high`, `xhigh` or `max`; `null` leaves its own default. 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.29",
"version": "0.10.30",
"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.29",
"version": "0.10.30",
"description": "Agent-agnostic, GitHub-style diff viewer and code review tool with a live agent loop",
"type": "module",
"bin": {
Expand Down
21 changes: 15 additions & 6 deletions packages/cli/src/commands/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { inboxConfigPath, inboxStorePath } from '../inbox/paths.js';
import { InboxStore, type RunTotals } from '../inbox/store.js';
import { runDaemon } from '../inbox/daemon.js';
import { allowFromEnv, mcpGateDecision } from '../inbox/mcp-gate.js';
import { buildView } from '../inbox/view.js';
import { buildView, type InboxRow } from '../inbox/view.js';
import { localHhMm, localWhen, minutesOf, money, tokensLabel } from '../inbox/runs.js';

export function registerInboxCommand(program: Command): void {
Expand Down Expand Up @@ -87,16 +87,15 @@ export function registerInboxCommand(program: Command): void {
return;
}

if (view.ready.length === 0 && view.working.length === 0 && view.handled.length === 0
&& view.other.length === 0 && view.dismissed.length === 0) {
if (view.alerted.length === 0 && 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;
}

section('Ready to review', view.ready.map(row =>
` ${sizeBadge(row)} ${pc.bold(`${row.repo}#${row.number}`)} ${row.title}${row.summary ? pc.dim(` ${row.summary}`) : ''}${row.alert ? pc.red(` ⚠ ${row.alert}`) : ''}${row.stale ? pc.yellow(' (stale — new commits)') : ''}`,
));
section('Alerted', view.alerted.map(preparedLine));
section('Ready to review', view.ready.map(preparedLine));
section('Queue', view.working.map(row =>
` ${pc.dim(row.status.padEnd(9))} ${row.repo}#${row.number} ${row.title} ${pc.dim(row.statusReason ?? '')}`,
));
Expand Down Expand Up @@ -199,6 +198,16 @@ function section(title: string, lines: string[]): void {
}
}

/** A prepared review as one line: its size, what was found, and what was raised about it. */
function preparedLine(row: InboxRow): string {
const findings = row.alertFindings.length;
return ` ${sizeBadge(row)} ${pc.bold(`${row.repo}#${row.number}`)} ${row.title}`
+ `${row.summary ? pc.dim(` ${row.summary}`) : ''}`
+ `${row.alert ? pc.red(` ⚠ ${row.alert}`) : ''}`
+ `${findings ? pc.red(` ${findings} finding${findings === 1 ? '' : 's'}`) : ''}`
+ `${row.stale ? pc.yellow(' (stale — new commits)') : ''}`;
}

function sizeBadge(row: { additions: number; deletions: number }): string {
return pc.dim(`+${row.additions}/-${row.deletions}`.padEnd(12));
}
26 changes: 21 additions & 5 deletions packages/cli/src/inbox/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ export function inboxPage(): string {
</span>
</header>
<main>
<section id="alerted-section" hidden>
<h2>Alerted</h2>
<div id="alerted"></div>
</section>
<section id="ready-section" hidden>
<h2>Ready to review</h2>
<div id="ready"></div>
Expand Down Expand Up @@ -215,6 +219,12 @@ export function inboxPage(): string {
return String(at.getHours()).padStart(2, '0') + ':' + String(at.getMinutes()).padStart(2, '0');
}

/** How many findings the agent named as the reason for the alert, when it named any. */
function findingsLabel(r) {
const named = (r.alertFindings || []).length;
return named ? named + ' finding' + (named === 1 ? '' : 's') : '';
}

/** What the agent runs behind a prepared review came to; the hover has them one by one. */
function spendLabel(r) {
return r.spend ? Math.round(r.spend.minutes) + ' min \\u00b7 ' + money(r.spend.costUsd) : '';
Expand Down Expand Up @@ -247,8 +257,8 @@ export function inboxPage(): string {
ciDot(r) +
'<span class="title"><div><span class="repo">' + esc(r.repo) + '#' + r.number + '</span> ' +
'<span class="name">' + esc(r.title) + '</span></div>' +
metaLine(['by ' + esc(r.author), r.changedFiles + ' file(s)', esc(r.summary || ''), spendLabel(r), times(r)],
r.spend ? r.spend.detail : '') + '</span>' +
metaLine(['by ' + esc(r.author), r.changedFiles + ' file(s)', esc(r.summary || ''), esc(r.alert || ''),
findingsLabel(r), spendLabel(r), times(r)], r.spend ? r.spend.detail : '') + '</span>' +
(r.alert ? '<span class="badge alert" title="' + esc(r.alert) + '">alert</span>' : '') +
(r.stale ? '<span class="badge stale">stale</span>' : '') +
'<span class="open-hint">open \\u2197</span>';
Expand Down Expand Up @@ -372,7 +382,7 @@ export function inboxPage(): string {
}

function announce(view) {
const current = new Map(view.ready.map(r => [r.id + '@' + (r.preparedAt || ''), r]));
const current = new Map([...view.alerted, ...view.ready].map(r => [r.id + '@' + (r.preparedAt || ''), r]));
if (known !== null && canNotify()) {
for (const [key, r] of current) {
if (known.has(key)) continue;
Expand Down Expand Up @@ -483,6 +493,7 @@ export function inboxPage(): string {
const res = await fetch('/api/inbox', { cache: 'no-store' });
const view = await res.json();
announce(view);
fill('alerted-section', 'alerted', view.alerted, r => withActions(readyRow(r), r));
fill('ready-section', 'ready', view.ready, r => withActions(readyRow(r), r));
fill('working-section', 'working', view.working, r => withActions(workingRow(r), r));
fill('handled-section', 'handled', view.handled, r => withActions(handledRow(r), r, REPREPARE_TITLE));
Expand All @@ -491,9 +502,14 @@ export function inboxPage(): string {
return withActions(plainRow(r, bad ? 'bad' : 'work', r.status), r);
});
fill('dismissed-section', 'dismissed', view.dismissed, r => withActions(plainRow(r, 'work', 'dismissed'), r));
const total = view.ready.length + view.working.length + view.handled.length + view.other.length + view.dismissed.length;
const total = view.alerted.length + view.ready.length + view.working.length + view.handled.length
+ view.other.length + view.dismissed.length;
el('all-empty').hidden = total > 0;
el('status').textContent = view.ready.length + ' ready \\u00b7 ' + view.working.length + ' queued';
el('status').textContent = [
view.alerted.length ? view.alerted.length + ' alerted' : '',
view.ready.length + ' ready',
view.working.length + ' queued',
].filter(Boolean).join(' \\u00b7 ');
showReload(view.ticking === true);
el('foot').textContent = [
'Updated ' + new Date().toLocaleTimeString() + (view.lastPollAt ? ' \\u00b7 last poll ' + ago(view.lastPollAt) : ''),
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/inbox/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export interface ValidateRun extends RunLog {
}

export type PrepareResult =
| { kind: 'prepared'; headSha: string; bundlePath: string; worktree: string; logPath: string; at: string; summary: string | null; alert: string | null; run: RunLog; validation: Validation; validateRun: ValidateRun | null }
| { kind: 'prepared'; headSha: string; bundlePath: string; worktree: string; logPath: string; at: string; summary: string | null; alert: string | null; alertFindings: string[]; run: RunLog; validation: Validation; validateRun: ValidateRun | null }
| { kind: 'skipped'; reason: string; logPath: string; run: RunLog }
| { kind: 'failed'; reason: string; failure: PrepareFailure; worktree: string | null; logPath: string | null; run: RunLog; resetsAt?: string | null };

Expand Down Expand Up @@ -192,7 +192,7 @@ export async function preparePr(snapshot: PrSnapshot, config: InboxConfig, deps:
return {
kind: 'prepared', headSha: head, bundlePath, worktree: dest, logPath, at: deps.now(),
summary: withValidation(summarizeBundleFile(bundlePath), validation), alert: verdict.alert,
run, validation, validateRun,
alertFindings: verdict.alertFindings, run, validation, validateRun,
};
} catch (err) {
return { kind: 'failed', failure: 'agent', reason: err instanceof Error ? err.message : String(err), worktree: dest, logPath, run };
Expand Down
44 changes: 34 additions & 10 deletions packages/cli/src/inbox/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,12 @@ export function composePrompt(ctx: PromptContext): string {
'',
indent(alertWhen.trim()),
'',
'If it does, print exactly one line, before the final line below:',
'If it does, print this line, before the final line below:',
' ALERT: <short reason>',
'and, when particular findings are the reason, a second line naming them:',
' ALERT-FINDINGS: <thread id> <thread id> \u2026',
'The ids are the ones `diffity agent comment` printed ("Created thread cf15e689"), separated',
'by spaces; name the findings the author should see now.',
'If it does not, print nothing about it.',
'',
);
Expand Down Expand Up @@ -128,10 +132,10 @@ function checkName(name: string): string {

/**
* What the agent's run amounted to, read from the last verdict line it printed; a prepared review
* carries the alert the agent raised on the way, if any.
* carries the alert the agent raised on the way, if any, and the findings it named as the reason.
*/
export type Verdict =
| { kind: 'prepared'; alert: string | null }
| { kind: 'prepared'; alert: string | null; alertFindings: string[] }
| { kind: 'skipped'; reason: string }
| { kind: 'none' };

Expand All @@ -141,7 +145,8 @@ export function verdictOf(stdout: string): Verdict {
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line === 'PREPARED') {
return { kind: 'prepared', alert: alertBefore(lines, i) };
const { alert, findings } = alertBefore(lines, i);
return { kind: 'prepared', alert, alertFindings: findings };
}
const skip = /^SKIP:\s*(.*)$/.exec(line);
if (skip) {
Expand All @@ -151,15 +156,34 @@ export function verdictOf(stdout: string): Verdict {
return { kind: 'none' };
}

/** The agent's ALERT line, if it printed one on the way to PREPARED; the last one counts. */
function alertBefore(lines: string[], preparedAt: number): string | null {
/**
* The agent's ALERT line, if it printed one on the way to PREPARED, and the findings its
* ALERT-FINDINGS line named as the reason; the last of each counts, in whichever order they were
* printed. Findings without an alert say nothing the reviewer asked to hear about, so they go with it.
*/
function alertBefore(lines: string[], preparedAt: number): { alert: string | null; findings: string[] } {
let alert: string | null = null;
let findings: string[] | null = null;
for (let i = preparedAt - 1; i >= 0; i--) {
const alert = /^ALERT:\s*(.*)$/.exec(lines[i]);
if (alert) {
return alert[1].trim() || 'no reason given';
const named = /^ALERT-FINDINGS:\s*(.*)$/.exec(lines[i]);
if (named) {
findings ??= threadIdsOf(named[1]);
continue;
}
const raised = /^ALERT:\s*(.*)$/.exec(lines[i]);
if (raised) {
alert ??= raised[1].trim() || 'no reason given';
}
}
return null;
return { alert, findings: alert === null ? [] : findings ?? [] };
}

/** A thread id as `diffity agent comment` prints it, or the full uuid behind it. */
const THREAD_ID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?$/;

/** The thread ids on an ALERT-FINDINGS line; anything that is not one is not a finding to look up. */
function threadIdsOf(text: string): string[] {
return [...new Set(text.toLowerCase().split(/[\s,]+/).filter(token => THREAD_ID.test(token)))];
}

function indent(text: string): string {
Expand Down
32 changes: 29 additions & 3 deletions packages/cli/src/inbox/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface InboxPr {
preparedAt: string | null;
summary: string | null;
alert: string | null;
/** The threads the agent named as the reason for the alert; empty when it named none. */
alertFindings: string[];
bundlePath: string | null;
worktreePath: string | null;
logPath: string | null;
Expand All @@ -71,6 +73,8 @@ export interface Prepared {
summary: string | null;
/** Why the agent judged this one to need the reviewer now, when it did. */
alert: string | null;
/** The threads it named as the reason, by id; an alert on the reviewer's own paths names none. */
alertFindings: string[];
}

/** A review diffity posted to the forge: the head it was posted against, and what it said. */
Expand Down Expand Up @@ -233,6 +237,7 @@ export class InboxStore {
bumped_at TEXT,
summary TEXT,
alert TEXT,
alert_findings TEXT,
ci_state TEXT
)
`);
Expand Down Expand Up @@ -270,7 +275,7 @@ export class InboxStore {
this.db.exec('CREATE INDEX IF NOT EXISTS inbox_handled_pr_at ON inbox_handled (pr_id, at)');
this.db.exec('CREATE TABLE IF NOT EXISTS inbox_state (key TEXT PRIMARY KEY, value TEXT)');
// A table from an earlier build gains the columns it lacks; a fresh one already has them.
for (const column of ['attempts INTEGER NOT NULL DEFAULT 0', 'created_at TEXT', 'updated_at TEXT', 'bumped_at TEXT', 'summary TEXT', 'alert TEXT', 'ci_state TEXT']) {
for (const column of ['attempts INTEGER NOT NULL DEFAULT 0', 'created_at TEXT', 'updated_at TEXT', 'bumped_at TEXT', 'summary TEXT', 'alert TEXT', 'alert_findings TEXT', 'ci_state TEXT']) {
try {
this.db.exec(`ALTER TABLE inbox_prs ADD COLUMN ${column}`);
} catch (err) {
Expand Down Expand Up @@ -357,9 +362,12 @@ export class InboxStore {
this.db.prepare(`
UPDATE inbox_prs
SET status = 'prepared', status_reason = NULL, prepared_head_sha = ?, prepared_at = ?,
bundle_path = ?, worktree_path = ?, log_path = ?, summary = ?, alert = ?
bundle_path = ?, worktree_path = ?, log_path = ?, summary = ?, alert = ?, alert_findings = ?
WHERE id = ?
`).run(prepared.headSha, prepared.at, prepared.bundlePath, prepared.worktreePath, prepared.logPath, prepared.summary, prepared.alert, id);
`).run(
prepared.headSha, prepared.at, prepared.bundlePath, prepared.worktreePath, prepared.logPath,
prepared.summary, prepared.alert, JSON.stringify(prepared.alertFindings), id,
);
}

/**
Expand Down Expand Up @@ -493,6 +501,7 @@ interface Row {
bumped_at: string | null;
summary: string | null;
alert: string | null;
alert_findings: string | null;
ci_state: string | null;
}

Expand Down Expand Up @@ -523,6 +532,7 @@ function rowToPr(row: Row): InboxPr {
preparedAt: row.prepared_at,
summary: row.summary,
alert: row.alert,
alertFindings: parseAlertFindings(row.alert_findings),
bundlePath: row.bundle_path,
worktreePath: row.worktree_path,
logPath: row.log_path,
Expand Down Expand Up @@ -588,6 +598,22 @@ function normaliseCiState(value: string | null): CiState | null {
return value !== null && CI_STATES.includes(value) ? (value as CiState) : null;
}

/**
* The thread ids behind an alert, as the column keeps them. A row from before the column existed
* has none, and so does one whose value is not a list of ids.
*/
function parseAlertFindings(value: string | null): string[] {
if (value === null) {
return [];
}
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
} catch {
return [];
}
}

/** A mark from a build that posted other kinds of review still reads as something the reviewer said. */
function normaliseEvent(value: string): ReviewEvent {
return (REVIEW_EVENTS as readonly string[]).includes(value) ? (value as ReviewEvent) : 'COMMENT';
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/inbox/tick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ async function prepareOne(store: InboxStore, snapshot: PrSnapshot, deps: TickDep
summary: result.summary,
// The agent's judgement first; the reviewer's own paths stand in when it raised nothing.
alert: result.alert ?? alertForPaths(snapshot.files, deps.alertPaths),
// Only the agent names findings, so a path alert stands on its own with none.
alertFindings: result.alertFindings,
});
deps.log(`prepared ${id}`);
if (result.validateRun?.note) {
Expand Down
Loading
Loading