@expo/code-review-cli 0.1.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/LICENSE +21 -0
- package/README.md +260 -0
- package/build/cli.js +54 -0
- package/build/commands/ci.js +130 -0
- package/build/commands/dismiss.js +97 -0
- package/build/commands/doctor.js +81 -0
- package/build/commands/init.js +82 -0
- package/build/commands/review.js +191 -0
- package/build/config/load.js +205 -0
- package/build/config/schema.js +65 -0
- package/build/core/auth.js +102 -0
- package/build/core/coordinator.js +24 -0
- package/build/core/diff.js +86 -0
- package/build/core/exec.js +61 -0
- package/build/core/log.js +10 -0
- package/build/core/noise.js +186 -0
- package/build/core/opencode.js +412 -0
- package/build/core/prompts.js +288 -0
- package/build/core/render.js +153 -0
- package/build/core/review.js +550 -0
- package/build/core/router.js +33 -0
- package/build/core/schema.js +107 -0
- package/build/core/suppress.js +60 -0
- package/build/core/tools.js +16 -0
- package/build/core/util.js +11 -0
- package/build/core/verify.js +93 -0
- package/build/reporters/github.js +166 -0
- package/build/reporters/reporter.js +1 -0
- package/build/reporters/terminal.js +93 -0
- package/build/sources/github-pr.js +36 -0
- package/build/sources/local-git.js +107 -0
- package/build/sources/source.js +1 -0
- package/package.json +43 -0
- package/templates/agents/consistency.md +53 -0
- package/templates/agents/correctness.md +32 -0
- package/templates/agents/security.md +51 -0
- package/templates/config.jsonc +44 -0
- package/templates/coordinator.md +62 -0
- package/templates/shared.md +79 -0
- package/templates/workflow.yml +43 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { normalizeCode } from './util.js';
|
|
4
|
+
/** Severity levels, ordered most→least severe for sorting/rendering. */
|
|
5
|
+
export const SEVERITIES = ['critical', 'warning', 'suggestion'];
|
|
6
|
+
/** Sort rank for severities (0 = most severe). Single source of truth. */
|
|
7
|
+
export const SEVERITY_RANK = { critical: 0, warning: 1, suggestion: 2 };
|
|
8
|
+
export const CATEGORIES = ['correctness', 'quality', 'security', 'secrets'];
|
|
9
|
+
export const DECISIONS = ['approve', 'approve_with_comments', 'request_changes'];
|
|
10
|
+
export const FindingSchema = z.object({
|
|
11
|
+
severity: z.enum(SEVERITIES),
|
|
12
|
+
category: z.enum(CATEGORIES),
|
|
13
|
+
file: z.string(),
|
|
14
|
+
line: z.number().int().nullable().optional().default(null),
|
|
15
|
+
title: z.string(),
|
|
16
|
+
rationale: z.string(),
|
|
17
|
+
suggestion: z.string().optional(),
|
|
18
|
+
/**
|
|
19
|
+
* Verbatim snippet of the flagged code, copied from the file. Used to
|
|
20
|
+
* quote-ground the finding: if this text isn't actually present in the file,
|
|
21
|
+
* the finding is treated as hallucinated and dropped.
|
|
22
|
+
*/
|
|
23
|
+
evidence: z.string().optional(),
|
|
24
|
+
});
|
|
25
|
+
/** A verifier's verdict on whether a finding is real (adversarial refute pass). */
|
|
26
|
+
export const VerdictSchema = z.object({
|
|
27
|
+
verified: z.boolean(),
|
|
28
|
+
reason: z.string().default(''),
|
|
29
|
+
});
|
|
30
|
+
export function parseVerdict(text) {
|
|
31
|
+
return VerdictSchema.parse(extractJsonObject(text));
|
|
32
|
+
}
|
|
33
|
+
/** Shape each sub-reviewer must emit. */
|
|
34
|
+
export const ReviewerOutputSchema = z.object({
|
|
35
|
+
findings: z.array(FindingSchema).default([]),
|
|
36
|
+
});
|
|
37
|
+
/** Mode-agnostic coordinator result; each Reporter decides how to render it. */
|
|
38
|
+
export const CoordinatorOutputSchema = z.object({
|
|
39
|
+
decision: z.enum(DECISIONS),
|
|
40
|
+
findings: z.array(FindingSchema).default([]),
|
|
41
|
+
summary: z.string(),
|
|
42
|
+
/**
|
|
43
|
+
* Human-readable notes about reduced coverage (e.g. a review pass that hit its
|
|
44
|
+
* time limit and returned partial findings, or was skipped). Populated by the
|
|
45
|
+
* engine after coordination, not by the model. Reporters surface these so a
|
|
46
|
+
* cut-short review is never presented as complete.
|
|
47
|
+
*/
|
|
48
|
+
incomplete: z.array(z.string()).default([]),
|
|
49
|
+
});
|
|
50
|
+
/** Minimum normalized evidence length to key a fingerprint on the code (below
|
|
51
|
+
* this we fall back to the title). */
|
|
52
|
+
const MIN_FP_EVIDENCE_LEN = 12;
|
|
53
|
+
/**
|
|
54
|
+
* Stable identifier for a finding — dedupes across re-reviews and is the key for
|
|
55
|
+
* dismissals. Excludes the line number (which shifts as a PR grows). Keys on the
|
|
56
|
+
* verbatim `evidence` snippet (v2) rather than the LLM-written `title`, which
|
|
57
|
+
* varies run-to-run and would make a dismissal silently lapse. When the flagged
|
|
58
|
+
* code later changes, the hash changes and the dismissal lapses — which is correct
|
|
59
|
+
* (you dismissed that code, not a blank check). Falls back to `title` only when
|
|
60
|
+
* there's too little evidence to key on.
|
|
61
|
+
*/
|
|
62
|
+
export function fingerprintFinding(finding) {
|
|
63
|
+
const evidence = normalizeCode(finding.evidence ?? '');
|
|
64
|
+
const key = evidence.length >= MIN_FP_EVIDENCE_LEN ? evidence : normalizeCode(finding.title);
|
|
65
|
+
const normalized = ['v2', finding.file, finding.category, key].join('|');
|
|
66
|
+
return createHash('sha1').update(normalized).digest('hex').slice(0, 12);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Extract the JSON payload from an LLM response. Prefers the last fenced
|
|
70
|
+
* ```json block; falls back to the outermost {...} span. Throws if neither
|
|
71
|
+
* parses.
|
|
72
|
+
*/
|
|
73
|
+
export function extractJsonObject(text) {
|
|
74
|
+
const fenceMatches = [...text.matchAll(/```(?:json)?\s*\n([\s\S]*?)```/gi)];
|
|
75
|
+
const candidates = [];
|
|
76
|
+
if (fenceMatches.length > 0) {
|
|
77
|
+
candidates.push(fenceMatches[fenceMatches.length - 1][1].trim());
|
|
78
|
+
}
|
|
79
|
+
const firstBrace = text.indexOf('{');
|
|
80
|
+
const lastBrace = text.lastIndexOf('}');
|
|
81
|
+
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
82
|
+
candidates.push(text.slice(firstBrace, lastBrace + 1));
|
|
83
|
+
}
|
|
84
|
+
let lastError;
|
|
85
|
+
for (const candidate of candidates) {
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(candidate);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
lastError = error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`Could not extract JSON from model response: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
94
|
+
}
|
|
95
|
+
/** The router's choice of which agent ids to run. */
|
|
96
|
+
export const RouteOutputSchema = z.object({
|
|
97
|
+
agents: z.array(z.string()).default([]),
|
|
98
|
+
});
|
|
99
|
+
export function parseRouteOutput(text) {
|
|
100
|
+
return RouteOutputSchema.parse(extractJsonObject(text));
|
|
101
|
+
}
|
|
102
|
+
export function parseReviewerOutput(text) {
|
|
103
|
+
return ReviewerOutputSchema.parse(extractJsonObject(text));
|
|
104
|
+
}
|
|
105
|
+
export function parseCoordinatorOutput(text) {
|
|
106
|
+
return CoordinatorOutputSchema.parse(extractJsonObject(text));
|
|
107
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const DIRECTIVE = 'expo-code-review-ignore';
|
|
4
|
+
/**
|
|
5
|
+
* Deterministic backstop for the inline `expo-code-review-ignore` directive (which
|
|
6
|
+
* was previously prompt-only, i.e. honored only if the model chose to). Drops a
|
|
7
|
+
* finding when its flagged line — or the line just above it — carries the
|
|
8
|
+
* directive.
|
|
9
|
+
*
|
|
10
|
+
* Carve-out: NEVER suppress a `critical` or `secrets` finding this way. An author
|
|
11
|
+
* could otherwise hide a real vulnerability in their own PR by adding one comment
|
|
12
|
+
* line; those always surface, consistent with the shared-prompt invariant.
|
|
13
|
+
*/
|
|
14
|
+
export async function applyInlineIgnores(findings, cwd, onProgress) {
|
|
15
|
+
const kept = [];
|
|
16
|
+
const suppressed = [];
|
|
17
|
+
const cache = new Map();
|
|
18
|
+
for (const finding of findings) {
|
|
19
|
+
if (!(await hasDirectiveNear(finding, cwd, cache))) {
|
|
20
|
+
kept.push(finding);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (finding.severity === 'critical' || finding.category === 'secrets') {
|
|
24
|
+
kept.push(finding);
|
|
25
|
+
onProgress?.(` inline-ignore present but NOT honored for ${finding.severity}/${finding.category} "${finding.title}"`);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
suppressed.push(finding);
|
|
29
|
+
onProgress?.(` suppressed "${finding.title}" via ${DIRECTIVE}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { kept, suppressed };
|
|
33
|
+
}
|
|
34
|
+
async function hasDirectiveNear(finding, cwd, cache) {
|
|
35
|
+
if (finding.line == null) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const lines = await readLines(finding.file, cwd, cache);
|
|
39
|
+
if (!lines) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const idx = finding.line - 1; // 1-based → 0-based
|
|
43
|
+
const flagged = lines[idx] ?? '';
|
|
44
|
+
const above = idx > 0 ? (lines[idx - 1] ?? '') : '';
|
|
45
|
+
return flagged.includes(DIRECTIVE) || above.includes(DIRECTIVE);
|
|
46
|
+
}
|
|
47
|
+
async function readLines(file, cwd, cache) {
|
|
48
|
+
if (cache.has(file)) {
|
|
49
|
+
return cache.get(file);
|
|
50
|
+
}
|
|
51
|
+
let lines;
|
|
52
|
+
try {
|
|
53
|
+
lines = (await readFile(path.resolve(cwd, file), 'utf8')).split('\n');
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
lines = null;
|
|
57
|
+
}
|
|
58
|
+
cache.set(file, lines);
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The OpenCode tool names the reviewer toggles. Single source of truth so the
|
|
2
|
+
* agent and coordinator tool maps can't drift apart. */
|
|
3
|
+
export const TOOL_NAMES = [
|
|
4
|
+
'read',
|
|
5
|
+
'grep',
|
|
6
|
+
'glob',
|
|
7
|
+
'list',
|
|
8
|
+
'bash',
|
|
9
|
+
'write',
|
|
10
|
+
'edit',
|
|
11
|
+
'patch',
|
|
12
|
+
];
|
|
13
|
+
/** Build a full tool map with only the listed tools enabled. */
|
|
14
|
+
export function toolMap(enabled) {
|
|
15
|
+
return Object.fromEntries(TOOL_NAMES.map(name => [name, enabled.includes(name)]));
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function sleep(ms) {
|
|
2
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
3
|
+
}
|
|
4
|
+
/** Extract a human-readable message from an unknown thrown value. */
|
|
5
|
+
export function errorMessage(error) {
|
|
6
|
+
return error instanceof Error ? error.message : String(error);
|
|
7
|
+
}
|
|
8
|
+
/** Collapse whitespace + lowercase — for tolerant code matching / fingerprinting. */
|
|
9
|
+
export function normalizeCode(text) {
|
|
10
|
+
return text.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
11
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseVerdict } from './schema.js';
|
|
4
|
+
import { addTokenUsage, promptAndParse, VERIFIER_AGENT } from './opencode.js';
|
|
5
|
+
import { buildVerifierSystem, buildVerifierTask } from './prompts.js';
|
|
6
|
+
import { errorMessage, normalizeCode } from './util.js';
|
|
7
|
+
// Verification runs after coordination (a serial tail step); keep it short. It
|
|
8
|
+
// runs criticals in parallel, so this bounds the added latency regardless of count.
|
|
9
|
+
const VERIFY_TIMEOUT_MS = 3 * 60 * 1000;
|
|
10
|
+
// Evidence shorter than this (normalized) is too weak to conclude "hallucinated".
|
|
11
|
+
const MIN_EVIDENCE_LEN = 12;
|
|
12
|
+
/**
|
|
13
|
+
* Deterministic quote-grounding: does the finding's `evidence` snippet actually
|
|
14
|
+
* appear in the file? Returns `unknown` (don't judge) when there's too little
|
|
15
|
+
* evidence or the file can't be read (e.g. a base-ref checkout that lacks a
|
|
16
|
+
* PR-added file), so we never drop a finding we couldn't actually check.
|
|
17
|
+
*/
|
|
18
|
+
async function evidencePresence(finding, cwd) {
|
|
19
|
+
const evidence = normalizeCode(finding.evidence ?? '');
|
|
20
|
+
if (evidence.length < MIN_EVIDENCE_LEN) {
|
|
21
|
+
return 'unknown';
|
|
22
|
+
}
|
|
23
|
+
let content;
|
|
24
|
+
try {
|
|
25
|
+
content = await readFile(path.resolve(cwd, finding.file), 'utf8');
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return 'unknown';
|
|
29
|
+
}
|
|
30
|
+
return normalizeCode(content).includes(evidence) ? 'present' : 'absent';
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Guard against hallucinated findings before they're surfaced:
|
|
34
|
+
* 1. Quote-grounding (deterministic, all findings): drop any whose quoted
|
|
35
|
+
* `evidence` is definitively not in the file.
|
|
36
|
+
* 2. Adversarial verify (LLM, criticals only): a skeptical pass re-reads the real
|
|
37
|
+
* file and must confirm the critical is genuine; refuted criticals are dropped.
|
|
38
|
+
* Fails OPEN — if a verify call itself errors, the critical is kept (better a
|
|
39
|
+
* possible false positive than hiding a real critical on an infra hiccup).
|
|
40
|
+
*/
|
|
41
|
+
export async function verifyFindings(handle, findings, cwd, onProgress) {
|
|
42
|
+
const dropped = [];
|
|
43
|
+
let cost = 0;
|
|
44
|
+
const tokens = {};
|
|
45
|
+
// Phase 1 — quote-grounding for every finding.
|
|
46
|
+
const checked = await Promise.all(findings.map(async (finding) => ({ finding, presence: await evidencePresence(finding, cwd) })));
|
|
47
|
+
const survivors = [];
|
|
48
|
+
for (const { finding, presence } of checked) {
|
|
49
|
+
if (presence === 'absent') {
|
|
50
|
+
dropped.push({ finding, reason: 'quoted code not found in file (likely hallucinated)' });
|
|
51
|
+
onProgress?.(` verify: dropped ${finding.severity} "${finding.title}" — quoted code not in ${finding.file}`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
survivors.push(finding);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Phase 2 — adversarial verify for surviving criticals, in parallel.
|
|
58
|
+
const refuted = new Set();
|
|
59
|
+
await Promise.all(survivors
|
|
60
|
+
.filter(finding => finding.severity === 'critical')
|
|
61
|
+
.map(async (finding, index) => {
|
|
62
|
+
try {
|
|
63
|
+
const { value, cost: verifyCost, tokens: verifyTokens } = await promptAndParse(handle, {
|
|
64
|
+
agent: VERIFIER_AGENT,
|
|
65
|
+
system: buildVerifierSystem(),
|
|
66
|
+
text: buildVerifierTask(finding),
|
|
67
|
+
title: `verify-${index}`,
|
|
68
|
+
maxWaitMs: VERIFY_TIMEOUT_MS,
|
|
69
|
+
finalizeOnTimeout: true,
|
|
70
|
+
}, parseVerdict);
|
|
71
|
+
cost += verifyCost;
|
|
72
|
+
addTokenUsage(tokens, verifyTokens);
|
|
73
|
+
if (!value.verified) {
|
|
74
|
+
refuted.add(finding);
|
|
75
|
+
onProgress?.(` verify: dropped critical "${finding.title}" — ${value.reason || 'refuted by verifier'}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// Fail open: keep the critical if verification itself failed.
|
|
80
|
+
onProgress?.(` verify: could not verify critical "${finding.title}" (${errorMessage(error)}); keeping it`);
|
|
81
|
+
}
|
|
82
|
+
}));
|
|
83
|
+
const kept = [];
|
|
84
|
+
for (const finding of survivors) {
|
|
85
|
+
if (refuted.has(finding)) {
|
|
86
|
+
dropped.push({ finding, reason: 'refuted by verifier' });
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
kept.push(finding);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { kept, dropped, cost, tokens };
|
|
93
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { writeFile, 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 { commentMarker, parseReviewState, renderMarkdown } from '../core/render.js';
|
|
6
|
+
import { fingerprintFinding } from '../core/schema.js';
|
|
7
|
+
const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
|
8
|
+
/**
|
|
9
|
+
* Maintains exactly one PR comment, updating it in place across re-reviews (and
|
|
10
|
+
* cleaning up duplicates) so the review converges instead of churning. Runs the
|
|
11
|
+
* break-glass check first. Comment-only: never calls the review-state APIs.
|
|
12
|
+
*/
|
|
13
|
+
export class GitHubReporter {
|
|
14
|
+
options;
|
|
15
|
+
marker;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.options = options;
|
|
18
|
+
this.marker = commentMarker(options.commentTag);
|
|
19
|
+
}
|
|
20
|
+
async checkBreakGlass() {
|
|
21
|
+
const comments = await this.fetchAllComments();
|
|
22
|
+
return comments.some(comment => typeof comment.body === 'string' &&
|
|
23
|
+
comment.body.includes(this.options.breakGlassMarker) &&
|
|
24
|
+
MAINTAINER_ASSOCIATIONS.has(comment.author_association ?? ''));
|
|
25
|
+
}
|
|
26
|
+
async postSkipNote() {
|
|
27
|
+
await this.upsertComment(`${this.marker}\n🤖 AI review skipped via \`${this.options.breakGlassMarker}\`.`);
|
|
28
|
+
}
|
|
29
|
+
async report(review) {
|
|
30
|
+
// Carry forward any per-PR dismissals recorded in the existing comment so they
|
|
31
|
+
// survive re-reviews (a dismissed finding stays in the collapsed section).
|
|
32
|
+
const existing = await this.findExistingComment();
|
|
33
|
+
const dismissed = existing
|
|
34
|
+
? (parseReviewState(existing.body, this.options.commentTag)?.dismissed ?? [])
|
|
35
|
+
: [];
|
|
36
|
+
await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, this.linkContext()));
|
|
37
|
+
}
|
|
38
|
+
/** PR context for turning finding locations into diff-line links. */
|
|
39
|
+
linkContext() {
|
|
40
|
+
return { repo: this.options.repo, prNumber: this.options.prNumber };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Add or remove per-PR finding dismissals in the reviewer's comment and re-render
|
|
44
|
+
* it in place — no re-review needed (the comment embeds the full review state).
|
|
45
|
+
*/
|
|
46
|
+
async applyDismissal(add, remove, by, reason) {
|
|
47
|
+
const existing = await this.findExistingComment();
|
|
48
|
+
if (!existing) {
|
|
49
|
+
throw new Error('No reviewer comment found on this PR yet — run a review first.');
|
|
50
|
+
}
|
|
51
|
+
const state = parseReviewState(existing.body, this.options.commentTag);
|
|
52
|
+
if (!state) {
|
|
53
|
+
throw new Error('The reviewer comment has no embedded state (posted before dismissals existed); re-run a review first.');
|
|
54
|
+
}
|
|
55
|
+
const validFps = new Set(state.review.findings.map(fingerprintFinding));
|
|
56
|
+
const matched = add.filter(fp => validFps.has(fp));
|
|
57
|
+
const unmatched = add.filter(fp => !validFps.has(fp));
|
|
58
|
+
let dismissed = state.dismissed.filter(record => !remove.includes(record.fp));
|
|
59
|
+
for (const fp of matched) {
|
|
60
|
+
if (!dismissed.some(record => record.fp === fp)) {
|
|
61
|
+
dismissed.push({ fp, by, reason });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
await this.patchComment(existing.id, renderMarkdown(state.review, this.options.commentTag, dismissed, this.linkContext()));
|
|
65
|
+
return { dismissedCount: dismissed.length, matched, unmatched };
|
|
66
|
+
}
|
|
67
|
+
/** Newest reviewer-tagged comment (id + body), or null if none posted yet. */
|
|
68
|
+
async findExistingComment() {
|
|
69
|
+
const marked = (await this.fetchAllComments()).filter(comment => comment.body?.includes(this.marker));
|
|
70
|
+
const keep = marked[marked.length - 1];
|
|
71
|
+
return keep ? { id: keep.id, body: keep.body ?? '' } : null;
|
|
72
|
+
}
|
|
73
|
+
// Safety cap on pagination (100/page): 30 pages = 3000 comments. Bounds a
|
|
74
|
+
// pathological PR; virtually every real PR exits far earlier.
|
|
75
|
+
static MAX_COMMENT_PAGES = 30;
|
|
76
|
+
/**
|
|
77
|
+
* Fetch ALL issue comments, paginating manually (a single page's array is valid
|
|
78
|
+
* JSON; `--paginate` concatenates arrays into invalid JSON). The issue-comments
|
|
79
|
+
* endpoint does NOT honor `sort`/`direction`, so results come back oldest-first
|
|
80
|
+
* — we must page to the end to see the newest comments (our own prior comment or
|
|
81
|
+
* a recent `/skip-review` can otherwise fall outside a single 100-comment window,
|
|
82
|
+
* causing duplicate comments and missed break-glass).
|
|
83
|
+
*/
|
|
84
|
+
async fetchAllComments() {
|
|
85
|
+
const all = [];
|
|
86
|
+
for (let page = 1; page <= GitHubReporter.MAX_COMMENT_PAGES; page++) {
|
|
87
|
+
const { stdout } = await run('gh', [
|
|
88
|
+
'api',
|
|
89
|
+
'-X',
|
|
90
|
+
'GET',
|
|
91
|
+
`repos/${this.options.repo}/issues/${this.options.prNumber}/comments`,
|
|
92
|
+
'-f',
|
|
93
|
+
'per_page=100',
|
|
94
|
+
'-f',
|
|
95
|
+
`page=${page}`,
|
|
96
|
+
], { cwd: this.options.cwd });
|
|
97
|
+
let batch;
|
|
98
|
+
try {
|
|
99
|
+
batch = JSON.parse(stdout);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
if (!Array.isArray(batch) || batch.length === 0) {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
all.push(...batch);
|
|
108
|
+
if (batch.length < 100) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return all;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Update the reviewer's existing comment if present (deleting older duplicates),
|
|
116
|
+
* otherwise create it. Comments come back oldest-first, so the LAST marked one
|
|
117
|
+
* is the newest and is the keeper.
|
|
118
|
+
*/
|
|
119
|
+
async upsertComment(body) {
|
|
120
|
+
const marked = (await this.fetchAllComments()).filter(comment => comment.body?.includes(this.marker));
|
|
121
|
+
if (marked.length === 0) {
|
|
122
|
+
await this.createComment(body);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const keep = marked[marked.length - 1];
|
|
126
|
+
const duplicates = marked.slice(0, -1);
|
|
127
|
+
await this.patchComment(keep.id, body);
|
|
128
|
+
for (const duplicate of duplicates) {
|
|
129
|
+
await this.deleteComment(duplicate.id);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async withBodyFile(body, fn) {
|
|
133
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'ecr-'));
|
|
134
|
+
const jsonPath = path.join(dir, 'comment.json');
|
|
135
|
+
try {
|
|
136
|
+
await writeFile(jsonPath, JSON.stringify({ body }), 'utf8');
|
|
137
|
+
return await fn(jsonPath);
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await rm(dir, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async createComment(body) {
|
|
144
|
+
await this.withBodyFile(body, jsonPath => run('gh', [
|
|
145
|
+
'api',
|
|
146
|
+
'-X',
|
|
147
|
+
'POST',
|
|
148
|
+
`repos/${this.options.repo}/issues/${this.options.prNumber}/comments`,
|
|
149
|
+
'--input',
|
|
150
|
+
jsonPath,
|
|
151
|
+
], { cwd: this.options.cwd }));
|
|
152
|
+
}
|
|
153
|
+
async patchComment(commentId, body) {
|
|
154
|
+
await this.withBodyFile(body, jsonPath => run('gh', [
|
|
155
|
+
'api',
|
|
156
|
+
'-X',
|
|
157
|
+
'PATCH',
|
|
158
|
+
`repos/${this.options.repo}/issues/comments/${commentId}`,
|
|
159
|
+
'--input',
|
|
160
|
+
jsonPath,
|
|
161
|
+
], { cwd: this.options.cwd }));
|
|
162
|
+
}
|
|
163
|
+
async deleteComment(commentId) {
|
|
164
|
+
await run('gh', ['api', '-X', 'DELETE', `repos/${this.options.repo}/issues/comments/${commentId}`], { cwd: this.options.cwd });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { decisionExitCode, decisionLabel, groupBySeverity, sortFindings, } from '../core/render.js';
|
|
2
|
+
import { SEVERITIES } from '../core/schema.js';
|
|
3
|
+
const ESC = '';
|
|
4
|
+
const RESET = `${ESC}[0m`;
|
|
5
|
+
const BOLD = `${ESC}[1m`;
|
|
6
|
+
const DIM = `${ESC}[2m`;
|
|
7
|
+
const COLORS = {
|
|
8
|
+
critical: `${ESC}[31m`,
|
|
9
|
+
warning: `${ESC}[33m`,
|
|
10
|
+
suggestion: `${ESC}[36m`,
|
|
11
|
+
};
|
|
12
|
+
const SEVERITY_LABEL = {
|
|
13
|
+
critical: 'CRITICAL',
|
|
14
|
+
warning: 'WARNING',
|
|
15
|
+
suggestion: 'SUGGESTION',
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Prints a human-readable summary grouped by severity; honors --json; never
|
|
19
|
+
* contacts GitHub. Maps the decision to a process exit code so it works as a
|
|
20
|
+
* pre-push/pre-commit gate.
|
|
21
|
+
*/
|
|
22
|
+
export class TerminalReporter {
|
|
23
|
+
options;
|
|
24
|
+
color;
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.options = options;
|
|
27
|
+
this.color = !options.json && Boolean(process.stdout.isTTY);
|
|
28
|
+
}
|
|
29
|
+
async report(review) {
|
|
30
|
+
if (this.options.json) {
|
|
31
|
+
process.stdout.write(`${JSON.stringify(review, null, 2)}\n`);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
// The review is the primary artifact → stdout (progress goes to stderr), so
|
|
35
|
+
// `ecr review > out.txt` captures the report and redirection works.
|
|
36
|
+
process.stdout.write(this.renderPretty(review));
|
|
37
|
+
}
|
|
38
|
+
process.exitCode = this.options.noFail ? 0 : decisionExitCode(review.decision);
|
|
39
|
+
}
|
|
40
|
+
renderPretty(review) {
|
|
41
|
+
const out = [''];
|
|
42
|
+
out.push(this.paint(BOLD, `AI code review — ${decisionLabel(review.decision)}`));
|
|
43
|
+
out.push(this.tally(review.findings), '');
|
|
44
|
+
out.push(review.summary, '');
|
|
45
|
+
if (review.incomplete.length > 0) {
|
|
46
|
+
out.push(this.paint(BOLD, '⏱️ Coverage note: some passes did not finish (partial coverage):'));
|
|
47
|
+
for (const note of review.incomplete) {
|
|
48
|
+
out.push(this.paint(DIM, ` - ${note}`));
|
|
49
|
+
}
|
|
50
|
+
out.push('');
|
|
51
|
+
}
|
|
52
|
+
if (review.findings.length === 0) {
|
|
53
|
+
out.push(this.paint(DIM, 'No findings.'), '');
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const groups = groupBySeverity(sortFindings(review.findings));
|
|
57
|
+
for (const severity of SEVERITIES) {
|
|
58
|
+
const findings = groups[severity];
|
|
59
|
+
if (findings.length === 0) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
out.push(this.paint(`${BOLD}${COLORS[severity]}`, `${SEVERITY_LABEL[severity]} (${findings.length})`), '');
|
|
63
|
+
for (const finding of findings) {
|
|
64
|
+
out.push(this.renderFinding(finding));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return `${out.join('\n')}\n`;
|
|
69
|
+
}
|
|
70
|
+
/** One-line count headline, e.g. "2 critical · 5 warning". */
|
|
71
|
+
tally(findings) {
|
|
72
|
+
const parts = SEVERITIES.map(severity => {
|
|
73
|
+
const n = findings.filter(finding => finding.severity === severity).length;
|
|
74
|
+
return n > 0 ? this.paint(COLORS[severity], `${n} ${severity}`) : null;
|
|
75
|
+
}).filter((part) => part !== null);
|
|
76
|
+
return parts.length > 0 ? parts.join(this.paint(DIM, ' · ')) : this.paint(DIM, 'no findings');
|
|
77
|
+
}
|
|
78
|
+
renderFinding(finding) {
|
|
79
|
+
const loc = finding.line != null ? `${finding.file}:${finding.line}` : finding.file;
|
|
80
|
+
const lines = [
|
|
81
|
+
` ${finding.title} ${this.paint(DIM, `(${finding.category})`)}`,
|
|
82
|
+
` ${this.paint(DIM, loc)}`,
|
|
83
|
+
` ${finding.rationale}`,
|
|
84
|
+
];
|
|
85
|
+
if (finding.suggestion) {
|
|
86
|
+
lines.push(` ${this.paint(DIM, 'Suggestion:')} ${finding.suggestion}`);
|
|
87
|
+
}
|
|
88
|
+
return `${lines.join('\n')}\n`;
|
|
89
|
+
}
|
|
90
|
+
paint(codes, text) {
|
|
91
|
+
return this.color ? `${codes}${text}${RESET}` : text;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { run } from '../core/exec.js';
|
|
2
|
+
import { parseUnifiedDiff } from '../core/diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* Pulls PR diff + metadata through the `gh` CLI, which is preinstalled and
|
|
5
|
+
* authenticated on GitHub Actions runners via GH_TOKEN.
|
|
6
|
+
*/
|
|
7
|
+
export class GitHubPRSource {
|
|
8
|
+
options;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
12
|
+
repoArgs() {
|
|
13
|
+
return this.options.repo ? ['--repo', this.options.repo] : [];
|
|
14
|
+
}
|
|
15
|
+
async getMetadata() {
|
|
16
|
+
const { stdout } = await run('gh', [
|
|
17
|
+
'pr',
|
|
18
|
+
'view',
|
|
19
|
+
String(this.options.prNumber),
|
|
20
|
+
...this.repoArgs(),
|
|
21
|
+
'--json',
|
|
22
|
+
'title,body,baseRefName,headRefName',
|
|
23
|
+
], { cwd: this.options.cwd });
|
|
24
|
+
const parsed = JSON.parse(stdout);
|
|
25
|
+
return {
|
|
26
|
+
title: parsed.title ?? '',
|
|
27
|
+
body: parsed.body ?? '',
|
|
28
|
+
baseRef: parsed.baseRefName ?? '',
|
|
29
|
+
headRef: parsed.headRefName ?? '',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async getChangedFiles() {
|
|
33
|
+
const { stdout } = await run('gh', ['pr', 'diff', String(this.options.prNumber), ...this.repoArgs()], { cwd: this.options.cwd });
|
|
34
|
+
return parseUnifiedDiff(stdout);
|
|
35
|
+
}
|
|
36
|
+
}
|