@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.
@@ -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');
@@ -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 { mode, provider, tokenEnv } = config.auth;
65
- if (tokenEnv) {
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)}`);
@@ -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). */
@@ -87,6 +87,7 @@ export async function loadReviewConfig(repoRoot) {
87
87
  provider: parsed.auth.provider,
88
88
  tokenEnv: parsed.auth.tokenEnv,
89
89
  },
90
+ review: parsed.review,
90
91
  };
91
92
  }
92
93
  /**
@@ -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
  });
@@ -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
- // Refuse to forward a well-known unrelated secret as the provider credential,
57
- // even if the (repo/PR-controlled) config names one that would leak it.
58
- if (tokenEnv && FORBIDDEN_TOKEN_ENVS.has(tokenEnv)) {
59
- throw new Error(`auth.tokenEnv is set to "${tokenEnv}", a well-known non-provider secret. Refusing ` +
60
- `to forward it to the model provider (that would leak the secret). Point auth.tokenEnv ` +
61
- `at a token minted for the model provider instead.`);
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 (!tokenEnv) {
77
- throw new Error('auth.mode "oauth" requires auth.tokenEnv to name the env var holding the OAuth token.');
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(`OAuth token env "${tokenEnv}" is not set.`);
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 });
@@ -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 };
@@ -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
- incomplete.push(`${capitalize(task.coverageLabel)} failed to run; those changes were not reviewed.`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
 
@@ -17,9 +17,14 @@ concurrency:
17
17
  jobs:
18
18
  review:
19
19
  runs-on: ubuntu-latest
20
- # Opt-in per PR: only run when the `ai-review` label is present. Remove this
21
- # line to review every PR automatically.
22
- if: contains(join(github.event.pull_request.labels.*.name, ','), 'ai-review')
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.