@mrkaran/hodor 0.6.2 → 0.6.3-rc.2
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 +6 -2
- package/dist/{chunk-DVJVQTVW.js → chunk-LO2QJD45.js} +15 -93
- package/dist/chunk-LO2QJD45.js.map +1 -0
- package/dist/{chunk-LQDQ7CHL.js → chunk-ZUYM6HY3.js} +656 -638
- package/dist/chunk-ZUYM6HY3.js.map +1 -0
- package/dist/cli.js +104 -10
- package/dist/cli.js.map +1 -1
- package/dist/{gitlab-5TWHYHEP.js → gitlab-OMV6OJ5Q.js} +8 -8
- package/dist/index.d.ts +18 -9
- package/dist/index.js +2 -2
- package/package.json +5 -3
- package/dist/chunk-DVJVQTVW.js.map +0 -1
- package/dist/chunk-LQDQ7CHL.js.map +0 -1
- /package/dist/{gitlab-5TWHYHEP.js.map → gitlab-OMV6OJ5Q.js.map} +0 -0
|
@@ -13,8 +13,9 @@ import {
|
|
|
13
13
|
renderMarkdown,
|
|
14
14
|
renderSummaryMarkdown,
|
|
15
15
|
resolveGitlabDiscussions,
|
|
16
|
-
summarizeGitlabNotes
|
|
17
|
-
|
|
16
|
+
summarizeGitlabNotes,
|
|
17
|
+
summarizeHodorNotes
|
|
18
|
+
} from "./chunk-LO2QJD45.js";
|
|
18
19
|
import {
|
|
19
20
|
relativizeWorkspacePath
|
|
20
21
|
} from "./chunk-AMUK6GDX.js";
|
|
@@ -215,15 +216,22 @@ function buildMrSections(mrMetadata) {
|
|
|
215
216
|
contextSection += "\n";
|
|
216
217
|
}
|
|
217
218
|
let notesSection = "";
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
219
|
+
const humanNotesSummary = summarizeGitlabNotes(mrMetadata.Notes);
|
|
220
|
+
const hodorNotesSummary = summarizeHodorNotes(mrMetadata.Notes);
|
|
221
|
+
if (humanNotesSummary) {
|
|
222
|
+
notesSection += `## Existing Human MR Notes
|
|
223
|
+
${humanNotesSummary}
|
|
224
|
+
`;
|
|
225
|
+
}
|
|
226
|
+
if (hodorNotesSummary) {
|
|
227
|
+
notesSection += `## Prior Hodor Reviews (deduplication only)
|
|
228
|
+
${hodorNotesSummary}
|
|
229
|
+
Use this history only to avoid repeating the same finding. Re-check the current diff independently.
|
|
222
230
|
`;
|
|
223
231
|
}
|
|
224
232
|
let reminderSection = "";
|
|
225
|
-
if (
|
|
226
|
-
reminderSection = "## Review Note Deduplication\n\nThe
|
|
233
|
+
if (humanNotesSummary || hodorNotesSummary) {
|
|
234
|
+
reminderSection = "## Review Note Deduplication\n\nThe human notes and prior Hodor reviews above may already cover some issues. Before reporting a finding:\n1. Check if it's already mentioned in existing notes\n2. Only report if your finding is materially different or more specific\n3. If an existing note is incorrect/outdated, explain why in your finding\n\nFocus on discovering NEW issues not yet discussed.\n";
|
|
227
235
|
}
|
|
228
236
|
return { contextSection, notesSection, reminderSection };
|
|
229
237
|
}
|
|
@@ -258,7 +266,8 @@ function normalizeLabelNames(rawLabels) {
|
|
|
258
266
|
}
|
|
259
267
|
|
|
260
268
|
// src/model.ts
|
|
261
|
-
import { getEnvApiKey
|
|
269
|
+
import { getEnvApiKey } from "@earendil-works/pi-ai/compat";
|
|
270
|
+
import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
262
271
|
var PROVIDER_ALIASES = {
|
|
263
272
|
bedrock: "amazon-bedrock"
|
|
264
273
|
};
|
|
@@ -269,7 +278,7 @@ function parseModelString(model) {
|
|
|
269
278
|
if (parts.length >= 2) {
|
|
270
279
|
const first = parts[0].toLowerCase();
|
|
271
280
|
const provider = PROVIDER_ALIASES[first] ?? first;
|
|
272
|
-
const knownProviders = new Set(
|
|
281
|
+
const knownProviders = new Set(getBuiltinProviders());
|
|
273
282
|
if (provider === "amazon-bedrock") {
|
|
274
283
|
let modelId = parts.slice(1).join("/");
|
|
275
284
|
if (modelId.startsWith("converse/")) {
|
|
@@ -309,6 +318,8 @@ function mapReasoningEffort(effort) {
|
|
|
309
318
|
return "high";
|
|
310
319
|
case "xhigh":
|
|
311
320
|
return "xhigh";
|
|
321
|
+
case "max":
|
|
322
|
+
return "max";
|
|
312
323
|
default:
|
|
313
324
|
return void 0;
|
|
314
325
|
}
|
|
@@ -545,100 +556,87 @@ function getPriorityFromTitle(title) {
|
|
|
545
556
|
return REVIEW_PRIORITY_TAGS.get(`[${match[1]}]`) ?? null;
|
|
546
557
|
}
|
|
547
558
|
|
|
548
|
-
// src/
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
var GitHubAPIError = class extends Error {
|
|
555
|
-
constructor(message) {
|
|
556
|
-
super(message);
|
|
557
|
-
this.name = "GitHubAPIError";
|
|
559
|
+
// src/platform.ts
|
|
560
|
+
function detectPlatform(prUrl) {
|
|
561
|
+
const url = new URL(prUrl);
|
|
562
|
+
const hostname = url.hostname;
|
|
563
|
+
if (prUrl.includes("/-/merge_requests/") || hostname.includes("gitlab")) {
|
|
564
|
+
return "gitlab";
|
|
558
565
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
const fields = [
|
|
562
|
-
"number",
|
|
563
|
-
"title",
|
|
564
|
-
"body",
|
|
565
|
-
"author",
|
|
566
|
-
"baseRefName",
|
|
567
|
-
"headRefName",
|
|
568
|
-
"baseRefOid",
|
|
569
|
-
"headRefOid",
|
|
570
|
-
"changedFiles",
|
|
571
|
-
"labels",
|
|
572
|
-
"comments",
|
|
573
|
-
"state",
|
|
574
|
-
"isDraft",
|
|
575
|
-
"createdAt",
|
|
576
|
-
"updatedAt",
|
|
577
|
-
"mergeable",
|
|
578
|
-
"url"
|
|
579
|
-
];
|
|
580
|
-
const repoFullPath = `${owner}/${repo}`;
|
|
581
|
-
try {
|
|
582
|
-
return await execJson("gh", [
|
|
583
|
-
"pr",
|
|
584
|
-
"view",
|
|
585
|
-
String(prNumber),
|
|
586
|
-
"-R",
|
|
587
|
-
repoFullPath,
|
|
588
|
-
"--json",
|
|
589
|
-
fields.join(",")
|
|
590
|
-
]);
|
|
591
|
-
} catch (err) {
|
|
592
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
593
|
-
throw new GitHubAPIError(msg);
|
|
566
|
+
if (prUrl.includes("/pulls/") || hostname.includes("gitea") || hostname.includes("forgejo") || hostname.includes("codeberg")) {
|
|
567
|
+
return "gitea";
|
|
594
568
|
}
|
|
569
|
+
if (prUrl.includes("/pull/") || hostname.includes("github")) {
|
|
570
|
+
return "github";
|
|
571
|
+
}
|
|
572
|
+
throw new Error(
|
|
573
|
+
`Cannot detect platform for URL: ${prUrl}. Expected a GitHub (/pull/), GitLab (/-/merge_requests/), or Gitea/Forgejo (/pulls/) URL.`
|
|
574
|
+
);
|
|
595
575
|
}
|
|
596
|
-
function
|
|
597
|
-
const
|
|
598
|
-
const
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
if (
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if (nodes.length > 0 && typeof nodes[0] === "object" && "node" in nodes[0]) {
|
|
622
|
-
nodes = nodes.map(
|
|
623
|
-
(edge) => edge.node ?? {}
|
|
576
|
+
function parsePrUrl(prUrl) {
|
|
577
|
+
const url = new URL(prUrl);
|
|
578
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
579
|
+
const host = url.host;
|
|
580
|
+
if (pathParts.length >= 4 && pathParts[2] === "pull") {
|
|
581
|
+
return {
|
|
582
|
+
owner: pathParts[0],
|
|
583
|
+
repo: pathParts[1],
|
|
584
|
+
prNumber: parsePositiveNumber(pathParts[3], "PR", prUrl, "/pull/"),
|
|
585
|
+
host
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
if (pathParts.length >= 4 && pathParts[2] === "pulls") {
|
|
589
|
+
return {
|
|
590
|
+
owner: pathParts[0],
|
|
591
|
+
repo: pathParts[1],
|
|
592
|
+
prNumber: parsePositiveNumber(pathParts[3], "PR", prUrl, "/pulls/"),
|
|
593
|
+
host
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
const mrIndex = pathParts.indexOf("merge_requests");
|
|
597
|
+
if (mrIndex >= 0) {
|
|
598
|
+
if (mrIndex < 2 || mrIndex + 1 >= pathParts.length) {
|
|
599
|
+
throw new Error(
|
|
600
|
+
`Invalid GitLab MR URL format: ${prUrl}. Expected .../-/merge_requests/<number>`
|
|
624
601
|
);
|
|
625
602
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
603
|
+
if (pathParts[mrIndex - 1] !== "-") {
|
|
604
|
+
throw new Error(
|
|
605
|
+
`Invalid GitLab MR URL format: ${prUrl}. Missing '/-/' segment before merge_requests.`
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
const repo = pathParts[mrIndex - 2];
|
|
609
|
+
const ownerParts = pathParts.slice(0, mrIndex - 2);
|
|
610
|
+
const owner = ownerParts.length > 0 ? ownerParts.join("/") : pathParts[0];
|
|
631
611
|
return {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
612
|
+
owner,
|
|
613
|
+
repo,
|
|
614
|
+
prNumber: parsePositiveNumber(
|
|
615
|
+
pathParts[mrIndex + 1],
|
|
616
|
+
"MR",
|
|
617
|
+
prUrl,
|
|
618
|
+
"/merge_requests/"
|
|
619
|
+
),
|
|
620
|
+
host
|
|
638
621
|
};
|
|
639
|
-
}
|
|
622
|
+
}
|
|
623
|
+
throw new Error(
|
|
624
|
+
`Invalid PR/MR URL format: ${prUrl}. Expected GitHub (/pull/), GitLab (/-/merge_requests/), or Gitea/Forgejo (/pulls/) URL.`
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
function parsePositiveNumber(raw, kind, prUrl, segment) {
|
|
628
|
+
const number = Number(raw);
|
|
629
|
+
if (!Number.isSafeInteger(number) || number <= 0) {
|
|
630
|
+
throw new Error(
|
|
631
|
+
`Invalid ${kind} number in URL: ${prUrl}. Expected a positive integer after ${segment}.`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
return number;
|
|
640
635
|
}
|
|
641
636
|
|
|
637
|
+
// src/publisher.ts
|
|
638
|
+
import { createHash } from "crypto";
|
|
639
|
+
|
|
642
640
|
// src/gitea.ts
|
|
643
641
|
var GiteaAPIError = class extends Error {
|
|
644
642
|
constructor(message) {
|
|
@@ -801,72 +799,459 @@ async function postGiteaPrComment(owner, repo, prNumber, body, host) {
|
|
|
801
799
|
}
|
|
802
800
|
}
|
|
803
801
|
|
|
804
|
-
// src/
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
}
|
|
813
|
-
};
|
|
814
|
-
function envOrNull(name) {
|
|
815
|
-
const value = process.env[name]?.trim();
|
|
816
|
-
return value ? value : null;
|
|
802
|
+
// src/publisher.ts
|
|
803
|
+
var FINDING_MARKER_RE = /<!--\s*hodor:finding:([a-f0-9]{64})\s*-->/i;
|
|
804
|
+
function getFindingFingerprint(finding, workspacePath) {
|
|
805
|
+
const path = relativizeWorkspacePath(
|
|
806
|
+
finding.code_location.absolute_file_path,
|
|
807
|
+
workspacePath ?? void 0
|
|
808
|
+
);
|
|
809
|
+
const title = finding.title.replace(/^\[P[0-3]\]\s*/, "").trim().toLowerCase();
|
|
810
|
+
return createHash("sha256").update(`${path}
|
|
811
|
+
${title}`).digest("hex");
|
|
817
812
|
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
813
|
+
function getDiscussionFingerprint(body) {
|
|
814
|
+
return body.match(FINDING_MARKER_RE)?.[1]?.toLowerCase() ?? null;
|
|
815
|
+
}
|
|
816
|
+
async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
|
|
817
|
+
const blocking = review.findings.filter((finding) => finding.priority <= 1).length;
|
|
818
|
+
const state = blocking > 0 ? "failed" : "success";
|
|
819
|
+
const description = blocking > 0 ? `${blocking} blocking issue(s) found` : review.findings.length > 0 ? `${review.findings.length} non-blocking issue(s)` : "No issues found";
|
|
820
|
+
await postGitlabCommitStatus(
|
|
821
|
+
parsed.owner,
|
|
822
|
+
parsed.repo,
|
|
823
|
+
diffRefs.head_sha,
|
|
824
|
+
state,
|
|
825
|
+
parsed.host,
|
|
826
|
+
{ description }
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
async function postReviewComment(opts) {
|
|
830
|
+
const { prUrl, reviewText, model, metricsFooter, headSha } = opts;
|
|
831
|
+
const platform = detectPlatform(prUrl);
|
|
832
|
+
const parsed = parsePrUrl(prUrl);
|
|
833
|
+
let body = reviewText;
|
|
834
|
+
if (headSha) body = `<!-- hodor:sha:${headSha} -->
|
|
835
|
+
${body}`;
|
|
836
|
+
if (model) body += `
|
|
837
|
+
---
|
|
838
|
+
|
|
839
|
+
Review generated by Hodor (model: \`${model}\`)`;
|
|
840
|
+
if (metricsFooter) body += `
|
|
841
|
+
|
|
842
|
+
${metricsFooter}`;
|
|
843
|
+
try {
|
|
844
|
+
if (platform === "github") {
|
|
845
|
+
await exec("gh", [
|
|
846
|
+
"pr",
|
|
847
|
+
"review",
|
|
848
|
+
String(parsed.prNumber),
|
|
849
|
+
"--repo",
|
|
850
|
+
`${parsed.owner}/${parsed.repo}`,
|
|
851
|
+
"--comment",
|
|
852
|
+
"--body",
|
|
853
|
+
body
|
|
854
|
+
]);
|
|
855
|
+
return { success: true, platform, prNumber: parsed.prNumber };
|
|
848
856
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
logger.info(`Detected GitHub Actions environment (base: ${baseRef ?? "unknown"})`);
|
|
857
|
-
return { path: workspaceDir, targetBranch: baseRef, diffBaseSha: null };
|
|
858
|
-
}
|
|
859
|
-
logger.warn(
|
|
860
|
-
`Detected GitHub Actions for ${repository}, but ${workspaceDir} is not a git checkout of ${expected}; falling back to clone`
|
|
857
|
+
if (platform === "gitea") {
|
|
858
|
+
await postGiteaPrComment(
|
|
859
|
+
parsed.owner,
|
|
860
|
+
parsed.repo,
|
|
861
|
+
parsed.prNumber,
|
|
862
|
+
body,
|
|
863
|
+
parsed.host
|
|
861
864
|
);
|
|
865
|
+
return { success: true, platform, prNumber: parsed.prNumber };
|
|
862
866
|
}
|
|
867
|
+
await postGitlabMrComment(
|
|
868
|
+
parsed.owner,
|
|
869
|
+
parsed.repo,
|
|
870
|
+
parsed.prNumber,
|
|
871
|
+
body,
|
|
872
|
+
parsed.host
|
|
873
|
+
);
|
|
874
|
+
return {
|
|
875
|
+
success: true,
|
|
876
|
+
platform,
|
|
877
|
+
mrNumber: parsed.prNumber,
|
|
878
|
+
summaryPosted: true
|
|
879
|
+
};
|
|
880
|
+
} catch (error) {
|
|
881
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
882
|
+
logger.error(`Failed to post comment: ${message}`);
|
|
883
|
+
return { success: false, platform, error: message };
|
|
863
884
|
}
|
|
864
|
-
return { path: null, targetBranch: null, diffBaseSha: null };
|
|
865
885
|
}
|
|
866
|
-
function
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
|
|
886
|
+
async function postReviewStructured(opts) {
|
|
887
|
+
const {
|
|
888
|
+
prUrl,
|
|
889
|
+
review,
|
|
890
|
+
model,
|
|
891
|
+
metricsFooter,
|
|
892
|
+
reviewStyle = "hybrid",
|
|
893
|
+
commitStatus = false,
|
|
894
|
+
headSha,
|
|
895
|
+
workspacePath,
|
|
896
|
+
reconcileDiscussions = false
|
|
897
|
+
} = opts;
|
|
898
|
+
const platform = detectPlatform(prUrl);
|
|
899
|
+
if (platform !== "gitlab" || reviewStyle === "summary") {
|
|
900
|
+
return postReviewComment({
|
|
901
|
+
prUrl,
|
|
902
|
+
reviewText: renderMarkdown(review),
|
|
903
|
+
model,
|
|
904
|
+
metricsFooter,
|
|
905
|
+
headSha
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
const parsed = parsePrUrl(prUrl);
|
|
909
|
+
const errors = [];
|
|
910
|
+
let diffRefs;
|
|
911
|
+
try {
|
|
912
|
+
diffRefs = await getGitlabMrDiffRefs(
|
|
913
|
+
parsed.owner,
|
|
914
|
+
parsed.repo,
|
|
915
|
+
parsed.prNumber,
|
|
916
|
+
parsed.host
|
|
917
|
+
);
|
|
918
|
+
} catch (error) {
|
|
919
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
920
|
+
logger.warn(`Failed to get diff_refs, falling back to summary mode: ${message}`);
|
|
921
|
+
return postReviewComment({
|
|
922
|
+
prUrl,
|
|
923
|
+
reviewText: renderMarkdown(review),
|
|
924
|
+
model,
|
|
925
|
+
metricsFooter,
|
|
926
|
+
headSha
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
const existingByFingerprint = /* @__PURE__ */ new Map();
|
|
930
|
+
try {
|
|
931
|
+
const discussions = await listHodorDiscussions(
|
|
932
|
+
parsed.owner,
|
|
933
|
+
parsed.repo,
|
|
934
|
+
parsed.prNumber,
|
|
935
|
+
parsed.host
|
|
936
|
+
);
|
|
937
|
+
for (const discussion of discussions) {
|
|
938
|
+
if (discussion.resolved) continue;
|
|
939
|
+
const fingerprint = getDiscussionFingerprint(discussion.body);
|
|
940
|
+
if (!fingerprint) continue;
|
|
941
|
+
const ids = existingByFingerprint.get(fingerprint) ?? /* @__PURE__ */ new Set();
|
|
942
|
+
ids.add(discussion.discussionId);
|
|
943
|
+
existingByFingerprint.set(fingerprint, ids);
|
|
944
|
+
}
|
|
945
|
+
} catch (error) {
|
|
946
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
947
|
+
if (reconcileDiscussions) {
|
|
948
|
+
errors.push(`discussion listing: ${message}`);
|
|
949
|
+
}
|
|
950
|
+
logger.warn(`Failed to list open Hodor discussions for deduplication: ${message}`);
|
|
951
|
+
}
|
|
952
|
+
let inlineCreated = 0;
|
|
953
|
+
let inlineFailed = 0;
|
|
954
|
+
let inlineDeduplicated = 0;
|
|
955
|
+
for (const finding of review.findings) {
|
|
956
|
+
const fingerprint = getFindingFingerprint(finding, workspacePath);
|
|
957
|
+
if (existingByFingerprint.has(fingerprint)) {
|
|
958
|
+
inlineDeduplicated++;
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
const relPath = relativizeWorkspacePath(
|
|
962
|
+
finding.code_location.absolute_file_path,
|
|
963
|
+
workspacePath ?? void 0
|
|
964
|
+
);
|
|
965
|
+
const title = /^\[P[0-3]\]/.test(finding.title) ? finding.title : `[P${finding.priority}] ${finding.title}`;
|
|
966
|
+
let body = `${HODOR_REVIEW_MARKER}
|
|
967
|
+
<!-- hodor:finding:${fingerprint} -->
|
|
968
|
+
**${title}**
|
|
969
|
+
|
|
970
|
+
${finding.body}`;
|
|
971
|
+
if (finding.suggestion) {
|
|
972
|
+
const { start, end } = finding.code_location.line_range;
|
|
973
|
+
const span = Math.max(0, end - start);
|
|
974
|
+
body += `
|
|
975
|
+
|
|
976
|
+
\`\`\`suggestion:-0+${span}
|
|
977
|
+
${finding.suggestion}
|
|
978
|
+
\`\`\``;
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
await createGitlabDraftNote(
|
|
982
|
+
parsed.owner,
|
|
983
|
+
parsed.repo,
|
|
984
|
+
parsed.prNumber,
|
|
985
|
+
body,
|
|
986
|
+
parsed.host,
|
|
987
|
+
{
|
|
988
|
+
filePath: relPath,
|
|
989
|
+
line: finding.code_location.line_range.start,
|
|
990
|
+
diffRefs
|
|
991
|
+
}
|
|
992
|
+
);
|
|
993
|
+
inlineCreated++;
|
|
994
|
+
} catch (error) {
|
|
995
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
996
|
+
errors.push(`inline note for ${finding.title}: ${message}`);
|
|
997
|
+
logger.warn(`Failed to create inline note for "${finding.title}": ${message}`);
|
|
998
|
+
inlineFailed++;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
logger.info(
|
|
1002
|
+
`Created ${inlineCreated} inline draft note(s)${inlineDeduplicated > 0 ? ` (${inlineDeduplicated} already open)` : ""}${inlineFailed > 0 ? ` (${inlineFailed} failed)` : ""}`
|
|
1003
|
+
);
|
|
1004
|
+
let draftsPublished = false;
|
|
1005
|
+
if (inlineCreated > 0) {
|
|
1006
|
+
try {
|
|
1007
|
+
await bulkPublishGitlabDraftNotes(
|
|
1008
|
+
parsed.owner,
|
|
1009
|
+
parsed.repo,
|
|
1010
|
+
parsed.prNumber,
|
|
1011
|
+
parsed.host
|
|
1012
|
+
);
|
|
1013
|
+
draftsPublished = true;
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1016
|
+
errors.push(`draft publish: ${message}`);
|
|
1017
|
+
logger.warn(`Failed to bulk publish draft notes: ${message}`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
let summaryPosted = false;
|
|
1021
|
+
if (reviewStyle === "hybrid" || review.findings.length === 0) {
|
|
1022
|
+
let summaryBody = renderSummaryMarkdown(review);
|
|
1023
|
+
if (headSha) summaryBody = `<!-- hodor:sha:${headSha} -->
|
|
1024
|
+
${summaryBody}`;
|
|
1025
|
+
if (model) summaryBody += `
|
|
1026
|
+
---
|
|
1027
|
+
|
|
1028
|
+
Review generated by Hodor (model: \`${model}\`)`;
|
|
1029
|
+
if (metricsFooter) summaryBody += `
|
|
1030
|
+
|
|
1031
|
+
${metricsFooter}`;
|
|
1032
|
+
try {
|
|
1033
|
+
await postGitlabMrComment(
|
|
1034
|
+
parsed.owner,
|
|
1035
|
+
parsed.repo,
|
|
1036
|
+
parsed.prNumber,
|
|
1037
|
+
summaryBody,
|
|
1038
|
+
parsed.host
|
|
1039
|
+
);
|
|
1040
|
+
summaryPosted = true;
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1043
|
+
errors.push(`summary comment: ${message}`);
|
|
1044
|
+
logger.warn(`Failed to post summary comment: ${message}`);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
let commitStatusPosted = false;
|
|
1048
|
+
if (commitStatus) {
|
|
1049
|
+
try {
|
|
1050
|
+
await postGitlabReviewCommitStatus(parsed, review, diffRefs);
|
|
1051
|
+
commitStatusPosted = true;
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1054
|
+
errors.push(`commit status: ${message}`);
|
|
1055
|
+
logger.warn(`Failed to post commit status: ${message}`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
let reconciledDiscussions = 0;
|
|
1059
|
+
const baseDeliveryComplete = reviewStyle === "hybrid" ? summaryPosted && inlineFailed === 0 && (inlineCreated === 0 || draftsPublished) : inlineFailed === 0 && (review.findings.length === 0 ? summaryPosted : inlineCreated === 0 || draftsPublished);
|
|
1060
|
+
if (reconcileDiscussions && baseDeliveryComplete) {
|
|
1061
|
+
const currentFingerprints = new Set(
|
|
1062
|
+
review.findings.map((finding) => getFindingFingerprint(finding, workspacePath))
|
|
1063
|
+
);
|
|
1064
|
+
const staleDiscussionIds = [...existingByFingerprint.entries()].filter(([fingerprint]) => !currentFingerprints.has(fingerprint)).flatMap(([, ids]) => [...ids]);
|
|
1065
|
+
if (staleDiscussionIds.length > 0) {
|
|
1066
|
+
reconciledDiscussions = await resolveGitlabDiscussions(
|
|
1067
|
+
parsed.owner,
|
|
1068
|
+
parsed.repo,
|
|
1069
|
+
parsed.prNumber,
|
|
1070
|
+
staleDiscussionIds,
|
|
1071
|
+
parsed.host
|
|
1072
|
+
);
|
|
1073
|
+
if (reconciledDiscussions !== staleDiscussionIds.length) {
|
|
1074
|
+
errors.push(
|
|
1075
|
+
`discussion reconciliation: resolved ${reconciledDiscussions}/${staleDiscussionIds.length}`
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
const success = baseDeliveryComplete && (!commitStatus || commitStatusPosted) && (!reconcileDiscussions || !errors.some((error) => error.startsWith("discussion ")));
|
|
1081
|
+
return {
|
|
1082
|
+
success,
|
|
1083
|
+
platform: "gitlab",
|
|
1084
|
+
mrNumber: parsed.prNumber,
|
|
1085
|
+
error: success ? void 0 : errors[0] ?? "Review delivery was incomplete",
|
|
1086
|
+
errors,
|
|
1087
|
+
summaryPosted,
|
|
1088
|
+
inlineCreated,
|
|
1089
|
+
inlineFailed,
|
|
1090
|
+
draftsPublished,
|
|
1091
|
+
commitStatusPosted,
|
|
1092
|
+
reconciledDiscussions
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// src/agent.ts
|
|
1097
|
+
import { existsSync } from "fs";
|
|
1098
|
+
import { join as join2 } from "path";
|
|
1099
|
+
|
|
1100
|
+
// src/github.ts
|
|
1101
|
+
var GitHubAPIError = class extends Error {
|
|
1102
|
+
constructor(message) {
|
|
1103
|
+
super(message);
|
|
1104
|
+
this.name = "GitHubAPIError";
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
async function fetchGithubPrInfo(owner, repo, prNumber) {
|
|
1108
|
+
const fields = [
|
|
1109
|
+
"number",
|
|
1110
|
+
"title",
|
|
1111
|
+
"body",
|
|
1112
|
+
"author",
|
|
1113
|
+
"baseRefName",
|
|
1114
|
+
"headRefName",
|
|
1115
|
+
"baseRefOid",
|
|
1116
|
+
"headRefOid",
|
|
1117
|
+
"changedFiles",
|
|
1118
|
+
"labels",
|
|
1119
|
+
"comments",
|
|
1120
|
+
"state",
|
|
1121
|
+
"isDraft",
|
|
1122
|
+
"createdAt",
|
|
1123
|
+
"updatedAt",
|
|
1124
|
+
"mergeable",
|
|
1125
|
+
"url"
|
|
1126
|
+
];
|
|
1127
|
+
const repoFullPath = `${owner}/${repo}`;
|
|
1128
|
+
try {
|
|
1129
|
+
return await execJson("gh", [
|
|
1130
|
+
"pr",
|
|
1131
|
+
"view",
|
|
1132
|
+
String(prNumber),
|
|
1133
|
+
"-R",
|
|
1134
|
+
repoFullPath,
|
|
1135
|
+
"--json",
|
|
1136
|
+
fields.join(",")
|
|
1137
|
+
]);
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1140
|
+
throw new GitHubAPIError(msg);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
function normalizeGithubMetadata(raw) {
|
|
1144
|
+
const author = raw.author ?? {};
|
|
1145
|
+
const labels = raw.labels ?? [];
|
|
1146
|
+
const comments = raw.comments;
|
|
1147
|
+
return {
|
|
1148
|
+
title: raw.title,
|
|
1149
|
+
description: raw.body ?? "",
|
|
1150
|
+
source_branch: raw.headRefName,
|
|
1151
|
+
target_branch: raw.baseRefName,
|
|
1152
|
+
changes_count: raw.changedFiles,
|
|
1153
|
+
labels: labels.map((lbl) => ({ name: lbl.name ?? lbl.id })),
|
|
1154
|
+
author: {
|
|
1155
|
+
username: author.login ?? author.name,
|
|
1156
|
+
name: author.name
|
|
1157
|
+
},
|
|
1158
|
+
Notes: githubCommentsToNotes(comments)
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
function githubCommentsToNotes(comments) {
|
|
1162
|
+
if (!comments) return [];
|
|
1163
|
+
let nodes;
|
|
1164
|
+
if (Array.isArray(comments)) {
|
|
1165
|
+
nodes = comments;
|
|
1166
|
+
} else if (typeof comments === "object") {
|
|
1167
|
+
nodes = comments.nodes ?? comments.edges ?? [];
|
|
1168
|
+
if (nodes.length > 0 && typeof nodes[0] === "object" && "node" in nodes[0]) {
|
|
1169
|
+
nodes = nodes.map(
|
|
1170
|
+
(edge) => edge.node ?? {}
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
} else {
|
|
1174
|
+
nodes = [];
|
|
1175
|
+
}
|
|
1176
|
+
return nodes.map((node) => {
|
|
1177
|
+
const author = node.author ?? {};
|
|
1178
|
+
return {
|
|
1179
|
+
body: node.body ?? "",
|
|
1180
|
+
author: {
|
|
1181
|
+
username: author.login ?? author.name,
|
|
1182
|
+
name: author.name
|
|
1183
|
+
},
|
|
1184
|
+
created_at: node.createdAt
|
|
1185
|
+
};
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// src/workspace.ts
|
|
1190
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
1191
|
+
import { tmpdir } from "os";
|
|
1192
|
+
import { join } from "path";
|
|
1193
|
+
var WorkspaceError = class extends Error {
|
|
1194
|
+
constructor(message) {
|
|
1195
|
+
super(message);
|
|
1196
|
+
this.name = "WorkspaceError";
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
function envOrNull(name) {
|
|
1200
|
+
const value = process.env[name]?.trim();
|
|
1201
|
+
return value ? value : null;
|
|
1202
|
+
}
|
|
1203
|
+
async function detectCiWorkspace(owner, repo) {
|
|
1204
|
+
const expected = `${owner}/${repo}`;
|
|
1205
|
+
if (process.env.GITLAB_CI === "true") {
|
|
1206
|
+
const projectDir = envOrNull("CI_PROJECT_DIR");
|
|
1207
|
+
const projectPath = envOrNull("CI_PROJECT_PATH");
|
|
1208
|
+
const targetBranch = envOrNull("CI_MERGE_REQUEST_TARGET_BRANCH_NAME");
|
|
1209
|
+
const diffBaseSha = envOrNull("CI_MERGE_REQUEST_DIFF_BASE_SHA");
|
|
1210
|
+
if (projectDir && projectPath && (projectPath === expected || projectPath.endsWith(`/${expected}`))) {
|
|
1211
|
+
if (await isSameRepo(projectDir, owner, repo)) {
|
|
1212
|
+
logger.info(`Detected GitLab CI environment (target: ${targetBranch ?? "unknown"})`);
|
|
1213
|
+
return { path: projectDir, targetBranch, diffBaseSha };
|
|
1214
|
+
}
|
|
1215
|
+
logger.warn(
|
|
1216
|
+
`Detected GitLab CI for ${projectPath}, but ${projectDir} is not a git checkout of ${expected}; falling back to clone`
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (process.env.GITEA_ACTIONS === "true" || process.env.FORGEJO_ACTIONS === "true") {
|
|
1221
|
+
const workspaceDir = envOrNull("GITHUB_WORKSPACE");
|
|
1222
|
+
const repository = envOrNull("GITHUB_REPOSITORY");
|
|
1223
|
+
const baseRef = envOrNull("GITHUB_BASE_REF");
|
|
1224
|
+
if (workspaceDir && repository === expected) {
|
|
1225
|
+
const ciType = process.env.FORGEJO_ACTIONS ? "Forgejo" : "Gitea";
|
|
1226
|
+
if (await isSameRepo(workspaceDir, owner, repo)) {
|
|
1227
|
+
logger.info(`Detected ${ciType} Actions environment (base: ${baseRef ?? "unknown"})`);
|
|
1228
|
+
return { path: workspaceDir, targetBranch: baseRef, diffBaseSha: null };
|
|
1229
|
+
}
|
|
1230
|
+
logger.warn(
|
|
1231
|
+
`Detected ${ciType} Actions for ${repository}, but ${workspaceDir} is not a git checkout of ${expected}; falling back to clone`
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
if (process.env.GITHUB_ACTIONS === "true") {
|
|
1236
|
+
const workspaceDir = envOrNull("GITHUB_WORKSPACE");
|
|
1237
|
+
const repository = envOrNull("GITHUB_REPOSITORY");
|
|
1238
|
+
const baseRef = envOrNull("GITHUB_BASE_REF");
|
|
1239
|
+
if (workspaceDir && repository === expected) {
|
|
1240
|
+
if (await isSameRepo(workspaceDir, owner, repo)) {
|
|
1241
|
+
logger.info(`Detected GitHub Actions environment (base: ${baseRef ?? "unknown"})`);
|
|
1242
|
+
return { path: workspaceDir, targetBranch: baseRef, diffBaseSha: null };
|
|
1243
|
+
}
|
|
1244
|
+
logger.warn(
|
|
1245
|
+
`Detected GitHub Actions for ${repository}, but ${workspaceDir} is not a git checkout of ${expected}; falling back to clone`
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return { path: null, targetBranch: null, diffBaseSha: null };
|
|
1250
|
+
}
|
|
1251
|
+
function normalizeGitRemotePath(remoteUrl) {
|
|
1252
|
+
const trimmed = remoteUrl.trim().replace(/\.git$/, "");
|
|
1253
|
+
try {
|
|
1254
|
+
const url = new URL(trimmed);
|
|
870
1255
|
return url.pathname.replace(/^\/+/, "").replace(/\.git$/, "");
|
|
871
1256
|
} catch {
|
|
872
1257
|
}
|
|
@@ -1271,247 +1656,88 @@ function resolveReviewLocations(review, opts) {
|
|
|
1271
1656
|
const buf = readFileSync2(path);
|
|
1272
1657
|
if (buf.byteLength <= MAX_RESOLVE_BYTES) content = buf.toString("utf-8");
|
|
1273
1658
|
} catch {
|
|
1274
|
-
content = null;
|
|
1275
|
-
}
|
|
1276
|
-
fileCache.set(path, content);
|
|
1277
|
-
return content;
|
|
1278
|
-
};
|
|
1279
|
-
const findings = review.findings.map((finding) => {
|
|
1280
|
-
stats.total++;
|
|
1281
|
-
const { existing_code: existingCode, code_location: loc } = finding;
|
|
1282
|
-
if (!existingCode) {
|
|
1283
|
-
stats.noSnippet++;
|
|
1284
|
-
return finding;
|
|
1285
|
-
}
|
|
1286
|
-
if (workspaceRoot && !isWithinWorkspace(workspaceRoot, loc.absolute_file_path)) {
|
|
1287
|
-
stats.unmatched++;
|
|
1288
|
-
logger.warn(
|
|
1289
|
-
`Location resolution: ${loc.absolute_file_path} is outside the workspace for "${finding.title}"; keeping model range`
|
|
1290
|
-
);
|
|
1291
|
-
return finding;
|
|
1292
|
-
}
|
|
1293
|
-
const fileContent = readFile(loc.absolute_file_path);
|
|
1294
|
-
if (fileContent === null) {
|
|
1295
|
-
stats.unmatched++;
|
|
1296
|
-
logger.warn(`Location resolution: could not read ${loc.absolute_file_path} for "${finding.title}"`);
|
|
1297
|
-
return finding;
|
|
1298
|
-
}
|
|
1299
|
-
let changedLines;
|
|
1300
|
-
for (const [relPath, lines] of changedByFile) {
|
|
1301
|
-
if (loc.absolute_file_path.endsWith(`/${relPath}`) || loc.absolute_file_path === relPath) {
|
|
1302
|
-
changedLines = lines;
|
|
1303
|
-
break;
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
const result = resolveLineRange({
|
|
1307
|
-
existingCode,
|
|
1308
|
-
fileContent,
|
|
1309
|
-
modelRange: loc.line_range,
|
|
1310
|
-
changedLines
|
|
1311
|
-
});
|
|
1312
|
-
if (result.status === "confirmed") stats.confirmed++;
|
|
1313
|
-
else if (result.status === "corrected") stats.corrected++;
|
|
1314
|
-
else stats.unmatched++;
|
|
1315
|
-
if (result.status === "corrected") {
|
|
1316
|
-
logger.info(
|
|
1317
|
-
`Location resolution: corrected "${finding.title}" ${loc.line_range.start}-${loc.line_range.end} -> ${result.start}-${result.end}`
|
|
1318
|
-
);
|
|
1319
|
-
return {
|
|
1320
|
-
...finding,
|
|
1321
|
-
code_location: { ...loc, line_range: { start: result.start, end: result.end } }
|
|
1322
|
-
};
|
|
1323
|
-
}
|
|
1324
|
-
if (result.status === "unmatched") {
|
|
1325
|
-
logger.warn(
|
|
1326
|
-
`Location resolution: snippet not found for "${finding.title}" in ${loc.absolute_file_path}; keeping model range ${loc.line_range.start}-${loc.line_range.end}`
|
|
1327
|
-
);
|
|
1328
|
-
}
|
|
1329
|
-
return finding;
|
|
1330
|
-
});
|
|
1331
|
-
return { review: { ...review, findings }, stats };
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
// src/system-prompt.ts
|
|
1335
|
-
var REVIEW_SYSTEM_PROMPT = `You are a code review agent. You analyze pull request diffs to find production bugs.
|
|
1336
|
-
|
|
1337
|
-
<ROLE>
|
|
1338
|
-
* You are in READ-ONLY mode. Do NOT modify any files, create files, commit, or install dependencies.
|
|
1339
|
-
* Your only job is to analyze the diff, identify bugs, and produce a review.
|
|
1340
|
-
* Submit the final review via the \`submit_review\` tool. Do NOT output the final review as normal assistant text.
|
|
1341
|
-
* Be proportional: scale your analysis depth to the diff size. A small, single-file diff needs only a few iterations; a large multi-file refactor warrants deeper investigation.
|
|
1342
|
-
* Do NOT write to PLAN.md or AGENTS.md.
|
|
1343
|
-
* Do NOT run package managers (npm install, go mod download, pip install, etc.).
|
|
1344
|
-
* Follow the instructions in the user prompt exactly as given.
|
|
1345
|
-
</ROLE>
|
|
1346
|
-
|
|
1347
|
-
<EFFICIENCY>
|
|
1348
|
-
* Combine multiple bash commands where possible (e.g. \`cmd1 && cmd2\`).
|
|
1349
|
-
* Use the grep and find tools for code search \u2014 do not shell out to grep/find.
|
|
1350
|
-
* Prefer \`git diff\` to see changes for specific files. Only use read when you need surrounding context that the diff alone cannot provide.
|
|
1351
|
-
* Do not use cat/head/tail to read files.
|
|
1352
|
-
* Keep reasoning proportional to the task. A small diff does not need extensive deliberation.
|
|
1353
|
-
</EFFICIENCY>`;
|
|
1354
|
-
|
|
1355
|
-
// src/agent.ts
|
|
1356
|
-
function detectPlatform(prUrl) {
|
|
1357
|
-
const url = new URL(prUrl);
|
|
1358
|
-
const hostname = url.hostname;
|
|
1359
|
-
if (prUrl.includes("/-/merge_requests/") || hostname.includes("gitlab")) {
|
|
1360
|
-
return "gitlab";
|
|
1361
|
-
}
|
|
1362
|
-
if (prUrl.includes("/pulls/") || hostname.includes("gitea") || hostname.includes("forgejo") || hostname.includes("codeberg")) {
|
|
1363
|
-
return "gitea";
|
|
1364
|
-
}
|
|
1365
|
-
if (prUrl.includes("/pull/") || hostname.includes("github")) {
|
|
1366
|
-
return "github";
|
|
1367
|
-
}
|
|
1368
|
-
throw new Error(
|
|
1369
|
-
`Cannot detect platform for URL: ${prUrl}. Expected a GitHub (/pull/), GitLab (/-/merge_requests/), or Gitea/Forgejo (/pulls/) URL.`
|
|
1370
|
-
);
|
|
1371
|
-
}
|
|
1372
|
-
function parsePrUrl(prUrl) {
|
|
1373
|
-
const url = new URL(prUrl);
|
|
1374
|
-
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
1375
|
-
const host = url.host;
|
|
1376
|
-
if (pathParts.length >= 4 && pathParts[2] === "pull") {
|
|
1377
|
-
const prNumber = parseInt(pathParts[3], 10);
|
|
1378
|
-
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
|
|
1379
|
-
throw new Error(`Invalid PR number in URL: ${prUrl}. Expected a positive integer after /pull/.`);
|
|
1380
|
-
}
|
|
1381
|
-
return {
|
|
1382
|
-
owner: pathParts[0],
|
|
1383
|
-
repo: pathParts[1],
|
|
1384
|
-
prNumber,
|
|
1385
|
-
host
|
|
1386
|
-
};
|
|
1387
|
-
}
|
|
1388
|
-
if (pathParts.length >= 4 && pathParts[2] === "pulls") {
|
|
1389
|
-
const prNumber = parseInt(pathParts[3], 10);
|
|
1390
|
-
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
|
|
1391
|
-
throw new Error(`Invalid PR number in URL: ${prUrl}. Expected a positive integer after /pulls/.`);
|
|
1392
|
-
}
|
|
1393
|
-
return {
|
|
1394
|
-
owner: pathParts[0],
|
|
1395
|
-
repo: pathParts[1],
|
|
1396
|
-
prNumber,
|
|
1397
|
-
host
|
|
1398
|
-
};
|
|
1399
|
-
}
|
|
1400
|
-
const mrIndex = pathParts.indexOf("merge_requests");
|
|
1401
|
-
if (mrIndex >= 0) {
|
|
1402
|
-
if (mrIndex < 2 || mrIndex + 1 >= pathParts.length) {
|
|
1403
|
-
throw new Error(
|
|
1404
|
-
`Invalid GitLab MR URL format: ${prUrl}. Expected .../-/merge_requests/<number>`
|
|
1405
|
-
);
|
|
1406
|
-
}
|
|
1407
|
-
if (pathParts[mrIndex - 1] !== "-") {
|
|
1408
|
-
throw new Error(
|
|
1409
|
-
`Invalid GitLab MR URL format: ${prUrl}. Missing '/-/' segment before merge_requests.`
|
|
1410
|
-
);
|
|
1411
|
-
}
|
|
1412
|
-
const repo = pathParts[mrIndex - 2];
|
|
1413
|
-
const ownerParts = pathParts.slice(0, mrIndex - 2);
|
|
1414
|
-
const owner = ownerParts.length > 0 ? ownerParts.join("/") : pathParts[0];
|
|
1415
|
-
const prNumber = parseInt(pathParts[mrIndex + 1], 10);
|
|
1416
|
-
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
|
|
1417
|
-
throw new Error(`Invalid MR number in URL: ${prUrl}. Expected a positive integer after /merge_requests/.`);
|
|
1418
|
-
}
|
|
1419
|
-
return { owner, repo, prNumber, host };
|
|
1420
|
-
}
|
|
1421
|
-
throw new Error(
|
|
1422
|
-
`Invalid PR/MR URL format: ${prUrl}. Expected GitHub (/pull/), GitLab (/-/merge_requests/), or Gitea/Forgejo (/pulls/) URL.`
|
|
1423
|
-
);
|
|
1424
|
-
}
|
|
1425
|
-
function formatLocationRelative(loc, workspacePath) {
|
|
1426
|
-
return relativizeWorkspacePath(loc.absolute_file_path, workspacePath ?? void 0);
|
|
1427
|
-
}
|
|
1428
|
-
async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
|
|
1429
|
-
const blocking = review.findings.filter((f) => f.priority <= 1).length;
|
|
1430
|
-
const state = blocking > 0 ? "failed" : "success";
|
|
1431
|
-
const description = blocking > 0 ? `${blocking} blocking issue(s) found` : review.findings.length > 0 ? `${review.findings.length} non-blocking issue(s)` : "No issues found";
|
|
1432
|
-
await postGitlabCommitStatus(
|
|
1433
|
-
parsed.owner,
|
|
1434
|
-
parsed.repo,
|
|
1435
|
-
diffRefs.head_sha,
|
|
1436
|
-
state,
|
|
1437
|
-
parsed.host,
|
|
1438
|
-
{ description }
|
|
1439
|
-
);
|
|
1440
|
-
}
|
|
1441
|
-
async function postReviewComment(opts) {
|
|
1442
|
-
const { prUrl, reviewText, model, metricsFooter, headSha } = opts;
|
|
1443
|
-
const platform = detectPlatform(prUrl);
|
|
1444
|
-
logger.info(`Posting comment to ${platform} PR/MR: ${prUrl}`);
|
|
1445
|
-
let parsed;
|
|
1446
|
-
try {
|
|
1447
|
-
parsed = parsePrUrl(prUrl);
|
|
1448
|
-
} catch (err) {
|
|
1449
|
-
return { success: false, error: String(err) };
|
|
1450
|
-
}
|
|
1451
|
-
let body = reviewText;
|
|
1452
|
-
if (headSha) {
|
|
1453
|
-
body = `<!-- hodor:sha:${headSha} -->
|
|
1454
|
-
${body}`;
|
|
1455
|
-
}
|
|
1456
|
-
if (model) {
|
|
1457
|
-
body = `${body}
|
|
1458
|
-
|
|
1459
|
-
---
|
|
1460
|
-
|
|
1461
|
-
Review generated by Hodor (model: \`${model}\`)`;
|
|
1462
|
-
}
|
|
1463
|
-
if (metricsFooter) {
|
|
1464
|
-
body = `${body}
|
|
1465
|
-
|
|
1466
|
-
${metricsFooter}`;
|
|
1467
|
-
}
|
|
1468
|
-
try {
|
|
1469
|
-
if (platform === "github") {
|
|
1470
|
-
await exec("gh", [
|
|
1471
|
-
"pr",
|
|
1472
|
-
"review",
|
|
1473
|
-
String(parsed.prNumber),
|
|
1474
|
-
"--repo",
|
|
1475
|
-
`${parsed.owner}/${parsed.repo}`,
|
|
1476
|
-
"--comment",
|
|
1477
|
-
"--body",
|
|
1478
|
-
body
|
|
1479
|
-
]);
|
|
1480
|
-
logger.info(`Successfully posted review to GitHub PR #${parsed.prNumber}`);
|
|
1481
|
-
return { success: true, platform: "github", prNumber: parsed.prNumber };
|
|
1482
|
-
} else if (platform === "gitea") {
|
|
1483
|
-
await postGiteaPrComment(
|
|
1484
|
-
parsed.owner,
|
|
1485
|
-
parsed.repo,
|
|
1486
|
-
parsed.prNumber,
|
|
1487
|
-
body,
|
|
1488
|
-
parsed.host
|
|
1489
|
-
);
|
|
1490
|
-
logger.info(`Successfully posted review to Gitea PR #${parsed.prNumber}`);
|
|
1491
|
-
return { success: true, platform: "gitea", prNumber: parsed.prNumber };
|
|
1492
|
-
} else {
|
|
1493
|
-
await postGitlabMrComment(
|
|
1494
|
-
parsed.owner,
|
|
1495
|
-
parsed.repo,
|
|
1496
|
-
parsed.prNumber,
|
|
1497
|
-
body,
|
|
1498
|
-
parsed.host
|
|
1659
|
+
content = null;
|
|
1660
|
+
}
|
|
1661
|
+
fileCache.set(path, content);
|
|
1662
|
+
return content;
|
|
1663
|
+
};
|
|
1664
|
+
const findings = review.findings.map((finding) => {
|
|
1665
|
+
stats.total++;
|
|
1666
|
+
const { existing_code: existingCode, code_location: loc } = finding;
|
|
1667
|
+
if (!existingCode) {
|
|
1668
|
+
stats.noSnippet++;
|
|
1669
|
+
return finding;
|
|
1670
|
+
}
|
|
1671
|
+
if (workspaceRoot && !isWithinWorkspace(workspaceRoot, loc.absolute_file_path)) {
|
|
1672
|
+
stats.unmatched++;
|
|
1673
|
+
logger.warn(
|
|
1674
|
+
`Location resolution: ${loc.absolute_file_path} is outside the workspace for "${finding.title}"; keeping model range`
|
|
1499
1675
|
);
|
|
1676
|
+
return finding;
|
|
1677
|
+
}
|
|
1678
|
+
const fileContent = readFile(loc.absolute_file_path);
|
|
1679
|
+
if (fileContent === null) {
|
|
1680
|
+
stats.unmatched++;
|
|
1681
|
+
logger.warn(`Location resolution: could not read ${loc.absolute_file_path} for "${finding.title}"`);
|
|
1682
|
+
return finding;
|
|
1683
|
+
}
|
|
1684
|
+
let changedLines;
|
|
1685
|
+
for (const [relPath, lines] of changedByFile) {
|
|
1686
|
+
if (loc.absolute_file_path.endsWith(`/${relPath}`) || loc.absolute_file_path === relPath) {
|
|
1687
|
+
changedLines = lines;
|
|
1688
|
+
break;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
const result = resolveLineRange({
|
|
1692
|
+
existingCode,
|
|
1693
|
+
fileContent,
|
|
1694
|
+
modelRange: loc.line_range,
|
|
1695
|
+
changedLines
|
|
1696
|
+
});
|
|
1697
|
+
if (result.status === "confirmed") stats.confirmed++;
|
|
1698
|
+
else if (result.status === "corrected") stats.corrected++;
|
|
1699
|
+
else stats.unmatched++;
|
|
1700
|
+
if (result.status === "corrected") {
|
|
1500
1701
|
logger.info(
|
|
1501
|
-
`
|
|
1702
|
+
`Location resolution: corrected "${finding.title}" ${loc.line_range.start}-${loc.line_range.end} -> ${result.start}-${result.end}`
|
|
1502
1703
|
);
|
|
1503
1704
|
return {
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
mrNumber: parsed.prNumber
|
|
1705
|
+
...finding,
|
|
1706
|
+
code_location: { ...loc, line_range: { start: result.start, end: result.end } }
|
|
1507
1707
|
};
|
|
1508
1708
|
}
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1709
|
+
if (result.status === "unmatched") {
|
|
1710
|
+
logger.warn(
|
|
1711
|
+
`Location resolution: snippet not found for "${finding.title}" in ${loc.absolute_file_path}; keeping model range ${loc.line_range.start}-${loc.line_range.end}`
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
return finding;
|
|
1715
|
+
});
|
|
1716
|
+
return { review: { ...review, findings }, stats };
|
|
1514
1717
|
}
|
|
1718
|
+
|
|
1719
|
+
// src/system-prompt.ts
|
|
1720
|
+
var REVIEW_SYSTEM_PROMPT = `You are a code review agent. You analyze pull request diffs to find production bugs.
|
|
1721
|
+
|
|
1722
|
+
<ROLE>
|
|
1723
|
+
* You are in READ-ONLY mode. Do NOT modify any files, create files, commit, or install dependencies.
|
|
1724
|
+
* Your only job is to analyze the diff, identify bugs, and produce a review.
|
|
1725
|
+
* Submit the final review via the \`submit_review\` tool. Do NOT output the final review as normal assistant text.
|
|
1726
|
+
* Be proportional: scale your analysis depth to the diff size. A small, single-file diff needs only a few iterations; a large multi-file refactor warrants deeper investigation.
|
|
1727
|
+
* Do NOT write to PLAN.md or AGENTS.md.
|
|
1728
|
+
* Do NOT run package managers (npm install, go mod download, pip install, etc.).
|
|
1729
|
+
* Follow the instructions in the user prompt exactly as given.
|
|
1730
|
+
</ROLE>
|
|
1731
|
+
|
|
1732
|
+
<EFFICIENCY>
|
|
1733
|
+
* Combine multiple bash commands where possible (e.g. \`cmd1 && cmd2\`).
|
|
1734
|
+
* Use the grep and find tools for code search \u2014 do not shell out to grep/find.
|
|
1735
|
+
* Prefer \`git diff\` to see changes for specific files. Only use read when you need surrounding context that the diff alone cannot provide.
|
|
1736
|
+
* Do not use cat/head/tail to read files.
|
|
1737
|
+
* Keep reasoning proportional to the task. A small diff does not need extensive deliberation.
|
|
1738
|
+
</EFFICIENCY>`;
|
|
1739
|
+
|
|
1740
|
+
// src/review-diff.ts
|
|
1515
1741
|
var HODOR_REVIEW_SHA_RE = /^\s*<!--\s*hodor:sha:([a-f0-9]{40})\s*-->/i;
|
|
1516
1742
|
function getHodorReviewShaCandidates(notes) {
|
|
1517
1743
|
if (!notes || notes.length === 0) return [];
|
|
@@ -1534,14 +1760,7 @@ function getHodorReviewShaCandidates(notes) {
|
|
|
1534
1760
|
if (a.createdAtMs == null && b.createdAtMs != null) return 1;
|
|
1535
1761
|
return a.index - b.index;
|
|
1536
1762
|
});
|
|
1537
|
-
|
|
1538
|
-
const shas = [];
|
|
1539
|
-
for (const { sha } of candidates) {
|
|
1540
|
-
if (seen.has(sha)) continue;
|
|
1541
|
-
seen.add(sha);
|
|
1542
|
-
shas.push(sha);
|
|
1543
|
-
}
|
|
1544
|
-
return shas;
|
|
1763
|
+
return [...new Set(candidates.map(({ sha }) => sha))];
|
|
1545
1764
|
}
|
|
1546
1765
|
async function findLatestValidReviewSha(notes, workspacePath) {
|
|
1547
1766
|
const candidates = getHodorReviewShaCandidates(notes);
|
|
@@ -1549,222 +1768,26 @@ async function findLatestValidReviewSha(notes, workspacePath) {
|
|
|
1549
1768
|
logger.info(`Found ${candidates.length} previous Hodor review marker(s)`);
|
|
1550
1769
|
for (const sha of candidates) {
|
|
1551
1770
|
try {
|
|
1552
|
-
const { stdout:
|
|
1553
|
-
|
|
1554
|
-
|
|
1771
|
+
const { stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
|
|
1772
|
+
cwd: workspacePath
|
|
1773
|
+
});
|
|
1774
|
+
if (objectType.trim() !== "commit") throw new Error("not a commit");
|
|
1775
|
+
await exec("git", ["merge-base", "--is-ancestor", sha, "HEAD"], {
|
|
1776
|
+
cwd: workspacePath
|
|
1777
|
+
});
|
|
1555
1778
|
return sha;
|
|
1556
1779
|
} catch {
|
|
1557
|
-
logger.info(
|
|
1558
|
-
|
|
1559
|
-
}
|
|
1560
|
-
return null;
|
|
1561
|
-
}
|
|
1562
|
-
async function postReviewStructured(opts) {
|
|
1563
|
-
const {
|
|
1564
|
-
prUrl,
|
|
1565
|
-
review,
|
|
1566
|
-
model,
|
|
1567
|
-
metricsFooter,
|
|
1568
|
-
reviewStyle,
|
|
1569
|
-
commitStatus,
|
|
1570
|
-
codeQualityPath,
|
|
1571
|
-
headSha,
|
|
1572
|
-
workspacePath
|
|
1573
|
-
} = opts;
|
|
1574
|
-
const platform = detectPlatform(prUrl);
|
|
1575
|
-
if (platform === "github") {
|
|
1576
|
-
return postReviewComment({
|
|
1577
|
-
prUrl,
|
|
1578
|
-
reviewText: renderMarkdown(review),
|
|
1579
|
-
model,
|
|
1580
|
-
metricsFooter,
|
|
1581
|
-
headSha
|
|
1582
|
-
});
|
|
1583
|
-
}
|
|
1584
|
-
if (reviewStyle === "summary") {
|
|
1585
|
-
return postReviewComment({
|
|
1586
|
-
prUrl,
|
|
1587
|
-
reviewText: renderMarkdown(review),
|
|
1588
|
-
model,
|
|
1589
|
-
metricsFooter,
|
|
1590
|
-
headSha
|
|
1591
|
-
});
|
|
1592
|
-
}
|
|
1593
|
-
const parsed = parsePrUrl(prUrl);
|
|
1594
|
-
try {
|
|
1595
|
-
const discussions = await listHodorDiscussions(
|
|
1596
|
-
parsed.owner,
|
|
1597
|
-
parsed.repo,
|
|
1598
|
-
parsed.prNumber,
|
|
1599
|
-
parsed.host
|
|
1600
|
-
);
|
|
1601
|
-
const unresolvedIds = [...new Set(
|
|
1602
|
-
discussions.filter((d) => !d.resolved).map((d) => d.discussionId)
|
|
1603
|
-
)];
|
|
1604
|
-
if (unresolvedIds.length > 0) {
|
|
1605
|
-
const resolved = await resolveGitlabDiscussions(
|
|
1606
|
-
parsed.owner,
|
|
1607
|
-
parsed.repo,
|
|
1608
|
-
parsed.prNumber,
|
|
1609
|
-
unresolvedIds,
|
|
1610
|
-
parsed.host
|
|
1611
|
-
);
|
|
1612
|
-
if (resolved > 0) logger.info(`Resolved ${resolved} old Hodor discussion(s)`);
|
|
1613
|
-
}
|
|
1614
|
-
} catch (err) {
|
|
1615
|
-
logger.warn(`Failed to resolve old discussions: ${err instanceof Error ? err.message : err}`);
|
|
1616
|
-
}
|
|
1617
|
-
let diffRefs = null;
|
|
1618
|
-
try {
|
|
1619
|
-
diffRefs = await getGitlabMrDiffRefs(
|
|
1620
|
-
parsed.owner,
|
|
1621
|
-
parsed.repo,
|
|
1622
|
-
parsed.prNumber,
|
|
1623
|
-
parsed.host
|
|
1624
|
-
);
|
|
1625
|
-
} catch (err) {
|
|
1626
|
-
logger.warn(`Failed to get diff_refs, falling back to summary mode: ${err instanceof Error ? err.message : err}`);
|
|
1627
|
-
}
|
|
1628
|
-
if (!diffRefs) {
|
|
1629
|
-
return postReviewComment({
|
|
1630
|
-
prUrl,
|
|
1631
|
-
reviewText: renderMarkdown(review),
|
|
1632
|
-
model,
|
|
1633
|
-
metricsFooter,
|
|
1634
|
-
headSha
|
|
1635
|
-
});
|
|
1636
|
-
}
|
|
1637
|
-
let inlineCount = 0;
|
|
1638
|
-
let failedCount = 0;
|
|
1639
|
-
let summaryPosted = false;
|
|
1640
|
-
let draftsPublished = false;
|
|
1641
|
-
let statusPosted = false;
|
|
1642
|
-
const postingErrors = [];
|
|
1643
|
-
for (const finding of review.findings) {
|
|
1644
|
-
const relPath = formatLocationRelative(finding.code_location, workspacePath);
|
|
1645
|
-
const priorityTag = `[P${finding.priority}]`;
|
|
1646
|
-
const title = /^\[P[0-3]\]/.test(finding.title) ? finding.title : `${priorityTag} ${finding.title}`;
|
|
1647
|
-
let body = `${HODOR_REVIEW_MARKER}
|
|
1648
|
-
**${title}**
|
|
1649
|
-
|
|
1650
|
-
${finding.body}`;
|
|
1651
|
-
if (finding.suggestion) {
|
|
1652
|
-
const { start, end } = finding.code_location.line_range;
|
|
1653
|
-
const span = Math.max(0, end - start);
|
|
1654
|
-
body += `
|
|
1655
|
-
|
|
1656
|
-
\`\`\`suggestion:-0+${span}
|
|
1657
|
-
${finding.suggestion}
|
|
1658
|
-
\`\`\``;
|
|
1659
|
-
}
|
|
1660
|
-
try {
|
|
1661
|
-
await createGitlabDraftNote(
|
|
1662
|
-
parsed.owner,
|
|
1663
|
-
parsed.repo,
|
|
1664
|
-
parsed.prNumber,
|
|
1665
|
-
body,
|
|
1666
|
-
parsed.host,
|
|
1667
|
-
{
|
|
1668
|
-
filePath: relPath,
|
|
1669
|
-
line: finding.code_location.line_range.start,
|
|
1670
|
-
diffRefs
|
|
1671
|
-
}
|
|
1672
|
-
);
|
|
1673
|
-
inlineCount++;
|
|
1674
|
-
} catch (err) {
|
|
1675
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1676
|
-
logger.warn(`Failed to create inline note for "${finding.title}": ${msg}`);
|
|
1677
|
-
postingErrors.push(`inline note: ${msg}`);
|
|
1678
|
-
failedCount++;
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
1681
|
-
logger.info(`Created ${inlineCount} inline draft note(s)${failedCount > 0 ? ` (${failedCount} failed)` : ""}`);
|
|
1682
|
-
if (reviewStyle === "hybrid" || reviewStyle === void 0) {
|
|
1683
|
-
let summaryBody = renderSummaryMarkdown(review);
|
|
1684
|
-
if (headSha) summaryBody = `<!-- hodor:sha:${headSha} -->
|
|
1685
|
-
${summaryBody}`;
|
|
1686
|
-
if (model) summaryBody += `
|
|
1687
|
-
---
|
|
1688
|
-
|
|
1689
|
-
Review generated by Hodor (model: \`${model}\`)`;
|
|
1690
|
-
if (metricsFooter) summaryBody += `
|
|
1691
|
-
|
|
1692
|
-
${metricsFooter}`;
|
|
1693
|
-
try {
|
|
1694
|
-
await postGitlabMrComment(
|
|
1695
|
-
parsed.owner,
|
|
1696
|
-
parsed.repo,
|
|
1697
|
-
parsed.prNumber,
|
|
1698
|
-
summaryBody,
|
|
1699
|
-
parsed.host
|
|
1700
|
-
);
|
|
1701
|
-
summaryPosted = true;
|
|
1702
|
-
} catch (err) {
|
|
1703
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1704
|
-
logger.warn(`Failed to post summary comment: ${msg}`);
|
|
1705
|
-
postingErrors.push(`summary comment: ${msg}`);
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1708
|
-
if (inlineCount > 0) {
|
|
1709
|
-
try {
|
|
1710
|
-
await bulkPublishGitlabDraftNotes(
|
|
1711
|
-
parsed.owner,
|
|
1712
|
-
parsed.repo,
|
|
1713
|
-
parsed.prNumber,
|
|
1714
|
-
parsed.host
|
|
1780
|
+
logger.info(
|
|
1781
|
+
`Skipping previous review SHA ${sha.slice(0, 8)}; not a valid ancestor of HEAD`
|
|
1715
1782
|
);
|
|
1716
|
-
logger.info("Published all draft notes");
|
|
1717
|
-
draftsPublished = true;
|
|
1718
|
-
} catch (err) {
|
|
1719
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1720
|
-
logger.warn(`Failed to bulk publish draft notes: ${msg}`);
|
|
1721
|
-
postingErrors.push(`draft publish: ${msg}`);
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
if (commitStatus && diffRefs) {
|
|
1725
|
-
try {
|
|
1726
|
-
await postGitlabReviewCommitStatus(parsed, review, diffRefs);
|
|
1727
|
-
logger.info("Posted commit status");
|
|
1728
|
-
statusPosted = true;
|
|
1729
|
-
} catch (err) {
|
|
1730
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1731
|
-
logger.warn(`Failed to post commit status: ${msg}`);
|
|
1732
|
-
postingErrors.push(`commit status: ${msg}`);
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
if (codeQualityPath) {
|
|
1736
|
-
try {
|
|
1737
|
-
const { formatCodeQualityReport } = await import("./codequality-DTJK2LGF.js");
|
|
1738
|
-
const report = formatCodeQualityReport(review, workspacePath ?? void 0);
|
|
1739
|
-
const { writeFileSync } = await import("fs");
|
|
1740
|
-
writeFileSync(codeQualityPath, report, "utf-8");
|
|
1741
|
-
logger.info(`Wrote code quality report to ${codeQualityPath}`);
|
|
1742
|
-
} catch (err) {
|
|
1743
|
-
logger.warn(`Failed to write code quality report: ${err instanceof Error ? err.message : err}`);
|
|
1744
1783
|
}
|
|
1745
1784
|
}
|
|
1746
|
-
|
|
1747
|
-
const expectedInlineComments = reviewStyle === "inline" && review.findings.length > 0;
|
|
1748
|
-
if (postingErrors.length > 0 && !visibleResult || expectedInlineComments && inlineCount === 0) {
|
|
1749
|
-
return {
|
|
1750
|
-
success: false,
|
|
1751
|
-
platform: "gitlab",
|
|
1752
|
-
mrNumber: parsed.prNumber,
|
|
1753
|
-
error: postingErrors[0] ?? "No GitLab inline comments were created"
|
|
1754
|
-
};
|
|
1755
|
-
}
|
|
1756
|
-
return {
|
|
1757
|
-
success: true,
|
|
1758
|
-
platform: "gitlab",
|
|
1759
|
-
mrNumber: parsed.prNumber
|
|
1760
|
-
};
|
|
1785
|
+
return null;
|
|
1761
1786
|
}
|
|
1762
1787
|
var DIFF_SKIP_PATTERNS = [
|
|
1763
1788
|
/(?:^|\/)testdata\//,
|
|
1764
|
-
// test fixture directories
|
|
1765
1789
|
/(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|go\.sum|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock)$/,
|
|
1766
1790
|
/\.mdx?$/
|
|
1767
|
-
// markdown docs
|
|
1768
1791
|
];
|
|
1769
1792
|
function filterEmbeddedDiff(rawDiff) {
|
|
1770
1793
|
const skippedFiles = [];
|
|
@@ -1777,7 +1800,7 @@ function filterEmbeddedDiff(rawDiff) {
|
|
|
1777
1800
|
continue;
|
|
1778
1801
|
}
|
|
1779
1802
|
const filePath = match[1];
|
|
1780
|
-
if (DIFF_SKIP_PATTERNS.some((
|
|
1803
|
+
if (DIFF_SKIP_PATTERNS.some((pattern) => pattern.test(filePath))) {
|
|
1781
1804
|
skippedFiles.push(filePath);
|
|
1782
1805
|
} else {
|
|
1783
1806
|
kept.push(section);
|
|
@@ -1785,6 +1808,9 @@ function filterEmbeddedDiff(rawDiff) {
|
|
|
1785
1808
|
}
|
|
1786
1809
|
return { filtered: kept.join(""), skippedFiles };
|
|
1787
1810
|
}
|
|
1811
|
+
|
|
1812
|
+
// src/review-recovery.ts
|
|
1813
|
+
import { Value } from "@sinclair/typebox/value";
|
|
1788
1814
|
var SUBMIT_REVIEW_RECOVERY_ATTEMPTS = 2;
|
|
1789
1815
|
function buildSubmitReviewRecoveryPrompt(attempt, maxAttempts) {
|
|
1790
1816
|
const finalAttempt = attempt >= maxAttempts ? "\nThis is the final automatic recovery attempt; do not end the turn without calling `submit_review`." : "";
|
|
@@ -1798,64 +1824,56 @@ function buildSubmitReviewRecoveryPrompt(attempt, maxAttempts) {
|
|
|
1798
1824
|
].filter(Boolean).join("\n");
|
|
1799
1825
|
}
|
|
1800
1826
|
function parseReviewFromAssistantText(text) {
|
|
1801
|
-
const
|
|
1802
|
-
for (const candidate of candidates) {
|
|
1827
|
+
for (const candidate of getJsonCandidates(text)) {
|
|
1803
1828
|
try {
|
|
1804
1829
|
const parsed = JSON.parse(candidate);
|
|
1805
|
-
if (!Value.Check(SUBMIT_REVIEW_SCHEMA, parsed))
|
|
1806
|
-
continue;
|
|
1807
|
-
}
|
|
1830
|
+
if (!Value.Check(SUBMIT_REVIEW_SCHEMA, parsed)) continue;
|
|
1808
1831
|
return validateReviewOutput(parsed);
|
|
1809
1832
|
} catch {
|
|
1810
1833
|
}
|
|
1811
1834
|
}
|
|
1812
1835
|
return null;
|
|
1813
1836
|
}
|
|
1837
|
+
function summarizeLastAssistantMessage(session) {
|
|
1838
|
+
const messages = session.messages;
|
|
1839
|
+
const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant");
|
|
1840
|
+
if (!lastAssistant) return "no assistant message";
|
|
1841
|
+
const stopReason = typeof lastAssistant.stopReason === "string" ? lastAssistant.stopReason : "unknown";
|
|
1842
|
+
const errorMessage = typeof lastAssistant.errorMessage === "string" ? `, error=${JSON.stringify(truncateForLog(lastAssistant.errorMessage, 300))}` : "";
|
|
1843
|
+
const content = Array.isArray(lastAssistant.content) ? lastAssistant.content.map((item) => {
|
|
1844
|
+
const block = item;
|
|
1845
|
+
const type = typeof block.type === "string" ? block.type : "unknown";
|
|
1846
|
+
return type === "toolCall" && typeof block.name === "string" ? `toolCall:${block.name}` : type;
|
|
1847
|
+
}).join(",") : "unknown";
|
|
1848
|
+
const rawText = session.getLastAssistantText()?.trim();
|
|
1849
|
+
const textSummary = rawText ? `, text=${JSON.stringify(truncateForLog(rawText.replace(/\s+/g, " "), 500))}` : "";
|
|
1850
|
+
return `stopReason=${stopReason}, content=[${content || "none"}]${errorMessage}${textSummary}`;
|
|
1851
|
+
}
|
|
1814
1852
|
function getJsonCandidates(text) {
|
|
1815
1853
|
const candidates = [];
|
|
1816
1854
|
const seen = /* @__PURE__ */ new Set();
|
|
1817
|
-
const
|
|
1855
|
+
const add = (value) => {
|
|
1818
1856
|
const trimmed = value.trim();
|
|
1819
1857
|
if (!trimmed || seen.has(trimmed)) return;
|
|
1820
1858
|
seen.add(trimmed);
|
|
1821
1859
|
candidates.push(trimmed);
|
|
1822
1860
|
};
|
|
1823
|
-
|
|
1824
|
-
const
|
|
1825
|
-
|
|
1826
|
-
addCandidate(match[1] ?? "");
|
|
1861
|
+
add(text);
|
|
1862
|
+
for (const match of text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
|
|
1863
|
+
add(match[1] ?? "");
|
|
1827
1864
|
}
|
|
1828
1865
|
const firstBrace = text.indexOf("{");
|
|
1829
1866
|
const lastBrace = text.lastIndexOf("}");
|
|
1830
1867
|
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
1831
|
-
|
|
1868
|
+
add(text.slice(firstBrace, lastBrace + 1));
|
|
1832
1869
|
}
|
|
1833
1870
|
return candidates;
|
|
1834
1871
|
}
|
|
1835
|
-
function summarizeLastAssistantMessage(session) {
|
|
1836
|
-
const messages = session.messages;
|
|
1837
|
-
const lastAssistant = [...messages].reverse().find((msg) => msg.role === "assistant");
|
|
1838
|
-
if (!lastAssistant) {
|
|
1839
|
-
return "no assistant message";
|
|
1840
|
-
}
|
|
1841
|
-
const stopReason = typeof lastAssistant.stopReason === "string" ? lastAssistant.stopReason : "unknown";
|
|
1842
|
-
const errorMessage = typeof lastAssistant.errorMessage === "string" ? `, error=${JSON.stringify(truncateForLog(lastAssistant.errorMessage, 300))}` : "";
|
|
1843
|
-
const content = Array.isArray(lastAssistant.content) ? lastAssistant.content.map((item) => {
|
|
1844
|
-
const block = item;
|
|
1845
|
-
const type = typeof block.type === "string" ? block.type : "unknown";
|
|
1846
|
-
if (type === "toolCall" && typeof block.name === "string") {
|
|
1847
|
-
return `toolCall:${block.name}`;
|
|
1848
|
-
}
|
|
1849
|
-
return type;
|
|
1850
|
-
}).join(",") : "unknown";
|
|
1851
|
-
const rawText = session.getLastAssistantText()?.trim();
|
|
1852
|
-
const textSummary = rawText ? `, text=${JSON.stringify(truncateForLog(rawText.replace(/\s+/g, " "), 500))}` : "";
|
|
1853
|
-
return `stopReason=${stopReason}, content=[${content || "none"}]${errorMessage}${textSummary}`;
|
|
1854
|
-
}
|
|
1855
1872
|
function truncateForLog(text, maxLength) {
|
|
1856
|
-
|
|
1857
|
-
return `${text.slice(0, maxLength - 1)}\u2026`;
|
|
1873
|
+
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}\u2026`;
|
|
1858
1874
|
}
|
|
1875
|
+
|
|
1876
|
+
// src/agent.ts
|
|
1859
1877
|
async function reviewPr(opts) {
|
|
1860
1878
|
const {
|
|
1861
1879
|
prUrl,
|
|
@@ -2367,4 +2385,4 @@ export {
|
|
|
2367
2385
|
postReviewStructured,
|
|
2368
2386
|
reviewPr
|
|
2369
2387
|
};
|
|
2370
|
-
//# sourceMappingURL=chunk-
|
|
2388
|
+
//# sourceMappingURL=chunk-ZUYM6HY3.js.map
|