@expo/code-review-cli 0.5.1 → 0.6.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 +46 -19
- package/build/commands/ci.js +149 -32
- package/build/commands/review.js +5 -3
- package/build/commands/setup-auth.js +34 -17
- package/build/config/load.js +15 -0
- package/build/config/schema.js +8 -3
- package/build/core/auth.js +56 -6
- package/build/core/opencode.js +52 -9
- package/build/core/render.js +2 -2
- package/build/core/review.js +83 -14
- package/build/core/schema.js +8 -0
- package/build/core/scrub.js +62 -0
- package/build/core/throttle.js +94 -0
- package/build/sources/github-pr.js +99 -18
- package/package.json +1 -1
- package/templates/command.yml +7 -1
- package/templates/config.jsonc +7 -3
- package/templates/dismiss.yml +3 -0
- package/templates/workflow.yml +17 -5
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ subscription** sign-in (it runs OpenCode's browser login and extracts the token
|
|
|
55
55
|
for you). `doctor` offers to run it whenever a credential is missing.
|
|
56
56
|
|
|
57
57
|
In CI, store the same values as repo secrets (`OPENAI_API_KEY`; plus
|
|
58
|
-
`
|
|
58
|
+
`CODEX_OAUTH_ACCESS_TOKEN` for the mixed setup) — the scaffolded workflow
|
|
59
59
|
forwards them.
|
|
60
60
|
|
|
61
61
|
**Have a ChatGPT Plus/Pro (Codex) subscription? Use both.** The recommended
|
|
@@ -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`
|
|
@@ -344,7 +359,7 @@ coordinator, and per-repo `noise.additionalIgnores`.
|
|
|
344
359
|
{
|
|
345
360
|
"model": "openai/gpt-5.5", // default model for the specialists
|
|
346
361
|
"policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
|
|
347
|
-
"chunk": { "maxChangedLines": 1000, "maxFiles": 20,
|
|
362
|
+
"chunk": { "maxChangedLines": 1000, "maxFiles": 20 }, // concurrency defaults: 6 (API key) / 3 (subscription)
|
|
348
363
|
"noise": { "additionalIgnores": ["packages/*/build/**"] },
|
|
349
364
|
"review": { "trigger": "all", // which PRs `ecr ci` reviews: "all"
|
|
350
365
|
"label": "ai-review", // (default, except ai-review:skip) or
|
|
@@ -406,6 +421,15 @@ change which model reviewed your code. Use an explicit override instead.
|
|
|
406
421
|
session, inside the same budget — instead of spending the whole cap on a dead
|
|
407
422
|
request. Progress lines say how long a reply has been silent, so this is legible in
|
|
408
423
|
the CI log.
|
|
424
|
+
- **Rate limits are detected and waited out, not fought.** The reviewer watches the
|
|
425
|
+
OpenCode server's own log for provider 429s (hard evidence, per run). A stall
|
|
426
|
+
*with* recent 429 evidence is throttling, not a wedge — the pass waits in 90s
|
|
427
|
+
beats (without consuming its one retry) instead of re-sending its whole context
|
|
428
|
+
into a limited account; explicit 429 errors retry on a slow 15s/45s/90s schedule.
|
|
429
|
+
Subscription (oauth) runs also default to `concurrency` 3 instead of 6, since one
|
|
430
|
+
account may be serving several PRs' reviews at once. Rate-limit events are
|
|
431
|
+
reported in the job log and the run log (`rateLimitEvents`), so throttling is a
|
|
432
|
+
visible fact about a run, never a mystery slowdown.
|
|
409
433
|
- **Soft landing on timeout** — at either cap, the run is interrupted and the agent
|
|
410
434
|
is asked to return the findings it already has, rather than discarding its work.
|
|
411
435
|
Tools are disabled for that request, so the salvage step can't resume investigating
|
|
@@ -490,7 +514,7 @@ set in `config.auth` (credentials come from OpenCode):
|
|
|
490
514
|
|
|
491
515
|
```jsonc
|
|
492
516
|
"auth": { "providers": {
|
|
493
|
-
"openai": { "mode": "oauth", "tokenEnv": "
|
|
517
|
+
"openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" },
|
|
494
518
|
"openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
|
|
495
519
|
} }
|
|
496
520
|
```
|
|
@@ -499,11 +523,14 @@ set in `config.auth` (credentials come from OpenCode):
|
|
|
499
523
|
(`upstream` names the SDK it's backed by): agents reference `openai-api/gpt-5.5-pro`
|
|
500
524
|
in frontmatter while everything else stays on `openai/gpt-5.5`. Notes:
|
|
501
525
|
|
|
502
|
-
- **The oauth `tokenEnv` holds the
|
|
503
|
-
ChatGPT sign-in (
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
526
|
+
- **The oauth `tokenEnv` holds the ACCESS token** from an `opencode auth login`
|
|
527
|
+
ChatGPT sign-in (`ecr setup-auth` extracts it) — a plain bearer, valid for
|
|
528
|
+
days, with no rotation involvement. Do **not** use the refresh token as a
|
|
529
|
+
shared secret: refresh tokens are single-use (rotation), so a static copy is
|
|
530
|
+
spent by its first use and the sign-in dies with it. Access tokens expire
|
|
531
|
+
(~10 days observed), so CI secrets need periodic re-minting — see the
|
|
532
|
+
token-rotator item in the [roadmap](./ROADMAP.md); `doctor` and the run
|
|
533
|
+
preflight warn before expiry.
|
|
507
534
|
- **The API key needs exactly two permissions** — a *Restricted* key with
|
|
508
535
|
*Model capabilities*: **Responses → Request** and **Chat completions →
|
|
509
536
|
Request**; everything else (including *List models*) stays None. Create it
|
|
@@ -511,7 +538,7 @@ set in `config.auth` (credentials come from OpenCode):
|
|
|
511
538
|
instructions too.)
|
|
512
539
|
- **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
|
|
513
540
|
comma-separated set of both env names
|
|
514
|
-
(`
|
|
541
|
+
(`CODEX_OAUTH_ACCESS_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
|
|
515
542
|
workflow.
|
|
516
543
|
- **Auditability**: every pass logs which provider/model answered it (job log,
|
|
517
544
|
step summary, run log), so the subscription/API split is visible per run.
|
package/build/commands/ci.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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
5
|
import { repoRoot, run } from "../core/exec.js";
|
|
5
6
|
import { errorMessage } from "../core/util.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 (${reason}). ` +
|
|
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
|
+
}
|
|
104
175
|
}
|
|
176
|
+
finally {
|
|
177
|
+
await source.dispose();
|
|
178
|
+
await trustedRoot?.cleanup();
|
|
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);
|
|
@@ -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,6 +442,7 @@ 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
|
}
|
|
@@ -335,7 +453,6 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
|
|
|
335
453
|
}
|
|
336
454
|
results.push({ scope: scope.name, isDefault, review });
|
|
337
455
|
}
|
|
338
|
-
await source.dispose();
|
|
339
456
|
const reporterFor = (tag, withLink = false) => new GitHubReporter({
|
|
340
457
|
prNumber,
|
|
341
458
|
repo,
|
package/build/commands/review.js
CHANGED
|
@@ -38,9 +38,11 @@ Options:
|
|
|
38
38
|
--no-fail always exit 0, even on request-changes
|
|
39
39
|
-h, --help show this help
|
|
40
40
|
|
|
41
|
-
Note:
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
Note: with --repo (or in CI), --pr materializes the PR-head tree (pinned to its
|
|
42
|
+
immutable commit, scrubbed of ambient runtime config) so reads match the PR; if
|
|
43
|
+
that isn't possible, it falls back to your checked-out files with a warning.
|
|
44
|
+
Config always loads from YOUR checkout in local runs — you are the trust
|
|
45
|
+
principal here. In \`ecr ci\`, config loads from the PR's trusted base commit.
|
|
44
46
|
|
|
45
47
|
Exit codes: 0 approve / approve-with-comments, 1 request-changes, 2 error.
|
|
46
48
|
`;
|
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import readline from "node:readline/promises";
|
|
6
6
|
import { hasConfig, loadReviewConfig } from "../config/load.js";
|
|
7
|
+
import { jwtExpiryMs } from "../core/auth.js";
|
|
7
8
|
import { opencodeBinSource } from "../core/opencode.js";
|
|
8
9
|
import { errorMessage } from "../core/util.js";
|
|
9
10
|
const USAGE = `ecr setup-auth — set up model credentials for local runs
|
|
@@ -48,13 +49,25 @@ export function opencodeAuthJsonPath(env = process.env) {
|
|
|
48
49
|
const dataHome = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
|
|
49
50
|
return path.join(dataHome, "opencode", "auth.json");
|
|
50
51
|
}
|
|
51
|
-
/**
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The stored ChatGPT sign-in's ACCESS token, if OpenCode has a live one. The
|
|
54
|
+
* refresh token deliberately never leaves OpenCode's store: refresh tokens are
|
|
55
|
+
* SINGLE-USE (rotation) and OpenCode is their sole legitimate consumer — a copy
|
|
56
|
+
* in a shell config or CI secret dies on the next rotation and can take the
|
|
57
|
+
* whole sign-in with it. The access token is a plain bearer that stays valid for
|
|
58
|
+
* days and never touches rotation.
|
|
59
|
+
*/
|
|
60
|
+
async function readStoredAccessToken() {
|
|
53
61
|
try {
|
|
54
62
|
const raw = await readFile(opencodeAuthJsonPath(), "utf8");
|
|
55
63
|
const parsed = JSON.parse(raw);
|
|
56
64
|
const openai = parsed.openai;
|
|
57
|
-
|
|
65
|
+
if (openai?.type !== "oauth" || !openai.access) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const expiresMs = jwtExpiryMs(openai.access) ?? 0;
|
|
69
|
+
// An expired stored token means the sign-in needs redoing anyway.
|
|
70
|
+
return expiresMs > Date.now() ? { token: openai.access, expiresMs } : null;
|
|
58
71
|
}
|
|
59
72
|
catch {
|
|
60
73
|
return null;
|
|
@@ -96,7 +109,7 @@ export async function setupAuthCommand(argv = []) {
|
|
|
96
109
|
else {
|
|
97
110
|
err("No .expo-code-review config here — setting up the default ChatGPT/Codex flow.");
|
|
98
111
|
plan = planFromAuth([
|
|
99
|
-
{ provider: "openai", mode: "oauth", tokenEnv: "
|
|
112
|
+
{ provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_ACCESS_TOKEN" },
|
|
100
113
|
]);
|
|
101
114
|
}
|
|
102
115
|
if (!plan.chatgptLogin && plan.manualKeys.length === 0 && plan.unsupported.length === 0) {
|
|
@@ -110,14 +123,15 @@ export async function setupAuthCommand(argv = []) {
|
|
|
110
123
|
err(`✓ ${tokenEnv} is already set in this shell — skipping the ChatGPT sign-in.`);
|
|
111
124
|
}
|
|
112
125
|
else {
|
|
113
|
-
let
|
|
114
|
-
if (
|
|
115
|
-
err(
|
|
116
|
-
|
|
117
|
-
|
|
126
|
+
let stored = await readStoredAccessToken();
|
|
127
|
+
if (stored) {
|
|
128
|
+
err(`Found a live ChatGPT sign-in in OpenCode (access token valid ` +
|
|
129
|
+
`${Math.max(1, Math.round((stored.expiresMs - Date.now()) / 86_400_000))} more day(s)).`);
|
|
130
|
+
if (!(await confirm(`Use it for ${tokenEnv}?`, yes))) {
|
|
131
|
+
stored = null;
|
|
118
132
|
}
|
|
119
133
|
}
|
|
120
|
-
if (!
|
|
134
|
+
if (!stored) {
|
|
121
135
|
err("This will run the bundled `opencode auth login` (interactive).");
|
|
122
136
|
err("When it prompts:");
|
|
123
137
|
err(" 1. select the provider: OpenAI");
|
|
@@ -133,17 +147,20 @@ export async function setupAuthCommand(argv = []) {
|
|
|
133
147
|
if (result.status !== 0) {
|
|
134
148
|
throw new Error(`\`opencode auth login\` exited with ${result.status ?? "a signal"}; nothing was changed.`);
|
|
135
149
|
}
|
|
136
|
-
|
|
137
|
-
if (!
|
|
138
|
-
throw new Error("The login finished but no ChatGPT sign-in was stored — did you select " +
|
|
150
|
+
stored = await readStoredAccessToken();
|
|
151
|
+
if (!stored) {
|
|
152
|
+
throw new Error("The login finished but no live ChatGPT sign-in was stored — did you select " +
|
|
139
153
|
'OpenAI → "Sign in with ChatGPT"? Re-run `ecr setup-auth` to try again.');
|
|
140
154
|
}
|
|
141
155
|
}
|
|
142
156
|
}
|
|
143
|
-
if (
|
|
144
|
-
// The
|
|
145
|
-
//
|
|
146
|
-
|
|
157
|
+
if (stored) {
|
|
158
|
+
// The ACCESS token: a plain bearer, valid for days, no rotation involved.
|
|
159
|
+
// (The refresh token stays in OpenCode's store — it is single-use, and
|
|
160
|
+
// copying it anywhere kills it on the next rotation.)
|
|
161
|
+
exports.push(exportLine(tokenEnv, stored.token));
|
|
162
|
+
err(`Note: this access token expires in ~${Math.max(1, Math.round((stored.expiresMs - Date.now()) / 86_400_000))} day(s); ` +
|
|
163
|
+
`re-run \`ecr setup-auth\` then to refresh it (your OpenCode sign-in stays valid).`);
|
|
147
164
|
}
|
|
148
165
|
}
|
|
149
166
|
}
|
package/build/config/load.js
CHANGED
|
@@ -177,6 +177,21 @@ export function loadAuthFromRoot(rootConfig, manifest) {
|
|
|
177
177
|
}
|
|
178
178
|
return rootConfig.auth;
|
|
179
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Whether a scope's own config dir exists under `root` — the same path
|
|
182
|
+
* `loadScopeConfig` reads (deliberately NOT via resolveConfigDir: ECR_CONFIG_DIR
|
|
183
|
+
* must never redirect scope subtrees). `ecr ci` uses this against the TRUSTED
|
|
184
|
+
* BASE root to give scopes that are new in a PR a defined miss behavior (review
|
|
185
|
+
* with the root config; the scope config activates after merge) instead of
|
|
186
|
+
* failing the run on exactly the PR that introduces the scope.
|
|
187
|
+
*/
|
|
188
|
+
export function hasScopeConfig(root, scope) {
|
|
189
|
+
if (scope.config === ".") {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
const dir = path.join(root, scope.config, CONFIG_DIRNAME);
|
|
193
|
+
return existsSync(path.join(dir, "config.jsonc")) || existsSync(path.join(dir, "config.json"));
|
|
194
|
+
}
|
|
180
195
|
/**
|
|
181
196
|
* Load one scope's fully-resolved config. The default scope (config '.') reuses
|
|
182
197
|
* the root config unchanged except auth; a nested scope reads its own
|
package/build/config/schema.js
CHANGED
|
@@ -38,10 +38,15 @@ export const ReviewConfigSchema = z.object({
|
|
|
38
38
|
maxChangedLines: z.number().int().positive().default(1000),
|
|
39
39
|
// Secondary guard so a chunk isn't an absurd number of tiny-diff files.
|
|
40
40
|
maxFiles: z.number().int().positive().default(20),
|
|
41
|
-
// Max concurrent reviewer calls across all agents/chunks.
|
|
42
|
-
|
|
41
|
+
// Max concurrent reviewer calls across all agents/chunks. Unset ⇒ resolved
|
|
42
|
+
// from the auth mode: 6 for API-key runs, 3 when a subscription (oauth)
|
|
43
|
+
// credential is configured — one ChatGPT account handles six parallel
|
|
44
|
+
// streams poorly (requests get parked = the stall signature), and several
|
|
45
|
+
// PRs may be reviewing on the same credential at once. An explicit value
|
|
46
|
+
// here always wins. See effectiveConcurrency in core/review.ts.
|
|
47
|
+
concurrency: z.number().int().positive().optional(),
|
|
43
48
|
})
|
|
44
|
-
.default({ maxChangedLines: 1000, maxFiles: 20
|
|
49
|
+
.default({ maxChangedLines: 1000, maxFiles: 20 }),
|
|
45
50
|
noise: z
|
|
46
51
|
.object({
|
|
47
52
|
additionalIgnores: z.array(z.string()).default([]),
|