@expo/code-review-cli 0.2.3 → 0.4.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.
Files changed (43) hide show
  1. package/README.md +183 -6
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +427 -28
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +172 -32
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +124 -30
  8. package/build/commands/verify-config.js +214 -0
  9. package/build/config/load.js +155 -52
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +127 -8
  12. package/build/core/auth.js +101 -38
  13. package/build/core/coordinator.js +5 -5
  14. package/build/core/diff.js +19 -19
  15. package/build/core/exec.js +10 -10
  16. package/build/core/log.js +3 -3
  17. package/build/core/noise.js +52 -52
  18. package/build/core/opencode.js +98 -44
  19. package/build/core/prompts.js +157 -148
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +187 -81
  22. package/build/core/router.js +10 -10
  23. package/build/core/schema.js +26 -12
  24. package/build/core/step-summary.js +18 -0
  25. package/build/core/suppress.js +7 -7
  26. package/build/core/tools.js +9 -9
  27. package/build/core/util.js +2 -2
  28. package/build/core/verify.js +25 -25
  29. package/build/reporters/github.js +103 -51
  30. package/build/reporters/terminal.js +19 -19
  31. package/build/sources/github-pr.js +21 -21
  32. package/build/sources/local-git.js +20 -20
  33. package/build/sources/source.js +35 -1
  34. package/package.json +6 -1
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +164 -0
  37. package/templates/config.jsonc +10 -0
  38. package/templates/coordinator.md +5 -3
  39. package/templates/dismiss.yml +110 -0
  40. package/templates/routing.jsonc +27 -0
  41. package/templates/scope-config.jsonc +25 -0
  42. package/templates/shared.md +12 -0
  43. package/templates/workflow.yml +58 -23
@@ -1,18 +1,21 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { loadReviewConfig } from '../config/load.js';
3
- import { repoRoot } from '../core/exec.js';
4
- import { errorMessage } from '../core/util.js';
5
- import { runReview } from '../core/review.js';
6
- import { GitHubPRSource } from '../sources/github-pr.js';
7
- import { GitHubReporter } from '../reporters/github.js';
1
+ import { readFile } from "node:fs/promises";
2
+ import { loadAuthFromRoot, loadReviewConfig, loadScopeConfig } from "../config/load.js";
3
+ import { loadRoutingManifest, resolveScopes, scopedCommentTag, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
4
+ import { repoRoot, run } from "../core/exec.js";
5
+ import { errorMessage } from "../core/util.js";
6
+ import { buildDiffLineIndex } from "../core/render.js";
7
+ import { runReview } from "../core/review.js";
8
+ import { GitHubPRSource } from "../sources/github-pr.js";
9
+ import { memoizeSource } from "../sources/source.js";
10
+ import { GitHubReporter } from "../reporters/github.js";
8
11
  /** Resolve the PR number from the Actions event payload or GITHUB_REF. */
9
12
  async function resolvePrNumber() {
10
13
  const eventPath = process.env.GITHUB_EVENT_PATH;
11
14
  if (eventPath) {
12
15
  try {
13
- const event = JSON.parse(await readFile(eventPath, 'utf8'));
16
+ const event = JSON.parse(await readFile(eventPath, "utf8"));
14
17
  const number = event.pull_request?.number ?? event.issue?.number ?? event.number;
15
- if (typeof number === 'number') {
18
+ if (typeof number === "number") {
16
19
  return number;
17
20
  }
18
21
  }
@@ -20,7 +23,7 @@ async function resolvePrNumber() {
20
23
  // fall through
21
24
  }
22
25
  }
23
- const match = (process.env.GITHUB_REF ?? '').match(/refs\/pull\/(\d+)\//);
26
+ const match = (process.env.GITHUB_REF ?? "").match(/refs\/pull\/(\d+)\//);
24
27
  return match ? Number(match[1]) : null;
25
28
  }
26
29
  const CI_USAGE = `ecr ci — review the current GitHub PR and post/update one comment.
@@ -29,45 +32,117 @@ For GitHub Actions: reads the PR number + repo from the event/env, gets the diff
29
32
  via \`gh pr diff\`, runs the reviewer, and upserts a single PR comment. Comment-only
30
33
  and non-blocking (a reviewer failure never fails the PR's checks).
31
34
 
35
+ Monorepos: when .expo-code-review/routing.jsonc exists, ci fans out INTERNALLY —
36
+ it assigns each changed file to exactly one scope (last-match-wins) and reviews
37
+ each active scope over only its files, then renders one aggregated comment (or one
38
+ per scope). With no manifest, behavior is unchanged.
39
+
32
40
  Options:
33
- --agents <a,b> Run only these agents (comma-separated ids); default: all
34
- --route Let the router pick relevant agents from the diff
35
- -h, --help Show this help
41
+ --agents <a,b> Run only these agents (comma-separated ids); default: all
42
+ --route Let the router pick relevant agents from the diff
43
+ --scopes <a,b> Limit the fan-out to these named scopes (routing only)
44
+ --config-dir <dir> Load the ROOT config.jsonc + routing.jsonc from <dir>
45
+ instead of .expo-code-review/ (also ECR_CONFIG_DIR). Scope
46
+ subtrees stay repo-root-relative.
47
+ --comment <mode> Override manifest comment mode: single | per-scope
48
+ --force Manual override: review even if the trigger policy (label
49
+ trigger / ai-review:skip) would skip. Break-glass and the
50
+ auth lock still apply. A /review comment command implies this.
51
+ -h, --help Show this help
36
52
 
37
53
  Env: GITHUB_REPOSITORY, GITHUB_EVENT_PATH/GITHUB_REF (PR number), GH_TOKEN,
38
54
  and model credentials per .expo-code-review/config.jsonc (or REVIEWER_MODEL).
55
+ GITHUB_EVENT_NAME=issue_comment implies --force (a /review comment command).
39
56
  `;
40
57
  export async function ciCommand(argv = []) {
41
- if (argv.includes('-h') || argv.includes('--help')) {
58
+ if (argv.includes("-h") || argv.includes("--help")) {
42
59
  process.stdout.write(CI_USAGE);
43
60
  return;
44
61
  }
45
62
  const agents = parseAgents(argv);
46
- const route = argv.includes('--route');
63
+ const route = argv.includes("--route");
64
+ const scopesFilter = parseListFlag(argv, "--scopes");
65
+ const commentOverride = parseCommentMode(argv);
66
+ // The ROOT config dir escape hatch (mirrors `ecr review`): an explicit
67
+ // --config-dir wins, else resolveConfigDir falls back to ECR_CONFIG_DIR, else
68
+ // the default .expo-code-review/. Applies to config.jsonc AND routing.jsonc.
69
+ let configDir;
70
+ try {
71
+ configDir = parseValueFlag(argv, "--config-dir");
72
+ }
73
+ catch (error) {
74
+ process.stderr.write(`${errorMessage(error)}\n\n${CI_USAGE}`);
75
+ process.exitCode = 2;
76
+ return;
77
+ }
78
+ // A maintainer's explicit `/review` (comment command or --force) is a manual
79
+ // escape hatch that bypasses the trigger-policy gate only (see passesTriggerGate).
80
+ const bypassTriggerGate = shouldBypassTriggerGate(argv);
47
81
  const root = await repoRoot();
48
82
  if (root && root !== process.cwd()) {
49
83
  process.chdir(root);
50
84
  }
85
+ const cwd = process.cwd();
51
86
  const repo = process.env.GITHUB_REPOSITORY;
52
87
  const prNumber = await resolvePrNumber();
53
88
  if (!repo || prNumber == null) {
54
- process.stderr.write('CI reviewer: could not determine repository or PR number from the environment. Skipping.\n');
89
+ process.stderr.write("CI reviewer: could not determine repository or PR number from the environment. Skipping.\n");
90
+ return;
91
+ }
92
+ // A malformed manifest is a loud, non-blocking error (never a silent fallback).
93
+ let manifest;
94
+ try {
95
+ manifest = await loadRoutingManifest(cwd, { configDir });
96
+ }
97
+ catch (error) {
98
+ process.stderr.write(`CI reviewer: invalid routing.jsonc: ${errorMessage(error)}\n`);
99
+ return;
100
+ }
101
+ if (manifest == null) {
102
+ await runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate, configDir);
55
103
  return;
56
104
  }
105
+ try {
106
+ await runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir);
107
+ }
108
+ catch (error) {
109
+ // Fan-out failures stay non-blocking (single-writer property is the point).
110
+ process.stderr.write(`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`);
111
+ }
112
+ }
113
+ /**
114
+ * The pre-routing single-config path. Kept byte-for-byte equivalent so that with no
115
+ * routing.jsonc the CLI behaves exactly as before (backcompat invariant).
116
+ */
117
+ async function runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate, configDir) {
57
118
  let config;
58
119
  try {
59
- config = await loadReviewConfig(process.cwd());
120
+ config = await loadReviewConfig(cwd, { configDir });
60
121
  }
61
122
  catch (error) {
62
123
  process.stderr.write(`CI reviewer: ${errorMessage(error)}\n`);
63
124
  return;
64
125
  }
126
+ // Layer-1 auth lock (mirrors doctor): when the workflow pins the expected token
127
+ // env var name, refuse to run if the config names anything else. The workflow's
128
+ // bash guard is a text sweep (layer 2) and can't see through JSON escapes; this
129
+ // check compares the tokenEnv the loader actually honors.
130
+ const expectedTokenEnv = process.env.ECR_EXPECTED_TOKEN_ENV;
131
+ if (expectedTokenEnv && config.auth.tokenEnv !== expectedTokenEnv) {
132
+ process.stderr.write(`CI reviewer: auth.tokenEnv "${config.auth.tokenEnv ?? "(none)"}" != ECR_EXPECTED_TOKEN_ENV "${expectedTokenEnv}"; refusing to run.\n`);
133
+ return;
134
+ }
135
+ // Config-driven trigger policy (.expo-code-review/config.jsonc → review): decide
136
+ // whether this PR should be reviewed at all (bypassed by a manual /review).
137
+ if (!passesTriggerGate(await fetchPrLabels(repo, prNumber, cwd), config.review, bypassTriggerGate)) {
138
+ return;
139
+ }
65
140
  const reporter = new GitHubReporter({
66
141
  prNumber,
67
142
  repo,
68
143
  commentTag: config.commentTag,
69
144
  breakGlassMarker: config.breakGlassMarker,
70
- cwd: process.cwd(),
145
+ cwd,
71
146
  });
72
147
  try {
73
148
  if (await reporter.checkBreakGlass()) {
@@ -80,12 +155,12 @@ export async function ciCommand(argv = []) {
80
155
  process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
81
156
  }
82
157
  try {
83
- const review = await runReview(new GitHubPRSource({ prNumber, repo, cwd: process.cwd() }), {
158
+ const review = await runReview(new GitHubPRSource({ prNumber, repo, cwd }), {
84
159
  config,
85
- mode: 'ci',
160
+ mode: "ci",
86
161
  agents,
87
162
  route,
88
- onProgress: message => process.stderr.write(`${message}\n`),
163
+ onProgress: (message) => process.stderr.write(`${message}\n`),
89
164
  });
90
165
  await reporter.report(review);
91
166
  process.stderr.write(`CI reviewer: posted review (${review.decision}).\n`);
@@ -99,7 +174,7 @@ export async function ciCommand(argv = []) {
99
174
  process.stderr.write(`CI reviewer: run failed (non-blocking): ${reason}\n`);
100
175
  try {
101
176
  await reporter.report({
102
- decision: 'approve_with_comments',
177
+ decision: "approve_with_comments",
103
178
  findings: [],
104
179
  summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${reason}`,
105
180
  incomplete: [],
@@ -110,21 +185,345 @@ export async function ciCommand(argv = []) {
110
185
  }
111
186
  }
112
187
  }
188
+ /** A scope's non-blocking failure placeholder (same shape as the legacy failure notice). */
189
+ function failureReview(scopeName, reason) {
190
+ return {
191
+ decision: "approve_with_comments",
192
+ findings: [],
193
+ summary: `⚠️ The AI reviewer failed to run for scope **${scopeName}**, so those changes were **not** reviewed:\n\n> ${reason}`,
194
+ incomplete: [],
195
+ };
196
+ }
197
+ /** The routing fan-out: one process, N scopes reviewed sequentially, one render. */
198
+ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir) {
199
+ // The root config + manifest follow the override; scope configs stay
200
+ // repo-root-relative (loadScopeConfig reads <root>/<scope.config>/.expo-code-review).
201
+ const rootConfig = await loadReviewConfig(cwd, { configDir });
202
+ // The root/aggregate marker is the ACTUAL root-owned comment tag so the
203
+ // pre-routing comment and its dismissal state upsert in place, not stranded
204
+ // under a new marker (risk 8/9). manifest.defaults.commentTag is the
205
+ // manifest-side default; when it diverges from the root config's tag (e.g. a
206
+ // repo with a custom root tag adopts routing.jsonc without setting
207
+ // defaults.commentTag, so Zod defaults it), keep the root config's tag and
208
+ // warn rather than silently posting under a fresh marker and losing history.
209
+ const rootTag = rootConfig.commentTag;
210
+ if (manifest.defaults.commentTag !== rootConfig.commentTag) {
211
+ process.stderr.write(`CI reviewer: routing.jsonc defaults.commentTag "${manifest.defaults.commentTag}" != root config.jsonc commentTag "${rootConfig.commentTag}"; using the root config's tag so the existing comment and its dismissals carry over. Set defaults.commentTag to match to silence this.\n`);
212
+ }
213
+ // Every enforced agent must exist in the ROOT roster (it is the source of truth).
214
+ const missing = manifest.defaults.enforceAgents.filter((id) => !rootConfig.agents.some((agent) => agent.id === id));
215
+ if (missing.length > 0) {
216
+ process.stderr.write(`CI reviewer: defaults.enforceAgents references unknown root agent(s): ${missing.join(", ")}. Skipping.\n`);
217
+ return;
218
+ }
219
+ // Layer-1 auth lock (mirrors doctor): when the workflow pins the expected token
220
+ // env var name, refuse to run if the HONORED auth (routing.jsonc defaults.auth
221
+ // when present, else the root config.jsonc) names anything else. The workflow's
222
+ // bash guard is a text sweep (layer 2) and can't see through JSON escapes; this
223
+ // check compares the tokenEnv the loader actually honors, so a PR-supplied
224
+ // defaults.auth can never repoint the forwarded credential.
225
+ const expectedTokenEnv = process.env.ECR_EXPECTED_TOKEN_ENV;
226
+ if (expectedTokenEnv) {
227
+ const honored = loadAuthFromRoot(rootConfig, manifest);
228
+ if (honored.tokenEnv !== expectedTokenEnv) {
229
+ process.stderr.write(`CI reviewer: honored auth.tokenEnv "${honored.tokenEnv ?? "(none)"}" != ECR_EXPECTED_TOKEN_ENV "${expectedTokenEnv}"; refusing to run.\n`);
230
+ return;
231
+ }
232
+ }
233
+ // Trigger policy is central (infra-owned): the ROOT config's `review` block
234
+ // gates the whole routed run — scope configs never widen or narrow the trigger.
235
+ // A manual /review bypasses this gate (break-glass + auth lock still apply).
236
+ if (!passesTriggerGate(await fetchPrLabels(repo, prNumber, cwd), rootConfig.review, bypassTriggerGate)) {
237
+ return;
238
+ }
239
+ const source = memoizeSource(new GitHubPRSource({ prNumber, repo, cwd }));
240
+ const changed = await source.getChangedFiles();
241
+ const resolution = resolveScopes(manifest, changed.map((file) => file.path));
242
+ process.stderr.write("CI reviewer: scope ownership —\n");
243
+ for (const line of formatOwnerTable(resolution)) {
244
+ process.stderr.write(`${line}\n`);
245
+ }
246
+ for (const overlap of resolution.overlaps) {
247
+ process.stderr.write(`CI reviewer: ⚠ ${overlap.file} matched ${overlap.matched.join(", ")} → ${overlap.winner} wins\n`);
248
+ }
249
+ if (resolution.unmatched.length > 0) {
250
+ process.stderr.write(`CI reviewer: ⚠ ${resolution.unmatched.length} changed file(s) matched no scope (add a **/* catch-all).\n`);
251
+ }
252
+ // Filter to named scopes AFTER resolution, so unmatched/overlaps stay honest.
253
+ let active = resolution.active;
254
+ if (scopesFilter) {
255
+ const known = new Set(manifest.scopes.map((scope) => scope.name));
256
+ const unknown = scopesFilter.filter((name) => !known.has(name));
257
+ if (unknown.length > 0) {
258
+ process.stderr.write(`CI reviewer: unknown scope(s) in --scopes: ${unknown.join(", ")}\n`);
259
+ }
260
+ active = active.filter((scope) => scopesFilter.includes(scope.name));
261
+ }
262
+ const mode = commentOverride ?? manifest.comment;
263
+ // Break-glass: ONE check via a reporter on the root marker, before fan-out.
264
+ const bgReporter = new GitHubReporter({
265
+ prNumber,
266
+ repo,
267
+ commentTag: rootTag,
268
+ breakGlassMarker: rootConfig.breakGlassMarker,
269
+ cwd,
270
+ });
271
+ try {
272
+ if (await bgReporter.checkBreakGlass()) {
273
+ process.stderr.write(`CI reviewer: ${rootConfig.breakGlassMarker} detected; skipping.\n`);
274
+ await bgReporter.postSkipNote();
275
+ await source.dispose();
276
+ return;
277
+ }
278
+ }
279
+ catch (error) {
280
+ process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
281
+ }
282
+ // Build ONE link context for all scopes (rate-limit hygiene): diff lines from the
283
+ // already-fetched changed files, base SHA via a single `gh pr view`.
284
+ const link = {
285
+ repo,
286
+ prNumber,
287
+ diffLines: buildDiffLineIndex(changed.map((file) => ({ path: file.path, patch: file.patch }))),
288
+ };
289
+ try {
290
+ const { stdout } = await run("gh", ["pr", "view", String(prNumber), "--repo", repo, "--json", "baseRefOid"], { cwd });
291
+ const oid = JSON.parse(stdout).baseRefOid;
292
+ if (oid) {
293
+ link.baseSha = oid;
294
+ }
295
+ }
296
+ catch {
297
+ // leave baseSha unset → out-of-diff findings degrade to plain text
298
+ }
299
+ // Divide the passes budget across active scopes (risk 4), floored so a single
300
+ // scope still gets a workable window (see budget.* in routing.jsonc). Active
301
+ // scopes run sequentially, so N × perScope is the real wall-clock; when the
302
+ // floor forces that past the total, keep the floor but warn loudly.
303
+ const totalMs = manifest.budget.totalPassesMinutes * 60_000;
304
+ const minMs = manifest.budget.minScopeMinutes * 60_000;
305
+ const { perScopeMs: budget, overshoot } = scopePassesBudgetMs(totalMs, minMs, active.length);
306
+ if (overshoot) {
307
+ const expectedMinutes = Math.round((active.length * budget) / 60_000);
308
+ process.stderr.write(`CI reviewer: ⚠ ${active.length} scopes × floor = ${expectedMinutes}min exceeds budget.totalPassesMinutes (${manifest.budget.totalPassesMinutes}m); expect longer runs — raise the job timeout or trim scopes.\n`);
309
+ }
310
+ const results = [];
311
+ for (const scope of active) {
312
+ const scopeDef = manifest.scopes.find((entry) => entry.name === scope.name);
313
+ const isDefault = scope.configDir === ".";
314
+ let review;
315
+ try {
316
+ const config = await loadScopeConfig(cwd, scopeDef, manifest, rootConfig);
317
+ review = await runReview(source, {
318
+ config,
319
+ mode: "ci",
320
+ agents,
321
+ route,
322
+ includePaths: scope.files,
323
+ passesBudgetMs: budget,
324
+ onProgress: (message) => process.stderr.write(`[${scope.name}] ${message}\n`),
325
+ });
326
+ }
327
+ catch (error) {
328
+ const reason = errorMessage(error);
329
+ process.stderr.write(`CI reviewer: [${scope.name}] failed (non-blocking): ${reason}\n`);
330
+ review = failureReview(scope.name, reason);
331
+ }
332
+ results.push({ scope: scope.name, isDefault, review });
333
+ }
334
+ await source.dispose();
335
+ const reporterFor = (tag, withLink = false) => new GitHubReporter({
336
+ prNumber,
337
+ repo,
338
+ commentTag: tag,
339
+ breakGlassMarker: rootConfig.breakGlassMarker,
340
+ cwd,
341
+ linkContext: withLink ? link : undefined,
342
+ });
343
+ if (mode === "single") {
344
+ const aggregate = reporterFor(rootTag, true);
345
+ let finalResults = results;
346
+ if (scopesFilter) {
347
+ // A partial run (--scopes) is authoritative ONLY for the named scopes: merge
348
+ // the other scopes' previous results out of the existing aggregate comment's
349
+ // state so re-running one scope doesn't silently discard the rest.
350
+ const prior = (await aggregate.readState())?.scopes ?? [];
351
+ const byName = new Map(results.map((result) => [result.scope, result]));
352
+ for (const previous of prior) {
353
+ if (!scopesFilter.includes(previous.scope) && !byName.has(previous.scope)) {
354
+ byName.set(previous.scope, previous);
355
+ }
356
+ }
357
+ // Manifest order; scopes no longer in the manifest drop out.
358
+ finalResults = manifest.scopes
359
+ .map((scope) => byName.get(scope.name))
360
+ .filter((entry) => entry != null);
361
+ }
362
+ await aggregate.reportAggregate(finalResults, resolution.unmatched);
363
+ // Clean up any per-scope comments from a previous per-scope run. A partial run
364
+ // only ever touches the named scopes' comments.
365
+ for (const scope of manifest.scopes) {
366
+ if (scopesFilter && !scopesFilter.includes(scope.name)) {
367
+ continue;
368
+ }
369
+ await reporterFor(scopedCommentTag(rootTag, scope.name)).clear();
370
+ }
371
+ process.stderr.write(`CI reviewer: posted aggregate review for ${finalResults.length} scope(s).\n`);
372
+ }
373
+ else {
374
+ for (const scope of manifest.scopes) {
375
+ // A partial run (--scopes) must never touch the other scopes' live comments
376
+ // (their reviews and dismissal state stay exactly as posted).
377
+ if (scopesFilter && !scopesFilter.includes(scope.name)) {
378
+ continue;
379
+ }
380
+ const reporter = reporterFor(scopedCommentTag(rootTag, scope.name), true);
381
+ const result = results.find((entry) => entry.scope === scope.name);
382
+ if (result) {
383
+ await reporter.report(result.review);
384
+ }
385
+ else {
386
+ // A reconciled scope with zero matched files gets its stale comment deleted.
387
+ await reporter.clear();
388
+ }
389
+ }
390
+ // The default scope posts under its scoped tag too, so clear the bare root-tag
391
+ // comment once so a single→per-scope switch doesn't strand it. A partial run
392
+ // (--scopes) skips this mode-switch cleanup: the root-tag comment may hold the
393
+ // other scopes' aggregate results.
394
+ if (!scopesFilter) {
395
+ await reporterFor(rootTag).clear();
396
+ }
397
+ process.stderr.write("CI reviewer: posted per-scope reviews.\n");
398
+ }
399
+ }
400
+ /**
401
+ * Current PR labels via gh (more authoritative than the possibly-stale event
402
+ * payload); on failure, returns [] so a label-read hiccup never silently skips a
403
+ * PR (shouldReview defaults toward reviewing).
404
+ */
405
+ async function fetchPrLabels(repo, prNumber, cwd) {
406
+ try {
407
+ const { stdout } = await run("gh", [
408
+ "pr",
409
+ "view",
410
+ String(prNumber),
411
+ "--repo",
412
+ repo,
413
+ "--json",
414
+ "labels",
415
+ "--jq",
416
+ ".labels[].name",
417
+ ], { cwd });
418
+ return stdout
419
+ .split("\n")
420
+ .map((name) => name.trim())
421
+ .filter(Boolean);
422
+ }
423
+ catch (error) {
424
+ process.stderr.write(`CI reviewer: could not read PR labels (continuing): ${errorMessage(error)}\n`);
425
+ return [];
426
+ }
427
+ }
428
+ /**
429
+ * Decide whether a PR should be reviewed, given its labels and the repo's trigger
430
+ * policy. `skipLabel` always wins (write-gated opt-out). In "label" mode a PR must
431
+ * carry `label` or a `label:<agent>` variant; in "all" mode every non-skipped PR
432
+ * is reviewed. Pure so it's unit-testable and matches exact label names (no
433
+ * substring surprises like `ai-review:skip` satisfying an `ai-review` check).
434
+ */
435
+ export function shouldReview(labels, review) {
436
+ if (labels.includes(review.skipLabel)) {
437
+ return { review: false, reason: `the ${review.skipLabel} label is set` };
438
+ }
439
+ if (review.trigger === "label") {
440
+ const optedIn = labels.some((name) => name === review.label || name.startsWith(`${review.label}:`));
441
+ return optedIn
442
+ ? { review: true, reason: `the ${review.label} label is set` }
443
+ : { review: false, reason: `trigger is "label" and no ${review.label} label is set` };
444
+ }
445
+ return { review: true, reason: 'trigger is "all"' };
446
+ }
447
+ /**
448
+ * Whether an explicit manual invocation should bypass the trigger/skip gate. A
449
+ * maintainer's `/review` is a manual escape hatch, so it must run even when the PR
450
+ * carries the skipLabel or the trigger policy would otherwise skip it. Detected via
451
+ * the `--force` flag OR the GitHub event being a comment command — `issue_comment`
452
+ * only reaches `ecr ci` through a /review command workflow, never the auto
453
+ * `pull_request` workflow. Pure (env injected) so it's unit-testable.
454
+ */
455
+ export function shouldBypassTriggerGate(argv, env = process.env) {
456
+ return argv.includes("--force") || env.GITHUB_EVENT_NAME === "issue_comment";
457
+ }
458
+ /**
459
+ * Apply the trigger policy, honoring a manual-override bypass. Returns whether to
460
+ * proceed. When a bypass overrides a gate that would have skipped, emits a stderr
461
+ * notice so the override is visible in the job log; a normal (non-bypassed) skip
462
+ * emits the usual skip line. The bypass affects ONLY this trigger gate —
463
+ * break-glass and the auth lock are separate and still apply.
464
+ */
465
+ function passesTriggerGate(labels, review, bypass) {
466
+ const gate = shouldReview(labels, review);
467
+ if (gate.review) {
468
+ return true;
469
+ }
470
+ if (bypass) {
471
+ process.stderr.write(`CI reviewer: manual /review — bypassing trigger policy (${gate.reason}).\n`);
472
+ return true;
473
+ }
474
+ process.stderr.write(`CI reviewer: skipping — ${gate.reason}.\n`);
475
+ return false;
476
+ }
113
477
  /** Parse `--agents a,b,c` from argv (undefined = all agents). */
114
478
  function parseAgents(argv) {
115
- const index = argv.indexOf('--agents');
479
+ return parseListFlag(argv, "--agents");
480
+ }
481
+ /** Parse a comma-separated list flag (`--flag a,b`); undefined when absent/empty. */
482
+ function parseListFlag(argv, flag) {
483
+ const index = argv.indexOf(flag);
116
484
  if (index === -1) {
117
485
  return undefined;
118
486
  }
119
487
  const value = argv[index + 1];
120
488
  // A missing value, or the next token being another flag (e.g. `--agents --route`),
121
- // means no agent list was given — treat as "all" rather than misparsing `--route`
122
- // as an agent id. Mirrors review.ts's requireValue.
123
- if (!value || value.startsWith('--')) {
489
+ // means no list was given — treat as "all"/absent rather than misparsing the next
490
+ // flag as a value. Mirrors review.ts's requireValue.
491
+ if (!value || value.startsWith("--")) {
124
492
  return undefined;
125
493
  }
126
494
  return value
127
- .split(',')
128
- .map(id => id.trim())
495
+ .split(",")
496
+ .map((id) => id.trim())
129
497
  .filter(Boolean);
130
498
  }
499
+ /** Parse a single-value flag (`--flag value`); undefined when absent, throws when
500
+ * present without a value (matching review.ts's requireValue). */
501
+ function parseValueFlag(argv, flag) {
502
+ const index = argv.indexOf(flag);
503
+ if (index === -1) {
504
+ return undefined;
505
+ }
506
+ const value = argv[index + 1];
507
+ if (value === undefined || value.startsWith("--")) {
508
+ throw new Error(`${flag} requires a value`);
509
+ }
510
+ return value;
511
+ }
512
+ /** Parse `--comment single|per-scope` (undefined = use the manifest's setting). */
513
+ function parseCommentMode(argv) {
514
+ const index = argv.indexOf("--comment");
515
+ if (index === -1) {
516
+ return undefined;
517
+ }
518
+ const value = argv[index + 1];
519
+ if (value === "single" || value === "per-scope") {
520
+ return value;
521
+ }
522
+ // A present-but-invalid mode (e.g. `--comment foo`) would otherwise fall back to
523
+ // the manifest silently; warn like --scopes does for unknown scope names. A
524
+ // missing value or a following flag is treated as absent.
525
+ if (value && !value.startsWith("--")) {
526
+ process.stderr.write(`CI reviewer: ignoring invalid --comment mode "${value}" (expected single | per-scope)\n`);
527
+ }
528
+ return undefined;
529
+ }
@@ -1,7 +1,7 @@
1
- import { loadReviewConfig } from '../config/load.js';
2
- import { repoRoot, resolveRepo } from '../core/exec.js';
3
- import { errorMessage } from '../core/util.js';
4
- import { GitHubReporter } from '../reporters/github.js';
1
+ import { loadReviewConfig } from "../config/load.js";
2
+ import { repoRoot, resolveRepo } from "../core/exec.js";
3
+ import { errorMessage } from "../core/util.js";
4
+ import { GitHubReporter } from "../reporters/github.js";
5
5
  const USAGE = `ecr dismiss / undismiss — hide (or restore) a finding on a PR
6
6
 
7
7
  Usage:
@@ -17,31 +17,31 @@ function parseArgs(argv) {
17
17
  for (let i = 0; i < argv.length; i++) {
18
18
  const arg = argv[i];
19
19
  switch (arg) {
20
- case '--pr':
20
+ case "--pr":
21
21
  args.pr = Number(argv[++i]);
22
22
  break;
23
- case '--repo':
23
+ case "--repo":
24
24
  args.repo = argv[++i];
25
25
  break;
26
- case '--reason':
26
+ case "--reason":
27
27
  args.reason = argv[++i];
28
28
  break;
29
- case '--by':
29
+ case "--by":
30
30
  args.by = argv[++i];
31
31
  break;
32
32
  default:
33
- if (arg.startsWith('--')) {
33
+ if (arg.startsWith("--")) {
34
34
  throw new Error(`Unknown argument: ${arg}`);
35
35
  }
36
36
  // Bare arg = a finding id. Sanitize to the fingerprint alphabet.
37
- args.ids.push(arg.replace(/[^a-f0-9]/g, ''));
37
+ args.ids.push(arg.replace(/[^a-f0-9]/g, ""));
38
38
  }
39
39
  }
40
40
  args.ids = args.ids.filter(Boolean);
41
41
  return args;
42
42
  }
43
43
  export async function dismissCommand(argv, mode) {
44
- if (argv.includes('-h') || argv.includes('--help')) {
44
+ if (argv.includes("-h") || argv.includes("--help")) {
45
45
  process.stdout.write(USAGE);
46
46
  return;
47
47
  }
@@ -55,12 +55,12 @@ export async function dismissCommand(argv, mode) {
55
55
  return;
56
56
  }
57
57
  if (args.pr == null || !Number.isInteger(args.pr) || args.pr <= 0) {
58
- process.stderr.write('dismiss: --pr <number> is required.\n');
58
+ process.stderr.write("dismiss: --pr <number> is required.\n");
59
59
  process.exitCode = 2;
60
60
  return;
61
61
  }
62
62
  if (args.ids.length === 0) {
63
- process.stderr.write('dismiss: provide at least one finding id.\n');
63
+ process.stderr.write("dismiss: provide at least one finding id.\n");
64
64
  process.exitCode = 2;
65
65
  return;
66
66
  }
@@ -79,15 +79,15 @@ export async function dismissCommand(argv, mode) {
79
79
  breakGlassMarker: config.breakGlassMarker,
80
80
  cwd,
81
81
  });
82
- const result = await reporter.applyDismissal(mode === 'add' ? args.ids : [], mode === 'remove' ? args.ids : [], args.by, args.reason);
83
- if (mode === 'add') {
82
+ const result = await reporter.applyDismissal(mode === "add" ? args.ids : [], mode === "remove" ? args.ids : [], args.by, args.reason);
83
+ if (mode === "add") {
84
84
  process.stderr.write(`Dismissed ${result.matched.length} finding(s) on ${repo}#${args.pr}.\n`);
85
85
  }
86
86
  else {
87
87
  process.stderr.write(`Restored finding(s) on ${repo}#${args.pr}.\n`);
88
88
  }
89
89
  if (result.unmatched.length > 0) {
90
- process.stderr.write(`Unknown id(s) (no matching finding): ${result.unmatched.join(', ')}\n`);
90
+ process.stderr.write(`Unknown id(s) (no matching finding): ${result.unmatched.join(", ")}\n`);
91
91
  }
92
92
  }
93
93
  catch (error) {