@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,74 +1,267 @@
1
- import { loadReviewConfig, hasConfig } from '../config/load.js';
2
- import { checkProviderAuth } from '../core/auth.js';
3
- import { onPath, repoRoot, run } from '../core/exec.js';
4
- import { errorMessage } from '../core/util.js';
1
+ import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, resolveConfigDir, tokenEnvMismatch, } from "../config/load.js";
2
+ import { loadRoutingManifest, resolveScopes, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
3
+ import { checkProviderAuth } from "../core/auth.js";
4
+ import { opencodeBinSource } from "../core/opencode.js";
5
+ import { git, onPath, repoRoot, run } from "../core/exec.js";
6
+ import { errorMessage } from "../core/util.js";
7
+ import path from "node:path";
5
8
  const USAGE = `ecr doctor — check environment, config, and credentials
6
9
 
7
10
  Usage:
8
- ecr doctor
11
+ ecr doctor [--list-scopes]
9
12
 
10
13
  Verifies: opencode + git (+ gh for \`ecr ci\`) on PATH, .expo-code-review/ config is
11
- valid, agent prompts resolve, and the configured model's token env is set.
14
+ valid, agent prompts resolve, and the configured model's token env is set. When a
15
+ routing.jsonc is present, also validates every scope, the auth singleton, scope
16
+ ownership over tracked files, and comment-tag uniqueness.
17
+
18
+ Options:
19
+ --list-scopes Print the routing scope table (name, dir, paths, agents, tag)
12
20
  `;
21
+ /**
22
+ * `opencode --version`, run from `binDir` if given (so the bundled CLI can be asked
23
+ * directly) or from PATH otherwise. Null when it can't be determined — a version we
24
+ * can't read is worth staying quiet about, not failing over.
25
+ */
26
+ async function opencodeVersion(binDir) {
27
+ const command = binDir ? path.join(binDir, "opencode") : "opencode";
28
+ const { stdout, code } = await run(command, ["--version"], { check: false });
29
+ if (code !== 0) {
30
+ return null;
31
+ }
32
+ return stdout.trim().split("\n")[0]?.trim() || null;
33
+ }
13
34
  /** Preflight checks so a broken setup surfaces clearly instead of silently no-opping. */
14
35
  export async function doctorCommand(argv = []) {
15
- if (argv.includes('-h') || argv.includes('--help')) {
36
+ if (argv.includes("-h") || argv.includes("--help")) {
16
37
  process.stdout.write(USAGE);
17
38
  return;
18
39
  }
19
40
  const root = (await repoRoot()) ?? process.cwd();
41
+ if (argv.includes("--list-scopes")) {
42
+ await listScopes(root);
43
+ return;
44
+ }
20
45
  let ok = true;
21
46
  const line = (pass, message) => {
22
47
  if (!pass) {
23
48
  ok = false;
24
49
  }
25
- process.stdout.write(` ${pass ? '' : ''} ${message}\n`);
50
+ process.stdout.write(` ${pass ? "" : ""} ${message}\n`);
51
+ };
52
+ const info = (message) => {
53
+ process.stdout.write(` ℹ ${message}\n`);
54
+ };
55
+ const warn = (message) => {
56
+ process.stdout.write(` ⚠ ${message}\n`);
26
57
  };
27
58
  process.stdout.write(`expo-code-review doctor (repo: ${root})\n`);
28
- const opencodeInstalled = await onPath('opencode');
59
+ // When the ROOT config dir is overridden, config.jsonc AND routing.jsonc are
60
+ // read from the resolved dir (scope subtrees stay repo-root-relative). Surface
61
+ // it so a green doctor run can't hide that it checked a non-default root.
62
+ if (process.env.ECR_CONFIG_DIR) {
63
+ info(`ECR_CONFIG_DIR override active: root config.jsonc and routing.jsonc read from ${resolveConfigDir(root)} (scope subtrees stay repo-root-relative)`);
64
+ }
65
+ // The SDK spawns a bare `opencode`, so the version that actually runs is a PATH
66
+ // lookup. Report which one wins and whether it matches the version this package
67
+ // pins: a stale global install against a newer SDK rejects model ids the SDK
68
+ // considers valid (`ProviderModelNotFoundError`), which is otherwise a baffling
69
+ // failure that only reproduces on one machine. `startOpencode` prepends our own
70
+ // bin dir so the pinned one wins at runtime — this just makes the drift visible.
71
+ const bin = opencodeBinSource();
72
+ const opencodeInstalled = (await onPath("opencode")) || bin.pinned;
29
73
  line(opencodeInstalled, opencodeInstalled
30
- ? 'opencode CLI found on PATH'
31
- : 'opencode CLI NOT on PATH (install `opencode-ai`, or add node_modules/.bin to PATH)');
32
- line(await onPath('git'), 'git found on PATH');
74
+ ? "opencode CLI available"
75
+ : "opencode CLI NOT found (install `opencode-ai`, or add node_modules/.bin to PATH)");
76
+ if (opencodeInstalled) {
77
+ const pinnedVersion = bin.pinned ? await opencodeVersion(bin.dir) : null;
78
+ const pathVersion = await opencodeVersion(null);
79
+ if (pinnedVersion) {
80
+ line(true, `opencode ${pinnedVersion} (bundled with this reviewer; used at runtime)`);
81
+ if (pathVersion && pathVersion !== pinnedVersion) {
82
+ warn(`a different opencode ${pathVersion} is first on your PATH — runs use the bundled ${pinnedVersion}, ` +
83
+ `but other tooling (and \`opencode\` by hand) will use ${pathVersion}`);
84
+ }
85
+ }
86
+ else if (pathVersion) {
87
+ warn(`using opencode ${pathVersion} from PATH — this reviewer's own \`opencode-ai\` dependency could not be ` +
88
+ `resolved, so the CLI and SDK versions can drift (a stale CLI rejects model ids the SDK accepts)`);
89
+ }
90
+ }
91
+ line(await onPath("git"), "git found on PATH");
33
92
  // `gh` is only needed for `ecr ci` (posting PR comments), so treat it as
34
93
  // informational (ℹ) rather than a hard failure for local `ecr review` users.
35
- const info = (message) => {
36
- process.stdout.write(` ℹ ${message}\n`);
37
- };
38
- if (await onPath('gh')) {
94
+ if (await onPath("gh")) {
39
95
  let authed = false;
40
96
  try {
41
- await run('gh', ['auth', 'status'], { cwd: root });
97
+ await run("gh", ["auth", "status"], { cwd: root });
42
98
  authed = true;
43
99
  }
44
100
  catch {
45
101
  authed = false;
46
102
  }
47
103
  if (authed) {
48
- line(true, 'gh CLI found and authenticated (used by `ecr ci`)');
104
+ line(true, "gh CLI found and authenticated (used by `ecr ci`)");
49
105
  }
50
106
  else {
51
- info('gh CLI found but not authenticated — run `gh auth login` before `ecr ci`');
107
+ info("gh CLI found but not authenticated — run `gh auth login` before `ecr ci`");
52
108
  }
53
109
  }
54
110
  else {
55
- info('gh CLI not on PATH — only needed for `ecr ci` (posting PR comments)');
111
+ info("gh CLI not on PATH — only needed for `ecr ci` (posting PR comments)");
56
112
  }
113
+ let rootConfig;
57
114
  if (!hasConfig(root)) {
58
- line(false, `no ${'.expo-code-review'}/config.jsonc (run \`ecr init\`)`);
115
+ line(false, `no ${".expo-code-review"}/config.jsonc (run \`ecr init\`)`);
59
116
  }
60
117
  else {
61
118
  try {
62
- const config = await loadReviewConfig(root);
63
- line(true, `config valid: ${config.agents.length} agent(s) [${config.agents.map(a => a.id).join(', ')}], coordinator model ${config.coordinator.model}`);
64
- line(config.agents.every(a => Boolean(a.promptText.trim())), 'all agent prompt files resolved and non-empty');
65
- const readiness = checkProviderAuth(config);
119
+ rootConfig = await loadReviewConfig(root);
120
+ line(true, `config valid: ${rootConfig.agents.length} agent(s) [${rootConfig.agents.map((a) => a.id).join(", ")}], coordinator model ${rootConfig.coordinator.model}`);
121
+ line(rootConfig.agents.every((a) => Boolean(a.promptText.trim())), "all agent prompt files resolved and non-empty");
122
+ const readiness = checkProviderAuth(rootConfig);
66
123
  line(readiness.ok, `auth: ${readiness.detail}`);
124
+ // A suspicious-but-not-provably-broken credential: worth saying, never a failure
125
+ // (the shape rules are heuristics — see checkOauthTokenShape).
126
+ if (readiness.warning) {
127
+ warn(`auth: ${readiness.warning}`);
128
+ }
67
129
  }
68
130
  catch (error) {
69
131
  line(false, `config invalid: ${errorMessage(error)}`);
70
132
  }
71
133
  }
72
- process.stdout.write(ok ? '\nAll good.\n' : '\nIssues found (see above).\n');
134
+ // Routing manifest checks (only when a routing.jsonc is present).
135
+ let manifest = null;
136
+ try {
137
+ manifest = await loadRoutingManifest(root);
138
+ }
139
+ catch (error) {
140
+ line(false, `routing.jsonc invalid: ${errorMessage(error)}`);
141
+ }
142
+ if (manifest && rootConfig) {
143
+ process.stdout.write("\nRouting manifest:\n");
144
+ line(true, `manifest valid: ${manifest.scopes.length} scope(s), comment mode "${manifest.comment}"`);
145
+ // enforceAgents must exist in the ROOT roster.
146
+ for (const id of manifest.defaults.enforceAgents) {
147
+ const present = rootConfig.agents.some((agent) => agent.id === id);
148
+ line(present, present
149
+ ? `enforced agent "${id}" found in the root roster`
150
+ : `enforced agent "${id}" is NOT in the root roster (defaults.enforceAgents)`);
151
+ }
152
+ for (const scope of manifest.scopes) {
153
+ let scopeConfig;
154
+ try {
155
+ scopeConfig = await loadScopeConfig(root, scope, manifest, rootConfig);
156
+ }
157
+ catch (error) {
158
+ // A scope config declaring auth/breakGlass/commentTag surfaces its Zod
159
+ // error HERE, before CI.
160
+ line(false, `scope ${scope.name}: ${errorMessage(error)}`);
161
+ continue;
162
+ }
163
+ line(true, `scope ${scope.name}: ${scopeConfig.agents.length} agent(s) [${scopeConfig.agents.map((a) => a.id).join(", ")}], config ${scope.config}`);
164
+ }
165
+ // Passes-budget headroom: active scopes run sequentially, so the worst case
166
+ // is every scope active at the per-scope floor. If scopes.length × the floor
167
+ // exceeds the total, runs can outlast the total budget (a ⚠, not a failure —
168
+ // tune budget.* or the job timeout).
169
+ const totalMs = manifest.budget.totalPassesMinutes * 60_000;
170
+ const minMs = manifest.budget.minScopeMinutes * 60_000;
171
+ const { overshoot } = scopePassesBudgetMs(totalMs, minMs, manifest.scopes.length);
172
+ if (overshoot) {
173
+ warn(`passes budget: ${manifest.scopes.length} scopes × ${manifest.budget.minScopeMinutes}m floor = ${manifest.scopes.length * manifest.budget.minScopeMinutes}m worst case exceeds budget.totalPassesMinutes (${manifest.budget.totalPassesMinutes}m) — raise the job timeout or trim scopes`);
174
+ }
175
+ else {
176
+ line(true, `passes budget: worst case ${manifest.scopes.length} scopes × ${manifest.budget.minScopeMinutes}m floor fits budget.totalPassesMinutes (${manifest.budget.totalPassesMinutes}m)`);
177
+ }
178
+ // Per-scope comment markers are always derived (`<tag>:<scope>`, unique by
179
+ // scope-name uniqueness) and the scope schema rejects commentTag overrides,
180
+ // so marker collisions are impossible by construction — nothing to check.
181
+ // auth singleton: exactly one honored source (defaults.auth or root config auth).
182
+ const auth = loadAuthFromRoot(rootConfig, manifest);
183
+ const hasManifestAuth = Boolean(manifest.defaults.auth);
184
+ line(true, `auth singleton: honored from ${hasManifestAuth ? "routing.jsonc defaults.auth" : "root config.jsonc"} ` +
185
+ `(${auth.map((entry) => `${entry.mode}/${entry.provider}`).join(", ")})`);
186
+ const expected = process.env.ECR_EXPECTED_TOKEN_ENV;
187
+ if (expected) {
188
+ const mismatch = tokenEnvMismatch(auth, expected);
189
+ if (mismatch) {
190
+ line(false, `auth: ${mismatch}`);
191
+ }
192
+ }
193
+ for (const entry of auth) {
194
+ if (entry.tokenEnv) {
195
+ line(Boolean(process.env[entry.tokenEnv]), `auth token env ${entry.tokenEnv} (${entry.provider}) is ${process.env[entry.tokenEnv] ? "set" : "NOT set"}`);
196
+ }
197
+ }
198
+ // Owner-table dry run over tracked files (graft 4).
199
+ try {
200
+ const tracked = (await git(["ls-files"], root))
201
+ .split("\n")
202
+ .map((f) => f.trim())
203
+ .filter(Boolean);
204
+ const resolution = resolveScopes(manifest, tracked);
205
+ const hasCatchAll = manifest.scopes.some((scope) => scope.paths.includes("**/*"));
206
+ line(resolution.unmatched.length === 0 || hasCatchAll, resolution.unmatched.length === 0
207
+ ? `scope coverage: all ${tracked.length} tracked file(s) match a scope`
208
+ : `scope coverage: ${resolution.unmatched.length} file(s) match no scope${hasCatchAll ? " (ok — a **/* catch-all exists)" : " (add a **/* catch-all)"}`);
209
+ if (resolution.overlaps.length > 0) {
210
+ warn(`${resolution.overlaps.length} file(s) match >1 scope (last-match wins):`);
211
+ for (const owner of formatOwnerTable(resolution, 20)) {
212
+ process.stdout.write(`${owner}\n`);
213
+ }
214
+ }
215
+ }
216
+ catch (error) {
217
+ info(`scope coverage: could not run \`git ls-files\` (${errorMessage(error)})`);
218
+ }
219
+ }
220
+ process.stdout.write(ok ? "\nAll good.\n" : "\nIssues found (see ✗ above).\n");
221
+ process.exitCode = ok ? 0 : 1;
222
+ }
223
+ /** Print the routing scope table; exit 0/1 on manifest validity alone. */
224
+ async function listScopes(root) {
225
+ let manifest;
226
+ try {
227
+ manifest = await loadRoutingManifest(root);
228
+ }
229
+ catch (error) {
230
+ process.stdout.write(` ✗ routing.jsonc invalid: ${errorMessage(error)}\n`);
231
+ process.exitCode = 1;
232
+ return;
233
+ }
234
+ if (!manifest) {
235
+ process.stdout.write(`No ${".expo-code-review"}/routing.jsonc — run \`ecr init --monorepo\`.\n`);
236
+ process.exitCode = 0;
237
+ return;
238
+ }
239
+ let rootConfig;
240
+ try {
241
+ rootConfig = await loadReviewConfig(root);
242
+ }
243
+ catch (error) {
244
+ process.stdout.write(` ✗ root config invalid: ${errorMessage(error)}\n`);
245
+ process.exitCode = 1;
246
+ return;
247
+ }
248
+ process.stdout.write(`Routing scopes (comment mode: ${manifest.comment}):\n\n`);
249
+ let ok = true;
250
+ for (const scope of manifest.scopes) {
251
+ process.stdout.write(` ${scope.name}\n`);
252
+ process.stdout.write(` config: ${scope.config}\n`);
253
+ process.stdout.write(` paths: ${scope.paths.join(", ")}\n`);
254
+ try {
255
+ const config = await loadScopeConfig(root, scope, manifest, rootConfig);
256
+ process.stdout.write(` agents: ${config.agents.map((a) => (a.alwaysRun ? `${a.id}*` : a.id)).join(", ")}\n`);
257
+ process.stdout.write(` tag: ${config.commentTag}\n`);
258
+ }
259
+ catch (error) {
260
+ ok = false;
261
+ process.stdout.write(` ERROR: ${errorMessage(error)}\n`);
262
+ }
263
+ process.stdout.write("\n");
264
+ }
265
+ process.stdout.write("(* = enforced, alwaysRun)\n");
73
266
  process.exitCode = ok ? 0 : 1;
74
267
  }
@@ -1,41 +1,56 @@
1
- import { cp, mkdir, writeFile } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
3
- import path from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { CONFIG_DIRNAME } from '../config/load.js';
6
- import { repoRoot } from '../core/exec.js';
7
- import { errorMessage } from '../core/util.js';
8
- const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/', import.meta.url));
1
+ import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { CONFIG_DIRNAME } from "../config/load.js";
6
+ import { ROUTING_FILENAME } from "../config/routing.js";
7
+ import { RoutingScopeSchema } from "../config/schema.js";
8
+ import { repoRoot } from "../core/exec.js";
9
+ import { errorMessage } from "../core/util.js";
10
+ const TEMPLATES_DIR = fileURLToPath(new URL("../../templates/", import.meta.url));
9
11
  const USAGE = `ecr init — scaffold .expo-code-review/ in the current repo
10
12
 
11
13
  Usage:
12
- ecr init [--no-workflow] [--force]
14
+ ecr init [--no-workflow] [--force] Scaffold the root config (+ CI workflow)
15
+ ecr init --monorepo [--force] …and add a routing.jsonc (one default scope)
16
+ ecr init --scope <dir> [--force] Scaffold a per-team scope under <dir> and
17
+ register it in the root routing.jsonc
13
18
 
14
19
  Options:
15
- --no-workflow Skip writing the CI workflow (.github/workflows/expo-code-review.yml)
20
+ --monorepo Also write .expo-code-review/routing.jsonc (routing manifest)
21
+ --scope <dir> Scaffold <dir>/.expo-code-review/ (no auth) + add a scope entry
22
+ --no-workflow Skip writing the CI workflows (review, command, and dismiss
23
+ under .github/workflows/)
16
24
  --force Overwrite existing files
17
25
  -h, --help Show this help
18
26
  `;
19
27
  export async function initCommand(argv) {
20
- if (argv.includes('-h') || argv.includes('--help')) {
28
+ if (argv.includes("-h") || argv.includes("--help")) {
21
29
  process.stdout.write(USAGE);
22
30
  return;
23
31
  }
24
32
  try {
25
- await scaffold(argv);
33
+ const scopeDir = parseValue(argv, "--scope");
34
+ if (scopeDir != null) {
35
+ await scaffoldScope(argv, scopeDir);
36
+ }
37
+ else {
38
+ await scaffold(argv);
39
+ }
26
40
  }
27
41
  catch (error) {
28
42
  process.stderr.write(`init failed: ${errorMessage(error)}\n`);
29
43
  process.exitCode = 2;
30
44
  }
31
45
  }
32
- /** Scaffold .expo-code-review/ (and optionally the CI workflow) into the repo. */
46
+ /** Scaffold .expo-code-review/ (and optionally the CI workflow + routing manifest). */
33
47
  async function scaffold(argv) {
34
- const force = argv.includes('--force');
48
+ const force = argv.includes("--force");
35
49
  // The CI workflow is scaffolded by default (most repos adopting this want it);
36
50
  // `--no-workflow` opts out. `--with-workflow` is still accepted as a no-op for
37
51
  // back-compat.
38
- const withWorkflow = !argv.includes('--no-workflow');
52
+ const withWorkflow = !argv.includes("--no-workflow");
53
+ const monorepo = argv.includes("--monorepo");
39
54
  const root = (await repoRoot()) ?? process.cwd();
40
55
  const configDir = path.join(root, CONFIG_DIRNAME);
41
56
  // Create only the config dir; let copyInto create prompts/ so it reports
@@ -43,40 +58,235 @@ async function scaffold(argv) {
43
58
  await mkdir(configDir, { recursive: true });
44
59
  const created = [];
45
60
  const skipped = [];
46
- await copyInto(path.join(TEMPLATES_DIR, 'config.jsonc'), path.join(configDir, 'config.jsonc'), force, created, skipped, root);
47
- await copyInto(path.join(TEMPLATES_DIR, 'shared.md'), path.join(configDir, 'shared.md'), force, created, skipped, root);
48
- await copyInto(path.join(TEMPLATES_DIR, 'coordinator.md'), path.join(configDir, 'coordinator.md'), force, created, skipped, root);
49
- await copyInto(path.join(TEMPLATES_DIR, 'agents'), path.join(configDir, 'agents'), force, created, skipped, root);
50
- const gitignorePath = path.join(configDir, '.gitignore');
61
+ await copyInto(path.join(TEMPLATES_DIR, "config.jsonc"), path.join(configDir, "config.jsonc"), force, created, skipped, root);
62
+ await copyInto(path.join(TEMPLATES_DIR, "shared.md"), path.join(configDir, "shared.md"), force, created, skipped, root);
63
+ await copyInto(path.join(TEMPLATES_DIR, "coordinator.md"), path.join(configDir, "coordinator.md"), force, created, skipped, root);
64
+ await copyInto(path.join(TEMPLATES_DIR, "agents"), path.join(configDir, "agents"), force, created, skipped, root);
65
+ const gitignorePath = path.join(configDir, ".gitignore");
51
66
  if (force || !existsSync(gitignorePath)) {
52
- await writeFile(gitignorePath, '.runs/\n', 'utf8');
67
+ await writeFile(gitignorePath, ".runs/\n", "utf8");
53
68
  created.push(path.relative(root, gitignorePath));
54
69
  }
55
70
  else {
56
71
  skipped.push(path.relative(root, gitignorePath));
57
72
  }
73
+ if (monorepo) {
74
+ await copyInto(path.join(TEMPLATES_DIR, ROUTING_FILENAME), path.join(configDir, ROUTING_FILENAME), force, created, skipped, root);
75
+ }
58
76
  if (withWorkflow) {
59
- const workflowDir = path.join(root, '.github', 'workflows');
77
+ const workflowDir = path.join(root, ".github", "workflows");
60
78
  await mkdir(workflowDir, { recursive: true });
61
- await copyInto(path.join(TEMPLATES_DIR, 'workflow.yml'), path.join(workflowDir, 'expo-code-review.yml'), force, created, skipped, root);
79
+ // The auto (pull_request) workflow, plus the two issue_comment command
80
+ // workflows: `/review` (on-demand one-shot) and `/dismiss` (hide a finding).
81
+ await copyInto(path.join(TEMPLATES_DIR, "workflow.yml"), path.join(workflowDir, "expo-code-review.yml"), force, created, skipped, root);
82
+ await copyInto(path.join(TEMPLATES_DIR, "command.yml"), path.join(workflowDir, "expo-code-review-command.yml"), force, created, skipped, root);
83
+ await copyInto(path.join(TEMPLATES_DIR, "dismiss.yml"), path.join(workflowDir, "expo-code-review-dismiss.yml"), force, created, skipped, root);
84
+ }
85
+ reportFiles(created, skipped);
86
+ process.stdout.write([
87
+ "",
88
+ "Next steps:",
89
+ ` 1. Customize ${CONFIG_DIRNAME}/agents/*.md (and shared.md, coordinator.md) for this repo.`,
90
+ " 2. Configure a model provider in OpenCode (or set REVIEWER_MODEL).",
91
+ " 3. Run `ecr doctor`, then `ecr review`.",
92
+ withWorkflow
93
+ ? " 4. Add the model-key secret referenced by the workflow, then add an `ai-review` label to a PR."
94
+ : " 4. (No CI workflow written — re-run without `--no-workflow` to add it.)",
95
+ monorepo
96
+ ? ` 5. Add per-team scopes with \`ecr init --scope <dir>\` (see ${CONFIG_DIRNAME}/${ROUTING_FILENAME}).`
97
+ : ` 5. Monorepo? Run \`ecr init --monorepo\` to add a routing manifest.`,
98
+ "",
99
+ ].join("\n"));
100
+ }
101
+ /**
102
+ * Scaffold a per-team scope under <dir>: <dir>/.expo-code-review/ with a no-auth
103
+ * config, prompts and agents, then register the scope in the root routing.jsonc.
104
+ */
105
+ async function scaffoldScope(argv, scopeDirRaw) {
106
+ const force = argv.includes("--force");
107
+ const root = (await repoRoot()) ?? process.cwd();
108
+ const scopeDir = scopeDirRaw.replace(/\/+$/, "");
109
+ const routingPath = path.join(root, CONFIG_DIRNAME, ROUTING_FILENAME);
110
+ if (!existsSync(routingPath)) {
111
+ throw new Error(`no ${CONFIG_DIRNAME}/${ROUTING_FILENAME} — run \`ecr init --monorepo\` first`);
112
+ }
113
+ // Derive the scope entry and validate it BEFORE creating any files: the name must
114
+ // satisfy RoutingScopeSchema's kebab-case rule (derived by sanitizing the dir,
115
+ // apps/Foo_Bar -> apps-foo-bar), and the config path is rejected when absolute or
116
+ // containing ".." — validating first keeps a traversal dir (e.g. `--scope
117
+ // ../../outside`) from orphaning files outside the repo, and a bad name from
118
+ // making routing.jsonc unloadable and silently stopping every review.
119
+ const entry = {
120
+ name: scopeDir
121
+ .toLowerCase()
122
+ .replace(/[^a-z0-9]+/g, "-")
123
+ .replace(/^-+|-+$/g, ""),
124
+ paths: [`${scopeDir}/**`],
125
+ config: scopeDir,
126
+ };
127
+ const parsed = RoutingScopeSchema.safeParse(entry);
128
+ if (!parsed.success) {
129
+ throw new Error(`scope dir "${scopeDir}" yields an invalid scope entry (${parsed.error.issues[0]?.message}); ` +
130
+ `rename the directory or add the scope to ${CONFIG_DIRNAME}/${ROUTING_FILENAME} manually`);
131
+ }
132
+ const configDir = path.join(root, scopeDir, CONFIG_DIRNAME);
133
+ await mkdir(configDir, { recursive: true });
134
+ const created = [];
135
+ const skipped = [];
136
+ // The scope's config.jsonc is the auth-free scope template.
137
+ await copyInto(path.join(TEMPLATES_DIR, "scope-config.jsonc"), path.join(configDir, "config.jsonc"), force, created, skipped, root);
138
+ await copyInto(path.join(TEMPLATES_DIR, "shared.md"), path.join(configDir, "shared.md"), force, created, skipped, root);
139
+ await copyInto(path.join(TEMPLATES_DIR, "coordinator.md"), path.join(configDir, "coordinator.md"), force, created, skipped, root);
140
+ await copyInto(path.join(TEMPLATES_DIR, "agents"), path.join(configDir, "agents"), force, created, skipped, root);
141
+ const gitignorePath = path.join(configDir, ".gitignore");
142
+ if (force || !existsSync(gitignorePath)) {
143
+ await writeFile(gitignorePath, ".runs/\n", "utf8");
144
+ created.push(path.relative(root, gitignorePath));
145
+ }
146
+ else {
147
+ skipped.push(path.relative(root, gitignorePath));
148
+ }
149
+ // Register the scope in the root routing manifest, preserving comments/formatting.
150
+ const raw = await readFile(routingPath, "utf8");
151
+ const updated = appendScopeEntry(raw, entry);
152
+ let manifestNote;
153
+ if (updated == null) {
154
+ manifestNote = ` ! could not locate the "scopes" array in ${CONFIG_DIRNAME}/${ROUTING_FILENAME}; add this entry manually:\n ${JSON.stringify(entry)}`;
62
155
  }
156
+ else if (updated === raw) {
157
+ manifestNote = ` skipped ${CONFIG_DIRNAME}/${ROUTING_FILENAME} (scope "${entry.name}" already present)`;
158
+ }
159
+ else {
160
+ await writeFile(routingPath, updated, "utf8");
161
+ manifestNote = ` updated ${CONFIG_DIRNAME}/${ROUTING_FILENAME} (+ scope "${entry.name}")`;
162
+ }
163
+ reportFiles(created, skipped);
164
+ process.stdout.write(`${manifestNote}\n`);
165
+ process.stdout.write([
166
+ "",
167
+ "Next steps:",
168
+ ` 1. Customize ${scopeDir}/${CONFIG_DIRNAME}/agents/*.md for this team.`,
169
+ ` 2. Add to CODEOWNERS so only the team edits its scope:`,
170
+ ` /${scopeDir}/${CONFIG_DIRNAME}/ @your-team`,
171
+ " 3. Run `ecr doctor --list-scopes` to verify routing.",
172
+ "",
173
+ ].join("\n"));
174
+ }
175
+ /**
176
+ * Insert a scope entry before the closing ] of the "scopes" array in raw JSONC,
177
+ * preserving comments/formatting. Returns the new text, the original text unchanged
178
+ * when a scope of the same name is already present, or null when the array can't be
179
+ * located (caller then prints the entry for manual addition).
180
+ */
181
+ export function appendScopeEntry(routingRaw, entry) {
182
+ const keyMatch = routingRaw.search(/"scopes"\s*:/);
183
+ if (keyMatch === -1) {
184
+ return null;
185
+ }
186
+ const arrayStart = routingRaw.indexOf("[", keyMatch);
187
+ if (arrayStart === -1) {
188
+ return null;
189
+ }
190
+ // Find the matching close bracket by depth, skipping strings and // and /* */
191
+ // comments, and remember the last CONTENT character inside the array — users
192
+ // annotate routing.jsonc with comments, so the separating comma must land
193
+ // after the last entry, never inside a trailing comment.
194
+ let depth = 0;
195
+ let end = -1;
196
+ let lastContent = -1;
197
+ let i = arrayStart;
198
+ while (i < routingRaw.length) {
199
+ const char = routingRaw[i];
200
+ if (char === "/" && routingRaw[i + 1] === "/") {
201
+ const newline = routingRaw.indexOf("\n", i);
202
+ if (newline === -1) {
203
+ break;
204
+ }
205
+ i = newline;
206
+ continue;
207
+ }
208
+ if (char === "/" && routingRaw[i + 1] === "*") {
209
+ const close = routingRaw.indexOf("*/", i + 2);
210
+ if (close === -1) {
211
+ break;
212
+ }
213
+ i = close + 2;
214
+ continue;
215
+ }
216
+ if (char === '"') {
217
+ i++;
218
+ while (i < routingRaw.length && routingRaw[i] !== '"') {
219
+ if (routingRaw[i] === "\\") {
220
+ i++;
221
+ }
222
+ i++;
223
+ }
224
+ lastContent = i;
225
+ i++;
226
+ continue;
227
+ }
228
+ if (char === "[") {
229
+ depth++;
230
+ if (i > arrayStart) {
231
+ lastContent = i;
232
+ }
233
+ }
234
+ else if (char === "]") {
235
+ depth--;
236
+ if (depth === 0) {
237
+ end = i;
238
+ break;
239
+ }
240
+ lastContent = i;
241
+ }
242
+ else if (!/\s/.test(char)) {
243
+ lastContent = i;
244
+ }
245
+ i++;
246
+ }
247
+ if (end === -1) {
248
+ return null;
249
+ }
250
+ // Idempotent: don't add a duplicate name.
251
+ const inner = routingRaw.slice(arrayStart + 1, end);
252
+ if (new RegExp(`"name"\\s*:\\s*"${escapeRegExp(entry.name)}"`).test(inner)) {
253
+ return routingRaw;
254
+ }
255
+ const line = ` { "name": ${JSON.stringify(entry.name)}, "paths": ${JSON.stringify(entry.paths)}, "config": ${JSON.stringify(entry.config)} }`;
256
+ const hasEntries = lastContent > arrayStart;
257
+ const needsComma = hasEntries && routingRaw[lastContent] !== ",";
258
+ // Insert the comma immediately after the last entry's final character (before
259
+ // any trailing comment), then append the new entry line before the ']'.
260
+ const withComma = needsComma
261
+ ? `${routingRaw.slice(0, lastContent + 1)},${routingRaw.slice(lastContent + 1)}`
262
+ : routingRaw;
263
+ const endAdjusted = needsComma ? end + 1 : end;
264
+ const before = withComma.slice(0, endAdjusted).replace(/\s*$/, "");
265
+ const after = withComma.slice(endAdjusted);
266
+ return `${before}\n${line}\n ${after}`;
267
+ }
268
+ function escapeRegExp(value) {
269
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
270
+ }
271
+ function reportFiles(created, skipped) {
63
272
  for (const file of created) {
64
273
  process.stdout.write(` created ${file}\n`);
65
274
  }
66
275
  for (const file of skipped) {
67
276
  process.stdout.write(` skipped ${file} (exists; use --force to overwrite)\n`);
68
277
  }
69
- process.stdout.write([
70
- '',
71
- 'Next steps:',
72
- ` 1. Customize ${CONFIG_DIRNAME}/agents/*.md (and shared.md, coordinator.md) for this repo.`,
73
- ' 2. Configure a model provider in OpenCode (or set REVIEWER_MODEL).',
74
- ' 3. Run `ecr doctor`, then `ecr review`.',
75
- withWorkflow
76
- ? ' 4. Add the model-key secret referenced by the workflow, then add an `ai-review` label to a PR.'
77
- : ' 4. (No CI workflow written — re-run without `--no-workflow` to add it.)',
78
- '',
79
- ].join('\n'));
278
+ }
279
+ /** Parse a `--flag <value>` option; returns undefined when the flag is absent. */
280
+ function parseValue(argv, flag) {
281
+ const index = argv.indexOf(flag);
282
+ if (index === -1) {
283
+ return undefined;
284
+ }
285
+ const value = argv[index + 1];
286
+ if (!value || value.startsWith("--")) {
287
+ throw new Error(`${flag} requires a value`);
288
+ }
289
+ return value;
80
290
  }
81
291
  async function copyInto(src, dest, force, created, skipped, root) {
82
292
  const existed = existsSync(dest);