@jphutchins/code-review 0.1.0-alpha.5 → 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 +331 -58
- 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 +31 -21
- package/templates/inline.eta +19 -3
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ npx @jphutchins/code-review <subcommand>
|
|
|
42
42
|
| `inline` | Build the GitHub reviews `comments[]` payload from findings + diff (in-diff validation; strays demote to the summary) |
|
|
43
43
|
| `adapt` | Map a native agent-CLI result envelope onto the abstract SPEC §6.1 envelope |
|
|
44
44
|
| `extract` | Recover findings/triage JSON from a native envelope via the deterministic extraction ladder |
|
|
45
|
+
| `lower-suggestions` | Validate each finding's `patch` against the real PR-head file and lower it to an exact `suggestion` + line range, or drop it |
|
|
45
46
|
| `cost` | Recompute USD cost from the envelope's per-model token counts + a price map |
|
|
46
47
|
| `validate` | Validate findings JSON against the published schema |
|
|
47
48
|
| `print-schema` | Print a bundled schema (findings, triage, prices) |
|
|
@@ -75,6 +76,23 @@ endpoint is a per-repo **Actions variable** (`API_BASE_URL`, required, no defaul
|
|
|
75
76
|
another provider requires adding that provider's API host to the workflow's egress allowlist in the
|
|
76
77
|
same reviewed PR.
|
|
77
78
|
|
|
79
|
+
## Artifacts
|
|
80
|
+
|
|
81
|
+
The review job uploads two artifacts, each visible from the workflow run the sticky's disclosure
|
|
82
|
+
links to:
|
|
83
|
+
|
|
84
|
+
- **`code-review-findings`** — the findings JSON + result envelope the comment job renders. The
|
|
85
|
+
sticky comment embeds this same JSON directly, base64-encoded, in an
|
|
86
|
+
`<!-- code-review:findings-json;base64 <base64> -->` HTML comment — a reviewing agent (or any
|
|
87
|
+
downstream tool) SHOULD base64-decode and parse that marker rather than parse the comment's prose.
|
|
88
|
+
Embedding in the comment (rather than only linking the artifact) keeps the pointer from expiring
|
|
89
|
+
with artifact retention; when the encoded findings are too large to embed, the sticky falls back to
|
|
90
|
+
a `<!-- code-review:findings-json <url> -->` link marker instead
|
|
91
|
+
([SPEC §5.1 item 7](SPEC.md#51-sticky-summary-comment)).
|
|
92
|
+
- **`code-review-transcript`** — the full Claude Code session transcripts for the triage and review
|
|
93
|
+
phases. This is advisory/auditability only: it is never read by the comment job and never affects
|
|
94
|
+
what gets posted.
|
|
95
|
+
|
|
78
96
|
## What's here
|
|
79
97
|
|
|
80
98
|
- **[SPEC.md](SPEC.md)** — the normative, provider-agnostic specification.
|
package/dist/index.js
CHANGED
|
@@ -73,6 +73,7 @@ var computeSeverityCounts = (findings) => findings.reduce(
|
|
|
73
73
|
(acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
|
|
74
74
|
emptySeverityCounts()
|
|
75
75
|
);
|
|
76
|
+
var EMBED_LIMIT = 4e4;
|
|
76
77
|
var render = (input) => {
|
|
77
78
|
const eta = new Eta({ autoTrim: false });
|
|
78
79
|
const usageAvailable = input.envelope !== null;
|
|
@@ -80,6 +81,8 @@ var render = (input) => {
|
|
|
80
81
|
const route = input.route ?? input.envelope?.route ?? null;
|
|
81
82
|
const effort = input.effort ?? input.envelope?.effort ?? null;
|
|
82
83
|
const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
|
|
84
|
+
const findingsB64 = Buffer.from(JSON.stringify(input.findings), "utf-8").toString("base64");
|
|
85
|
+
const embeddedFindings = findingsB64.length <= EMBED_LIMIT ? findingsB64 : null;
|
|
83
86
|
return eta.renderString(input.template, {
|
|
84
87
|
findings: input.findings,
|
|
85
88
|
envelope: input.envelope,
|
|
@@ -93,8 +96,12 @@ var render = (input) => {
|
|
|
93
96
|
severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
|
|
94
97
|
strays: (input.strays ?? []).map(sanitizeFinding),
|
|
95
98
|
inlineDisposition: input.inlineDisposition ?? null,
|
|
99
|
+
runUrl: input.runUrl ?? null,
|
|
100
|
+
jsonUrl: input.jsonUrl ?? null,
|
|
101
|
+
embeddedFindings,
|
|
102
|
+
reviewUrl: input.reviewUrl ?? null,
|
|
96
103
|
formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
|
|
97
|
-
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",
|
|
98
105
|
formatDuration: (ms) => {
|
|
99
106
|
if (!Number.isFinite(ms) || ms < 0) return "\u2014";
|
|
100
107
|
const s = Math.round(ms / 1e3);
|
|
@@ -183,8 +190,26 @@ var partitionFindings = (findings, index) => {
|
|
|
183
190
|
|
|
184
191
|
// src/inline.ts
|
|
185
192
|
var escapeBackticks2 = (text) => text.replace(/```/g, "`` ` ``");
|
|
186
|
-
var
|
|
187
|
-
|
|
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];
|
|
188
213
|
if (f.suggestion !== null && f.suggestion !== void 0) {
|
|
189
214
|
const safe = escapeBackticks2(f.suggestion);
|
|
190
215
|
parts.push(`\`\`\`suggestion
|
|
@@ -193,22 +218,27 @@ ${safe}
|
|
|
193
218
|
}
|
|
194
219
|
return parts.join("\n\n");
|
|
195
220
|
};
|
|
196
|
-
var renderCommentBody = (f, eta, template) => {
|
|
221
|
+
var renderCommentBody = (f, eta, template, modelsText, jsonUrl) => {
|
|
197
222
|
return eta.renderString(template, {
|
|
198
223
|
...f,
|
|
199
|
-
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
|
|
200
228
|
});
|
|
201
229
|
};
|
|
202
|
-
var buildInlineComments = (findings, diff,
|
|
230
|
+
var buildInlineComments = (findings, diff, context = {}) => {
|
|
231
|
+
const { inlineTemplate, models = [], jsonUrl } = context;
|
|
203
232
|
const index = indexDiff(diff);
|
|
204
233
|
const { inDiff, strays } = partitionFindings(findings, index);
|
|
205
234
|
const eta = inlineTemplate ? new Eta({ autoTrim: false }) : null;
|
|
235
|
+
const modelsText = formatModels(models);
|
|
206
236
|
const comments = inDiff.map((f) => {
|
|
207
237
|
const comment = {
|
|
208
238
|
path: f.path,
|
|
209
239
|
line: f.end_line,
|
|
210
240
|
side: defaultSide(f.side),
|
|
211
|
-
body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate) : buildCommentBody(f)
|
|
241
|
+
body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl) : buildCommentBody(f, jsonUrl)
|
|
212
242
|
};
|
|
213
243
|
if (f.start_line < f.end_line) {
|
|
214
244
|
return {
|
|
@@ -269,7 +299,9 @@ var FindingShape = t.intersection([
|
|
|
269
299
|
suggestion: t.union([t.string, t.null]),
|
|
270
300
|
confidence: Confidence,
|
|
271
301
|
code: t.string,
|
|
272
|
-
code_url: t.string
|
|
302
|
+
code_url: t.string,
|
|
303
|
+
reasoning: t.string,
|
|
304
|
+
patch: t.string
|
|
273
305
|
})
|
|
274
306
|
]);
|
|
275
307
|
var EndGeStart = t.refinement(
|
|
@@ -345,7 +377,7 @@ var TestSummaryCodec = t.intersection([
|
|
|
345
377
|
failures: t.array(TestFailureCodec)
|
|
346
378
|
})
|
|
347
379
|
]);
|
|
348
|
-
var DEFAULT_SCHEMA_VERSION = "0.
|
|
380
|
+
var DEFAULT_SCHEMA_VERSION = "0.3.0";
|
|
349
381
|
|
|
350
382
|
// src/validate.ts
|
|
351
383
|
var addFormats = _addFormats;
|
|
@@ -381,15 +413,45 @@ var unsafeUnwrap = (decoded) => {
|
|
|
381
413
|
if (decoded._tag === "Right") return decoded.right;
|
|
382
414
|
throw new Error("io-ts decode failed \u2014 data does not match expected shape");
|
|
383
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
|
+
};
|
|
384
438
|
var identity = (decoded) => decoded;
|
|
385
439
|
var findingsTable = [
|
|
386
440
|
{
|
|
387
|
-
minor: "0.
|
|
441
|
+
minor: "0.3",
|
|
388
442
|
defaultVersion: DEFAULT_SCHEMA_VERSION,
|
|
389
443
|
schemaFile: "findings.schema.json",
|
|
390
444
|
codec: FindingsCodec,
|
|
391
445
|
normalize: identity,
|
|
392
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
|
|
393
455
|
}
|
|
394
456
|
];
|
|
395
457
|
var triageTable = [
|
|
@@ -595,9 +657,19 @@ var loadTestReport = (path) => {
|
|
|
595
657
|
}
|
|
596
658
|
return decoded.right;
|
|
597
659
|
};
|
|
598
|
-
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.`;
|
|
599
671
|
const body = JSON.stringify({
|
|
600
|
-
body:
|
|
672
|
+
body: pointer,
|
|
601
673
|
commit_id: headSha,
|
|
602
674
|
event: "COMMENT",
|
|
603
675
|
comments: comments.map((c) => ({
|
|
@@ -605,10 +677,14 @@ var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
|
|
|
605
677
|
line: c.line,
|
|
606
678
|
side: c.side,
|
|
607
679
|
...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
|
|
608
|
-
body: c.body
|
|
680
|
+
body: formatMarkdown(c.body)
|
|
609
681
|
}))
|
|
610
682
|
});
|
|
611
|
-
await ghApi(
|
|
683
|
+
const stdout = await ghApi(
|
|
684
|
+
[`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"],
|
|
685
|
+
body
|
|
686
|
+
);
|
|
687
|
+
return parseHtmlUrl(stdout);
|
|
612
688
|
};
|
|
613
689
|
var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
614
690
|
const stdout = await ghApi(
|
|
@@ -628,30 +704,42 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
|
628
704
|
const parsed = JSON.parse(last);
|
|
629
705
|
return { id: parsed.id, body: parsed.body };
|
|
630
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
|
+
};
|
|
631
715
|
var patchComment = async (repo, commentId, body, ghApi) => {
|
|
632
|
-
await ghApi(
|
|
716
|
+
const stdout = await ghApi(
|
|
633
717
|
[`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
|
|
634
718
|
JSON.stringify({ body })
|
|
635
719
|
);
|
|
720
|
+
const htmlUrl = parseHtmlUrl(stdout);
|
|
721
|
+
return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
|
|
636
722
|
};
|
|
637
723
|
var postComment = async (repo, prNumber, body, ghApi) => {
|
|
638
|
-
await ghApi(
|
|
724
|
+
const stdout = await ghApi(
|
|
639
725
|
[`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
|
|
640
726
|
JSON.stringify({ body })
|
|
641
727
|
);
|
|
728
|
+
return parseCommentRef(stdout);
|
|
642
729
|
};
|
|
643
730
|
var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
|
|
644
731
|
if (existing !== null) {
|
|
645
|
-
await patchComment(repo, existing.id, body, ghApi);
|
|
732
|
+
const patched = await patchComment(repo, existing.id, body, ghApi);
|
|
646
733
|
process.stderr.write(
|
|
647
734
|
`Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
|
|
648
735
|
`
|
|
649
736
|
);
|
|
650
|
-
|
|
651
|
-
await postComment(repo, prNumber, body, ghApi);
|
|
652
|
-
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
653
|
-
`);
|
|
737
|
+
return { id: existing.id, url: patched?.html_url };
|
|
654
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;
|
|
655
743
|
};
|
|
656
744
|
var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
|
|
657
745
|
var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
@@ -720,15 +808,19 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
720
808
|
}
|
|
721
809
|
const template = readFileSync(input.templatePath, "utf-8");
|
|
722
810
|
const inlineTemplate = input.inlineTemplatePath ? readFileSync(input.inlineTemplatePath, "utf-8") : void 0;
|
|
723
|
-
const renderNotice = (message) =>
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
+
);
|
|
732
824
|
if (isEmptyDiff(diff)) {
|
|
733
825
|
await upsertSticky(
|
|
734
826
|
input.repo,
|
|
@@ -754,28 +846,32 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
754
846
|
const envelope = loadEnvelope(input.envelopePath);
|
|
755
847
|
const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
|
|
756
848
|
if (envelope === null) {
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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);
|
|
769
865
|
process.stderr.write(
|
|
770
866
|
"Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
|
|
771
867
|
);
|
|
772
868
|
process.exit(0);
|
|
773
869
|
}
|
|
774
|
-
const { comments: rawComments, strays } = buildInlineComments(
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
);
|
|
870
|
+
const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
|
|
871
|
+
inlineTemplate,
|
|
872
|
+
models: envelope.models.map((m) => m.model),
|
|
873
|
+
jsonUrl: input.jsonUrl
|
|
874
|
+
});
|
|
779
875
|
const { comments, longFiles } = checkLongSuggestions(rawComments);
|
|
780
876
|
for (const wf of longFiles) {
|
|
781
877
|
process.stderr.write(
|
|
@@ -786,7 +882,7 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
786
882
|
const botReviews = comments.length > 0 ? await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi) : [];
|
|
787
883
|
const alreadyReviewedThisSha = botReviews.some((r) => r.commitId === input.headSha);
|
|
788
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;
|
|
789
|
-
const
|
|
885
|
+
const commonRenderInput = {
|
|
790
886
|
findings,
|
|
791
887
|
envelope,
|
|
792
888
|
prices: decodedPrices.right,
|
|
@@ -797,14 +893,18 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
797
893
|
testReport,
|
|
798
894
|
severityCounts: computeSeverityCounts(findings.findings),
|
|
799
895
|
strays,
|
|
800
|
-
inlineDisposition
|
|
801
|
-
|
|
896
|
+
inlineDisposition,
|
|
897
|
+
runUrl: input.runUrl,
|
|
898
|
+
jsonUrl: input.jsonUrl
|
|
899
|
+
};
|
|
900
|
+
const longFilesNote = longFiles.length > 0 ? `
|
|
802
901
|
|
|
803
902
|
---
|
|
804
903
|
|
|
805
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.
|
|
806
|
-
` : ""
|
|
807
|
-
|
|
905
|
+
` : "";
|
|
906
|
+
const renderBody = (reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, reviewUrl: reviewUrl2 }) + longFilesNote);
|
|
907
|
+
const stickyRef = await upsertSticky(input.repo, prNumber, existingSticky, renderBody(), ghApi);
|
|
808
908
|
if (comments.length === 0) return;
|
|
809
909
|
if (alreadyReviewedThisSha) {
|
|
810
910
|
process.stderr.write(
|
|
@@ -817,11 +917,30 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
817
917
|
if (stalePriorReviewIds.length > 0) {
|
|
818
918
|
await dismissReviews(input.repo, prNumber, stalePriorReviewIds, ghApi);
|
|
819
919
|
}
|
|
820
|
-
await postInlineReview(
|
|
920
|
+
const reviewUrl = await postInlineReview(
|
|
921
|
+
input.repo,
|
|
922
|
+
prNumber,
|
|
923
|
+
input.headSha,
|
|
924
|
+
comments,
|
|
925
|
+
stickyRef?.url,
|
|
926
|
+
ghApi
|
|
927
|
+
);
|
|
821
928
|
process.stderr.write(
|
|
822
929
|
`Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
|
|
823
930
|
`
|
|
824
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
|
+
}
|
|
943
|
+
}
|
|
825
944
|
};
|
|
826
945
|
var renderOutputs = (result) => {
|
|
827
946
|
switch (result.kind) {
|
|
@@ -1032,7 +1151,7 @@ var candidateFromJsonText = (kind, text) => {
|
|
|
1032
1151
|
};
|
|
1033
1152
|
var FENCE_OPEN = /^\s*(`{3,})/;
|
|
1034
1153
|
var FENCE_MARKER_ONLY = /^`+$/;
|
|
1035
|
-
var
|
|
1154
|
+
var scanLine2 = (state, line) => {
|
|
1036
1155
|
if (state.openLength === null) {
|
|
1037
1156
|
const opened = FENCE_OPEN.exec(line)?.[1]?.length;
|
|
1038
1157
|
return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
|
|
@@ -1041,7 +1160,7 @@ var scanLine = (state, line) => {
|
|
|
1041
1160
|
const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
|
|
1042
1161
|
return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
|
|
1043
1162
|
};
|
|
1044
|
-
var scanFencedBlocks = (text) => text.split("\n").reduce(
|
|
1163
|
+
var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
|
|
1045
1164
|
var ladderFailureDiagnostics = (input) => {
|
|
1046
1165
|
const native = parseNativeForExtraction(input.native);
|
|
1047
1166
|
const preview = (s) => {
|
|
@@ -1176,6 +1295,83 @@ var adapt = (adapterName, native, agentFilePath, meta = {}) => {
|
|
|
1176
1295
|
}
|
|
1177
1296
|
};
|
|
1178
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
|
+
|
|
1179
1375
|
// src/index.ts
|
|
1180
1376
|
var readJSON = (path) => {
|
|
1181
1377
|
try {
|
|
@@ -1305,7 +1501,7 @@ var inlineCmd = defineCommand({
|
|
|
1305
1501
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1306
1502
|
const diff = readFileSync(resolve$1(args.diff), "utf-8");
|
|
1307
1503
|
const inlineTemplate = args.template ? readFileSync(resolve$1(args.template), "utf-8") : void 0;
|
|
1308
|
-
const { comments, strays } = buildInlineComments(findings.findings, diff, inlineTemplate);
|
|
1504
|
+
const { comments, strays } = buildInlineComments(findings.findings, diff, { inlineTemplate });
|
|
1309
1505
|
process.stdout.write(
|
|
1310
1506
|
JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
|
|
1311
1507
|
);
|
|
@@ -1475,6 +1671,72 @@ ${ladderFailureDiagnostics(input)}
|
|
|
1475
1671
|
fail(describeLadderFailure(outcome));
|
|
1476
1672
|
}
|
|
1477
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
|
+
});
|
|
1478
1740
|
var requireAdapterName = (name) => {
|
|
1479
1741
|
if (isAdapterName(name)) return name;
|
|
1480
1742
|
fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
|
|
@@ -1626,6 +1888,14 @@ var postCmd = defineCommand({
|
|
|
1626
1888
|
"test-report": {
|
|
1627
1889
|
type: "string",
|
|
1628
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"
|
|
1629
1899
|
}
|
|
1630
1900
|
},
|
|
1631
1901
|
run: async ({ args }) => {
|
|
@@ -1641,7 +1911,9 @@ var postCmd = defineCommand({
|
|
|
1641
1911
|
route: args.route,
|
|
1642
1912
|
headBranch: args["head-branch"],
|
|
1643
1913
|
effort: args.effort,
|
|
1644
|
-
testReportPath: args["test-report"]
|
|
1914
|
+
testReportPath: args["test-report"],
|
|
1915
|
+
runUrl: args["run-url"],
|
|
1916
|
+
jsonUrl: args["json-url"]
|
|
1645
1917
|
});
|
|
1646
1918
|
}
|
|
1647
1919
|
});
|
|
@@ -1649,7 +1921,7 @@ var main = defineCommand({
|
|
|
1649
1921
|
meta: {
|
|
1650
1922
|
name: "code-review",
|
|
1651
1923
|
version: packageVersion,
|
|
1652
|
-
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"
|
|
1653
1925
|
},
|
|
1654
1926
|
subCommands: {
|
|
1655
1927
|
gather: gatherCmd,
|
|
@@ -1660,6 +1932,7 @@ var main = defineCommand({
|
|
|
1660
1932
|
validate: validateCmd,
|
|
1661
1933
|
adapt: adaptCmd,
|
|
1662
1934
|
extract: extractCmd,
|
|
1935
|
+
"lower-suggestions": lowerSuggestionsCmd,
|
|
1663
1936
|
"print-schema": printSchemaCmd
|
|
1664
1937
|
}
|
|
1665
1938
|
});
|