@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.
- package/README.md +307 -47
- package/build/cli.js +24 -17
- package/build/commands/ci.js +410 -43
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +219 -26
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +118 -30
- package/build/commands/verify-config.js +252 -0
- package/build/config/load.js +200 -55
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +153 -19
- package/build/core/auth.js +237 -75
- package/build/core/coordinator.js +7 -7
- package/build/core/diff.js +19 -19
- package/build/core/exec.js +10 -10
- package/build/core/log.js +3 -3
- package/build/core/noise.js +52 -52
- package/build/core/opencode.js +495 -95
- package/build/core/prompts.js +220 -150
- package/build/core/render.js +202 -48
- package/build/core/review.js +277 -102
- package/build/core/router.js +10 -10
- package/build/core/schema.js +26 -12
- package/build/core/step-summary.js +18 -0
- package/build/core/suppress.js +7 -7
- package/build/core/tools.js +9 -9
- package/build/core/util.js +2 -2
- package/build/core/verify.js +28 -26
- package/build/reporters/github.js +103 -51
- package/build/reporters/terminal.js +19 -19
- package/build/sources/github-pr.js +21 -21
- package/build/sources/local-git.js +20 -20
- package/build/sources/source.js +35 -1
- package/package.json +8 -3
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +167 -0
- package/templates/config.jsonc +26 -13
- package/templates/coordinator.md +5 -3
- package/templates/dismiss.yml +110 -0
- package/templates/routing.jsonc +27 -0
- package/templates/scope-config.jsonc +25 -0
- package/templates/shared.md +12 -0
- package/templates/workflow.yml +61 -26
package/build/commands/doctor.js
CHANGED
|
@@ -1,74 +1,267 @@
|
|
|
1
|
-
import { loadReviewConfig, hasConfig } from
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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(
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
?
|
|
31
|
-
:
|
|
32
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
104
|
+
line(true, "gh CLI found and authenticated (used by `ecr ci`)");
|
|
49
105
|
}
|
|
50
106
|
else {
|
|
51
|
-
info(
|
|
107
|
+
info("gh CLI found but not authenticated — run `gh auth login` before `ecr ci`");
|
|
52
108
|
}
|
|
53
109
|
}
|
|
54
110
|
else {
|
|
55
|
-
info(
|
|
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 ${
|
|
115
|
+
line(false, `no ${".expo-code-review"}/config.jsonc (run \`ecr init\`)`);
|
|
59
116
|
}
|
|
60
117
|
else {
|
|
61
118
|
try {
|
|
62
|
-
|
|
63
|
-
line(true, `config valid: ${
|
|
64
|
-
line(
|
|
65
|
-
const readiness = checkProviderAuth(
|
|
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
|
-
|
|
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
|
}
|
package/build/commands/init.js
CHANGED
|
@@ -1,41 +1,56 @@
|
|
|
1
|
-
import { cp, mkdir, writeFile } from
|
|
2
|
-
import { existsSync } from
|
|
3
|
-
import path from
|
|
4
|
-
import { fileURLToPath } from
|
|
5
|
-
import { CONFIG_DIRNAME } from
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
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
|
-
--
|
|
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(
|
|
28
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
21
29
|
process.stdout.write(USAGE);
|
|
22
30
|
return;
|
|
23
31
|
}
|
|
24
32
|
try {
|
|
25
|
-
|
|
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
|
|
46
|
+
/** Scaffold .expo-code-review/ (and optionally the CI workflow + routing manifest). */
|
|
33
47
|
async function scaffold(argv) {
|
|
34
|
-
const force = argv.includes(
|
|
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(
|
|
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,
|
|
47
|
-
await copyInto(path.join(TEMPLATES_DIR,
|
|
48
|
-
await copyInto(path.join(TEMPLATES_DIR,
|
|
49
|
-
await copyInto(path.join(TEMPLATES_DIR,
|
|
50
|
-
const gitignorePath = path.join(configDir,
|
|
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,
|
|
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,
|
|
77
|
+
const workflowDir = path.join(root, ".github", "workflows");
|
|
60
78
|
await mkdir(workflowDir, { recursive: true });
|
|
61
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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);
|