@expo/code-review-cli 0.3.0 → 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 +406 -43
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +173 -26
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +118 -30
- package/build/commands/verify-config.js +214 -0
- package/build/config/load.js +154 -52
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +116 -12
- package/build/core/auth.js +32 -29
- 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 +44 -44
- package/build/core/prompts.js +157 -148
- package/build/core/render.js +202 -48
- package/build/core/review.js +147 -85
- 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/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 +50 -20
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,54 +32,109 @@ 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");
|
|
55
90
|
return;
|
|
56
91
|
}
|
|
57
|
-
|
|
92
|
+
// A malformed manifest is a loud, non-blocking error (never a silent fallback).
|
|
93
|
+
let manifest;
|
|
58
94
|
try {
|
|
59
|
-
|
|
95
|
+
manifest = await loadRoutingManifest(cwd, { configDir });
|
|
60
96
|
}
|
|
61
97
|
catch (error) {
|
|
62
|
-
process.stderr.write(`CI reviewer: ${errorMessage(error)}\n`);
|
|
98
|
+
process.stderr.write(`CI reviewer: invalid routing.jsonc: ${errorMessage(error)}\n`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (manifest == null) {
|
|
102
|
+
await runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate, configDir);
|
|
63
103
|
return;
|
|
64
104
|
}
|
|
65
|
-
// Config-driven trigger policy (.expo-code-review/config.jsonc → review): decide
|
|
66
|
-
// whether this PR should be reviewed at all. Fetch current labels via gh (more
|
|
67
|
-
// authoritative than the possibly-stale event payload); on failure, default to
|
|
68
|
-
// reviewing so a label-read hiccup never silently skips a PR.
|
|
69
|
-
let labels = [];
|
|
70
105
|
try {
|
|
71
|
-
|
|
72
|
-
labels = stdout.split('\n').map(name => name.trim()).filter(Boolean);
|
|
106
|
+
await runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir);
|
|
73
107
|
}
|
|
74
108
|
catch (error) {
|
|
75
|
-
|
|
109
|
+
// Fan-out failures stay non-blocking (single-writer property is the point).
|
|
110
|
+
process.stderr.write(`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`);
|
|
76
111
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The pre-routing single-config path. Kept byte-for-byte equivalent so that with no
|
|
115
|
+
* routing.jsonc the CLI behaves exactly as before (backcompat invariant).
|
|
116
|
+
*/
|
|
117
|
+
async function runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate, configDir) {
|
|
118
|
+
let config;
|
|
119
|
+
try {
|
|
120
|
+
config = await loadReviewConfig(cwd, { configDir });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
process.stderr.write(`CI reviewer: ${errorMessage(error)}\n`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Layer-1 auth lock (mirrors doctor): when the workflow pins the expected token
|
|
127
|
+
// env var name, refuse to run if the config names anything else. The workflow's
|
|
128
|
+
// bash guard is a text sweep (layer 2) and can't see through JSON escapes; this
|
|
129
|
+
// check compares the tokenEnv the loader actually honors.
|
|
130
|
+
const expectedTokenEnv = process.env.ECR_EXPECTED_TOKEN_ENV;
|
|
131
|
+
if (expectedTokenEnv && 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)) {
|
|
80
138
|
return;
|
|
81
139
|
}
|
|
82
140
|
const reporter = new GitHubReporter({
|
|
@@ -84,7 +142,7 @@ export async function ciCommand(argv = []) {
|
|
|
84
142
|
repo,
|
|
85
143
|
commentTag: config.commentTag,
|
|
86
144
|
breakGlassMarker: config.breakGlassMarker,
|
|
87
|
-
cwd
|
|
145
|
+
cwd,
|
|
88
146
|
});
|
|
89
147
|
try {
|
|
90
148
|
if (await reporter.checkBreakGlass()) {
|
|
@@ -97,12 +155,12 @@ export async function ciCommand(argv = []) {
|
|
|
97
155
|
process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
|
|
98
156
|
}
|
|
99
157
|
try {
|
|
100
|
-
const review = await runReview(new GitHubPRSource({ prNumber, repo, cwd
|
|
158
|
+
const review = await runReview(new GitHubPRSource({ prNumber, repo, cwd }), {
|
|
101
159
|
config,
|
|
102
|
-
mode:
|
|
160
|
+
mode: "ci",
|
|
103
161
|
agents,
|
|
104
162
|
route,
|
|
105
|
-
onProgress: message => process.stderr.write(`${message}\n`),
|
|
163
|
+
onProgress: (message) => process.stderr.write(`${message}\n`),
|
|
106
164
|
});
|
|
107
165
|
await reporter.report(review);
|
|
108
166
|
process.stderr.write(`CI reviewer: posted review (${review.decision}).\n`);
|
|
@@ -116,7 +174,7 @@ export async function ciCommand(argv = []) {
|
|
|
116
174
|
process.stderr.write(`CI reviewer: run failed (non-blocking): ${reason}\n`);
|
|
117
175
|
try {
|
|
118
176
|
await reporter.report({
|
|
119
|
-
decision:
|
|
177
|
+
decision: "approve_with_comments",
|
|
120
178
|
findings: [],
|
|
121
179
|
summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${reason}`,
|
|
122
180
|
incomplete: [],
|
|
@@ -127,6 +185,246 @@ export async function ciCommand(argv = []) {
|
|
|
127
185
|
}
|
|
128
186
|
}
|
|
129
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
|
+
}
|
|
130
428
|
/**
|
|
131
429
|
* Decide whether a PR should be reviewed, given its labels and the repo's trigger
|
|
132
430
|
* policy. `skipLabel` always wins (write-gated opt-out). In "label" mode a PR must
|
|
@@ -138,29 +436,94 @@ export function shouldReview(labels, review) {
|
|
|
138
436
|
if (labels.includes(review.skipLabel)) {
|
|
139
437
|
return { review: false, reason: `the ${review.skipLabel} label is set` };
|
|
140
438
|
}
|
|
141
|
-
if (review.trigger ===
|
|
142
|
-
const optedIn = labels.some(name => name === review.label || name.startsWith(`${review.label}:`));
|
|
439
|
+
if (review.trigger === "label") {
|
|
440
|
+
const optedIn = labels.some((name) => name === review.label || name.startsWith(`${review.label}:`));
|
|
143
441
|
return optedIn
|
|
144
442
|
? { review: true, reason: `the ${review.label} label is set` }
|
|
145
443
|
: { review: false, reason: `trigger is "label" and no ${review.label} label is set` };
|
|
146
444
|
}
|
|
147
445
|
return { review: true, reason: 'trigger is "all"' };
|
|
148
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
|
+
}
|
|
149
477
|
/** Parse `--agents a,b,c` from argv (undefined = all agents). */
|
|
150
478
|
function parseAgents(argv) {
|
|
151
|
-
|
|
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);
|
|
152
484
|
if (index === -1) {
|
|
153
485
|
return undefined;
|
|
154
486
|
}
|
|
155
487
|
const value = argv[index + 1];
|
|
156
488
|
// A missing value, or the next token being another flag (e.g. `--agents --route`),
|
|
157
|
-
// means no
|
|
158
|
-
// as
|
|
159
|
-
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("--")) {
|
|
160
492
|
return undefined;
|
|
161
493
|
}
|
|
162
494
|
return value
|
|
163
|
-
.split(
|
|
164
|
-
.map(id => id.trim())
|
|
495
|
+
.split(",")
|
|
496
|
+
.map((id) => id.trim())
|
|
165
497
|
.filter(Boolean);
|
|
166
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) {
|