@expo/code-review-cli 0.7.0 → 0.8.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 +118 -13
- package/build/cli.js +7 -0
- package/build/commands/ci.js +299 -28
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +3 -0
- package/build/commands/feedback.js +433 -0
- package/build/commands/init.js +231 -15
- package/build/commands/review.js +191 -51
- package/build/commands/setup-auth.js +3 -0
- package/build/commands/verify-config.js +3 -0
- package/build/config/load.js +39 -0
- package/build/config/routing.js +7 -0
- package/build/config/schema.js +92 -0
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +5 -1
- package/build/core/claude-code.js +12 -1
- package/build/core/context-file.js +42 -0
- package/build/core/coordinator.js +2 -2
- package/build/core/diff.js +1 -0
- package/build/core/exec.js +4 -0
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +22 -0
- package/build/core/prompts.js +311 -3
- package/build/core/render.js +255 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +290 -15
- package/build/core/schema.js +213 -2
- package/build/core/scrub.js +4 -0
- package/build/core/stack-confirm.js +137 -0
- package/build/core/stack.js +25 -0
- package/build/core/step-summary.js +1 -0
- package/build/core/suppress.js +2 -0
- package/build/core/throttle.js +2 -0
- package/build/core/util.js +1 -0
- package/build/core/verify.js +5 -0
- package/build/reporters/github.js +465 -31
- package/build/reporters/terminal.js +2 -0
- package/build/sources/github-pr.js +272 -0
- package/build/sources/local-git.js +3 -0
- package/build/sources/source.js +35 -0
- package/package.json +2 -1
- package/templates/agents/consistency.md +2 -0
- package/templates/agents/correctness.md +2 -0
- package/templates/agents/security.md +3 -0
- package/templates/atlantis.yml +123 -0
- package/templates/command.yml +4 -0
- package/templates/config.jsonc +50 -1
- package/templates/coordinator.md +34 -9
- package/templates/dismiss.yml +4 -0
- package/templates/routing.jsonc +3 -0
- package/templates/scope-config.jsonc +1 -0
- package/templates/shared.md +96 -1
- package/templates/workflow.yml +5 -0
package/build/commands/init.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ref LLP 0007#init-and-dismiss — scaffolds .expo-code-review/; --scope validates before any file lands on disk
|
|
1
2
|
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import { existsSync } from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
@@ -5,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
6
|
import { CONFIG_DIRNAME } from "../config/load.js";
|
|
6
7
|
import { ROUTING_FILENAME } from "../config/routing.js";
|
|
7
8
|
import { RoutingScopeSchema } from "../config/schema.js";
|
|
9
|
+
import { FORBIDDEN_TOKEN_ENVS } from "../core/auth.js";
|
|
8
10
|
import { repoRoot } from "../core/exec.js";
|
|
9
11
|
import { errorMessage } from "../core/util.js";
|
|
10
12
|
const TEMPLATES_DIR = fileURLToPath(new URL("../../templates/", import.meta.url));
|
|
@@ -21,9 +23,18 @@ Options:
|
|
|
21
23
|
--scope <dir> Scaffold <dir>/.expo-code-review/ (no auth) + add a scope entry
|
|
22
24
|
--no-workflow Skip writing the CI workflows (review, command, and dismiss
|
|
23
25
|
under .github/workflows/)
|
|
26
|
+
--token-env <name[,name…]>
|
|
27
|
+
Env var(s) holding the model credential (default OPENAI_API_KEY,
|
|
28
|
+
e.g. CLAUDE_CODE_OAUTH_TOKEN). The scaffolded workflows forward
|
|
29
|
+
the matching repo secret(s) and expect this tokenEnv
|
|
24
30
|
--force Overwrite existing files
|
|
31
|
+
--force-workflows
|
|
32
|
+
Overwrite only the CI workflow files, keeping your customized
|
|
33
|
+
config.jsonc, prompts, and agents/. Use this to re-run
|
|
34
|
+
--token-env on an already-scaffolded repo.
|
|
25
35
|
-h, --help Show this help
|
|
26
36
|
`;
|
|
37
|
+
const DEFAULT_TOKEN_ENV = "OPENAI_API_KEY";
|
|
27
38
|
export async function initCommand(argv) {
|
|
28
39
|
if (argv.includes("-h") || argv.includes("--help")) {
|
|
29
40
|
process.stdout.write(USAGE);
|
|
@@ -32,6 +43,9 @@ export async function initCommand(argv) {
|
|
|
32
43
|
try {
|
|
33
44
|
const scopeDir = parseValue(argv, "--scope");
|
|
34
45
|
if (scopeDir != null) {
|
|
46
|
+
if (parseValue(argv, "--token-env") != null) {
|
|
47
|
+
throw new Error("--token-env applies to the root scaffold's workflows; drop it from `--scope`");
|
|
48
|
+
}
|
|
35
49
|
await scaffoldScope(argv, scopeDir);
|
|
36
50
|
}
|
|
37
51
|
else {
|
|
@@ -46,13 +60,58 @@ export async function initCommand(argv) {
|
|
|
46
60
|
/** Scaffold .expo-code-review/ (and optionally the CI workflow + routing manifest). */
|
|
47
61
|
async function scaffold(argv) {
|
|
48
62
|
const force = argv.includes("--force");
|
|
63
|
+
// Re-running --token-env on an adopted repo needs to rewrite the workflow YAML
|
|
64
|
+
// without clobbering the adopter's tuned prompts and auth config, so the
|
|
65
|
+
// workflow writes honor a narrower force that leaves everything else skipped.
|
|
66
|
+
const forceWorkflows = force || argv.includes("--force-workflows");
|
|
49
67
|
// The CI workflow is scaffolded by default (most repos adopting this want it);
|
|
50
68
|
// `--no-workflow` opts out. `--with-workflow` is still accepted as a no-op for
|
|
51
69
|
// back-compat.
|
|
52
70
|
const withWorkflow = !argv.includes("--no-workflow");
|
|
53
71
|
const monorepo = argv.includes("--monorepo");
|
|
72
|
+
// Validate before any file is written so a bad flag can't leave a half scaffold.
|
|
73
|
+
const tokenEnvs = parseTokenEnvs(parseValue(argv, "--token-env"));
|
|
74
|
+
if (!withWorkflow && parseValue(argv, "--token-env") != null) {
|
|
75
|
+
throw new Error("--token-env customizes the CI workflows; drop it or remove --no-workflow");
|
|
76
|
+
}
|
|
54
77
|
const root = (await repoRoot()) ?? process.cwd();
|
|
55
78
|
const configDir = path.join(root, CONFIG_DIRNAME);
|
|
79
|
+
// A non-default --token-env only takes effect through the review workflows,
|
|
80
|
+
// but existing workflow files are skipped (not rewritten) without --force —
|
|
81
|
+
// the flag would silently never reach CI while the next steps claim the
|
|
82
|
+
// workflow references the new secret. Refuse before any file is written.
|
|
83
|
+
// @ref LLP 0007#init-and-dismiss [implements] — validated before any file is written; no half scaffold
|
|
84
|
+
if (withWorkflow && tokenEnvs.join(",") !== DEFAULT_TOKEN_ENV && !forceWorkflows) {
|
|
85
|
+
const existing = ["expo-code-review.yml", "expo-code-review-command.yml"]
|
|
86
|
+
.map((name) => path.join(".github", "workflows", name))
|
|
87
|
+
.filter((rel) => existsSync(path.join(root, rel)));
|
|
88
|
+
if (existing.length > 0) {
|
|
89
|
+
throw new Error(`--token-env cannot take effect: ${existing.join(" and ")} already ` +
|
|
90
|
+
`exist${existing.length > 1 ? "" : "s"} and would be skipped, so CI would keep ` +
|
|
91
|
+
`forwarding the default secret. Re-run with --force-workflows to rewrite just the ` +
|
|
92
|
+
`workflows (keeps your config.jsonc, prompts, and agents/), with --force to rewrite ` +
|
|
93
|
+
`everything, or edit their ECR_EXPECTED_TOKEN_ENV fallback and forwarded secret ` +
|
|
94
|
+
`lines by hand.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// The reverse of the guard above: --force-workflows (or --force) always rewrites
|
|
98
|
+
// the review workflows from the pristine template, which forwards the default
|
|
99
|
+
// OPENAI_API_KEY unless --token-env names the credential again. An adopter who
|
|
100
|
+
// scaffolded with a non-default tokenEnv and later refreshes the workflow YAML
|
|
101
|
+
// without re-passing --token-env would silently lose the forwarded secret, and
|
|
102
|
+
// CI would keep passing the auth lock but run with an empty credential. Refuse
|
|
103
|
+
// before any file is written; naming --token-env again (or the default, to reset
|
|
104
|
+
// on purpose) is the explicit opt-in.
|
|
105
|
+
if (withWorkflow && forceWorkflows && parseValue(argv, "--token-env") == null) {
|
|
106
|
+
const baked = await detectWorkflowTokenEnv(root);
|
|
107
|
+
if (baked != null && baked !== DEFAULT_TOKEN_ENV) {
|
|
108
|
+
throw new Error(`refusing to revert the CI credential wiring: the existing review workflows forward ` +
|
|
109
|
+
`${baked}, but this run has no --token-env, so rewriting them would restore the ` +
|
|
110
|
+
`default ${DEFAULT_TOKEN_ENV} and CI would run with an empty credential. Re-run with ` +
|
|
111
|
+
`--token-env ${baked} to keep the current credential, or --token-env ${DEFAULT_TOKEN_ENV} ` +
|
|
112
|
+
`to reset to OpenAI on purpose.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
56
115
|
// Create only the config dir; let copyInto create prompts/ so it reports
|
|
57
116
|
// accurately as created vs skipped.
|
|
58
117
|
await mkdir(configDir, { recursive: true });
|
|
@@ -78,25 +137,35 @@ async function scaffold(argv) {
|
|
|
78
137
|
await mkdir(workflowDir, { recursive: true });
|
|
79
138
|
// The auto (pull_request) workflow, plus the two issue_comment command
|
|
80
139
|
// workflows: `/review` (on-demand one-shot) and `/dismiss` (hide a finding).
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
140
|
+
// The two review-running workflows get the tokenEnv substituted so the
|
|
141
|
+
// credential mapping stays STATIC in the committed YAML (the auth lock relies
|
|
142
|
+
// on that); dismiss.yml runs no model and needs no credential.
|
|
143
|
+
await copyTemplate(path.join(TEMPLATES_DIR, "workflow.yml"), path.join(workflowDir, "expo-code-review.yml"), forceWorkflows, created, skipped, root, (raw) => substituteTokenEnv(raw, tokenEnvs));
|
|
144
|
+
await copyTemplate(path.join(TEMPLATES_DIR, "command.yml"), path.join(workflowDir, "expo-code-review-command.yml"), forceWorkflows, created, skipped, root, (raw) => substituteTokenEnv(raw, tokenEnvs));
|
|
145
|
+
await copyInto(path.join(TEMPLATES_DIR, "dismiss.yml"), path.join(workflowDir, "expo-code-review-dismiss.yml"), forceWorkflows, created, skipped, root);
|
|
84
146
|
}
|
|
85
147
|
reportFiles(created, skipped);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
148
|
+
const names = tokenEnvs.map((name) => `\`${name}\``).join(" + ");
|
|
149
|
+
const steps = [
|
|
150
|
+
`Customize ${CONFIG_DIRNAME}/agents/*.md (and shared.md, coordinator.md) for this repo.`,
|
|
151
|
+
// --token-env only rewires the workflows; the scaffolded config.jsonc still
|
|
152
|
+
// declares OPENAI_API_KEY, and CI's `ecr verify-config` refuses to review
|
|
153
|
+
// until the config's tokenEnv set matches the workflow's expected set.
|
|
154
|
+
...(tokenEnvs.join(",") !== DEFAULT_TOKEN_ENV
|
|
155
|
+
? [
|
|
156
|
+
`Point ${CONFIG_DIRNAME}/config.jsonc at ${tokenEnvs.length > 1 ? "these credentials" : "this credential"}: set \`auth\` (and \`model\`) per the file's comments — CI's \`ecr verify-config\` refuses to review until the config names ${names}.`,
|
|
157
|
+
]
|
|
158
|
+
: []),
|
|
159
|
+
"Configure a model provider in OpenCode (or set REVIEWER_MODEL).",
|
|
160
|
+
"Run `ecr doctor`, then `ecr review`.",
|
|
92
161
|
withWorkflow
|
|
93
|
-
?
|
|
94
|
-
: "
|
|
162
|
+
? `Add the ${names} repo secret${tokenEnvs.length > 1 ? "s" : ""} referenced by the workflow, then add an \`ai-review\` label to a PR.`
|
|
163
|
+
: "(No CI workflow written — re-run without `--no-workflow` to add it.)",
|
|
95
164
|
monorepo
|
|
96
|
-
? `
|
|
97
|
-
:
|
|
98
|
-
|
|
99
|
-
].join("\n"));
|
|
165
|
+
? `Add per-team scopes with \`ecr init --scope <dir>\` (see ${CONFIG_DIRNAME}/${ROUTING_FILENAME}).`
|
|
166
|
+
: "Monorepo? Run `ecr init --monorepo` to add a routing manifest.",
|
|
167
|
+
];
|
|
168
|
+
process.stdout.write(["", "Next steps:", ...steps.map((step, index) => ` ${index + 1}. ${step}`), ""].join("\n"));
|
|
100
169
|
}
|
|
101
170
|
/**
|
|
102
171
|
* Scaffold a per-team scope under <dir>: <dir>/.expo-code-review/ with a no-auth
|
|
@@ -110,6 +179,7 @@ async function scaffoldScope(argv, scopeDirRaw) {
|
|
|
110
179
|
if (!existsSync(routingPath)) {
|
|
111
180
|
throw new Error(`no ${CONFIG_DIRNAME}/${ROUTING_FILENAME} — run \`ecr init --monorepo\` first`);
|
|
112
181
|
}
|
|
182
|
+
// @ref LLP 0007#init-and-dismiss [implements] — traversal and unparsable-routing.jsonc prevented before any file lands
|
|
113
183
|
// Derive the scope entry and validate it BEFORE creating any files: the name must
|
|
114
184
|
// satisfy RoutingScopeSchema's kebab-case rule (derived by sanitizing the dir,
|
|
115
185
|
// apps/Foo_Bar -> apps-foo-bar), and the config path is rejected when absolute or
|
|
@@ -172,6 +242,7 @@ async function scaffoldScope(argv, scopeDirRaw) {
|
|
|
172
242
|
"",
|
|
173
243
|
].join("\n"));
|
|
174
244
|
}
|
|
245
|
+
// @ref LLP 0007#init-and-dismiss [implements] — comment-preserving text surgery; idempotent; comma placement rules
|
|
175
246
|
/**
|
|
176
247
|
* Insert a scope entry before the closing ] of the "scopes" array in raw JSONC,
|
|
177
248
|
* preserving comments/formatting. Returns the new text, the original text unchanged
|
|
@@ -293,3 +364,148 @@ async function copyInto(src, dest, force, created, skipped, root) {
|
|
|
293
364
|
await cp(src, dest, { recursive: true, force, errorOnExist: false });
|
|
294
365
|
(existed && !force ? skipped : created).push(path.relative(root, dest));
|
|
295
366
|
}
|
|
367
|
+
/** Like copyInto for a single file, but pipes the content through `transform`. */
|
|
368
|
+
async function copyTemplate(src, dest, force, created, skipped, root, transform) {
|
|
369
|
+
if (existsSync(dest) && !force) {
|
|
370
|
+
skipped.push(path.relative(root, dest));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
await writeFile(dest, transform(await readFile(src, "utf8")), "utf8");
|
|
374
|
+
created.push(path.relative(root, dest));
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Parse + validate `--token-env`: a comma-separated list of env var names holding
|
|
378
|
+
* the model credential(s). Refuses names the runtime would refuse anyway
|
|
379
|
+
* (FORBIDDEN_TOKEN_ENVS) so a bad choice fails here, not at review time.
|
|
380
|
+
*/
|
|
381
|
+
// @ref LLP 0007#init-and-dismiss [implements] — validated before any file is written; no half scaffold
|
|
382
|
+
export function parseTokenEnvs(value) {
|
|
383
|
+
if (value == null) {
|
|
384
|
+
return [DEFAULT_TOKEN_ENV];
|
|
385
|
+
}
|
|
386
|
+
const names = value.split(",").map((name) => name.trim());
|
|
387
|
+
if (names.some((name) => !/^[A-Z][A-Z0-9_]*$/.test(name))) {
|
|
388
|
+
throw new Error(`--token-env must be UPPER_SNAKE_CASE env var name(s), got "${value}"`);
|
|
389
|
+
}
|
|
390
|
+
for (const name of names) {
|
|
391
|
+
if (FORBIDDEN_TOKEN_ENVS.has(name)) {
|
|
392
|
+
throw new Error(`--token-env ${name} is a well-known unrelated secret; the reviewer refuses it`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (new Set(names).size !== names.length) {
|
|
396
|
+
throw new Error(`--token-env has duplicate names: "${value}"`);
|
|
397
|
+
}
|
|
398
|
+
return names;
|
|
399
|
+
}
|
|
400
|
+
// Secrets that a review workflow forwards for reasons other than the model
|
|
401
|
+
// credential, so a non-default name here is not a baked credential to preserve.
|
|
402
|
+
const NON_MODEL_FORWARDED_SECRETS = new Set(["GH_TOKEN", "GITHUB_TOKEN"]);
|
|
403
|
+
/**
|
|
404
|
+
* Detect the non-default model credential an existing review workflow bakes in,
|
|
405
|
+
* so a --force-workflows run can refuse to silently revert it to the default
|
|
406
|
+
* instead of rewriting from the pristine (OpenAI) template. Reads two
|
|
407
|
+
* independent signals and returns whichever is non-default:
|
|
408
|
+
* - the `ECR_EXPECTED_TOKEN_ENV` fallback (`vars.ECR_EXPECTED_TOKEN_ENV || '<name>'`),
|
|
409
|
+
* the joined list init bakes for the auth lock; and
|
|
410
|
+
* - the forwarded credential lines (`<NAME>: ${{ secrets.<NAME> }}`), which are
|
|
411
|
+
* what actually exposes the secret to the job. A hand edit — or a repo-variable
|
|
412
|
+
* lock (`vars.ECR_EXPECTED_TOKEN_ENV`) that leaves the YAML fallback at the
|
|
413
|
+
* default — can change the forwarded line alone, so reading only the fallback
|
|
414
|
+
* would miss the baked credential and revert it.
|
|
415
|
+
* Returns the credential value to re-pass via --token-env, or null when the
|
|
416
|
+
* workflows forward only the default (or none exist).
|
|
417
|
+
*/
|
|
418
|
+
async function detectWorkflowTokenEnv(root) {
|
|
419
|
+
for (const name of ["expo-code-review.yml", "expo-code-review-command.yml"]) {
|
|
420
|
+
const file = path.join(root, ".github", "workflows", name);
|
|
421
|
+
if (!existsSync(file)) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const raw = await readFile(file, "utf8");
|
|
425
|
+
const fallback = raw.match(/vars\.ECR_EXPECTED_TOKEN_ENV \|\| '([^']*)'/);
|
|
426
|
+
if (fallback && fallback[1] !== DEFAULT_TOKEN_ENV) {
|
|
427
|
+
return fallback[1];
|
|
428
|
+
}
|
|
429
|
+
const forwarded = forwardedModelCredentials(raw);
|
|
430
|
+
if (forwarded.length > 0) {
|
|
431
|
+
return forwarded.join(",");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Slice the `Run AI review` step (its `env:` maps the model credential) out of a
|
|
438
|
+
* workflow file, so credential detection reads only the block the scaffold owns
|
|
439
|
+
* and not secrets a hand edit forwards to unrelated steps (a deploy step's AWS
|
|
440
|
+
* keys, an acknowledge step's GH_TOKEN, …). Steps sit at 6-space `- ` indent, so
|
|
441
|
+
* the step runs until the next such line or EOF. Returns "" when the step is
|
|
442
|
+
* absent (drifted or removed template).
|
|
443
|
+
*/
|
|
444
|
+
function runAiReviewStep(raw) {
|
|
445
|
+
const start = raw.indexOf("- name: Run AI review");
|
|
446
|
+
if (start === -1) {
|
|
447
|
+
return "";
|
|
448
|
+
}
|
|
449
|
+
const rest = raw.slice(start);
|
|
450
|
+
const nextStep = rest.search(/\n {6}- /);
|
|
451
|
+
return nextStep === -1 ? rest : rest.slice(0, nextStep);
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Names of the non-default model credential a workflow's `Run AI review` step
|
|
455
|
+
* forwards, read from its `<NAME>: ${{ secrets.<...> }}` env lines. Skips the
|
|
456
|
+
* default, the known non-model secrets (GH_TOKEN), and any FORBIDDEN_TOKEN_ENVS
|
|
457
|
+
* name — the runtime refuses those as a model credential, so surfacing one as a
|
|
458
|
+
* baked credential would produce a remediation (`--token-env <name>`) that either
|
|
459
|
+
* cannot pass parseTokenEnvs or would wire an unrelated secret to the provider.
|
|
460
|
+
* Scanning is scoped to the credential block the scaffold owns so a secret a hand
|
|
461
|
+
* edit forwards to another step is never mistaken for the model credential.
|
|
462
|
+
*/
|
|
463
|
+
function forwardedModelCredentials(raw) {
|
|
464
|
+
const names = [];
|
|
465
|
+
const re = /^\s*([A-Z][A-Z0-9_]*):\s*\$\{\{\s*secrets\.[A-Z][A-Z0-9_]*\s*\}\}/gm;
|
|
466
|
+
for (const match of runAiReviewStep(raw).matchAll(re)) {
|
|
467
|
+
const name = match[1];
|
|
468
|
+
if (name === DEFAULT_TOKEN_ENV ||
|
|
469
|
+
NON_MODEL_FORWARDED_SECRETS.has(name) ||
|
|
470
|
+
FORBIDDEN_TOKEN_ENVS.has(name)) {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (!names.includes(name)) {
|
|
474
|
+
names.push(name);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return names;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Rewrite a scaffolded review workflow for a non-default tokenEnv: the
|
|
481
|
+
* ECR_EXPECTED_TOKEN_ENV fallback and the forwarded credential secret(s). GitHub
|
|
482
|
+
* Actions never exposes a secret the YAML doesn't map explicitly, so without this
|
|
483
|
+
* a Claude/Codex setup would pass the auth lock but run with an empty credential.
|
|
484
|
+
* Throws when a template marker is missing (template drift must fail loudly, not
|
|
485
|
+
* scaffold a workflow that silently keeps the OpenAI-only wiring).
|
|
486
|
+
*/
|
|
487
|
+
// @ref LLP 0009#what-ecr-init-scaffolds [implements] — static credential mapping baked at init from a trusted flag, never resolved at run time
|
|
488
|
+
export function substituteTokenEnv(raw, tokenEnvs) {
|
|
489
|
+
const joined = tokenEnvs.join(",");
|
|
490
|
+
if (joined === DEFAULT_TOKEN_ENV) {
|
|
491
|
+
return raw;
|
|
492
|
+
}
|
|
493
|
+
const expectedFallback = `vars.ECR_EXPECTED_TOKEN_ENV || '${DEFAULT_TOKEN_ENV}'`;
|
|
494
|
+
const credentialBlock = [
|
|
495
|
+
" # OpenAI API key — the env var named by auth.tokenEnv in config.jsonc.",
|
|
496
|
+
" # Store it as a repo secret; a project-scoped key restricted to model",
|
|
497
|
+
" # inference (with a spend limit) is all the reviewer needs.",
|
|
498
|
+
" OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}",
|
|
499
|
+
].join("\n");
|
|
500
|
+
if (!raw.includes(expectedFallback) || !raw.includes(credentialBlock)) {
|
|
501
|
+
throw new Error("workflow template drifted: tokenEnv markers not found (report this bug)");
|
|
502
|
+
}
|
|
503
|
+
const replacement = [
|
|
504
|
+
` # Model credential${tokenEnvs.length > 1 ? "s" : ""} — the env var${tokenEnvs.length > 1 ? "s" : ""} named by auth.tokenEnv in config.jsonc.`,
|
|
505
|
+
" # Store each as a repo secret under the same name.",
|
|
506
|
+
...tokenEnvs.map((name) => ` ${name}: \${{ secrets.${name} }}`),
|
|
507
|
+
].join("\n");
|
|
508
|
+
return raw
|
|
509
|
+
.replaceAll(expectedFallback, `vars.ECR_EXPECTED_TOKEN_ENV || '${joined}'`)
|
|
510
|
+
.replace(credentialBlock, replacement);
|
|
511
|
+
}
|
package/build/commands/review.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules — local runs: the person at the terminal is the trust principal, even with --pr
|
|
1
2
|
import { loadReviewConfig, loadScopeConfig } from "../config/load.js";
|
|
2
3
|
import { loadRoutingManifest, resolveScopes, scopedCommentTag } from "../config/routing.js";
|
|
3
4
|
import { repoRoot, resolveRepo } from "../core/exec.js";
|
|
4
5
|
import { errorMessage } from "../core/util.js";
|
|
6
|
+
import { readContextFile } from "../core/context-file.js";
|
|
7
|
+
import { feedbackNeedsRunSeam } from "../core/adjudicate.js";
|
|
5
8
|
import { runReview } from "../core/review.js";
|
|
6
9
|
import { LocalGitSource } from "../sources/local-git.js";
|
|
7
10
|
import { GitHubPRSource } from "../sources/github-pr.js";
|
|
8
|
-
import { memoizeSource } from "../sources/source.js";
|
|
11
|
+
import { memoizeSource, stackConfirmFromConfig, stackWalkFromConfig } from "../sources/source.js";
|
|
9
12
|
import { TerminalReporter } from "../reporters/terminal.js";
|
|
10
13
|
import { GitHubReporter } from "../reporters/github.js";
|
|
11
14
|
const USAGE = `ecr review — AI code review, printed to your terminal
|
|
@@ -34,6 +37,11 @@ Options:
|
|
|
34
37
|
runs its config over just that scope's changed files
|
|
35
38
|
--config-dir <dir> load config from <dir> instead of .expo-code-review/
|
|
36
39
|
(also ECR_CONFIG_DIR); can't combine with --scope
|
|
40
|
+
--context-file <p> inject <p>'s UTF-8 text into reviewer prompts as an
|
|
41
|
+
explicitly UNTRUSTED external-context block
|
|
42
|
+
--stack-aware with --pr: walk the open PRs stacked on top and let the
|
|
43
|
+
coordinator requalify absence-style findings a later PR
|
|
44
|
+
already addresses (off by default; rejected without --pr)
|
|
37
45
|
--json emit machine-readable JSON on stdout
|
|
38
46
|
--no-fail always exit 0, even on request-changes
|
|
39
47
|
-h, --help show this help
|
|
@@ -57,6 +65,7 @@ function parseArgs(argv) {
|
|
|
57
65
|
staged: false,
|
|
58
66
|
post: false,
|
|
59
67
|
route: false,
|
|
68
|
+
stackAware: false,
|
|
60
69
|
json: false,
|
|
61
70
|
noFail: false,
|
|
62
71
|
help: false,
|
|
@@ -103,6 +112,12 @@ function parseArgs(argv) {
|
|
|
103
112
|
case "--config-dir":
|
|
104
113
|
args.configDir = requireValue(arg, argv[++i]);
|
|
105
114
|
break;
|
|
115
|
+
case "--context-file":
|
|
116
|
+
args.contextFile = requireValue(arg, argv[++i]);
|
|
117
|
+
break;
|
|
118
|
+
case "--stack-aware":
|
|
119
|
+
args.stackAware = true;
|
|
120
|
+
break;
|
|
106
121
|
case "--json":
|
|
107
122
|
args.json = true;
|
|
108
123
|
break;
|
|
@@ -147,6 +162,29 @@ export async function reviewCommand(argv) {
|
|
|
147
162
|
if (root && root !== process.cwd()) {
|
|
148
163
|
process.chdir(root);
|
|
149
164
|
}
|
|
165
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules [implements] — --context-file is orthogonal enrichment; local read errors fail loud (exit 2)
|
|
166
|
+
// Read the context file ONCE, in the command layer (routed review calls runReview
|
|
167
|
+
// per scope; reading inside would re-read the file N times). Local runs FAIL LOUD
|
|
168
|
+
// on a read error: the user typed the path, so a typo or oversized file is a
|
|
169
|
+
// mistake to surface, not to silently skip (unlike `ecr ci`, which warns and
|
|
170
|
+
// continues to keep the never-fail-checks invariant).
|
|
171
|
+
let contextText;
|
|
172
|
+
if (args.contextFile) {
|
|
173
|
+
try {
|
|
174
|
+
contextText = await readContextFile(args.contextFile);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
process.stderr.write(`--context-file: ${errorMessage(error)}\n`);
|
|
178
|
+
process.exitCode = 2;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Empty/whitespace-only file: warn (a typo'd or unwritten path) but do not fail
|
|
182
|
+
// — there is simply no context to add.
|
|
183
|
+
if (!contextText.trim()) {
|
|
184
|
+
process.stderr.write(`--context-file: ${args.contextFile} is empty; no context added.\n`);
|
|
185
|
+
contextText = undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
150
188
|
try {
|
|
151
189
|
const cwd = process.cwd();
|
|
152
190
|
const makeSource = () => args.pr != null
|
|
@@ -176,46 +214,74 @@ export async function reviewCommand(argv) {
|
|
|
176
214
|
process.stdout.write(`No changed files in scope ${args.scope}.\n`);
|
|
177
215
|
return;
|
|
178
216
|
}
|
|
217
|
+
// Build the PR reporter up front when posting, so adjudicate mode can judge the
|
|
218
|
+
// replies against the source before the result is rendered (see the non-scope
|
|
219
|
+
// path). A scope always posts under the DERIVED marker `<rootTag>:<scope>` —
|
|
220
|
+
// from the ROOT config's tag exactly like `ecr ci` does (runRoutedCi prefers
|
|
221
|
+
// rootConfig.commentTag over manifest defaults when they diverge) — so a
|
|
222
|
+
// standalone scope post and CI's per-scope post/clear/reconcile paths always
|
|
223
|
+
// target the same marker, and the bare aggregate marker is never used here.
|
|
224
|
+
// (Per-scope commentTag overrides are rejected by the scope schema for exactly
|
|
225
|
+
// this reason.)
|
|
226
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules [constrained-by] — must match ci.ts's derivation; the scope schema ban on commentTag is what keeps them aligned
|
|
227
|
+
const { repo: postRepo, error: postRepoError } = await resolvePostRepo(args, cwd);
|
|
228
|
+
const reporter = postRepo != null && args.pr != null
|
|
229
|
+
? new GitHubReporter({
|
|
230
|
+
prNumber: args.pr,
|
|
231
|
+
repo: postRepo,
|
|
232
|
+
commentTag: scopedCommentTag(rootConfig.commentTag, args.scope),
|
|
233
|
+
breakGlassMarker: config.breakGlassMarker,
|
|
234
|
+
cwd,
|
|
235
|
+
// Root-only feedback config (loadScopeConfig inherits it from the root).
|
|
236
|
+
feedback: config.feedback,
|
|
237
|
+
headSha: await reviewedHeadSha(source),
|
|
238
|
+
})
|
|
239
|
+
: null;
|
|
179
240
|
const review = await runReview(source, {
|
|
180
241
|
config,
|
|
181
242
|
mode: "local",
|
|
182
243
|
agents: args.agents,
|
|
183
244
|
route: args.route,
|
|
184
245
|
includePaths: files,
|
|
246
|
+
contextText,
|
|
247
|
+
// Explicit --stack-aware only: local config is not a trusted base, so the
|
|
248
|
+
// user typing the flag is the trust principal. Bounds come from the root
|
|
249
|
+
// config; validateArgs already rejected --stack-aware without --pr, and the
|
|
250
|
+
// pr guard here keeps that invariant local.
|
|
251
|
+
stack: args.stackAware && args.pr != null ? stackWalkFromConfig(rootConfig.stack) : undefined,
|
|
252
|
+
stackConfirm: args.stackAware && args.pr != null
|
|
253
|
+
? stackConfirmFromConfig(rootConfig.stack)
|
|
254
|
+
: undefined,
|
|
255
|
+
feedback: reporter && feedbackNeedsRunSeam(config.feedback)
|
|
256
|
+
? { config: config.feedback, match: (r) => reporter.matchAdjudicationItems(r) }
|
|
257
|
+
: undefined,
|
|
185
258
|
onProgress: (message) => process.stderr.write(`${message}\n`),
|
|
186
259
|
});
|
|
187
260
|
await new TerminalReporter({ json: args.json, noFail: args.noFail }).report(review);
|
|
188
|
-
if (args.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// Respect the author's break-glass opt-out, same as the non-scope path.
|
|
206
|
-
let breakGlass = false;
|
|
207
|
-
try {
|
|
208
|
-
breakGlass = await reporter.checkBreakGlass();
|
|
209
|
-
}
|
|
210
|
-
catch {
|
|
211
|
-
breakGlass = false;
|
|
212
|
-
}
|
|
213
|
-
if (breakGlass) {
|
|
214
|
-
process.stderr.write(`\nNot posting: ${config.breakGlassMarker} is set on ${repo}#${args.pr} (break-glass).\n`);
|
|
261
|
+
if (args.pr != null && args.post) {
|
|
262
|
+
if (reporter) {
|
|
263
|
+
// Respect the author's break-glass opt-out, same as the non-scope path.
|
|
264
|
+
let breakGlass = false;
|
|
265
|
+
try {
|
|
266
|
+
breakGlass = await reporter.checkBreakGlass();
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
breakGlass = false;
|
|
270
|
+
}
|
|
271
|
+
if (breakGlass) {
|
|
272
|
+
process.stderr.write(`\nNot posting: ${config.breakGlassMarker} is set on ${postRepo}#${args.pr} (break-glass).\n`);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
await reporter.report(review, review.feedback);
|
|
276
|
+
process.stderr.write(`\nPosted scope "${args.scope}" review to ${postRepo}#${args.pr}.\n`);
|
|
277
|
+
}
|
|
215
278
|
}
|
|
216
279
|
else {
|
|
217
|
-
|
|
218
|
-
|
|
280
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules [implements] — a
|
|
281
|
+
// `gh` failure resolving the repo must not hide the review already
|
|
282
|
+
// printed above; only the post step fails here, with a clear message.
|
|
283
|
+
process.stderr.write(`\nNot posted: could not resolve the repo for --post (${errorMessage(postRepoError)}). Pass --repo owner/repo.\n`);
|
|
284
|
+
process.exitCode = 2;
|
|
219
285
|
}
|
|
220
286
|
}
|
|
221
287
|
}
|
|
@@ -226,39 +292,65 @@ export async function reviewCommand(argv) {
|
|
|
226
292
|
}
|
|
227
293
|
const config = await loadReviewConfig(cwd, { configDir: args.configDir });
|
|
228
294
|
const source = makeSource();
|
|
295
|
+
// Build the PR reporter up front when posting, so adjudicate mode can judge the
|
|
296
|
+
// PR's replies against the source before the result is rendered. Feedback only
|
|
297
|
+
// has replies to match when reviewing a PR and posting (the terminal preview never
|
|
298
|
+
// renders annotations), so it is wired only on the --post --pr path.
|
|
299
|
+
const { repo: postRepo, error: postRepoError } = await resolvePostRepo(args, cwd);
|
|
300
|
+
const reporter = postRepo != null && args.pr != null
|
|
301
|
+
? new GitHubReporter({
|
|
302
|
+
prNumber: args.pr,
|
|
303
|
+
repo: postRepo,
|
|
304
|
+
commentTag: config.commentTag,
|
|
305
|
+
breakGlassMarker: config.breakGlassMarker,
|
|
306
|
+
cwd,
|
|
307
|
+
feedback: config.feedback,
|
|
308
|
+
headSha: await reviewedHeadSha(source),
|
|
309
|
+
})
|
|
310
|
+
: null;
|
|
229
311
|
const review = await runReview(source, {
|
|
230
312
|
config,
|
|
231
313
|
mode: "local",
|
|
232
314
|
agents: args.agents,
|
|
233
315
|
route: args.route,
|
|
316
|
+
contextText,
|
|
317
|
+
// Explicit --stack-aware only (see the scope branch); validateArgs already
|
|
318
|
+
// rejected --stack-aware without --pr.
|
|
319
|
+
stack: args.stackAware && args.pr != null ? stackWalkFromConfig(config.stack) : undefined,
|
|
320
|
+
stackConfirm: args.stackAware && args.pr != null ? stackConfirmFromConfig(config.stack) : undefined,
|
|
321
|
+
feedback: reporter && feedbackNeedsRunSeam(config.feedback)
|
|
322
|
+
? { config: config.feedback, match: (r) => reporter.matchAdjudicationItems(r) }
|
|
323
|
+
: undefined,
|
|
234
324
|
onProgress: (message) => process.stderr.write(`${message}\n`),
|
|
235
325
|
});
|
|
236
326
|
// Always print the result here first.
|
|
237
327
|
await new TerminalReporter({ json: args.json, noFail: args.noFail }).report(review);
|
|
238
328
|
// Then, only if asked, publish the same result to the PR.
|
|
239
|
-
if (args.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (breakGlass) {
|
|
257
|
-
process.stderr.write(`\nNot posting: ${config.breakGlassMarker} is set on ${repo}#${args.pr} (break-glass).\n`);
|
|
329
|
+
if (args.pr != null && args.post) {
|
|
330
|
+
if (reporter) {
|
|
331
|
+
// Respect the author's break-glass opt-out, same as the CI path.
|
|
332
|
+
let breakGlass = false;
|
|
333
|
+
try {
|
|
334
|
+
breakGlass = await reporter.checkBreakGlass();
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
breakGlass = false;
|
|
338
|
+
}
|
|
339
|
+
if (breakGlass) {
|
|
340
|
+
process.stderr.write(`\nNot posting: ${config.breakGlassMarker} is set on ${postRepo}#${args.pr} (break-glass).\n`);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
await reporter.report(review, review.feedback);
|
|
344
|
+
process.stderr.write(`\nPosted review to ${postRepo}#${args.pr}.\n`);
|
|
345
|
+
}
|
|
258
346
|
}
|
|
259
347
|
else {
|
|
260
|
-
|
|
261
|
-
|
|
348
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules [implements] — a `gh`
|
|
349
|
+
// failure resolving the repo (no auth, no network, no GitHub remote, rate
|
|
350
|
+
// limit) must not hide the review already printed above; only the post
|
|
351
|
+
// step fails here, with a clear message.
|
|
352
|
+
process.stderr.write(`\nNot posted: could not resolve the repo for --post (${errorMessage(postRepoError)}). Pass --repo owner/repo.\n`);
|
|
353
|
+
process.exitCode = 2;
|
|
262
354
|
}
|
|
263
355
|
}
|
|
264
356
|
}
|
|
@@ -267,6 +359,49 @@ export async function reviewCommand(argv) {
|
|
|
267
359
|
process.exitCode = 2;
|
|
268
360
|
}
|
|
269
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* The head commit the review reads, when the source can pin one (a GitHub PR). The
|
|
364
|
+
* reporter binds every adjudication verdict to it, so a stored verdict carries to a
|
|
365
|
+
* later run only while the reviewed source is unchanged (see mergeFeedback). Never
|
|
366
|
+
* throws: an unresolvable head reads as unknown source, which re-judges the reply
|
|
367
|
+
* instead of trusting a verdict about code we cannot pin.
|
|
368
|
+
*/
|
|
369
|
+
async function reviewedHeadSha(source) {
|
|
370
|
+
try {
|
|
371
|
+
return (await source.getMetadata()).headOid;
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules [implements] — local runs
|
|
378
|
+
// still print the review even when --post's repo can't be resolved
|
|
379
|
+
/**
|
|
380
|
+
* Resolve the repo needed for --post --pr, without ever throwing: a `gh` failure
|
|
381
|
+
* (no auth, no network, no GitHub remote, rate limit) must degrade to "skip the
|
|
382
|
+
* post step", not abort the review itself — the local run already trusts the
|
|
383
|
+
* caller and should still show them the review. Callers treat a returned `error`
|
|
384
|
+
* as "no repo, and here's why" once they reach the post step; the review and its
|
|
385
|
+
* optional feedback seam simply run without a reporter until then.
|
|
386
|
+
*
|
|
387
|
+
* `resolve` is injectable (defaults to the real `resolveRepo`) purely so tests can
|
|
388
|
+
* exercise the failure path deterministically, without a `gh` binary or network.
|
|
389
|
+
*/
|
|
390
|
+
export async function resolvePostRepo(args, cwd, resolve = resolveRepo) {
|
|
391
|
+
if (!(args.post && args.pr != null)) {
|
|
392
|
+
return {};
|
|
393
|
+
}
|
|
394
|
+
if (args.repo) {
|
|
395
|
+
return { repo: args.repo };
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
return { repo: await resolve(cwd) };
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
return { error };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules [implements] — mutually exclusive flags rejected outright, never silently ignored
|
|
270
405
|
/** Reject flag combinations that don't make sense together. */
|
|
271
406
|
function validateArgs(args) {
|
|
272
407
|
if (args.pr != null && (args.base || args.head || args.staged)) {
|
|
@@ -275,6 +410,11 @@ function validateArgs(args) {
|
|
|
275
410
|
if (args.pr == null && (args.repo || args.post)) {
|
|
276
411
|
throw new Error("--repo/--post only apply together with --pr.");
|
|
277
412
|
}
|
|
413
|
+
// Same rule as --repo/--post: the stack walk needs a PR to walk from, so a bare
|
|
414
|
+
// --stack-aware would be silently ignored — reject it instead.
|
|
415
|
+
if (args.pr == null && args.stackAware) {
|
|
416
|
+
throw new Error("--stack-aware only applies together with --pr.");
|
|
417
|
+
}
|
|
278
418
|
// --staged diffs the index against HEAD, so --base/--head have no effect. Reject
|
|
279
419
|
// the combination rather than silently ignoring the range the user asked for.
|
|
280
420
|
if (args.staged && (args.base || args.head)) {
|