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