@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.
- package/README.md +307 -47
- package/build/cli.js +24 -17
- package/build/commands/ci.js +410 -43
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +219 -26
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +118 -30
- package/build/commands/verify-config.js +252 -0
- package/build/config/load.js +200 -55
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +153 -19
- package/build/core/auth.js +237 -75
- package/build/core/coordinator.js +7 -7
- package/build/core/diff.js +19 -19
- package/build/core/exec.js +10 -10
- package/build/core/log.js +3 -3
- package/build/core/noise.js +52 -52
- package/build/core/opencode.js +495 -95
- package/build/core/prompts.js +220 -150
- package/build/core/render.js +202 -48
- package/build/core/review.js +277 -102
- package/build/core/router.js +10 -10
- package/build/core/schema.js +26 -12
- package/build/core/step-summary.js +18 -0
- package/build/core/suppress.js +7 -7
- package/build/core/tools.js +9 -9
- package/build/core/util.js +2 -2
- package/build/core/verify.js +28 -26
- package/build/reporters/github.js +103 -51
- package/build/reporters/terminal.js +19 -19
- package/build/sources/github-pr.js +21 -21
- package/build/sources/local-git.js +20 -20
- package/build/sources/source.js +35 -1
- package/package.json +8 -3
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +167 -0
- package/templates/config.jsonc +26 -13
- package/templates/coordinator.md +5 -3
- package/templates/dismiss.yml +110 -0
- package/templates/routing.jsonc +27 -0
- package/templates/scope-config.jsonc +25 -0
- package/templates/shared.md +12 -0
- package/templates/workflow.yml +61 -26
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { decisionExitCode, decisionLabel, groupBySeverity, sortFindings
|
|
2
|
-
import { SEVERITIES } from
|
|
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:
|
|
14
|
-
warning:
|
|
15
|
-
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,
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
86
|
+
lines.push(` ${this.paint(DIM, "Suggestion:")} ${finding.suggestion}`);
|
|
87
87
|
}
|
|
88
|
-
return `${lines.join(
|
|
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
|
|
2
|
-
import { tmpdir } from
|
|
3
|
-
import path from
|
|
4
|
-
import { run } from
|
|
5
|
-
import { parseUnifiedDiff } from
|
|
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 ? [
|
|
16
|
+
return this.options.repo ? ["--repo", this.options.repo] : [];
|
|
17
17
|
}
|
|
18
18
|
async getMetadata() {
|
|
19
|
-
const { stdout } = await run(
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const { stdout } = await run("gh", [
|
|
20
|
+
"pr",
|
|
21
|
+
"view",
|
|
22
22
|
String(this.options.prNumber),
|
|
23
23
|
...this.repoArgs(),
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
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(
|
|
58
|
-
parent = await mkdtemp(path.join(tmpdir(),
|
|
59
|
-
const dir = path.join(parent,
|
|
60
|
-
await run(
|
|
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(
|
|
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
|
|
2
|
-
import { parseUnifiedDiff } from
|
|
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([
|
|
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 [
|
|
28
|
+
for (const guess of ["origin/main", "origin/master", "main", "master"]) {
|
|
29
29
|
try {
|
|
30
|
-
await git([
|
|
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
|
|
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([
|
|
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:
|
|
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 ??
|
|
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([
|
|
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([
|
|
76
|
+
raw = await git(["diff", `${base}...${this.options.head}`], this.cwd);
|
|
77
77
|
}
|
|
78
78
|
else {
|
|
79
|
-
const tracked = await git([
|
|
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(
|
|
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([
|
|
93
|
-
const files = listing.split(
|
|
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(
|
|
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(
|
|
105
|
+
return chunks.join("\n");
|
|
106
106
|
}
|
|
107
107
|
}
|
package/build/sources/source.js
CHANGED
|
@@ -1 +1,35 @@
|
|
|
1
|
-
|
|
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
|
+
"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": "
|
|
36
|
-
"opencode-ai": "
|
|
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
|
package/templates/config.jsonc
CHANGED
|
@@ -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
|
|
5
|
-
"model": "
|
|
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:
|
|
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:
|
|
45
|
-
// "
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// For
|
|
50
|
-
//
|
|
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": "
|
|
53
|
-
"provider": "
|
|
54
|
-
"tokenEnv": "
|
|
65
|
+
"mode": "api-key",
|
|
66
|
+
"provider": "openai",
|
|
67
|
+
"tokenEnv": "OPENAI_API_KEY"
|
|
55
68
|
}
|
|
56
69
|
}
|
package/templates/coordinator.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
# The coordinator
|
|
3
|
-
#
|
|
4
|
-
|
|
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
|