@mrkaran/hodor 0.6.2 → 0.6.3-rc.2

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