mirror of
https://github.com/expo/code-review-cli.git
synced 2026-11-15 03:38:09 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf3fdce10 |
No files matched your search
@@ -26,7 +26,12 @@ flowchart TD
|
||||
## Usage
|
||||
|
||||
Run via `npx @expo/code-review-cli <command>` (or the `ecr` / `expo-code-review`
|
||||
binary once installed).
|
||||
binary once installed). On a repo that already has `.expo-code-review/` set up,
|
||||
getting model credentials for local runs is one command:
|
||||
|
||||
```bash
|
||||
npx @expo/code-review-cli setup-auth
|
||||
```
|
||||
|
||||
Reviewing a PR (`--pr`/`ci`) needs the GitHub CLI — `brew install gh && gh auth login`.
|
||||
Everything else the reviewer needs (including the `opencode` runtime) ships with the
|
||||
@@ -34,34 +39,24 @@ package.
|
||||
|
||||
### First-time setup
|
||||
|
||||
Scaffold, add credentials, verify.
|
||||
|
||||
```bash
|
||||
# Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
|
||||
# 1. Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
|
||||
npx @expo/code-review-cli init
|
||||
```
|
||||
|
||||
Then give it model credentials. **Default: an OpenAI API key** — the scaffolded
|
||||
config reviews with GPT via `auth.mode "api-key"`.
|
||||
|
||||
Create the key in the OpenAI dashboard, scoped to the minimum the reviewer needs:
|
||||
|
||||
- Put it in a **dedicated project** (not "Default project") so you can set a
|
||||
monthly budget + alert on it and see the reviewer's spend in isolation.
|
||||
- Make it a **Restricted** key with exactly two permissions, both under *Model
|
||||
capabilities*: **Responses (/v1/responses) → Request** and **Chat completions
|
||||
(/v1/chat/completions) → Request**. Everything else — including *List models* —
|
||||
stays **None** (the reviewer resolves model ids from its own catalog and only
|
||||
ever makes inference requests).
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-proj-...
|
||||
# Check env, config, and credentials
|
||||
# 2. Get model credentials — guided; prints the export lines for your shell config
|
||||
npx @expo/code-review-cli setup-auth
|
||||
# 3. Verify env, config, and credentials
|
||||
npx @expo/code-review-cli doctor
|
||||
```
|
||||
|
||||
In CI, store the same key as the `OPENAI_API_KEY` repo secret (the scaffolded
|
||||
workflow forwards it).
|
||||
`setup-auth` reads the repo's config and walks through each credential it needs:
|
||||
an OpenAI **API key** (the scaffolded default — it prints where to create the key
|
||||
and the exact restricted permissions to grant), and/or a **ChatGPT/Codex
|
||||
subscription** sign-in (it runs OpenCode's browser login and extracts the token
|
||||
for you). `doctor` offers to run it whenever a credential is missing.
|
||||
|
||||
In CI, store the same values as repo secrets (`OPENAI_API_KEY`; plus
|
||||
`CODEX_OAUTH_REFRESH_TOKEN` for the mixed setup) — the scaffolded workflow
|
||||
forwards them.
|
||||
|
||||
**Have a ChatGPT Plus/Pro (Codex) subscription? Use both.** The recommended
|
||||
production setup pairs the subscription (runs the default models at no marginal
|
||||
@@ -115,6 +110,7 @@ is a ready example to adapt.
|
||||
| `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
|
||||
| `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
|
||||
| `ecr init --scope <dir>` | Scaffold a per-team scope under `<dir>` and register it in the manifest. |
|
||||
| `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. |
|
||||
| `ecr review [options]` | Review local changes and print an advisory review (default command). |
|
||||
| `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
|
||||
| `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
|
||||
@@ -508,9 +504,11 @@ set in `config.auth` (credentials come from OpenCode):
|
||||
access tokens are short-lived, so the refresh token is the durable secret and
|
||||
OpenCode mints access tokens on demand. Refresh-token reuse across runs is
|
||||
verified, so a static CI secret works.
|
||||
- **The API key needs the same two permissions** as the default setup above
|
||||
(Responses + Chat completions → Request; all else None), in a budget-capped
|
||||
project.
|
||||
- **The API key needs exactly two permissions** — a *Restricted* key with
|
||||
*Model capabilities*: **Responses → Request** and **Chat completions →
|
||||
Request**; everything else (including *List models*) stays None. Create it
|
||||
in a dedicated, budget-capped project. (`ecr setup-auth` prints these
|
||||
instructions too.)
|
||||
- **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
|
||||
comma-separated set of both env names
|
||||
(`CODEX_OAUTH_REFRESH_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"ecr": "build/cli.js",
|
||||
"expo-code-review": "build/cli.js"
|
||||
"expo-code-review": "build/cli.js",
|
||||
"code-review-cli": "build/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
import { planFromAuth, exportLine, opencodeAuthJsonPath } from "../commands/setup-auth.js";
|
||||
|
||||
test("plan: the mixed setup needs one ChatGPT login and one manual key", () => {
|
||||
const plan = planFromAuth([
|
||||
{ provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_REFRESH_TOKEN" },
|
||||
{ provider: "openai-api", mode: "api-key", tokenEnv: "OPENAI_API_KEY", upstream: "openai" },
|
||||
]);
|
||||
expect(plan.chatgptLogin).toEqual({ tokenEnv: "CODEX_OAUTH_REFRESH_TOKEN" });
|
||||
expect(plan.manualKeys).toEqual([
|
||||
{ provider: "openai-api", tokenEnv: "OPENAI_API_KEY", upstream: "openai" },
|
||||
]);
|
||||
expect(plan.unsupported).toEqual([]);
|
||||
});
|
||||
|
||||
test("plan: anthropic oauth is unsupported (dead path), api-key without tokenEnv needs nothing", () => {
|
||||
const plan = planFromAuth([
|
||||
{ provider: "anthropic", mode: "oauth", tokenEnv: "ANTHROPIC_OAUTH_API_KEY" },
|
||||
// Relies on OpenCode's own login — nothing for setup-auth to do.
|
||||
{ provider: "openai", mode: "api-key" },
|
||||
]);
|
||||
expect(plan.chatgptLogin).toBeUndefined();
|
||||
expect(plan.manualKeys).toEqual([]);
|
||||
expect(plan.unsupported).toHaveLength(1);
|
||||
expect(plan.unsupported[0]?.provider).toBe("anthropic");
|
||||
});
|
||||
|
||||
test("export line is single-quoted so shells never expand the value", () => {
|
||||
expect(exportLine("CODEX_OAUTH_REFRESH_TOKEN", "abc$HOME!x")).toBe(
|
||||
"export CODEX_OAUTH_REFRESH_TOKEN='abc$HOME!x'",
|
||||
);
|
||||
});
|
||||
|
||||
test("opencode auth.json path honors XDG_DATA_HOME and falls back to ~/.local/share", () => {
|
||||
expect(opencodeAuthJsonPath({ XDG_DATA_HOME: "/custom/data" })).toBe(
|
||||
"/custom/data/opencode/auth.json",
|
||||
);
|
||||
expect(opencodeAuthJsonPath({})).toContain("/.local/share/opencode/auth.json");
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { dismissCommand } from "./commands/dismiss.js";
|
||||
import { doctorCommand } from "./commands/doctor.js";
|
||||
import { initCommand } from "./commands/init.js";
|
||||
import { reviewCommand } from "./commands/review.js";
|
||||
import { setupAuthCommand } from "./commands/setup-auth.js";
|
||||
import { verifyConfigCommand } from "./commands/verify-config.js";
|
||||
|
||||
const USAGE = `expo-code-review (ecr) — config-driven AI code reviewer
|
||||
@@ -14,6 +15,7 @@ Usage:
|
||||
ecr dismiss --pr <n> <id...> Hide a finding on a PR (see \`ecr dismiss --help\`).
|
||||
ecr undismiss --pr <n> <id...> Restore a dismissed finding.
|
||||
ecr init [--monorepo] [--scope <dir>] Scaffold .expo-code-review/ in this repo.
|
||||
ecr setup-auth [--yes] Walk through getting model credentials for local runs.
|
||||
ecr doctor [--list-scopes] Check environment, config, credentials, and scopes.
|
||||
ecr verify-config [--expected <env>] [--json] Refuse to run if a config could redirect the credential (CI guard).
|
||||
|
||||
@@ -52,6 +54,9 @@ async function main(): Promise<void> {
|
||||
case "init":
|
||||
await initCommand(rest);
|
||||
break;
|
||||
case "setup-auth":
|
||||
await setupAuthCommand(rest);
|
||||
break;
|
||||
case "doctor":
|
||||
await doctorCommand(rest);
|
||||
break;
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
scopePassesBudgetMs,
|
||||
formatOwnerTable,
|
||||
} from "../config/routing.js";
|
||||
import readline from "node:readline/promises";
|
||||
|
||||
import type { LoadedScopeConfig } from "../config/load.js";
|
||||
import type { RoutingManifest } from "../config/schema.js";
|
||||
import { setupAuthCommand } from "./setup-auth.js";
|
||||
import { checkProviderAuth } from "../core/auth.js";
|
||||
import { opencodeBinSource } from "../core/opencode.js";
|
||||
import { git, onPath, repoRoot, run } from "../core/exec.js";
|
||||
@@ -162,6 +165,27 @@ export async function doctorCommand(argv: string[] = []): Promise<void> {
|
||||
if (readiness.warning) {
|
||||
warn(`auth: ${readiness.warning}`);
|
||||
}
|
||||
// A missing credential has a guided fix — offer it right here when someone is
|
||||
// at the terminal, rather than making them find the command in the README.
|
||||
if (!readiness.ok) {
|
||||
if (process.stdin.isTTY && process.stdout.isTTY) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
||||
let runIt = false;
|
||||
try {
|
||||
const answer = (await rl.question(" Run `ecr setup-auth` to fix this now? [Y/n] "))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
runIt = answer === "" || answer === "y" || answer === "yes";
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
if (runIt) {
|
||||
await setupAuthCommand([]);
|
||||
}
|
||||
} else {
|
||||
info("run `ecr setup-auth` for a guided credential setup");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
line(false, `config invalid: ${errorMessage(error)}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline/promises";
|
||||
|
||||
import { hasConfig, loadReviewConfig } from "../config/load.js";
|
||||
import type { AuthConfigEntry } from "../config/schema.js";
|
||||
import { opencodeBinSource } from "../core/opencode.js";
|
||||
import { errorMessage } from "../core/util.js";
|
||||
|
||||
const USAGE = `ecr setup-auth — set up model credentials for local runs
|
||||
|
||||
Reads this repo's .expo-code-review/config.jsonc auth entries and walks through
|
||||
getting each credential:
|
||||
• a ChatGPT/Codex subscription (oauth/openai): runs the bundled
|
||||
\`opencode auth login\` (interactive; opens your browser), then prints the
|
||||
\`export <tokenEnv>=…\` line to add to your shell config. An existing
|
||||
OpenCode ChatGPT sign-in is reused instead of re-authenticating.
|
||||
• an API key (api-key entries): prints where to create the key, the exact
|
||||
permissions it needs, and the export line to fill in.
|
||||
|
||||
Without a repo config, it offers the recommended ChatGPT/Codex subscription flow
|
||||
with the default env name.
|
||||
|
||||
Options:
|
||||
--yes Skip confirmation prompts (still interactive during the login itself).
|
||||
`;
|
||||
|
||||
/** What setup-auth must do for a given auth config. Pure, so it's testable. */
|
||||
export interface SetupPlan {
|
||||
/** oauth/openai entry — satisfiable by an `opencode auth login` ChatGPT sign-in. */
|
||||
chatgptLogin?: { tokenEnv: string };
|
||||
/** api-key entries — a human must mint these; we print instructions. */
|
||||
manualKeys: Array<{ provider: string; tokenEnv: string; upstream?: string }>;
|
||||
/** oauth entries for providers we have no automated flow for. */
|
||||
unsupported: AuthConfigEntry[];
|
||||
}
|
||||
|
||||
export function planFromAuth(auth: AuthConfigEntry[]): SetupPlan {
|
||||
const plan: SetupPlan = { manualKeys: [], unsupported: [] };
|
||||
for (const entry of auth) {
|
||||
if (entry.mode === "oauth" && entry.provider === "openai" && entry.tokenEnv) {
|
||||
plan.chatgptLogin = { tokenEnv: entry.tokenEnv };
|
||||
} else if (entry.mode === "api-key" && entry.tokenEnv) {
|
||||
plan.manualKeys.push({
|
||||
provider: entry.provider,
|
||||
tokenEnv: entry.tokenEnv,
|
||||
upstream: entry.upstream,
|
||||
});
|
||||
} else if (entry.mode === "oauth") {
|
||||
plan.unsupported.push(entry);
|
||||
}
|
||||
// api-key without tokenEnv relies on OpenCode's own login — nothing to set up.
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** Where OpenCode's own (non-isolated) auth.json lives. */
|
||||
export function opencodeAuthJsonPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const dataHome = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
|
||||
return path.join(dataHome, "opencode", "auth.json");
|
||||
}
|
||||
|
||||
/** The stored ChatGPT sign-in's refresh token, if OpenCode has one. */
|
||||
async function readStoredRefreshToken(): Promise<string | null> {
|
||||
try {
|
||||
const raw = await readFile(opencodeAuthJsonPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<
|
||||
string,
|
||||
{ type?: string; refresh?: string } | undefined
|
||||
>;
|
||||
const openai = parsed.openai;
|
||||
return openai?.type === "oauth" && openai.refresh ? openai.refresh : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm(question: string, skip: boolean): Promise<boolean> {
|
||||
if (skip) {
|
||||
return true;
|
||||
}
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
||||
try {
|
||||
const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
|
||||
return answer === "" || answer === "y" || answer === "yes";
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The line to paste into a shell config. Single-quoted: tokens never contain '. */
|
||||
export function exportLine(tokenEnv: string, value: string): string {
|
||||
return `export ${tokenEnv}='${value}'`;
|
||||
}
|
||||
|
||||
export async function setupAuthCommand(argv: string[] = []): Promise<void> {
|
||||
if (argv.includes("-h") || argv.includes("--help")) {
|
||||
process.stdout.write(USAGE);
|
||||
return;
|
||||
}
|
||||
const yes = argv.includes("--yes");
|
||||
const out = (line = ""): boolean => process.stdout.write(`${line}\n`);
|
||||
const err = (line = ""): boolean => process.stderr.write(`${line}\n`);
|
||||
|
||||
try {
|
||||
// Plan from the repo config when there is one; otherwise offer the
|
||||
// recommended subscription flow with the default env name.
|
||||
let plan: SetupPlan;
|
||||
if (hasConfig(process.cwd())) {
|
||||
const config = await loadReviewConfig(process.cwd());
|
||||
plan = planFromAuth(config.auth);
|
||||
} else {
|
||||
err("No .expo-code-review config here — setting up the default ChatGPT/Codex flow.");
|
||||
plan = planFromAuth([
|
||||
{ provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_REFRESH_TOKEN" },
|
||||
]);
|
||||
}
|
||||
|
||||
if (!plan.chatgptLogin && plan.manualKeys.length === 0 && plan.unsupported.length === 0) {
|
||||
out(
|
||||
"This repo's auth config needs no local credential setup (OpenCode's own login covers it).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const exports: string[] = [];
|
||||
|
||||
if (plan.chatgptLogin) {
|
||||
const { tokenEnv } = plan.chatgptLogin;
|
||||
if (process.env[tokenEnv]) {
|
||||
err(`✓ ${tokenEnv} is already set in this shell — skipping the ChatGPT sign-in.`);
|
||||
} else {
|
||||
let refresh = await readStoredRefreshToken();
|
||||
if (refresh) {
|
||||
err("Found an existing ChatGPT sign-in in OpenCode.");
|
||||
if (!(await confirm(`Reuse it for ${tokenEnv}?`, yes))) {
|
||||
refresh = null;
|
||||
}
|
||||
}
|
||||
if (!refresh) {
|
||||
err("This will run the bundled `opencode auth login` (interactive).");
|
||||
err("When it prompts:");
|
||||
err(" 1. select the provider: OpenAI");
|
||||
err(" 2. select the method: Sign in with ChatGPT (Codex subscription)");
|
||||
err(" 3. your browser opens — sign in and authorize.");
|
||||
if (!(await confirm("Run it now?", yes))) {
|
||||
err("Skipped the ChatGPT sign-in.");
|
||||
} else {
|
||||
const binDir = opencodeBinSource().dir;
|
||||
const opencode = binDir ? path.join(binDir, "opencode") : "opencode";
|
||||
const result = spawnSync(opencode, ["auth", "login"], { stdio: "inherit" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`\`opencode auth login\` exited with ${result.status ?? "a signal"}; nothing was changed.`,
|
||||
);
|
||||
}
|
||||
refresh = await readStoredRefreshToken();
|
||||
if (!refresh) {
|
||||
throw new Error(
|
||||
"The login finished but no ChatGPT sign-in was stored — did you select " +
|
||||
'OpenAI → "Sign in with ChatGPT"? Re-run `ecr setup-auth` to try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (refresh) {
|
||||
// The REFRESH token is the durable secret: access tokens are short-lived,
|
||||
// and OpenCode mints them from this on demand.
|
||||
exports.push(exportLine(tokenEnv, refresh));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of plan.manualKeys) {
|
||||
if (process.env[key.tokenEnv]) {
|
||||
err(`✓ ${key.tokenEnv} is already set in this shell — skipping.`);
|
||||
continue;
|
||||
}
|
||||
const upstream = key.upstream ?? key.provider;
|
||||
err("");
|
||||
err(`${key.tokenEnv} (${key.provider}) is an API key — create it by hand:`);
|
||||
if (upstream === "openai") {
|
||||
err(" https://platform.openai.com/api-keys — in a dedicated project (set a");
|
||||
err(" monthly budget), as a RESTRICTED key with exactly two permissions, both");
|
||||
err(" under Model capabilities: Responses → Request, Chat completions → Request.");
|
||||
err(" Everything else (including List models) stays None.");
|
||||
} else if (upstream === "anthropic") {
|
||||
err(" https://console.anthropic.com/settings/keys — a workspace-scoped key");
|
||||
err(" with a spend limit is all the reviewer needs.");
|
||||
} else {
|
||||
err(` mint a key for the "${upstream}" provider.`);
|
||||
}
|
||||
exports.push(exportLine(key.tokenEnv, "<paste the key here>"));
|
||||
}
|
||||
|
||||
for (const entry of plan.unsupported) {
|
||||
err("");
|
||||
err(
|
||||
`auth for "${entry.provider}" is mode "oauth", which has no automated setup flow here` +
|
||||
(entry.provider === "anthropic"
|
||||
? " — and cannot work: Anthropic prohibits subscription tokens in third-party tools. Use an API key instead."
|
||||
: `. Set ${entry.tokenEnv ?? "its token env"} manually.`),
|
||||
);
|
||||
}
|
||||
|
||||
if (exports.length > 0) {
|
||||
const rc = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "your shell config";
|
||||
err("");
|
||||
err(`Add ${exports.length === 1 ? "this line" : "these lines"} to ${rc}:`);
|
||||
out("");
|
||||
for (const line of exports) {
|
||||
out(` ${line}`);
|
||||
}
|
||||
out("");
|
||||
err(`Then restart your shell (or \`source ${rc}\`) and run \`ecr doctor\` to verify.`);
|
||||
} else {
|
||||
err("");
|
||||
err("Nothing to add — run `ecr doctor` to verify your setup.");
|
||||
}
|
||||
} catch (error) {
|
||||
err(`setup-auth failed: ${errorMessage(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user