@expo/code-review-cli 0.2.3 → 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/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 +45 -1
- package/package.json +1 -1
- package/templates/config.jsonc +10 -0
- package/templates/workflow.yml +8 -3
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
|
@@ -245,7 +245,12 @@ export async function runReview(source, options) {
|
|
|
245
245
|
if (!(error instanceof AgentTimeoutError)) {
|
|
246
246
|
failedPasses++;
|
|
247
247
|
progress(` ${task.label}: FAILED (${errorMessage(error)})`);
|
|
248
|
-
|
|
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.`);
|
|
249
254
|
return;
|
|
250
255
|
}
|
|
251
256
|
// Account for the abandoned investigation's spend regardless of what's next.
|
|
@@ -375,6 +380,7 @@ export async function runReview(source, options) {
|
|
|
375
380
|
if (removedAfterChecks > 0) {
|
|
376
381
|
output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
|
|
377
382
|
}
|
|
383
|
+
progress(formatUsageSummary(tokenTotals, sum(agentCosts)));
|
|
378
384
|
await safeLog(logPath, {
|
|
379
385
|
...baseRecord,
|
|
380
386
|
agentCosts,
|
|
@@ -488,6 +494,29 @@ export function reconcileSummary(summary, remaining) {
|
|
|
488
494
|
function capitalize(text) {
|
|
489
495
|
return text.length > 0 ? text[0].toUpperCase() + text.slice(1) : text;
|
|
490
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.';
|
|
491
520
|
function selectAgents(all, filter) {
|
|
492
521
|
if (!filter?.length) {
|
|
493
522
|
return all;
|
|
@@ -563,6 +592,21 @@ export async function runGrowableQueue(initial, limit, fn) {
|
|
|
563
592
|
function sum(costs) {
|
|
564
593
|
return Object.values(costs).reduce((total, value) => total + value, 0);
|
|
565
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
|
+
}
|
|
566
610
|
async function safeLog(logPath, record) {
|
|
567
611
|
try {
|
|
568
612
|
await writeRunLog(logPath, record);
|
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.
|