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