@expo/code-review-cli 0.5.0 → 0.5.2
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 +45 -35
- package/build/cli.js +5 -0
- package/build/commands/doctor.js +25 -0
- package/build/commands/setup-auth.js +217 -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 +29 -2
- package/build/core/schema.js +8 -0
- package/build/core/throttle.js +94 -0
- package/package.json +3 -2
- package/templates/config.jsonc +7 -3
package/README.md
CHANGED
|
@@ -26,7 +26,12 @@ flowchart TD
|
|
|
26
26
|
## Usage
|
|
27
27
|
|
|
28
28
|
Run via `npx @expo/code-review-cli <command>` (or the `ecr` / `expo-code-review`
|
|
29
|
-
binary once installed).
|
|
29
|
+
binary once installed). On a repo that already has `.expo-code-review/` set up,
|
|
30
|
+
getting model credentials for local runs is one command:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx @expo/code-review-cli setup-auth
|
|
34
|
+
```
|
|
30
35
|
|
|
31
36
|
Reviewing a PR (`--pr`/`ci`) needs the GitHub CLI — `brew install gh && gh auth login`.
|
|
32
37
|
Everything else the reviewer needs (including the `opencode` runtime) ships with the
|
|
@@ -34,34 +39,24 @@ package.
|
|
|
34
39
|
|
|
35
40
|
### First-time setup
|
|
36
41
|
|
|
37
|
-
Scaffold, add credentials, verify.
|
|
38
|
-
|
|
39
42
|
```bash
|
|
40
|
-
# Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
|
|
43
|
+
# 1. Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
|
|
41
44
|
npx @expo/code-review-cli init
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
config reviews with GPT via `auth.mode "api-key"`.
|
|
46
|
-
|
|
47
|
-
Create the key in the OpenAI dashboard, scoped to the minimum the reviewer needs:
|
|
48
|
-
|
|
49
|
-
- Put it in a **dedicated project** (not "Default project") so you can set a
|
|
50
|
-
monthly budget + alert on it and see the reviewer's spend in isolation.
|
|
51
|
-
- Make it a **Restricted** key with exactly two permissions, both under *Model
|
|
52
|
-
capabilities*: **Responses (/v1/responses) → Request** and **Chat completions
|
|
53
|
-
(/v1/chat/completions) → Request**. Everything else — including *List models* —
|
|
54
|
-
stays **None** (the reviewer resolves model ids from its own catalog and only
|
|
55
|
-
ever makes inference requests).
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
export OPENAI_API_KEY=sk-proj-...
|
|
59
|
-
# Check env, config, and credentials
|
|
45
|
+
# 2. Get model credentials — guided; prints the export lines for your shell config
|
|
46
|
+
npx @expo/code-review-cli setup-auth
|
|
47
|
+
# 3. Verify env, config, and credentials
|
|
60
48
|
npx @expo/code-review-cli doctor
|
|
61
49
|
```
|
|
62
50
|
|
|
63
|
-
|
|
64
|
-
|
|
51
|
+
`setup-auth` reads the repo's config and walks through each credential it needs:
|
|
52
|
+
an OpenAI **API key** (the scaffolded default — it prints where to create the key
|
|
53
|
+
and the exact restricted permissions to grant), and/or a **ChatGPT/Codex
|
|
54
|
+
subscription** sign-in (it runs OpenCode's browser login and extracts the token
|
|
55
|
+
for you). `doctor` offers to run it whenever a credential is missing.
|
|
56
|
+
|
|
57
|
+
In CI, store the same values as repo secrets (`OPENAI_API_KEY`; plus
|
|
58
|
+
`CODEX_OAUTH_ACCESS_TOKEN` for the mixed setup) — the scaffolded workflow
|
|
59
|
+
forwards them.
|
|
65
60
|
|
|
66
61
|
**Have a ChatGPT Plus/Pro (Codex) subscription? Use both.** The recommended
|
|
67
62
|
production setup pairs the subscription (runs the default models at no marginal
|
|
@@ -115,6 +110,7 @@ is a ready example to adapt.
|
|
|
115
110
|
| `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
|
|
116
111
|
| `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
|
|
117
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. |
|
|
118
114
|
| `ecr review [options]` | Review local changes and print an advisory review (default command). |
|
|
119
115
|
| `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
|
|
120
116
|
| `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
|
|
@@ -348,7 +344,7 @@ coordinator, and per-repo `noise.additionalIgnores`.
|
|
|
348
344
|
{
|
|
349
345
|
"model": "openai/gpt-5.5", // default model for the specialists
|
|
350
346
|
"policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
|
|
351
|
-
"chunk": { "maxChangedLines": 1000, "maxFiles": 20,
|
|
347
|
+
"chunk": { "maxChangedLines": 1000, "maxFiles": 20 }, // concurrency defaults: 6 (API key) / 3 (subscription)
|
|
352
348
|
"noise": { "additionalIgnores": ["packages/*/build/**"] },
|
|
353
349
|
"review": { "trigger": "all", // which PRs `ecr ci` reviews: "all"
|
|
354
350
|
"label": "ai-review", // (default, except ai-review:skip) or
|
|
@@ -410,6 +406,15 @@ change which model reviewed your code. Use an explicit override instead.
|
|
|
410
406
|
session, inside the same budget — instead of spending the whole cap on a dead
|
|
411
407
|
request. Progress lines say how long a reply has been silent, so this is legible in
|
|
412
408
|
the CI log.
|
|
409
|
+
- **Rate limits are detected and waited out, not fought.** The reviewer watches the
|
|
410
|
+
OpenCode server's own log for provider 429s (hard evidence, per run). A stall
|
|
411
|
+
*with* recent 429 evidence is throttling, not a wedge — the pass waits in 90s
|
|
412
|
+
beats (without consuming its one retry) instead of re-sending its whole context
|
|
413
|
+
into a limited account; explicit 429 errors retry on a slow 15s/45s/90s schedule.
|
|
414
|
+
Subscription (oauth) runs also default to `concurrency` 3 instead of 6, since one
|
|
415
|
+
account may be serving several PRs' reviews at once. Rate-limit events are
|
|
416
|
+
reported in the job log and the run log (`rateLimitEvents`), so throttling is a
|
|
417
|
+
visible fact about a run, never a mystery slowdown.
|
|
413
418
|
- **Soft landing on timeout** — at either cap, the run is interrupted and the agent
|
|
414
419
|
is asked to return the findings it already has, rather than discarding its work.
|
|
415
420
|
Tools are disabled for that request, so the salvage step can't resume investigating
|
|
@@ -494,7 +499,7 @@ set in `config.auth` (credentials come from OpenCode):
|
|
|
494
499
|
|
|
495
500
|
```jsonc
|
|
496
501
|
"auth": { "providers": {
|
|
497
|
-
"openai": { "mode": "oauth", "tokenEnv": "
|
|
502
|
+
"openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" },
|
|
498
503
|
"openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
|
|
499
504
|
} }
|
|
500
505
|
```
|
|
@@ -503,17 +508,22 @@ set in `config.auth` (credentials come from OpenCode):
|
|
|
503
508
|
(`upstream` names the SDK it's backed by): agents reference `openai-api/gpt-5.5-pro`
|
|
504
509
|
in frontmatter while everything else stays on `openai/gpt-5.5`. Notes:
|
|
505
510
|
|
|
506
|
-
- **The oauth `tokenEnv` holds the
|
|
507
|
-
ChatGPT sign-in (
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
511
|
+
- **The oauth `tokenEnv` holds the ACCESS token** from an `opencode auth login`
|
|
512
|
+
ChatGPT sign-in (`ecr setup-auth` extracts it) — a plain bearer, valid for
|
|
513
|
+
days, with no rotation involvement. Do **not** use the refresh token as a
|
|
514
|
+
shared secret: refresh tokens are single-use (rotation), so a static copy is
|
|
515
|
+
spent by its first use and the sign-in dies with it. Access tokens expire
|
|
516
|
+
(~10 days observed), so CI secrets need periodic re-minting — see the
|
|
517
|
+
token-rotator item in the [roadmap](./ROADMAP.md); `doctor` and the run
|
|
518
|
+
preflight warn before expiry.
|
|
519
|
+
- **The API key needs exactly two permissions** — a *Restricted* key with
|
|
520
|
+
*Model capabilities*: **Responses → Request** and **Chat completions →
|
|
521
|
+
Request**; everything else (including *List models*) stays None. Create it
|
|
522
|
+
in a dedicated, budget-capped project. (`ecr setup-auth` prints these
|
|
523
|
+
instructions too.)
|
|
514
524
|
- **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
|
|
515
525
|
comma-separated set of both env names
|
|
516
|
-
(`
|
|
526
|
+
(`CODEX_OAUTH_ACCESS_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
|
|
517
527
|
workflow.
|
|
518
528
|
- **Auditability**: every pass logs which provider/model answered it (job log,
|
|
519
529
|
step summary, run log), so the subscription/API split is visible per run.
|
package/build/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dismissCommand } from "./commands/dismiss.js";
|
|
|
4
4
|
import { doctorCommand } from "./commands/doctor.js";
|
|
5
5
|
import { initCommand } from "./commands/init.js";
|
|
6
6
|
import { reviewCommand } from "./commands/review.js";
|
|
7
|
+
import { setupAuthCommand } from "./commands/setup-auth.js";
|
|
7
8
|
import { verifyConfigCommand } from "./commands/verify-config.js";
|
|
8
9
|
const USAGE = `expo-code-review (ecr) — config-driven AI code reviewer
|
|
9
10
|
|
|
@@ -13,6 +14,7 @@ Usage:
|
|
|
13
14
|
ecr dismiss --pr <n> <id...> Hide a finding on a PR (see \`ecr dismiss --help\`).
|
|
14
15
|
ecr undismiss --pr <n> <id...> Restore a dismissed finding.
|
|
15
16
|
ecr init [--monorepo] [--scope <dir>] Scaffold .expo-code-review/ in this repo.
|
|
17
|
+
ecr setup-auth [--yes] Walk through getting model credentials for local runs.
|
|
16
18
|
ecr doctor [--list-scopes] Check environment, config, credentials, and scopes.
|
|
17
19
|
ecr verify-config [--expected <env>] [--json] Refuse to run if a config could redirect the credential (CI guard).
|
|
18
20
|
|
|
@@ -47,6 +49,9 @@ async function main() {
|
|
|
47
49
|
case "init":
|
|
48
50
|
await initCommand(rest);
|
|
49
51
|
break;
|
|
52
|
+
case "setup-auth":
|
|
53
|
+
await setupAuthCommand(rest);
|
|
54
|
+
break;
|
|
50
55
|
case "doctor":
|
|
51
56
|
await doctorCommand(rest);
|
|
52
57
|
break;
|
package/build/commands/doctor.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, resolveConfigDir, tokenEnvMismatch, } from "../config/load.js";
|
|
2
2
|
import { loadRoutingManifest, resolveScopes, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import { setupAuthCommand } from "./setup-auth.js";
|
|
3
5
|
import { checkProviderAuth } from "../core/auth.js";
|
|
4
6
|
import { opencodeBinSource } from "../core/opencode.js";
|
|
5
7
|
import { git, onPath, repoRoot, run } from "../core/exec.js";
|
|
@@ -126,6 +128,29 @@ export async function doctorCommand(argv = []) {
|
|
|
126
128
|
if (readiness.warning) {
|
|
127
129
|
warn(`auth: ${readiness.warning}`);
|
|
128
130
|
}
|
|
131
|
+
// A missing credential has a guided fix — offer it right here when someone is
|
|
132
|
+
// at the terminal, rather than making them find the command in the README.
|
|
133
|
+
if (!readiness.ok) {
|
|
134
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
135
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
136
|
+
let runIt = false;
|
|
137
|
+
try {
|
|
138
|
+
const answer = (await rl.question(" Run `ecr setup-auth` to fix this now? [Y/n] "))
|
|
139
|
+
.trim()
|
|
140
|
+
.toLowerCase();
|
|
141
|
+
runIt = answer === "" || answer === "y" || answer === "yes";
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
rl.close();
|
|
145
|
+
}
|
|
146
|
+
if (runIt) {
|
|
147
|
+
await setupAuthCommand([]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
info("run `ecr setup-auth` for a guided credential setup");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
129
154
|
}
|
|
130
155
|
catch (error) {
|
|
131
156
|
line(false, `config invalid: ${errorMessage(error)}`);
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline/promises";
|
|
6
|
+
import { hasConfig, loadReviewConfig } from "../config/load.js";
|
|
7
|
+
import { jwtExpiryMs } from "../core/auth.js";
|
|
8
|
+
import { opencodeBinSource } from "../core/opencode.js";
|
|
9
|
+
import { errorMessage } from "../core/util.js";
|
|
10
|
+
const USAGE = `ecr setup-auth — set up model credentials for local runs
|
|
11
|
+
|
|
12
|
+
Reads this repo's .expo-code-review/config.jsonc auth entries and walks through
|
|
13
|
+
getting each credential:
|
|
14
|
+
• a ChatGPT/Codex subscription (oauth/openai): runs the bundled
|
|
15
|
+
\`opencode auth login\` (interactive; opens your browser), then prints the
|
|
16
|
+
\`export <tokenEnv>=…\` line to add to your shell config. An existing
|
|
17
|
+
OpenCode ChatGPT sign-in is reused instead of re-authenticating.
|
|
18
|
+
• an API key (api-key entries): prints where to create the key, the exact
|
|
19
|
+
permissions it needs, and the export line to fill in.
|
|
20
|
+
|
|
21
|
+
Without a repo config, it offers the recommended ChatGPT/Codex subscription flow
|
|
22
|
+
with the default env name.
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--yes Skip confirmation prompts (still interactive during the login itself).
|
|
26
|
+
`;
|
|
27
|
+
export function planFromAuth(auth) {
|
|
28
|
+
const plan = { manualKeys: [], unsupported: [] };
|
|
29
|
+
for (const entry of auth) {
|
|
30
|
+
if (entry.mode === "oauth" && entry.provider === "openai" && entry.tokenEnv) {
|
|
31
|
+
plan.chatgptLogin = { tokenEnv: entry.tokenEnv };
|
|
32
|
+
}
|
|
33
|
+
else if (entry.mode === "api-key" && entry.tokenEnv) {
|
|
34
|
+
plan.manualKeys.push({
|
|
35
|
+
provider: entry.provider,
|
|
36
|
+
tokenEnv: entry.tokenEnv,
|
|
37
|
+
upstream: entry.upstream,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
else if (entry.mode === "oauth") {
|
|
41
|
+
plan.unsupported.push(entry);
|
|
42
|
+
}
|
|
43
|
+
// api-key without tokenEnv relies on OpenCode's own login — nothing to set up.
|
|
44
|
+
}
|
|
45
|
+
return plan;
|
|
46
|
+
}
|
|
47
|
+
/** Where OpenCode's own (non-isolated) auth.json lives. */
|
|
48
|
+
export function opencodeAuthJsonPath(env = process.env) {
|
|
49
|
+
const dataHome = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
|
|
50
|
+
return path.join(dataHome, "opencode", "auth.json");
|
|
51
|
+
}
|
|
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() {
|
|
61
|
+
try {
|
|
62
|
+
const raw = await readFile(opencodeAuthJsonPath(), "utf8");
|
|
63
|
+
const parsed = JSON.parse(raw);
|
|
64
|
+
const openai = parsed.openai;
|
|
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;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function confirm(question, skip) {
|
|
77
|
+
if (skip) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
81
|
+
try {
|
|
82
|
+
const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
|
|
83
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
rl.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** The line to paste into a shell config. Single-quoted: tokens never contain '. */
|
|
90
|
+
export function exportLine(tokenEnv, value) {
|
|
91
|
+
return `export ${tokenEnv}='${value}'`;
|
|
92
|
+
}
|
|
93
|
+
export async function setupAuthCommand(argv = []) {
|
|
94
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
95
|
+
process.stdout.write(USAGE);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const yes = argv.includes("--yes");
|
|
99
|
+
const out = (line = "") => process.stdout.write(`${line}\n`);
|
|
100
|
+
const err = (line = "") => process.stderr.write(`${line}\n`);
|
|
101
|
+
try {
|
|
102
|
+
// Plan from the repo config when there is one; otherwise offer the
|
|
103
|
+
// recommended subscription flow with the default env name.
|
|
104
|
+
let plan;
|
|
105
|
+
if (hasConfig(process.cwd())) {
|
|
106
|
+
const config = await loadReviewConfig(process.cwd());
|
|
107
|
+
plan = planFromAuth(config.auth);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
err("No .expo-code-review config here — setting up the default ChatGPT/Codex flow.");
|
|
111
|
+
plan = planFromAuth([
|
|
112
|
+
{ provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_ACCESS_TOKEN" },
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
if (!plan.chatgptLogin && plan.manualKeys.length === 0 && plan.unsupported.length === 0) {
|
|
116
|
+
out("This repo's auth config needs no local credential setup (OpenCode's own login covers it).");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const exports = [];
|
|
120
|
+
if (plan.chatgptLogin) {
|
|
121
|
+
const { tokenEnv } = plan.chatgptLogin;
|
|
122
|
+
if (process.env[tokenEnv]) {
|
|
123
|
+
err(`✓ ${tokenEnv} is already set in this shell — skipping the ChatGPT sign-in.`);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
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;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!stored) {
|
|
135
|
+
err("This will run the bundled `opencode auth login` (interactive).");
|
|
136
|
+
err("When it prompts:");
|
|
137
|
+
err(" 1. select the provider: OpenAI");
|
|
138
|
+
err(" 2. select the method: Sign in with ChatGPT (Codex subscription)");
|
|
139
|
+
err(" 3. your browser opens — sign in and authorize.");
|
|
140
|
+
if (!(await confirm("Run it now?", yes))) {
|
|
141
|
+
err("Skipped the ChatGPT sign-in.");
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
const binDir = opencodeBinSource().dir;
|
|
145
|
+
const opencode = binDir ? path.join(binDir, "opencode") : "opencode";
|
|
146
|
+
const result = spawnSync(opencode, ["auth", "login"], { stdio: "inherit" });
|
|
147
|
+
if (result.status !== 0) {
|
|
148
|
+
throw new Error(`\`opencode auth login\` exited with ${result.status ?? "a signal"}; nothing was changed.`);
|
|
149
|
+
}
|
|
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 " +
|
|
153
|
+
'OpenAI → "Sign in with ChatGPT"? Re-run `ecr setup-auth` to try again.');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
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).`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const key of plan.manualKeys) {
|
|
168
|
+
if (process.env[key.tokenEnv]) {
|
|
169
|
+
err(`✓ ${key.tokenEnv} is already set in this shell — skipping.`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const upstream = key.upstream ?? key.provider;
|
|
173
|
+
err("");
|
|
174
|
+
err(`${key.tokenEnv} (${key.provider}) is an API key — create it by hand:`);
|
|
175
|
+
if (upstream === "openai") {
|
|
176
|
+
err(" https://platform.openai.com/api-keys — in a dedicated project (set a");
|
|
177
|
+
err(" monthly budget), as a RESTRICTED key with exactly two permissions, both");
|
|
178
|
+
err(" under Model capabilities: Responses → Request, Chat completions → Request.");
|
|
179
|
+
err(" Everything else (including List models) stays None.");
|
|
180
|
+
}
|
|
181
|
+
else if (upstream === "anthropic") {
|
|
182
|
+
err(" https://console.anthropic.com/settings/keys — a workspace-scoped key");
|
|
183
|
+
err(" with a spend limit is all the reviewer needs.");
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
err(` mint a key for the "${upstream}" provider.`);
|
|
187
|
+
}
|
|
188
|
+
exports.push(exportLine(key.tokenEnv, "<paste the key here>"));
|
|
189
|
+
}
|
|
190
|
+
for (const entry of plan.unsupported) {
|
|
191
|
+
err("");
|
|
192
|
+
err(`auth for "${entry.provider}" is mode "oauth", which has no automated setup flow here` +
|
|
193
|
+
(entry.provider === "anthropic"
|
|
194
|
+
? " — and cannot work: Anthropic prohibits subscription tokens in third-party tools. Use an API key instead."
|
|
195
|
+
: `. Set ${entry.tokenEnv ?? "its token env"} manually.`));
|
|
196
|
+
}
|
|
197
|
+
if (exports.length > 0) {
|
|
198
|
+
const rc = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "your shell config";
|
|
199
|
+
err("");
|
|
200
|
+
err(`Add ${exports.length === 1 ? "this line" : "these lines"} to ${rc}:`);
|
|
201
|
+
out("");
|
|
202
|
+
for (const line of exports) {
|
|
203
|
+
out(` ${line}`);
|
|
204
|
+
}
|
|
205
|
+
out("");
|
|
206
|
+
err(`Then restart your shell (or \`source ${rc}\`) and run \`ecr doctor\` to verify.`);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
err("");
|
|
210
|
+
err("Nothing to add — run `ecr doctor` to verify your setup.");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
err(`setup-auth failed: ${errorMessage(error)}`);
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
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([]),
|
package/build/core/auth.js
CHANGED
|
@@ -74,6 +74,31 @@ export function checkOauthTokenShape(provider, token, tokenEnv) {
|
|
|
74
74
|
detail: `${tokenEnv} holds only ${token.length} characters, too short to be a real ${provider} token — it looks truncated. ${fix}`,
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
+
// A ChatGPT access token carries its own expiry — check it up front, so a
|
|
78
|
+
// lapsed credential is one clear message instead of N failed passes, and a
|
|
79
|
+
// nearly-lapsed one warns before it bites mid-run.
|
|
80
|
+
if (provider === "openai" && isJwtAccessToken(token)) {
|
|
81
|
+
const expires = jwtExpiryMs(token);
|
|
82
|
+
if (expires !== null) {
|
|
83
|
+
const remainingMs = expires - Date.now();
|
|
84
|
+
if (remainingMs <= 0) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
detail: `${tokenEnv} holds a ChatGPT access token that EXPIRED ${Math.ceil(-remainingMs / 86_400_000)} day(s) ago. ` +
|
|
88
|
+
`Mint a fresh one (\`ecr setup-auth\`, or your token-rotator job) and update ${tokenEnv}.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (remainingMs < 3 * 86_400_000) {
|
|
92
|
+
return {
|
|
93
|
+
...ok,
|
|
94
|
+
detail: `oauth for ${provider}; token env ${tokenEnv} is set`,
|
|
95
|
+
warning: `${tokenEnv}'s ChatGPT access token expires in ${Math.max(1, Math.round(remainingMs / 3_600_000))}h — ` +
|
|
96
|
+
`re-mint it soon (\`ecr setup-auth\`, or your token-rotator job).`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { ...ok, detail: `oauth for ${provider}; token env ${tokenEnv} is set` };
|
|
101
|
+
}
|
|
77
102
|
// Only anthropic's formats are known well enough to say anything about.
|
|
78
103
|
if (provider !== "anthropic") {
|
|
79
104
|
return ok;
|
|
@@ -241,20 +266,45 @@ export function checkProviderAuth(config, env = process.env) {
|
|
|
241
266
|
...(warnings.length > 0 ? { warning: warnings.join("; ") } : {}),
|
|
242
267
|
};
|
|
243
268
|
}
|
|
269
|
+
/** A ChatGPT access token is a JWT (three base64url segments); refresh tokens are opaque. */
|
|
270
|
+
export function isJwtAccessToken(token) {
|
|
271
|
+
return token.startsWith("eyJ") && token.split(".").length === 3;
|
|
272
|
+
}
|
|
273
|
+
/** A JWT's `exp` claim as epoch ms, decoded (not verified) — null when unreadable. */
|
|
274
|
+
export function jwtExpiryMs(token) {
|
|
275
|
+
const payload = token.split(".")[1];
|
|
276
|
+
if (!payload) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
281
|
+
return typeof claims.exp === "number" ? claims.exp * 1000 : null;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
244
287
|
/**
|
|
245
|
-
* The auth.json entry for one oauth credential.
|
|
246
|
-
* -
|
|
247
|
-
*
|
|
248
|
-
*
|
|
288
|
+
* The auth.json entry for one oauth credential. Shaped by what the token IS:
|
|
289
|
+
* - a JWT (an ACCESS token, e.g. from `ecr setup-auth` or a rotator job): use it
|
|
290
|
+
* as-is and never refresh — refresh tokens are SINGLE-USE (rotation), so a
|
|
291
|
+
* static/shared secret must not participate in rotation at all. Expiry comes
|
|
292
|
+
* from the JWT's own `exp` claim so OpenCode trusts it exactly as long as it
|
|
293
|
+
* is valid.
|
|
294
|
+
* - an opaque openai token (a REFRESH token): store it with `expires: 0` and let
|
|
295
|
+
* OpenCode's codex plugin mint the access token. Only safe when this run is
|
|
296
|
+
* the token's SOLE consumer — a value shared across runs/repos dies on first
|
|
297
|
+
* rotation (learned the hard way).
|
|
249
298
|
* - everything else: the token IS the access credential (e.g. long-lived
|
|
250
299
|
* setup-token style bearers), far-future expiry so OpenCode never tries to
|
|
251
300
|
* refresh a credential that has no refresh half.
|
|
252
301
|
*/
|
|
253
302
|
export function oauthAuthJsonEntry(provider, token) {
|
|
254
|
-
if (provider === "openai") {
|
|
303
|
+
if (provider === "openai" && !isJwtAccessToken(token)) {
|
|
255
304
|
return { type: "oauth", access: "", refresh: token, expires: 0 };
|
|
256
305
|
}
|
|
257
|
-
|
|
306
|
+
const expires = isJwtAccessToken(token) ? jwtExpiryMs(token) : null;
|
|
307
|
+
return { type: "oauth", access: token, refresh: "", expires: expires ?? Date.now() + YEAR_MS };
|
|
258
308
|
}
|
|
259
309
|
/**
|
|
260
310
|
* Prepare model credentials for the OpenCode server from the repo's auth entries
|
package/build/core/opencode.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createOpencode } from "@opencode-ai/sdk";
|
|
4
|
+
import { RateLimitWatch } from "./throttle.js";
|
|
4
5
|
import { toolMap } from "./tools.js";
|
|
5
6
|
import { errorMessage, sleep } from "./util.js";
|
|
6
7
|
/** Sum token usage across attempts (for per-task/run totals). */
|
|
@@ -175,7 +176,9 @@ export async function startOpencode(config) {
|
|
|
175
176
|
port: 0,
|
|
176
177
|
config: config,
|
|
177
178
|
});
|
|
178
|
-
|
|
179
|
+
// Watch THIS server's log for provider throttle evidence (prepareAuth has already
|
|
180
|
+
// pointed XDG_DATA_HOME at the run's isolated dir when auth is injected).
|
|
181
|
+
return { client, url: server.url, close: () => server.close(), rateLimit: new RateLimitWatch() };
|
|
179
182
|
}
|
|
180
183
|
/** `provider/model` as the server reported it, or undefined if it reported neither. */
|
|
181
184
|
export function formatModel(providerID, modelID) {
|
|
@@ -357,17 +360,26 @@ const FINALIZE_STALL_MS = 60 * 1000;
|
|
|
357
360
|
// Breathing room before the retry: if the silence came from provider-side throttling
|
|
358
361
|
// or backoff, reconnecting instantly is the worst move.
|
|
359
362
|
const STALL_RETRY_BACKOFF_MS = 20 * 1000;
|
|
363
|
+
// When the account is provably rate-limited, wait in longer beats: re-sending the
|
|
364
|
+
// pass's whole context into a throttled account only deepens the limit. Several
|
|
365
|
+
// waits fit inside a pass budget, and each is long enough for a limit window to move.
|
|
366
|
+
const RATE_LIMIT_WAIT_MS = 90 * 1000;
|
|
360
367
|
// Only retry when enough of the pass's budget remains for the fresh attempt to
|
|
361
368
|
// plausibly finish; otherwise go straight to the soft landing.
|
|
362
369
|
const STALL_RETRY_MIN_REMAINING_MS = STALL_MS + 60 * 1000;
|
|
363
370
|
/**
|
|
364
|
-
* What to do about a stalled attempt:
|
|
365
|
-
*
|
|
366
|
-
*
|
|
371
|
+
* What to do about a stalled attempt: WAIT (the account is provably rate-limited —
|
|
372
|
+
* see core/throttle.ts — so patience beats re-sending the context; waits don't
|
|
373
|
+
* consume the one retry), start over from a clean session, or stop and salvage
|
|
374
|
+
* findings. Exactly ONE wedged retry, and only with enough budget left for it to
|
|
375
|
+
* land — a second wedged attempt would just spend the rest of the pass's window,
|
|
367
376
|
* which is the failure this whole mechanism exists to end. Exported for tests.
|
|
368
377
|
*/
|
|
369
|
-
export function stallAction(
|
|
370
|
-
|
|
378
|
+
export function stallAction(wedgedRetries, remainingMs, rateLimited = false) {
|
|
379
|
+
if (rateLimited && remainingMs > STALL_RETRY_MIN_REMAINING_MS) {
|
|
380
|
+
return "wait";
|
|
381
|
+
}
|
|
382
|
+
return wedgedRetries === 0 && remainingMs > STALL_RETRY_MIN_REMAINING_MS ? "retry" : "soft-land";
|
|
371
383
|
}
|
|
372
384
|
const FINALIZE_PROMPT = "You have reached your time budget. STOP investigating now — do NOT read, grep, " +
|
|
373
385
|
"glob, list, or open any more files, and do not call any tools. Based ONLY on " +
|
|
@@ -551,6 +563,7 @@ export async function promptAgent(handle, args) {
|
|
|
551
563
|
throw finalizeError;
|
|
552
564
|
}
|
|
553
565
|
};
|
|
566
|
+
let wedgedRetries = 0;
|
|
554
567
|
for (let attempt = 0;; attempt++) {
|
|
555
568
|
const session = unwrap(await handle.client.session.create({
|
|
556
569
|
body: { title: attempt === 0 ? args.title : `${args.title}-retry${attempt}` },
|
|
@@ -578,11 +591,27 @@ export async function promptAgent(handle, args) {
|
|
|
578
591
|
// wedged request to answer. Exactly one retry, and only when enough budget
|
|
579
592
|
// remains for it to land; after that the finalize is still worth a try as the
|
|
580
593
|
// only remaining salvage (in eas-cli#4084 the session did respond once aborted).
|
|
594
|
+
//
|
|
595
|
+
// EXCEPT when the server log shows the account is rate-limited: then the
|
|
596
|
+
// silence is throttling, not a wedge, and the patient move is to wait —
|
|
597
|
+
// re-sending the pass's whole context would deepen the limit. Waits repeat
|
|
598
|
+
// (never consuming the one wedged retry) until the evidence goes stale or
|
|
599
|
+
// the pass runs out of room, both bounded by the pass deadline.
|
|
581
600
|
if (error instanceof NoProgress) {
|
|
582
601
|
await abortQuietly(handle, session.id);
|
|
583
602
|
absorb(error);
|
|
603
|
+
await handle.rateLimit.check();
|
|
584
604
|
const remaining = deadline - Date.now();
|
|
585
|
-
|
|
605
|
+
const action = stallAction(wedgedRetries, remaining, handle.rateLimit.recentlyLimited());
|
|
606
|
+
if (action === "wait") {
|
|
607
|
+
args.onActivity?.(`provider is rate-limiting this account (429 in the server log; ` +
|
|
608
|
+
`${handle.rateLimit.events} so far) — waiting ${Math.round(RATE_LIMIT_WAIT_MS / 1000)}s ` +
|
|
609
|
+
`instead of retrying (${Math.round(remaining / 60000)}m of budget left)`);
|
|
610
|
+
await sleep(RATE_LIMIT_WAIT_MS);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (action === "retry") {
|
|
614
|
+
wedgedRetries++;
|
|
586
615
|
args.onActivity?.(`stalled — no output for ${Math.round(error.idleMs / 1000)}s; ` +
|
|
587
616
|
`retrying once from a clean session (${Math.round(remaining / 60000)}m of budget left)`);
|
|
588
617
|
await sleep(STALL_RETRY_BACKOFF_MS);
|
|
@@ -606,6 +635,18 @@ const CORRECTIVE = "\n\nIMPORTANT: your previous reply could not be parsed. Repl
|
|
|
606
635
|
const CORRECTIVE_WAIT_MS = 2 * 60 * 1000;
|
|
607
636
|
/** Backoff (ms) before the 2nd and 3rd attempt of a transient-failing model call. */
|
|
608
637
|
const TRANSIENT_BACKOFF_MS = [2_000, 8_000];
|
|
638
|
+
/**
|
|
639
|
+
* Rate limits need patience, not persistence: a limited account stays limited for
|
|
640
|
+
* tens of seconds to minutes, so the 2s/8s schedule just burns the retries. Shared
|
|
641
|
+
* subscription credentials (several PRs reviewing at once) make this the common
|
|
642
|
+
* transient, hence the dedicated, slower schedule.
|
|
643
|
+
*/
|
|
644
|
+
const RATE_LIMIT_BACKOFF_MS = [15_000, 45_000, 90_000];
|
|
645
|
+
const RATE_LIMIT_ERROR = /\b429\b|rate.?limit|too many requests/i;
|
|
646
|
+
/** A transient error that is specifically a provider rate limit. */
|
|
647
|
+
export function isRateLimitError(error) {
|
|
648
|
+
return !(error instanceof AgentTimeoutError) && RATE_LIMIT_ERROR.test(errorMessage(error));
|
|
649
|
+
}
|
|
609
650
|
/**
|
|
610
651
|
* A transient, retryable API failure — a one-off rate-limit (429), server error
|
|
611
652
|
* (5xx), or network blip — as opposed to a timeout (which means "abandon", see
|
|
@@ -649,11 +690,13 @@ async function withTransientRetry(label, onActivity, fn) {
|
|
|
649
690
|
return await fn();
|
|
650
691
|
}
|
|
651
692
|
catch (error) {
|
|
652
|
-
|
|
693
|
+
// Rate limits get the slower, longer schedule — see RATE_LIMIT_BACKOFF_MS.
|
|
694
|
+
const schedule = isRateLimitError(error) ? RATE_LIMIT_BACKOFF_MS : TRANSIENT_BACKOFF_MS;
|
|
695
|
+
const waitMs = schedule[attempt];
|
|
653
696
|
if (waitMs === undefined || !isTransientApiError(error)) {
|
|
654
697
|
throw error;
|
|
655
698
|
}
|
|
656
|
-
onActivity?.(`${label}: transient API error (${errorMessage(error)}); retry ${attempt + 1}/${
|
|
699
|
+
onActivity?.(`${label}: transient API error (${errorMessage(error)}); retry ${attempt + 1}/${schedule.length} in ${Math.round(waitMs / 1000)}s`);
|
|
657
700
|
await sleep(waitMs);
|
|
658
701
|
}
|
|
659
702
|
}
|
package/build/core/render.js
CHANGED
|
@@ -108,7 +108,7 @@ export function renderMarkdown(review, tag, dismissed = [], link) {
|
|
|
108
108
|
const kept = withFp.filter(({ fp }) => !dismissedByFp.has(fp));
|
|
109
109
|
const dropped = withFp.filter(({ fp }) => dismissedByFp.has(fp));
|
|
110
110
|
const lines = [commentMarker(tag), "## 🤖 AI code review", ""];
|
|
111
|
-
lines.push(`**Decision:** ${decisionLabel(review.decision)}`, "", review.summary, "");
|
|
111
|
+
lines.push(`**Decision:** ${review.couldNotComplete ? "No review — every pass failed" : decisionLabel(review.decision)}`, "", review.summary, "");
|
|
112
112
|
if (review.incomplete.length > 0) {
|
|
113
113
|
lines.push("> ⏱️ **Coverage note:** coverage is partial — some review passes did not", "> finish (timed out or failed), so issues may exist in areas not fully reviewed:", ...review.incomplete.map((note) => `> - ${note}`), "");
|
|
114
114
|
}
|
|
@@ -230,7 +230,7 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
|
|
|
230
230
|
"| --- | --- | --- |",
|
|
231
231
|
];
|
|
232
232
|
for (const { result, kept } of perScope) {
|
|
233
|
-
lines.push(`| ${result.scope} | ${decisionLabel(result.review.decision)} | ${kept.length} |`);
|
|
233
|
+
lines.push(`| ${result.scope} | ${result.review.couldNotComplete ? "No review — every pass failed" : decisionLabel(result.review.decision)} | ${kept.length} |`);
|
|
234
234
|
}
|
|
235
235
|
lines.push("");
|
|
236
236
|
const anyIncomplete = results.some((result) => result.review.incomplete.length > 0);
|
package/build/core/review.js
CHANGED
|
@@ -32,6 +32,20 @@ function makeRunId() {
|
|
|
32
32
|
* → coordinate → apply policy. Returns a CoordinatorOutput; the CLI commands are
|
|
33
33
|
* thin wrappers that supply a Source and render the result.
|
|
34
34
|
*/
|
|
35
|
+
/**
|
|
36
|
+
* Max concurrent reviewer calls: an explicit config value wins; otherwise 3 when a
|
|
37
|
+
* subscription (oauth) credential is configured, else 6. One ChatGPT account
|
|
38
|
+
* handles six parallel streams poorly — requests get parked server-side (the
|
|
39
|
+
* stall signature seen on eas-cli#4084), and several PRs may be reviewing on the
|
|
40
|
+
* same credential at once — so subscription runs trade a little wall-clock for a
|
|
41
|
+
* lot of reliability. Exported for tests.
|
|
42
|
+
*/
|
|
43
|
+
export function effectiveConcurrency(config) {
|
|
44
|
+
if (config.chunk.concurrency) {
|
|
45
|
+
return config.chunk.concurrency;
|
|
46
|
+
}
|
|
47
|
+
return config.auth.some((entry) => entry.mode === "oauth") ? 3 : 6;
|
|
48
|
+
}
|
|
35
49
|
export async function runReview(source, options) {
|
|
36
50
|
const { config } = options;
|
|
37
51
|
const started = Date.now();
|
|
@@ -177,9 +191,10 @@ export async function runReview(source, options) {
|
|
|
177
191
|
const chunks = chunkByLines(workspace.files, config.chunk.maxChangedLines, config.chunk.maxFiles);
|
|
178
192
|
// Only chunk (and add a cross-cutting pass) when the diff exceeds one chunk.
|
|
179
193
|
const chunked = chunks.length > 1;
|
|
194
|
+
const concurrency = effectiveConcurrency(config);
|
|
180
195
|
progress(`Running ${selectedAgents.length} reviewer(s) [${selectedAgents.map((a) => a.id).join(", ")}] over ${chunks.length} chunk(s)` +
|
|
181
196
|
`${chunked ? " + cross-cutting pass" : ""} ` +
|
|
182
|
-
`(${kept.length} files, concurrency ${
|
|
197
|
+
`(${kept.length} files, concurrency ${concurrency})…`);
|
|
183
198
|
for (const agent of selectedAgents) {
|
|
184
199
|
agentFindings[agent.id] = [];
|
|
185
200
|
agentCosts[agent.id] = 0;
|
|
@@ -313,7 +328,7 @@ export async function runReview(source, options) {
|
|
|
313
328
|
// TIMEOUT, instead of dropping the work we break it into units that converge:
|
|
314
329
|
// subdivide the chunk, then a fast no-tools pass, and only report a coverage gap
|
|
315
330
|
// when even that can't finish inside the budget — so dropped work is never silent.
|
|
316
|
-
await runGrowableQueue(tasks,
|
|
331
|
+
await runGrowableQueue(tasks, concurrency, async (task, enqueue) => {
|
|
317
332
|
const minutes = Math.round(task.maxWaitMs / 60000);
|
|
318
333
|
try {
|
|
319
334
|
const { value, cost, truncated, tokens, model } = await promptAndParse(handle, {
|
|
@@ -443,6 +458,9 @@ export async function runReview(source, options) {
|
|
|
443
458
|
summary: "⚠️ The AI review could not complete: every review pass failed or timed out, " +
|
|
444
459
|
'so these changes were effectively NOT reviewed. Treat this as "no review", not "looks good".',
|
|
445
460
|
incomplete: coverageNotes,
|
|
461
|
+
// Presentation override: without it the comment header reads "Decision:
|
|
462
|
+
// Approve with comments" over a review that reviewed nothing (euxy#8).
|
|
463
|
+
couldNotComplete: true,
|
|
446
464
|
};
|
|
447
465
|
}
|
|
448
466
|
else {
|
|
@@ -515,6 +533,14 @@ export async function runReview(source, options) {
|
|
|
515
533
|
if (removedAfterChecks > 0) {
|
|
516
534
|
output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
|
|
517
535
|
}
|
|
536
|
+
// Surface provider throttling as a fact about the run: passes already waited or
|
|
537
|
+
// backed off, but the operator should still SEE that it happened (a run that
|
|
538
|
+
// was rate-limited is slower and may carry partial passes — that's the cause).
|
|
539
|
+
await handle.rateLimit.check();
|
|
540
|
+
if (handle.rateLimit.events > 0) {
|
|
541
|
+
progress(` ⚠ provider rate-limited this run ${handle.rateLimit.events} time(s) ` +
|
|
542
|
+
`(429s in the OpenCode server log) — passes waited it out rather than failing`);
|
|
543
|
+
}
|
|
518
544
|
// Every pass says which model actually answered it — in the job log, the step
|
|
519
545
|
// summary table, and the run log — so a wrong or substituted model is always
|
|
520
546
|
// visible, not just when the substitution warning fires.
|
|
@@ -535,6 +561,7 @@ export async function runReview(source, options) {
|
|
|
535
561
|
agentFindings,
|
|
536
562
|
coverageNotes,
|
|
537
563
|
verifierDropped,
|
|
564
|
+
...(handle.rateLimit.events > 0 ? { rateLimitEvents: handle.rateLimit.events } : {}),
|
|
538
565
|
durationMs: Date.now() - started,
|
|
539
566
|
decision: output.decision,
|
|
540
567
|
findingCount: output.findings.length,
|
package/build/core/schema.js
CHANGED
|
@@ -46,6 +46,14 @@ export const CoordinatorOutputSchema = z.object({
|
|
|
46
46
|
* cut-short review is never presented as complete.
|
|
47
47
|
*/
|
|
48
48
|
incomplete: z.array(z.string()).default([]),
|
|
49
|
+
/**
|
|
50
|
+
* True when EVERY pass failed — nothing was actually reviewed. Set by the
|
|
51
|
+
* engine, never the model. Reporters must not render an approving decision
|
|
52
|
+
* label for such a run (the decision enum has no "no review" member, and
|
|
53
|
+
* widening it would ripple through dismiss state and exit codes — this flag
|
|
54
|
+
* overrides the presentation instead).
|
|
55
|
+
*/
|
|
56
|
+
couldNotComplete: z.boolean().optional(),
|
|
49
57
|
});
|
|
50
58
|
/** Minimum normalized evidence length to key a fingerprint on the code (below
|
|
51
59
|
* this we fall back to the title). */
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Rate-limit evidence, read from the OpenCode server's own log.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: provider throttling reaches us in two shapes. An EXPLICIT
|
|
8
|
+
* failure (HTTP 429 / "rate limit" stream error) surfaces in OpenCode's log as a
|
|
9
|
+
* structured ERROR line — that is proof. A SILENT one (the request is accepted
|
|
10
|
+
* and parked server-side) produces no error anywhere and is indistinguishable
|
|
11
|
+
* from a wedged request from the outside. So: the log watcher turns the explicit
|
|
12
|
+
* case into a hard signal, and the stall path treats "stall + recent explicit
|
|
13
|
+
* evidence" as throttling — the one situation where the right move is to WAIT
|
|
14
|
+
* (re-sending the whole context into a limited account only makes it worse).
|
|
15
|
+
*
|
|
16
|
+
* During oauth runs prepareAuth points XDG_DATA_HOME at an isolated temp dir, so
|
|
17
|
+
* the log we read belongs to exactly this run's server — no cross-talk with a
|
|
18
|
+
* developer's own OpenCode sessions.
|
|
19
|
+
*/
|
|
20
|
+
/** The OpenCode server's log file under the active data dir. */
|
|
21
|
+
export function opencodeLogFile(env = process.env) {
|
|
22
|
+
const dataHome = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
|
|
23
|
+
return path.join(dataHome, "opencode", "log", "opencode.log");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Provider throttle signatures in OpenCode log lines. Matched only against ERROR
|
|
27
|
+
* lines (a chatty INFO line mentioning "retry" must not count as evidence).
|
|
28
|
+
*/
|
|
29
|
+
const RATE_LIMIT_PATTERN = /\b429\b|rate.?limit|too many requests|quota exceeded/i;
|
|
30
|
+
const ERROR_LINE = /\blevel=ERROR\b/;
|
|
31
|
+
/** Count rate-limit ERROR lines in a chunk of log text. Pure, for tests. */
|
|
32
|
+
export function countRateLimitLines(chunk) {
|
|
33
|
+
let count = 0;
|
|
34
|
+
for (const line of chunk.split("\n")) {
|
|
35
|
+
if (ERROR_LINE.test(line) && RATE_LIMIT_PATTERN.test(line)) {
|
|
36
|
+
count++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return count;
|
|
40
|
+
}
|
|
41
|
+
/** How recent explicit evidence must be for a stall to be read as throttling. */
|
|
42
|
+
const EVIDENCE_WINDOW_MS = 5 * 60 * 1000;
|
|
43
|
+
/**
|
|
44
|
+
* Incremental watcher over the OpenCode server log. `check()` reads only what was
|
|
45
|
+
* appended since the last call (cheap enough for poll loops); `recentlyLimited()`
|
|
46
|
+
* is the signal the stall path consults. Fails soft everywhere: a missing or
|
|
47
|
+
* unreadable log yields "no evidence", never an error.
|
|
48
|
+
*/
|
|
49
|
+
export class RateLimitWatch {
|
|
50
|
+
file;
|
|
51
|
+
/** Total rate-limit ERROR lines seen this run. */
|
|
52
|
+
events = 0;
|
|
53
|
+
/** Wall-clock time evidence was last SEEN (observation time, not log time). */
|
|
54
|
+
lastSeenAt = 0;
|
|
55
|
+
offset = 0;
|
|
56
|
+
constructor(file = opencodeLogFile()) {
|
|
57
|
+
this.file = file;
|
|
58
|
+
}
|
|
59
|
+
/** Scan newly-appended log lines for rate-limit evidence. */
|
|
60
|
+
async check() {
|
|
61
|
+
try {
|
|
62
|
+
const handle = await open(this.file, "r");
|
|
63
|
+
try {
|
|
64
|
+
const { size } = await handle.stat();
|
|
65
|
+
if (size < this.offset) {
|
|
66
|
+
this.offset = 0; // rotated/truncated — rescan from the top
|
|
67
|
+
}
|
|
68
|
+
if (size === this.offset) {
|
|
69
|
+
return this.events;
|
|
70
|
+
}
|
|
71
|
+
const length = size - this.offset;
|
|
72
|
+
const buffer = Buffer.alloc(length);
|
|
73
|
+
await handle.read(buffer, 0, length, this.offset);
|
|
74
|
+
this.offset = size;
|
|
75
|
+
const found = countRateLimitLines(buffer.toString("utf8"));
|
|
76
|
+
if (found > 0) {
|
|
77
|
+
this.events += found;
|
|
78
|
+
this.lastSeenAt = Date.now();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await handle.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// No log yet (server just started) or unreadable — no evidence, no error.
|
|
87
|
+
}
|
|
88
|
+
return this.events;
|
|
89
|
+
}
|
|
90
|
+
/** True when explicit throttle evidence appeared within the recency window. */
|
|
91
|
+
recentlyLimited(now = Date.now()) {
|
|
92
|
+
return this.lastSeenAt > 0 && now - this.lastSeenAt < EVIDENCE_WINDOW_MS;
|
|
93
|
+
}
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expo/code-review-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"type": "module",
|
|
11
11
|
"bin": {
|
|
12
12
|
"ecr": "build/cli.js",
|
|
13
|
-
"expo-code-review": "build/cli.js"
|
|
13
|
+
"expo-code-review": "build/cli.js",
|
|
14
|
+
"code-review-cli": "build/cli.js"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"build",
|
package/templates/config.jsonc
CHANGED
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
// Large diffs are split into focused chunks by changed-line count, plus a
|
|
24
24
|
// cross-cutting pass for multi-file issues. Diffs under maxChangedLines are one
|
|
25
25
|
// full-context pass. Defaults shown; raise/lower per your model + PR sizes.
|
|
26
|
+
// Concurrency defaults by auth mode: 6 with an API key, 3 on a subscription
|
|
27
|
+
// (oauth) credential — one account handles many parallel streams poorly, and
|
|
28
|
+
// several PRs may review on the same credential at once. Set it to override.
|
|
26
29
|
// "chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 4 },
|
|
27
30
|
|
|
28
31
|
// Which PRs `ecr ci` reviews. This is the source of truth for trigger policy;
|
|
@@ -56,11 +59,12 @@
|
|
|
56
59
|
// of the agents that need the pro tier, and set ECR_EXPECTED_TOKEN_ENV in the
|
|
57
60
|
// workflow to the comma-separated set of both env names.
|
|
58
61
|
// "auth": { "providers": {
|
|
59
|
-
// "openai": { "mode": "oauth", "tokenEnv": "
|
|
62
|
+
// "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" },
|
|
60
63
|
// "openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
|
|
61
64
|
// } }
|
|
62
|
-
// (openai oauth: tokenEnv holds the
|
|
63
|
-
// ChatGPT sign-in —
|
|
65
|
+
// (openai oauth: tokenEnv holds the ACCESS token from an `opencode auth login`
|
|
66
|
+
// ChatGPT sign-in — `ecr setup-auth` extracts it. NEVER share the refresh
|
|
67
|
+
// token: it is single-use and dies on first rotation.)
|
|
64
68
|
"auth": {
|
|
65
69
|
"mode": "api-key",
|
|
66
70
|
"provider": "openai",
|