@jphutchins/code-review 0.1.0-alpha.1
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 +103 -0
- package/dist/index.js +1627 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
- package/schema/VERSIONING.md +92 -0
- package/schema/findings.schema.json +88 -0
- package/schema/prices.example.json +9 -0
- package/schema/prices.schema.json +54 -0
- package/schema/triage.schema.json +19 -0
- package/templates/comment.eta +80 -0
- package/templates/inline.eta +9 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1627 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { defineCommand, runMain } from 'citty';
|
|
3
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
4
|
+
import { resolve as resolve$1, join } from 'path';
|
|
5
|
+
import { Eta } from 'eta';
|
|
6
|
+
import parseDiff from 'parse-diff';
|
|
7
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
8
|
+
import _addFormats from 'ajv-formats';
|
|
9
|
+
import * as t from 'io-ts';
|
|
10
|
+
import { execFile } from 'child_process';
|
|
11
|
+
|
|
12
|
+
// src/cost.ts
|
|
13
|
+
var defaultWarn = (message) => {
|
|
14
|
+
process.stderr.write(`${message}
|
|
15
|
+
`);
|
|
16
|
+
};
|
|
17
|
+
var computeModelCost = (entry, prices, warn) => {
|
|
18
|
+
const p = prices.models[entry.model];
|
|
19
|
+
const cacheRead = entry.cache_read_tokens ?? 0;
|
|
20
|
+
const cacheWrite = entry.cache_write_tokens ?? 0;
|
|
21
|
+
if (!p) {
|
|
22
|
+
warn(
|
|
23
|
+
`code-review cost: unknown model "${entry.model}" \u2014 no entry in price map; cost for this model set to $0`
|
|
24
|
+
);
|
|
25
|
+
return {
|
|
26
|
+
model: entry.model,
|
|
27
|
+
inputTokens: entry.input_tokens,
|
|
28
|
+
outputTokens: entry.output_tokens,
|
|
29
|
+
cacheReadTokens: cacheRead,
|
|
30
|
+
cacheWriteTokens: cacheWrite,
|
|
31
|
+
costUSD: 0
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const costUSD = (entry.input_tokens * p.in + entry.output_tokens * p.out + cacheRead * p.cache_read + cacheWrite * p.cache_write) / 1e6;
|
|
35
|
+
return {
|
|
36
|
+
model: entry.model,
|
|
37
|
+
inputTokens: entry.input_tokens,
|
|
38
|
+
outputTokens: entry.output_tokens,
|
|
39
|
+
cacheReadTokens: cacheRead,
|
|
40
|
+
cacheWriteTokens: cacheWrite,
|
|
41
|
+
costUSD
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
var computeCost = (models, prices, warn = defaultWarn) => {
|
|
45
|
+
const lines = models.map((entry) => computeModelCost(entry, prices, warn));
|
|
46
|
+
return {
|
|
47
|
+
lines,
|
|
48
|
+
totalInputTokens: lines.reduce((s, l) => s + l.inputTokens, 0),
|
|
49
|
+
totalOutputTokens: lines.reduce((s, l) => s + l.outputTokens, 0),
|
|
50
|
+
totalCacheReadTokens: lines.reduce((s, l) => s + l.cacheReadTokens, 0),
|
|
51
|
+
totalCacheWriteTokens: lines.reduce((s, l) => s + l.cacheWriteTokens, 0),
|
|
52
|
+
totalCostUSD: lines.reduce((s, l) => s + l.costUSD, 0)
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// src/render.ts
|
|
57
|
+
var escapeBackticks = (text) => text.replace(/```/g, "`` ` ``");
|
|
58
|
+
var escapePipes = (text) => text.replace(/\|/g, "\\|");
|
|
59
|
+
var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
|
|
60
|
+
var sanitizeFinding = (f) => ({
|
|
61
|
+
...f,
|
|
62
|
+
title: escapePipes(f.title),
|
|
63
|
+
path: escapeCodeBackticks(f.path),
|
|
64
|
+
suggestion: f.suggestion ? escapeBackticks(f.suggestion) : f.suggestion
|
|
65
|
+
});
|
|
66
|
+
var render = (input) => {
|
|
67
|
+
const eta = new Eta({ autoTrim: false });
|
|
68
|
+
const usageAvailable = input.envelope !== null;
|
|
69
|
+
const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
|
|
70
|
+
const route = input.route;
|
|
71
|
+
const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
|
|
72
|
+
const findings = input.findings.findings.map(sanitizeFinding);
|
|
73
|
+
const safeFindings = { ...input.findings, findings };
|
|
74
|
+
const uniqueFiles = [...new Set(findings.map((f) => f.path))];
|
|
75
|
+
return eta.renderString(input.template, {
|
|
76
|
+
findings: safeFindings,
|
|
77
|
+
envelope: input.envelope,
|
|
78
|
+
usageAvailable,
|
|
79
|
+
costReport,
|
|
80
|
+
route,
|
|
81
|
+
effort: input.effort ?? null,
|
|
82
|
+
modelNames,
|
|
83
|
+
testReport: input.testReport ?? null,
|
|
84
|
+
reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
|
|
85
|
+
totalCount: findings.length,
|
|
86
|
+
fileCount: uniqueFiles.length,
|
|
87
|
+
// REC-CO-1: nits (and only nits) fold into <details>; everything else stays visible.
|
|
88
|
+
visibleFindings: findings.filter((f) => f.severity !== "nit"),
|
|
89
|
+
nitFindings: findings.filter((f) => f.severity === "nit"),
|
|
90
|
+
suggestionCount: findings.filter((f) => f.suggestion).length,
|
|
91
|
+
formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
|
|
92
|
+
formatCost: (n) => Number.isFinite(n) ? `$${n.toFixed(3)}` : "\u2014",
|
|
93
|
+
formatDuration: (ms) => {
|
|
94
|
+
if (!Number.isFinite(ms) || ms < 0) return "\u2014";
|
|
95
|
+
const s = Math.round(ms / 1e3);
|
|
96
|
+
return s >= 60 ? `${String(Math.floor(s / 60))}m ${String(s % 60)}s` : `${String(s)}s`;
|
|
97
|
+
},
|
|
98
|
+
verdictBadge: (v) => {
|
|
99
|
+
switch (v) {
|
|
100
|
+
case "approve":
|
|
101
|
+
return "\u2705 approved";
|
|
102
|
+
case "comment":
|
|
103
|
+
return "\u{1F4AC} comment";
|
|
104
|
+
case "changes":
|
|
105
|
+
return "\u{1F527} changes requested";
|
|
106
|
+
default:
|
|
107
|
+
return `\u2753 ${v}`;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
severityEmoji: (s) => {
|
|
111
|
+
switch (s) {
|
|
112
|
+
case "critical":
|
|
113
|
+
return "\u{1F534}";
|
|
114
|
+
case "major":
|
|
115
|
+
return "\u{1F7E0}";
|
|
116
|
+
case "minor":
|
|
117
|
+
return "\u{1F535}";
|
|
118
|
+
case "nit":
|
|
119
|
+
return "\u26AA";
|
|
120
|
+
default:
|
|
121
|
+
return "\u2753";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
var key = (path, line) => `${path}:${String(line)}`;
|
|
127
|
+
var indexChanges = (keys, fpath, changes) => {
|
|
128
|
+
for (const change of changes) {
|
|
129
|
+
if (change.type === "normal") {
|
|
130
|
+
if (change.ln1 !== void 0) keys.add(key(fpath, change.ln1));
|
|
131
|
+
if (change.ln2 !== void 0) keys.add(key(fpath, change.ln2));
|
|
132
|
+
} else if (change.ln !== void 0) {
|
|
133
|
+
keys.add(key(fpath, change.ln));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
var indexDiff = (diff) => {
|
|
138
|
+
const files = parseDiff(diff);
|
|
139
|
+
if (!Array.isArray(files)) return /* @__PURE__ */ new Set();
|
|
140
|
+
const keys = /* @__PURE__ */ new Set();
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
const primary = file.from ?? file.to;
|
|
143
|
+
if (!primary) continue;
|
|
144
|
+
indexChanges(
|
|
145
|
+
keys,
|
|
146
|
+
primary,
|
|
147
|
+
file.chunks.flatMap((c) => c.changes)
|
|
148
|
+
);
|
|
149
|
+
if (file.to && file.to !== "/dev/null" && file.to !== primary) {
|
|
150
|
+
indexChanges(
|
|
151
|
+
keys,
|
|
152
|
+
file.to,
|
|
153
|
+
file.chunks.flatMap((c) => c.changes)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return keys;
|
|
158
|
+
};
|
|
159
|
+
var isInDiff = (index, path, line) => index.has(key(path, line));
|
|
160
|
+
var isEmptyDiff = (diff) => {
|
|
161
|
+
if (diff.trim().length === 0) return true;
|
|
162
|
+
const files = parseDiff(diff);
|
|
163
|
+
return !Array.isArray(files) || files.length === 0 || files.every((f) => f.chunks.length === 0);
|
|
164
|
+
};
|
|
165
|
+
var defaultSide = (side) => side === "LEFT" ? "LEFT" : "RIGHT";
|
|
166
|
+
var partitionFindings = (findings, index) => {
|
|
167
|
+
const inDiff = [];
|
|
168
|
+
const strays = [];
|
|
169
|
+
for (const f of findings) {
|
|
170
|
+
if (isInDiff(index, f.path, f.start_line) && isInDiff(index, f.path, f.end_line)) {
|
|
171
|
+
inDiff.push(f);
|
|
172
|
+
} else {
|
|
173
|
+
strays.push(f);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { inDiff, strays };
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// src/inline.ts
|
|
180
|
+
var escapeBackticks2 = (text) => text.replace(/```/g, "`` ` ``");
|
|
181
|
+
var buildCommentBody = (f) => {
|
|
182
|
+
const parts = [f.body];
|
|
183
|
+
if (f.suggestion !== null && f.suggestion !== void 0) {
|
|
184
|
+
const safe = escapeBackticks2(f.suggestion);
|
|
185
|
+
parts.push(`\`\`\`suggestion
|
|
186
|
+
${safe}
|
|
187
|
+
\`\`\``);
|
|
188
|
+
}
|
|
189
|
+
return parts.join("\n\n");
|
|
190
|
+
};
|
|
191
|
+
var renderCommentBody = (f, eta, template) => {
|
|
192
|
+
return eta.renderString(template, {
|
|
193
|
+
...f,
|
|
194
|
+
suggestion: f.suggestion !== null && f.suggestion !== void 0 ? escapeBackticks2(f.suggestion) : null
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
var buildInlineComments = (findings, diff, inlineTemplate) => {
|
|
198
|
+
const index = indexDiff(diff);
|
|
199
|
+
const { inDiff, strays } = partitionFindings(findings, index);
|
|
200
|
+
const eta = inlineTemplate ? new Eta({ autoTrim: false }) : null;
|
|
201
|
+
const comments = inDiff.map((f) => {
|
|
202
|
+
const comment = {
|
|
203
|
+
path: f.path,
|
|
204
|
+
line: f.end_line,
|
|
205
|
+
side: defaultSide(f.side),
|
|
206
|
+
body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate) : buildCommentBody(f)
|
|
207
|
+
};
|
|
208
|
+
if (f.start_line < f.end_line) {
|
|
209
|
+
return {
|
|
210
|
+
...comment,
|
|
211
|
+
start_line: f.start_line,
|
|
212
|
+
start_side: defaultSide(f.side)
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return comment;
|
|
216
|
+
});
|
|
217
|
+
return { comments, strays };
|
|
218
|
+
};
|
|
219
|
+
var renderStraysSection = (strays) => {
|
|
220
|
+
if (strays.length === 0) return "";
|
|
221
|
+
const items = strays.map(
|
|
222
|
+
(f) => `- **${f.severity}** \xB7 \`${f.path}:${String(f.start_line)}\` \u2014 ${f.title}`
|
|
223
|
+
);
|
|
224
|
+
return [
|
|
225
|
+
"",
|
|
226
|
+
"---",
|
|
227
|
+
"",
|
|
228
|
+
"#### \u26A0\uFE0F Findings on lines not in the diff (demoted to summary)",
|
|
229
|
+
"",
|
|
230
|
+
...items
|
|
231
|
+
].join("\n");
|
|
232
|
+
};
|
|
233
|
+
var SeverityCodec = t.union([
|
|
234
|
+
t.literal("critical"),
|
|
235
|
+
t.literal("major"),
|
|
236
|
+
t.literal("minor"),
|
|
237
|
+
t.literal("nit")
|
|
238
|
+
]);
|
|
239
|
+
var SideCodec = t.union([t.literal("RIGHT"), t.literal("LEFT")]);
|
|
240
|
+
var VerdictCodec = t.union([t.literal("approve"), t.literal("comment"), t.literal("changes")]);
|
|
241
|
+
var LineNumber = t.refinement(
|
|
242
|
+
t.number,
|
|
243
|
+
(n) => Number.isInteger(n) && n >= 1,
|
|
244
|
+
"LineNumber"
|
|
245
|
+
);
|
|
246
|
+
var Confidence = t.refinement(t.number, (n) => n >= 0 && n <= 1, "Confidence");
|
|
247
|
+
var SCHEMA_VERSION_RE = /^(0|[1-9]\d*)\.(\d+)\.(\d+)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
248
|
+
var SchemaVersion = t.refinement(
|
|
249
|
+
t.string,
|
|
250
|
+
(s) => SCHEMA_VERSION_RE.test(s),
|
|
251
|
+
"SchemaVersion"
|
|
252
|
+
);
|
|
253
|
+
var FindingShape = t.intersection([
|
|
254
|
+
t.type({
|
|
255
|
+
path: t.string,
|
|
256
|
+
start_line: LineNumber,
|
|
257
|
+
end_line: LineNumber,
|
|
258
|
+
severity: SeverityCodec,
|
|
259
|
+
title: t.string,
|
|
260
|
+
body: t.string
|
|
261
|
+
}),
|
|
262
|
+
t.partial({
|
|
263
|
+
side: SideCodec,
|
|
264
|
+
suggestion: t.union([t.string, t.null]),
|
|
265
|
+
confidence: Confidence,
|
|
266
|
+
code: t.string,
|
|
267
|
+
code_url: t.string
|
|
268
|
+
})
|
|
269
|
+
]);
|
|
270
|
+
var EndGeStart = t.refinement(
|
|
271
|
+
FindingShape,
|
|
272
|
+
(f) => f.end_line >= f.start_line,
|
|
273
|
+
"EndGeStart"
|
|
274
|
+
);
|
|
275
|
+
var FindingCodec = t.exact(EndGeStart);
|
|
276
|
+
var FindingsCodec = t.exact(
|
|
277
|
+
t.type({
|
|
278
|
+
schema_version: SchemaVersion,
|
|
279
|
+
summary: t.string,
|
|
280
|
+
verdict: VerdictCodec,
|
|
281
|
+
findings: t.array(FindingCodec)
|
|
282
|
+
})
|
|
283
|
+
);
|
|
284
|
+
var TriageCodec = t.type({
|
|
285
|
+
safe: t.boolean,
|
|
286
|
+
reasons: t.string
|
|
287
|
+
});
|
|
288
|
+
var TokenCount = t.refinement(
|
|
289
|
+
t.number,
|
|
290
|
+
(n) => Number.isInteger(n) && n >= 0,
|
|
291
|
+
"TokenCount"
|
|
292
|
+
);
|
|
293
|
+
var ModelUsageEntryCodec = t.intersection([
|
|
294
|
+
t.type({
|
|
295
|
+
model: t.string,
|
|
296
|
+
input_tokens: TokenCount,
|
|
297
|
+
output_tokens: TokenCount
|
|
298
|
+
}),
|
|
299
|
+
t.partial({
|
|
300
|
+
cache_read_tokens: TokenCount,
|
|
301
|
+
cache_write_tokens: TokenCount
|
|
302
|
+
})
|
|
303
|
+
]);
|
|
304
|
+
var ResultEnvelopeCodec = t.intersection([
|
|
305
|
+
t.type({
|
|
306
|
+
schema_version: t.string,
|
|
307
|
+
findings: FindingsCodec,
|
|
308
|
+
models: t.array(ModelUsageEntryCodec),
|
|
309
|
+
turns: TokenCount,
|
|
310
|
+
duration_ms: TokenCount
|
|
311
|
+
}),
|
|
312
|
+
t.partial({
|
|
313
|
+
vendor_cost_usd: t.union([t.number, t.null])
|
|
314
|
+
})
|
|
315
|
+
]);
|
|
316
|
+
var ModelPricesCodec = t.type({
|
|
317
|
+
in: t.number,
|
|
318
|
+
out: t.number,
|
|
319
|
+
cache_read: t.number,
|
|
320
|
+
cache_write: t.number
|
|
321
|
+
});
|
|
322
|
+
var PriceMapCodec = t.type({
|
|
323
|
+
_updated: t.string,
|
|
324
|
+
_unit: t.string,
|
|
325
|
+
models: t.record(t.string, ModelPricesCodec)
|
|
326
|
+
});
|
|
327
|
+
var TestFailureCodec = t.intersection([
|
|
328
|
+
t.type({ name: t.string }),
|
|
329
|
+
t.partial({ message: t.string })
|
|
330
|
+
]);
|
|
331
|
+
var TestSummaryCodec = t.intersection([
|
|
332
|
+
t.type({
|
|
333
|
+
passed: t.number,
|
|
334
|
+
failed: t.number,
|
|
335
|
+
total: t.number
|
|
336
|
+
}),
|
|
337
|
+
t.partial({
|
|
338
|
+
failures: t.array(TestFailureCodec)
|
|
339
|
+
})
|
|
340
|
+
]);
|
|
341
|
+
var DEFAULT_SCHEMA_VERSION = "0.2.0";
|
|
342
|
+
|
|
343
|
+
// src/validate.ts
|
|
344
|
+
var addFormats = _addFormats;
|
|
345
|
+
var validatorCache = /* @__PURE__ */ new Map();
|
|
346
|
+
var compileSchema = (schemaPath) => {
|
|
347
|
+
const cached = validatorCache.get(schemaPath);
|
|
348
|
+
if (cached) return cached;
|
|
349
|
+
let schemaJson;
|
|
350
|
+
try {
|
|
351
|
+
schemaJson = readFileSync(schemaPath, "utf-8");
|
|
352
|
+
} catch {
|
|
353
|
+
throw new Error(`Cannot read schema file: ${schemaPath}`);
|
|
354
|
+
}
|
|
355
|
+
let schema;
|
|
356
|
+
try {
|
|
357
|
+
schema = JSON.parse(schemaJson);
|
|
358
|
+
} catch {
|
|
359
|
+
throw new Error(`Invalid JSON in schema file: ${schemaPath}`);
|
|
360
|
+
}
|
|
361
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true });
|
|
362
|
+
addFormats(ajv);
|
|
363
|
+
const validator = ajv.compile(schema);
|
|
364
|
+
validatorCache.set(schemaPath, validator);
|
|
365
|
+
return validator;
|
|
366
|
+
};
|
|
367
|
+
var validateAgainstSchema = (findings, schemaPath) => {
|
|
368
|
+
const validator = compileSchema(schemaPath);
|
|
369
|
+
const valid = validator(findings);
|
|
370
|
+
const errors = valid || !validator.errors ? [] : validator.errors.map((e) => `${e.instancePath} ${e.message ?? "unknown error"}`);
|
|
371
|
+
return { valid, errors };
|
|
372
|
+
};
|
|
373
|
+
var unsafeUnwrap = (decoded) => {
|
|
374
|
+
if (decoded._tag === "Right") return decoded.right;
|
|
375
|
+
throw new Error("io-ts decode failed \u2014 data does not match expected shape");
|
|
376
|
+
};
|
|
377
|
+
var identity = (decoded) => decoded;
|
|
378
|
+
var findingsTable = [
|
|
379
|
+
{
|
|
380
|
+
minor: "0.2",
|
|
381
|
+
defaultVersion: DEFAULT_SCHEMA_VERSION,
|
|
382
|
+
schemaFile: "findings.schema.json",
|
|
383
|
+
codec: FindingsCodec,
|
|
384
|
+
normalize: identity,
|
|
385
|
+
latest: true
|
|
386
|
+
}
|
|
387
|
+
];
|
|
388
|
+
var triageTable = [
|
|
389
|
+
{
|
|
390
|
+
minor: "0.1",
|
|
391
|
+
defaultVersion: "0.1.0",
|
|
392
|
+
schemaFile: "triage.schema.json",
|
|
393
|
+
codec: TriageCodec,
|
|
394
|
+
normalize: identity,
|
|
395
|
+
latest: true
|
|
396
|
+
}
|
|
397
|
+
];
|
|
398
|
+
var pricesTable = [
|
|
399
|
+
{
|
|
400
|
+
minor: "0.1",
|
|
401
|
+
defaultVersion: "0.1.0",
|
|
402
|
+
schemaFile: "prices.schema.json",
|
|
403
|
+
codec: PriceMapCodec,
|
|
404
|
+
normalize: identity,
|
|
405
|
+
latest: true
|
|
406
|
+
}
|
|
407
|
+
];
|
|
408
|
+
var tables = {
|
|
409
|
+
findings: findingsTable,
|
|
410
|
+
triage: triageTable,
|
|
411
|
+
prices: pricesTable
|
|
412
|
+
};
|
|
413
|
+
var tableFor = (kind) => tables[kind];
|
|
414
|
+
var majorMinor = (version) => version.split(".").slice(0, 2).join(".");
|
|
415
|
+
var describeValidationError = (e) => {
|
|
416
|
+
const path = e.context.map((entry) => entry.key).filter((key2) => key2.length > 0).join(".");
|
|
417
|
+
return e.message ?? `${path || "(root)"}: invalid value ${JSON.stringify(e.value)}`;
|
|
418
|
+
};
|
|
419
|
+
var formatErrors = (errors) => errors.map(describeValidationError);
|
|
420
|
+
var declaredVersion = (raw) => typeof raw === "object" && raw !== null && "schema_version" in raw ? typeof raw.schema_version === "string" ? raw.schema_version : void 0 : void 0;
|
|
421
|
+
var supportedVersions = (kind) => tableFor(kind).map((entry) => entry.minor);
|
|
422
|
+
var defaultVersion = (kind) => {
|
|
423
|
+
const latest = tableFor(kind).find((entry) => entry.latest);
|
|
424
|
+
if (!latest) throw new Error(`Registry invariant violated \u2014 no latest entry for "${kind}"`);
|
|
425
|
+
return latest.defaultVersion;
|
|
426
|
+
};
|
|
427
|
+
var bundledSchemaPath = (relativePath) => resolve$1(import.meta.dirname, "..", "schema", relativePath);
|
|
428
|
+
var schemaPathFor = (kind, version) => {
|
|
429
|
+
const table = tableFor(kind);
|
|
430
|
+
const entry = version === void 0 ? table.find((v) => v.latest) : table.find((v) => v.minor === majorMinor(version));
|
|
431
|
+
if (!entry) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`Unsupported ${kind} schema version "${version ?? ""}" \u2014 supported: ${supportedVersions(kind).join(", ")}`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
return bundledSchemaPath(entry.schemaFile);
|
|
437
|
+
};
|
|
438
|
+
var resolveFindings = (raw) => {
|
|
439
|
+
const version = declaredVersion(raw);
|
|
440
|
+
if (version === void 0) return { kind: "missing-version" };
|
|
441
|
+
const entry = findingsTable.find((v) => v.minor === majorMinor(version));
|
|
442
|
+
if (!entry) {
|
|
443
|
+
return { kind: "unsupported-version", version, supported: supportedVersions("findings") };
|
|
444
|
+
}
|
|
445
|
+
const decoded = entry.codec.decode(raw);
|
|
446
|
+
return decoded._tag === "Left" ? { kind: "invalid-shape", errors: formatErrors(decoded.left) } : { kind: "ok", version, value: entry.normalize(decoded.right) };
|
|
447
|
+
};
|
|
448
|
+
var resolveSingleVersion = (kind, raw) => {
|
|
449
|
+
const entry = tableFor(kind)[0];
|
|
450
|
+
if (!entry) throw new Error(`Registry invariant violated \u2014 no entry for "${kind}"`);
|
|
451
|
+
const decoded = entry.codec.decode(raw);
|
|
452
|
+
return decoded._tag === "Left" ? { kind: "invalid-shape", errors: formatErrors(decoded.left) } : { kind: "ok", version: entry.defaultVersion, value: entry.normalize(decoded.right) };
|
|
453
|
+
};
|
|
454
|
+
var resolvers = {
|
|
455
|
+
findings: resolveFindings,
|
|
456
|
+
triage: (raw) => resolveSingleVersion("triage", raw),
|
|
457
|
+
prices: (raw) => resolveSingleVersion("prices", raw)
|
|
458
|
+
};
|
|
459
|
+
var resolve = (kind, raw) => resolvers[kind](raw);
|
|
460
|
+
var runGhApi = (args, stdin, env) => new Promise((resolve3, reject) => {
|
|
461
|
+
const child = execFile(
|
|
462
|
+
"gh",
|
|
463
|
+
["api", ...args],
|
|
464
|
+
{ env: { ...process.env, ...env }, encoding: "utf-8", maxBuffer: 100 * 1024 * 1024 },
|
|
465
|
+
(err, stdout, stderr) => {
|
|
466
|
+
if (err) {
|
|
467
|
+
const stderrStr = typeof stderr === "string" && stderr.trim() ? stderr.trim() : "";
|
|
468
|
+
const errStr = err instanceof Error ? err.message : "unknown error";
|
|
469
|
+
reject(new Error(`gh api failed: ${stderrStr || errStr}`));
|
|
470
|
+
} else {
|
|
471
|
+
resolve3(stdout);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
);
|
|
475
|
+
if (stdin !== void 0) {
|
|
476
|
+
child.stdin?.end(stdin);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// src/pr.ts
|
|
481
|
+
var fetchPrCandidates = async (repo, headSha, ghApi) => {
|
|
482
|
+
const stdout = await ghApi([
|
|
483
|
+
`repos/${repo}/commits/${headSha}/pulls`,
|
|
484
|
+
"--jq",
|
|
485
|
+
".[] | {number: .number, state: .state, headRef: .head.ref}"
|
|
486
|
+
]);
|
|
487
|
+
return stdout.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
488
|
+
};
|
|
489
|
+
var resolvePr = (candidates, headBranch) => {
|
|
490
|
+
if (candidates.length === 0) return { kind: "none" };
|
|
491
|
+
const scoped = candidates.length > 1 && headBranch ? candidates.filter((c) => c.headRef === headBranch) : candidates;
|
|
492
|
+
const chosen = scoped[0] ?? candidates[0];
|
|
493
|
+
if (chosen === void 0) return { kind: "none" };
|
|
494
|
+
return chosen.state === "open" ? { kind: "open", prNumber: chosen.number } : { kind: "not-open", prNumber: chosen.number, state: chosen.state };
|
|
495
|
+
};
|
|
496
|
+
var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
|
|
497
|
+
`repos/${repo}/pulls/${String(prNumber)}`,
|
|
498
|
+
"-H",
|
|
499
|
+
"Accept: application/vnd.github.v3.diff"
|
|
500
|
+
]);
|
|
501
|
+
|
|
502
|
+
// src/post.ts
|
|
503
|
+
var DEFAULT_MARKER = "<!-- code-review -->";
|
|
504
|
+
var MAX_SUGGESTION_LINES = 10;
|
|
505
|
+
var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
|
|
506
|
+
var countSuggestionLines = (text) => text.split("\n").length;
|
|
507
|
+
var checkLongSuggestions = (comments) => {
|
|
508
|
+
const longFiles = [];
|
|
509
|
+
const adjusted = comments.map((c) => {
|
|
510
|
+
const match = /```suggestion\n([\s\S]*?)\n```/.exec(c.body);
|
|
511
|
+
if (match?.[1] && countSuggestionLines(match[1]) > MAX_SUGGESTION_LINES) {
|
|
512
|
+
longFiles.push(`${c.path}:${String(c.line)}`);
|
|
513
|
+
return {
|
|
514
|
+
...c,
|
|
515
|
+
body: c.body.replace(
|
|
516
|
+
/```suggestion\n[\s\S]*?\n```/,
|
|
517
|
+
"*(suggestion omitted \u2014 exceeds GitHub's ~10-line suggestion limit; see summary)*"
|
|
518
|
+
)
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
return c;
|
|
522
|
+
});
|
|
523
|
+
return { comments: adjusted, longFiles };
|
|
524
|
+
};
|
|
525
|
+
var extractReviewedSha = (commentBody) => REVIEWED_SHA_RE.exec(commentBody)?.[1] ?? null;
|
|
526
|
+
var noticeFindings = (message) => ({
|
|
527
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
528
|
+
summary: `### \u26A0\uFE0F ${message}`,
|
|
529
|
+
verdict: "comment",
|
|
530
|
+
findings: []
|
|
531
|
+
});
|
|
532
|
+
var loadFindings = (path) => {
|
|
533
|
+
let raw;
|
|
534
|
+
try {
|
|
535
|
+
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
536
|
+
} catch {
|
|
537
|
+
return { kind: "corrupt" };
|
|
538
|
+
}
|
|
539
|
+
const resolution = resolve("findings", raw);
|
|
540
|
+
switch (resolution.kind) {
|
|
541
|
+
case "ok":
|
|
542
|
+
return { kind: "ok", findings: resolution.value };
|
|
543
|
+
case "unsupported-version":
|
|
544
|
+
return { kind: "unsupported-schema-version", version: resolution.version };
|
|
545
|
+
case "invalid-shape":
|
|
546
|
+
case "missing-version":
|
|
547
|
+
return { kind: "invalid-shape" };
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
var noticeMessageFor = (result) => {
|
|
551
|
+
switch (result.kind) {
|
|
552
|
+
case "corrupt":
|
|
553
|
+
return "Review output was missing or malformed \u2014 the review did not complete. See the workflow run for logs.";
|
|
554
|
+
case "invalid-shape":
|
|
555
|
+
return "Review output was malformed \u2014 it did not conform to the findings schema. See the workflow run for logs.";
|
|
556
|
+
case "unsupported-schema-version": {
|
|
557
|
+
const supported = supportedVersions("findings").map((minor) => `${minor}.x`).join(", ");
|
|
558
|
+
return `Review output declares schema_version "${result.version}", which this commenter does not support (supported: ${supported}). See the workflow run for logs.`;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
var loadEnvelope = (path) => {
|
|
563
|
+
let raw;
|
|
564
|
+
try {
|
|
565
|
+
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
566
|
+
} catch {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
const decoded = ResultEnvelopeCodec.decode(raw);
|
|
570
|
+
return decoded._tag === "Right" ? decoded.right : null;
|
|
571
|
+
};
|
|
572
|
+
var loadTestReport = (path) => {
|
|
573
|
+
let raw;
|
|
574
|
+
try {
|
|
575
|
+
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
576
|
+
} catch (err) {
|
|
577
|
+
process.stderr.write(
|
|
578
|
+
`Warning: could not read test report at ${path}: ${err instanceof Error ? err.message : String(err)} \u2014 omitting test panel
|
|
579
|
+
`
|
|
580
|
+
);
|
|
581
|
+
return void 0;
|
|
582
|
+
}
|
|
583
|
+
const decoded = TestSummaryCodec.decode(raw);
|
|
584
|
+
if (decoded._tag === "Left") {
|
|
585
|
+
process.stderr.write(
|
|
586
|
+
`Warning: test report at ${path} does not match the expected shape \u2014 omitting test panel
|
|
587
|
+
`
|
|
588
|
+
);
|
|
589
|
+
return void 0;
|
|
590
|
+
}
|
|
591
|
+
return decoded.right;
|
|
592
|
+
};
|
|
593
|
+
var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
|
|
594
|
+
const body = JSON.stringify({
|
|
595
|
+
body: "",
|
|
596
|
+
commit_id: headSha,
|
|
597
|
+
event: "COMMENT",
|
|
598
|
+
comments: comments.map((c) => ({
|
|
599
|
+
path: c.path,
|
|
600
|
+
line: c.line,
|
|
601
|
+
side: c.side,
|
|
602
|
+
...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
|
|
603
|
+
body: c.body
|
|
604
|
+
}))
|
|
605
|
+
});
|
|
606
|
+
await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"], body);
|
|
607
|
+
};
|
|
608
|
+
var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
609
|
+
const stdout = await ghApi(
|
|
610
|
+
[
|
|
611
|
+
`repos/${repo}/issues/${String(prNumber)}/comments`,
|
|
612
|
+
"--paginate",
|
|
613
|
+
"--jq",
|
|
614
|
+
".[] | select(.user.login == env.CODE_REVIEW_BOT_LOGIN and (.body | startswith(env.CODE_REVIEW_MARKER))) | {id: .id, body: .body}"
|
|
615
|
+
],
|
|
616
|
+
void 0,
|
|
617
|
+
{ CODE_REVIEW_BOT_LOGIN: botLogin, CODE_REVIEW_MARKER: marker }
|
|
618
|
+
);
|
|
619
|
+
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
620
|
+
if (lines.length === 0) return null;
|
|
621
|
+
const last = lines[lines.length - 1] ?? null;
|
|
622
|
+
if (last === null) return null;
|
|
623
|
+
const parsed = JSON.parse(last);
|
|
624
|
+
return { id: parsed.id, body: parsed.body };
|
|
625
|
+
};
|
|
626
|
+
var patchComment = async (repo, commentId, body, ghApi) => {
|
|
627
|
+
await ghApi(
|
|
628
|
+
[`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
|
|
629
|
+
JSON.stringify({ body })
|
|
630
|
+
);
|
|
631
|
+
};
|
|
632
|
+
var postComment = async (repo, prNumber, body, ghApi) => {
|
|
633
|
+
await ghApi(
|
|
634
|
+
[`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
|
|
635
|
+
JSON.stringify({ body })
|
|
636
|
+
);
|
|
637
|
+
};
|
|
638
|
+
var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
|
|
639
|
+
if (existing !== null) {
|
|
640
|
+
await patchComment(repo, existing.id, body, ghApi);
|
|
641
|
+
process.stderr.write(
|
|
642
|
+
`Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
|
|
643
|
+
`
|
|
644
|
+
);
|
|
645
|
+
} else {
|
|
646
|
+
await postComment(repo, prNumber, body, ghApi);
|
|
647
|
+
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
648
|
+
`);
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
|
|
652
|
+
var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
|
|
653
|
+
const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
|
|
654
|
+
let reviews;
|
|
655
|
+
try {
|
|
656
|
+
reviews = JSON.parse(stdout || "[]");
|
|
657
|
+
} catch {
|
|
658
|
+
return [];
|
|
659
|
+
}
|
|
660
|
+
if (!Array.isArray(reviews)) return [];
|
|
661
|
+
return reviews.filter((r) => isBotReview(r)).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => r.id);
|
|
662
|
+
};
|
|
663
|
+
var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
664
|
+
const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
|
|
665
|
+
for (const id of ids) {
|
|
666
|
+
try {
|
|
667
|
+
await ghApi(
|
|
668
|
+
[
|
|
669
|
+
`repos/${repo}/pulls/${String(prNumber)}/reviews/${String(id)}/dismissals`,
|
|
670
|
+
"-X",
|
|
671
|
+
"PUT",
|
|
672
|
+
"--input",
|
|
673
|
+
"-"
|
|
674
|
+
],
|
|
675
|
+
JSON.stringify({ message: "Superseded by a new review for an updated commit." })
|
|
676
|
+
);
|
|
677
|
+
} catch (err) {
|
|
678
|
+
process.stderr.write(
|
|
679
|
+
`Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
|
|
680
|
+
`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
var post = async (input, ghApi = runGhApi) => {
|
|
686
|
+
const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
|
|
687
|
+
const resolution = resolvePr(candidates, input.headBranch);
|
|
688
|
+
if (resolution.kind === "none") {
|
|
689
|
+
process.stderr.write(`No open PR for ${input.headSha} \u2014 nothing to post
|
|
690
|
+
`);
|
|
691
|
+
process.exit(0);
|
|
692
|
+
}
|
|
693
|
+
if (resolution.kind === "not-open") {
|
|
694
|
+
process.stderr.write(
|
|
695
|
+
`PR #${String(resolution.prNumber)} for ${input.headSha} is not open (state: ${resolution.state}) \u2014 nothing to post
|
|
696
|
+
`
|
|
697
|
+
);
|
|
698
|
+
process.exit(0);
|
|
699
|
+
}
|
|
700
|
+
const prNumber = resolution.prNumber;
|
|
701
|
+
const diff = await fetchDiff(input.repo, prNumber, ghApi);
|
|
702
|
+
const existingSticky = await findBotComment(
|
|
703
|
+
input.repo,
|
|
704
|
+
prNumber,
|
|
705
|
+
input.botLogin,
|
|
706
|
+
DEFAULT_MARKER,
|
|
707
|
+
ghApi
|
|
708
|
+
);
|
|
709
|
+
const previousReviewedSha = existingSticky ? extractReviewedSha(existingSticky.body) : null;
|
|
710
|
+
const isRerunOfSameSha = previousReviewedSha !== null && previousReviewedSha === input.headSha;
|
|
711
|
+
const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
|
|
712
|
+
const decodedPrices = PriceMapCodec.decode(prices);
|
|
713
|
+
if (decodedPrices._tag === "Left") {
|
|
714
|
+
throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
|
|
715
|
+
}
|
|
716
|
+
const template = readFileSync(input.templatePath, "utf-8");
|
|
717
|
+
const inlineTemplate = input.inlineTemplatePath ? readFileSync(input.inlineTemplatePath, "utf-8") : void 0;
|
|
718
|
+
const renderNotice = (message) => render({
|
|
719
|
+
findings: noticeFindings(message),
|
|
720
|
+
envelope: null,
|
|
721
|
+
prices: decodedPrices.right,
|
|
722
|
+
template,
|
|
723
|
+
route: input.route,
|
|
724
|
+
reviewedSha: input.headSha,
|
|
725
|
+
effort: input.effort
|
|
726
|
+
});
|
|
727
|
+
if (isEmptyDiff(diff)) {
|
|
728
|
+
await upsertSticky(
|
|
729
|
+
input.repo,
|
|
730
|
+
prNumber,
|
|
731
|
+
existingSticky,
|
|
732
|
+
renderNotice("The diff for this PR is empty \u2014 nothing to review."),
|
|
733
|
+
ghApi
|
|
734
|
+
);
|
|
735
|
+
process.exit(0);
|
|
736
|
+
}
|
|
737
|
+
const findingsResult = loadFindings(input.findingsPath);
|
|
738
|
+
if (findingsResult.kind !== "ok") {
|
|
739
|
+
await upsertSticky(
|
|
740
|
+
input.repo,
|
|
741
|
+
prNumber,
|
|
742
|
+
existingSticky,
|
|
743
|
+
renderNotice(noticeMessageFor(findingsResult)),
|
|
744
|
+
ghApi
|
|
745
|
+
);
|
|
746
|
+
process.exit(0);
|
|
747
|
+
}
|
|
748
|
+
const findings = findingsResult.findings;
|
|
749
|
+
const envelope = loadEnvelope(input.envelopePath);
|
|
750
|
+
const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
|
|
751
|
+
if (envelope === null) {
|
|
752
|
+
const body2 = render({
|
|
753
|
+
findings,
|
|
754
|
+
envelope: null,
|
|
755
|
+
prices: decodedPrices.right,
|
|
756
|
+
template,
|
|
757
|
+
route: input.route,
|
|
758
|
+
reviewedSha: input.headSha,
|
|
759
|
+
effort: input.effort,
|
|
760
|
+
testReport
|
|
761
|
+
});
|
|
762
|
+
await upsertSticky(input.repo, prNumber, existingSticky, body2, ghApi);
|
|
763
|
+
process.stderr.write(
|
|
764
|
+
"Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
|
|
765
|
+
);
|
|
766
|
+
process.exit(0);
|
|
767
|
+
}
|
|
768
|
+
const { comments: rawComments, strays } = buildInlineComments(
|
|
769
|
+
findings.findings,
|
|
770
|
+
diff,
|
|
771
|
+
inlineTemplate
|
|
772
|
+
);
|
|
773
|
+
const { comments, longFiles } = checkLongSuggestions(rawComments);
|
|
774
|
+
for (const wf of longFiles) {
|
|
775
|
+
process.stderr.write(
|
|
776
|
+
`Warning: suggestion in ${wf} exceeds ${String(MAX_SUGGESTION_LINES)} lines \u2014 omitted from inline to avoid 422
|
|
777
|
+
`
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
let body = render({
|
|
781
|
+
findings,
|
|
782
|
+
envelope,
|
|
783
|
+
prices: decodedPrices.right,
|
|
784
|
+
template,
|
|
785
|
+
route: input.route,
|
|
786
|
+
reviewedSha: input.headSha,
|
|
787
|
+
effort: input.effort,
|
|
788
|
+
testReport
|
|
789
|
+
});
|
|
790
|
+
const straysMd = renderStraysSection(strays);
|
|
791
|
+
if (straysMd.length > 0) body += straysMd;
|
|
792
|
+
if (longFiles.length > 0) {
|
|
793
|
+
body += `
|
|
794
|
+
|
|
795
|
+
---
|
|
796
|
+
|
|
797
|
+
> **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from inline comments. See the findings above for details.
|
|
798
|
+
`;
|
|
799
|
+
}
|
|
800
|
+
if (!isRerunOfSameSha && previousReviewedSha !== null) {
|
|
801
|
+
await dismissPriorBotReviews(input.repo, prNumber, input.botLogin, ghApi);
|
|
802
|
+
}
|
|
803
|
+
await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
|
|
804
|
+
if (isRerunOfSameSha) {
|
|
805
|
+
process.stderr.write(
|
|
806
|
+
`Head SHA ${input.headSha} matches the previous review \u2014 updated sticky only, no new inline review
|
|
807
|
+
`
|
|
808
|
+
);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (comments.length > 0) {
|
|
812
|
+
await postInlineReview(input.repo, prNumber, input.headSha, comments, ghApi);
|
|
813
|
+
process.stderr.write(
|
|
814
|
+
`Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
|
|
815
|
+
`
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
var renderOutputs = (result) => {
|
|
820
|
+
switch (result.kind) {
|
|
821
|
+
case "skip":
|
|
822
|
+
return "skip=true\n";
|
|
823
|
+
case "gathered":
|
|
824
|
+
return `pr=${String(result.pr)}
|
|
825
|
+
conclusion=${result.conclusion}
|
|
826
|
+
diff_size=${String(result.diffSize)}
|
|
827
|
+
`;
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
var runGit = (args) => new Promise((resolve3, reject) => {
|
|
831
|
+
execFile(
|
|
832
|
+
"git",
|
|
833
|
+
[...args],
|
|
834
|
+
{ encoding: "utf-8", maxBuffer: 100 * 1024 * 1024 },
|
|
835
|
+
(err, stdout, stderr) => {
|
|
836
|
+
if (err) {
|
|
837
|
+
const stderrStr = typeof stderr === "string" && stderr.trim() ? stderr.trim() : "";
|
|
838
|
+
const errStr = err instanceof Error ? err.message : "unknown error";
|
|
839
|
+
reject(new Error(`git ${args.join(" ")} failed: ${stderrStr || errStr}`));
|
|
840
|
+
} else {
|
|
841
|
+
resolve3(stdout);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
);
|
|
845
|
+
});
|
|
846
|
+
var PrMetaCodec = t.type({
|
|
847
|
+
changed_files: t.number,
|
|
848
|
+
base_sha: t.string,
|
|
849
|
+
title: t.string,
|
|
850
|
+
body: t.union([t.string, t.null])
|
|
851
|
+
});
|
|
852
|
+
var IssueCommentCodec = t.type({
|
|
853
|
+
id: t.number,
|
|
854
|
+
body: t.union([t.string, t.null]),
|
|
855
|
+
user: t.type({ login: t.string })
|
|
856
|
+
});
|
|
857
|
+
var IssueCommentsCodec = t.array(IssueCommentCodec);
|
|
858
|
+
var JobCodec = t.type({ id: t.number, conclusion: t.union([t.string, t.null]) });
|
|
859
|
+
var JobsResponseCodec = t.type({ jobs: t.array(JobCodec) });
|
|
860
|
+
var fetchPrMeta = async (repo, prNumber, ghApi) => {
|
|
861
|
+
const stdout = await ghApi([
|
|
862
|
+
`repos/${repo}/pulls/${String(prNumber)}`,
|
|
863
|
+
"--jq",
|
|
864
|
+
"{changed_files: .changed_files, base_sha: .base.sha, title: .title, body: .body}"
|
|
865
|
+
]);
|
|
866
|
+
const decoded = PrMetaCodec.decode(JSON.parse(stdout));
|
|
867
|
+
if (decoded._tag === "Left") {
|
|
868
|
+
throw new Error(`PR metadata for #${String(prNumber)} did not match the expected shape`);
|
|
869
|
+
}
|
|
870
|
+
return decoded.right;
|
|
871
|
+
};
|
|
872
|
+
var fetchApiDiff = async (repo, prNumber, ghApi) => {
|
|
873
|
+
try {
|
|
874
|
+
return await fetchDiff(repo, prNumber, ghApi);
|
|
875
|
+
} catch {
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
var fetchPriorReview = async (repo, prNumber, botLogin, ghApi) => {
|
|
880
|
+
try {
|
|
881
|
+
const stdout = await ghApi([`repos/${repo}/issues/${String(prNumber)}/comments`, "--paginate"]);
|
|
882
|
+
const decoded = IssueCommentsCodec.decode(JSON.parse(stdout || "[]"));
|
|
883
|
+
if (decoded._tag === "Left") return null;
|
|
884
|
+
const byBot = decoded.right.filter((c) => c.user.login === botLogin);
|
|
885
|
+
const last = byBot[byBot.length - 1];
|
|
886
|
+
return last ? { id: last.id, body: last.body } : null;
|
|
887
|
+
} catch {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
|
|
892
|
+
const stdout = await ghApi([`repos/${repo}/actions/runs/${runId}/jobs`]);
|
|
893
|
+
const decoded = JobsResponseCodec.decode(JSON.parse(stdout));
|
|
894
|
+
if (decoded._tag === "Left") {
|
|
895
|
+
throw new Error(`Jobs list for run ${runId} did not match the expected shape`);
|
|
896
|
+
}
|
|
897
|
+
for (const job of decoded.right.jobs.filter((j) => j.conclusion === "failure")) {
|
|
898
|
+
try {
|
|
899
|
+
const log = await ghApi([`repos/${repo}/actions/jobs/${String(job.id)}/logs`]);
|
|
900
|
+
writeFileSync(join(outDir, `job_${String(job.id)}.log`), log);
|
|
901
|
+
} catch (err) {
|
|
902
|
+
process.stderr.write(
|
|
903
|
+
`Warning: failed to download logs for job ${String(job.id)}: ${err instanceof Error ? err.message : String(err)} \u2014 continuing with the logs retrieved so far
|
|
904
|
+
`
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
|
|
910
|
+
const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
|
|
911
|
+
const resolution = resolvePr(candidates, input.headBranch);
|
|
912
|
+
if (resolution.kind === "none") {
|
|
913
|
+
process.stderr.write(`No open PR for ${input.headSha} \u2014 nothing to review
|
|
914
|
+
`);
|
|
915
|
+
return { kind: "skip" };
|
|
916
|
+
}
|
|
917
|
+
if (resolution.kind === "not-open") {
|
|
918
|
+
process.stderr.write(
|
|
919
|
+
`PR #${String(resolution.prNumber)} for ${input.headSha} is not open (state: ${resolution.state}) \u2014 nothing to review
|
|
920
|
+
`
|
|
921
|
+
);
|
|
922
|
+
return { kind: "skip" };
|
|
923
|
+
}
|
|
924
|
+
const prNumber = resolution.prNumber;
|
|
925
|
+
const meta = await fetchPrMeta(input.repo, prNumber, ghApi);
|
|
926
|
+
const apiDiff = await fetchApiDiff(input.repo, prNumber, ghApi);
|
|
927
|
+
const diff = apiDiff !== null && !(apiDiff.length === 0 && meta.changed_files > 0) ? apiDiff : await (async () => {
|
|
928
|
+
process.stderr.write(
|
|
929
|
+
`PR diff fetch failed or was empty for ${String(meta.changed_files)} changed files \u2014 falling back to git diff
|
|
930
|
+
`
|
|
931
|
+
);
|
|
932
|
+
await gitRun(["fetch", "origin", input.headSha]);
|
|
933
|
+
return gitRun(["diff", meta.base_sha, input.headSha]);
|
|
934
|
+
})();
|
|
935
|
+
writeFileSync(join(input.outDir, "pr.diff"), diff);
|
|
936
|
+
writeFileSync(
|
|
937
|
+
join(input.outDir, "pr_context.json"),
|
|
938
|
+
JSON.stringify({ title: meta.title, body: meta.body })
|
|
939
|
+
);
|
|
940
|
+
const prior = await fetchPriorReview(input.repo, prNumber, input.botLogin, ghApi);
|
|
941
|
+
writeFileSync(
|
|
942
|
+
join(input.outDir, "prior_review.json"),
|
|
943
|
+
prior === null ? "null" : JSON.stringify(prior)
|
|
944
|
+
);
|
|
945
|
+
if (input.conclusion === "failure") {
|
|
946
|
+
await downloadFailingJobLogs(input.repo, input.runId, input.outDir, ghApi);
|
|
947
|
+
}
|
|
948
|
+
return {
|
|
949
|
+
kind: "gathered",
|
|
950
|
+
pr: prNumber,
|
|
951
|
+
conclusion: input.conclusion,
|
|
952
|
+
diffSize: Buffer.byteLength(diff, "utf8")
|
|
953
|
+
};
|
|
954
|
+
};
|
|
955
|
+
var fieldOf = (raw, key2) => typeof raw === "object" && raw !== null && key2 in raw ? raw[key2] : void 0;
|
|
956
|
+
var parseNativeForExtraction = (raw) => ({
|
|
957
|
+
result: fieldOf(raw, "result"),
|
|
958
|
+
structuredOutput: fieldOf(raw, "structured_output"),
|
|
959
|
+
isError: fieldOf(raw, "is_error"),
|
|
960
|
+
subtype: fieldOf(raw, "subtype"),
|
|
961
|
+
apiErrorStatus: fieldOf(raw, "api_error_status")
|
|
962
|
+
});
|
|
963
|
+
var isNullish = (value) => value === null || value === void 0;
|
|
964
|
+
var isErrorEnvelope = (native) => native.isError === true || !isNullish(native.subtype) && native.subtype !== "success" || !isNullish(native.apiErrorStatus);
|
|
965
|
+
var describeErrorEnvelope = (native) => {
|
|
966
|
+
const parts = [
|
|
967
|
+
...native.isError === true ? ["is_error=true"] : [],
|
|
968
|
+
...!isNullish(native.subtype) && native.subtype !== "success" ? [`subtype=${JSON.stringify(native.subtype)}`] : [],
|
|
969
|
+
...!isNullish(native.apiErrorStatus) ? [`api_error_status=${JSON.stringify(native.apiErrorStatus)}`] : []
|
|
970
|
+
];
|
|
971
|
+
return `agent run did not complete successfully (${parts.join(", ")})`;
|
|
972
|
+
};
|
|
973
|
+
var withDefaultSchemaVersion = (candidate) => typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && !("schema_version" in candidate) ? { ...candidate, schema_version: defaultVersion("findings") } : candidate;
|
|
974
|
+
var normalizeCandidate = (kind, candidate) => kind === "findings" ? withDefaultSchemaVersion(candidate) : candidate;
|
|
975
|
+
var candidateVersion = (kind, candidate) => {
|
|
976
|
+
if (kind !== "findings") return void 0;
|
|
977
|
+
const version = fieldOf(candidate, "schema_version");
|
|
978
|
+
return typeof version === "string" ? version : void 0;
|
|
979
|
+
};
|
|
980
|
+
var safeSchemaPathFor = (kind, version) => {
|
|
981
|
+
try {
|
|
982
|
+
return schemaPathFor(kind, version);
|
|
983
|
+
} catch {
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
var canonicalize = (value) => Array.isArray(value) ? value.map(canonicalize) : value !== null && typeof value === "object" ? Object.fromEntries(
|
|
988
|
+
Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => [k, canonicalize(v)])
|
|
989
|
+
) : value;
|
|
990
|
+
var dedupCandidates = (candidates) => {
|
|
991
|
+
const seen = /* @__PURE__ */ new Set();
|
|
992
|
+
return candidates.filter((c) => {
|
|
993
|
+
const key2 = JSON.stringify(canonicalize(c.candidate));
|
|
994
|
+
if (seen.has(key2)) return false;
|
|
995
|
+
seen.add(key2);
|
|
996
|
+
return true;
|
|
997
|
+
});
|
|
998
|
+
};
|
|
999
|
+
var gateCandidate = (kind, rawCandidate) => {
|
|
1000
|
+
const candidate = normalizeCandidate(kind, rawCandidate);
|
|
1001
|
+
const schemaPath = safeSchemaPathFor(kind, candidateVersion(kind, candidate));
|
|
1002
|
+
if (schemaPath === null) return null;
|
|
1003
|
+
if (!validateAgainstSchema(candidate, schemaPath).valid) return null;
|
|
1004
|
+
const resolution = resolve(kind, candidate);
|
|
1005
|
+
return resolution.kind === "ok" ? { version: resolution.version, candidate } : null;
|
|
1006
|
+
};
|
|
1007
|
+
var tryParseJson = (text) => {
|
|
1008
|
+
try {
|
|
1009
|
+
return { ok: true, value: JSON.parse(text) };
|
|
1010
|
+
} catch {
|
|
1011
|
+
return { ok: false };
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
var readFileOrNull = (path) => {
|
|
1015
|
+
try {
|
|
1016
|
+
return readFileSync(path, "utf-8");
|
|
1017
|
+
} catch {
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
var candidateFromJsonText = (kind, text) => {
|
|
1022
|
+
if (text === null) return null;
|
|
1023
|
+
const parsed = tryParseJson(text);
|
|
1024
|
+
return parsed.ok ? gateCandidate(kind, parsed.value) : null;
|
|
1025
|
+
};
|
|
1026
|
+
var FENCE_OPEN = /^\s*(`{3,})/;
|
|
1027
|
+
var FENCE_MARKER_ONLY = /^`+$/;
|
|
1028
|
+
var scanLine = (state, line) => {
|
|
1029
|
+
if (state.openLength === null) {
|
|
1030
|
+
const opened = FENCE_OPEN.exec(line)?.[1]?.length;
|
|
1031
|
+
return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
|
|
1032
|
+
}
|
|
1033
|
+
const trimmed = line.trim();
|
|
1034
|
+
const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
|
|
1035
|
+
return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
|
|
1036
|
+
};
|
|
1037
|
+
var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine, { blocks: [], openLength: null, buffer: [] }).blocks;
|
|
1038
|
+
var describeLadderFailure = (outcome) => {
|
|
1039
|
+
switch (outcome.kind) {
|
|
1040
|
+
case "error-envelope":
|
|
1041
|
+
return `review did not complete: ${outcome.detail}`;
|
|
1042
|
+
case "none":
|
|
1043
|
+
return `could not recover a validating candidate: ${outcome.detail}`;
|
|
1044
|
+
case "ambiguous":
|
|
1045
|
+
return `ambiguous candidates: ${outcome.detail}`;
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
var okOutcome = (gated) => ({
|
|
1049
|
+
kind: "ok",
|
|
1050
|
+
version: gated.version,
|
|
1051
|
+
candidate: gated.candidate
|
|
1052
|
+
});
|
|
1053
|
+
var extractStructured = (input) => {
|
|
1054
|
+
const native = parseNativeForExtraction(input.native);
|
|
1055
|
+
if (isErrorEnvelope(native)) {
|
|
1056
|
+
return { kind: "error-envelope", detail: describeErrorEnvelope(native) };
|
|
1057
|
+
}
|
|
1058
|
+
if (input.kind === "findings" && input.agentFilePath !== void 0) {
|
|
1059
|
+
const fromFile = candidateFromJsonText(input.kind, readFileOrNull(input.agentFilePath));
|
|
1060
|
+
if (fromFile) return okOutcome(fromFile);
|
|
1061
|
+
}
|
|
1062
|
+
if (native.structuredOutput !== void 0) {
|
|
1063
|
+
const fromStructured = gateCandidate(input.kind, native.structuredOutput);
|
|
1064
|
+
if (fromStructured) return okOutcome(fromStructured);
|
|
1065
|
+
}
|
|
1066
|
+
if (typeof native.result === "string") {
|
|
1067
|
+
const fromResult = candidateFromJsonText(input.kind, native.result.trim());
|
|
1068
|
+
if (fromResult) return okOutcome(fromResult);
|
|
1069
|
+
const fencedCandidates = dedupCandidates(
|
|
1070
|
+
scanFencedBlocks(native.result).map((block) => candidateFromJsonText(input.kind, block)).filter((gated) => gated !== null)
|
|
1071
|
+
);
|
|
1072
|
+
const [survivor, ...rest] = fencedCandidates;
|
|
1073
|
+
if (survivor !== void 0 && rest.length === 0) return okOutcome(survivor);
|
|
1074
|
+
if (survivor !== void 0) {
|
|
1075
|
+
return {
|
|
1076
|
+
kind: "ambiguous",
|
|
1077
|
+
detail: `${String(fencedCandidates.length)} distinct fenced JSON blocks each validate against the ${input.kind} schema \u2014 refusing to pick one`
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
return {
|
|
1082
|
+
kind: "none",
|
|
1083
|
+
detail: `no --agent-file, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
|
|
1084
|
+
};
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
// src/adapt.ts
|
|
1088
|
+
var left = (message) => ({ _tag: "Left", left: message });
|
|
1089
|
+
var right = (value) => ({ _tag: "Right", right: value });
|
|
1090
|
+
var isAdapterName = (name) => name === "claude-code";
|
|
1091
|
+
var ClaudeCodeModelUsageEntryCodec = t.intersection([
|
|
1092
|
+
t.type({
|
|
1093
|
+
inputTokens: t.number,
|
|
1094
|
+
outputTokens: t.number
|
|
1095
|
+
}),
|
|
1096
|
+
t.partial({
|
|
1097
|
+
cacheReadInputTokens: t.number,
|
|
1098
|
+
cacheCreationInputTokens: t.number,
|
|
1099
|
+
costUSD: t.number
|
|
1100
|
+
})
|
|
1101
|
+
]);
|
|
1102
|
+
var ClaudeCodeEnvelopeCodec = t.intersection([
|
|
1103
|
+
t.type({
|
|
1104
|
+
modelUsage: t.record(t.string, ClaudeCodeModelUsageEntryCodec),
|
|
1105
|
+
num_turns: t.number,
|
|
1106
|
+
duration_ms: t.number
|
|
1107
|
+
}),
|
|
1108
|
+
t.partial({
|
|
1109
|
+
total_cost_usd: t.union([t.number, t.null]),
|
|
1110
|
+
structured_output: t.unknown
|
|
1111
|
+
})
|
|
1112
|
+
]);
|
|
1113
|
+
var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entry]) => ({
|
|
1114
|
+
model,
|
|
1115
|
+
input_tokens: entry.inputTokens,
|
|
1116
|
+
output_tokens: entry.outputTokens,
|
|
1117
|
+
...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
|
|
1118
|
+
...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
|
|
1119
|
+
}));
|
|
1120
|
+
var adaptClaudeCode = (native, agentFilePath) => {
|
|
1121
|
+
const outcome = extractStructured({ kind: "findings", native, agentFilePath });
|
|
1122
|
+
if (outcome.kind !== "ok") {
|
|
1123
|
+
return left(describeLadderFailure(outcome));
|
|
1124
|
+
}
|
|
1125
|
+
const resolution = resolve("findings", outcome.candidate);
|
|
1126
|
+
return resolution.kind === "ok" ? right({
|
|
1127
|
+
schema_version: resolution.version,
|
|
1128
|
+
findings: resolution.value,
|
|
1129
|
+
models: mapModelUsage(native.modelUsage),
|
|
1130
|
+
turns: native.num_turns,
|
|
1131
|
+
duration_ms: native.duration_ms,
|
|
1132
|
+
vendor_cost_usd: native.total_cost_usd ?? null
|
|
1133
|
+
}) : left(
|
|
1134
|
+
"internal error: the extraction ladder validated a candidate the registry then rejected"
|
|
1135
|
+
);
|
|
1136
|
+
};
|
|
1137
|
+
var adapt = (adapterName, native, agentFilePath) => {
|
|
1138
|
+
switch (adapterName) {
|
|
1139
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhaustive by design; AdapterName grows (e.g. "opencode") without collapsing this switch to an if
|
|
1140
|
+
case "claude-code": {
|
|
1141
|
+
const decoded = ClaudeCodeEnvelopeCodec.decode(native);
|
|
1142
|
+
if (decoded._tag === "Left") {
|
|
1143
|
+
return left("native envelope does not match the Claude Code output shape");
|
|
1144
|
+
}
|
|
1145
|
+
return adaptClaudeCode(decoded.right, agentFilePath);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
// src/index.ts
|
|
1151
|
+
var readJSON = (path) => {
|
|
1152
|
+
try {
|
|
1153
|
+
return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
|
|
1154
|
+
} catch (err) {
|
|
1155
|
+
fail(`Cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1156
|
+
throw new Error("unreachable", { cause: err });
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
var fail = (msg) => {
|
|
1160
|
+
process.stderr.write(`${msg}
|
|
1161
|
+
`);
|
|
1162
|
+
process.exit(1);
|
|
1163
|
+
};
|
|
1164
|
+
var decode = (either, label) => {
|
|
1165
|
+
try {
|
|
1166
|
+
return unsafeUnwrap(either);
|
|
1167
|
+
} catch {
|
|
1168
|
+
fail(`${label} does not match expected shape`);
|
|
1169
|
+
}
|
|
1170
|
+
throw new Error("unreachable");
|
|
1171
|
+
};
|
|
1172
|
+
var unwrapAdapt = (either) => {
|
|
1173
|
+
try {
|
|
1174
|
+
if (either._tag === "Left") throw new Error(either.left);
|
|
1175
|
+
return either.right;
|
|
1176
|
+
} catch (err) {
|
|
1177
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
1178
|
+
}
|
|
1179
|
+
throw new Error("unreachable");
|
|
1180
|
+
};
|
|
1181
|
+
var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
|
|
1182
|
+
var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
|
|
1183
|
+
var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
|
|
1184
|
+
var resolvePricesPath = (pricesArg) => {
|
|
1185
|
+
if (pricesArg) return resolve$1(pricesArg);
|
|
1186
|
+
process.stderr.write(
|
|
1187
|
+
"code-review: no --prices given \u2014 using the bundled example prices (all zero); cost figures will be $0\n"
|
|
1188
|
+
);
|
|
1189
|
+
return bundledPath("schema", "prices.example.json");
|
|
1190
|
+
};
|
|
1191
|
+
var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
|
|
1192
|
+
var renderCmd = defineCommand({
|
|
1193
|
+
meta: {
|
|
1194
|
+
name: "render",
|
|
1195
|
+
description: "Render a code-review comment from findings + usage + prices"
|
|
1196
|
+
},
|
|
1197
|
+
args: {
|
|
1198
|
+
findings: {
|
|
1199
|
+
type: "positional",
|
|
1200
|
+
description: "Path to findings JSON",
|
|
1201
|
+
required: true
|
|
1202
|
+
},
|
|
1203
|
+
template: {
|
|
1204
|
+
type: "string",
|
|
1205
|
+
description: "Path to Eta template file (default: bundled templates/comment.eta)"
|
|
1206
|
+
},
|
|
1207
|
+
usage: {
|
|
1208
|
+
type: "string",
|
|
1209
|
+
description: "Path to result envelope JSON (from agent CLI)",
|
|
1210
|
+
required: true
|
|
1211
|
+
},
|
|
1212
|
+
prices: {
|
|
1213
|
+
type: "string",
|
|
1214
|
+
description: "Path to price map JSON (default: bundled schema/prices.example.json \u2014 all zero)"
|
|
1215
|
+
},
|
|
1216
|
+
"reviewed-sha": {
|
|
1217
|
+
type: "string",
|
|
1218
|
+
description: "SHA of the last reviewed commit"
|
|
1219
|
+
},
|
|
1220
|
+
route: {
|
|
1221
|
+
type: "string",
|
|
1222
|
+
description: 'Review route label (e.g. "full review" or "mechanic")',
|
|
1223
|
+
required: true
|
|
1224
|
+
},
|
|
1225
|
+
effort: {
|
|
1226
|
+
type: "string",
|
|
1227
|
+
description: 'Effort label to render in the route line (e.g. "max" or "low"); omitted when absent'
|
|
1228
|
+
},
|
|
1229
|
+
"test-report": {
|
|
1230
|
+
type: "string",
|
|
1231
|
+
description: TEST_REPORT_DESCRIPTION
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
1234
|
+
run: async ({ args }) => {
|
|
1235
|
+
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1236
|
+
const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
|
|
1237
|
+
const templatePath = resolveTemplatePath(args.template);
|
|
1238
|
+
const pricesPath = resolvePricesPath(args.prices);
|
|
1239
|
+
const prices = decode(PriceMapCodec.decode(readJSON(pricesPath)), "prices");
|
|
1240
|
+
const template = readFileSync(templatePath, "utf-8");
|
|
1241
|
+
const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
|
|
1242
|
+
const output = render({
|
|
1243
|
+
findings,
|
|
1244
|
+
envelope,
|
|
1245
|
+
prices,
|
|
1246
|
+
template,
|
|
1247
|
+
reviewedSha: args["reviewed-sha"],
|
|
1248
|
+
route: args.route,
|
|
1249
|
+
effort: args.effort,
|
|
1250
|
+
testReport
|
|
1251
|
+
});
|
|
1252
|
+
process.stdout.write(output);
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
var inlineCmd = defineCommand({
|
|
1256
|
+
meta: {
|
|
1257
|
+
name: "inline",
|
|
1258
|
+
description: "Build GitHub reviews comments[] payload from findings + diff"
|
|
1259
|
+
},
|
|
1260
|
+
args: {
|
|
1261
|
+
findings: {
|
|
1262
|
+
type: "positional",
|
|
1263
|
+
description: "Path to findings JSON",
|
|
1264
|
+
required: true
|
|
1265
|
+
},
|
|
1266
|
+
diff: {
|
|
1267
|
+
type: "string",
|
|
1268
|
+
description: "Path to PR diff file",
|
|
1269
|
+
required: true
|
|
1270
|
+
},
|
|
1271
|
+
template: {
|
|
1272
|
+
type: "string",
|
|
1273
|
+
description: "Path to inline comment Eta template (default: built-in format)"
|
|
1274
|
+
}
|
|
1275
|
+
},
|
|
1276
|
+
run: async ({ args }) => {
|
|
1277
|
+
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1278
|
+
const diff = readFileSync(resolve$1(args.diff), "utf-8");
|
|
1279
|
+
const inlineTemplate = args.template ? readFileSync(resolve$1(args.template), "utf-8") : void 0;
|
|
1280
|
+
const { comments, strays } = buildInlineComments(findings.findings, diff, inlineTemplate);
|
|
1281
|
+
process.stdout.write(
|
|
1282
|
+
JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
});
|
|
1286
|
+
var costCmd = defineCommand({
|
|
1287
|
+
meta: {
|
|
1288
|
+
name: "cost",
|
|
1289
|
+
description: "Recompute USD cost from the envelope's models array + price map"
|
|
1290
|
+
},
|
|
1291
|
+
args: {
|
|
1292
|
+
envelope: {
|
|
1293
|
+
type: "positional",
|
|
1294
|
+
description: "Path to result envelope JSON",
|
|
1295
|
+
required: true
|
|
1296
|
+
},
|
|
1297
|
+
prices: {
|
|
1298
|
+
type: "string",
|
|
1299
|
+
description: "Path to price map JSON",
|
|
1300
|
+
required: true
|
|
1301
|
+
}
|
|
1302
|
+
},
|
|
1303
|
+
run: async ({ args }) => {
|
|
1304
|
+
const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.envelope)), "envelope");
|
|
1305
|
+
const prices = decode(PriceMapCodec.decode(readJSON(args.prices)), "prices");
|
|
1306
|
+
const report = computeCost(envelope.models, prices);
|
|
1307
|
+
process.stdout.write(JSON.stringify(report, null, 2));
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
var declaredSchemaVersion = (raw) => typeof raw === "object" && raw !== null && "schema_version" in raw ? typeof raw.schema_version === "string" ? raw.schema_version : void 0 : void 0;
|
|
1311
|
+
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredSchemaVersion(raw) : void 0;
|
|
1312
|
+
var validateCmd = defineCommand({
|
|
1313
|
+
meta: {
|
|
1314
|
+
name: "validate",
|
|
1315
|
+
description: "Validate a findings/triage/prices JSON document against the canonical schema"
|
|
1316
|
+
},
|
|
1317
|
+
args: {
|
|
1318
|
+
document: {
|
|
1319
|
+
type: "positional",
|
|
1320
|
+
description: "Path to the JSON document to validate (of the given --kind)",
|
|
1321
|
+
required: true
|
|
1322
|
+
},
|
|
1323
|
+
kind: {
|
|
1324
|
+
type: "string",
|
|
1325
|
+
description: "Schema kind to validate against: findings | triage | prices (default: findings)"
|
|
1326
|
+
},
|
|
1327
|
+
schema: {
|
|
1328
|
+
type: "string",
|
|
1329
|
+
description: "Path to a schema file (wins over --kind; default: the bundled schema derived from --kind, --schema-version, the document's declared schema_version, or the bundled latest)"
|
|
1330
|
+
},
|
|
1331
|
+
"schema-version": {
|
|
1332
|
+
type: "string",
|
|
1333
|
+
description: "Schema major.minor version to validate against (default: the document's declared schema_version for findings, or the kind's latest)"
|
|
1334
|
+
}
|
|
1335
|
+
},
|
|
1336
|
+
run: async ({ args }) => {
|
|
1337
|
+
const kind = requireSchemaKind(args.kind || "findings");
|
|
1338
|
+
const documentRaw = readJSON(args.document);
|
|
1339
|
+
const schemaPath = args.schema ? resolve$1(args.schema) : requireSchemaPath(kind, args["schema-version"] || derivedSchemaVersion(kind, documentRaw));
|
|
1340
|
+
const { valid, errors } = validateAgainstSchema(documentRaw, schemaPath);
|
|
1341
|
+
if (valid) {
|
|
1342
|
+
process.stdout.write("\u2705 valid\n");
|
|
1343
|
+
} else {
|
|
1344
|
+
process.stderr.write("\u274C invalid\n");
|
|
1345
|
+
for (const e of errors) process.stderr.write(` - ${e}
|
|
1346
|
+
`);
|
|
1347
|
+
process.exit(1);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
1351
|
+
var adaptCmd = defineCommand({
|
|
1352
|
+
meta: {
|
|
1353
|
+
name: "adapt",
|
|
1354
|
+
description: "Map a native agent-CLI result envelope onto the abstract SPEC \xA76.1 envelope"
|
|
1355
|
+
},
|
|
1356
|
+
args: {
|
|
1357
|
+
native: {
|
|
1358
|
+
type: "positional",
|
|
1359
|
+
description: "Path to the native result envelope JSON (from the agent CLI)",
|
|
1360
|
+
required: true
|
|
1361
|
+
},
|
|
1362
|
+
adapter: {
|
|
1363
|
+
type: "string",
|
|
1364
|
+
description: 'Adapter to use (currently: "claude-code")',
|
|
1365
|
+
required: true
|
|
1366
|
+
},
|
|
1367
|
+
"agent-file": {
|
|
1368
|
+
type: "string",
|
|
1369
|
+
description: "Path to a file the agent was told to write its own validated findings JSON to (wins over the native envelope's structured_output/result when it validates)"
|
|
1370
|
+
}
|
|
1371
|
+
},
|
|
1372
|
+
run: async ({ args }) => {
|
|
1373
|
+
const envelope = unwrapAdapt(
|
|
1374
|
+
adapt(requireAdapterName(args.adapter), readJSON(args.native), args["agent-file"])
|
|
1375
|
+
);
|
|
1376
|
+
process.stdout.write(`${JSON.stringify(envelope, null, 2)}
|
|
1377
|
+
`);
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
var isExtractSchemaKind = (s) => s === "findings" || s === "triage";
|
|
1381
|
+
var requireExtractSchemaKind = (name) => {
|
|
1382
|
+
if (isExtractSchemaKind(name)) return name;
|
|
1383
|
+
fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
|
|
1384
|
+
throw new Error("unreachable");
|
|
1385
|
+
};
|
|
1386
|
+
var failClosedTriage = (outcome) => ({
|
|
1387
|
+
safe: false,
|
|
1388
|
+
reasons: describeLadderFailure(outcome)
|
|
1389
|
+
});
|
|
1390
|
+
var extractCmd = defineCommand({
|
|
1391
|
+
meta: {
|
|
1392
|
+
name: "extract",
|
|
1393
|
+
description: "Recover findings/triage JSON from a native agent-CLI result envelope via the deterministic extraction ladder"
|
|
1394
|
+
},
|
|
1395
|
+
args: {
|
|
1396
|
+
native: {
|
|
1397
|
+
type: "positional",
|
|
1398
|
+
description: "Path to the native result envelope JSON (from the agent CLI)",
|
|
1399
|
+
required: true
|
|
1400
|
+
},
|
|
1401
|
+
adapter: {
|
|
1402
|
+
type: "string",
|
|
1403
|
+
description: 'Adapter whose native envelope shape to extract from (currently: "claude-code")',
|
|
1404
|
+
required: true
|
|
1405
|
+
},
|
|
1406
|
+
kind: {
|
|
1407
|
+
type: "string",
|
|
1408
|
+
description: "Schema kind to extract: findings | triage",
|
|
1409
|
+
required: true
|
|
1410
|
+
},
|
|
1411
|
+
"agent-file": {
|
|
1412
|
+
type: "string",
|
|
1413
|
+
description: "Path to a file the agent was told to write its own validated JSON to (findings only \u2014 a documented no-op for triage)"
|
|
1414
|
+
}
|
|
1415
|
+
},
|
|
1416
|
+
run: async ({ args }) => {
|
|
1417
|
+
requireAdapterName(args.adapter);
|
|
1418
|
+
const kind = requireExtractSchemaKind(args.kind);
|
|
1419
|
+
const outcome = extractStructured({
|
|
1420
|
+
kind,
|
|
1421
|
+
native: readJSON(args.native),
|
|
1422
|
+
agentFilePath: args["agent-file"]
|
|
1423
|
+
});
|
|
1424
|
+
if (outcome.kind === "ok") {
|
|
1425
|
+
process.stdout.write(`${JSON.stringify(outcome.candidate, null, 2)}
|
|
1426
|
+
`);
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
if (kind === "triage") {
|
|
1430
|
+
process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
|
|
1431
|
+
`);
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
fail(describeLadderFailure(outcome));
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
var requireAdapterName = (name) => {
|
|
1438
|
+
if (isAdapterName(name)) return name;
|
|
1439
|
+
fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
|
|
1440
|
+
throw new Error("unreachable");
|
|
1441
|
+
};
|
|
1442
|
+
var isSchemaKind = (s) => s === "findings" || s === "triage" || s === "prices";
|
|
1443
|
+
var requireSchemaKind = (name) => {
|
|
1444
|
+
if (isSchemaKind(name)) return name;
|
|
1445
|
+
fail(`Unknown schema "${name}" \u2014 expected one of: findings, triage, prices`);
|
|
1446
|
+
throw new Error("unreachable");
|
|
1447
|
+
};
|
|
1448
|
+
var requireSchemaPath = (kind, version) => {
|
|
1449
|
+
try {
|
|
1450
|
+
return schemaPathFor(kind, version);
|
|
1451
|
+
} catch (err) {
|
|
1452
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
1453
|
+
}
|
|
1454
|
+
throw new Error("unreachable");
|
|
1455
|
+
};
|
|
1456
|
+
var printSchemaCmd = defineCommand({
|
|
1457
|
+
meta: {
|
|
1458
|
+
name: "print-schema",
|
|
1459
|
+
description: "Print a bundled schema JSON"
|
|
1460
|
+
},
|
|
1461
|
+
args: {
|
|
1462
|
+
name: {
|
|
1463
|
+
type: "positional",
|
|
1464
|
+
description: "Schema to print: findings | triage | prices",
|
|
1465
|
+
required: true
|
|
1466
|
+
},
|
|
1467
|
+
"schema-version": {
|
|
1468
|
+
type: "string",
|
|
1469
|
+
description: "Schema major.minor version to print (default: latest)"
|
|
1470
|
+
}
|
|
1471
|
+
},
|
|
1472
|
+
run: async ({ args }) => {
|
|
1473
|
+
const schemaKind = requireSchemaKind(args.name);
|
|
1474
|
+
const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
|
|
1475
|
+
process.stdout.write(readFileSync(schemaPath, "utf-8"));
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1478
|
+
var gatherCmd = defineCommand({
|
|
1479
|
+
meta: {
|
|
1480
|
+
name: "gather",
|
|
1481
|
+
description: "Resolve the PR from the CI head SHA and gather review inputs (diff with git-diff fallback, PR context, prior bot review, failing-job logs) as files for the review agent"
|
|
1482
|
+
},
|
|
1483
|
+
args: {
|
|
1484
|
+
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
1485
|
+
"head-sha": {
|
|
1486
|
+
type: "string",
|
|
1487
|
+
description: "Trusted head SHA to resolve the PR (from workflow_run.head_sha)",
|
|
1488
|
+
required: true
|
|
1489
|
+
},
|
|
1490
|
+
"head-branch": {
|
|
1491
|
+
type: "string",
|
|
1492
|
+
description: "Head branch to disambiguate the PR when multiple share a commit"
|
|
1493
|
+
},
|
|
1494
|
+
"run-id": {
|
|
1495
|
+
type: "string",
|
|
1496
|
+
description: "CI run id (from workflow_run.id); its failing jobs' logs are downloaded on failure",
|
|
1497
|
+
required: true
|
|
1498
|
+
},
|
|
1499
|
+
conclusion: {
|
|
1500
|
+
type: "string",
|
|
1501
|
+
description: "CI conclusion (e.g. success | failure); failure triggers failing-job log download",
|
|
1502
|
+
required: true
|
|
1503
|
+
},
|
|
1504
|
+
"bot-login": {
|
|
1505
|
+
type: "string",
|
|
1506
|
+
description: "Bot login whose last PR comment is captured as prior review (default: github-actions[bot])"
|
|
1507
|
+
},
|
|
1508
|
+
"out-dir": {
|
|
1509
|
+
type: "string",
|
|
1510
|
+
description: "Directory to write gathered files into (default: current directory)"
|
|
1511
|
+
}
|
|
1512
|
+
},
|
|
1513
|
+
run: async ({ args }) => {
|
|
1514
|
+
const result = await gather({
|
|
1515
|
+
repo: args.repo,
|
|
1516
|
+
headSha: args["head-sha"],
|
|
1517
|
+
headBranch: args["head-branch"],
|
|
1518
|
+
runId: args["run-id"],
|
|
1519
|
+
conclusion: args.conclusion,
|
|
1520
|
+
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
1521
|
+
outDir: args["out-dir"] ? resolve$1(args["out-dir"]) : process.cwd()
|
|
1522
|
+
});
|
|
1523
|
+
process.stdout.write(renderOutputs(result));
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
var postCmd = defineCommand({
|
|
1527
|
+
meta: {
|
|
1528
|
+
name: "post",
|
|
1529
|
+
description: "Post a complete review (inline comments + sticky summary) from findings + envelope + diff"
|
|
1530
|
+
},
|
|
1531
|
+
args: {
|
|
1532
|
+
findings: {
|
|
1533
|
+
type: "positional",
|
|
1534
|
+
description: "Path to findings JSON",
|
|
1535
|
+
required: true
|
|
1536
|
+
},
|
|
1537
|
+
"head-sha": {
|
|
1538
|
+
type: "string",
|
|
1539
|
+
description: "Trusted head SHA to resolve the PR (from workflow_run.head_sha)",
|
|
1540
|
+
required: true
|
|
1541
|
+
},
|
|
1542
|
+
repo: {
|
|
1543
|
+
type: "string",
|
|
1544
|
+
description: "Repository (owner/name)",
|
|
1545
|
+
required: true
|
|
1546
|
+
},
|
|
1547
|
+
usage: {
|
|
1548
|
+
type: "string",
|
|
1549
|
+
description: "Path to result envelope JSON (from agent CLI)",
|
|
1550
|
+
required: true
|
|
1551
|
+
},
|
|
1552
|
+
prices: {
|
|
1553
|
+
type: "string",
|
|
1554
|
+
description: "Path to price map JSON (default: bundled schema/prices.example.json \u2014 all zero)"
|
|
1555
|
+
},
|
|
1556
|
+
template: {
|
|
1557
|
+
type: "string",
|
|
1558
|
+
description: "Path to Eta template file for the summary comment (default: bundled templates/comment.eta)"
|
|
1559
|
+
},
|
|
1560
|
+
"inline-template": {
|
|
1561
|
+
type: "string",
|
|
1562
|
+
description: "Path to inline comment Eta template (default: built-in format)"
|
|
1563
|
+
},
|
|
1564
|
+
route: {
|
|
1565
|
+
type: "string",
|
|
1566
|
+
description: 'Review route label (e.g. "full review" or "mechanic")',
|
|
1567
|
+
required: true
|
|
1568
|
+
},
|
|
1569
|
+
"bot-login": {
|
|
1570
|
+
type: "string",
|
|
1571
|
+
description: "Bot login to trust for sticky comment upsert (default: github-actions[bot])"
|
|
1572
|
+
},
|
|
1573
|
+
"head-branch": {
|
|
1574
|
+
type: "string",
|
|
1575
|
+
description: "Head branch to disambiguate PR when multiple share a commit"
|
|
1576
|
+
},
|
|
1577
|
+
effort: {
|
|
1578
|
+
type: "string",
|
|
1579
|
+
description: 'Effort label to render in the route line (e.g. "max" or "low"); omitted when absent'
|
|
1580
|
+
},
|
|
1581
|
+
"test-report": {
|
|
1582
|
+
type: "string",
|
|
1583
|
+
description: TEST_REPORT_DESCRIPTION
|
|
1584
|
+
}
|
|
1585
|
+
},
|
|
1586
|
+
run: async ({ args }) => {
|
|
1587
|
+
await post({
|
|
1588
|
+
repo: args.repo,
|
|
1589
|
+
headSha: args["head-sha"],
|
|
1590
|
+
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
1591
|
+
findingsPath: args.findings,
|
|
1592
|
+
envelopePath: args.usage,
|
|
1593
|
+
pricesPath: resolvePricesPath(args.prices),
|
|
1594
|
+
templatePath: resolveTemplatePath(args.template),
|
|
1595
|
+
inlineTemplatePath: args["inline-template"] ? resolve$1(args["inline-template"]) : void 0,
|
|
1596
|
+
route: args.route,
|
|
1597
|
+
headBranch: args["head-branch"],
|
|
1598
|
+
effort: args.effort,
|
|
1599
|
+
testReportPath: args["test-report"]
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
});
|
|
1603
|
+
var main = defineCommand({
|
|
1604
|
+
meta: {
|
|
1605
|
+
name: "code-review",
|
|
1606
|
+
version: packageVersion,
|
|
1607
|
+
description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, cost, and validate findings JSON"
|
|
1608
|
+
},
|
|
1609
|
+
subCommands: {
|
|
1610
|
+
gather: gatherCmd,
|
|
1611
|
+
render: renderCmd,
|
|
1612
|
+
inline: inlineCmd,
|
|
1613
|
+
post: postCmd,
|
|
1614
|
+
cost: costCmd,
|
|
1615
|
+
validate: validateCmd,
|
|
1616
|
+
adapt: adaptCmd,
|
|
1617
|
+
extract: extractCmd,
|
|
1618
|
+
"print-schema": printSchemaCmd
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
if (!process.env["VITEST"]) {
|
|
1622
|
+
await runMain(main);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
export { main };
|
|
1626
|
+
//# sourceMappingURL=index.js.map
|
|
1627
|
+
//# sourceMappingURL=index.js.map
|