@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.
@@ -13,8 +13,9 @@ import {
13
13
  renderMarkdown,
14
14
  renderSummaryMarkdown,
15
15
  resolveGitlabDiscussions,
16
- summarizeGitlabNotes
17
- } from "./chunk-DVJVQTVW.js";
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 notesSummary = summarizeGitlabNotes(mrMetadata.Notes);
219
- if (notesSummary) {
220
- notesSection = `## Existing MR Notes
221
- ${notesSummary}
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 (notesSummary) {
226
- reminderSection = "## Review Note Deduplication\n\nThe discussions 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";
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/agent.ts
549
- import { existsSync } from "fs";
550
- import { join as join2 } from "path";
551
- import { Value } from "@sinclair/typebox/value";
552
-
553
- // src/github.ts
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
- async function fetchGithubPrInfo(owner, repo, prNumber) {
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 normalizeGithubMetadata(raw) {
597
- const author = raw.author ?? {};
598
- const labels = raw.labels ?? [];
599
- const comments = raw.comments;
600
- return {
601
- title: raw.title,
602
- description: raw.body ?? "",
603
- source_branch: raw.headRefName,
604
- target_branch: raw.baseRefName,
605
- changes_count: raw.changedFiles,
606
- labels: labels.map((lbl) => ({ name: lbl.name ?? lbl.id })),
607
- author: {
608
- username: author.login ?? author.name,
609
- name: author.name
610
- },
611
- Notes: githubCommentsToNotes(comments)
612
- };
613
- }
614
- function githubCommentsToNotes(comments) {
615
- if (!comments) return [];
616
- let nodes;
617
- if (Array.isArray(comments)) {
618
- nodes = comments;
619
- } else if (typeof comments === "object") {
620
- nodes = comments.nodes ?? comments.edges ?? [];
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
- } else {
627
- nodes = [];
628
- }
629
- return nodes.map((node) => {
630
- const author = node.author ?? {};
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
- body: node.body ?? "",
633
- author: {
634
- username: author.login ?? author.name,
635
- name: author.name
636
- },
637
- created_at: node.createdAt
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/workspace.ts
805
- import { mkdtemp, rm } from "fs/promises";
806
- import { tmpdir } from "os";
807
- import { join } from "path";
808
- var WorkspaceError = class extends Error {
809
- constructor(message) {
810
- super(message);
811
- this.name = "WorkspaceError";
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
- async function detectCiWorkspace(owner, repo) {
819
- const expected = `${owner}/${repo}`;
820
- if (process.env.GITLAB_CI === "true") {
821
- const projectDir = envOrNull("CI_PROJECT_DIR");
822
- const projectPath = envOrNull("CI_PROJECT_PATH");
823
- const targetBranch = envOrNull("CI_MERGE_REQUEST_TARGET_BRANCH_NAME");
824
- const diffBaseSha = envOrNull("CI_MERGE_REQUEST_DIFF_BASE_SHA");
825
- if (projectDir && projectPath && (projectPath === expected || projectPath.endsWith(`/${expected}`))) {
826
- if (await isSameRepo(projectDir, owner, repo)) {
827
- logger.info(`Detected GitLab CI environment (target: ${targetBranch ?? "unknown"})`);
828
- return { path: projectDir, targetBranch, diffBaseSha };
829
- }
830
- logger.warn(
831
- `Detected GitLab CI for ${projectPath}, but ${projectDir} is not a git checkout of ${expected}; falling back to clone`
832
- );
833
- }
834
- }
835
- if (process.env.GITEA_ACTIONS === "true" || process.env.FORGEJO_ACTIONS === "true") {
836
- const workspaceDir = envOrNull("GITHUB_WORKSPACE");
837
- const repository = envOrNull("GITHUB_REPOSITORY");
838
- const baseRef = envOrNull("GITHUB_BASE_REF");
839
- if (workspaceDir && repository === expected) {
840
- const ciType = process.env.FORGEJO_ACTIONS ? "Forgejo" : "Gitea";
841
- if (await isSameRepo(workspaceDir, owner, repo)) {
842
- logger.info(`Detected ${ciType} Actions environment (base: ${baseRef ?? "unknown"})`);
843
- return { path: workspaceDir, targetBranch: baseRef, diffBaseSha: null };
844
- }
845
- logger.warn(
846
- `Detected ${ciType} Actions for ${repository}, but ${workspaceDir} is not a git checkout of ${expected}; falling back to clone`
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
- if (process.env.GITHUB_ACTIONS === "true") {
851
- const workspaceDir = envOrNull("GITHUB_WORKSPACE");
852
- const repository = envOrNull("GITHUB_REPOSITORY");
853
- const baseRef = envOrNull("GITHUB_BASE_REF");
854
- if (workspaceDir && repository === expected) {
855
- if (await isSameRepo(workspaceDir, owner, repo)) {
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 normalizeGitRemotePath(remoteUrl) {
867
- const trimmed = remoteUrl.trim().replace(/\.git$/, "");
868
- try {
869
- const url = new URL(trimmed);
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
- `Successfully posted review to GitLab MR !${parsed.prNumber}`
1699
+ `Location resolution: corrected "${finding.title}" ${loc.line_range.start}-${loc.line_range.end} -> ${result.start}-${result.end}`
1502
1700
  );
1503
1701
  return {
1504
- success: true,
1505
- platform: "gitlab",
1506
- mrNumber: parsed.prNumber
1702
+ ...finding,
1703
+ code_location: { ...loc, line_range: { start: result.start, end: result.end } }
1507
1704
  };
1508
1705
  }
1509
- } catch (err) {
1510
- const msg = err instanceof Error ? err.message : String(err);
1511
- logger.error(`Failed to post comment: ${msg}`);
1512
- return { success: false, error: msg };
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
- const seen = /* @__PURE__ */ new Set();
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: objType } = await exec("git", ["cat-file", "-t", sha], { cwd: workspacePath });
1553
- if (objType.trim() !== "commit") throw new Error("not a commit");
1554
- await exec("git", ["merge-base", "--is-ancestor", sha, "HEAD"], { cwd: workspacePath });
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(`Skipping previous review SHA ${sha.slice(0, 8)}; not a valid ancestor of HEAD`);
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
- const visibleResult = summaryPosted || inlineCount > 0 && draftsPublished || statusPosted;
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((re) => re.test(filePath))) {
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 candidates = getJsonCandidates(text);
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 addCandidate = (value) => {
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
- addCandidate(text);
1824
- const fencedJson = /```(?:json)?\s*([\s\S]*?)```/gi;
1825
- for (const match of text.matchAll(fencedJson)) {
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
- addCandidate(text.slice(firstBrace, lastBrace + 1));
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
- if (text.length <= maxLength) return text;
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-LQDQ7CHL.js.map
2385
+ //# sourceMappingURL=chunk-AIXGPWDG.js.map