@mrkaran/hodor 0.6.2 → 0.6.3-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/{chunk-LQDQ7CHL.js → chunk-AIXGPWDG.js} +651 -636
- package/dist/chunk-AIXGPWDG.js.map +1 -0
- package/dist/{chunk-DVJVQTVW.js → chunk-LO2QJD45.js} +15 -93
- package/dist/chunk-LO2QJD45.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 +3 -1
- 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
|
}
|
|
@@ -545,100 +553,87 @@ function getPriorityFromTitle(title) {
|
|
|
545
553
|
return REVIEW_PRIORITY_TAGS.get(`[${match[1]}]`) ?? null;
|
|
546
554
|
}
|
|
547
555
|
|
|
548
|
-
// src/
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
var GitHubAPIError = class extends Error {
|
|
555
|
-
constructor(message) {
|
|
556
|
-
super(message);
|
|
557
|
-
this.name = "GitHubAPIError";
|
|
556
|
+
// src/platform.ts
|
|
557
|
+
function detectPlatform(prUrl) {
|
|
558
|
+
const url = new URL(prUrl);
|
|
559
|
+
const hostname = url.hostname;
|
|
560
|
+
if (prUrl.includes("/-/merge_requests/") || hostname.includes("gitlab")) {
|
|
561
|
+
return "gitlab";
|
|
558
562
|
}
|
|
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);
|
|
563
|
+
if (prUrl.includes("/pulls/") || hostname.includes("gitea") || hostname.includes("forgejo") || hostname.includes("codeberg")) {
|
|
564
|
+
return "gitea";
|
|
594
565
|
}
|
|
566
|
+
if (prUrl.includes("/pull/") || hostname.includes("github")) {
|
|
567
|
+
return "github";
|
|
568
|
+
}
|
|
569
|
+
throw new Error(
|
|
570
|
+
`Cannot detect platform for URL: ${prUrl}. Expected a GitHub (/pull/), GitLab (/-/merge_requests/), or Gitea/Forgejo (/pulls/) URL.`
|
|
571
|
+
);
|
|
595
572
|
}
|
|
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 ?? {}
|
|
573
|
+
function parsePrUrl(prUrl) {
|
|
574
|
+
const url = new URL(prUrl);
|
|
575
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
576
|
+
const host = url.host;
|
|
577
|
+
if (pathParts.length >= 4 && pathParts[2] === "pull") {
|
|
578
|
+
return {
|
|
579
|
+
owner: pathParts[0],
|
|
580
|
+
repo: pathParts[1],
|
|
581
|
+
prNumber: parsePositiveNumber(pathParts[3], "PR", prUrl, "/pull/"),
|
|
582
|
+
host
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
if (pathParts.length >= 4 && pathParts[2] === "pulls") {
|
|
586
|
+
return {
|
|
587
|
+
owner: pathParts[0],
|
|
588
|
+
repo: pathParts[1],
|
|
589
|
+
prNumber: parsePositiveNumber(pathParts[3], "PR", prUrl, "/pulls/"),
|
|
590
|
+
host
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
const mrIndex = pathParts.indexOf("merge_requests");
|
|
594
|
+
if (mrIndex >= 0) {
|
|
595
|
+
if (mrIndex < 2 || mrIndex + 1 >= pathParts.length) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
`Invalid GitLab MR URL format: ${prUrl}. Expected .../-/merge_requests/<number>`
|
|
624
598
|
);
|
|
625
599
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
600
|
+
if (pathParts[mrIndex - 1] !== "-") {
|
|
601
|
+
throw new Error(
|
|
602
|
+
`Invalid GitLab MR URL format: ${prUrl}. Missing '/-/' segment before merge_requests.`
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
const repo = pathParts[mrIndex - 2];
|
|
606
|
+
const ownerParts = pathParts.slice(0, mrIndex - 2);
|
|
607
|
+
const owner = ownerParts.length > 0 ? ownerParts.join("/") : pathParts[0];
|
|
631
608
|
return {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
609
|
+
owner,
|
|
610
|
+
repo,
|
|
611
|
+
prNumber: parsePositiveNumber(
|
|
612
|
+
pathParts[mrIndex + 1],
|
|
613
|
+
"MR",
|
|
614
|
+
prUrl,
|
|
615
|
+
"/merge_requests/"
|
|
616
|
+
),
|
|
617
|
+
host
|
|
638
618
|
};
|
|
639
|
-
}
|
|
619
|
+
}
|
|
620
|
+
throw new Error(
|
|
621
|
+
`Invalid PR/MR URL format: ${prUrl}. Expected GitHub (/pull/), GitLab (/-/merge_requests/), or Gitea/Forgejo (/pulls/) URL.`
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
function parsePositiveNumber(raw, kind, prUrl, segment) {
|
|
625
|
+
const number = Number(raw);
|
|
626
|
+
if (!Number.isSafeInteger(number) || number <= 0) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
`Invalid ${kind} number in URL: ${prUrl}. Expected a positive integer after ${segment}.`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
return number;
|
|
640
632
|
}
|
|
641
633
|
|
|
634
|
+
// src/publisher.ts
|
|
635
|
+
import { createHash } from "crypto";
|
|
636
|
+
|
|
642
637
|
// src/gitea.ts
|
|
643
638
|
var GiteaAPIError = class extends Error {
|
|
644
639
|
constructor(message) {
|
|
@@ -801,72 +796,459 @@ async function postGiteaPrComment(owner, repo, prNumber, body, host) {
|
|
|
801
796
|
}
|
|
802
797
|
}
|
|
803
798
|
|
|
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;
|
|
799
|
+
// src/publisher.ts
|
|
800
|
+
var FINDING_MARKER_RE = /<!--\s*hodor:finding:([a-f0-9]{64})\s*-->/i;
|
|
801
|
+
function getFindingFingerprint(finding, workspacePath) {
|
|
802
|
+
const path = relativizeWorkspacePath(
|
|
803
|
+
finding.code_location.absolute_file_path,
|
|
804
|
+
workspacePath ?? void 0
|
|
805
|
+
);
|
|
806
|
+
const title = finding.title.replace(/^\[P[0-3]\]\s*/, "").trim().toLowerCase();
|
|
807
|
+
return createHash("sha256").update(`${path}
|
|
808
|
+
${title}`).digest("hex");
|
|
817
809
|
}
|
|
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
|
-
|
|
810
|
+
function getDiscussionFingerprint(body) {
|
|
811
|
+
return body.match(FINDING_MARKER_RE)?.[1]?.toLowerCase() ?? null;
|
|
812
|
+
}
|
|
813
|
+
async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
|
|
814
|
+
const blocking = review.findings.filter((finding) => finding.priority <= 1).length;
|
|
815
|
+
const state = blocking > 0 ? "failed" : "success";
|
|
816
|
+
const description = blocking > 0 ? `${blocking} blocking issue(s) found` : review.findings.length > 0 ? `${review.findings.length} non-blocking issue(s)` : "No issues found";
|
|
817
|
+
await postGitlabCommitStatus(
|
|
818
|
+
parsed.owner,
|
|
819
|
+
parsed.repo,
|
|
820
|
+
diffRefs.head_sha,
|
|
821
|
+
state,
|
|
822
|
+
parsed.host,
|
|
823
|
+
{ description }
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
async function postReviewComment(opts) {
|
|
827
|
+
const { prUrl, reviewText, model, metricsFooter, headSha } = opts;
|
|
828
|
+
const platform = detectPlatform(prUrl);
|
|
829
|
+
const parsed = parsePrUrl(prUrl);
|
|
830
|
+
let body = reviewText;
|
|
831
|
+
if (headSha) body = `<!-- hodor:sha:${headSha} -->
|
|
832
|
+
${body}`;
|
|
833
|
+
if (model) body += `
|
|
834
|
+
---
|
|
835
|
+
|
|
836
|
+
Review generated by Hodor (model: \`${model}\`)`;
|
|
837
|
+
if (metricsFooter) body += `
|
|
838
|
+
|
|
839
|
+
${metricsFooter}`;
|
|
840
|
+
try {
|
|
841
|
+
if (platform === "github") {
|
|
842
|
+
await exec("gh", [
|
|
843
|
+
"pr",
|
|
844
|
+
"review",
|
|
845
|
+
String(parsed.prNumber),
|
|
846
|
+
"--repo",
|
|
847
|
+
`${parsed.owner}/${parsed.repo}`,
|
|
848
|
+
"--comment",
|
|
849
|
+
"--body",
|
|
850
|
+
body
|
|
851
|
+
]);
|
|
852
|
+
return { success: true, platform, prNumber: parsed.prNumber };
|
|
848
853
|
}
|
|
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`
|
|
854
|
+
if (platform === "gitea") {
|
|
855
|
+
await postGiteaPrComment(
|
|
856
|
+
parsed.owner,
|
|
857
|
+
parsed.repo,
|
|
858
|
+
parsed.prNumber,
|
|
859
|
+
body,
|
|
860
|
+
parsed.host
|
|
861
861
|
);
|
|
862
|
+
return { success: true, platform, prNumber: parsed.prNumber };
|
|
862
863
|
}
|
|
864
|
+
await postGitlabMrComment(
|
|
865
|
+
parsed.owner,
|
|
866
|
+
parsed.repo,
|
|
867
|
+
parsed.prNumber,
|
|
868
|
+
body,
|
|
869
|
+
parsed.host
|
|
870
|
+
);
|
|
871
|
+
return {
|
|
872
|
+
success: true,
|
|
873
|
+
platform,
|
|
874
|
+
mrNumber: parsed.prNumber,
|
|
875
|
+
summaryPosted: true
|
|
876
|
+
};
|
|
877
|
+
} catch (error) {
|
|
878
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
879
|
+
logger.error(`Failed to post comment: ${message}`);
|
|
880
|
+
return { success: false, platform, error: message };
|
|
863
881
|
}
|
|
864
|
-
return { path: null, targetBranch: null, diffBaseSha: null };
|
|
865
882
|
}
|
|
866
|
-
function
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
|
|
883
|
+
async function postReviewStructured(opts) {
|
|
884
|
+
const {
|
|
885
|
+
prUrl,
|
|
886
|
+
review,
|
|
887
|
+
model,
|
|
888
|
+
metricsFooter,
|
|
889
|
+
reviewStyle = "hybrid",
|
|
890
|
+
commitStatus = false,
|
|
891
|
+
headSha,
|
|
892
|
+
workspacePath,
|
|
893
|
+
reconcileDiscussions = false
|
|
894
|
+
} = opts;
|
|
895
|
+
const platform = detectPlatform(prUrl);
|
|
896
|
+
if (platform !== "gitlab" || reviewStyle === "summary") {
|
|
897
|
+
return postReviewComment({
|
|
898
|
+
prUrl,
|
|
899
|
+
reviewText: renderMarkdown(review),
|
|
900
|
+
model,
|
|
901
|
+
metricsFooter,
|
|
902
|
+
headSha
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
const parsed = parsePrUrl(prUrl);
|
|
906
|
+
const errors = [];
|
|
907
|
+
let diffRefs;
|
|
908
|
+
try {
|
|
909
|
+
diffRefs = await getGitlabMrDiffRefs(
|
|
910
|
+
parsed.owner,
|
|
911
|
+
parsed.repo,
|
|
912
|
+
parsed.prNumber,
|
|
913
|
+
parsed.host
|
|
914
|
+
);
|
|
915
|
+
} catch (error) {
|
|
916
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
917
|
+
logger.warn(`Failed to get diff_refs, falling back to summary mode: ${message}`);
|
|
918
|
+
return postReviewComment({
|
|
919
|
+
prUrl,
|
|
920
|
+
reviewText: renderMarkdown(review),
|
|
921
|
+
model,
|
|
922
|
+
metricsFooter,
|
|
923
|
+
headSha
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
const existingByFingerprint = /* @__PURE__ */ new Map();
|
|
927
|
+
try {
|
|
928
|
+
const discussions = await listHodorDiscussions(
|
|
929
|
+
parsed.owner,
|
|
930
|
+
parsed.repo,
|
|
931
|
+
parsed.prNumber,
|
|
932
|
+
parsed.host
|
|
933
|
+
);
|
|
934
|
+
for (const discussion of discussions) {
|
|
935
|
+
if (discussion.resolved) continue;
|
|
936
|
+
const fingerprint = getDiscussionFingerprint(discussion.body);
|
|
937
|
+
if (!fingerprint) continue;
|
|
938
|
+
const ids = existingByFingerprint.get(fingerprint) ?? /* @__PURE__ */ new Set();
|
|
939
|
+
ids.add(discussion.discussionId);
|
|
940
|
+
existingByFingerprint.set(fingerprint, ids);
|
|
941
|
+
}
|
|
942
|
+
} catch (error) {
|
|
943
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
944
|
+
if (reconcileDiscussions) {
|
|
945
|
+
errors.push(`discussion listing: ${message}`);
|
|
946
|
+
}
|
|
947
|
+
logger.warn(`Failed to list open Hodor discussions for deduplication: ${message}`);
|
|
948
|
+
}
|
|
949
|
+
let inlineCreated = 0;
|
|
950
|
+
let inlineFailed = 0;
|
|
951
|
+
let inlineDeduplicated = 0;
|
|
952
|
+
for (const finding of review.findings) {
|
|
953
|
+
const fingerprint = getFindingFingerprint(finding, workspacePath);
|
|
954
|
+
if (existingByFingerprint.has(fingerprint)) {
|
|
955
|
+
inlineDeduplicated++;
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
const relPath = relativizeWorkspacePath(
|
|
959
|
+
finding.code_location.absolute_file_path,
|
|
960
|
+
workspacePath ?? void 0
|
|
961
|
+
);
|
|
962
|
+
const title = /^\[P[0-3]\]/.test(finding.title) ? finding.title : `[P${finding.priority}] ${finding.title}`;
|
|
963
|
+
let body = `${HODOR_REVIEW_MARKER}
|
|
964
|
+
<!-- hodor:finding:${fingerprint} -->
|
|
965
|
+
**${title}**
|
|
966
|
+
|
|
967
|
+
${finding.body}`;
|
|
968
|
+
if (finding.suggestion) {
|
|
969
|
+
const { start, end } = finding.code_location.line_range;
|
|
970
|
+
const span = Math.max(0, end - start);
|
|
971
|
+
body += `
|
|
972
|
+
|
|
973
|
+
\`\`\`suggestion:-0+${span}
|
|
974
|
+
${finding.suggestion}
|
|
975
|
+
\`\`\``;
|
|
976
|
+
}
|
|
977
|
+
try {
|
|
978
|
+
await createGitlabDraftNote(
|
|
979
|
+
parsed.owner,
|
|
980
|
+
parsed.repo,
|
|
981
|
+
parsed.prNumber,
|
|
982
|
+
body,
|
|
983
|
+
parsed.host,
|
|
984
|
+
{
|
|
985
|
+
filePath: relPath,
|
|
986
|
+
line: finding.code_location.line_range.start,
|
|
987
|
+
diffRefs
|
|
988
|
+
}
|
|
989
|
+
);
|
|
990
|
+
inlineCreated++;
|
|
991
|
+
} catch (error) {
|
|
992
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
993
|
+
errors.push(`inline note for ${finding.title}: ${message}`);
|
|
994
|
+
logger.warn(`Failed to create inline note for "${finding.title}": ${message}`);
|
|
995
|
+
inlineFailed++;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
logger.info(
|
|
999
|
+
`Created ${inlineCreated} inline draft note(s)${inlineDeduplicated > 0 ? ` (${inlineDeduplicated} already open)` : ""}${inlineFailed > 0 ? ` (${inlineFailed} failed)` : ""}`
|
|
1000
|
+
);
|
|
1001
|
+
let draftsPublished = false;
|
|
1002
|
+
if (inlineCreated > 0) {
|
|
1003
|
+
try {
|
|
1004
|
+
await bulkPublishGitlabDraftNotes(
|
|
1005
|
+
parsed.owner,
|
|
1006
|
+
parsed.repo,
|
|
1007
|
+
parsed.prNumber,
|
|
1008
|
+
parsed.host
|
|
1009
|
+
);
|
|
1010
|
+
draftsPublished = true;
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1013
|
+
errors.push(`draft publish: ${message}`);
|
|
1014
|
+
logger.warn(`Failed to bulk publish draft notes: ${message}`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
let summaryPosted = false;
|
|
1018
|
+
if (reviewStyle === "hybrid" || review.findings.length === 0) {
|
|
1019
|
+
let summaryBody = renderSummaryMarkdown(review);
|
|
1020
|
+
if (headSha) summaryBody = `<!-- hodor:sha:${headSha} -->
|
|
1021
|
+
${summaryBody}`;
|
|
1022
|
+
if (model) summaryBody += `
|
|
1023
|
+
---
|
|
1024
|
+
|
|
1025
|
+
Review generated by Hodor (model: \`${model}\`)`;
|
|
1026
|
+
if (metricsFooter) summaryBody += `
|
|
1027
|
+
|
|
1028
|
+
${metricsFooter}`;
|
|
1029
|
+
try {
|
|
1030
|
+
await postGitlabMrComment(
|
|
1031
|
+
parsed.owner,
|
|
1032
|
+
parsed.repo,
|
|
1033
|
+
parsed.prNumber,
|
|
1034
|
+
summaryBody,
|
|
1035
|
+
parsed.host
|
|
1036
|
+
);
|
|
1037
|
+
summaryPosted = true;
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1040
|
+
errors.push(`summary comment: ${message}`);
|
|
1041
|
+
logger.warn(`Failed to post summary comment: ${message}`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
let commitStatusPosted = false;
|
|
1045
|
+
if (commitStatus) {
|
|
1046
|
+
try {
|
|
1047
|
+
await postGitlabReviewCommitStatus(parsed, review, diffRefs);
|
|
1048
|
+
commitStatusPosted = true;
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1051
|
+
errors.push(`commit status: ${message}`);
|
|
1052
|
+
logger.warn(`Failed to post commit status: ${message}`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
let reconciledDiscussions = 0;
|
|
1056
|
+
const baseDeliveryComplete = reviewStyle === "hybrid" ? summaryPosted && inlineFailed === 0 && (inlineCreated === 0 || draftsPublished) : inlineFailed === 0 && (review.findings.length === 0 ? summaryPosted : inlineCreated === 0 || draftsPublished);
|
|
1057
|
+
if (reconcileDiscussions && baseDeliveryComplete) {
|
|
1058
|
+
const currentFingerprints = new Set(
|
|
1059
|
+
review.findings.map((finding) => getFindingFingerprint(finding, workspacePath))
|
|
1060
|
+
);
|
|
1061
|
+
const staleDiscussionIds = [...existingByFingerprint.entries()].filter(([fingerprint]) => !currentFingerprints.has(fingerprint)).flatMap(([, ids]) => [...ids]);
|
|
1062
|
+
if (staleDiscussionIds.length > 0) {
|
|
1063
|
+
reconciledDiscussions = await resolveGitlabDiscussions(
|
|
1064
|
+
parsed.owner,
|
|
1065
|
+
parsed.repo,
|
|
1066
|
+
parsed.prNumber,
|
|
1067
|
+
staleDiscussionIds,
|
|
1068
|
+
parsed.host
|
|
1069
|
+
);
|
|
1070
|
+
if (reconciledDiscussions !== staleDiscussionIds.length) {
|
|
1071
|
+
errors.push(
|
|
1072
|
+
`discussion reconciliation: resolved ${reconciledDiscussions}/${staleDiscussionIds.length}`
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
const success = baseDeliveryComplete && (!commitStatus || commitStatusPosted) && (!reconcileDiscussions || !errors.some((error) => error.startsWith("discussion ")));
|
|
1078
|
+
return {
|
|
1079
|
+
success,
|
|
1080
|
+
platform: "gitlab",
|
|
1081
|
+
mrNumber: parsed.prNumber,
|
|
1082
|
+
error: success ? void 0 : errors[0] ?? "Review delivery was incomplete",
|
|
1083
|
+
errors,
|
|
1084
|
+
summaryPosted,
|
|
1085
|
+
inlineCreated,
|
|
1086
|
+
inlineFailed,
|
|
1087
|
+
draftsPublished,
|
|
1088
|
+
commitStatusPosted,
|
|
1089
|
+
reconciledDiscussions
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// src/agent.ts
|
|
1094
|
+
import { existsSync } from "fs";
|
|
1095
|
+
import { join as join2 } from "path";
|
|
1096
|
+
|
|
1097
|
+
// src/github.ts
|
|
1098
|
+
var GitHubAPIError = class extends Error {
|
|
1099
|
+
constructor(message) {
|
|
1100
|
+
super(message);
|
|
1101
|
+
this.name = "GitHubAPIError";
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
async function fetchGithubPrInfo(owner, repo, prNumber) {
|
|
1105
|
+
const fields = [
|
|
1106
|
+
"number",
|
|
1107
|
+
"title",
|
|
1108
|
+
"body",
|
|
1109
|
+
"author",
|
|
1110
|
+
"baseRefName",
|
|
1111
|
+
"headRefName",
|
|
1112
|
+
"baseRefOid",
|
|
1113
|
+
"headRefOid",
|
|
1114
|
+
"changedFiles",
|
|
1115
|
+
"labels",
|
|
1116
|
+
"comments",
|
|
1117
|
+
"state",
|
|
1118
|
+
"isDraft",
|
|
1119
|
+
"createdAt",
|
|
1120
|
+
"updatedAt",
|
|
1121
|
+
"mergeable",
|
|
1122
|
+
"url"
|
|
1123
|
+
];
|
|
1124
|
+
const repoFullPath = `${owner}/${repo}`;
|
|
1125
|
+
try {
|
|
1126
|
+
return await execJson("gh", [
|
|
1127
|
+
"pr",
|
|
1128
|
+
"view",
|
|
1129
|
+
String(prNumber),
|
|
1130
|
+
"-R",
|
|
1131
|
+
repoFullPath,
|
|
1132
|
+
"--json",
|
|
1133
|
+
fields.join(",")
|
|
1134
|
+
]);
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1137
|
+
throw new GitHubAPIError(msg);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
function normalizeGithubMetadata(raw) {
|
|
1141
|
+
const author = raw.author ?? {};
|
|
1142
|
+
const labels = raw.labels ?? [];
|
|
1143
|
+
const comments = raw.comments;
|
|
1144
|
+
return {
|
|
1145
|
+
title: raw.title,
|
|
1146
|
+
description: raw.body ?? "",
|
|
1147
|
+
source_branch: raw.headRefName,
|
|
1148
|
+
target_branch: raw.baseRefName,
|
|
1149
|
+
changes_count: raw.changedFiles,
|
|
1150
|
+
labels: labels.map((lbl) => ({ name: lbl.name ?? lbl.id })),
|
|
1151
|
+
author: {
|
|
1152
|
+
username: author.login ?? author.name,
|
|
1153
|
+
name: author.name
|
|
1154
|
+
},
|
|
1155
|
+
Notes: githubCommentsToNotes(comments)
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
function githubCommentsToNotes(comments) {
|
|
1159
|
+
if (!comments) return [];
|
|
1160
|
+
let nodes;
|
|
1161
|
+
if (Array.isArray(comments)) {
|
|
1162
|
+
nodes = comments;
|
|
1163
|
+
} else if (typeof comments === "object") {
|
|
1164
|
+
nodes = comments.nodes ?? comments.edges ?? [];
|
|
1165
|
+
if (nodes.length > 0 && typeof nodes[0] === "object" && "node" in nodes[0]) {
|
|
1166
|
+
nodes = nodes.map(
|
|
1167
|
+
(edge) => edge.node ?? {}
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
} else {
|
|
1171
|
+
nodes = [];
|
|
1172
|
+
}
|
|
1173
|
+
return nodes.map((node) => {
|
|
1174
|
+
const author = node.author ?? {};
|
|
1175
|
+
return {
|
|
1176
|
+
body: node.body ?? "",
|
|
1177
|
+
author: {
|
|
1178
|
+
username: author.login ?? author.name,
|
|
1179
|
+
name: author.name
|
|
1180
|
+
},
|
|
1181
|
+
created_at: node.createdAt
|
|
1182
|
+
};
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/workspace.ts
|
|
1187
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
1188
|
+
import { tmpdir } from "os";
|
|
1189
|
+
import { join } from "path";
|
|
1190
|
+
var WorkspaceError = class extends Error {
|
|
1191
|
+
constructor(message) {
|
|
1192
|
+
super(message);
|
|
1193
|
+
this.name = "WorkspaceError";
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
function envOrNull(name) {
|
|
1197
|
+
const value = process.env[name]?.trim();
|
|
1198
|
+
return value ? value : null;
|
|
1199
|
+
}
|
|
1200
|
+
async function detectCiWorkspace(owner, repo) {
|
|
1201
|
+
const expected = `${owner}/${repo}`;
|
|
1202
|
+
if (process.env.GITLAB_CI === "true") {
|
|
1203
|
+
const projectDir = envOrNull("CI_PROJECT_DIR");
|
|
1204
|
+
const projectPath = envOrNull("CI_PROJECT_PATH");
|
|
1205
|
+
const targetBranch = envOrNull("CI_MERGE_REQUEST_TARGET_BRANCH_NAME");
|
|
1206
|
+
const diffBaseSha = envOrNull("CI_MERGE_REQUEST_DIFF_BASE_SHA");
|
|
1207
|
+
if (projectDir && projectPath && (projectPath === expected || projectPath.endsWith(`/${expected}`))) {
|
|
1208
|
+
if (await isSameRepo(projectDir, owner, repo)) {
|
|
1209
|
+
logger.info(`Detected GitLab CI environment (target: ${targetBranch ?? "unknown"})`);
|
|
1210
|
+
return { path: projectDir, targetBranch, diffBaseSha };
|
|
1211
|
+
}
|
|
1212
|
+
logger.warn(
|
|
1213
|
+
`Detected GitLab CI for ${projectPath}, but ${projectDir} is not a git checkout of ${expected}; falling back to clone`
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (process.env.GITEA_ACTIONS === "true" || process.env.FORGEJO_ACTIONS === "true") {
|
|
1218
|
+
const workspaceDir = envOrNull("GITHUB_WORKSPACE");
|
|
1219
|
+
const repository = envOrNull("GITHUB_REPOSITORY");
|
|
1220
|
+
const baseRef = envOrNull("GITHUB_BASE_REF");
|
|
1221
|
+
if (workspaceDir && repository === expected) {
|
|
1222
|
+
const ciType = process.env.FORGEJO_ACTIONS ? "Forgejo" : "Gitea";
|
|
1223
|
+
if (await isSameRepo(workspaceDir, owner, repo)) {
|
|
1224
|
+
logger.info(`Detected ${ciType} Actions environment (base: ${baseRef ?? "unknown"})`);
|
|
1225
|
+
return { path: workspaceDir, targetBranch: baseRef, diffBaseSha: null };
|
|
1226
|
+
}
|
|
1227
|
+
logger.warn(
|
|
1228
|
+
`Detected ${ciType} Actions for ${repository}, but ${workspaceDir} is not a git checkout of ${expected}; falling back to clone`
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
if (process.env.GITHUB_ACTIONS === "true") {
|
|
1233
|
+
const workspaceDir = envOrNull("GITHUB_WORKSPACE");
|
|
1234
|
+
const repository = envOrNull("GITHUB_REPOSITORY");
|
|
1235
|
+
const baseRef = envOrNull("GITHUB_BASE_REF");
|
|
1236
|
+
if (workspaceDir && repository === expected) {
|
|
1237
|
+
if (await isSameRepo(workspaceDir, owner, repo)) {
|
|
1238
|
+
logger.info(`Detected GitHub Actions environment (base: ${baseRef ?? "unknown"})`);
|
|
1239
|
+
return { path: workspaceDir, targetBranch: baseRef, diffBaseSha: null };
|
|
1240
|
+
}
|
|
1241
|
+
logger.warn(
|
|
1242
|
+
`Detected GitHub Actions for ${repository}, but ${workspaceDir} is not a git checkout of ${expected}; falling back to clone`
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
return { path: null, targetBranch: null, diffBaseSha: null };
|
|
1247
|
+
}
|
|
1248
|
+
function normalizeGitRemotePath(remoteUrl) {
|
|
1249
|
+
const trimmed = remoteUrl.trim().replace(/\.git$/, "");
|
|
1250
|
+
try {
|
|
1251
|
+
const url = new URL(trimmed);
|
|
870
1252
|
return url.pathname.replace(/^\/+/, "").replace(/\.git$/, "");
|
|
871
1253
|
} catch {
|
|
872
1254
|
}
|
|
@@ -1271,247 +1653,88 @@ function resolveReviewLocations(review, opts) {
|
|
|
1271
1653
|
const buf = readFileSync2(path);
|
|
1272
1654
|
if (buf.byteLength <= MAX_RESOLVE_BYTES) content = buf.toString("utf-8");
|
|
1273
1655
|
} 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
|
|
1656
|
+
content = null;
|
|
1657
|
+
}
|
|
1658
|
+
fileCache.set(path, content);
|
|
1659
|
+
return content;
|
|
1660
|
+
};
|
|
1661
|
+
const findings = review.findings.map((finding) => {
|
|
1662
|
+
stats.total++;
|
|
1663
|
+
const { existing_code: existingCode, code_location: loc } = finding;
|
|
1664
|
+
if (!existingCode) {
|
|
1665
|
+
stats.noSnippet++;
|
|
1666
|
+
return finding;
|
|
1667
|
+
}
|
|
1668
|
+
if (workspaceRoot && !isWithinWorkspace(workspaceRoot, loc.absolute_file_path)) {
|
|
1669
|
+
stats.unmatched++;
|
|
1670
|
+
logger.warn(
|
|
1671
|
+
`Location resolution: ${loc.absolute_file_path} is outside the workspace for "${finding.title}"; keeping model range`
|
|
1499
1672
|
);
|
|
1673
|
+
return finding;
|
|
1674
|
+
}
|
|
1675
|
+
const fileContent = readFile(loc.absolute_file_path);
|
|
1676
|
+
if (fileContent === null) {
|
|
1677
|
+
stats.unmatched++;
|
|
1678
|
+
logger.warn(`Location resolution: could not read ${loc.absolute_file_path} for "${finding.title}"`);
|
|
1679
|
+
return finding;
|
|
1680
|
+
}
|
|
1681
|
+
let changedLines;
|
|
1682
|
+
for (const [relPath, lines] of changedByFile) {
|
|
1683
|
+
if (loc.absolute_file_path.endsWith(`/${relPath}`) || loc.absolute_file_path === relPath) {
|
|
1684
|
+
changedLines = lines;
|
|
1685
|
+
break;
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
const result = resolveLineRange({
|
|
1689
|
+
existingCode,
|
|
1690
|
+
fileContent,
|
|
1691
|
+
modelRange: loc.line_range,
|
|
1692
|
+
changedLines
|
|
1693
|
+
});
|
|
1694
|
+
if (result.status === "confirmed") stats.confirmed++;
|
|
1695
|
+
else if (result.status === "corrected") stats.corrected++;
|
|
1696
|
+
else stats.unmatched++;
|
|
1697
|
+
if (result.status === "corrected") {
|
|
1500
1698
|
logger.info(
|
|
1501
|
-
`
|
|
1699
|
+
`Location resolution: corrected "${finding.title}" ${loc.line_range.start}-${loc.line_range.end} -> ${result.start}-${result.end}`
|
|
1502
1700
|
);
|
|
1503
1701
|
return {
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
mrNumber: parsed.prNumber
|
|
1702
|
+
...finding,
|
|
1703
|
+
code_location: { ...loc, line_range: { start: result.start, end: result.end } }
|
|
1507
1704
|
};
|
|
1508
1705
|
}
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1706
|
+
if (result.status === "unmatched") {
|
|
1707
|
+
logger.warn(
|
|
1708
|
+
`Location resolution: snippet not found for "${finding.title}" in ${loc.absolute_file_path}; keeping model range ${loc.line_range.start}-${loc.line_range.end}`
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
return finding;
|
|
1712
|
+
});
|
|
1713
|
+
return { review: { ...review, findings }, stats };
|
|
1514
1714
|
}
|
|
1715
|
+
|
|
1716
|
+
// src/system-prompt.ts
|
|
1717
|
+
var REVIEW_SYSTEM_PROMPT = `You are a code review agent. You analyze pull request diffs to find production bugs.
|
|
1718
|
+
|
|
1719
|
+
<ROLE>
|
|
1720
|
+
* You are in READ-ONLY mode. Do NOT modify any files, create files, commit, or install dependencies.
|
|
1721
|
+
* Your only job is to analyze the diff, identify bugs, and produce a review.
|
|
1722
|
+
* Submit the final review via the \`submit_review\` tool. Do NOT output the final review as normal assistant text.
|
|
1723
|
+
* 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.
|
|
1724
|
+
* Do NOT write to PLAN.md or AGENTS.md.
|
|
1725
|
+
* Do NOT run package managers (npm install, go mod download, pip install, etc.).
|
|
1726
|
+
* Follow the instructions in the user prompt exactly as given.
|
|
1727
|
+
</ROLE>
|
|
1728
|
+
|
|
1729
|
+
<EFFICIENCY>
|
|
1730
|
+
* Combine multiple bash commands where possible (e.g. \`cmd1 && cmd2\`).
|
|
1731
|
+
* Use the grep and find tools for code search \u2014 do not shell out to grep/find.
|
|
1732
|
+
* Prefer \`git diff\` to see changes for specific files. Only use read when you need surrounding context that the diff alone cannot provide.
|
|
1733
|
+
* Do not use cat/head/tail to read files.
|
|
1734
|
+
* Keep reasoning proportional to the task. A small diff does not need extensive deliberation.
|
|
1735
|
+
</EFFICIENCY>`;
|
|
1736
|
+
|
|
1737
|
+
// src/review-diff.ts
|
|
1515
1738
|
var HODOR_REVIEW_SHA_RE = /^\s*<!--\s*hodor:sha:([a-f0-9]{40})\s*-->/i;
|
|
1516
1739
|
function getHodorReviewShaCandidates(notes) {
|
|
1517
1740
|
if (!notes || notes.length === 0) return [];
|
|
@@ -1534,14 +1757,7 @@ function getHodorReviewShaCandidates(notes) {
|
|
|
1534
1757
|
if (a.createdAtMs == null && b.createdAtMs != null) return 1;
|
|
1535
1758
|
return a.index - b.index;
|
|
1536
1759
|
});
|
|
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;
|
|
1760
|
+
return [...new Set(candidates.map(({ sha }) => sha))];
|
|
1545
1761
|
}
|
|
1546
1762
|
async function findLatestValidReviewSha(notes, workspacePath) {
|
|
1547
1763
|
const candidates = getHodorReviewShaCandidates(notes);
|
|
@@ -1549,222 +1765,26 @@ async function findLatestValidReviewSha(notes, workspacePath) {
|
|
|
1549
1765
|
logger.info(`Found ${candidates.length} previous Hodor review marker(s)`);
|
|
1550
1766
|
for (const sha of candidates) {
|
|
1551
1767
|
try {
|
|
1552
|
-
const { stdout:
|
|
1553
|
-
|
|
1554
|
-
|
|
1768
|
+
const { stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
|
|
1769
|
+
cwd: workspacePath
|
|
1770
|
+
});
|
|
1771
|
+
if (objectType.trim() !== "commit") throw new Error("not a commit");
|
|
1772
|
+
await exec("git", ["merge-base", "--is-ancestor", sha, "HEAD"], {
|
|
1773
|
+
cwd: workspacePath
|
|
1774
|
+
});
|
|
1555
1775
|
return sha;
|
|
1556
1776
|
} 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
|
|
1777
|
+
logger.info(
|
|
1778
|
+
`Skipping previous review SHA ${sha.slice(0, 8)}; not a valid ancestor of HEAD`
|
|
1715
1779
|
);
|
|
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
1780
|
}
|
|
1745
1781
|
}
|
|
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
|
-
};
|
|
1782
|
+
return null;
|
|
1761
1783
|
}
|
|
1762
1784
|
var DIFF_SKIP_PATTERNS = [
|
|
1763
1785
|
/(?:^|\/)testdata\//,
|
|
1764
|
-
// test fixture directories
|
|
1765
1786
|
/(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|go\.sum|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock)$/,
|
|
1766
1787
|
/\.mdx?$/
|
|
1767
|
-
// markdown docs
|
|
1768
1788
|
];
|
|
1769
1789
|
function filterEmbeddedDiff(rawDiff) {
|
|
1770
1790
|
const skippedFiles = [];
|
|
@@ -1777,7 +1797,7 @@ function filterEmbeddedDiff(rawDiff) {
|
|
|
1777
1797
|
continue;
|
|
1778
1798
|
}
|
|
1779
1799
|
const filePath = match[1];
|
|
1780
|
-
if (DIFF_SKIP_PATTERNS.some((
|
|
1800
|
+
if (DIFF_SKIP_PATTERNS.some((pattern) => pattern.test(filePath))) {
|
|
1781
1801
|
skippedFiles.push(filePath);
|
|
1782
1802
|
} else {
|
|
1783
1803
|
kept.push(section);
|
|
@@ -1785,6 +1805,9 @@ function filterEmbeddedDiff(rawDiff) {
|
|
|
1785
1805
|
}
|
|
1786
1806
|
return { filtered: kept.join(""), skippedFiles };
|
|
1787
1807
|
}
|
|
1808
|
+
|
|
1809
|
+
// src/review-recovery.ts
|
|
1810
|
+
import { Value } from "@sinclair/typebox/value";
|
|
1788
1811
|
var SUBMIT_REVIEW_RECOVERY_ATTEMPTS = 2;
|
|
1789
1812
|
function buildSubmitReviewRecoveryPrompt(attempt, maxAttempts) {
|
|
1790
1813
|
const finalAttempt = attempt >= maxAttempts ? "\nThis is the final automatic recovery attempt; do not end the turn without calling `submit_review`." : "";
|
|
@@ -1798,64 +1821,56 @@ function buildSubmitReviewRecoveryPrompt(attempt, maxAttempts) {
|
|
|
1798
1821
|
].filter(Boolean).join("\n");
|
|
1799
1822
|
}
|
|
1800
1823
|
function parseReviewFromAssistantText(text) {
|
|
1801
|
-
const
|
|
1802
|
-
for (const candidate of candidates) {
|
|
1824
|
+
for (const candidate of getJsonCandidates(text)) {
|
|
1803
1825
|
try {
|
|
1804
1826
|
const parsed = JSON.parse(candidate);
|
|
1805
|
-
if (!Value.Check(SUBMIT_REVIEW_SCHEMA, parsed))
|
|
1806
|
-
continue;
|
|
1807
|
-
}
|
|
1827
|
+
if (!Value.Check(SUBMIT_REVIEW_SCHEMA, parsed)) continue;
|
|
1808
1828
|
return validateReviewOutput(parsed);
|
|
1809
1829
|
} catch {
|
|
1810
1830
|
}
|
|
1811
1831
|
}
|
|
1812
1832
|
return null;
|
|
1813
1833
|
}
|
|
1834
|
+
function summarizeLastAssistantMessage(session) {
|
|
1835
|
+
const messages = session.messages;
|
|
1836
|
+
const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant");
|
|
1837
|
+
if (!lastAssistant) return "no assistant message";
|
|
1838
|
+
const stopReason = typeof lastAssistant.stopReason === "string" ? lastAssistant.stopReason : "unknown";
|
|
1839
|
+
const errorMessage = typeof lastAssistant.errorMessage === "string" ? `, error=${JSON.stringify(truncateForLog(lastAssistant.errorMessage, 300))}` : "";
|
|
1840
|
+
const content = Array.isArray(lastAssistant.content) ? lastAssistant.content.map((item) => {
|
|
1841
|
+
const block = item;
|
|
1842
|
+
const type = typeof block.type === "string" ? block.type : "unknown";
|
|
1843
|
+
return type === "toolCall" && typeof block.name === "string" ? `toolCall:${block.name}` : type;
|
|
1844
|
+
}).join(",") : "unknown";
|
|
1845
|
+
const rawText = session.getLastAssistantText()?.trim();
|
|
1846
|
+
const textSummary = rawText ? `, text=${JSON.stringify(truncateForLog(rawText.replace(/\s+/g, " "), 500))}` : "";
|
|
1847
|
+
return `stopReason=${stopReason}, content=[${content || "none"}]${errorMessage}${textSummary}`;
|
|
1848
|
+
}
|
|
1814
1849
|
function getJsonCandidates(text) {
|
|
1815
1850
|
const candidates = [];
|
|
1816
1851
|
const seen = /* @__PURE__ */ new Set();
|
|
1817
|
-
const
|
|
1852
|
+
const add = (value) => {
|
|
1818
1853
|
const trimmed = value.trim();
|
|
1819
1854
|
if (!trimmed || seen.has(trimmed)) return;
|
|
1820
1855
|
seen.add(trimmed);
|
|
1821
1856
|
candidates.push(trimmed);
|
|
1822
1857
|
};
|
|
1823
|
-
|
|
1824
|
-
const
|
|
1825
|
-
|
|
1826
|
-
addCandidate(match[1] ?? "");
|
|
1858
|
+
add(text);
|
|
1859
|
+
for (const match of text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
|
|
1860
|
+
add(match[1] ?? "");
|
|
1827
1861
|
}
|
|
1828
1862
|
const firstBrace = text.indexOf("{");
|
|
1829
1863
|
const lastBrace = text.lastIndexOf("}");
|
|
1830
1864
|
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
1831
|
-
|
|
1865
|
+
add(text.slice(firstBrace, lastBrace + 1));
|
|
1832
1866
|
}
|
|
1833
1867
|
return candidates;
|
|
1834
1868
|
}
|
|
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
1869
|
function truncateForLog(text, maxLength) {
|
|
1856
|
-
|
|
1857
|
-
return `${text.slice(0, maxLength - 1)}\u2026`;
|
|
1870
|
+
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}\u2026`;
|
|
1858
1871
|
}
|
|
1872
|
+
|
|
1873
|
+
// src/agent.ts
|
|
1859
1874
|
async function reviewPr(opts) {
|
|
1860
1875
|
const {
|
|
1861
1876
|
prUrl,
|
|
@@ -2367,4 +2382,4 @@ export {
|
|
|
2367
2382
|
postReviewStructured,
|
|
2368
2383
|
reviewPr
|
|
2369
2384
|
};
|
|
2370
|
-
//# sourceMappingURL=chunk-
|
|
2385
|
+
//# sourceMappingURL=chunk-AIXGPWDG.js.map
|