@expo/code-review-cli 0.3.0 → 0.5.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.
Files changed (43) hide show
  1. package/README.md +307 -47
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +410 -43
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +219 -26
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +118 -30
  8. package/build/commands/verify-config.js +252 -0
  9. package/build/config/load.js +200 -55
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +153 -19
  12. package/build/core/auth.js +237 -75
  13. package/build/core/coordinator.js +7 -7
  14. package/build/core/diff.js +19 -19
  15. package/build/core/exec.js +10 -10
  16. package/build/core/log.js +3 -3
  17. package/build/core/noise.js +52 -52
  18. package/build/core/opencode.js +495 -95
  19. package/build/core/prompts.js +220 -150
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +277 -102
  22. package/build/core/router.js +10 -10
  23. package/build/core/schema.js +26 -12
  24. package/build/core/step-summary.js +18 -0
  25. package/build/core/suppress.js +7 -7
  26. package/build/core/tools.js +9 -9
  27. package/build/core/util.js +2 -2
  28. package/build/core/verify.js +28 -26
  29. package/build/reporters/github.js +103 -51
  30. package/build/reporters/terminal.js +19 -19
  31. package/build/sources/github-pr.js +21 -21
  32. package/build/sources/local-git.js +20 -20
  33. package/build/sources/source.js +35 -1
  34. package/package.json +8 -3
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +167 -0
  37. package/templates/config.jsonc +26 -13
  38. package/templates/coordinator.md +5 -3
  39. package/templates/dismiss.yml +110 -0
  40. package/templates/routing.jsonc +27 -0
  41. package/templates/scope-config.jsonc +25 -0
  42. package/templates/shared.md +12 -0
  43. package/templates/workflow.yml +61 -26
@@ -1,6 +1,6 @@
1
- import { decisionExitCode, decisionLabel, groupBySeverity, sortFindings, } from '../core/render.js';
2
- import { SEVERITIES } from '../core/schema.js';
3
- const ESC = '';
1
+ import { decisionExitCode, decisionLabel, groupBySeverity, sortFindings } from "../core/render.js";
2
+ import { SEVERITIES } from "../core/schema.js";
3
+ const ESC = "";
4
4
  const RESET = `${ESC}[0m`;
5
5
  const BOLD = `${ESC}[1m`;
6
6
  const DIM = `${ESC}[2m`;
@@ -10,9 +10,9 @@ const COLORS = {
10
10
  suggestion: `${ESC}[36m`,
11
11
  };
12
12
  const SEVERITY_LABEL = {
13
- critical: 'CRITICAL',
14
- warning: 'WARNING',
15
- suggestion: 'SUGGESTION',
13
+ critical: "CRITICAL",
14
+ warning: "WARNING",
15
+ suggestion: "SUGGESTION",
16
16
  };
17
17
  /**
18
18
  * Prints a human-readable summary grouped by severity; honors --json; never
@@ -38,19 +38,19 @@ export class TerminalReporter {
38
38
  process.exitCode = this.options.noFail ? 0 : decisionExitCode(review.decision);
39
39
  }
40
40
  renderPretty(review) {
41
- const out = [''];
41
+ const out = [""];
42
42
  out.push(this.paint(BOLD, `AI code review — ${decisionLabel(review.decision)}`));
43
- out.push(this.tally(review.findings), '');
44
- out.push(review.summary, '');
43
+ out.push(this.tally(review.findings), "");
44
+ out.push(review.summary, "");
45
45
  if (review.incomplete.length > 0) {
46
- out.push(this.paint(BOLD, '⏱️ Coverage note: some passes did not finish (partial coverage):'));
46
+ out.push(this.paint(BOLD, "⏱️ Coverage note: some passes did not finish (partial coverage):"));
47
47
  for (const note of review.incomplete) {
48
48
  out.push(this.paint(DIM, ` - ${note}`));
49
49
  }
50
- out.push('');
50
+ out.push("");
51
51
  }
52
52
  if (review.findings.length === 0) {
53
- out.push(this.paint(DIM, 'No findings.'), '');
53
+ out.push(this.paint(DIM, "No findings."), "");
54
54
  }
55
55
  else {
56
56
  const groups = groupBySeverity(sortFindings(review.findings));
@@ -59,21 +59,21 @@ export class TerminalReporter {
59
59
  if (findings.length === 0) {
60
60
  continue;
61
61
  }
62
- out.push(this.paint(`${BOLD}${COLORS[severity]}`, `${SEVERITY_LABEL[severity]} (${findings.length})`), '');
62
+ out.push(this.paint(`${BOLD}${COLORS[severity]}`, `${SEVERITY_LABEL[severity]} (${findings.length})`), "");
63
63
  for (const finding of findings) {
64
64
  out.push(this.renderFinding(finding));
65
65
  }
66
66
  }
67
67
  }
68
- return `${out.join('\n')}\n`;
68
+ return `${out.join("\n")}\n`;
69
69
  }
70
70
  /** One-line count headline, e.g. "2 critical · 5 warning". */
71
71
  tally(findings) {
72
- const parts = SEVERITIES.map(severity => {
73
- const n = findings.filter(finding => finding.severity === severity).length;
72
+ const parts = SEVERITIES.map((severity) => {
73
+ const n = findings.filter((finding) => finding.severity === severity).length;
74
74
  return n > 0 ? this.paint(COLORS[severity], `${n} ${severity}`) : null;
75
75
  }).filter((part) => part !== null);
76
- return parts.length > 0 ? parts.join(this.paint(DIM, ' · ')) : this.paint(DIM, 'no findings');
76
+ return parts.length > 0 ? parts.join(this.paint(DIM, " · ")) : this.paint(DIM, "no findings");
77
77
  }
78
78
  renderFinding(finding) {
79
79
  const loc = finding.line != null ? `${finding.file}:${finding.line}` : finding.file;
@@ -83,9 +83,9 @@ export class TerminalReporter {
83
83
  ` ${finding.rationale}`,
84
84
  ];
85
85
  if (finding.suggestion) {
86
- lines.push(` ${this.paint(DIM, 'Suggestion:')} ${finding.suggestion}`);
86
+ lines.push(` ${this.paint(DIM, "Suggestion:")} ${finding.suggestion}`);
87
87
  }
88
- return `${lines.join('\n')}\n`;
88
+ return `${lines.join("\n")}\n`;
89
89
  }
90
90
  paint(codes, text) {
91
91
  return this.color ? `${codes}${text}${RESET}` : text;
@@ -1,8 +1,8 @@
1
- import { mkdtemp, rm } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import path from 'node:path';
4
- import { run } from '../core/exec.js';
5
- import { parseUnifiedDiff } from '../core/diff.js';
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { run } from "../core/exec.js";
5
+ import { parseUnifiedDiff } from "../core/diff.js";
6
6
  /**
7
7
  * Pulls PR diff + metadata through the `gh` CLI, which is preinstalled and
8
8
  * authenticated on GitHub Actions runners via GH_TOKEN.
@@ -13,27 +13,27 @@ export class GitHubPRSource {
13
13
  this.options = options;
14
14
  }
15
15
  repoArgs() {
16
- return this.options.repo ? ['--repo', this.options.repo] : [];
16
+ return this.options.repo ? ["--repo", this.options.repo] : [];
17
17
  }
18
18
  async getMetadata() {
19
- const { stdout } = await run('gh', [
20
- 'pr',
21
- 'view',
19
+ const { stdout } = await run("gh", [
20
+ "pr",
21
+ "view",
22
22
  String(this.options.prNumber),
23
23
  ...this.repoArgs(),
24
- '--json',
25
- 'title,body,baseRefName,headRefName',
24
+ "--json",
25
+ "title,body,baseRefName,headRefName",
26
26
  ], { cwd: this.options.cwd });
27
27
  const parsed = JSON.parse(stdout);
28
28
  return {
29
- title: parsed.title ?? '',
30
- body: parsed.body ?? '',
31
- baseRef: parsed.baseRefName ?? '',
32
- headRef: parsed.headRefName ?? '',
29
+ title: parsed.title ?? "",
30
+ body: parsed.body ?? "",
31
+ baseRef: parsed.baseRefName ?? "",
32
+ headRef: parsed.headRefName ?? "",
33
33
  };
34
34
  }
35
35
  async getChangedFiles() {
36
- const { stdout } = await run('gh', ['pr', 'diff', String(this.options.prNumber), ...this.repoArgs()], { cwd: this.options.cwd });
36
+ const { stdout } = await run("gh", ["pr", "diff", String(this.options.prNumber), ...this.repoArgs()], { cwd: this.options.cwd });
37
37
  return parseUnifiedDiff(stdout);
38
38
  }
39
39
  /**
@@ -54,16 +54,16 @@ export class GitHubPRSource {
54
54
  const ref = `refs/pull/${this.options.prNumber}/head`;
55
55
  let parent;
56
56
  try {
57
- await run('git', ['fetch', '--no-tags', '--depth=1', url, ref], { cwd });
58
- parent = await mkdtemp(path.join(tmpdir(), 'ecr-prhead-'));
59
- const dir = path.join(parent, 'head'); // must not pre-exist for `worktree add`
60
- await run('git', ['worktree', 'add', '--detach', dir, 'FETCH_HEAD'], { cwd });
57
+ await run("git", ["fetch", "--no-tags", "--depth=1", url, ref], { cwd });
58
+ parent = await mkdtemp(path.join(tmpdir(), "ecr-prhead-"));
59
+ const dir = path.join(parent, "head"); // must not pre-exist for `worktree add`
60
+ await run("git", ["worktree", "add", "--detach", dir, "FETCH_HEAD"], { cwd });
61
61
  const removeParent = parent;
62
62
  return {
63
63
  dir,
64
64
  cleanup: async () => {
65
65
  try {
66
- await run('git', ['worktree', 'remove', '--force', dir], { cwd });
66
+ await run("git", ["worktree", "remove", "--force", dir], { cwd });
67
67
  }
68
68
  catch {
69
69
  // best effort — fall through to removing the temp dir
@@ -1,5 +1,5 @@
1
- import { git, run } from '../core/exec.js';
2
- import { parseUnifiedDiff } from '../core/diff.js';
1
+ import { git, run } from "../core/exec.js";
2
+ import { parseUnifiedDiff } from "../core/diff.js";
3
3
  /**
4
4
  * Reads local git state. No network calls. Default compares the working tree
5
5
  * against the merge-base with the default branch; flags override base/head or
@@ -16,8 +16,8 @@ export class LocalGitSource {
16
16
  }
17
17
  async defaultBranch() {
18
18
  try {
19
- const ref = (await git(['symbolic-ref', 'refs/remotes/origin/HEAD'], this.cwd)).trim();
20
- const short = ref.replace(/^refs\/remotes\//, '');
19
+ const ref = (await git(["symbolic-ref", "refs/remotes/origin/HEAD"], this.cwd)).trim();
20
+ const short = ref.replace(/^refs\/remotes\//, "");
21
21
  if (short) {
22
22
  return short;
23
23
  }
@@ -25,16 +25,16 @@ export class LocalGitSource {
25
25
  catch {
26
26
  // fall through to guesses
27
27
  }
28
- for (const guess of ['origin/main', 'origin/master', 'main', 'master']) {
28
+ for (const guess of ["origin/main", "origin/master", "main", "master"]) {
29
29
  try {
30
- await git(['rev-parse', '--verify', '--quiet', guess], this.cwd);
30
+ await git(["rev-parse", "--verify", "--quiet", guess], this.cwd);
31
31
  return guess;
32
32
  }
33
33
  catch {
34
34
  // try next
35
35
  }
36
36
  }
37
- return 'main';
37
+ return "main";
38
38
  }
39
39
  async resolveBase() {
40
40
  if (this.resolvedBase) {
@@ -46,7 +46,7 @@ export class LocalGitSource {
46
46
  }
47
47
  const branch = await this.defaultBranch();
48
48
  try {
49
- this.resolvedBase = (await git(['merge-base', branch, 'HEAD'], this.cwd)).trim();
49
+ this.resolvedBase = (await git(["merge-base", branch, "HEAD"], this.cwd)).trim();
50
50
  }
51
51
  catch {
52
52
  this.resolvedBase = branch;
@@ -55,30 +55,30 @@ export class LocalGitSource {
55
55
  }
56
56
  async getMetadata() {
57
57
  if (this.options.staged) {
58
- return { title: '', body: '', baseRef: 'HEAD', headRef: 'STAGED' };
58
+ return { title: "", body: "", baseRef: "HEAD", headRef: "STAGED" };
59
59
  }
60
60
  const base = await this.resolveBase();
61
61
  return {
62
- title: '',
63
- body: '',
62
+ title: "",
63
+ body: "",
64
64
  baseRef: base,
65
- headRef: this.options.head ?? 'WORKING_TREE',
65
+ headRef: this.options.head ?? "WORKING_TREE",
66
66
  };
67
67
  }
68
68
  async getChangedFiles() {
69
69
  let raw;
70
70
  if (this.options.staged) {
71
- raw = await git(['diff', '--staged'], this.cwd);
71
+ raw = await git(["diff", "--staged"], this.cwd);
72
72
  }
73
73
  else {
74
74
  const base = await this.resolveBase();
75
75
  if (this.options.head) {
76
- raw = await git(['diff', `${base}...${this.options.head}`], this.cwd);
76
+ raw = await git(["diff", `${base}...${this.options.head}`], this.cwd);
77
77
  }
78
78
  else {
79
- const tracked = await git(['diff', base], this.cwd);
79
+ const tracked = await git(["diff", base], this.cwd);
80
80
  const untracked = await this.untrackedDiffs();
81
- raw = [tracked, untracked].filter(chunk => chunk.trim()).join('\n');
81
+ raw = [tracked, untracked].filter((chunk) => chunk.trim()).join("\n");
82
82
  }
83
83
  }
84
84
  return parseUnifiedDiff(raw);
@@ -89,12 +89,12 @@ export class LocalGitSource {
89
89
  */
90
90
  async untrackedDiffs() {
91
91
  // -z: null-terminated output so filenames containing newlines parse correctly.
92
- const listing = await git(['ls-files', '-z', '--others', '--exclude-standard'], this.cwd);
93
- const files = listing.split('\0').filter(Boolean);
92
+ const listing = await git(["ls-files", "-z", "--others", "--exclude-standard"], this.cwd);
93
+ const files = listing.split("\0").filter(Boolean);
94
94
  const chunks = [];
95
95
  for (const file of files) {
96
96
  // `--` so a filename beginning with `-` can't be read as a git option.
97
- const { stdout } = await run('git', ['diff', '--no-index', '--', '/dev/null', file], {
97
+ const { stdout } = await run("git", ["diff", "--no-index", "--", "/dev/null", file], {
98
98
  cwd: this.cwd,
99
99
  check: false,
100
100
  });
@@ -102,6 +102,6 @@ export class LocalGitSource {
102
102
  chunks.push(stdout);
103
103
  }
104
104
  }
105
- return chunks.join('\n');
105
+ return chunks.join("\n");
106
106
  }
107
107
  }
@@ -1 +1,35 @@
1
- export {};
1
+ /**
2
+ * Wrap a source so getMetadata/getChangedFiles/prepareReadRootAsync each run once
3
+ * and are shared across N sequential runReview calls (one `gh pr diff`, one
4
+ * PR-head worktree for the whole fan-out — rate-limit hygiene, risk 6). The wrapped
5
+ * prepareReadRootAsync hands each run a handle whose cleanup() is a no-op; the real
6
+ * cleanup is deferred to dispose(), which must be called once after the last scope.
7
+ */
8
+ export function memoizeSource(source) {
9
+ let metadataPromise;
10
+ let changedPromise;
11
+ let readRootPromise;
12
+ let realHandle = null;
13
+ return {
14
+ getMetadata: () => (metadataPromise ??= source.getMetadata()),
15
+ getChangedFiles: () => (changedPromise ??= source.getChangedFiles()),
16
+ prepareReadRootAsync: async () => {
17
+ readRootPromise ??= source.prepareReadRootAsync
18
+ ? source.prepareReadRootAsync()
19
+ : Promise.resolve(null);
20
+ realHandle = await readRootPromise;
21
+ if (!realHandle) {
22
+ return null;
23
+ }
24
+ // No-op cleanup per run; the real teardown happens once in dispose().
25
+ return { dir: realHandle.dir, cleanup: async () => { } };
26
+ },
27
+ dispose: async () => {
28
+ if (realHandle) {
29
+ const handle = realHandle;
30
+ realHandle = null;
31
+ await handle.cleanup();
32
+ }
33
+ },
34
+ };
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
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": {
@@ -26,18 +26,23 @@
26
26
  "build": "tsc -p tsconfig.build.json",
27
27
  "clean": "rimraf build",
28
28
  "typecheck": "tsc --noEmit",
29
+ "lint": "oxlint src",
30
+ "fmt": "oxfmt src",
31
+ "fmt:check": "oxfmt --check src",
29
32
  "dev": "bun run src/cli.ts",
30
33
  "test:unit": "bun test",
31
34
  "release": "bash scripts/release.sh",
32
35
  "prepublishOnly": "rimraf build && tsc -p tsconfig.build.json"
33
36
  },
34
37
  "dependencies": {
35
- "@opencode-ai/sdk": "^1.18.2",
36
- "opencode-ai": "^1.18.2",
38
+ "@opencode-ai/sdk": "1.18.4",
39
+ "opencode-ai": "1.18.4",
37
40
  "zod": "^4.4.3"
38
41
  },
39
42
  "devDependencies": {
40
43
  "@types/node": "20.14.8",
44
+ "oxfmt": "^0.60.0",
45
+ "oxlint": "^1.75.0",
41
46
  "rimraf": "3.0.2",
42
47
  "typescript": "5.5.4"
43
48
  }
@@ -1,6 +1,11 @@
1
1
  ---
2
2
  description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
3
3
  alwaysRun: true
4
+ # Security is the highest-stakes agent and benefits most from stronger threat-model
5
+ # reasoning, so it runs on the pro tier even though the other specialists use the
6
+ # default model. Scoped to this one agent to limit the extra latency/rate-limit cost;
7
+ # subdivide-on-timeout + the per-fetch deadline keep a slow pro pass from hanging.
8
+ model: openai/gpt-5.5-pro
4
9
  ---
5
10
 
6
11
  # Security & secrets
@@ -0,0 +1,167 @@
1
+ name: AI code review (command)
2
+
3
+ # On-demand, ONE-SHOT reviewer triggered by a PR comment (maintainers only):
4
+ # /review run once now; the router picks the agents
5
+ # /review all run once with every agent
6
+ # /review correctness security run once with just those agents
7
+ # This never changes configuration. CONTINUOUS review is configured in
8
+ # expo-code-review.yml (the `pull_request` workflow) via the `review.trigger`
9
+ # policy in .expo-code-review/config.jsonc and the `ai-review:skip` label.
10
+
11
+ on:
12
+ issue_comment:
13
+ types: [created]
14
+
15
+ # Comment-only: read the repo, write PR comments (issue comments API).
16
+ permissions:
17
+ contents: read
18
+ pull-requests: write
19
+ issues: write
20
+
21
+ env:
22
+ # Published reviewer run via npx (override with repo variable ECR_VERSION; pin to
23
+ # a specific version to freeze it). Used for the guard AND the review so the engine
24
+ # that clears a config is the same engine that then reads it.
25
+ ECR_VERSION: ${{ vars.ECR_VERSION || 'latest' }}
26
+
27
+ concurrency:
28
+ group: ai-code-review-cmd-${{ github.event.issue.number }}
29
+ cancel-in-progress: true
30
+
31
+ jobs:
32
+ command:
33
+ # Only PR comments starting with /review, from a maintainer.
34
+ if: >-
35
+ github.event.issue.pull_request != null &&
36
+ startsWith(github.event.comment.body, '/review') &&
37
+ contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
38
+ runs-on: ubuntu-latest
39
+ # Bound the run so a slow/stalled review fails fast rather than hanging. Keep it
40
+ # above the passes budget (budget.totalPassesMinutes, 55m) + coordinator (10m) +
41
+ # verification + setup, like the auto-review workflow's cap.
42
+ timeout-minutes: 90
43
+ # A reviewer failure must never fail the PR's checks.
44
+ continue-on-error: true
45
+ steps:
46
+ - name: Parse command
47
+ id: cmd
48
+ env:
49
+ # Via env (never inline ${{ }}) so an untrusted comment can't inject shell.
50
+ COMMENT: ${{ github.event.comment.body }}
51
+ run: |
52
+ line=$(printf '%s' "$COMMENT" | head -n1 | tr -d '\r')
53
+ verb=$(printf '%s' "$line" | awk '{print $1}')
54
+ rest=$(printf '%s' "$line" | cut -s -d' ' -f2-)
55
+ # Only /review (one-shot). Continuous review is policy/label-driven, not a
56
+ # comment; /review no longer changes any configuration.
57
+ if [ "$verb" != "/review" ]; then
58
+ echo "run=false" >> "$GITHUB_OUTPUT"; exit 0
59
+ fi
60
+ # Bare "/review" -> router picks; "all" -> every agent; names -> subset.
61
+ # Sanitize agent ids to [a-zA-Z0-9,_-] to keep the value shell-safe.
62
+ agents=""
63
+ route=false
64
+ if [ -z "$rest" ]; then
65
+ route=true
66
+ elif [ "$rest" != "all" ]; then
67
+ agents=$(printf '%s' "$rest" | tr ' ' ',' | tr -cd 'a-zA-Z0-9,_-')
68
+ fi
69
+ {
70
+ echo "run=true"
71
+ echo "agents=$agents"
72
+ echo "route=$route"
73
+ } >> "$GITHUB_OUTPUT"
74
+
75
+ - name: Acknowledge
76
+ if: steps.cmd.outputs.run == 'true'
77
+ env:
78
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
79
+ run: gh api -X POST "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" -f content=eyes
80
+
81
+ # SECURITY: `issue_comment` is NOT fork-restricted by GitHub — it always
82
+ # runs in the base-repo context with full secrets and a write-scoped token,
83
+ # regardless of whether the commented-on PR is from a fork. We check out ONLY
84
+ # the trusted base ref (the default branch) for the `.expo-code-review/`
85
+ # config, and never `gh pr checkout` the PR head. The reviewer engine itself
86
+ # is the PUBLISHED @expo/code-review-cli (fetched by npx), not built from any
87
+ # checkout, so attacker-controlled PR code never runs here. The diff + PR
88
+ # metadata come from the API (`gh pr diff`/`gh pr view`).
89
+ - name: Checkout (base ref only — never the PR head)
90
+ if: steps.cmd.outputs.run == 'true'
91
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
92
+ with:
93
+ fetch-depth: 1
94
+
95
+ - name: Set up Node
96
+ if: steps.cmd.outputs.run == 'true'
97
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
98
+ with:
99
+ node-version: 24
100
+ # The reviewer runs via npx and never installs with a package manager, so
101
+ # disable setup-node's auto package-manager cache (its post step would try
102
+ # to save an empty cache and error).
103
+ package-manager-cache: false
104
+
105
+ # SECURITY: the base-ref checkout above includes every .expo-code-review/
106
+ # config.jsonc + routing.jsonc, whose auth.tokenEnv names the env var the CLI
107
+ # forwards as the model credential. The canonical guard ships with the CLI:
108
+ # `ecr verify-config` sweeps every config (root + routing + all scopes, referenced
109
+ # or not) with the engine's real JSONC parser and refuses unless tokenEnv appears
110
+ # exactly once, in a ROOT-owned file, equal to ECR_EXPECTED_TOKEN_ENV — so a
111
+ # base-ref config change can't repoint it at another runner secret, sneak in a
112
+ # JSON-escaped key, or stage an unreferenced scope config with its own auth.
113
+ # This is layer 2; layer 1 is the runtime ECR_EXPECTED_TOKEN_ENV lock in `ecr ci`.
114
+ # Runs after Set up Node so the guard runs the SAME $ECR_VERSION `ecr ci` will.
115
+ - name: Guard config tokenEnv (root + routing + all scopes)
116
+ if: steps.cmd.outputs.run == 'true'
117
+ env:
118
+ # (Comma-separated set for a multi-credential auth.providers config.)
119
+ ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
120
+ run: npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr verify-config
121
+
122
+ - name: Run AI review
123
+ if: steps.cmd.outputs.run == 'true'
124
+ continue-on-error: true
125
+ env:
126
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
127
+ # Layer-1 auth lock: the CLI refuses to run when the tokenEnv it would honor
128
+ # differs from this. Keep it in sync with the guard's EXPECTED.
129
+ ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
130
+ # OpenAI API key — the env var named by auth.tokenEnv in config.jsonc.
131
+ # Store it as a repo secret; a project-scoped key restricted to model
132
+ # inference (with a spend limit) is all the reviewer needs.
133
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
134
+ # Optional: override the model for every agent (uses your OpenCode login).
135
+ REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
136
+ AGENTS: ${{ steps.cmd.outputs.agents }}
137
+ ROUTE: ${{ steps.cmd.outputs.route }}
138
+ # NOTE: running via `issue_comment` makes this a manual /review, which the CLI
139
+ # detects (GITHUB_EVENT_NAME=issue_comment) and treats as a trigger-gate bypass
140
+ # — it reviews even when the config trigger policy or an `ai-review:skip` label
141
+ # would skip the auto workflow. The bypass affects ONLY the trigger gate; the
142
+ # config guard above, break-glass, and the auth lock still apply.
143
+ run: |
144
+ # Array (not a string) so the flags expand as separate argv entries
145
+ # without unquoted word-splitting. AGENTS is a single sanitized,
146
+ # space-free comma list, so it stays one element.
147
+ ARGS=()
148
+ if [ -n "$AGENTS" ]; then
149
+ ARGS=(--agents "$AGENTS")
150
+ elif [ "$ROUTE" = "true" ]; then
151
+ ARGS=(--route)
152
+ fi
153
+ npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci "${ARGS[@]}"
154
+
155
+ # Same ephemeral per-run log as the pull_request workflow — a /review command
156
+ # runs the full `ecr ci`, whose .expo-code-review/.runs/ log is gone when the
157
+ # runner tears down. always() captures it even on error, gated on run=='true'
158
+ # (a non-/review comment writes no log); issue.number IS the PR number here
159
+ # (issue_comment context has no pull_request.number).
160
+ - name: Upload review run log
161
+ if: always() && steps.cmd.outputs.run == 'true'
162
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
163
+ with:
164
+ name: review-run-log-pr${{ github.event.issue.number }}
165
+ path: .expo-code-review/.runs/reviews.jsonl
166
+ if-no-files-found: ignore
167
+ retention-days: 14
@@ -1,14 +1,14 @@
1
1
  {
2
2
  // Default model for every agent. Override per-agent via frontmatter in the
3
3
  // agent's markdown, or at runtime with the REVIEWER_MODEL env var
4
- // (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini-fast).
5
- "model": "anthropic/claude-sonnet-5",
4
+ // (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini).
5
+ "model": "openai/gpt-5.5",
6
6
 
7
7
  // Agents: every markdown file in agents/ is one reviewer (id = filename).
8
8
  // Add or remove files to change the roster — no list needed here.
9
9
  // shared.md (prepended to every agent + coordinator) and coordinator.md are
10
10
  // reserved filenames. Per-agent overrides go in each file's YAML frontmatter,
11
- // e.g. `---\nmodel: anthropic/claude-opus-4-1\n---`.
11
+ // e.g. `---\nmodel: openai/gpt-5.5-pro\n---`.
12
12
 
13
13
  "policy": {
14
14
  // Phase 1: keep signal high by surfacing only critical/warning.
@@ -41,16 +41,29 @@
41
41
  // HTML marker used to find + update the single PR comment. Keep it stable.
42
42
  "commentTag": "expo-ai-code-reviewer",
43
43
 
44
- // How model credentials are provided. Default: a Claude Pro/Max subscription.
45
- // "oauth": tokenEnv holds a Claude Pro/Max OAuth token (from
46
- // `claude setup-token`); it's injected as a bearer credential.
47
- // "api-key": tokenEnv holds a provider API key (sent as x-api-key). If you
48
- // omit `auth`, OpenCode's own login / ANTHROPIC_API_KEY is used.
49
- // For OpenAI/GPT or another provider, omit `auth` and set REVIEWER_MODEL after an
50
- // `opencode auth login` for that provider.
44
+ // How model credentials are provided. Default: an OpenAI API key.
45
+ // "api-key": tokenEnv names the env var holding a provider API key. In CI,
46
+ // store the key as a repo secret and pass it under that env var
47
+ // (the scaffolded workflow does). If you omit `auth` entirely,
48
+ // OpenCode's own login / ambient provider env vars are used.
49
+ // For Anthropic/Claude, set provider "anthropic", tokenEnv "ANTHROPIC_API_KEY",
50
+ // and an anthropic/... model above. For another provider, omit `auth` and set
51
+ // REVIEWER_MODEL after an `opencode auth login` for that provider.
52
+ //
53
+ // MIXED setup (a ChatGPT/Codex subscription for the default models, plus a
54
+ // metered API key for pro-tier models the subscription doesn't offer): use the
55
+ // per-provider map instead, reference `openai-api/...` models in the frontmatter
56
+ // of the agents that need the pro tier, and set ECR_EXPECTED_TOKEN_ENV in the
57
+ // workflow to the comma-separated set of both env names.
58
+ // "auth": { "providers": {
59
+ // "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN" },
60
+ // "openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
61
+ // } }
62
+ // (openai oauth: tokenEnv holds the REFRESH token from an `opencode auth login`
63
+ // ChatGPT sign-in — copy `.openai.refresh` from OpenCode's auth.json.)
51
64
  "auth": {
52
- "mode": "oauth",
53
- "provider": "anthropic",
54
- "tokenEnv": "ANTHROPIC_OAUTH_API_KEY"
65
+ "mode": "api-key",
66
+ "provider": "openai",
67
+ "tokenEnv": "OPENAI_API_KEY"
55
68
  }
56
69
  }
@@ -1,7 +1,9 @@
1
1
  ---
2
- # The coordinator only consolidates text (no repo tools), so a fast, cheap model
3
- # fits and keeps this serial step from adding latency. Override as you like.
4
- model: anthropic/claude-haiku-4-5-20251001
2
+ # The coordinator makes the final call de-duping, re-judging severity, and
3
+ # deciding so it runs on the pro tier: consolidation quality matters more here
4
+ # than the small serial-tail latency it adds (no repo tools, one bounded pass).
5
+ # Override with a cheaper model if you'd rather trade decision quality for latency.
6
+ model: openai/gpt-5.5-pro
5
7
  ---
6
8
 
7
9
  # Coordinator — consolidation & decision