@mrkaran/hodor 0.6.3-rc.1 → 0.6.3-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/{chunk-AIXGPWDG.js → chunk-N7TSXVUE.js} +311 -54
- package/dist/chunk-N7TSXVUE.js.map +1 -0
- package/dist/{chunk-LO2QJD45.js → chunk-W7ZUJIFT.js} +3 -2
- package/dist/chunk-W7ZUJIFT.js.map +1 -0
- package/dist/cli.js +52 -27
- package/dist/cli.js.map +1 -1
- package/dist/{gitlab-OMV6OJ5Q.js → gitlab-L3VGOT6Q.js} +2 -2
- package/dist/index.d.ts +14 -0
- package/dist/index.js +2 -2
- package/package.json +3 -3
- package/templates/tool-review.md +2 -2
- package/dist/chunk-AIXGPWDG.js.map +0 -1
- package/dist/chunk-LO2QJD45.js.map +0 -1
- /package/dist/{gitlab-OMV6OJ5Q.js.map → gitlab-L3VGOT6Q.js.map} +0 -0
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" /></a>
|
|
2
|
+
|
|
1
3
|
# Hodor
|
|
2
4
|
|
|
3
5
|
> Agentic code reviewer for GitHub PRs, GitLab MRs, Gitea/Forgejo PRs, and local diffs. Powered by the [pi-coding-agent](https://github.com/badlogic/pi-mono) SDK.
|
|
@@ -231,9 +233,14 @@ See [AUTOMATED_REVIEWS.md](./docs/AUTOMATED_REVIEWS.md) for advanced workflows.
|
|
|
231
233
|
Hodor automatically optimizes token usage:
|
|
232
234
|
|
|
233
235
|
- **Diff embedding**: For PRs under 200KB, the diff is embedded directly in the prompt, cutting agent turns from ~60 to ~5.
|
|
234
|
-
- **Incremental reviews**: On re-runs, only reviews changes since the last hodor comment
|
|
236
|
+
- **Incremental reviews**: On re-runs, only reviews changes since the last hodor comment. After a force-push or rebase, Hodor compares the last reviewed snapshot directly with the current HEAD instead of reviewing the whole MR again.
|
|
237
|
+
- **Identical-HEAD reuse**: Successful summaries include a versioned, compressed review payload. Pipeline retries with the same HEAD, model, reasoning request, and prompt configuration reuse that result while still regenerating artifacts and retrying delivery.
|
|
238
|
+
- **Adaptive reasoning**: Models that default to `xhigh` use `high` for routine small and incremental diffs, while risky, large, explicitly configured, and `--full` reviews retain the requested depth.
|
|
239
|
+
- **Focused exploration**: Embedded diffs include a changed-file manifest and direct the agent toward bounded context reads without limiting how far it may investigate.
|
|
235
240
|
- **Compaction**: SDK auto-summarizes older conversation turns when context grows too large.
|
|
236
241
|
|
|
242
|
+
Pass `--full` to bypass incremental mode and identical-HEAD reuse. Pass `--reasoning-effort` to override adaptive reasoning.
|
|
243
|
+
|
|
237
244
|
## Skills
|
|
238
245
|
|
|
239
246
|
Hodor discovers repository-specific review guidelines from `.agents/skills/`, the cross-client Agent Skills convention:
|
|
@@ -287,7 +294,7 @@ Hodor is written in TypeScript and runs on [Bun](https://bun.sh). Key components
|
|
|
287
294
|
| `src/metrics.ts` | Token usage and cost formatting |
|
|
288
295
|
| `templates/` | Review prompt template (JSON schema) |
|
|
289
296
|
|
|
290
|
-
The agent runtime is provided by [`@
|
|
297
|
+
The agent runtime is provided by [`@earendil-works/pi-coding-agent`](https://github.com/earendil-works/pi) with [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) for LLM access. The agent session gets read-only tools (bash, read, grep, find, ls) and a review prompt, then autonomously analyzes the PR.
|
|
291
298
|
|
|
292
299
|
---
|
|
293
300
|
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
resolveGitlabDiscussions,
|
|
16
16
|
summarizeGitlabNotes,
|
|
17
17
|
summarizeHodorNotes
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-W7ZUJIFT.js";
|
|
19
19
|
import {
|
|
20
20
|
relativizeWorkspacePath
|
|
21
21
|
} from "./chunk-AMUK6GDX.js";
|
|
@@ -39,6 +39,8 @@ function buildPrReviewPrompt(opts) {
|
|
|
39
39
|
customPromptFile,
|
|
40
40
|
embeddedDiff,
|
|
41
41
|
previousReviewSha,
|
|
42
|
+
reviewDiffMode,
|
|
43
|
+
changedFiles = [],
|
|
42
44
|
localMode = false
|
|
43
45
|
} = opts;
|
|
44
46
|
let templateFile;
|
|
@@ -68,9 +70,10 @@ function buildPrReviewPrompt(opts) {
|
|
|
68
70
|
let prDiffCmd;
|
|
69
71
|
let gitDiffCmd;
|
|
70
72
|
if (previousReviewSha) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
const separator = reviewDiffMode === "snapshot" ? " " : "...";
|
|
74
|
+
prDiffCmd = `git --no-pager diff ${previousReviewSha}${separator}HEAD --name-only`;
|
|
75
|
+
gitDiffCmd = `git --no-pager diff ${previousReviewSha}${separator}HEAD`;
|
|
76
|
+
logger.info(`${reviewDiffMode === "snapshot" ? "Snapshot" : "Incremental"} review: diffing from ${previousReviewSha.slice(0, 8)} to HEAD`);
|
|
74
77
|
} else if (localMode) {
|
|
75
78
|
prDiffCmd = `git --no-pager diff ${targetBranch} --name-only`;
|
|
76
79
|
gitDiffCmd = `git --no-pager diff ${targetBranch}`;
|
|
@@ -89,7 +92,7 @@ function buildPrReviewPrompt(opts) {
|
|
|
89
92
|
}
|
|
90
93
|
let diffExplanation;
|
|
91
94
|
if (previousReviewSha) {
|
|
92
|
-
diffExplanation = `**Incremental mode**: Showing only changes since the last hodor review (commit \`${previousReviewSha.slice(0, 8)}\`).`;
|
|
95
|
+
diffExplanation = reviewDiffMode === "snapshot" ? `**Snapshot delta mode**: The MR history was rewritten. This directly compares the last reviewed snapshot (commit \`${previousReviewSha.slice(0, 8)}\`) with the current HEAD; it does not imply ancestry.` : `**Incremental mode**: Showing only changes since the last hodor review (commit \`${previousReviewSha.slice(0, 8)}\`).`;
|
|
93
96
|
} else if (diffBaseSha) {
|
|
94
97
|
diffExplanation = `**GitLab CI Advantage**: This uses GitLab's pre-calculated merge base SHA (\`CI_MERGE_REQUEST_DIFF_BASE_SHA\`), which matches exactly what the GitLab UI shows. This is more reliable than three-dot syntax because it handles force pushes, rebases, and messy histories correctly.`;
|
|
95
98
|
} else {
|
|
@@ -98,25 +101,20 @@ function buildPrReviewPrompt(opts) {
|
|
|
98
101
|
const { contextSection, notesSection, reminderSection } = buildMrSections(mrMetadata);
|
|
99
102
|
let incrementalSection = "";
|
|
100
103
|
if (previousReviewSha) {
|
|
101
|
-
incrementalSection = `## Incremental Review Mode
|
|
104
|
+
incrementalSection = `## ${reviewDiffMode === "snapshot" ? "Snapshot Delta" : "Incremental Review"} Mode
|
|
102
105
|
|
|
103
|
-
This is a follow-up review. A previous hodor review was done at commit \`${previousReviewSha.slice(0, 8)}\`. The diff below shows ONLY changes since that review. Your job is to review that delta, not the whole MR again.
|
|
104
|
-
|
|
105
|
-
Rules for incremental reviews:
|
|
106
|
-
1. Only report bugs introduced or still affected by the new delta.
|
|
107
|
-
2. Do not re-report issues that are already mentioned in existing notes unless the new delta changes the same code and the issue remains newly relevant.
|
|
108
|
-
3. If the delta is small and self-contained, decide from the embedded diff and submit the review without broad repository exploration.
|
|
109
|
-
4. For mechanical changes like route/path/string renames, verify the direct call sites or tests only when the diff itself leaves a concrete compatibility question.
|
|
110
|
-
5. If the delta does not introduce a production bug, submit no findings.
|
|
111
|
-
|
|
112
|
-
`;
|
|
106
|
+
This is a follow-up review. A previous hodor review was done at commit \`${previousReviewSha.slice(0, 8)}\`. ` + (reviewDiffMode === "snapshot" ? "The branch history was rewritten, so the diff below compares that reviewed snapshot directly with the current HEAD. " : "The diff below shows ONLY changes since that review. ") + "Your job is to review that delta, not the whole MR again.\n\nRules for incremental reviews:\n1. Only report bugs introduced or still affected by the new delta.\n2. Do not re-report issues that are already mentioned in existing notes unless the new delta changes the same code and the issue remains newly relevant.\n3. If the delta is small and self-contained, decide from the embedded diff and submit the review without broad repository exploration.\n4. For mechanical changes like route/path/string renames, verify the direct call sites or tests only when the diff itself leaves a concrete compatibility question.\n5. If the delta does not introduce a production bug, submit no findings.\n\n";
|
|
113
107
|
}
|
|
114
108
|
let embeddedDiffSection;
|
|
115
109
|
let diffFetchInstructions;
|
|
116
110
|
let reviewProcessSection;
|
|
117
111
|
let startInstruction;
|
|
118
112
|
if (embeddedDiff) {
|
|
119
|
-
|
|
113
|
+
const changedFileManifest = changedFiles.length > 0 ? `
|
|
114
|
+
Changed files (${changedFiles.length}):
|
|
115
|
+
${changedFiles.map((file) => `- \`${file}\``).join("\n")}
|
|
116
|
+
` : "";
|
|
117
|
+
embeddedDiffSection = "## Full Diff (Pre-fetched)\n\nThe complete diff for this PR is provided below. Analyze it directly. Do not run another command to list changed files. Use `read` or `grep` only if you need additional file context beyond what the diff shows.\n" + changedFileManifest + "\n````diff\n" + embeddedDiff + "\n````\n";
|
|
120
118
|
diffFetchInstructions = `## Review the Diff Above
|
|
121
119
|
|
|
122
120
|
### Critical Rules
|
|
@@ -127,7 +125,7 @@ Rules for incremental reviews:
|
|
|
127
125
|
- NEVER flag "dependency version downgrade" (branch not rebased)
|
|
128
126
|
- NEVER compare entire codebase to ${targetBranch} - DIFF ONLY
|
|
129
127
|
`;
|
|
130
|
-
reviewProcessSection = "## Review Process\n\n1. Analyze the embedded diff above thoroughly\n2. Use `grep` to search for patterns when needed\n3. Use
|
|
128
|
+
reviewProcessSection = "## Review Process\n\n1. Analyze the embedded diff above thoroughly\n2. Use `grep` to search for patterns when needed\n3. Use bounded line-range reads when surrounding context is essential; avoid reading entire large files\n4. Do not repeat a diff, grep, or read operation whose result is already in context\n5. Submit your review using `submit_review`\n";
|
|
131
129
|
startInstruction = previousReviewSha ? "Analyze only the incremental diff provided above. If it is self-contained, submit your review without extra tool calls." : "Analyze the diff provided above, then submit your review using `submit_review`.";
|
|
132
130
|
} else {
|
|
133
131
|
embeddedDiffSection = "";
|
|
@@ -266,7 +264,8 @@ function normalizeLabelNames(rawLabels) {
|
|
|
266
264
|
}
|
|
267
265
|
|
|
268
266
|
// src/model.ts
|
|
269
|
-
import { getEnvApiKey
|
|
267
|
+
import { getEnvApiKey } from "@earendil-works/pi-ai/compat";
|
|
268
|
+
import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
270
269
|
var PROVIDER_ALIASES = {
|
|
271
270
|
bedrock: "amazon-bedrock"
|
|
272
271
|
};
|
|
@@ -277,7 +276,7 @@ function parseModelString(model) {
|
|
|
277
276
|
if (parts.length >= 2) {
|
|
278
277
|
const first = parts[0].toLowerCase();
|
|
279
278
|
const provider = PROVIDER_ALIASES[first] ?? first;
|
|
280
|
-
const knownProviders = new Set(
|
|
279
|
+
const knownProviders = new Set(getBuiltinProviders());
|
|
281
280
|
if (provider === "amazon-bedrock") {
|
|
282
281
|
let modelId = parts.slice(1).join("/");
|
|
283
282
|
if (modelId.startsWith("converse/")) {
|
|
@@ -317,6 +316,8 @@ function mapReasoningEffort(effort) {
|
|
|
317
316
|
return "high";
|
|
318
317
|
case "xhigh":
|
|
319
318
|
return "xhigh";
|
|
319
|
+
case "max":
|
|
320
|
+
return "max";
|
|
320
321
|
default:
|
|
321
322
|
return void 0;
|
|
322
323
|
}
|
|
@@ -326,8 +327,25 @@ function normalizeModelMatchValue(value) {
|
|
|
326
327
|
}
|
|
327
328
|
function getDefaultReasoningEffortForModel(model) {
|
|
328
329
|
const values = [model.id, model.name].filter((value) => Boolean(value));
|
|
329
|
-
const
|
|
330
|
-
return
|
|
330
|
+
const isAdaptiveOpus = values.map(normalizeModelMatchValue).some((value) => value.includes("opus-4-7") || value.includes("opus-4-8"));
|
|
331
|
+
return isAdaptiveOpus ? "xhigh" : void 0;
|
|
332
|
+
}
|
|
333
|
+
var HIGH_RISK_PATH_RE = /(?:^|\/)(?:migrations?|schema|auth|security|permissions?|crypto|iam)(?:\/|\.|$)|\.tf$/im;
|
|
334
|
+
var HIGH_RISK_CHANGE_RE = /^\+.*\b(?:authorization|authentication|permission|transaction|mutex|semaphore|encrypt|decrypt|credential|secret)\b/im;
|
|
335
|
+
function selectReasoningEffort(opts) {
|
|
336
|
+
const requested = mapReasoningEffort(opts.requested);
|
|
337
|
+
if (requested) return requested;
|
|
338
|
+
const modelDefault = opts.modelDefault;
|
|
339
|
+
if (modelDefault !== "xhigh") return modelDefault;
|
|
340
|
+
if (opts.forcedFull) return modelDefault;
|
|
341
|
+
const risky = Boolean(
|
|
342
|
+
opts.diff && (HIGH_RISK_PATH_RE.test(opts.diff) || HIGH_RISK_CHANGE_RE.test(opts.diff))
|
|
343
|
+
);
|
|
344
|
+
if (risky) return modelDefault;
|
|
345
|
+
if (opts.mode === "incremental" || opts.mode === "snapshot") return "high";
|
|
346
|
+
const changedLines = (opts.stats?.additions ?? 0) + (opts.stats?.deletions ?? 0);
|
|
347
|
+
if (opts.stats && opts.stats.files <= 10 && changedLines <= 500) return "high";
|
|
348
|
+
return modelDefault;
|
|
331
349
|
}
|
|
332
350
|
function getApiKey(model) {
|
|
333
351
|
const llmKey = process.env.LLM_API_KEY;
|
|
@@ -360,9 +378,12 @@ function formatDuration(seconds) {
|
|
|
360
378
|
}
|
|
361
379
|
function formatMetricsMarkdown(metrics) {
|
|
362
380
|
const totalInput = metrics.inputTokens + metrics.cacheReadTokens;
|
|
363
|
-
const parts = [`in \`${tok(totalInput)}\``];
|
|
381
|
+
const parts = [`in \`${tok(totalInput)}\``, `fresh \`${tok(metrics.inputTokens)}\``];
|
|
364
382
|
if (metrics.cacheReadTokens > 0) {
|
|
365
|
-
parts.push(`
|
|
383
|
+
parts.push(`cache read \`${tok(metrics.cacheReadTokens)}\``);
|
|
384
|
+
}
|
|
385
|
+
if (metrics.cacheWriteTokens > 0) {
|
|
386
|
+
parts.push(`cache write \`${tok(metrics.cacheWriteTokens)}\``);
|
|
366
387
|
}
|
|
367
388
|
parts.push(`out \`${tok(metrics.outputTokens)}\``);
|
|
368
389
|
const lines = [
|
|
@@ -387,6 +408,9 @@ function printMetrics(metrics, stream = process.stderr) {
|
|
|
387
408
|
const hitPct = (metrics.cacheReadTokens / totalInput * 100).toFixed(0);
|
|
388
409
|
tokenLine += dim(` (${tok(metrics.cacheReadTokens)} cached ${hitPct}% \xB7 ${tok(metrics.inputTokens)} fresh)`);
|
|
389
410
|
}
|
|
411
|
+
if (metrics.cacheWriteTokens > 0) {
|
|
412
|
+
tokenLine += dim(` \xB7 ${tok(metrics.cacheWriteTokens)} cache write`);
|
|
413
|
+
}
|
|
390
414
|
tokenLine += ` ${bold(tok(metrics.outputTokens))} out`;
|
|
391
415
|
tokenLine += dim(` (${tok(metrics.totalTokens)} total)`);
|
|
392
416
|
write(tokenLine);
|
|
@@ -444,6 +468,21 @@ async function pushMetrics(opts) {
|
|
|
444
468
|
`# HELP hodor_review_duration_seconds Review duration in seconds`,
|
|
445
469
|
`# TYPE hodor_review_duration_seconds gauge`,
|
|
446
470
|
`hodor_review_duration_seconds${labelSuffix} ${metrics.durationSeconds}`,
|
|
471
|
+
`# HELP hodor_review_diff_files Number of files included in the reviewed diff`,
|
|
472
|
+
`# TYPE hodor_review_diff_files gauge`,
|
|
473
|
+
`hodor_review_diff_files${labelSuffix} ${metrics.diffFiles ?? 0}`,
|
|
474
|
+
`# HELP hodor_review_diff_additions Added lines included in the reviewed diff`,
|
|
475
|
+
`# TYPE hodor_review_diff_additions gauge`,
|
|
476
|
+
`hodor_review_diff_additions${labelSuffix} ${metrics.diffAdditions ?? 0}`,
|
|
477
|
+
`# HELP hodor_review_diff_deletions Deleted lines included in the reviewed diff`,
|
|
478
|
+
`# TYPE hodor_review_diff_deletions gauge`,
|
|
479
|
+
`hodor_review_diff_deletions${labelSuffix} ${metrics.diffDeletions ?? 0}`,
|
|
480
|
+
`# HELP hodor_review_diff_bytes Size of the reviewed diff in bytes`,
|
|
481
|
+
`# TYPE hodor_review_diff_bytes gauge`,
|
|
482
|
+
`hodor_review_diff_bytes${labelSuffix} ${metrics.diffBytes ?? 0}`,
|
|
483
|
+
`# HELP hodor_review_reused Whether an existing identical-HEAD review was reused`,
|
|
484
|
+
`# TYPE hodor_review_reused gauge`,
|
|
485
|
+
`hodor_review_reused${labelSuffix} ${metrics.reused ? 1 : 0}`,
|
|
447
486
|
""
|
|
448
487
|
];
|
|
449
488
|
const body = lines.join("\n");
|
|
@@ -824,12 +863,15 @@ async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
|
|
|
824
863
|
);
|
|
825
864
|
}
|
|
826
865
|
async function postReviewComment(opts) {
|
|
827
|
-
const { prUrl, reviewText, model, metricsFooter, headSha } = opts;
|
|
866
|
+
const { prUrl, reviewText, model, metricsFooter, headSha, cacheMarker } = opts;
|
|
828
867
|
const platform = detectPlatform(prUrl);
|
|
829
868
|
const parsed = parsePrUrl(prUrl);
|
|
830
869
|
let body = reviewText;
|
|
831
870
|
if (headSha) body = `<!-- hodor:sha:${headSha} -->
|
|
832
871
|
${body}`;
|
|
872
|
+
if (cacheMarker) body = body.replace("\n", `
|
|
873
|
+
${cacheMarker}
|
|
874
|
+
`);
|
|
833
875
|
if (model) body += `
|
|
834
876
|
---
|
|
835
877
|
|
|
@@ -890,7 +932,9 @@ async function postReviewStructured(opts) {
|
|
|
890
932
|
commitStatus = false,
|
|
891
933
|
headSha,
|
|
892
934
|
workspacePath,
|
|
893
|
-
reconcileDiscussions = false
|
|
935
|
+
reconcileDiscussions = false,
|
|
936
|
+
cacheMarker,
|
|
937
|
+
skipSummary = false
|
|
894
938
|
} = opts;
|
|
895
939
|
const platform = detectPlatform(prUrl);
|
|
896
940
|
if (platform !== "gitlab" || reviewStyle === "summary") {
|
|
@@ -899,7 +943,8 @@ async function postReviewStructured(opts) {
|
|
|
899
943
|
reviewText: renderMarkdown(review),
|
|
900
944
|
model,
|
|
901
945
|
metricsFooter,
|
|
902
|
-
headSha
|
|
946
|
+
headSha,
|
|
947
|
+
cacheMarker
|
|
903
948
|
});
|
|
904
949
|
}
|
|
905
950
|
const parsed = parsePrUrl(prUrl);
|
|
@@ -920,7 +965,8 @@ async function postReviewStructured(opts) {
|
|
|
920
965
|
reviewText: renderMarkdown(review),
|
|
921
966
|
model,
|
|
922
967
|
metricsFooter,
|
|
923
|
-
headSha
|
|
968
|
+
headSha,
|
|
969
|
+
cacheMarker
|
|
924
970
|
});
|
|
925
971
|
}
|
|
926
972
|
const existingByFingerprint = /* @__PURE__ */ new Map();
|
|
@@ -1015,10 +1061,13 @@ ${finding.suggestion}
|
|
|
1015
1061
|
}
|
|
1016
1062
|
}
|
|
1017
1063
|
let summaryPosted = false;
|
|
1018
|
-
if (reviewStyle === "hybrid" || review.findings.length === 0) {
|
|
1064
|
+
if (!skipSummary && (reviewStyle === "hybrid" || review.findings.length === 0)) {
|
|
1019
1065
|
let summaryBody = renderSummaryMarkdown(review);
|
|
1020
1066
|
if (headSha) summaryBody = `<!-- hodor:sha:${headSha} -->
|
|
1021
1067
|
${summaryBody}`;
|
|
1068
|
+
if (cacheMarker) summaryBody = summaryBody.replace("\n", `
|
|
1069
|
+
${cacheMarker}
|
|
1070
|
+
`);
|
|
1022
1071
|
if (model) summaryBody += `
|
|
1023
1072
|
---
|
|
1024
1073
|
|
|
@@ -1053,7 +1102,7 @@ ${metricsFooter}`;
|
|
|
1053
1102
|
}
|
|
1054
1103
|
}
|
|
1055
1104
|
let reconciledDiscussions = 0;
|
|
1056
|
-
const baseDeliveryComplete = reviewStyle === "hybrid" ? summaryPosted && inlineFailed === 0 && (inlineCreated === 0 || draftsPublished) : inlineFailed === 0 && (review.findings.length === 0 ? summaryPosted : inlineCreated === 0 || draftsPublished);
|
|
1105
|
+
const baseDeliveryComplete = reviewStyle === "hybrid" ? (summaryPosted || skipSummary) && inlineFailed === 0 && (inlineCreated === 0 || draftsPublished) : inlineFailed === 0 && (review.findings.length === 0 ? summaryPosted : inlineCreated === 0 || draftsPublished);
|
|
1057
1106
|
if (reconcileDiscussions && baseDeliveryComplete) {
|
|
1058
1107
|
const currentFingerprints = new Set(
|
|
1059
1108
|
review.findings.map((finding) => getFindingFingerprint(finding, workspacePath))
|
|
@@ -1114,6 +1163,7 @@ async function fetchGithubPrInfo(owner, repo, prNumber) {
|
|
|
1114
1163
|
"changedFiles",
|
|
1115
1164
|
"labels",
|
|
1116
1165
|
"comments",
|
|
1166
|
+
"reviews",
|
|
1117
1167
|
"state",
|
|
1118
1168
|
"isDraft",
|
|
1119
1169
|
"createdAt",
|
|
@@ -1141,6 +1191,7 @@ function normalizeGithubMetadata(raw) {
|
|
|
1141
1191
|
const author = raw.author ?? {};
|
|
1142
1192
|
const labels = raw.labels ?? [];
|
|
1143
1193
|
const comments = raw.comments;
|
|
1194
|
+
const reviews = raw.reviews;
|
|
1144
1195
|
return {
|
|
1145
1196
|
title: raw.title,
|
|
1146
1197
|
description: raw.body ?? "",
|
|
@@ -1152,7 +1203,10 @@ function normalizeGithubMetadata(raw) {
|
|
|
1152
1203
|
username: author.login ?? author.name,
|
|
1153
1204
|
name: author.name
|
|
1154
1205
|
},
|
|
1155
|
-
Notes:
|
|
1206
|
+
Notes: [
|
|
1207
|
+
...githubCommentsToNotes(comments),
|
|
1208
|
+
...githubCommentsToNotes(reviews)
|
|
1209
|
+
]
|
|
1156
1210
|
};
|
|
1157
1211
|
}
|
|
1158
1212
|
function githubCommentsToNotes(comments) {
|
|
@@ -1731,6 +1785,8 @@ var REVIEW_SYSTEM_PROMPT = `You are a code review agent. You analyze pull reques
|
|
|
1731
1785
|
* Use the grep and find tools for code search \u2014 do not shell out to grep/find.
|
|
1732
1786
|
* Prefer \`git diff\` to see changes for specific files. Only use read when you need surrounding context that the diff alone cannot provide.
|
|
1733
1787
|
* Do not use cat/head/tail to read files.
|
|
1788
|
+
* Prefer bounded line-range reads. Do not read an entire large file when a changed hunk, symbol, or caller range is sufficient.
|
|
1789
|
+
* Do not repeat diff, grep, or read operations whose results are already in context.
|
|
1734
1790
|
* Keep reasoning proportional to the task. A small diff does not need extensive deliberation.
|
|
1735
1791
|
</EFFICIENCY>`;
|
|
1736
1792
|
|
|
@@ -1759,28 +1815,68 @@ function getHodorReviewShaCandidates(notes) {
|
|
|
1759
1815
|
});
|
|
1760
1816
|
return [...new Set(candidates.map(({ sha }) => sha))];
|
|
1761
1817
|
}
|
|
1762
|
-
async function
|
|
1818
|
+
async function findLatestReviewBase(notes, workspacePath) {
|
|
1763
1819
|
const candidates = getHodorReviewShaCandidates(notes);
|
|
1764
1820
|
if (candidates.length === 0) return null;
|
|
1765
1821
|
logger.info(`Found ${candidates.length} previous Hodor review marker(s)`);
|
|
1766
1822
|
for (const sha of candidates) {
|
|
1767
1823
|
try {
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1824
|
+
let objectType;
|
|
1825
|
+
try {
|
|
1826
|
+
({ stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
|
|
1827
|
+
cwd: workspacePath
|
|
1828
|
+
}));
|
|
1829
|
+
} catch {
|
|
1830
|
+
await exec("git", ["fetch", "--quiet", "origin", sha], {
|
|
1831
|
+
cwd: workspacePath
|
|
1832
|
+
});
|
|
1833
|
+
({ stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
|
|
1834
|
+
cwd: workspacePath
|
|
1835
|
+
}));
|
|
1836
|
+
}
|
|
1771
1837
|
if (objectType.trim() !== "commit") throw new Error("not a commit");
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1838
|
+
try {
|
|
1839
|
+
await exec("git", ["merge-base", "--is-ancestor", sha, "HEAD"], {
|
|
1840
|
+
cwd: workspacePath
|
|
1841
|
+
});
|
|
1842
|
+
return { sha, mode: "incremental" };
|
|
1843
|
+
} catch {
|
|
1844
|
+
logger.info(
|
|
1845
|
+
`Previous review SHA ${sha.slice(0, 8)} is not an ancestor; using snapshot delta`
|
|
1846
|
+
);
|
|
1847
|
+
return { sha, mode: "snapshot" };
|
|
1848
|
+
}
|
|
1776
1849
|
} catch {
|
|
1777
1850
|
logger.info(
|
|
1778
|
-
`Skipping previous review SHA ${sha.slice(0, 8)};
|
|
1851
|
+
`Skipping previous review SHA ${sha.slice(0, 8)}; commit is unavailable`
|
|
1779
1852
|
);
|
|
1780
1853
|
}
|
|
1781
1854
|
}
|
|
1782
1855
|
return null;
|
|
1783
1856
|
}
|
|
1857
|
+
function getDiffStats(diff) {
|
|
1858
|
+
let files = 0;
|
|
1859
|
+
let additions = 0;
|
|
1860
|
+
let deletions = 0;
|
|
1861
|
+
for (const line of diff.split("\n")) {
|
|
1862
|
+
if (line.startsWith("diff --git ")) files++;
|
|
1863
|
+
else if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
|
1864
|
+
else if (line.startsWith("-") && !line.startsWith("---")) deletions++;
|
|
1865
|
+
}
|
|
1866
|
+
return {
|
|
1867
|
+
files,
|
|
1868
|
+
additions,
|
|
1869
|
+
deletions,
|
|
1870
|
+
bytes: Buffer.byteLength(diff, "utf-8")
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
function getChangedFiles(diff) {
|
|
1874
|
+
const files = [];
|
|
1875
|
+
for (const match of diff.matchAll(/^diff --git a\/(.*?) b\/(.*?)$/gm)) {
|
|
1876
|
+
files.push(match[2]);
|
|
1877
|
+
}
|
|
1878
|
+
return [...new Set(files)];
|
|
1879
|
+
}
|
|
1784
1880
|
var DIFF_SKIP_PATTERNS = [
|
|
1785
1881
|
/(?:^|\/)testdata\//,
|
|
1786
1882
|
/(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|go\.sum|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock)$/,
|
|
@@ -1806,6 +1902,66 @@ function filterEmbeddedDiff(rawDiff) {
|
|
|
1806
1902
|
return { filtered: kept.join(""), skippedFiles };
|
|
1807
1903
|
}
|
|
1808
1904
|
|
|
1905
|
+
// src/review-cache.ts
|
|
1906
|
+
import { createHash as createHash2 } from "crypto";
|
|
1907
|
+
import { gzipSync, gunzipSync } from "zlib";
|
|
1908
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
1909
|
+
var REVIEW_PROMPT_VERSION = "2026-07-16.1";
|
|
1910
|
+
var CACHE_MARKER_RE = /<!--\s*hodor:cache:v1:([A-Za-z0-9_-]+)\s*-->/;
|
|
1911
|
+
function getReviewCacheKey(opts) {
|
|
1912
|
+
let promptFileContents = "";
|
|
1913
|
+
if (opts.promptFile) {
|
|
1914
|
+
promptFileContents = readFileSync3(opts.promptFile, "utf-8");
|
|
1915
|
+
}
|
|
1916
|
+
return createHash2("sha256").update(JSON.stringify({
|
|
1917
|
+
version: REVIEW_PROMPT_VERSION,
|
|
1918
|
+
headSha: opts.headSha,
|
|
1919
|
+
model: opts.model,
|
|
1920
|
+
// "auto" deliberately stays stable when an identical HEAD changes from
|
|
1921
|
+
// a full review to an empty incremental diff on a pipeline retry.
|
|
1922
|
+
reasoning: opts.requestedReasoningEffort?.toLowerCase() ?? "auto",
|
|
1923
|
+
customPrompt: opts.customPrompt ?? "",
|
|
1924
|
+
promptFileContents
|
|
1925
|
+
})).digest("hex");
|
|
1926
|
+
}
|
|
1927
|
+
function buildReviewCacheMarker(key, review, workspacePath) {
|
|
1928
|
+
const portableReview = {
|
|
1929
|
+
...review,
|
|
1930
|
+
findings: review.findings.map((finding) => ({
|
|
1931
|
+
...finding,
|
|
1932
|
+
code_location: {
|
|
1933
|
+
...finding.code_location,
|
|
1934
|
+
absolute_file_path: `/workspace/${relativizeWorkspacePath(
|
|
1935
|
+
finding.code_location.absolute_file_path,
|
|
1936
|
+
workspacePath ?? void 0
|
|
1937
|
+
)}`
|
|
1938
|
+
}
|
|
1939
|
+
}))
|
|
1940
|
+
};
|
|
1941
|
+
const payload = { key, review: portableReview };
|
|
1942
|
+
const encoded = gzipSync(JSON.stringify(payload)).toString("base64url");
|
|
1943
|
+
return `<!-- hodor:cache:v1:${encoded} -->`;
|
|
1944
|
+
}
|
|
1945
|
+
function findCachedReview(notes, key) {
|
|
1946
|
+
if (!notes) return null;
|
|
1947
|
+
const newestFirst = [...notes].sort(
|
|
1948
|
+
(a, b) => Date.parse(b.created_at ?? "") - Date.parse(a.created_at ?? "")
|
|
1949
|
+
);
|
|
1950
|
+
for (const note of newestFirst) {
|
|
1951
|
+
const encoded = note.body?.match(CACHE_MARKER_RE)?.[1];
|
|
1952
|
+
if (!encoded || encoded.length > 5e5) continue;
|
|
1953
|
+
try {
|
|
1954
|
+
const payload = JSON.parse(
|
|
1955
|
+
gunzipSync(Buffer.from(encoded, "base64url"), { maxOutputLength: 1e6 }).toString("utf-8")
|
|
1956
|
+
);
|
|
1957
|
+
if (payload.key !== key || !payload.review) continue;
|
|
1958
|
+
return validateReviewOutput(payload.review);
|
|
1959
|
+
} catch {
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
return null;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1809
1965
|
// src/review-recovery.ts
|
|
1810
1966
|
import { Value } from "@sinclair/typebox/value";
|
|
1811
1967
|
var SUBMIT_REVIEW_RECOVERY_ATTEMPTS = 2;
|
|
@@ -1963,10 +2119,7 @@ async function reviewPr(opts) {
|
|
|
1963
2119
|
);
|
|
1964
2120
|
}
|
|
1965
2121
|
}
|
|
1966
|
-
const
|
|
1967
|
-
if (!reasoningEffort && thinkingLevel) {
|
|
1968
|
-
logger.info(`Default reasoning effort for ${piModel.name}: ${thinkingLevel}`);
|
|
1969
|
-
}
|
|
2122
|
+
const modelDefaultThinkingLevel = getDefaultReasoningEffortForModel(piModel);
|
|
1970
2123
|
if (parsed.provider !== "amazon-bedrock") {
|
|
1971
2124
|
const resolvedKey = await modelRegistry.getApiKeyForProvider(parsed.provider);
|
|
1972
2125
|
if (!resolvedKey) {
|
|
@@ -2072,26 +2225,84 @@ async function reviewPr(opts) {
|
|
|
2072
2225
|
logger.warn(`Failed to fetch Gitea metadata: ${err}`);
|
|
2073
2226
|
}
|
|
2074
2227
|
}
|
|
2075
|
-
const previousReviewSha = full ? null : await findLatestValidReviewSha(mrMetadata?.Notes, workspacePath);
|
|
2076
|
-
if (full) {
|
|
2077
|
-
logger.info("Full review mode: ignoring previous hodor reviews, diffing entire source-vs-target range");
|
|
2078
|
-
} else if (previousReviewSha) {
|
|
2079
|
-
logger.info(`Incremental mode: previous review at ${previousReviewSha.slice(0, 8)}`);
|
|
2080
|
-
}
|
|
2081
2228
|
let headSha = null;
|
|
2082
2229
|
if (!localMode) {
|
|
2083
2230
|
const { stdout: headShaRaw } = await exec("git", ["rev-parse", "HEAD"], { cwd: workspacePath });
|
|
2084
2231
|
headSha = headShaRaw.trim();
|
|
2085
2232
|
}
|
|
2233
|
+
let reviewCacheKey = null;
|
|
2234
|
+
if (!localMode && !full && headSha) {
|
|
2235
|
+
reviewCacheKey = getReviewCacheKey({
|
|
2236
|
+
headSha,
|
|
2237
|
+
model,
|
|
2238
|
+
requestedReasoningEffort: reasoningEffort,
|
|
2239
|
+
customPrompt,
|
|
2240
|
+
promptFile
|
|
2241
|
+
});
|
|
2242
|
+
const cachedReview = findCachedReview(mrMetadata?.Notes, reviewCacheKey);
|
|
2243
|
+
if (cachedReview) {
|
|
2244
|
+
logger.info(`Reusing cached Hodor review for HEAD ${headSha.slice(0, 8)}`);
|
|
2245
|
+
const metrics2 = {
|
|
2246
|
+
inputTokens: 0,
|
|
2247
|
+
outputTokens: 0,
|
|
2248
|
+
cacheReadTokens: 0,
|
|
2249
|
+
cacheWriteTokens: 0,
|
|
2250
|
+
totalTokens: 0,
|
|
2251
|
+
cost: 0,
|
|
2252
|
+
turns: 0,
|
|
2253
|
+
toolCalls: 0,
|
|
2254
|
+
durationSeconds: 0,
|
|
2255
|
+
reviewMode: "reused",
|
|
2256
|
+
reasoningEffort: reasoningEffort ?? "auto",
|
|
2257
|
+
diffFiles: 0,
|
|
2258
|
+
diffAdditions: 0,
|
|
2259
|
+
diffDeletions: 0,
|
|
2260
|
+
diffBytes: 0,
|
|
2261
|
+
reused: true
|
|
2262
|
+
};
|
|
2263
|
+
logger.info(`Review telemetry: ${JSON.stringify({
|
|
2264
|
+
reviewMode: metrics2.reviewMode,
|
|
2265
|
+
reasoningEffort: metrics2.reasoningEffort,
|
|
2266
|
+
reused: true,
|
|
2267
|
+
headSha: headSha.slice(0, 12),
|
|
2268
|
+
findings: cachedReview.findings.length
|
|
2269
|
+
})}`);
|
|
2270
|
+
printMetrics(metrics2);
|
|
2271
|
+
return {
|
|
2272
|
+
review: cachedReview,
|
|
2273
|
+
metricsFooter: includeMetricsFooter ? formatMetricsMarkdown(metrics2) : null,
|
|
2274
|
+
headSha,
|
|
2275
|
+
metrics: metrics2,
|
|
2276
|
+
workspacePath,
|
|
2277
|
+
cacheMarker: null,
|
|
2278
|
+
reusedReview: true
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
const previousReviewBase = full || localMode ? null : await findLatestReviewBase(mrMetadata?.Notes, workspacePath);
|
|
2283
|
+
const previousReviewSha = previousReviewBase?.sha ?? null;
|
|
2284
|
+
let reviewMode = localMode ? "local" : previousReviewBase?.mode ?? "full";
|
|
2285
|
+
if (full) {
|
|
2286
|
+
reviewMode = "full";
|
|
2287
|
+
logger.info("Full review mode: ignoring previous hodor reviews, diffing entire source-vs-target range");
|
|
2288
|
+
} else if (previousReviewBase) {
|
|
2289
|
+
logger.info(`${previousReviewBase.mode === "snapshot" ? "Snapshot delta" : "Incremental"} mode: previous review at ${previousReviewSha?.slice(0, 8)}`);
|
|
2290
|
+
}
|
|
2086
2291
|
const MAX_EMBED_BYTES = 200 * 1024;
|
|
2087
2292
|
let embeddedDiff = null;
|
|
2293
|
+
let reviewDiff = null;
|
|
2294
|
+
let diffStats = null;
|
|
2295
|
+
let changedFiles = [];
|
|
2088
2296
|
try {
|
|
2089
|
-
const diffArgs = previousReviewSha ? ["--no-pager", "diff", `${previousReviewSha}...HEAD`] : diffBaseSha ? ["--no-pager", "diff", diffBaseSha, "HEAD"] : localMode ? ["--no-pager", "diff", targetBranch] : ["--no-pager", "diff", `origin/${targetBranch}...HEAD`];
|
|
2297
|
+
const diffArgs = previousReviewSha ? previousReviewBase?.mode === "snapshot" ? ["--no-pager", "diff", previousReviewSha, "HEAD"] : ["--no-pager", "diff", `${previousReviewSha}...HEAD`] : diffBaseSha ? ["--no-pager", "diff", diffBaseSha, "HEAD"] : localMode ? ["--no-pager", "diff", targetBranch] : ["--no-pager", "diff", `origin/${targetBranch}...HEAD`];
|
|
2090
2298
|
const { stdout: rawDiff } = await exec("git", diffArgs, { cwd: workspacePath });
|
|
2091
2299
|
const { filtered: filteredDiff, skippedFiles } = filterEmbeddedDiff(rawDiff);
|
|
2092
2300
|
if (skippedFiles.length > 0) {
|
|
2093
2301
|
logger.info(`Filtered ${skippedFiles.length} file(s) from embedded diff: ${skippedFiles.join(", ")}`);
|
|
2094
2302
|
}
|
|
2303
|
+
reviewDiff = filteredDiff;
|
|
2304
|
+
diffStats = getDiffStats(filteredDiff);
|
|
2305
|
+
changedFiles = getChangedFiles(filteredDiff);
|
|
2095
2306
|
if (Buffer.byteLength(filteredDiff, "utf-8") <= MAX_EMBED_BYTES) {
|
|
2096
2307
|
embeddedDiff = filteredDiff;
|
|
2097
2308
|
logger.info(`Embedding diff in prompt (${Buffer.byteLength(filteredDiff, "utf-8")} bytes, raw: ${Buffer.byteLength(rawDiff, "utf-8")} bytes)`);
|
|
@@ -2101,6 +2312,17 @@ async function reviewPr(opts) {
|
|
|
2101
2312
|
} catch (err) {
|
|
2102
2313
|
logger.warn(`Failed to pre-fetch diff, falling back to command mode: ${err}`);
|
|
2103
2314
|
}
|
|
2315
|
+
const thinkingLevel = selectReasoningEffort({
|
|
2316
|
+
requested: reasoningEffort,
|
|
2317
|
+
modelDefault: modelDefaultThinkingLevel,
|
|
2318
|
+
mode: reviewMode,
|
|
2319
|
+
forcedFull: full,
|
|
2320
|
+
diff: reviewDiff,
|
|
2321
|
+
stats: diffStats
|
|
2322
|
+
});
|
|
2323
|
+
if (thinkingLevel) {
|
|
2324
|
+
logger.info(`Reasoning effort for ${piModel.name}: ${thinkingLevel}${reasoningEffort ? " (explicit)" : " (adaptive)"}`);
|
|
2325
|
+
}
|
|
2104
2326
|
const prompt = buildPrReviewPrompt({
|
|
2105
2327
|
prUrl: prUrl ?? `local diff (against ${targetBranch})`,
|
|
2106
2328
|
platform,
|
|
@@ -2111,6 +2333,8 @@ async function reviewPr(opts) {
|
|
|
2111
2333
|
customPromptFile: promptFile,
|
|
2112
2334
|
embeddedDiff,
|
|
2113
2335
|
previousReviewSha,
|
|
2336
|
+
reviewDiffMode: reviewMode,
|
|
2337
|
+
changedFiles,
|
|
2114
2338
|
localMode
|
|
2115
2339
|
});
|
|
2116
2340
|
const startTime = Date.now();
|
|
@@ -2342,14 +2566,47 @@ async function reviewPr(opts) {
|
|
|
2342
2566
|
cost,
|
|
2343
2567
|
turns: turnCount,
|
|
2344
2568
|
toolCalls: toolCallCount,
|
|
2345
|
-
durationSeconds: Math.round(durationSeconds)
|
|
2569
|
+
durationSeconds: Math.round(durationSeconds),
|
|
2570
|
+
reviewMode,
|
|
2571
|
+
reasoningEffort: thinkingLevel ?? "none",
|
|
2572
|
+
diffFiles: diffStats?.files ?? 0,
|
|
2573
|
+
diffAdditions: diffStats?.additions ?? 0,
|
|
2574
|
+
diffDeletions: diffStats?.deletions ?? 0,
|
|
2575
|
+
diffBytes: diffStats?.bytes ?? 0,
|
|
2576
|
+
reused: false
|
|
2346
2577
|
};
|
|
2578
|
+
logger.info(`Review telemetry: ${JSON.stringify({
|
|
2579
|
+
reviewMode: metrics.reviewMode,
|
|
2580
|
+
reasoningEffort: metrics.reasoningEffort,
|
|
2581
|
+
reused: false,
|
|
2582
|
+
diffFiles: metrics.diffFiles,
|
|
2583
|
+
diffAdditions: metrics.diffAdditions,
|
|
2584
|
+
diffDeletions: metrics.diffDeletions,
|
|
2585
|
+
diffBytes: metrics.diffBytes,
|
|
2586
|
+
turns: metrics.turns,
|
|
2587
|
+
toolCalls: metrics.toolCalls,
|
|
2588
|
+
inputTokens: metrics.inputTokens,
|
|
2589
|
+
cacheReadTokens: metrics.cacheReadTokens,
|
|
2590
|
+
cacheWriteTokens: metrics.cacheWriteTokens,
|
|
2591
|
+
outputTokens: metrics.outputTokens,
|
|
2592
|
+
cost: metrics.cost,
|
|
2593
|
+
findings: review.findings.length
|
|
2594
|
+
})}`);
|
|
2347
2595
|
printMetrics(metrics);
|
|
2348
2596
|
let metricsFooter = null;
|
|
2349
2597
|
if (includeMetricsFooter) {
|
|
2350
2598
|
metricsFooter = formatMetricsMarkdown(metrics);
|
|
2351
2599
|
}
|
|
2352
|
-
|
|
2600
|
+
const cacheMarker = reviewCacheKey ? buildReviewCacheMarker(reviewCacheKey, review, workspacePath) : null;
|
|
2601
|
+
return {
|
|
2602
|
+
review,
|
|
2603
|
+
metricsFooter,
|
|
2604
|
+
headSha,
|
|
2605
|
+
metrics,
|
|
2606
|
+
workspacePath,
|
|
2607
|
+
cacheMarker,
|
|
2608
|
+
reusedReview: false
|
|
2609
|
+
};
|
|
2353
2610
|
} finally {
|
|
2354
2611
|
activeSession?.dispose();
|
|
2355
2612
|
for (const [key, val] of Object.entries(envSnapshot)) {
|
|
@@ -2382,4 +2639,4 @@ export {
|
|
|
2382
2639
|
postReviewStructured,
|
|
2383
2640
|
reviewPr
|
|
2384
2641
|
};
|
|
2385
|
-
//# sourceMappingURL=chunk-
|
|
2642
|
+
//# sourceMappingURL=chunk-N7TSXVUE.js.map
|