@expo/code-review-cli 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/build/commands/ci.js +37 -1
- package/build/commands/doctor.js +3 -10
- package/build/commands/review.js +7 -1
- package/build/config/load.js +1 -0
- package/build/config/schema.js +15 -0
- package/build/core/auth.js +72 -12
- package/build/core/opencode.js +56 -2
- package/build/core/review.js +68 -1
- package/build/sources/github-pr.js +46 -0
- package/package.json +1 -1
- package/templates/config.jsonc +10 -0
- package/templates/workflow.yml +8 -3
package/README.md
CHANGED
|
@@ -84,9 +84,11 @@ Options (most to least common):
|
|
|
84
84
|
| `--no-fail` | Always exit 0 (otherwise a `request_changes` decision exits non-zero). |
|
|
85
85
|
| `-h`, `--help` | Show help. |
|
|
86
86
|
|
|
87
|
-
`--pr` uses the PR's diff (authoritative)
|
|
88
|
-
|
|
89
|
-
`
|
|
87
|
+
`--pr` uses the PR's diff (authoritative) and checks the PR head out into a
|
|
88
|
+
throwaway worktree so the agents' surrounding-source reads and the verifier see the
|
|
89
|
+
PR's versions of files — no manual `gh pr checkout` needed, and your working tree is
|
|
90
|
+
left untouched. (If that materialization can't run — e.g. not a git checkout — it
|
|
91
|
+
falls back to reading the current working directory.)
|
|
90
92
|
|
|
91
93
|
In CI it runs automatically from the scaffolded workflows — by label or a `/review`
|
|
92
94
|
comment (see **CI usage**). From Claude Code (or another agent), add a slash command
|
package/build/commands/ci.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { loadReviewConfig } from '../config/load.js';
|
|
3
|
-
import { repoRoot } from '../core/exec.js';
|
|
3
|
+
import { repoRoot, run } from '../core/exec.js';
|
|
4
4
|
import { errorMessage } from '../core/util.js';
|
|
5
5
|
import { runReview } from '../core/review.js';
|
|
6
6
|
import { GitHubPRSource } from '../sources/github-pr.js';
|
|
@@ -62,6 +62,23 @@ export async function ciCommand(argv = []) {
|
|
|
62
62
|
process.stderr.write(`CI reviewer: ${errorMessage(error)}\n`);
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
|
+
// Config-driven trigger policy (.expo-code-review/config.jsonc → review): decide
|
|
66
|
+
// whether this PR should be reviewed at all. Fetch current labels via gh (more
|
|
67
|
+
// authoritative than the possibly-stale event payload); on failure, default to
|
|
68
|
+
// reviewing so a label-read hiccup never silently skips a PR.
|
|
69
|
+
let labels = [];
|
|
70
|
+
try {
|
|
71
|
+
const { stdout } = await run('gh', ['pr', 'view', String(prNumber), '--repo', repo, '--json', 'labels', '--jq', '.labels[].name'], { cwd: process.cwd() });
|
|
72
|
+
labels = stdout.split('\n').map(name => name.trim()).filter(Boolean);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
process.stderr.write(`CI reviewer: could not read PR labels (continuing): ${errorMessage(error)}\n`);
|
|
76
|
+
}
|
|
77
|
+
const gate = shouldReview(labels, config.review);
|
|
78
|
+
if (!gate.review) {
|
|
79
|
+
process.stderr.write(`CI reviewer: skipping — ${gate.reason}.\n`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
65
82
|
const reporter = new GitHubReporter({
|
|
66
83
|
prNumber,
|
|
67
84
|
repo,
|
|
@@ -110,6 +127,25 @@ export async function ciCommand(argv = []) {
|
|
|
110
127
|
}
|
|
111
128
|
}
|
|
112
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Decide whether a PR should be reviewed, given its labels and the repo's trigger
|
|
132
|
+
* policy. `skipLabel` always wins (write-gated opt-out). In "label" mode a PR must
|
|
133
|
+
* carry `label` or a `label:<agent>` variant; in "all" mode every non-skipped PR
|
|
134
|
+
* is reviewed. Pure so it's unit-testable and matches exact label names (no
|
|
135
|
+
* substring surprises like `ai-review:skip` satisfying an `ai-review` check).
|
|
136
|
+
*/
|
|
137
|
+
export function shouldReview(labels, review) {
|
|
138
|
+
if (labels.includes(review.skipLabel)) {
|
|
139
|
+
return { review: false, reason: `the ${review.skipLabel} label is set` };
|
|
140
|
+
}
|
|
141
|
+
if (review.trigger === 'label') {
|
|
142
|
+
const optedIn = labels.some(name => name === review.label || name.startsWith(`${review.label}:`));
|
|
143
|
+
return optedIn
|
|
144
|
+
? { review: true, reason: `the ${review.label} label is set` }
|
|
145
|
+
: { review: false, reason: `trigger is "label" and no ${review.label} label is set` };
|
|
146
|
+
}
|
|
147
|
+
return { review: true, reason: 'trigger is "all"' };
|
|
148
|
+
}
|
|
113
149
|
/** Parse `--agents a,b,c` from argv (undefined = all agents). */
|
|
114
150
|
function parseAgents(argv) {
|
|
115
151
|
const index = argv.indexOf('--agents');
|
package/build/commands/doctor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadReviewConfig, hasConfig } from '../config/load.js';
|
|
2
|
+
import { checkProviderAuth } from '../core/auth.js';
|
|
2
3
|
import { onPath, repoRoot, run } from '../core/exec.js';
|
|
3
4
|
import { errorMessage } from '../core/util.js';
|
|
4
5
|
const USAGE = `ecr doctor — check environment, config, and credentials
|
|
@@ -61,16 +62,8 @@ export async function doctorCommand(argv = []) {
|
|
|
61
62
|
const config = await loadReviewConfig(root);
|
|
62
63
|
line(true, `config valid: ${config.agents.length} agent(s) [${config.agents.map(a => a.id).join(', ')}], coordinator model ${config.coordinator.model}`);
|
|
63
64
|
line(config.agents.every(a => Boolean(a.promptText.trim())), 'all agent prompt files resolved and non-empty');
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
const present = Boolean(process.env[tokenEnv]);
|
|
67
|
-
line(present, present
|
|
68
|
-
? `auth: ${mode} for ${provider}; token env ${tokenEnv} is set`
|
|
69
|
-
: `auth: ${mode} for ${provider}; token env ${tokenEnv} is NOT set`);
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
line(true, `auth: ${mode} for ${provider}; no tokenEnv configured — relying on OpenCode's own login or REVIEWER_MODEL`);
|
|
73
|
-
}
|
|
65
|
+
const readiness = checkProviderAuth(config);
|
|
66
|
+
line(readiness.ok, `auth: ${readiness.detail}`);
|
|
74
67
|
}
|
|
75
68
|
catch (error) {
|
|
76
69
|
line(false, `config invalid: ${errorMessage(error)}`);
|
package/build/commands/review.js
CHANGED
|
@@ -16,7 +16,8 @@ Source (pick one):
|
|
|
16
16
|
(default) diff the working tree against the merge-base
|
|
17
17
|
--base <ref> base ref to diff against
|
|
18
18
|
--head <ref> head ref to diff
|
|
19
|
-
--staged review only staged changes
|
|
19
|
+
--staged review only staged changes (index vs HEAD; not combinable
|
|
20
|
+
with --base/--head)
|
|
20
21
|
--pr <n> review GitHub PR #n by number (diff fetched via \`gh\`, no
|
|
21
22
|
checkout needed); can't be combined with --base/--head/--staged
|
|
22
23
|
|
|
@@ -187,5 +188,10 @@ function validateArgs(args) {
|
|
|
187
188
|
if (args.pr == null && (args.repo || args.post)) {
|
|
188
189
|
throw new Error('--repo/--post only apply together with --pr.');
|
|
189
190
|
}
|
|
191
|
+
// --staged diffs the index against HEAD, so --base/--head have no effect. Reject
|
|
192
|
+
// the combination rather than silently ignoring the range the user asked for.
|
|
193
|
+
if (args.staged && (args.base || args.head)) {
|
|
194
|
+
throw new Error('--staged reviews the staged changes (index vs HEAD) and cannot be combined with --base/--head.');
|
|
195
|
+
}
|
|
190
196
|
}
|
|
191
197
|
/** Resolve owner/repo from the current checkout via gh (for --post). */
|
package/build/config/load.js
CHANGED
package/build/config/schema.js
CHANGED
|
@@ -62,4 +62,19 @@ export const ReviewConfigSchema = z.object({
|
|
|
62
62
|
tokenEnv: z.string().optional(),
|
|
63
63
|
})
|
|
64
64
|
.default({ mode: 'api-key', provider: 'anthropic' }),
|
|
65
|
+
review: z
|
|
66
|
+
.object({
|
|
67
|
+
// Which PRs `ecr ci` acts on — the source of truth for trigger policy (a
|
|
68
|
+
// workflow `if:` gate, if any, is an optional coarse filter layered on top):
|
|
69
|
+
// "all" — review every PR, unless it carries the `skipLabel`.
|
|
70
|
+
// "label" — review only PRs carrying `label` (e.g. `ai-review`) or a
|
|
71
|
+
// `label:<agent>` variant. `skipLabel` still wins.
|
|
72
|
+
trigger: z.enum(['all', 'label']).default('all'),
|
|
73
|
+
// Opt-in label (and prefix for `label:<agent>`) used when trigger is "label".
|
|
74
|
+
label: z.string().default('ai-review'),
|
|
75
|
+
// Opt a single PR out of review. A label (not a config flag) because labels
|
|
76
|
+
// are write-gated to maintainers — a PR author can't add one to dodge review.
|
|
77
|
+
skipLabel: z.string().default('ai-review:skip'),
|
|
78
|
+
})
|
|
79
|
+
.default({ trigger: 'all', label: 'ai-review', skipLabel: 'ai-review:skip' }),
|
|
65
80
|
});
|
package/build/core/auth.js
CHANGED
|
@@ -32,6 +32,67 @@ const FORBIDDEN_TOKEN_ENVS = new Set([
|
|
|
32
32
|
'SSH_PRIVATE_KEY',
|
|
33
33
|
]);
|
|
34
34
|
const YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether the configured model provider has a usable credential, WITHOUT
|
|
37
|
+
* mutating the environment. Shared by `prepareAuth` (fail fast before spinning up
|
|
38
|
+
* the server and every pass) and `doctor` (report), so the two never drift.
|
|
39
|
+
*
|
|
40
|
+
* We only report `ok: false` when we're confident there is no credential — a
|
|
41
|
+
* missing OAuth token, a forbidden tokenEnv, or an api-key run with neither the
|
|
42
|
+
* configured tokenEnv nor the provider's own key env set. When nothing is
|
|
43
|
+
* configured and no known key env is present, we assume OpenCode's own login may
|
|
44
|
+
* cover it and don't hard-fail. `REVIEWER_MODEL` bypasses provider auth entirely.
|
|
45
|
+
*/
|
|
46
|
+
export function checkProviderAuth(config, env = process.env) {
|
|
47
|
+
const { mode, provider, tokenEnv } = config.auth;
|
|
48
|
+
if (env.REVIEWER_MODEL) {
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
detail: `REVIEWER_MODEL override (${env.REVIEWER_MODEL}); using OpenCode's own login for that model`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (tokenEnv && FORBIDDEN_TOKEN_ENVS.has(tokenEnv)) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
detail: `auth.tokenEnv is "${tokenEnv}", a well-known non-provider secret; refusing to ` +
|
|
58
|
+
`forward it to the model provider (that would leak it). Point auth.tokenEnv at a ` +
|
|
59
|
+
`token minted for the provider instead.`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (mode === 'oauth') {
|
|
63
|
+
if (!tokenEnv) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
detail: 'auth.mode "oauth" requires auth.tokenEnv to name the env var holding the OAuth token.',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (!env[tokenEnv]) {
|
|
70
|
+
return { ok: false, detail: `auth is oauth for ${provider} but token env "${tokenEnv}" is not set.` };
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, detail: `oauth for ${provider}; token env ${tokenEnv} is set` };
|
|
73
|
+
}
|
|
74
|
+
// api-key: usable if the configured tokenEnv is set, or the provider's own key
|
|
75
|
+
// env is already present in the environment.
|
|
76
|
+
const providerKeyEnv = PROVIDER_KEY_ENV[provider];
|
|
77
|
+
if (tokenEnv && env[tokenEnv]) {
|
|
78
|
+
return { ok: true, detail: `api-key for ${provider}; token env ${tokenEnv} is set` };
|
|
79
|
+
}
|
|
80
|
+
if (providerKeyEnv && env[providerKeyEnv]) {
|
|
81
|
+
return { ok: true, detail: `api-key for ${provider}; ${providerKeyEnv} is set` };
|
|
82
|
+
}
|
|
83
|
+
if (!tokenEnv && !providerKeyEnv) {
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
detail: `api-key for ${provider}; no tokenEnv configured and no known key env — relying on OpenCode's own login`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const names = [tokenEnv, providerKeyEnv].filter(Boolean).join(' or ');
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
detail: `configured api-key for ${provider} but no credential is set — set ${names}, or set ` +
|
|
93
|
+
`REVIEWER_MODEL to a model you're already logged into.`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
35
96
|
/**
|
|
36
97
|
* Prepare model credentials for the OpenCode server based on the repo's auth mode.
|
|
37
98
|
* Must run before the server starts (it mutates env). Returns a cleanup handle.
|
|
@@ -53,12 +114,13 @@ export async function prepareAuth(config) {
|
|
|
53
114
|
if (process.env.REVIEWER_MODEL) {
|
|
54
115
|
return noop;
|
|
55
116
|
}
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
117
|
+
// Fail fast, before starting the server and every pass, if the configured
|
|
118
|
+
// provider has no usable credential — otherwise it surfaces as N failed passes
|
|
119
|
+
// mid-run. This is the same readiness check `doctor` reports, and it also covers
|
|
120
|
+
// the forbidden-secret guard (refusing to forward a well-known unrelated secret).
|
|
121
|
+
const readiness = checkProviderAuth(config);
|
|
122
|
+
if (!readiness.ok) {
|
|
123
|
+
throw new Error(readiness.detail);
|
|
62
124
|
}
|
|
63
125
|
if (mode === 'api-key') {
|
|
64
126
|
if (tokenEnv) {
|
|
@@ -72,13 +134,11 @@ export async function prepareAuth(config) {
|
|
|
72
134
|
}
|
|
73
135
|
return noop;
|
|
74
136
|
}
|
|
75
|
-
// oauth
|
|
76
|
-
if
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
const token = process.env[tokenEnv];
|
|
137
|
+
// oauth — checkProviderAuth guarantees tokenEnv is set and present; read
|
|
138
|
+
// defensively so TypeScript narrows and this stays correct if called directly.
|
|
139
|
+
const token = tokenEnv ? process.env[tokenEnv] : undefined;
|
|
80
140
|
if (!token) {
|
|
81
|
-
throw new Error(
|
|
141
|
+
throw new Error('auth.mode "oauth" requires auth.tokenEnv to name a set OAuth token env.');
|
|
82
142
|
}
|
|
83
143
|
const dir = await mkdtemp(path.join(tmpdir(), 'ecr-auth-'));
|
|
84
144
|
await mkdir(path.join(dir, 'opencode'), { recursive: true });
|
package/build/core/opencode.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createOpencode } from '@opencode-ai/sdk';
|
|
2
2
|
import { toolMap } from './tools.js';
|
|
3
|
-
import { sleep } from './util.js';
|
|
3
|
+
import { errorMessage, sleep } from './util.js';
|
|
4
4
|
/** Sum token usage across attempts (for per-task/run totals). */
|
|
5
5
|
export function addTokenUsage(into, from) {
|
|
6
6
|
if (!from) {
|
|
@@ -240,6 +240,60 @@ const CORRECTIVE = '\n\nIMPORTANT: your previous reply could not be parsed. Repl
|
|
|
240
240
|
// Budget for a corrective "re-emit the JSON" reply — no fresh investigation, so
|
|
241
241
|
// it should return almost immediately.
|
|
242
242
|
const CORRECTIVE_WAIT_MS = 2 * 60 * 1000;
|
|
243
|
+
/** Backoff (ms) before the 2nd and 3rd attempt of a transient-failing model call. */
|
|
244
|
+
const TRANSIENT_BACKOFF_MS = [2_000, 8_000];
|
|
245
|
+
/**
|
|
246
|
+
* A transient, retryable API failure — a one-off rate-limit (429), server error
|
|
247
|
+
* (5xx), or network blip — as opposed to a timeout (which means "abandon", see
|
|
248
|
+
* AgentTimeoutError) or a JSON-parse failure (handled by the corrective re-emit in
|
|
249
|
+
* promptAndParse). We match on the error text because the OpenCode SDK surfaces
|
|
250
|
+
* these as plain Errors; an AgentTimeoutError is never transient.
|
|
251
|
+
*/
|
|
252
|
+
const TRANSIENT_PATTERNS = [
|
|
253
|
+
/\b429\b/,
|
|
254
|
+
/\b50[0-9]\b/,
|
|
255
|
+
/rate.?limit/i,
|
|
256
|
+
/overloaded/i,
|
|
257
|
+
/too many requests/i,
|
|
258
|
+
/temporarily unavailable/i,
|
|
259
|
+
/ETIMEDOUT/i,
|
|
260
|
+
/ECONNRESET/i,
|
|
261
|
+
/ECONNREFUSED/i,
|
|
262
|
+
/ENOTFOUND/i,
|
|
263
|
+
/EAI_AGAIN/i,
|
|
264
|
+
/socket hang ?up/i,
|
|
265
|
+
/network error/i,
|
|
266
|
+
/fetch failed/i,
|
|
267
|
+
];
|
|
268
|
+
export function isTransientApiError(error) {
|
|
269
|
+
if (error instanceof AgentTimeoutError) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
const message = errorMessage(error);
|
|
273
|
+
return TRANSIENT_PATTERNS.some(pattern => pattern.test(message));
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Run a model call, retrying with bounded backoff on a transient API error. This
|
|
277
|
+
* is deliberately separate from the timeout path (abandon, never retry) and the
|
|
278
|
+
* parse-failure path (corrective re-emit): a one-off 429/5xx/network error used to
|
|
279
|
+
* drop the whole pass with no retry, reported as a coverage gap. Non-transient
|
|
280
|
+
* errors (incl. AgentTimeoutError) propagate immediately.
|
|
281
|
+
*/
|
|
282
|
+
async function withTransientRetry(label, onActivity, fn) {
|
|
283
|
+
for (let attempt = 0;; attempt++) {
|
|
284
|
+
try {
|
|
285
|
+
return await fn();
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
const waitMs = TRANSIENT_BACKOFF_MS[attempt];
|
|
289
|
+
if (waitMs === undefined || !isTransientApiError(error)) {
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
onActivity?.(`${label}: transient API error (${errorMessage(error)}); retry ${attempt + 1}/${TRANSIENT_BACKOFF_MS.length} in ${Math.round(waitMs / 1000)}s`);
|
|
293
|
+
await sleep(waitMs);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
243
297
|
/**
|
|
244
298
|
* Prompt an agent and parse its reply. On a JSON-parse failure, first retry in
|
|
245
299
|
* the SAME session: the model still holds all the file context it read, so the
|
|
@@ -259,7 +313,7 @@ export async function promptAndParse(handle, args, parse) {
|
|
|
259
313
|
truncated = truncated || (result.truncated ?? false);
|
|
260
314
|
addTokenUsage(tokens, result.tokens);
|
|
261
315
|
};
|
|
262
|
-
const first = await promptAgent(handle, args);
|
|
316
|
+
const first = await withTransientRetry(`Agent "${args.agent}"`, args.onActivity, () => promptAgent(handle, args));
|
|
263
317
|
record(first);
|
|
264
318
|
try {
|
|
265
319
|
return { value: parse(first.text), cost, truncated, tokens };
|
package/build/core/review.js
CHANGED
|
@@ -67,7 +67,28 @@ export async function runReview(source, options) {
|
|
|
67
67
|
});
|
|
68
68
|
return output;
|
|
69
69
|
}
|
|
70
|
+
// Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
|
|
71
|
+
// and doing it first means nothing that can throw sits between the chdir and the
|
|
72
|
+
// guarded blocks — so a prepareAuth failure can't leak the worktree or leave cwd
|
|
73
|
+
// pointing at it.
|
|
70
74
|
const auth = await prepareAuth(config);
|
|
75
|
+
// Read the PR-head tree (not the current checkout) when the source can materialize
|
|
76
|
+
// it, so the agents' surrounding-source reads and the verifier's re-reads see the
|
|
77
|
+
// versions that match the diff. Config is already fully loaded in memory, so the
|
|
78
|
+
// chdir doesn't affect it; run-log/patch paths are absolute; gh/git calls already
|
|
79
|
+
// ran above. Fails soft to the current directory.
|
|
80
|
+
const originalCwd = process.cwd();
|
|
81
|
+
const readRoot = (await source.prepareReadRootAsync?.()) ?? null;
|
|
82
|
+
const restoreCwd = async () => {
|
|
83
|
+
if (readRoot) {
|
|
84
|
+
process.chdir(originalCwd);
|
|
85
|
+
await readRoot.cleanup();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
if (readRoot) {
|
|
89
|
+
progress('Reviewing the PR-head tree (so reads match the PR, not the checkout).');
|
|
90
|
+
process.chdir(readRoot.dir);
|
|
91
|
+
}
|
|
71
92
|
progress('Starting OpenCode server…');
|
|
72
93
|
let handle = null;
|
|
73
94
|
try {
|
|
@@ -75,6 +96,7 @@ export async function runReview(source, options) {
|
|
|
75
96
|
}
|
|
76
97
|
catch (error) {
|
|
77
98
|
await auth.cleanup();
|
|
99
|
+
await restoreCwd();
|
|
78
100
|
throw new Error(`Failed to start the OpenCode server. Ensure the \`opencode\` CLI is installed and ` +
|
|
79
101
|
`model credentials are configured.\n${errorMessage(error)}`);
|
|
80
102
|
}
|
|
@@ -223,7 +245,12 @@ export async function runReview(source, options) {
|
|
|
223
245
|
if (!(error instanceof AgentTimeoutError)) {
|
|
224
246
|
failedPasses++;
|
|
225
247
|
progress(` ${task.label}: FAILED (${errorMessage(error)})`);
|
|
226
|
-
|
|
248
|
+
// An auth/permission failure hits every pass identically; push one shared,
|
|
249
|
+
// actionable note (deduped into a single coverage line) instead of N generic
|
|
250
|
+
// per-pass failures that bury the real, fixable cause.
|
|
251
|
+
incomplete.push(isAuthError(error)
|
|
252
|
+
? AUTH_FAILURE_NOTE
|
|
253
|
+
: `${capitalize(task.coverageLabel)} failed to run; those changes were not reviewed.`);
|
|
227
254
|
return;
|
|
228
255
|
}
|
|
229
256
|
// Account for the abandoned investigation's spend regardless of what's next.
|
|
@@ -353,6 +380,7 @@ export async function runReview(source, options) {
|
|
|
353
380
|
if (removedAfterChecks > 0) {
|
|
354
381
|
output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
|
|
355
382
|
}
|
|
383
|
+
progress(formatUsageSummary(tokenTotals, sum(agentCosts)));
|
|
356
384
|
await safeLog(logPath, {
|
|
357
385
|
...baseRecord,
|
|
358
386
|
agentCosts,
|
|
@@ -382,6 +410,7 @@ export async function runReview(source, options) {
|
|
|
382
410
|
finally {
|
|
383
411
|
handle?.close();
|
|
384
412
|
await auth.cleanup();
|
|
413
|
+
await restoreCwd();
|
|
385
414
|
}
|
|
386
415
|
}
|
|
387
416
|
/**
|
|
@@ -465,6 +494,29 @@ export function reconcileSummary(summary, remaining) {
|
|
|
465
494
|
function capitalize(text) {
|
|
466
495
|
return text.length > 0 ? text[0].toUpperCase() + text.slice(1) : text;
|
|
467
496
|
}
|
|
497
|
+
/**
|
|
498
|
+
* An authentication/authorization failure from the model provider (401/403, a
|
|
499
|
+
* rejected/expired/missing credential) — distinct from a transient blip or a real
|
|
500
|
+
* code finding. Every pass hits the same wall, so the caller collapses it into one
|
|
501
|
+
* actionable coverage note instead of N generic "failed to run" lines.
|
|
502
|
+
*/
|
|
503
|
+
export function isAuthError(error) {
|
|
504
|
+
const message = errorMessage(error).toLowerCase();
|
|
505
|
+
const cred = /(api.?key|token|credential)/;
|
|
506
|
+
const problem = /(invalid|expired|revoked|missing|rejected|no)/;
|
|
507
|
+
return (/\b401\b|\b403\b/.test(message) ||
|
|
508
|
+
/unauthor/.test(message) ||
|
|
509
|
+
/\bforbidden\b/.test(message) ||
|
|
510
|
+
/authentication/.test(message) ||
|
|
511
|
+
/permission denied/.test(message) ||
|
|
512
|
+
/invalid x-api-key/.test(message) ||
|
|
513
|
+
// a credential noun and a problem word near each other, in either order
|
|
514
|
+
new RegExp(`${problem.source}\\b[^.]{0,20}${cred.source}`).test(message) ||
|
|
515
|
+
new RegExp(`${cred.source}[^.]{0,20}${problem.source}`).test(message));
|
|
516
|
+
}
|
|
517
|
+
const AUTH_FAILURE_NOTE = 'The model provider rejected the request (authentication or permission). Check the ' +
|
|
518
|
+
'configured credential (auth.tokenEnv, or REVIEWER_MODEL for a local run) and re-run — ' +
|
|
519
|
+
'those changes were not reviewed.';
|
|
468
520
|
function selectAgents(all, filter) {
|
|
469
521
|
if (!filter?.length) {
|
|
470
522
|
return all;
|
|
@@ -540,6 +592,21 @@ export async function runGrowableQueue(initial, limit, fn) {
|
|
|
540
592
|
function sum(costs) {
|
|
541
593
|
return Object.values(costs).reduce((total, value) => total + value, 0);
|
|
542
594
|
}
|
|
595
|
+
/**
|
|
596
|
+
* One-line usage summary for the run. Emitted via progress so it lands in the CI
|
|
597
|
+
* job log (and the local terminal) — `.runs/reviews.jsonl` is ephemeral in CI, so
|
|
598
|
+
* this is the only place the token/cache totals are visible after a CI run, which
|
|
599
|
+
* is how prompt-cache effectiveness gets confirmed there.
|
|
600
|
+
*/
|
|
601
|
+
export function formatUsageSummary(tokens, totalCost) {
|
|
602
|
+
const parts = [`input ${tokens.input ?? 0}`, `output ${tokens.output ?? 0}`];
|
|
603
|
+
if (tokens.reasoning) {
|
|
604
|
+
parts.push(`reasoning ${tokens.reasoning}`);
|
|
605
|
+
}
|
|
606
|
+
parts.push(`cache read ${tokens.cache?.read ?? 0}`, `cache write ${tokens.cache?.write ?? 0}`);
|
|
607
|
+
const cost = totalCost > 0 ? ` (cost $${totalCost.toFixed(4)})` : '';
|
|
608
|
+
return `Token usage — ${parts.join(', ')}${cost}`;
|
|
609
|
+
}
|
|
543
610
|
async function safeLog(logPath, record) {
|
|
544
611
|
try {
|
|
545
612
|
await writeRunLog(logPath, record);
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
1
4
|
import { run } from '../core/exec.js';
|
|
2
5
|
import { parseUnifiedDiff } from '../core/diff.js';
|
|
3
6
|
/**
|
|
@@ -33,4 +36,47 @@ export class GitHubPRSource {
|
|
|
33
36
|
const { stdout } = await run('gh', ['pr', 'diff', String(this.options.prNumber), ...this.repoArgs()], { cwd: this.options.cwd });
|
|
34
37
|
return parseUnifiedDiff(stdout);
|
|
35
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Check the PR HEAD out into a throwaway git worktree so the agents and verifier
|
|
41
|
+
* read the PR's versions of files (not whatever branch happens to be checked out).
|
|
42
|
+
* Fetches the head from the repo's own URL — `refs/pull/<n>/head`, which the base
|
|
43
|
+
* repo hosts even for fork PRs — so it's always the correct PR, independent of the
|
|
44
|
+
* local `origin`. Fails SOFT: any problem (not a git repo, fetch/worktree error)
|
|
45
|
+
* returns null, and the review falls back to reading the current checkout.
|
|
46
|
+
*/
|
|
47
|
+
async prepareReadRootAsync() {
|
|
48
|
+
const cwd = this.options.cwd;
|
|
49
|
+
if (!this.options.repo) {
|
|
50
|
+
// Without an explicit owner/repo we can't build the fetch URL safely.
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const url = `https://github.com/${this.options.repo}.git`;
|
|
54
|
+
const ref = `refs/pull/${this.options.prNumber}/head`;
|
|
55
|
+
let parent;
|
|
56
|
+
try {
|
|
57
|
+
await run('git', ['fetch', '--no-tags', '--depth=1', url, ref], { cwd });
|
|
58
|
+
parent = await mkdtemp(path.join(tmpdir(), 'ecr-prhead-'));
|
|
59
|
+
const dir = path.join(parent, 'head'); // must not pre-exist for `worktree add`
|
|
60
|
+
await run('git', ['worktree', 'add', '--detach', dir, 'FETCH_HEAD'], { cwd });
|
|
61
|
+
const removeParent = parent;
|
|
62
|
+
return {
|
|
63
|
+
dir,
|
|
64
|
+
cleanup: async () => {
|
|
65
|
+
try {
|
|
66
|
+
await run('git', ['worktree', 'remove', '--force', dir], { cwd });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// best effort — fall through to removing the temp dir
|
|
70
|
+
}
|
|
71
|
+
await rm(removeParent, { recursive: true, force: true });
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
if (parent) {
|
|
77
|
+
await rm(parent, { recursive: true, force: true }).catch(() => { });
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
36
82
|
}
|
package/package.json
CHANGED
package/templates/config.jsonc
CHANGED
|
@@ -25,6 +25,16 @@
|
|
|
25
25
|
// full-context pass. Defaults shown; raise/lower per your model + PR sizes.
|
|
26
26
|
// "chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 4 },
|
|
27
27
|
|
|
28
|
+
// Which PRs `ecr ci` reviews. This is the source of truth for trigger policy;
|
|
29
|
+
// the scaffolded workflow always calls `ecr ci` and lets this decide. (If you'd
|
|
30
|
+
// rather gate in the workflow instead, add an `if:` to the job — see the
|
|
31
|
+
// workflow template — and this still applies on top.)
|
|
32
|
+
// "trigger": "all" — review every PR, unless it has the `skipLabel`.
|
|
33
|
+
// "trigger": "label" — review only PRs labeled `label` (or `label:<agent>`).
|
|
34
|
+
// The `skipLabel` is write-gated (only maintainers can add labels), so a PR
|
|
35
|
+
// author can't opt their own PR out.
|
|
36
|
+
"review": { "trigger": "all", "label": "ai-review", "skipLabel": "ai-review:skip" },
|
|
37
|
+
|
|
28
38
|
// A maintainer comment containing this marker skips the CI review.
|
|
29
39
|
"breakGlass": { "marker": "/skip-review" },
|
|
30
40
|
|
package/templates/workflow.yml
CHANGED
|
@@ -17,9 +17,14 @@ concurrency:
|
|
|
17
17
|
jobs:
|
|
18
18
|
review:
|
|
19
19
|
runs-on: ubuntu-latest
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
|
|
20
|
+
# Trigger policy lives in .expo-code-review/config.jsonc (review.trigger); `ecr ci`
|
|
21
|
+
# self-gates on it (and honors the ai-review:skip label). This coarse gate just
|
|
22
|
+
# avoids spinning up a runner for a PR that explicitly opted out. Uses the array
|
|
23
|
+
# form of contains() for an EXACT label match ("ai-review:skip" is not "ai-review").
|
|
24
|
+
# Prefer to gate entirely here instead? Set config trigger to "label" and replace
|
|
25
|
+
# the line below with, e.g.:
|
|
26
|
+
# if: contains(github.event.pull_request.labels.*.name, 'ai-review')
|
|
27
|
+
if: ${{ !contains(github.event.pull_request.labels.*.name, 'ai-review:skip') }}
|
|
23
28
|
# Backstop so a stalled review fails fast instead of hanging.
|
|
24
29
|
timeout-minutes: 60
|
|
25
30
|
# A reviewer failure must never fail the PR's checks.
|