@expo/code-review-cli 0.5.2 → 0.7.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 +58 -22
- package/build/commands/ci.js +156 -39
- package/build/commands/doctor.js +167 -33
- package/build/commands/review.js +5 -3
- package/build/commands/setup-auth.js +83 -11
- package/build/config/load.js +15 -0
- package/build/config/schema.js +7 -3
- package/build/core/auth.js +122 -9
- package/build/core/claude-code.js +680 -0
- package/build/core/exec.js +278 -9
- package/build/core/opencode.js +95 -15
- package/build/core/prompts.js +19 -2
- package/build/core/render.js +21 -2
- package/build/core/review.js +212 -37
- package/build/core/schema.js +6 -1
- package/build/core/scrub.js +120 -0
- package/build/core/throttle.js +10 -0
- package/build/core/util.js +17 -0
- package/build/core/verify.js +13 -1
- package/build/reporters/github.js +79 -13
- package/build/sources/github-pr.js +110 -22
- package/build/sources/local-git.js +3 -2
- package/package.json +3 -3
- package/templates/command.yml +7 -1
- package/templates/config.jsonc +21 -3
- package/templates/dismiss.yml +3 -0
- package/templates/shared.md +28 -0
- package/templates/workflow.yml +17 -5
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ is a ready example to adapt.
|
|
|
110
110
|
| `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
|
|
111
111
|
| `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
|
|
112
112
|
| `ecr init --scope <dir>` | Scaffold a per-team scope under `<dir>` and register it in the manifest. |
|
|
113
|
-
| `ecr setup-auth [--yes]` | Walk through getting model credentials for local runs (ChatGPT sign-in and/or API keys), printing the `export` lines for your shell config. |
|
|
113
|
+
| `ecr setup-auth [--yes]` | Walk through getting model credentials for local runs (ChatGPT/Claude sign-in and/or API keys), printing the `export` lines for your shell config. |
|
|
114
114
|
| `ecr review [options]` | Review local changes and print an advisory review (default command). |
|
|
115
115
|
| `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
|
|
116
116
|
| `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
|
|
@@ -218,16 +218,31 @@ your-monorepo/
|
|
|
218
218
|
(e.g. `security`) are injected into every scope with `alwaysRun`, taken from the
|
|
219
219
|
root roster — a scope defining a same-id agent gets the root one, so a team can't
|
|
220
220
|
shadow the enforced reviewer with a weaker version on its own subtree.
|
|
221
|
-
- **
|
|
222
|
-
|
|
223
|
-
and
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
221
|
+
- **Configuration comes from the PR's trusted base commit.** In `ecr ci`, review
|
|
222
|
+
policy and reviewer configuration — `config.jsonc`, `routing.jsonc`, prompts,
|
|
223
|
+
models, and the auth mapping — load from the PR's immutable **base** commit,
|
|
224
|
+
materialized via the GitHub API. The PR head is untrusted data: it is
|
|
225
|
+
materialized separately (pinned to its immutable OID) purely as source content
|
|
226
|
+
to read and verify against. A PR editing rosters, prompts, or routing is
|
|
227
|
+
reviewed under the **previous** config; its changes activate after merge. If
|
|
228
|
+
the base commit can't be materialized, the run fails closed (one terminal
|
|
229
|
+
comment) — it never falls back to the checkout. A scope config that is new in
|
|
230
|
+
a PR is reviewed with the root config until it merges.
|
|
231
|
+
- **The model runtime never sees PR-owned ambient config.** The head worktree the
|
|
232
|
+
agents read from is scrubbed of runtime configuration before the OpenCode
|
|
233
|
+
server starts: `opencode.json{,c}`, `.opencode/` (plugins), `AGENTS.md`,
|
|
234
|
+
`CLAUDE.md`, `.claude/`, `.mcp.json`, `.cursor*`, and `.env*` at every depth.
|
|
235
|
+
A PR can't install a plugin, MCP server, instruction file, or `.env` into the
|
|
236
|
+
process that holds the model credential and the comment token. (Changes to
|
|
237
|
+
those files are still reviewed — their diffs are inlined in the prompt — but a
|
|
238
|
+
finding citing one can't be re-read during verification; that's the tradeoff.)
|
|
239
|
+
- **The scaffolded workflows check out only the base commit** with
|
|
240
|
+
`persist-credentials: false`; the CLI's own git fetches authenticate through
|
|
241
|
+
`gh` from `GH_TOKEN`, so the token never lands in `.git/config` or argv. The
|
|
242
|
+
CLI enforces the trust model itself, so a custom workflow that checks out the
|
|
243
|
+
PR head still gets base-commit configuration. The temporary escape hatch
|
|
244
|
+
`ecr ci --unsafe-config-from-head` restores the old behavior with a loud
|
|
245
|
+
security warning and will be removed on a minor boundary.
|
|
231
246
|
|
|
232
247
|
Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`
|
|
233
248
|
(the single authoritative router) and `/server/www/.expo-code-review/ @your-www-team`
|
|
@@ -530,18 +545,36 @@ set in `config.auth` (credentials come from OpenCode):
|
|
|
530
545
|
One caveat: OpenCode can't price alias models (they're config-declared), so
|
|
531
546
|
pro passes report `$0` in the run log's cost column — token counts are
|
|
532
547
|
correct, and the OpenAI project dashboard is the source of truth for spend.
|
|
533
|
-
- **Anthropic / Claude
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
548
|
+
- **Anthropic / Claude** — use `anthropic/...` model ids and every anthropic pass
|
|
549
|
+
runs through the **Claude Code CLI** (`claude -p --output-format json`), inferred
|
|
550
|
+
from the model. The credential is (in order) a `tokenEnv` you name, an ambient
|
|
551
|
+
`CLAUDE_CODE_OAUTH_TOKEN`, or your local `claude` login — an `auth` entry is
|
|
552
|
+
entirely optional. Run `claude setup-token` for a Max/Team subscription token
|
|
553
|
+
(forwarded as `CLAUDE_CODE_OAUTH_TOKEN`) or point `tokenEnv` at an Anthropic
|
|
554
|
+
Console API key (`sk-ant-api…`, forwarded as `ANTHROPIC_API_KEY`); the CLI reads
|
|
555
|
+
either. `ecr setup-auth` walks you through it. Each pass is trust-isolated and
|
|
556
|
+
read-only: it runs with `--safe-mode` (no `CLAUDE.md`/hooks/MCP/plugins),
|
|
557
|
+
`--strict-mcp-config`, `--permission-mode dontAsk`, and only the
|
|
558
|
+
`Read`/`Grep`/`Glob` tools — never `Bash`/`Edit`/`Write`/`WebFetch`/`WebSearch`.
|
|
559
|
+
The child env is an allowlist that omits ambient `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`
|
|
560
|
+
(only the configured credential is re-injected).
|
|
561
|
+
```jsonc
|
|
562
|
+
// Optional — no anthropic entry at all falls back to your `claude` login.
|
|
563
|
+
"auth": { "providers": {
|
|
564
|
+
"anthropic": { "tokenEnv": "CLAUDE_CODE_OAUTH_TOKEN" }
|
|
565
|
+
} }
|
|
566
|
+
```
|
|
539
567
|
- **Another provider** — the current path is the `REVIEWER_MODEL`
|
|
540
568
|
env override: `opencode auth login` once (pick the provider), then run with
|
|
541
569
|
e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
|
|
542
|
-
and uses your OpenCode login, so no `auth` block is needed.
|
|
543
|
-
|
|
544
|
-
|
|
570
|
+
and uses your OpenCode login, so no `auth` block is needed.
|
|
571
|
+
|
|
572
|
+
Engines are inferred **per agent** from that agent's resolved model alone: an
|
|
573
|
+
`anthropic/…` agent runs through the Claude Code CLI while other agents run through
|
|
574
|
+
OpenCode — in the SAME run. So an anthropic model may coexist with an `openai` (or
|
|
575
|
+
any other) OpenCode provider, and each agent's `model` selects its engine.
|
|
576
|
+
`REVIEWER_MODEL` still overrides every agent's model (and therefore every agent's
|
|
577
|
+
engine), converging the whole run onto one engine.
|
|
545
578
|
|
|
546
579
|
There is no shared fallback key; if a run fails for lack of credentials, authenticate
|
|
547
580
|
a provider in OpenCode. `ecr doctor` diagnoses setup.
|
|
@@ -555,8 +588,11 @@ rediscovering one fixable thing, then reports N coverage gaps. So before any pas
|
|
|
555
588
|
model, with nothing pointing at the credential. A truncated value, surrounding
|
|
556
589
|
whitespace, or a token that can't work for the configured `auth.mode` is rejected
|
|
557
590
|
by name.
|
|
558
|
-
- **Configured model ids are checked against the running
|
|
559
|
-
the provider doesn't have is reported once, up front,
|
|
591
|
+
- **Configured model ids for OpenCode-routed providers are checked against the running
|
|
592
|
+
server**, so a typo or an id the provider doesn't have is reported once, up front,
|
|
593
|
+
with the close matches. `anthropic/…` (Claude Code) model ids aren't checked up
|
|
594
|
+
front — Claude validates them per-request, so a typo there surfaces as a per-pass
|
|
595
|
+
error instead.
|
|
560
596
|
- **`ecr doctor` reports the `opencode` version actually in use** and warns when a
|
|
561
597
|
different one is first on your `PATH` — runs use the version this package pins.
|
|
562
598
|
|
package/build/commands/ci.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONFIG_DIRNAME, hasScopeConfig, loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
|
|
3
4
|
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";
|
|
5
|
+
import { repoRoot, resolveTrustedTool, run } from "../core/exec.js";
|
|
6
|
+
import { errorMessage, publicFailureReason } from "../core/util.js";
|
|
6
7
|
import { buildDiffLineIndex } from "../core/render.js";
|
|
7
8
|
import { runReview } from "../core/review.js";
|
|
8
9
|
import { GitHubPRSource } from "../sources/github-pr.js";
|
|
@@ -32,6 +33,15 @@ For GitHub Actions: reads the PR number + repo from the event/env, gets the diff
|
|
|
32
33
|
via \`gh pr diff\`, runs the reviewer, and upserts a single PR comment. Comment-only
|
|
33
34
|
and non-blocking (a reviewer failure never fails the PR's checks).
|
|
34
35
|
|
|
36
|
+
Trust model: review policy and reviewer configuration (config.jsonc, routing,
|
|
37
|
+
prompts, models, auth mapping) load from the PR's immutable BASE commit,
|
|
38
|
+
materialized via the GitHub API — never from the PR head — so a PR cannot change
|
|
39
|
+
the reviewer that evaluates it; config changes activate after merge. The PR head
|
|
40
|
+
is materialized separately (pinned to its immutable OID, scrubbed of ambient
|
|
41
|
+
runtime config) purely as source content to read and verify against. If the
|
|
42
|
+
trusted base cannot be materialized, the run fails closed with one terminal
|
|
43
|
+
comment; it never falls back to the checkout.
|
|
44
|
+
|
|
35
45
|
Monorepos: when .expo-code-review/routing.jsonc exists, ci fans out INTERNALLY —
|
|
36
46
|
it assigns each changed file to exactly one scope (last-match-wins) and reviews
|
|
37
47
|
each active scope over only its files, then renders one aggregated comment (or one
|
|
@@ -42,8 +52,17 @@ Options:
|
|
|
42
52
|
--route Let the router pick relevant agents from the diff
|
|
43
53
|
--scopes <a,b> Limit the fan-out to these named scopes (routing only)
|
|
44
54
|
--config-dir <dir> Load the ROOT config.jsonc + routing.jsonc from <dir>
|
|
45
|
-
instead of .expo-code-review/ (also ECR_CONFIG_DIR).
|
|
46
|
-
|
|
55
|
+
instead of .expo-code-review/ (also ECR_CONFIG_DIR). A
|
|
56
|
+
RELATIVE dir resolves beneath the trusted base commit; an
|
|
57
|
+
ABSOLUTE dir is an explicit operator trust decision. Scope
|
|
58
|
+
subtrees always resolve beneath the trusted base commit.
|
|
59
|
+
--unsafe-config-from-head
|
|
60
|
+
COMPATIBILITY ESCAPE HATCH: load configuration from the
|
|
61
|
+
current checkout instead of the PR's trusted base commit.
|
|
62
|
+
This lets a same-repo PR change the reviewer (policy,
|
|
63
|
+
prompts, model, auth mapping) that evaluates itself.
|
|
64
|
+
Never scaffolded; prints a security warning; will be
|
|
65
|
+
removed on a scheduled minor boundary.
|
|
47
66
|
--comment <mode> Override manifest comment mode: single | per-scope
|
|
48
67
|
--force Manual override: review even if the trigger policy (label
|
|
49
68
|
trigger / ai-review:skip) would skip. Break-glass and the
|
|
@@ -78,6 +97,7 @@ export async function ciCommand(argv = []) {
|
|
|
78
97
|
// A maintainer's explicit `/review` (comment command or --force) is a manual
|
|
79
98
|
// escape hatch that bypasses the trigger-policy gate only (see passesTriggerGate).
|
|
80
99
|
const bypassTriggerGate = shouldBypassTriggerGate(argv);
|
|
100
|
+
const unsafeConfigFromHead = argv.includes("--unsafe-config-from-head");
|
|
81
101
|
const root = await repoRoot();
|
|
82
102
|
if (root && root !== process.cwd()) {
|
|
83
103
|
process.chdir(root);
|
|
@@ -89,35 +109,120 @@ export async function ciCommand(argv = []) {
|
|
|
89
109
|
process.stderr.write("CI reviewer: could not determine repository or PR number from the environment. Skipping.\n");
|
|
90
110
|
return;
|
|
91
111
|
}
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
112
|
+
// ONE source for the whole run: metadata (incl. immutable OIDs), the diff, and
|
|
113
|
+
// the PR-head read root are each fetched once and shared across scopes.
|
|
114
|
+
const ghSource = new GitHubPRSource({ prNumber, repo, cwd });
|
|
115
|
+
const source = memoizeSource(ghSource);
|
|
116
|
+
// Trusted configuration root: review policy and reviewer config load from the
|
|
117
|
+
// PR's immutable BASE commit, so the PR head is data, never policy. Fail CLOSED:
|
|
118
|
+
// when the base can't be materialized, post the one terminal comment and stop —
|
|
119
|
+
// silently reading the checkout would let a head checkout smuggle config in.
|
|
120
|
+
let trustedRoot = null;
|
|
121
|
+
let configRoot = cwd;
|
|
122
|
+
if (unsafeConfigFromHead) {
|
|
123
|
+
process.stderr.write("CI reviewer: ⚠ SECURITY — --unsafe-config-from-head is set: reviewer configuration " +
|
|
124
|
+
"(policy, prompts, models, auth mapping) is being loaded from the current checkout, so " +
|
|
125
|
+
"a same-repository PR can change the reviewer that evaluates it. This escape hatch will " +
|
|
126
|
+
"be removed in a future minor release.\n");
|
|
96
127
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
128
|
+
else {
|
|
129
|
+
try {
|
|
130
|
+
trustedRoot = await ghSource.prepareTrustedConfigRootAsync();
|
|
131
|
+
configRoot = trustedRoot.dir;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
const reason = errorMessage(error);
|
|
135
|
+
process.stderr.write(`CI reviewer: could not materialize the PR's base commit for trusted configuration ` +
|
|
136
|
+
`(failing closed, not reviewing): ${reason}\n`);
|
|
137
|
+
await postTerminalFailureNote(repo, prNumber, cwd, `it could not load trusted configuration from the PR's base commit (${publicFailureReason(error)}). ` +
|
|
138
|
+
`This usually means the runner has no git checkout or no usable GH_TOKEN; re-run once fixed`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
100
141
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
142
|
+
try {
|
|
143
|
+
// A malformed manifest is a loud, non-blocking error (never a silent fallback).
|
|
144
|
+
let manifest;
|
|
145
|
+
try {
|
|
146
|
+
manifest = await loadRoutingManifest(configRoot, { configDir });
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
process.stderr.write(`CI reviewer: invalid routing.jsonc: ${errorMessage(error)}\n`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (manifest == null) {
|
|
153
|
+
await runLegacyCi(source, repo, prNumber, cwd, configRoot, {
|
|
154
|
+
agents,
|
|
155
|
+
route,
|
|
156
|
+
bypassTriggerGate,
|
|
157
|
+
configDir,
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
await runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, {
|
|
163
|
+
agents,
|
|
164
|
+
route,
|
|
165
|
+
scopesFilter,
|
|
166
|
+
commentOverride,
|
|
167
|
+
bypassTriggerGate,
|
|
168
|
+
configDir,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
// Fan-out failures stay non-blocking (single-writer property is the point).
|
|
173
|
+
process.stderr.write(`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
await source.dispose();
|
|
178
|
+
await trustedRoot?.cleanup();
|
|
104
179
|
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The one terminal "this PR was NOT reviewed" comment for failures that happen
|
|
183
|
+
* before any configuration is loaded (trusted-root materialization). Uses the
|
|
184
|
+
* DEFAULT comment tag/break-glass marker because the config that could customize
|
|
185
|
+
* them is exactly what failed to load; a repo with a custom tag gets a fresh
|
|
186
|
+
* comment rather than an upsert, which is the acceptable degraded case.
|
|
187
|
+
*/
|
|
188
|
+
async function postTerminalFailureNote(repo, prNumber, cwd, reason) {
|
|
105
189
|
try {
|
|
106
|
-
|
|
190
|
+
const reporter = new GitHubReporter({
|
|
191
|
+
prNumber,
|
|
192
|
+
repo,
|
|
193
|
+
commentTag: "expo-ai-code-reviewer",
|
|
194
|
+
breakGlassMarker: "/skip-review",
|
|
195
|
+
cwd,
|
|
196
|
+
});
|
|
197
|
+
await reporter.report({
|
|
198
|
+
decision: "approve_with_comments",
|
|
199
|
+
findings: [],
|
|
200
|
+
summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed: ${reason}.`,
|
|
201
|
+
incomplete: [],
|
|
202
|
+
couldNotComplete: true,
|
|
203
|
+
});
|
|
107
204
|
}
|
|
108
|
-
catch (
|
|
109
|
-
|
|
110
|
-
process.stderr.write(`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`);
|
|
205
|
+
catch (postError) {
|
|
206
|
+
process.stderr.write(`CI reviewer: also failed to post the failure notice: ${errorMessage(postError)}\n`);
|
|
111
207
|
}
|
|
112
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Run-log + patch-workspace anchor: ALWAYS the workspace checkout, never the
|
|
211
|
+
* (temporary, removed-on-exit) trusted config root — the workflow uploads
|
|
212
|
+
* `.expo-code-review/.runs/reviews.jsonl` from the workspace as an artifact.
|
|
213
|
+
*/
|
|
214
|
+
function workspaceRunsDir(cwd) {
|
|
215
|
+
return path.join(cwd, CONFIG_DIRNAME, ".runs");
|
|
216
|
+
}
|
|
113
217
|
/**
|
|
114
218
|
* The pre-routing single-config path. Kept byte-for-byte equivalent so that with no
|
|
115
219
|
* routing.jsonc the CLI behaves exactly as before (backcompat invariant).
|
|
116
220
|
*/
|
|
117
|
-
async function runLegacyCi(repo, prNumber, cwd,
|
|
221
|
+
async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
|
|
222
|
+
const { agents, route, bypassTriggerGate, configDir } = options;
|
|
118
223
|
let config;
|
|
119
224
|
try {
|
|
120
|
-
config = await loadReviewConfig(
|
|
225
|
+
config = await loadReviewConfig(configRoot, { configDir });
|
|
121
226
|
}
|
|
122
227
|
catch (error) {
|
|
123
228
|
process.stderr.write(`CI reviewer: ${errorMessage(error)}\n`);
|
|
@@ -158,11 +263,12 @@ async function runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate
|
|
|
158
263
|
process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
|
|
159
264
|
}
|
|
160
265
|
try {
|
|
161
|
-
const review = await runReview(
|
|
266
|
+
const review = await runReview(source, {
|
|
162
267
|
config,
|
|
163
268
|
mode: "ci",
|
|
164
269
|
agents,
|
|
165
270
|
route,
|
|
271
|
+
runsDir: workspaceRunsDir(cwd),
|
|
166
272
|
onProgress: (message) => process.stderr.write(`${message}\n`),
|
|
167
273
|
});
|
|
168
274
|
await reporter.report(review);
|
|
@@ -179,7 +285,7 @@ async function runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate
|
|
|
179
285
|
await reporter.report({
|
|
180
286
|
decision: "approve_with_comments",
|
|
181
287
|
findings: [],
|
|
182
|
-
summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${
|
|
288
|
+
summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${publicFailureReason(error)}`,
|
|
183
289
|
incomplete: [],
|
|
184
290
|
});
|
|
185
291
|
}
|
|
@@ -198,10 +304,12 @@ function failureReview(scopeName, reason) {
|
|
|
198
304
|
};
|
|
199
305
|
}
|
|
200
306
|
/** The routing fan-out: one process, N scopes reviewed sequentially, one render. */
|
|
201
|
-
async function runRoutedCi(manifest, repo, prNumber, cwd,
|
|
307
|
+
async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, options) {
|
|
308
|
+
const { agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir } = options;
|
|
202
309
|
// The root config + manifest follow the override; scope configs stay
|
|
203
|
-
//
|
|
204
|
-
|
|
310
|
+
// relative to the TRUSTED root (loadScopeConfig reads
|
|
311
|
+
// <configRoot>/<scope.config>/.expo-code-review).
|
|
312
|
+
const rootConfig = await loadReviewConfig(configRoot, { configDir });
|
|
205
313
|
// The root/aggregate marker is the ACTUAL root-owned comment tag so the
|
|
206
314
|
// pre-routing comment and its dismissal state upsert in place, not stranded
|
|
207
315
|
// under a new marker (risk 8/9). manifest.defaults.commentTag is the
|
|
@@ -240,7 +348,6 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
|
|
|
240
348
|
if (!passesTriggerGate(await fetchPrLabels(repo, prNumber, cwd), rootConfig.review, bypassTriggerGate)) {
|
|
241
349
|
return;
|
|
242
350
|
}
|
|
243
|
-
const source = memoizeSource(new GitHubPRSource({ prNumber, repo, cwd }));
|
|
244
351
|
const changed = await source.getChangedFiles();
|
|
245
352
|
const resolution = resolveScopes(manifest, changed.map((file) => file.path));
|
|
246
353
|
process.stderr.write("CI reviewer: scope ownership —\n");
|
|
@@ -276,7 +383,6 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
|
|
|
276
383
|
if (await bgReporter.checkBreakGlass()) {
|
|
277
384
|
process.stderr.write(`CI reviewer: ${rootConfig.breakGlassMarker} detected; skipping.\n`);
|
|
278
385
|
await bgReporter.postSkipNote();
|
|
279
|
-
await source.dispose();
|
|
280
386
|
return;
|
|
281
387
|
}
|
|
282
388
|
}
|
|
@@ -284,17 +390,17 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
|
|
|
284
390
|
process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
|
|
285
391
|
}
|
|
286
392
|
// Build ONE link context for all scopes (rate-limit hygiene): diff lines from the
|
|
287
|
-
// already-fetched changed files, base
|
|
393
|
+
// already-fetched changed files, base OID from the memoized PR metadata (the same
|
|
394
|
+
// immutable OID the trusted config root was materialized from).
|
|
288
395
|
const link = {
|
|
289
396
|
repo,
|
|
290
397
|
prNumber,
|
|
291
398
|
diffLines: buildDiffLineIndex(changed.map((file) => ({ path: file.path, patch: file.patch }))),
|
|
292
399
|
};
|
|
293
400
|
try {
|
|
294
|
-
const {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
link.baseSha = oid;
|
|
401
|
+
const { baseOid } = await source.getMetadata();
|
|
402
|
+
if (baseOid) {
|
|
403
|
+
link.baseSha = baseOid;
|
|
298
404
|
}
|
|
299
405
|
}
|
|
300
406
|
catch {
|
|
@@ -317,7 +423,18 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
|
|
|
317
423
|
const isDefault = scope.configDir === ".";
|
|
318
424
|
let review;
|
|
319
425
|
try {
|
|
320
|
-
|
|
426
|
+
// A scope whose config dir doesn't exist at the TRUSTED base commit is a
|
|
427
|
+
// scope this PR introduces: review it with the root config rather than
|
|
428
|
+
// failing the run on exactly that PR. The scope's own config (PR-owned,
|
|
429
|
+
// untrusted for this run) activates once it merges.
|
|
430
|
+
let effectiveScopeDef = scopeDef;
|
|
431
|
+
if (!hasScopeConfig(configRoot, scopeDef)) {
|
|
432
|
+
process.stderr.write(`CI reviewer: [${scope.name}] no config at "${scopeDef.config}" in the PR's base ` +
|
|
433
|
+
`commit (new in this PR?); reviewing with the root config — the scope's config ` +
|
|
434
|
+
`takes effect after merge.\n`);
|
|
435
|
+
effectiveScopeDef = { ...scopeDef, config: "." };
|
|
436
|
+
}
|
|
437
|
+
const config = await loadScopeConfig(configRoot, effectiveScopeDef, manifest, rootConfig);
|
|
321
438
|
review = await runReview(source, {
|
|
322
439
|
config,
|
|
323
440
|
mode: "ci",
|
|
@@ -325,17 +442,16 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
|
|
|
325
442
|
route,
|
|
326
443
|
includePaths: scope.files,
|
|
327
444
|
passesBudgetMs: budget,
|
|
445
|
+
runsDir: workspaceRunsDir(cwd),
|
|
328
446
|
onProgress: (message) => process.stderr.write(`[${scope.name}] ${message}\n`),
|
|
329
447
|
});
|
|
330
448
|
}
|
|
331
449
|
catch (error) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
review = failureReview(scope.name, reason);
|
|
450
|
+
process.stderr.write(`CI reviewer: [${scope.name}] failed (non-blocking): ${errorMessage(error)}\n`);
|
|
451
|
+
review = failureReview(scope.name, publicFailureReason(error));
|
|
335
452
|
}
|
|
336
453
|
results.push({ scope: scope.name, isDefault, review });
|
|
337
454
|
}
|
|
338
|
-
await source.dispose();
|
|
339
455
|
const reporterFor = (tag, withLink = false) => new GitHubReporter({
|
|
340
456
|
prNumber,
|
|
341
457
|
repo,
|
|
@@ -408,7 +524,8 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
|
|
|
408
524
|
*/
|
|
409
525
|
async function fetchPrLabels(repo, prNumber, cwd) {
|
|
410
526
|
try {
|
|
411
|
-
const
|
|
527
|
+
const gh = await resolveTrustedTool("gh");
|
|
528
|
+
const { stdout } = await run(gh, [
|
|
412
529
|
"pr",
|
|
413
530
|
"view",
|
|
414
531
|
String(prNumber),
|