@expo/code-review-cli 0.6.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 +151 -25
- package/build/cli.js +7 -0
- package/build/commands/ci.js +307 -36
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +170 -33
- 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 +86 -11
- 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 +99 -3
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +127 -10
- package/build/core/claude-code.js +691 -0
- 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 +282 -9
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +117 -15
- package/build/core/prompts.js +330 -5
- package/build/core/render.js +274 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +447 -39
- package/build/core/schema.js +219 -3
- package/build/core/scrub.js +63 -1
- 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 +12 -0
- package/build/core/util.js +18 -0
- package/build/core/verify.js +18 -1
- package/build/reporters/github.js +544 -44
- package/build/reporters/terminal.js +2 -0
- package/build/sources/github-pr.js +286 -7
- package/build/sources/local-git.js +6 -2
- package/build/sources/source.js +35 -0
- package/package.json +4 -3
- 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 +71 -4
- 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 +124 -1
- package/templates/workflow.yml +5 -0
package/build/commands/ci.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
// @ref LLP 0007#ecr-ci-the-trusted-root-run — fails CLOSED: trusted-root materialization failure never falls back to reading the checkout
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { CONFIG_DIRNAME, hasScopeConfig, loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
|
|
4
5
|
import { loadRoutingManifest, resolveScopes, scopedCommentTag, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
|
|
5
|
-
import { repoRoot, run } from "../core/exec.js";
|
|
6
|
-
import { errorMessage } from "../core/util.js";
|
|
6
|
+
import { repoRoot, resolveTrustedTool, run } from "../core/exec.js";
|
|
7
|
+
import { errorMessage, publicFailureReason } from "../core/util.js";
|
|
8
|
+
import { readContextFile } from "../core/context-file.js";
|
|
7
9
|
import { buildDiffLineIndex } from "../core/render.js";
|
|
10
|
+
import { applyPins, collectPins, scopedFingerprint } from "../core/schema.js";
|
|
11
|
+
import { dropStaleVerdict, feedbackApplied, feedbackNeedsRunSeam } from "../core/adjudicate.js";
|
|
8
12
|
import { runReview } from "../core/review.js";
|
|
9
13
|
import { GitHubPRSource } from "../sources/github-pr.js";
|
|
10
|
-
import { memoizeSource } from "../sources/source.js";
|
|
14
|
+
import { memoizeSource, stackConfirmFromConfig, stackWalkFromConfig } from "../sources/source.js";
|
|
11
15
|
import { GitHubReporter } from "../reporters/github.js";
|
|
12
16
|
/** Resolve the PR number from the Actions event payload or GITHUB_REF. */
|
|
13
17
|
async function resolvePrNumber() {
|
|
@@ -63,7 +67,11 @@ Options:
|
|
|
63
67
|
prompts, model, auth mapping) that evaluates itself.
|
|
64
68
|
Never scaffolded; prints a security warning; will be
|
|
65
69
|
removed on a scheduled minor boundary.
|
|
70
|
+
--context-file <p> Inject <p>'s UTF-8 text into reviewer prompts as UNTRUSTED
|
|
71
|
+
external context (missing/oversized file: warn, continue)
|
|
66
72
|
--comment <mode> Override manifest comment mode: single | per-scope
|
|
73
|
+
--no-stack-aware Force stack-aware requalification off for this run (it is
|
|
74
|
+
otherwise auto-enabled from the trusted-base stack.enabled)
|
|
67
75
|
--force Manual override: review even if the trigger policy (label
|
|
68
76
|
trigger / ai-review:skip) would skip. Break-glass and the
|
|
69
77
|
auth lock still apply. A /review comment command implies this.
|
|
@@ -82,12 +90,18 @@ export async function ciCommand(argv = []) {
|
|
|
82
90
|
const route = argv.includes("--route");
|
|
83
91
|
const scopesFilter = parseListFlag(argv, "--scopes");
|
|
84
92
|
const commentOverride = parseCommentMode(argv);
|
|
93
|
+
// Stack-aware review auto-enables from the trusted-base config; this argv escape
|
|
94
|
+
// hatch forces it off for one run. Safe to expose: it only ever makes the review
|
|
95
|
+
// more conservative (findings stay blocking).
|
|
96
|
+
const noStackAware = argv.includes("--no-stack-aware");
|
|
85
97
|
// The ROOT config dir escape hatch (mirrors `ecr review`): an explicit
|
|
86
98
|
// --config-dir wins, else resolveConfigDir falls back to ECR_CONFIG_DIR, else
|
|
87
99
|
// the default .expo-code-review/. Applies to config.jsonc AND routing.jsonc.
|
|
88
100
|
let configDir;
|
|
101
|
+
let contextFile;
|
|
89
102
|
try {
|
|
90
103
|
configDir = parseValueFlag(argv, "--config-dir");
|
|
104
|
+
contextFile = parseValueFlag(argv, "--context-file");
|
|
91
105
|
}
|
|
92
106
|
catch (error) {
|
|
93
107
|
process.stderr.write(`${errorMessage(error)}\n\n${CI_USAGE}`);
|
|
@@ -103,6 +117,25 @@ export async function ciCommand(argv = []) {
|
|
|
103
117
|
process.chdir(root);
|
|
104
118
|
}
|
|
105
119
|
const cwd = process.cwd();
|
|
120
|
+
// @ref LLP 0007#ecr-ci-the-trusted-root-run [implements] — --context-file degrades to no-context on read error; never fails checks
|
|
121
|
+
// Read the context file ONCE (routed CI runs runReview per scope; reading inside
|
|
122
|
+
// would re-read it N times). CI WARNS and continues on any read error: a broken
|
|
123
|
+
// Atlantis-provided plan file must never turn the PR's check red.
|
|
124
|
+
let contextText;
|
|
125
|
+
if (contextFile) {
|
|
126
|
+
try {
|
|
127
|
+
contextText = await readContextFile(contextFile);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
process.stderr.write(`CI reviewer: --context-file unusable, continuing without it: ${errorMessage(error)}\n`);
|
|
131
|
+
}
|
|
132
|
+
// Empty/whitespace-only plan file (e.g. Atlantis wrote nothing): warn and
|
|
133
|
+
// continue with no context — never fail the check on it.
|
|
134
|
+
if (contextText != null && !contextText.trim()) {
|
|
135
|
+
process.stderr.write(`CI reviewer: --context-file ${contextFile} is empty; continuing without context.\n`);
|
|
136
|
+
contextText = undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
106
139
|
const repo = process.env.GITHUB_REPOSITORY;
|
|
107
140
|
const prNumber = await resolvePrNumber();
|
|
108
141
|
if (!repo || prNumber == null) {
|
|
@@ -113,6 +146,7 @@ export async function ciCommand(argv = []) {
|
|
|
113
146
|
// the PR-head read root are each fetched once and shared across scopes.
|
|
114
147
|
const ghSource = new GitHubPRSource({ prNumber, repo, cwd });
|
|
115
148
|
const source = memoizeSource(ghSource);
|
|
149
|
+
// @ref LLP 0007#ecr-ci-the-trusted-root-run [implements] — trusted base config; fail closed with one hardcoded-tag terminal comment
|
|
116
150
|
// Trusted configuration root: review policy and reviewer config load from the
|
|
117
151
|
// PR's immutable BASE commit, so the PR head is data, never policy. Fail CLOSED:
|
|
118
152
|
// when the base can't be materialized, post the one terminal comment and stop —
|
|
@@ -134,7 +168,7 @@ export async function ciCommand(argv = []) {
|
|
|
134
168
|
const reason = errorMessage(error);
|
|
135
169
|
process.stderr.write(`CI reviewer: could not materialize the PR's base commit for trusted configuration ` +
|
|
136
170
|
`(failing closed, not reviewing): ${reason}\n`);
|
|
137
|
-
await postTerminalFailureNote(repo, prNumber, cwd, `it could not load trusted configuration from the PR's base commit (${
|
|
171
|
+
await postTerminalFailureNote(repo, prNumber, cwd, `it could not load trusted configuration from the PR's base commit (${publicFailureReason(error)}). ` +
|
|
138
172
|
`This usually means the runner has no git checkout or no usable GH_TOKEN; re-run once fixed`);
|
|
139
173
|
return;
|
|
140
174
|
}
|
|
@@ -155,6 +189,8 @@ export async function ciCommand(argv = []) {
|
|
|
155
189
|
route,
|
|
156
190
|
bypassTriggerGate,
|
|
157
191
|
configDir,
|
|
192
|
+
contextText,
|
|
193
|
+
noStackAware,
|
|
158
194
|
});
|
|
159
195
|
return;
|
|
160
196
|
}
|
|
@@ -166,6 +202,8 @@ export async function ciCommand(argv = []) {
|
|
|
166
202
|
commentOverride,
|
|
167
203
|
bypassTriggerGate,
|
|
168
204
|
configDir,
|
|
205
|
+
contextText,
|
|
206
|
+
noStackAware,
|
|
169
207
|
});
|
|
170
208
|
}
|
|
171
209
|
catch (error) {
|
|
@@ -206,6 +244,144 @@ async function postTerminalFailureNote(repo, prNumber, cwd, reason) {
|
|
|
206
244
|
process.stderr.write(`CI reviewer: also failed to post the failure notice: ${errorMessage(postError)}\n`);
|
|
207
245
|
}
|
|
208
246
|
}
|
|
247
|
+
// @ref LLP 0011#the-rebuttal-is-a-hypothesis [constrained-by] — only "adjudicate" mode runs the model; the reporter reads the same comment it posts to, and `fpOf` keys records the same way that comment stores them, so a prior verdict carries and the same words are not re-judged
|
|
248
|
+
/**
|
|
249
|
+
* The runReview feedback seam for a single-comment reporter. Wired when the mode
|
|
250
|
+
* judges replies ("adjudicate") OR when `dismiss` is opted in — a maintainer reply
|
|
251
|
+
* clearing a finding under `mode: "annotate"` needs the seam to compute `applied`,
|
|
252
|
+
* with no model call (see feedbackNeedsRunSeam). Otherwise the reporter matches
|
|
253
|
+
* replies itself at report time and the seam is absent (undefined). `fpOf` maps a
|
|
254
|
+
* finding to the fingerprint the target comment stores its feedback under
|
|
255
|
+
* (scope-namespaced for the aggregate comment, plain otherwise); omit it for the
|
|
256
|
+
* plain default.
|
|
257
|
+
*/
|
|
258
|
+
function adjudicationSeam(config, reporter, fpOf) {
|
|
259
|
+
if (!feedbackNeedsRunSeam(config.feedback)) {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
config: config.feedback,
|
|
264
|
+
match: (review) => reporter.matchAdjudicationItems(review, fpOf),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
// @ref LLP 0011#hard-floors-in-code [constrained-by] — one reporter per comment, so the
|
|
268
|
+
// PR-author lookup and the paginated comment list are fetched once per scope, not twice
|
|
269
|
+
/**
|
|
270
|
+
* Memoize one value per scope name. Used for the per-scope GitHubReporter: a scope's
|
|
271
|
+
* feedback seam reads the very comment that scope's report writes, and each reporter
|
|
272
|
+
* instance holds its own comment-list TTL cache and bot-login memo, so a second instance
|
|
273
|
+
* for the same comment tag re-fetches the identical paginated comment list and re-resolves
|
|
274
|
+
* the login. Exported so that sharing is unit-testable.
|
|
275
|
+
*/
|
|
276
|
+
export function memoizeByScope(build) {
|
|
277
|
+
const cache = new Map();
|
|
278
|
+
return (scope) => {
|
|
279
|
+
let value = cache.get(scope);
|
|
280
|
+
if (value === undefined) {
|
|
281
|
+
value = build(scope);
|
|
282
|
+
cache.set(scope, value);
|
|
283
|
+
}
|
|
284
|
+
return value;
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* A freshly-reviewed scope's adjudicated feedback records, already keyed under the
|
|
289
|
+
* scope-namespaced ids the aggregate comment renders (the single-mode seam builds them
|
|
290
|
+
* with a scoped `fpOf`, see runRoutedCi), so they merge into the aggregate view as-is.
|
|
291
|
+
* A carried-over prior scope (no fresh feedback) contributes nothing here.
|
|
292
|
+
*/
|
|
293
|
+
function scopeFeedbackRecords(result) {
|
|
294
|
+
return result.review.feedback ?? [];
|
|
295
|
+
}
|
|
296
|
+
// @ref LLP 0011#the-rebuttal-is-a-hypothesis [constrained-by] — matchAdjudicationItems
|
|
297
|
+
// throws on a seam fetch error BY DESIGN so the legacy/per-scope paths fall back to
|
|
298
|
+
// computeFeedback's stored-state preservation; runReview leaves `review.feedback`
|
|
299
|
+
// undefined in exactly that case (never `[]` — a successful-but-empty seam sets `[]`,
|
|
300
|
+
// which is truthy, see scopeFeedbackRecords). This merge honors the same rule for the
|
|
301
|
+
// aggregate comment: a scope's fresh records are authoritative for its findings'
|
|
302
|
+
// fingerprints ONLY when its own seam actually returned records this run.
|
|
303
|
+
// @ref LLP 0011#suppression-is-never-silent [implements] — the aggregate path applies
|
|
304
|
+
// the SAME dropStaleVerdict rule as the reporter's mergeFeedback, so a verdict can never
|
|
305
|
+
// clear a finding against a head it never judged just because this path was the one used.
|
|
306
|
+
// @ref LLP 0011#the-rebuttal-is-a-hypothesis — fresh records come ONLY from `results`,
|
|
307
|
+
// never `finalResults`: a carried scope's `finalResults` entry can still embed a stale
|
|
308
|
+
// per-scope `review.feedback` copy from a past full run, which would resurrect a record
|
|
309
|
+
// a human /undismiss already overrode at the top level.
|
|
310
|
+
/**
|
|
311
|
+
* The aggregate comment's feedback records for comment:'single' mode, merging:
|
|
312
|
+
* - fresh records for scopes whose seam succeeded this run (`review.feedback` is an
|
|
313
|
+
* array, possibly empty) — authoritative for their findings' fingerprints, so a
|
|
314
|
+
* prior record with no fresh counterpart there means the reply is gone
|
|
315
|
+
* (deleted/edited) and is dropped;
|
|
316
|
+
* - prior records for every OTHER fingerprint: carried-over scopes (a `--scopes`
|
|
317
|
+
* partial run) AND a re-reviewed scope whose seam itself failed (`review.feedback`
|
|
318
|
+
* stayed undefined) — so a transient GitHub fetch error can never delete a scope's
|
|
319
|
+
* prior reply attributions/verdicts with nothing to replace them.
|
|
320
|
+
*
|
|
321
|
+
* Every record taken from `prior` first goes through `dropStaleVerdict` against
|
|
322
|
+
* `headSha` — the SAME rule mergeFeedback applies on the reporter side. Without it this
|
|
323
|
+
* path would re-apply a verdict decided against a head the run no longer reviews: a
|
|
324
|
+
* scope whose seam threw keeps its prior record, `feedbackApplied` never looks at
|
|
325
|
+
* `sourceSha`, and reportAggregate skips computeFeedback when it gets explicit records,
|
|
326
|
+
* so nothing else would ever check. Fresh records are NOT re-checked — they were just
|
|
327
|
+
* stamped with this run's own source.
|
|
328
|
+
*
|
|
329
|
+
* `applied` is recomputed on every kept record (idempotent for fresh ones) so a
|
|
330
|
+
* carried/fallback record also honors a `dismiss` policy that changed since it was
|
|
331
|
+
* stored. `results` is this run's freshly-reviewed scopes — the SOLE source of fresh
|
|
332
|
+
* records AND the set whose fingerprints are fresh-authoritative; a carried scope's
|
|
333
|
+
* prior records already ride the `prior` top-level map, so reading records back out of
|
|
334
|
+
* `finalResults` would re-inject a stale per-scope copy (e.g. one a human /undismiss
|
|
335
|
+
* already overrode at the top level). `finalResults` is the full set the aggregate
|
|
336
|
+
* comment renders (includes scopes carried over via `mergePartialAggregate`), used only
|
|
337
|
+
* to resolve each kept record's current finding. Pure so it's unit-testable.
|
|
338
|
+
*/
|
|
339
|
+
export function mergeAggregateFeedback(results, finalResults, prior, feedbackConfig, headSha, pins) {
|
|
340
|
+
const scopedFpOf = (result, finding) => scopedFingerprint(result.isDefault ? null : result.scope, finding);
|
|
341
|
+
const seamOkByScope = new Map(results.map((result) => [
|
|
342
|
+
result.scope,
|
|
343
|
+
result.review.feedback !== undefined,
|
|
344
|
+
]));
|
|
345
|
+
const freshFps = new Set(results
|
|
346
|
+
.filter((result) => seamOkByScope.get(result.scope))
|
|
347
|
+
.flatMap((result) => result.review.findings.map((f) => scopedFpOf(result, f))));
|
|
348
|
+
const byFp = new Map(prior
|
|
349
|
+
.filter((record) => !freshFps.has(record.fp))
|
|
350
|
+
// A verdict decided against a head this run no longer reviews is dropped here,
|
|
351
|
+
// exactly as mergeFeedback drops it: the reply is judged again instead of
|
|
352
|
+
// clearing the finding against source it never saw.
|
|
353
|
+
.map((record) => [record.fp, dropStaleVerdict(record, headSha)]));
|
|
354
|
+
// Fresh records come only from THIS run's reviewed scopes; carried scopes'
|
|
355
|
+
// records already sit in `prior` above (a re-inject from `finalResults` would
|
|
356
|
+
// resurrect a stale per-scope copy over a newer top-level record).
|
|
357
|
+
for (const record of results.flatMap(scopeFeedbackRecords)) {
|
|
358
|
+
byFp.set(record.fp, record);
|
|
359
|
+
}
|
|
360
|
+
const findingByFp = new Map(finalResults.flatMap((result) => result.review.findings.map((f) => [scopedFpOf(result, f), f])));
|
|
361
|
+
// The maintainer `/undismiss` pins are state about FINDINGS, so they apply to fresh
|
|
362
|
+
// and carried records alike — a scope whose seam matched no reply this run must not
|
|
363
|
+
// hand back a record that a pin no longer covers.
|
|
364
|
+
const { records } = applyPins([...byFp.values()], collectPins(pins, prior));
|
|
365
|
+
return records.map((record) => {
|
|
366
|
+
const finding = findingByFp.get(record.fp);
|
|
367
|
+
return finding
|
|
368
|
+
? { ...record, applied: feedbackApplied(finding, record, feedbackConfig) }
|
|
369
|
+
: record;
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
// @ref LLP 0010#config-and-cli-surface [implements] — under ci, stack-aware is gated by the trusted-base config; a stack walk failure only ever warns (source fails open), never a check failure
|
|
373
|
+
/** Resolve the walk bounds when stack-aware is on (trusted-base enabled AND not
|
|
374
|
+
* forced off), else undefined (feature off → the manifest fetch is skipped). */
|
|
375
|
+
function resolveStackWalk(stack, noStackAware) {
|
|
376
|
+
return stack.enabled && !noStackAware ? stackWalkFromConfig(stack) : undefined;
|
|
377
|
+
}
|
|
378
|
+
// @ref LLP 0010#patch-level-confirmation-v2 [constrained-by] — v2 rides the same trusted-base gate as the walk, plus stack.confirmWithPatch (default false, so it ships dark)
|
|
379
|
+
/** Resolve the v2 patch-confirmation cap when the walk is on AND confirmWithPatch is
|
|
380
|
+
* set in the trusted-base config, else undefined (v2 off → grounding is the floor). */
|
|
381
|
+
function resolveStackConfirm(stack, noStackAware) {
|
|
382
|
+
return stack.enabled && !noStackAware ? stackConfirmFromConfig(stack) : undefined;
|
|
383
|
+
}
|
|
384
|
+
// @ref LLP 0007#ecr-ci-the-trusted-root-run [constrained-by] — run logs anchor at the workspace, never the removed-on-exit trusted root
|
|
209
385
|
/**
|
|
210
386
|
* Run-log + patch-workspace anchor: ALWAYS the workspace checkout, never the
|
|
211
387
|
* (temporary, removed-on-exit) trusted config root — the workflow uploads
|
|
@@ -219,7 +395,7 @@ function workspaceRunsDir(cwd) {
|
|
|
219
395
|
* routing.jsonc the CLI behaves exactly as before (backcompat invariant).
|
|
220
396
|
*/
|
|
221
397
|
async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
|
|
222
|
-
const { agents, route, bypassTriggerGate, configDir } = options;
|
|
398
|
+
const { agents, route, bypassTriggerGate, configDir, contextText, noStackAware } = options;
|
|
223
399
|
let config;
|
|
224
400
|
try {
|
|
225
401
|
config = await loadReviewConfig(configRoot, { configDir });
|
|
@@ -228,6 +404,7 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
|
|
|
228
404
|
process.stderr.write(`CI reviewer: ${errorMessage(error)}\n`);
|
|
229
405
|
return;
|
|
230
406
|
}
|
|
407
|
+
// @ref LLP 0007#verify-config-the-config-guard [implements] — runtime auth lock; the workflow bash sweep is only layer 2
|
|
231
408
|
// Layer-1 auth lock (mirrors doctor): when the workflow pins the expected token
|
|
232
409
|
// env var name, refuse to run if the config names anything else. The workflow's
|
|
233
410
|
// bash guard is a text sweep (layer 2) and can't see through JSON escapes; this
|
|
@@ -240,17 +417,32 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
|
|
|
240
417
|
return;
|
|
241
418
|
}
|
|
242
419
|
}
|
|
420
|
+
// @ref LLP 0007#trigger-policy-and-break-glass [implements] — exact-match labels; /review bypasses only the trigger gate
|
|
243
421
|
// Config-driven trigger policy (.expo-code-review/config.jsonc → review): decide
|
|
244
422
|
// whether this PR should be reviewed at all (bypassed by a manual /review).
|
|
245
423
|
if (!passesTriggerGate(await fetchPrLabels(repo, prNumber, cwd), config.review, bypassTriggerGate)) {
|
|
246
424
|
return;
|
|
247
425
|
}
|
|
426
|
+
// The head commit this run reviews: it binds every adjudication verdict to the source
|
|
427
|
+
// it judged, so a stored verdict carries to the next run only while the head is
|
|
428
|
+
// unchanged (see mergeFeedback). Memoized on the source — no extra API call. A
|
|
429
|
+
// metadata failure leaves it unknown, which re-judges replies instead of trusting a
|
|
430
|
+
// verdict against source we cannot pin.
|
|
431
|
+
let headSha;
|
|
432
|
+
try {
|
|
433
|
+
headSha = (await source.getMetadata()).headOid;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// leave headSha unset → stored verdicts do not carry (fail safe, costs budget only)
|
|
437
|
+
}
|
|
248
438
|
const reporter = new GitHubReporter({
|
|
249
439
|
prNumber,
|
|
250
440
|
repo,
|
|
251
441
|
commentTag: config.commentTag,
|
|
252
442
|
breakGlassMarker: config.breakGlassMarker,
|
|
253
443
|
cwd,
|
|
444
|
+
feedback: config.feedback,
|
|
445
|
+
headSha,
|
|
254
446
|
});
|
|
255
447
|
try {
|
|
256
448
|
if (await reporter.checkBreakGlass()) {
|
|
@@ -268,10 +460,18 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
|
|
|
268
460
|
mode: "ci",
|
|
269
461
|
agents,
|
|
270
462
|
route,
|
|
463
|
+
contextText,
|
|
464
|
+
stack: resolveStackWalk(config.stack, noStackAware),
|
|
465
|
+
stackConfirm: resolveStackConfirm(config.stack, noStackAware),
|
|
271
466
|
runsDir: workspaceRunsDir(cwd),
|
|
467
|
+
// Adjudicate mode judges the matched replies against the source before the
|
|
468
|
+
// comment is rendered; annotate mode lets the reporter match them at report
|
|
469
|
+
// time. Either way the feedback path is fail-open (runReview swallows its own
|
|
470
|
+
// errors), so it never fails the PR's checks.
|
|
471
|
+
feedback: adjudicationSeam(config, reporter),
|
|
272
472
|
onProgress: (message) => process.stderr.write(`${message}\n`),
|
|
273
473
|
});
|
|
274
|
-
await reporter.report(review);
|
|
474
|
+
await reporter.report(review, review.feedback);
|
|
275
475
|
process.stderr.write(`CI reviewer: posted review (${review.decision}).\n`);
|
|
276
476
|
}
|
|
277
477
|
catch (error) {
|
|
@@ -285,7 +485,7 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
|
|
|
285
485
|
await reporter.report({
|
|
286
486
|
decision: "approve_with_comments",
|
|
287
487
|
findings: [],
|
|
288
|
-
summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${
|
|
488
|
+
summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${publicFailureReason(error)}`,
|
|
289
489
|
incomplete: [],
|
|
290
490
|
});
|
|
291
491
|
}
|
|
@@ -303,9 +503,10 @@ function failureReview(scopeName, reason) {
|
|
|
303
503
|
incomplete: [],
|
|
304
504
|
};
|
|
305
505
|
}
|
|
506
|
+
// @ref LLP 0007#routed-ci-fan-out [implements] — root tag wins; sequential per-scope budgets; partial --scopes merges prior state
|
|
306
507
|
/** The routing fan-out: one process, N scopes reviewed sequentially, one render. */
|
|
307
508
|
async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, options) {
|
|
308
|
-
const { agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir } = options;
|
|
509
|
+
const { agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir, contextText, noStackAware, } = options;
|
|
309
510
|
// The root config + manifest follow the override; scope configs stay
|
|
310
511
|
// relative to the TRUSTED root (loadScopeConfig reads
|
|
311
512
|
// <configRoot>/<scope.config>/.expo-code-review).
|
|
@@ -397,11 +598,15 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
397
598
|
prNumber,
|
|
398
599
|
diffLines: buildDiffLineIndex(changed.map((file) => ({ path: file.path, patch: file.patch }))),
|
|
399
600
|
};
|
|
601
|
+
// The reviewed head OID also binds each adjudication verdict to the source it judged
|
|
602
|
+
// (see mergeFeedback); unresolved leaves it unknown, so verdicts are re-judged.
|
|
603
|
+
let headSha;
|
|
400
604
|
try {
|
|
401
|
-
const { baseOid } = await source.getMetadata();
|
|
605
|
+
const { baseOid, headOid } = await source.getMetadata();
|
|
402
606
|
if (baseOid) {
|
|
403
607
|
link.baseSha = baseOid;
|
|
404
608
|
}
|
|
609
|
+
headSha = headOid;
|
|
405
610
|
}
|
|
406
611
|
catch {
|
|
407
612
|
// leave baseSha unset → out-of-diff findings degrade to plain text
|
|
@@ -417,6 +622,35 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
417
622
|
const expectedMinutes = Math.round((active.length * budget) / 60_000);
|
|
418
623
|
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`);
|
|
419
624
|
}
|
|
625
|
+
// One PR has one stack: resolve the walk once from the ROOT (trusted-base) config
|
|
626
|
+
// and share it across every scope. The source memoizes the actual fetch.
|
|
627
|
+
const stackWalk = resolveStackWalk(rootConfig.stack, noStackAware);
|
|
628
|
+
const stackConfirm = resolveStackConfirm(rootConfig.stack, noStackAware);
|
|
629
|
+
const reporterFor = (tag, withLink = false) => new GitHubReporter({
|
|
630
|
+
prNumber,
|
|
631
|
+
repo,
|
|
632
|
+
commentTag: tag,
|
|
633
|
+
breakGlassMarker: rootConfig.breakGlassMarker,
|
|
634
|
+
cwd,
|
|
635
|
+
linkContext: withLink ? link : undefined,
|
|
636
|
+
// Root-only feedback config (see loadScopeConfig): lets a reporter posting with
|
|
637
|
+
// no explicit records match replies itself (annotate mode) at report time.
|
|
638
|
+
feedback: rootConfig.feedback,
|
|
639
|
+
headSha,
|
|
640
|
+
});
|
|
641
|
+
// comment:'single' mode: every active scope's feedback seam AND the final
|
|
642
|
+
// aggregate post target the SAME root-tag comment, so share one reporter
|
|
643
|
+
// instance (and its comment-list/login cache, see GitHubReporter's
|
|
644
|
+
// `fetchAllComments` TTL cache) across the whole run — a fresh reporter per
|
|
645
|
+
// scope would otherwise re-fetch the paginated comment list and re-resolve
|
|
646
|
+
// the bot login once per scope for the identical comment.
|
|
647
|
+
const singleModeReporter = mode === "single" ? reporterFor(rootTag, true) : undefined;
|
|
648
|
+
// Per-scope mode has the same property, one comment tag at a time: a scope's feedback
|
|
649
|
+
// seam reads the very comment that scope's review is posted to, so both must use the
|
|
650
|
+
// same instance or each scope re-fetches its comment list and re-resolves the bot
|
|
651
|
+
// login. `withLink` is inert for the seam (matchAdjudicationItems renders nothing), so
|
|
652
|
+
// one link-carrying reporter serves both uses.
|
|
653
|
+
const scopeReporter = memoizeByScope((name) => reporterFor(scopedCommentTag(rootTag, name), true));
|
|
420
654
|
const results = [];
|
|
421
655
|
for (const scope of active) {
|
|
422
656
|
const scopeDef = manifest.scopes.find((entry) => entry.name === scope.name);
|
|
@@ -435,52 +669,67 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
435
669
|
effectiveScopeDef = { ...scopeDef, config: "." };
|
|
436
670
|
}
|
|
437
671
|
const config = await loadScopeConfig(configRoot, effectiveScopeDef, manifest, rootConfig);
|
|
672
|
+
// The seam reads the replies off the comment this scope's review will land
|
|
673
|
+
// in — the aggregate (root) comment in "single" mode, the scoped comment
|
|
674
|
+
// otherwise — so prior verdicts carry. The fingerprint the records are keyed
|
|
675
|
+
// under must match how THAT comment stores them: scope-namespaced for the
|
|
676
|
+
// aggregate comment, plain for the scoped one. Feedback is root-only, so gate on it.
|
|
677
|
+
const feedbackSeam = feedbackNeedsRunSeam(rootConfig.feedback)
|
|
678
|
+
? adjudicationSeam(rootConfig, mode === "single" ? singleModeReporter : scopeReporter(scope.name), mode === "single"
|
|
679
|
+
? (finding) => scopedFingerprint(isDefault ? null : scope.name, finding)
|
|
680
|
+
: undefined)
|
|
681
|
+
: undefined;
|
|
438
682
|
review = await runReview(source, {
|
|
439
683
|
config,
|
|
440
684
|
mode: "ci",
|
|
441
685
|
agents,
|
|
442
686
|
route,
|
|
443
687
|
includePaths: scope.files,
|
|
688
|
+
contextText,
|
|
689
|
+
stack: stackWalk,
|
|
690
|
+
stackConfirm,
|
|
444
691
|
passesBudgetMs: budget,
|
|
445
692
|
runsDir: workspaceRunsDir(cwd),
|
|
693
|
+
feedback: feedbackSeam,
|
|
446
694
|
onProgress: (message) => process.stderr.write(`[${scope.name}] ${message}\n`),
|
|
447
695
|
});
|
|
448
696
|
}
|
|
449
697
|
catch (error) {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
review = failureReview(scope.name, reason);
|
|
698
|
+
process.stderr.write(`CI reviewer: [${scope.name}] failed (non-blocking): ${errorMessage(error)}\n`);
|
|
699
|
+
review = failureReview(scope.name, publicFailureReason(error));
|
|
453
700
|
}
|
|
454
701
|
results.push({ scope: scope.name, isDefault, review });
|
|
455
702
|
}
|
|
456
|
-
const reporterFor = (tag, withLink = false) => new GitHubReporter({
|
|
457
|
-
prNumber,
|
|
458
|
-
repo,
|
|
459
|
-
commentTag: tag,
|
|
460
|
-
breakGlassMarker: rootConfig.breakGlassMarker,
|
|
461
|
-
cwd,
|
|
462
|
-
linkContext: withLink ? link : undefined,
|
|
463
|
-
});
|
|
464
703
|
if (mode === "single") {
|
|
465
|
-
const aggregate =
|
|
704
|
+
const aggregate = singleModeReporter;
|
|
466
705
|
let finalResults = results;
|
|
467
706
|
if (scopesFilter) {
|
|
468
707
|
// A partial run (--scopes) is authoritative ONLY for the named scopes: merge
|
|
469
708
|
// the other scopes' previous results out of the existing aggregate comment's
|
|
470
709
|
// state so re-running one scope doesn't silently discard the rest.
|
|
471
710
|
const prior = (await aggregate.readState())?.scopes ?? [];
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
711
|
+
finalResults = mergePartialAggregate(results, prior, scopesFilter, manifest.scopes.map((scope) => scope.name));
|
|
712
|
+
}
|
|
713
|
+
if (finalResults.length === 0) {
|
|
714
|
+
// No scope has anything to report (no changed file matched any scope, and no
|
|
715
|
+
// prior scope results carry over): a review of nothing is not a review, so
|
|
716
|
+
// post nothing and delete a stale aggregate comment from an earlier run
|
|
717
|
+
// instead of leaving it up. The unmatched-files warning stays in the job log.
|
|
718
|
+
// @ref LLP 0007#routed-ci-fan-out — zero active scopes → no comment
|
|
719
|
+
await aggregate.clear();
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
// A seam-backed run hands the reporter the computed records (re-keyed to the
|
|
723
|
+
// scope-namespaced ids the aggregate renders under). Without the seam it
|
|
724
|
+
// passes none and the reporter matches replies itself.
|
|
725
|
+
const aggState = feedbackNeedsRunSeam(rootConfig.feedback)
|
|
726
|
+
? await aggregate.readState()
|
|
727
|
+
: null;
|
|
728
|
+
const aggFeedback = feedbackNeedsRunSeam(rootConfig.feedback)
|
|
729
|
+
? mergeAggregateFeedback(results, finalResults, aggState?.feedback ?? [], rootConfig.feedback, headSha, aggState?.pins)
|
|
730
|
+
: undefined;
|
|
731
|
+
await aggregate.reportAggregate(finalResults, resolution.unmatched, aggFeedback);
|
|
482
732
|
}
|
|
483
|
-
await aggregate.reportAggregate(finalResults, resolution.unmatched);
|
|
484
733
|
// Clean up any per-scope comments from a previous per-scope run. A partial run
|
|
485
734
|
// only ever touches the named scopes' comments.
|
|
486
735
|
for (const scope of manifest.scopes) {
|
|
@@ -489,7 +738,9 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
489
738
|
}
|
|
490
739
|
await reporterFor(scopedCommentTag(rootTag, scope.name)).clear();
|
|
491
740
|
}
|
|
492
|
-
process.stderr.write(
|
|
741
|
+
process.stderr.write(finalResults.length === 0
|
|
742
|
+
? "CI reviewer: no scope matched the changed files; nothing posted.\n"
|
|
743
|
+
: `CI reviewer: posted aggregate review for ${finalResults.length} scope(s).\n`);
|
|
493
744
|
}
|
|
494
745
|
else {
|
|
495
746
|
for (const scope of manifest.scopes) {
|
|
@@ -498,10 +749,12 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
498
749
|
if (scopesFilter && !scopesFilter.includes(scope.name)) {
|
|
499
750
|
continue;
|
|
500
751
|
}
|
|
501
|
-
|
|
752
|
+
// The same instance the scope's feedback seam already used (its comment-list and
|
|
753
|
+
// login caches are per-instance), so the report reuses that fetch.
|
|
754
|
+
const reporter = scopeReporter(scope.name);
|
|
502
755
|
const result = results.find((entry) => entry.scope === scope.name);
|
|
503
756
|
if (result) {
|
|
504
|
-
await reporter.report(result.review);
|
|
757
|
+
await reporter.report(result.review, result.review.feedback);
|
|
505
758
|
}
|
|
506
759
|
else {
|
|
507
760
|
// A reconciled scope with zero matched files gets its stale comment deleted.
|
|
@@ -518,6 +771,23 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
518
771
|
process.stderr.write("CI reviewer: posted per-scope reviews.\n");
|
|
519
772
|
}
|
|
520
773
|
}
|
|
774
|
+
/**
|
|
775
|
+
* Merge a partial run's fresh results (--scopes) with the prior aggregate
|
|
776
|
+
* comment's stored state, in manifest order. Fresh results win for their scope;
|
|
777
|
+
* scopes outside the filter keep their previous result; scopes no longer in the
|
|
778
|
+
* manifest drop out. Pure so it's unit-testable.
|
|
779
|
+
*/
|
|
780
|
+
export function mergePartialAggregate(results, prior, scopesFilter, manifestScopeNames) {
|
|
781
|
+
const byName = new Map(results.map((result) => [result.scope, result]));
|
|
782
|
+
for (const previous of prior) {
|
|
783
|
+
if (!scopesFilter.includes(previous.scope) && !byName.has(previous.scope)) {
|
|
784
|
+
byName.set(previous.scope, previous);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return manifestScopeNames
|
|
788
|
+
.map((name) => byName.get(name))
|
|
789
|
+
.filter((entry) => entry != null);
|
|
790
|
+
}
|
|
521
791
|
/**
|
|
522
792
|
* Current PR labels via gh (more authoritative than the possibly-stale event
|
|
523
793
|
* payload); on failure, returns [] so a label-read hiccup never silently skips a
|
|
@@ -525,7 +795,8 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
525
795
|
*/
|
|
526
796
|
async function fetchPrLabels(repo, prNumber, cwd) {
|
|
527
797
|
try {
|
|
528
|
-
const
|
|
798
|
+
const gh = await resolveTrustedTool("gh");
|
|
799
|
+
const { stdout } = await run(gh, [
|
|
529
800
|
"pr",
|
|
530
801
|
"view",
|
|
531
802
|
String(prNumber),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ref LLP 0007#init-and-dismiss [implements] — display-only mutation; dismissals persist across re-reviews, never re-run the review
|
|
1
2
|
import { loadReviewConfig } from "../config/load.js";
|
|
2
3
|
import { repoRoot, resolveRepo } from "../core/exec.js";
|
|
3
4
|
import { errorMessage } from "../core/util.js";
|
|
@@ -33,6 +34,7 @@ function parseArgs(argv) {
|
|
|
33
34
|
if (arg.startsWith("--")) {
|
|
34
35
|
throw new Error(`Unknown argument: ${arg}`);
|
|
35
36
|
}
|
|
37
|
+
// @ref LLP 0007#init-and-dismiss — a malformed id is silently mangled to hex, not rejected; it just becomes "unmatched" later
|
|
36
38
|
// Bare arg = a finding id. Sanitize to the fingerprint alphabet.
|
|
37
39
|
args.ids.push(arg.replace(/[^a-f0-9]/g, ""));
|
|
38
40
|
}
|
|
@@ -78,6 +80,10 @@ export async function dismissCommand(argv, mode) {
|
|
|
78
80
|
commentTag: config.commentTag,
|
|
79
81
|
breakGlassMarker: config.breakGlassMarker,
|
|
80
82
|
cwd,
|
|
83
|
+
// The re-render re-derives every feedback record's `applied` flag, so it needs the
|
|
84
|
+
// feedback policy in force now — without it a reply-cleared finding would be
|
|
85
|
+
// un-hidden by an unrelated /dismiss.
|
|
86
|
+
feedback: config.feedback,
|
|
81
87
|
});
|
|
82
88
|
const result = await reporter.applyDismissal(mode === "add" ? args.ids : [], mode === "remove" ? args.ids : [], args.by, args.reason);
|
|
83
89
|
if (mode === "add") {
|