@jphutchins/code-review 0.1.0-alpha.4 → 0.1.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/index.js +367 -90
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schema/VERSIONING.md +2 -1
- package/schema/findings.schema.json +10 -2
- package/schema/v0.2/findings.schema.json +88 -0
- package/templates/comment.eta +51 -46
- package/templates/inline.eta +19 -3
package/dist/index.js
CHANGED
|
@@ -63,6 +63,17 @@ var sanitizeFinding = (f) => ({
|
|
|
63
63
|
path: escapeCodeBackticks(f.path),
|
|
64
64
|
suggestion: f.suggestion ? escapeBackticks(f.suggestion) : f.suggestion
|
|
65
65
|
});
|
|
66
|
+
var emptySeverityCounts = () => ({
|
|
67
|
+
critical: 0,
|
|
68
|
+
major: 0,
|
|
69
|
+
minor: 0,
|
|
70
|
+
nit: 0
|
|
71
|
+
});
|
|
72
|
+
var computeSeverityCounts = (findings) => findings.reduce(
|
|
73
|
+
(acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
|
|
74
|
+
emptySeverityCounts()
|
|
75
|
+
);
|
|
76
|
+
var EMBED_LIMIT = 4e4;
|
|
66
77
|
var render = (input) => {
|
|
67
78
|
const eta = new Eta({ autoTrim: false });
|
|
68
79
|
const usageAvailable = input.envelope !== null;
|
|
@@ -70,11 +81,10 @@ var render = (input) => {
|
|
|
70
81
|
const route = input.route ?? input.envelope?.route ?? null;
|
|
71
82
|
const effort = input.effort ?? input.envelope?.effort ?? null;
|
|
72
83
|
const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const uniqueFiles = [...new Set(findings.map((f) => f.path))];
|
|
84
|
+
const findingsB64 = Buffer.from(JSON.stringify(input.findings), "utf-8").toString("base64");
|
|
85
|
+
const embeddedFindings = findingsB64.length <= EMBED_LIMIT ? findingsB64 : null;
|
|
76
86
|
return eta.renderString(input.template, {
|
|
77
|
-
findings:
|
|
87
|
+
findings: input.findings,
|
|
78
88
|
envelope: input.envelope,
|
|
79
89
|
usageAvailable,
|
|
80
90
|
costReport,
|
|
@@ -83,14 +93,15 @@ var render = (input) => {
|
|
|
83
93
|
modelNames,
|
|
84
94
|
testReport: input.testReport ?? null,
|
|
85
95
|
reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
|
|
97
|
+
strays: (input.strays ?? []).map(sanitizeFinding),
|
|
98
|
+
inlineDisposition: input.inlineDisposition ?? null,
|
|
99
|
+
runUrl: input.runUrl ?? null,
|
|
100
|
+
jsonUrl: input.jsonUrl ?? null,
|
|
101
|
+
embeddedFindings,
|
|
102
|
+
reviewUrl: input.reviewUrl ?? null,
|
|
92
103
|
formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
|
|
93
|
-
formatCost: (n) => Number.isFinite(n) ? `$${n.toFixed(
|
|
104
|
+
formatCost: (n) => Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
|
|
94
105
|
formatDuration: (ms) => {
|
|
95
106
|
if (!Number.isFinite(ms) || ms < 0) return "\u2014";
|
|
96
107
|
const s = Math.round(ms / 1e3);
|
|
@@ -179,8 +190,26 @@ var partitionFindings = (findings, index) => {
|
|
|
179
190
|
|
|
180
191
|
// src/inline.ts
|
|
181
192
|
var escapeBackticks2 = (text) => text.replace(/```/g, "`` ` ``");
|
|
182
|
-
var
|
|
183
|
-
|
|
193
|
+
var severityEmoji = (s) => {
|
|
194
|
+
switch (s) {
|
|
195
|
+
case "critical":
|
|
196
|
+
return "\u{1F534}";
|
|
197
|
+
case "major":
|
|
198
|
+
return "\u{1F7E0}";
|
|
199
|
+
case "minor":
|
|
200
|
+
return "\u{1F535}";
|
|
201
|
+
case "nit":
|
|
202
|
+
return "\u26AA";
|
|
203
|
+
default:
|
|
204
|
+
return "\u2753";
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
var severityHeader = (f) => `${severityEmoji(f.severity)} **${f.severity}** \u2014 ${f.title}`;
|
|
208
|
+
var jsonUrlMarker = (jsonUrl) => jsonUrl ? `<!-- code-review:findings-json ${jsonUrl} -->` : void 0;
|
|
209
|
+
var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
|
|
210
|
+
var buildCommentBody = (f, jsonUrl) => {
|
|
211
|
+
const marker = jsonUrlMarker(jsonUrl);
|
|
212
|
+
const parts = [...marker ? [marker] : [], severityHeader(f), f.body];
|
|
184
213
|
if (f.suggestion !== null && f.suggestion !== void 0) {
|
|
185
214
|
const safe = escapeBackticks2(f.suggestion);
|
|
186
215
|
parts.push(`\`\`\`suggestion
|
|
@@ -189,22 +218,27 @@ ${safe}
|
|
|
189
218
|
}
|
|
190
219
|
return parts.join("\n\n");
|
|
191
220
|
};
|
|
192
|
-
var renderCommentBody = (f, eta, template) => {
|
|
221
|
+
var renderCommentBody = (f, eta, template, modelsText, jsonUrl) => {
|
|
193
222
|
return eta.renderString(template, {
|
|
194
223
|
...f,
|
|
195
|
-
suggestion: f.suggestion !== null && f.suggestion !== void 0 ? escapeBackticks2(f.suggestion) : null
|
|
224
|
+
suggestion: f.suggestion !== null && f.suggestion !== void 0 ? escapeBackticks2(f.suggestion) : null,
|
|
225
|
+
severityEmoji,
|
|
226
|
+
modelsText,
|
|
227
|
+
jsonUrl: jsonUrl ?? null
|
|
196
228
|
});
|
|
197
229
|
};
|
|
198
|
-
var buildInlineComments = (findings, diff,
|
|
230
|
+
var buildInlineComments = (findings, diff, context = {}) => {
|
|
231
|
+
const { inlineTemplate, models = [], jsonUrl } = context;
|
|
199
232
|
const index = indexDiff(diff);
|
|
200
233
|
const { inDiff, strays } = partitionFindings(findings, index);
|
|
201
234
|
const eta = inlineTemplate ? new Eta({ autoTrim: false }) : null;
|
|
235
|
+
const modelsText = formatModels(models);
|
|
202
236
|
const comments = inDiff.map((f) => {
|
|
203
237
|
const comment = {
|
|
204
238
|
path: f.path,
|
|
205
239
|
line: f.end_line,
|
|
206
240
|
side: defaultSide(f.side),
|
|
207
|
-
body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate) : buildCommentBody(f)
|
|
241
|
+
body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl) : buildCommentBody(f, jsonUrl)
|
|
208
242
|
};
|
|
209
243
|
if (f.start_line < f.end_line) {
|
|
210
244
|
return {
|
|
@@ -265,7 +299,9 @@ var FindingShape = t.intersection([
|
|
|
265
299
|
suggestion: t.union([t.string, t.null]),
|
|
266
300
|
confidence: Confidence,
|
|
267
301
|
code: t.string,
|
|
268
|
-
code_url: t.string
|
|
302
|
+
code_url: t.string,
|
|
303
|
+
reasoning: t.string,
|
|
304
|
+
patch: t.string
|
|
269
305
|
})
|
|
270
306
|
]);
|
|
271
307
|
var EndGeStart = t.refinement(
|
|
@@ -341,7 +377,7 @@ var TestSummaryCodec = t.intersection([
|
|
|
341
377
|
failures: t.array(TestFailureCodec)
|
|
342
378
|
})
|
|
343
379
|
]);
|
|
344
|
-
var DEFAULT_SCHEMA_VERSION = "0.
|
|
380
|
+
var DEFAULT_SCHEMA_VERSION = "0.3.0";
|
|
345
381
|
|
|
346
382
|
// src/validate.ts
|
|
347
383
|
var addFormats = _addFormats;
|
|
@@ -377,15 +413,45 @@ var unsafeUnwrap = (decoded) => {
|
|
|
377
413
|
if (decoded._tag === "Right") return decoded.right;
|
|
378
414
|
throw new Error("io-ts decode failed \u2014 data does not match expected shape");
|
|
379
415
|
};
|
|
416
|
+
|
|
417
|
+
// src/format.ts
|
|
418
|
+
var FENCE_RE = /^\s*```/;
|
|
419
|
+
var scanLine = (state, line) => {
|
|
420
|
+
if (FENCE_RE.test(line)) {
|
|
421
|
+
return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
|
|
422
|
+
}
|
|
423
|
+
if (state.inFence) {
|
|
424
|
+
return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
|
|
425
|
+
}
|
|
426
|
+
const trimmed = line.replace(/[ \t]+$/, "");
|
|
427
|
+
if (trimmed !== "") {
|
|
428
|
+
return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
|
|
429
|
+
}
|
|
430
|
+
const blankRun = state.blankRun + 1;
|
|
431
|
+
return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
|
|
432
|
+
};
|
|
433
|
+
var formatMarkdown = (md) => {
|
|
434
|
+
const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
|
|
435
|
+
return `${lines.join("\n").replace(/\n+$/, "")}
|
|
436
|
+
`;
|
|
437
|
+
};
|
|
380
438
|
var identity = (decoded) => decoded;
|
|
381
439
|
var findingsTable = [
|
|
382
440
|
{
|
|
383
|
-
minor: "0.
|
|
441
|
+
minor: "0.3",
|
|
384
442
|
defaultVersion: DEFAULT_SCHEMA_VERSION,
|
|
385
443
|
schemaFile: "findings.schema.json",
|
|
386
444
|
codec: FindingsCodec,
|
|
387
445
|
normalize: identity,
|
|
388
446
|
latest: true
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
minor: "0.2",
|
|
450
|
+
defaultVersion: "0.2.0",
|
|
451
|
+
schemaFile: "v0.2/findings.schema.json",
|
|
452
|
+
codec: FindingsCodec,
|
|
453
|
+
normalize: identity,
|
|
454
|
+
latest: false
|
|
389
455
|
}
|
|
390
456
|
];
|
|
391
457
|
var triageTable = [
|
|
@@ -505,7 +571,6 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
|
|
|
505
571
|
// src/post.ts
|
|
506
572
|
var DEFAULT_MARKER = "<!-- code-review -->";
|
|
507
573
|
var MAX_SUGGESTION_LINES = 10;
|
|
508
|
-
var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
|
|
509
574
|
var countSuggestionLines = (text) => text.split("\n").length;
|
|
510
575
|
var checkLongSuggestions = (comments) => {
|
|
511
576
|
const longFiles = [];
|
|
@@ -525,7 +590,6 @@ var checkLongSuggestions = (comments) => {
|
|
|
525
590
|
});
|
|
526
591
|
return { comments: adjusted, longFiles };
|
|
527
592
|
};
|
|
528
|
-
var extractReviewedSha = (commentBody) => REVIEWED_SHA_RE.exec(commentBody)?.[1] ?? null;
|
|
529
593
|
var noticeFindings = (message) => ({
|
|
530
594
|
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
531
595
|
summary: `### \u26A0\uFE0F ${message}`,
|
|
@@ -593,9 +657,19 @@ var loadTestReport = (path) => {
|
|
|
593
657
|
}
|
|
594
658
|
return decoded.right;
|
|
595
659
|
};
|
|
596
|
-
var
|
|
660
|
+
var parseHtmlUrl = (raw) => {
|
|
661
|
+
try {
|
|
662
|
+
const parsed = JSON.parse(raw);
|
|
663
|
+
return typeof parsed.html_url === "string" ? parsed.html_url : void 0;
|
|
664
|
+
} catch {
|
|
665
|
+
return void 0;
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
var postInlineReview = async (repo, prNumber, headSha, comments, stickyUrl, ghApi) => {
|
|
669
|
+
const sha7 = headSha.slice(0, 7);
|
|
670
|
+
const pointer = stickyUrl ? `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the [summary comment](${stickyUrl}) for the verdict, walkthrough, and cost.` : `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the summary comment for the verdict, walkthrough, and cost.`;
|
|
597
671
|
const body = JSON.stringify({
|
|
598
|
-
body:
|
|
672
|
+
body: pointer,
|
|
599
673
|
commit_id: headSha,
|
|
600
674
|
event: "COMMENT",
|
|
601
675
|
comments: comments.map((c) => ({
|
|
@@ -603,10 +677,14 @@ var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
|
|
|
603
677
|
line: c.line,
|
|
604
678
|
side: c.side,
|
|
605
679
|
...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
|
|
606
|
-
body: c.body
|
|
680
|
+
body: formatMarkdown(c.body)
|
|
607
681
|
}))
|
|
608
682
|
});
|
|
609
|
-
await ghApi(
|
|
683
|
+
const stdout = await ghApi(
|
|
684
|
+
[`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"],
|
|
685
|
+
body
|
|
686
|
+
);
|
|
687
|
+
return parseHtmlUrl(stdout);
|
|
610
688
|
};
|
|
611
689
|
var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
612
690
|
const stdout = await ghApi(
|
|
@@ -626,33 +704,45 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
|
626
704
|
const parsed = JSON.parse(last);
|
|
627
705
|
return { id: parsed.id, body: parsed.body };
|
|
628
706
|
};
|
|
707
|
+
var parseCommentRef = (raw) => {
|
|
708
|
+
try {
|
|
709
|
+
const parsed = JSON.parse(raw);
|
|
710
|
+
return typeof parsed.id === "number" && typeof parsed.html_url === "string" ? { id: parsed.id, html_url: parsed.html_url } : null;
|
|
711
|
+
} catch {
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
};
|
|
629
715
|
var patchComment = async (repo, commentId, body, ghApi) => {
|
|
630
|
-
await ghApi(
|
|
716
|
+
const stdout = await ghApi(
|
|
631
717
|
[`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
|
|
632
718
|
JSON.stringify({ body })
|
|
633
719
|
);
|
|
720
|
+
const htmlUrl = parseHtmlUrl(stdout);
|
|
721
|
+
return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
|
|
634
722
|
};
|
|
635
723
|
var postComment = async (repo, prNumber, body, ghApi) => {
|
|
636
|
-
await ghApi(
|
|
724
|
+
const stdout = await ghApi(
|
|
637
725
|
[`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
|
|
638
726
|
JSON.stringify({ body })
|
|
639
727
|
);
|
|
728
|
+
return parseCommentRef(stdout);
|
|
640
729
|
};
|
|
641
730
|
var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
|
|
642
731
|
if (existing !== null) {
|
|
643
|
-
await patchComment(repo, existing.id, body, ghApi);
|
|
732
|
+
const patched = await patchComment(repo, existing.id, body, ghApi);
|
|
644
733
|
process.stderr.write(
|
|
645
734
|
`Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
|
|
646
735
|
`
|
|
647
736
|
);
|
|
648
|
-
|
|
649
|
-
await postComment(repo, prNumber, body, ghApi);
|
|
650
|
-
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
651
|
-
`);
|
|
737
|
+
return { id: existing.id, url: patched?.html_url };
|
|
652
738
|
}
|
|
739
|
+
const posted = await postComment(repo, prNumber, body, ghApi);
|
|
740
|
+
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
741
|
+
`);
|
|
742
|
+
return posted ? { id: posted.id, url: posted.html_url } : null;
|
|
653
743
|
};
|
|
654
744
|
var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
|
|
655
|
-
var
|
|
745
|
+
var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
656
746
|
const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
|
|
657
747
|
let reviews;
|
|
658
748
|
try {
|
|
@@ -661,10 +751,12 @@ var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
|
|
|
661
751
|
return [];
|
|
662
752
|
}
|
|
663
753
|
if (!Array.isArray(reviews)) return [];
|
|
664
|
-
return reviews.filter(
|
|
754
|
+
return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({
|
|
755
|
+
id: r.id,
|
|
756
|
+
commitId: typeof r.commit_id === "string" ? r.commit_id : ""
|
|
757
|
+
}));
|
|
665
758
|
};
|
|
666
|
-
var
|
|
667
|
-
const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
|
|
759
|
+
var dismissReviews = async (repo, prNumber, ids, ghApi) => {
|
|
668
760
|
for (const id of ids) {
|
|
669
761
|
try {
|
|
670
762
|
await ghApi(
|
|
@@ -709,8 +801,6 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
709
801
|
DEFAULT_MARKER,
|
|
710
802
|
ghApi
|
|
711
803
|
);
|
|
712
|
-
const previousReviewedSha = existingSticky ? extractReviewedSha(existingSticky.body) : null;
|
|
713
|
-
const isRerunOfSameSha = previousReviewedSha !== null && previousReviewedSha === input.headSha;
|
|
714
804
|
const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
|
|
715
805
|
const decodedPrices = PriceMapCodec.decode(prices);
|
|
716
806
|
if (decodedPrices._tag === "Left") {
|
|
@@ -718,15 +808,19 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
718
808
|
}
|
|
719
809
|
const template = readFileSync(input.templatePath, "utf-8");
|
|
720
810
|
const inlineTemplate = input.inlineTemplatePath ? readFileSync(input.inlineTemplatePath, "utf-8") : void 0;
|
|
721
|
-
const renderNotice = (message) =>
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
811
|
+
const renderNotice = (message) => formatMarkdown(
|
|
812
|
+
render({
|
|
813
|
+
findings: noticeFindings(message),
|
|
814
|
+
envelope: null,
|
|
815
|
+
prices: decodedPrices.right,
|
|
816
|
+
template,
|
|
817
|
+
route: input.route,
|
|
818
|
+
reviewedSha: input.headSha,
|
|
819
|
+
effort: input.effort,
|
|
820
|
+
runUrl: input.runUrl,
|
|
821
|
+
jsonUrl: input.jsonUrl
|
|
822
|
+
})
|
|
823
|
+
);
|
|
730
824
|
if (isEmptyDiff(diff)) {
|
|
731
825
|
await upsertSticky(
|
|
732
826
|
input.repo,
|
|
@@ -752,27 +846,32 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
752
846
|
const envelope = loadEnvelope(input.envelopePath);
|
|
753
847
|
const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
|
|
754
848
|
if (envelope === null) {
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
849
|
+
const body = formatMarkdown(
|
|
850
|
+
render({
|
|
851
|
+
findings,
|
|
852
|
+
envelope: null,
|
|
853
|
+
prices: decodedPrices.right,
|
|
854
|
+
template,
|
|
855
|
+
route: input.route,
|
|
856
|
+
reviewedSha: input.headSha,
|
|
857
|
+
effort: input.effort,
|
|
858
|
+
testReport,
|
|
859
|
+
inlineDisposition: { kind: "no-envelope" },
|
|
860
|
+
runUrl: input.runUrl,
|
|
861
|
+
jsonUrl: input.jsonUrl
|
|
862
|
+
})
|
|
863
|
+
);
|
|
864
|
+
await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
|
|
766
865
|
process.stderr.write(
|
|
767
866
|
"Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
|
|
768
867
|
);
|
|
769
868
|
process.exit(0);
|
|
770
869
|
}
|
|
771
|
-
const { comments: rawComments, strays } = buildInlineComments(
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
);
|
|
870
|
+
const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
|
|
871
|
+
inlineTemplate,
|
|
872
|
+
models: envelope.models.map((m) => m.model),
|
|
873
|
+
jsonUrl: input.jsonUrl
|
|
874
|
+
});
|
|
776
875
|
const { comments, longFiles } = checkLongSuggestions(rawComments);
|
|
777
876
|
for (const wf of longFiles) {
|
|
778
877
|
process.stderr.write(
|
|
@@ -780,7 +879,10 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
780
879
|
`
|
|
781
880
|
);
|
|
782
881
|
}
|
|
783
|
-
|
|
882
|
+
const botReviews = comments.length > 0 ? await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi) : [];
|
|
883
|
+
const alreadyReviewedThisSha = botReviews.some((r) => r.commitId === input.headSha);
|
|
884
|
+
const inlineDisposition = comments.length > 0 ? alreadyReviewedThisSha ? { kind: "suppressed-existing-review", sha: input.headSha } : { kind: "posted", count: comments.length, sha: input.headSha } : strays.length > 0 ? { kind: "none-in-diff" } : void 0;
|
|
885
|
+
const commonRenderInput = {
|
|
784
886
|
findings,
|
|
785
887
|
envelope,
|
|
786
888
|
prices: decodedPrices.right,
|
|
@@ -788,35 +890,56 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
788
890
|
route: input.route,
|
|
789
891
|
reviewedSha: input.headSha,
|
|
790
892
|
effort: input.effort,
|
|
791
|
-
testReport
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
893
|
+
testReport,
|
|
894
|
+
severityCounts: computeSeverityCounts(findings.findings),
|
|
895
|
+
strays,
|
|
896
|
+
inlineDisposition,
|
|
897
|
+
runUrl: input.runUrl,
|
|
898
|
+
jsonUrl: input.jsonUrl
|
|
899
|
+
};
|
|
900
|
+
const longFilesNote = longFiles.length > 0 ? `
|
|
797
901
|
|
|
798
902
|
---
|
|
799
903
|
|
|
800
|
-
> **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from inline comments
|
|
801
|
-
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
|
|
807
|
-
if (isRerunOfSameSha) {
|
|
904
|
+
> **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from the inline comments; the affected findings remain in the review.
|
|
905
|
+
` : "";
|
|
906
|
+
const renderBody = (reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, reviewUrl: reviewUrl2 }) + longFilesNote);
|
|
907
|
+
const stickyRef = await upsertSticky(input.repo, prNumber, existingSticky, renderBody(), ghApi);
|
|
908
|
+
if (comments.length === 0) return;
|
|
909
|
+
if (alreadyReviewedThisSha) {
|
|
808
910
|
process.stderr.write(
|
|
809
|
-
`
|
|
911
|
+
`A completed bot review already exists for ${input.headSha} \u2014 updated sticky only, no new inline review
|
|
810
912
|
`
|
|
811
913
|
);
|
|
812
914
|
return;
|
|
813
915
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
916
|
+
const stalePriorReviewIds = botReviews.filter((r) => r.commitId !== input.headSha).map((r) => r.id);
|
|
917
|
+
if (stalePriorReviewIds.length > 0) {
|
|
918
|
+
await dismissReviews(input.repo, prNumber, stalePriorReviewIds, ghApi);
|
|
919
|
+
}
|
|
920
|
+
const reviewUrl = await postInlineReview(
|
|
921
|
+
input.repo,
|
|
922
|
+
prNumber,
|
|
923
|
+
input.headSha,
|
|
924
|
+
comments,
|
|
925
|
+
stickyRef?.url,
|
|
926
|
+
ghApi
|
|
927
|
+
);
|
|
928
|
+
process.stderr.write(
|
|
929
|
+
`Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
|
|
818
930
|
`
|
|
819
|
-
|
|
931
|
+
);
|
|
932
|
+
if (stickyRef !== null && reviewUrl !== void 0) {
|
|
933
|
+
try {
|
|
934
|
+
await patchComment(input.repo, stickyRef.id, renderBody(reviewUrl), ghApi);
|
|
935
|
+
process.stderr.write(`Linked sticky comment #${String(stickyRef.id)} to the review
|
|
936
|
+
`);
|
|
937
|
+
} catch (err) {
|
|
938
|
+
process.stderr.write(
|
|
939
|
+
`Warning: failed to link the sticky summary to the review: ${err instanceof Error ? err.message : String(err)}
|
|
940
|
+
`
|
|
941
|
+
);
|
|
942
|
+
}
|
|
820
943
|
}
|
|
821
944
|
};
|
|
822
945
|
var renderOutputs = (result) => {
|
|
@@ -1028,7 +1151,7 @@ var candidateFromJsonText = (kind, text) => {
|
|
|
1028
1151
|
};
|
|
1029
1152
|
var FENCE_OPEN = /^\s*(`{3,})/;
|
|
1030
1153
|
var FENCE_MARKER_ONLY = /^`+$/;
|
|
1031
|
-
var
|
|
1154
|
+
var scanLine2 = (state, line) => {
|
|
1032
1155
|
if (state.openLength === null) {
|
|
1033
1156
|
const opened = FENCE_OPEN.exec(line)?.[1]?.length;
|
|
1034
1157
|
return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
|
|
@@ -1037,7 +1160,7 @@ var scanLine = (state, line) => {
|
|
|
1037
1160
|
const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
|
|
1038
1161
|
return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
|
|
1039
1162
|
};
|
|
1040
|
-
var scanFencedBlocks = (text) => text.split("\n").reduce(
|
|
1163
|
+
var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
|
|
1041
1164
|
var ladderFailureDiagnostics = (input) => {
|
|
1042
1165
|
const native = parseNativeForExtraction(input.native);
|
|
1043
1166
|
const preview = (s) => {
|
|
@@ -1172,6 +1295,83 @@ var adapt = (adapterName, native, agentFilePath, meta = {}) => {
|
|
|
1172
1295
|
}
|
|
1173
1296
|
};
|
|
1174
1297
|
|
|
1298
|
+
// src/patch.ts
|
|
1299
|
+
var HUNK_HEADER_RE = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/;
|
|
1300
|
+
var hunkOldStart = (line) => {
|
|
1301
|
+
const raw = HUNK_HEADER_RE.exec(line)?.[1];
|
|
1302
|
+
return raw !== void 0 ? Number(raw) : null;
|
|
1303
|
+
};
|
|
1304
|
+
var classifyBodyLine = (line) => {
|
|
1305
|
+
if (line.startsWith(" ")) return { kind: "context", text: line.slice(1) };
|
|
1306
|
+
if (line.startsWith("-")) return { kind: "removed", text: line.slice(1) };
|
|
1307
|
+
if (line.startsWith("+")) return { kind: "added", text: line.slice(1) };
|
|
1308
|
+
return null;
|
|
1309
|
+
};
|
|
1310
|
+
var trimmedMiddle = (body) => {
|
|
1311
|
+
const first = body.findIndex((l) => l.kind !== "context");
|
|
1312
|
+
if (first === -1) return [];
|
|
1313
|
+
const last = body.findLastIndex((l) => l.kind !== "context");
|
|
1314
|
+
return body.slice(first, last + 1);
|
|
1315
|
+
};
|
|
1316
|
+
var isContiguousChange = (middle) => {
|
|
1317
|
+
if (middle.some((l) => l.kind === "context")) return false;
|
|
1318
|
+
const firstAdded = middle.findIndex((l) => l.kind === "added");
|
|
1319
|
+
if (firstAdded === -1) return true;
|
|
1320
|
+
return middle.slice(0, firstAdded).every((l) => l.kind === "removed") && middle.slice(firstAdded).every((l) => l.kind === "added");
|
|
1321
|
+
};
|
|
1322
|
+
var removedRange = (body, oldStart) => body.reduce(
|
|
1323
|
+
(acc, line) => line.kind === "added" ? acc : {
|
|
1324
|
+
lineNumber: acc.lineNumber + 1,
|
|
1325
|
+
firstRemoved: line.kind === "removed" && acc.firstRemoved === null ? acc.lineNumber : acc.firstRemoved,
|
|
1326
|
+
lastRemoved: line.kind === "removed" ? acc.lineNumber : acc.lastRemoved
|
|
1327
|
+
},
|
|
1328
|
+
{ lineNumber: oldStart, firstRemoved: null, lastRemoved: null }
|
|
1329
|
+
);
|
|
1330
|
+
var drop = (reason) => ({ kind: "drop", reason });
|
|
1331
|
+
var lowerPatch = (patch, fileLines) => {
|
|
1332
|
+
const rawLines = patch.split("\n");
|
|
1333
|
+
const lines = rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
1334
|
+
const headerHits = lines.reduce(
|
|
1335
|
+
(acc, line, index) => {
|
|
1336
|
+
const oldStart = hunkOldStart(line);
|
|
1337
|
+
return oldStart !== null ? [...acc, { index, oldStart }] : acc;
|
|
1338
|
+
},
|
|
1339
|
+
[]
|
|
1340
|
+
);
|
|
1341
|
+
if (headerHits.length !== 1) {
|
|
1342
|
+
return drop(`expected exactly one hunk, got ${String(headerHits.length)}`);
|
|
1343
|
+
}
|
|
1344
|
+
const hit = headerHits[0];
|
|
1345
|
+
if (hit === void 0) return drop("malformed hunk header");
|
|
1346
|
+
const bodyRaw = lines.slice(hit.index + 1).filter((line) => !line.startsWith("\\"));
|
|
1347
|
+
const classified = bodyRaw.map(classifyBodyLine);
|
|
1348
|
+
if (classified.some((line) => line === null)) return drop("malformed hunk body line");
|
|
1349
|
+
const body = classified.filter((line) => line !== null);
|
|
1350
|
+
const oldSideTexts = body.filter((l) => l.kind !== "added").map((l) => l.text);
|
|
1351
|
+
const expected = fileLines.slice(hit.oldStart - 1, hit.oldStart - 1 + oldSideTexts.length);
|
|
1352
|
+
const oldSideMatches = expected.length === oldSideTexts.length && expected.every((line, i) => line === oldSideTexts[i]);
|
|
1353
|
+
if (!oldSideMatches) {
|
|
1354
|
+
return drop(
|
|
1355
|
+
`patch context does not match the file at lines ${String(hit.oldStart)}..${String(hit.oldStart + oldSideTexts.length - 1)}`
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
if (!isContiguousChange(trimmedMiddle(body))) {
|
|
1359
|
+
return drop("change is not a single contiguous block");
|
|
1360
|
+
}
|
|
1361
|
+
const removedCount = body.filter((l) => l.kind === "removed").length;
|
|
1362
|
+
const addedLines = body.filter((l) => l.kind === "added");
|
|
1363
|
+
if (removedCount === 0 && addedLines.length === 0) return drop("hunk contains no changes");
|
|
1364
|
+
if (removedCount === 0) return drop("pure insertion can't be expressed as a suggestion");
|
|
1365
|
+
const { firstRemoved, lastRemoved } = removedRange(body, hit.oldStart);
|
|
1366
|
+
if (firstRemoved === null || lastRemoved === null) return drop("malformed hunk body");
|
|
1367
|
+
return {
|
|
1368
|
+
kind: "ok",
|
|
1369
|
+
startLine: firstRemoved,
|
|
1370
|
+
endLine: lastRemoved,
|
|
1371
|
+
suggestion: addedLines.map((l) => l.text).join("\n")
|
|
1372
|
+
};
|
|
1373
|
+
};
|
|
1374
|
+
|
|
1175
1375
|
// src/index.ts
|
|
1176
1376
|
var readJSON = (path) => {
|
|
1177
1377
|
try {
|
|
@@ -1301,7 +1501,7 @@ var inlineCmd = defineCommand({
|
|
|
1301
1501
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1302
1502
|
const diff = readFileSync(resolve$1(args.diff), "utf-8");
|
|
1303
1503
|
const inlineTemplate = args.template ? readFileSync(resolve$1(args.template), "utf-8") : void 0;
|
|
1304
|
-
const { comments, strays } = buildInlineComments(findings.findings, diff, inlineTemplate);
|
|
1504
|
+
const { comments, strays } = buildInlineComments(findings.findings, diff, { inlineTemplate });
|
|
1305
1505
|
process.stdout.write(
|
|
1306
1506
|
JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
|
|
1307
1507
|
);
|
|
@@ -1471,6 +1671,72 @@ ${ladderFailureDiagnostics(input)}
|
|
|
1471
1671
|
fail(describeLadderFailure(outcome));
|
|
1472
1672
|
}
|
|
1473
1673
|
});
|
|
1674
|
+
var withoutPatch = (finding) => {
|
|
1675
|
+
const copy = { ...finding };
|
|
1676
|
+
delete copy.patch;
|
|
1677
|
+
return copy;
|
|
1678
|
+
};
|
|
1679
|
+
var readFileLines = (path) => {
|
|
1680
|
+
try {
|
|
1681
|
+
const rawLines = readFileSync(path, "utf-8").split("\n");
|
|
1682
|
+
return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
1683
|
+
} catch {
|
|
1684
|
+
return null;
|
|
1685
|
+
}
|
|
1686
|
+
};
|
|
1687
|
+
var lowerFinding = (finding, repoRoot) => {
|
|
1688
|
+
if (finding.patch === void 0) return finding;
|
|
1689
|
+
const base = withoutPatch(finding);
|
|
1690
|
+
const lines = readFileLines(resolve$1(repoRoot, finding.path));
|
|
1691
|
+
if (lines === null) {
|
|
1692
|
+
process.stderr.write(
|
|
1693
|
+
`lower-suggestions: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
|
|
1694
|
+
`
|
|
1695
|
+
);
|
|
1696
|
+
return base;
|
|
1697
|
+
}
|
|
1698
|
+
const result = lowerPatch(finding.patch, lines);
|
|
1699
|
+
if (result.kind === "drop") {
|
|
1700
|
+
process.stderr.write(
|
|
1701
|
+
`lower-suggestions: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
|
|
1702
|
+
`
|
|
1703
|
+
);
|
|
1704
|
+
return base;
|
|
1705
|
+
}
|
|
1706
|
+
return {
|
|
1707
|
+
...base,
|
|
1708
|
+
suggestion: result.suggestion,
|
|
1709
|
+
start_line: result.startLine,
|
|
1710
|
+
end_line: result.endLine
|
|
1711
|
+
};
|
|
1712
|
+
};
|
|
1713
|
+
var lowerSuggestionsCmd = defineCommand({
|
|
1714
|
+
meta: {
|
|
1715
|
+
name: "lower-suggestions",
|
|
1716
|
+
description: "Validate each finding's patch against the real PR-head tree and lower it to an exact suggestion + range, or drop it (issue #10)"
|
|
1717
|
+
},
|
|
1718
|
+
args: {
|
|
1719
|
+
findings: {
|
|
1720
|
+
type: "positional",
|
|
1721
|
+
description: "Path to findings JSON",
|
|
1722
|
+
required: true
|
|
1723
|
+
},
|
|
1724
|
+
"repo-root": {
|
|
1725
|
+
type: "string",
|
|
1726
|
+
description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
|
|
1727
|
+
}
|
|
1728
|
+
},
|
|
1729
|
+
run: async ({ args }) => {
|
|
1730
|
+
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1731
|
+
const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
|
|
1732
|
+
const lowered = {
|
|
1733
|
+
...findings,
|
|
1734
|
+
findings: findings.findings.map((f) => lowerFinding(f, repoRoot))
|
|
1735
|
+
};
|
|
1736
|
+
process.stdout.write(`${JSON.stringify(lowered, null, 2)}
|
|
1737
|
+
`);
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1474
1740
|
var requireAdapterName = (name) => {
|
|
1475
1741
|
if (isAdapterName(name)) return name;
|
|
1476
1742
|
fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
|
|
@@ -1622,6 +1888,14 @@ var postCmd = defineCommand({
|
|
|
1622
1888
|
"test-report": {
|
|
1623
1889
|
type: "string",
|
|
1624
1890
|
description: TEST_REPORT_DESCRIPTION
|
|
1891
|
+
},
|
|
1892
|
+
"run-url": {
|
|
1893
|
+
type: "string",
|
|
1894
|
+
description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
|
|
1895
|
+
},
|
|
1896
|
+
"json-url": {
|
|
1897
|
+
type: "string",
|
|
1898
|
+
description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
|
|
1625
1899
|
}
|
|
1626
1900
|
},
|
|
1627
1901
|
run: async ({ args }) => {
|
|
@@ -1637,7 +1911,9 @@ var postCmd = defineCommand({
|
|
|
1637
1911
|
route: args.route,
|
|
1638
1912
|
headBranch: args["head-branch"],
|
|
1639
1913
|
effort: args.effort,
|
|
1640
|
-
testReportPath: args["test-report"]
|
|
1914
|
+
testReportPath: args["test-report"],
|
|
1915
|
+
runUrl: args["run-url"],
|
|
1916
|
+
jsonUrl: args["json-url"]
|
|
1641
1917
|
});
|
|
1642
1918
|
}
|
|
1643
1919
|
});
|
|
@@ -1645,7 +1921,7 @@ var main = defineCommand({
|
|
|
1645
1921
|
meta: {
|
|
1646
1922
|
name: "code-review",
|
|
1647
1923
|
version: packageVersion,
|
|
1648
|
-
description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, cost, and validate findings JSON"
|
|
1924
|
+
description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, lower-suggestions, cost, and validate findings JSON"
|
|
1649
1925
|
},
|
|
1650
1926
|
subCommands: {
|
|
1651
1927
|
gather: gatherCmd,
|
|
@@ -1656,6 +1932,7 @@ var main = defineCommand({
|
|
|
1656
1932
|
validate: validateCmd,
|
|
1657
1933
|
adapt: adaptCmd,
|
|
1658
1934
|
extract: extractCmd,
|
|
1935
|
+
"lower-suggestions": lowerSuggestionsCmd,
|
|
1659
1936
|
"print-schema": printSchemaCmd
|
|
1660
1937
|
}
|
|
1661
1938
|
});
|