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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,9 @@ running past the end would otherwise be counted and highlighted with nothing to

Every review you post from diffity is noted against its pull request, with the commit it was posted against, and the pull request then stays under **Handled** instead of vanishing when GitHub withdraws the review request. The card links to the pull request itself — the worktree is reclaimed once the review is out — and says what you said: "you approved", "you requested changes", "you commented", and when. When the author pushes after your review, the card moves to the top of the list, reads "new commits since you approved" and is badged `updated`, so a pull request that has come back to you is not something you have to remember; ↑ prepares a fresh review of the current head, re-request or not, and × sets the row aside until the next push. A pull request leaves the list when the author asks for a new review — the search lists it again and it goes back in the queue like anything else — or when it is merged or closed. This counts reviews posted from any diffity, so one you posted from your own clone brings its pull request into the list at the next poll, at the cost of one `gh pr view`. `diffity inbox status` prints the same list.

The daemon never posts your prepared reviews to GitHub unless you turn `postAlerts` on — they are local drafts you open and submit yourself. With it on, the only thing that goes out is the findings the agent named as the reason for an alert: one `COMMENT` review in your name, never an approval or a request for changes, every comment opening with `postPrefix` so nobody reads it as your verdict, and at most once per head. Those findings stay in the prepared review marked as already sent, so your own submit does not send them twice, and the pull request stays listed as awaiting you even though a submitted review withdraws the request — the daemon's post is what consumed it — until you review it yourself, dismiss it, or it is merged or closed. The post is the daemon's own call to `gh`, made after the agent has finished: the review agent still runs with your GitHub credentials stripped from its environment, and 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 all of this as the daemon's design, not a sandbox.
The preparing agent gets more than the diff. The pull request's description travels in its prompt, and the discussion so far — comments on the pull request, submitted reviews, and the inline review comments those reviews left — is written as `pr-context.json` in the pull request's own diffity data directory for the agent to read, never into the worktree, where an untracked file would turn up in the review's own diff. Both are framed as text written by the author and other commenters: information about the change, never instructions. The daemon fetches them with its own `gh`, because the agent runs with your credentials stripped; when the forge cannot be read, the review is prepared without them and the reason is logged.

The daemon never posts your prepared reviews to GitHub unless you turn `postAlerts` on — they are local drafts you open and submit yourself. With it on, the only thing that goes out is the findings the agent named as the reason for an alert: one `COMMENT` review in your name, never an approval or a request for changes, every comment opening with `postPrefix` so nobody reads it as your verdict, and at most once per head. `postFooter` ends the review's own body under a blank line, which is where a team mention or an "automated triage, not a review" line belongs: the inline comments never carry it, so it fires once for the review rather than once per finding. Those findings stay in the prepared review marked as already sent, so your own submit does not send them twice, and the pull request stays listed as awaiting you even though a submitted review withdraws the request — the daemon's post is what consumed it — until you review it yourself, dismiss it, or it is merged or closed. The post is the daemon's own call to `gh`, made after the agent has finished: the review agent still runs with your GitHub credentials stripped from its environment, and 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 all of this as the daemon's design, not a sandbox.

```bash
diffity inbox # run the watcher and a small status server
Expand All @@ -361,6 +363,7 @@ On first run it writes `~/.diffity/inbox/config.json`:
| `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. |
| `postAlerts` | Whether the daemon posts the findings behind an `alertWhen` alert to the pull request itself, as a `COMMENT` review in your name at most once per head (default false). An alert raised by `alertPaths` posts nothing — that is your own rule about the paths, with nothing in it to tell the author. Editable from the page's Settings panel. |
| `postPrefix` | What every posted comment opens with, so nobody reads one as a verdict you have stood behind (default `[Automated AI pre-review, not yet checked by human]`). Must not be empty while `postAlerts` is on. Editable from the page's Settings panel. |
| `postFooter` | Ends the posted review's own body, under a blank line: a team to mention, a line saying what posted this (default empty, which adds nothing). Only the review body carries it, so a mention fires once for the review rather than once per finding. 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. |
| `agent.mcpAllow` | The exact MCP tool names the agent may call, e.g. `mcp__claude_ai_Atlassian__getJiraIssue`. Empty (the default) means no MCP servers at all. 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.31",
"version": "0.10.32",
"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.31",
"version": "0.10.32",
"description": "Agent-agnostic, GitHub-style diff viewer and code review tool with a live agent loop",
"type": "module",
"bin": {
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/inbox/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export interface InboxConfig {
postAlerts: boolean;
/** Opens every posted comment, so nobody reads one as a verdict a human has stood behind. */
postPrefix: string;
/**
* Ends the posted review's own body, under a blank line: a team to mention, a line saying what
* posted this. Empty adds nothing. The inline comments never carry it — a mention there would
* fire once per finding.
*/
postFooter: string;
agent: AgentConfig;
validate: ValidateConfig;
/** Whether a pull request waits for its CI to pass before an agent is spent on it. */
Expand Down Expand Up @@ -105,6 +111,7 @@ export const DEFAULT_INBOX_CONFIG: InboxConfig = {
alertPaths: [],
postAlerts: false,
postPrefix: '[Automated AI pre-review, not yet checked by human]',
postFooter: '',
agent: { model: null, effort: null, mcpAllow: [], extraArgs: [], maxBudgetUsd: null },
validate: { model: null, timeoutMinutes: 15, maxBudgetUsd: null },
waitForCi: false,
Expand Down Expand Up @@ -189,6 +196,12 @@ export function parseInboxConfig(raw: unknown, source = 'inbox config'): InboxCo
}
config.postPrefix = obj.postPrefix;
}
if (obj.postFooter !== undefined) {
if (typeof obj.postFooter !== 'string') {
throw new Error(`${source}: postFooter must be a string`);
}
config.postFooter = obj.postFooter;
}
// Nothing goes to a pull request unprefixed: the prefix is what tells the author no human has
// stood behind the finding yet.
if (config.postAlerts && config.postPrefix.trim() === '') {
Expand Down Expand Up @@ -307,7 +320,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' | 'skipTitles' | 'alertWhen' | 'alertPaths' | 'postAlerts' | 'postPrefix' | 'maxPrepared' | 'pollMinutes' | 'live' | 'liveTimeoutMinutes' | 'prepareTimeoutMinutes' | 'waitForCi' | 'agent' | 'validate'>;
export type InboxSettings = Pick<InboxConfig, 'filter' | 'skipTitles' | 'alertWhen' | 'alertPaths' | 'postAlerts' | 'postPrefix' | 'postFooter' | '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
2 changes: 1 addition & 1 deletion packages/cli/src/inbox/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function settingsHost(config: InboxConfig, configPath: string | undefined
return {
get: () => ({
filter: config.filter, skipTitles: config.skipTitles, alertWhen: config.alertWhen, alertPaths: config.alertPaths,
postAlerts: config.postAlerts, postPrefix: config.postPrefix,
postAlerts: config.postAlerts, postPrefix: config.postPrefix, postFooter: config.postFooter,
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
5 changes: 5 additions & 0 deletions packages/cli/src/inbox/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ export function inboxPage(): string {
<label class="check"><input id="postAlerts" type="checkbox"> Also post the alert findings to the pull request, each prefixed with</label>
<input id="postPrefix" type="text" placeholder="[Automated AI pre-review, not yet checked by human]">
</div>
<label>End each posted review with:
<textarea id="postFooter" rows="2" placeholder="e.g. cc @NaturalCycles/platform \u2014 automated triage, not a review (empty: nothing)"></textarea>
</label>
<label>Alert me if a changed file matches (one glob per line):
<textarea id="alertPaths" rows="3" placeholder="e.g. packages/shared/src/model/** or **/dbref/**"></textarea>
</label>
Expand Down Expand Up @@ -452,6 +455,7 @@ export function inboxPage(): string {
el('alertPaths').value = (settings.alertPaths || []).join('\\n');
el('postAlerts').checked = settings.postAlerts;
el('postPrefix').value = settings.postPrefix || '';
el('postFooter').value = settings.postFooter || '';
for (const key of ['maxPrepared', 'pollMinutes', 'prepareTimeoutMinutes', 'liveTimeoutMinutes']) el(key).value = settings[key];
el('live').checked = settings.live;
el('waitForCi').checked = settings.waitForCi;
Expand Down Expand Up @@ -479,6 +483,7 @@ export function inboxPage(): string {
alertPaths: el('alertPaths').value.split('\\n').map(line => line.trim()).filter(Boolean),
postAlerts: el('postAlerts').checked,
postPrefix: el('postPrefix').value.trim(),
postFooter: el('postFooter').value,
maxPrepared: Number(el('maxPrepared').value),
pollMinutes: Number(el('pollMinutes').value),
prepareTimeoutMinutes: Number(el('prepareTimeoutMinutes').value),
Expand Down
38 changes: 36 additions & 2 deletions packages/cli/src/inbox/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ export interface PrepareDeps {
runAgent(opts: RunAgentOpts): Promise<{ stdout: string; timedOut: boolean }>;
/** The threads the drafting agent left in the session over this worktree. */
listThreads(worktree: string): Promise<ReviewThread[]>;
/**
* The pull request's description and discussion, written where the agent can read it, with the
* daemon's own credentials; the path to it, or null when the forge could not be read.
*/
prContext(snapshot: PrSnapshot, worktree: string): Promise<string | null>;
/** The daemon's own call to the forge, with its credentials — this is never the agent's. */
postReview(opts: PostReviewOpts): Promise<ReviewResult>;
markPosted(opts: MarkPostedOpts): void | Promise<void>;
Expand Down Expand Up @@ -156,12 +161,16 @@ export async function preparePr(snapshot: PrSnapshot, config: InboxConfig, deps:
let server: ServerHandle | null = null;
try {
server = await deps.startServer(dest, diffRef);
// Starting the server empties the pull request's data directory, which is where the discussion
// is written, so it is read after that and before the agent.
const contextPath = await prContextPath(snapshot, deps, dest);
const startedAt = deps.now();
const { stdout, timedOut } = await deps.runAgent({
argv: deps.agentArgv(),
prompt: composePrompt({
snapshot, worktreePath: dest, port: server.port, alertWhen: config.alertWhen,
filter: opts.bumped ? '' : config.filter, mcpAllow: config.agent.mcpAllow,
filter: opts.bumped ? '' : config.filter, mcpAllow: config.agent.mcpAllow, contextPath,
postPrefix: config.postAlerts ? config.postPrefix : null,
}),
cwd: dest,
logPath,
Expand Down Expand Up @@ -244,6 +253,20 @@ export async function preparePr(snapshot: PrSnapshot, config: InboxConfig, deps:
}
}

/**
* Where the pull request's discussion was written for the agent, or nothing: a review that has to
* do without the description and the comments is still a review, so a forge that cannot be read
* costs a log line rather than the preparation.
*/
async function prContextPath(snapshot: PrSnapshot, deps: PrepareDeps, worktree: string): Promise<string | null> {
try {
return await deps.prContext(snapshot, worktree);
} catch (err) {
deps.log(`${prId(snapshot)}: the discussion could not be read \u2014 ${err instanceof Error ? err.message : err}`);
return null;
}
}

/**
* Puts the findings the agent named behind its alert on the pull request, as one `COMMENT` review
* in the reviewer's name, every comment opening with the configured prefix so nobody reads it as a
Expand Down Expand Up @@ -276,7 +299,7 @@ async function postAlertFindings(
prNumber: snapshot.number,
headSha: ctx.head,
// Never a verdict: the reviewer has not read this yet, and only they approve or request changes.
submission: { event: 'COMMENT', body: `${config.postPrefix} ${ctx.alert}`, comments },
submission: { event: 'COMMENT', body: reviewBody(config, ctx.alert), comments },
});
if (result.reviewUrl === null) {
deps.log(`could not post alert findings to ${id}: ${result.errors.join('; ') || 'the forge created no review'}`);
Expand All @@ -294,6 +317,17 @@ async function postAlertFindings(
}
}

/**
* The posted review's own body: the reason behind the prefix, and the reviewer's footer under it
* when they have one. Only this body carries the footer, so a team mention in it fires once for
* the review rather than once per finding.
*/
function reviewBody(config: InboxConfig, alert: string): string {
const opening = `${config.postPrefix} ${alert}`;
const footer = config.postFooter.trim();
return footer === '' ? opening : `${opening}\n\n${footer}`;
}

/**
* The named findings as forge comments, in the order the agent named them: the ones still open
* after the checking pass — a dismissed or resolved finding is settled and does not go out — each
Expand Down
Loading
Loading