@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 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) but reads your checked-out files for
88
- surrounding context; for full fidelity, `gh pr checkout <n>` first and run a plain
89
- `ecr review`.
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
@@ -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 };
@@ -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
- 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.`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.2.2",
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.