@mrkaran/hodor 0.7.1 → 0.7.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/dist/{chunk-W7ZUJIFT.js → chunk-DALI4QRT.js} +21 -1
- package/dist/{chunk-W7ZUJIFT.js.map → chunk-DALI4QRT.js.map} +1 -1
- package/dist/{chunk-VERO4XYJ.js → chunk-GISFKKMM.js} +285 -150
- package/dist/chunk-GISFKKMM.js.map +1 -0
- package/dist/cli.js +3 -3
- package/dist/{gitlab-L3VGOT6Q.js → gitlab-JSVU4YFQ.js} +4 -2
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-VERO4XYJ.js.map +0 -1
- /package/dist/{gitlab-L3VGOT6Q.js.map → gitlab-JSVU4YFQ.js.map} +0 -0
|
@@ -10,12 +10,13 @@ import {
|
|
|
10
10
|
logger,
|
|
11
11
|
postGitlabCommitStatus,
|
|
12
12
|
postGitlabMrComment,
|
|
13
|
+
publishGitlabDraftNote,
|
|
13
14
|
renderMarkdown,
|
|
14
15
|
renderSummaryMarkdown,
|
|
15
16
|
resolveGitlabDiscussions,
|
|
16
17
|
summarizeGitlabNotes,
|
|
17
18
|
summarizeHodorNotes
|
|
18
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-DALI4QRT.js";
|
|
19
20
|
import {
|
|
20
21
|
relativizeWorkspacePath
|
|
21
22
|
} from "./chunk-AMUK6GDX.js";
|
|
@@ -30,6 +31,135 @@ function getTemplatePath(name) {
|
|
|
30
31
|
return resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates", name);
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
// src/review-diff.ts
|
|
35
|
+
var HODOR_REVIEW_SHA_RE = /^\s*<!--\s*hodor:sha:([a-f0-9]{40})\s*-->/i;
|
|
36
|
+
function getReviewDiffArgs(options) {
|
|
37
|
+
const {
|
|
38
|
+
platform,
|
|
39
|
+
targetBranch,
|
|
40
|
+
diffBaseSha,
|
|
41
|
+
previousReviewSha,
|
|
42
|
+
reviewDiffMode,
|
|
43
|
+
localMode = false
|
|
44
|
+
} = options;
|
|
45
|
+
const rebasedGitlabReview = platform === "gitlab" && reviewDiffMode === "snapshot";
|
|
46
|
+
if (previousReviewSha && !rebasedGitlabReview) {
|
|
47
|
+
return reviewDiffMode === "snapshot" ? ["--no-pager", "diff", previousReviewSha, "HEAD"] : ["--no-pager", "diff", `${previousReviewSha}...HEAD`];
|
|
48
|
+
}
|
|
49
|
+
if (localMode) return ["--no-pager", "diff", targetBranch];
|
|
50
|
+
if (diffBaseSha) return ["--no-pager", "diff", diffBaseSha, "HEAD"];
|
|
51
|
+
return ["--no-pager", "diff", `origin/${targetBranch}...HEAD`];
|
|
52
|
+
}
|
|
53
|
+
function getHodorReviewShaCandidates(notes) {
|
|
54
|
+
if (!notes || notes.length === 0) return [];
|
|
55
|
+
const candidates = [];
|
|
56
|
+
for (const [index, note] of notes.entries()) {
|
|
57
|
+
const match = note.body?.match(HODOR_REVIEW_SHA_RE);
|
|
58
|
+
if (!match) continue;
|
|
59
|
+
const createdAtMs = Date.parse(note.created_at ?? "");
|
|
60
|
+
candidates.push({
|
|
61
|
+
sha: match[1],
|
|
62
|
+
createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : null,
|
|
63
|
+
index
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
candidates.sort((a, b) => {
|
|
67
|
+
if (a.createdAtMs != null && b.createdAtMs != null && a.createdAtMs !== b.createdAtMs) {
|
|
68
|
+
return b.createdAtMs - a.createdAtMs;
|
|
69
|
+
}
|
|
70
|
+
if (a.createdAtMs != null && b.createdAtMs == null) return -1;
|
|
71
|
+
if (a.createdAtMs == null && b.createdAtMs != null) return 1;
|
|
72
|
+
return a.index - b.index;
|
|
73
|
+
});
|
|
74
|
+
return [...new Set(candidates.map(({ sha }) => sha))];
|
|
75
|
+
}
|
|
76
|
+
async function findLatestReviewBase(notes, workspacePath) {
|
|
77
|
+
const candidates = getHodorReviewShaCandidates(notes);
|
|
78
|
+
if (candidates.length === 0) return null;
|
|
79
|
+
logger.info(`Found ${candidates.length} previous Hodor review marker(s)`);
|
|
80
|
+
for (const sha of candidates) {
|
|
81
|
+
try {
|
|
82
|
+
let objectType;
|
|
83
|
+
try {
|
|
84
|
+
({ stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
|
|
85
|
+
cwd: workspacePath
|
|
86
|
+
}));
|
|
87
|
+
} catch {
|
|
88
|
+
await exec("git", ["fetch", "--quiet", "origin", sha], {
|
|
89
|
+
cwd: workspacePath
|
|
90
|
+
});
|
|
91
|
+
({ stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
|
|
92
|
+
cwd: workspacePath
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
if (objectType.trim() !== "commit") throw new Error("not a commit");
|
|
96
|
+
try {
|
|
97
|
+
await exec("git", ["merge-base", "--is-ancestor", sha, "HEAD"], {
|
|
98
|
+
cwd: workspacePath
|
|
99
|
+
});
|
|
100
|
+
return { sha, mode: "incremental" };
|
|
101
|
+
} catch {
|
|
102
|
+
logger.info(
|
|
103
|
+
`Previous review SHA ${sha.slice(0, 8)} is not an ancestor; using snapshot delta`
|
|
104
|
+
);
|
|
105
|
+
return { sha, mode: "snapshot" };
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
logger.info(
|
|
109
|
+
`Skipping previous review SHA ${sha.slice(0, 8)}; commit is unavailable`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
function getDiffStats(diff) {
|
|
116
|
+
let files = 0;
|
|
117
|
+
let additions = 0;
|
|
118
|
+
let deletions = 0;
|
|
119
|
+
for (const line of diff.split("\n")) {
|
|
120
|
+
if (line.startsWith("diff --git ")) files++;
|
|
121
|
+
else if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
|
122
|
+
else if (line.startsWith("-") && !line.startsWith("---")) deletions++;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
files,
|
|
126
|
+
additions,
|
|
127
|
+
deletions,
|
|
128
|
+
bytes: Buffer.byteLength(diff, "utf-8")
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function getChangedFiles(diff) {
|
|
132
|
+
const files = [];
|
|
133
|
+
for (const match of diff.matchAll(/^diff --git a\/(.*?) b\/(.*?)$/gm)) {
|
|
134
|
+
files.push(match[2]);
|
|
135
|
+
}
|
|
136
|
+
return [...new Set(files)];
|
|
137
|
+
}
|
|
138
|
+
var DIFF_SKIP_PATTERNS = [
|
|
139
|
+
/(?:^|\/)testdata\//,
|
|
140
|
+
/(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|go\.sum|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock)$/,
|
|
141
|
+
/\.mdx?$/
|
|
142
|
+
];
|
|
143
|
+
function filterEmbeddedDiff(rawDiff) {
|
|
144
|
+
const skippedFiles = [];
|
|
145
|
+
const sections = rawDiff.split(/(?=^diff --git )/m);
|
|
146
|
+
const kept = [];
|
|
147
|
+
for (const section of sections) {
|
|
148
|
+
const match = section.match(/^diff --git a\/(.*?) b\//);
|
|
149
|
+
if (!match) {
|
|
150
|
+
kept.push(section);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const filePath = match[1];
|
|
154
|
+
if (DIFF_SKIP_PATTERNS.some((pattern) => pattern.test(filePath))) {
|
|
155
|
+
skippedFiles.push(filePath);
|
|
156
|
+
} else {
|
|
157
|
+
kept.push(section);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { filtered: kept.join(""), skippedFiles };
|
|
161
|
+
}
|
|
162
|
+
|
|
33
163
|
// src/prompt.ts
|
|
34
164
|
function buildPrReviewPrompt(opts) {
|
|
35
165
|
const {
|
|
@@ -45,6 +175,9 @@ function buildPrReviewPrompt(opts) {
|
|
|
45
175
|
localMode = false,
|
|
46
176
|
singleTurn = false
|
|
47
177
|
} = opts;
|
|
178
|
+
const rebasedGitlabReview = platform === "gitlab" && reviewDiffMode === "snapshot";
|
|
179
|
+
const hasPreviousReviewDelta = Boolean(previousReviewSha && !rebasedGitlabReview);
|
|
180
|
+
const previousReviewShaText = previousReviewSha ?? "";
|
|
48
181
|
let templateText;
|
|
49
182
|
try {
|
|
50
183
|
templateText = readFileSync(getTemplatePath("review-task.md"), "utf-8");
|
|
@@ -61,44 +194,36 @@ function buildPrReviewPrompt(opts) {
|
|
|
61
194
|
if (previousReviewSha && !/^[a-f0-9]{40}$/.test(previousReviewSha)) {
|
|
62
195
|
throw new Error(`Invalid previous review SHA: ${previousReviewSha}`);
|
|
63
196
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (diffBaseSha) {
|
|
79
|
-
prDiffCmd = `git --no-pager diff ${diffBaseSha} HEAD --name-only`;
|
|
80
|
-
gitDiffCmd = `git --no-pager diff ${diffBaseSha} HEAD`;
|
|
81
|
-
logger.info(`Using GitLab CI_MERGE_REQUEST_DIFF_BASE_SHA: ${diffBaseSha.slice(0, 8)}`);
|
|
82
|
-
} else {
|
|
83
|
-
prDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD --name-only`;
|
|
84
|
-
gitDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD`;
|
|
85
|
-
}
|
|
197
|
+
const diffArgs = getReviewDiffArgs({
|
|
198
|
+
platform,
|
|
199
|
+
targetBranch,
|
|
200
|
+
diffBaseSha,
|
|
201
|
+
previousReviewSha,
|
|
202
|
+
reviewDiffMode,
|
|
203
|
+
localMode
|
|
204
|
+
});
|
|
205
|
+
const gitDiffCmd = `git ${diffArgs.join(" ")}`;
|
|
206
|
+
const prDiffCmd = `${gitDiffCmd} --name-only`;
|
|
207
|
+
if (hasPreviousReviewDelta) {
|
|
208
|
+
logger.info(`${reviewDiffMode === "snapshot" ? "Snapshot" : "Incremental"} review: diffing from ${previousReviewSha?.slice(0, 8)} to HEAD`);
|
|
209
|
+
} else if (rebasedGitlabReview) {
|
|
210
|
+
logger.info("Rebased GitLab review: diffing from the current MR base to HEAD");
|
|
86
211
|
}
|
|
87
212
|
let diffExplanation;
|
|
88
|
-
if (
|
|
89
|
-
diffExplanation = reviewDiffMode === "snapshot" ? `**Snapshot delta mode**: The MR history was rewritten. This directly compares the last reviewed snapshot (commit \`${
|
|
213
|
+
if (hasPreviousReviewDelta) {
|
|
214
|
+
diffExplanation = reviewDiffMode === "snapshot" ? `**Snapshot delta mode**: The MR history was rewritten. This directly compares the last reviewed snapshot (commit \`${previousReviewShaText.slice(0, 8)}\`) with the current HEAD; it does not imply ancestry.` : `**Incremental mode**: Showing only changes since the last hodor review (commit \`${previousReviewShaText.slice(0, 8)}\`).`;
|
|
90
215
|
} else if (diffBaseSha) {
|
|
91
|
-
diffExplanation = `**GitLab CI Advantage**: This uses
|
|
216
|
+
diffExplanation = `**GitLab CI Advantage**: This uses the merge base resolved from the current target branch, which matches the current GitLab MR diff after force pushes and rebases.`;
|
|
92
217
|
} else {
|
|
93
218
|
diffExplanation = `**Three-dot syntax** shows ONLY changes introduced on the source branch, excluding changes already on \`${targetBranch}\`.`;
|
|
94
219
|
}
|
|
95
220
|
const { contextSection, notesSection, reminderSection } = buildMrSections(mrMetadata);
|
|
96
221
|
const oneTurn = singleTurn && Boolean(embeddedDiff);
|
|
97
222
|
let incrementalSection = "";
|
|
98
|
-
if (
|
|
223
|
+
if (hasPreviousReviewDelta) {
|
|
99
224
|
incrementalSection = `## ${reviewDiffMode === "snapshot" ? "Snapshot Delta" : "Incremental Review"} Mode
|
|
100
225
|
|
|
101
|
-
This is a follow-up review. A previous hodor review was done at commit \`${
|
|
226
|
+
This is a follow-up review. A previous hodor review was done at commit \`${previousReviewShaText.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 findings 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.\n" + (oneTurn ? "4. No file-inspection tools are available; if a mechanical change like a route/path/string rename leaves a compatibility question you cannot settle from the diff, do not report it.\n" : "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.\n") + "5. If the delta does not produce a qualifying finding under the selected review instructions, submit no findings.\n\n";
|
|
102
227
|
}
|
|
103
228
|
let embeddedDiffSection;
|
|
104
229
|
let diffFetchInstructions;
|
|
@@ -126,7 +251,7 @@ ${changedFiles.map((file) => `- \`${file}\``).join("\n")}
|
|
|
126
251
|
startInstruction = "Analyze the diff above and call `submit_review` now, in this turn.";
|
|
127
252
|
} else {
|
|
128
253
|
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";
|
|
129
|
-
startInstruction =
|
|
254
|
+
startInstruction = hasPreviousReviewDelta ? "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`.";
|
|
130
255
|
}
|
|
131
256
|
} else {
|
|
132
257
|
embeddedDiffSection = "";
|
|
@@ -313,6 +438,31 @@ function parseModelString(model) {
|
|
|
313
438
|
}
|
|
314
439
|
return { provider: "anthropic", modelId: trimmed };
|
|
315
440
|
}
|
|
441
|
+
var BEDROCK_REGIONAL_PREFIXES = ["global", "us", "eu", "apac", "in", "jp", "au", "ca"];
|
|
442
|
+
function stripBedrockRegionalPrefix(modelId) {
|
|
443
|
+
const dot = modelId.indexOf(".");
|
|
444
|
+
if (dot <= 0) return null;
|
|
445
|
+
const prefix = modelId.slice(0, dot).toLowerCase();
|
|
446
|
+
if (!BEDROCK_REGIONAL_PREFIXES.includes(prefix)) return null;
|
|
447
|
+
return modelId.slice(dot + 1);
|
|
448
|
+
}
|
|
449
|
+
function isOpenAiBedrockModel(model) {
|
|
450
|
+
if (model.provider !== "amazon-bedrock") return false;
|
|
451
|
+
return [model.id, model.name].filter((value) => Boolean(value)).some((value) => value.toLowerCase().includes("openai"));
|
|
452
|
+
}
|
|
453
|
+
function addOpenAiBedrockReasoning(payload, effort) {
|
|
454
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
|
|
455
|
+
const request = payload;
|
|
456
|
+
const existingFields = request.additionalModelRequestFields;
|
|
457
|
+
const additionalModelRequestFields = existingFields && typeof existingFields === "object" && !Array.isArray(existingFields) ? existingFields : {};
|
|
458
|
+
return {
|
|
459
|
+
...request,
|
|
460
|
+
additionalModelRequestFields: {
|
|
461
|
+
...additionalModelRequestFields,
|
|
462
|
+
reasoning: { effort }
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
}
|
|
316
466
|
function extractBedrockArnRegion(arn) {
|
|
317
467
|
const parts = arn.split(":");
|
|
318
468
|
return parts.length >= 4 && parts[3] ? parts[3] : "us-east-1";
|
|
@@ -1144,6 +1294,7 @@ async function postReviewStructured(opts) {
|
|
|
1144
1294
|
let inlineCreated = 0;
|
|
1145
1295
|
let inlineFailed = 0;
|
|
1146
1296
|
let inlineDeduplicated = 0;
|
|
1297
|
+
const draftNoteIds = [];
|
|
1147
1298
|
for (const finding of review.findings) {
|
|
1148
1299
|
const fingerprint = getFindingFingerprint(finding, workspacePath);
|
|
1149
1300
|
if (existingByFingerprint.has(fingerprint)) {
|
|
@@ -1170,7 +1321,7 @@ ${finding.suggestion}
|
|
|
1170
1321
|
\`\`\``;
|
|
1171
1322
|
}
|
|
1172
1323
|
try {
|
|
1173
|
-
await createGitlabDraftNote(
|
|
1324
|
+
const draftNote = await createGitlabDraftNote(
|
|
1174
1325
|
parsed.owner,
|
|
1175
1326
|
parsed.repo,
|
|
1176
1327
|
parsed.prNumber,
|
|
@@ -1182,6 +1333,9 @@ ${finding.suggestion}
|
|
|
1182
1333
|
diffRefs
|
|
1183
1334
|
}
|
|
1184
1335
|
);
|
|
1336
|
+
if (typeof draftNote.id === "number" || typeof draftNote.id === "string") {
|
|
1337
|
+
draftNoteIds.push(draftNote.id);
|
|
1338
|
+
}
|
|
1185
1339
|
inlineCreated++;
|
|
1186
1340
|
} catch (error) {
|
|
1187
1341
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1205,8 +1359,32 @@ ${finding.suggestion}
|
|
|
1205
1359
|
draftsPublished = true;
|
|
1206
1360
|
} catch (error) {
|
|
1207
1361
|
const message = error instanceof Error ? error.message : String(error);
|
|
1208
|
-
errors.push(`draft publish: ${message}`);
|
|
1209
1362
|
logger.warn(`Failed to bulk publish draft notes: ${message}`);
|
|
1363
|
+
if (draftNoteIds.length === inlineCreated) {
|
|
1364
|
+
let individuallyPublished = 0;
|
|
1365
|
+
for (const draftNoteId of draftNoteIds) {
|
|
1366
|
+
try {
|
|
1367
|
+
await publishGitlabDraftNote(
|
|
1368
|
+
parsed.owner,
|
|
1369
|
+
parsed.repo,
|
|
1370
|
+
parsed.prNumber,
|
|
1371
|
+
draftNoteId,
|
|
1372
|
+
parsed.host
|
|
1373
|
+
);
|
|
1374
|
+
individuallyPublished++;
|
|
1375
|
+
} catch (publishError) {
|
|
1376
|
+
const publishMessage = publishError instanceof Error ? publishError.message : String(publishError);
|
|
1377
|
+
errors.push(`draft publish: ${publishMessage}`);
|
|
1378
|
+
logger.warn(`Failed to publish draft note ${draftNoteId}: ${publishMessage}`);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
draftsPublished = individuallyPublished === inlineCreated;
|
|
1382
|
+
if (draftsPublished) {
|
|
1383
|
+
logger.info(`Published ${individuallyPublished} draft note(s) individually`);
|
|
1384
|
+
}
|
|
1385
|
+
} else {
|
|
1386
|
+
errors.push(`draft publish: ${message}`);
|
|
1387
|
+
}
|
|
1210
1388
|
}
|
|
1211
1389
|
}
|
|
1212
1390
|
let summaryPosted = false;
|
|
@@ -1457,6 +1635,31 @@ async function detectCiWorkspace(owner, repo) {
|
|
|
1457
1635
|
}
|
|
1458
1636
|
return { path: null, targetBranch: null, diffBaseSha: null };
|
|
1459
1637
|
}
|
|
1638
|
+
async function resolveGitlabDiffBaseSha(workspace, targetBranch, fallbackSha) {
|
|
1639
|
+
if (!targetBranch) return fallbackSha;
|
|
1640
|
+
try {
|
|
1641
|
+
await exec("git", ["fetch", "--no-tags", "origin", targetBranch], { cwd: workspace });
|
|
1642
|
+
const { stdout } = await exec("git", ["merge-base", "HEAD", "FETCH_HEAD"], { cwd: workspace });
|
|
1643
|
+
const mergeBase = stdout.trim();
|
|
1644
|
+
if (mergeBase) {
|
|
1645
|
+
logger.info(`Calculated current GitLab MR diff base: ${mergeBase.slice(0, 8)}`);
|
|
1646
|
+
return mergeBase;
|
|
1647
|
+
}
|
|
1648
|
+
} catch (err) {
|
|
1649
|
+
logger.warn(`Could not calculate current GitLab MR diff base: ${err}`);
|
|
1650
|
+
}
|
|
1651
|
+
try {
|
|
1652
|
+
const { stdout } = await exec("git", ["merge-base", "HEAD", `origin/${targetBranch}`], { cwd: workspace });
|
|
1653
|
+
const mergeBase = stdout.trim();
|
|
1654
|
+
if (mergeBase) {
|
|
1655
|
+
logger.info(`Calculated GitLab MR diff base from origin/${targetBranch}: ${mergeBase.slice(0, 8)}`);
|
|
1656
|
+
return mergeBase;
|
|
1657
|
+
}
|
|
1658
|
+
} catch {
|
|
1659
|
+
}
|
|
1660
|
+
if (fallbackSha) logger.warn(`Falling back to CI_MERGE_REQUEST_DIFF_BASE_SHA: ${fallbackSha.slice(0, 8)}`);
|
|
1661
|
+
return fallbackSha;
|
|
1662
|
+
}
|
|
1460
1663
|
function normalizeGitRemotePath(remoteUrl) {
|
|
1461
1664
|
const trimmed = remoteUrl.trim().replace(/\.git$/, "");
|
|
1462
1665
|
try {
|
|
@@ -1671,11 +1874,18 @@ async function setupWorkspace(opts) {
|
|
|
1671
1874
|
try {
|
|
1672
1875
|
const ci = await detectCiWorkspace(owner, repo);
|
|
1673
1876
|
let detectedTargetBranch = ci.targetBranch;
|
|
1674
|
-
|
|
1877
|
+
let detectedDiffBaseSha = ci.diffBaseSha;
|
|
1675
1878
|
let workspace;
|
|
1676
1879
|
let isTemporary = false;
|
|
1677
1880
|
if (ci.path) {
|
|
1678
1881
|
workspace = ci.path;
|
|
1882
|
+
if (platform === "gitlab" && ci.targetBranch) {
|
|
1883
|
+
detectedDiffBaseSha = await resolveGitlabDiffBaseSha(
|
|
1884
|
+
workspace,
|
|
1885
|
+
ci.targetBranch,
|
|
1886
|
+
detectedDiffBaseSha
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1679
1889
|
if (platform === "github" && !detectedTargetBranch) {
|
|
1680
1890
|
detectedTargetBranch = await getGithubBaseBranch(workspace, prNumber);
|
|
1681
1891
|
}
|
|
@@ -1925,118 +2135,6 @@ function resolveReviewLocations(review, opts) {
|
|
|
1925
2135
|
return { review: { ...review, findings }, stats };
|
|
1926
2136
|
}
|
|
1927
2137
|
|
|
1928
|
-
// src/review-diff.ts
|
|
1929
|
-
var HODOR_REVIEW_SHA_RE = /^\s*<!--\s*hodor:sha:([a-f0-9]{40})\s*-->/i;
|
|
1930
|
-
function getHodorReviewShaCandidates(notes) {
|
|
1931
|
-
if (!notes || notes.length === 0) return [];
|
|
1932
|
-
const candidates = [];
|
|
1933
|
-
for (const [index, note] of notes.entries()) {
|
|
1934
|
-
const match = note.body?.match(HODOR_REVIEW_SHA_RE);
|
|
1935
|
-
if (!match) continue;
|
|
1936
|
-
const createdAtMs = Date.parse(note.created_at ?? "");
|
|
1937
|
-
candidates.push({
|
|
1938
|
-
sha: match[1],
|
|
1939
|
-
createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : null,
|
|
1940
|
-
index
|
|
1941
|
-
});
|
|
1942
|
-
}
|
|
1943
|
-
candidates.sort((a, b) => {
|
|
1944
|
-
if (a.createdAtMs != null && b.createdAtMs != null && a.createdAtMs !== b.createdAtMs) {
|
|
1945
|
-
return b.createdAtMs - a.createdAtMs;
|
|
1946
|
-
}
|
|
1947
|
-
if (a.createdAtMs != null && b.createdAtMs == null) return -1;
|
|
1948
|
-
if (a.createdAtMs == null && b.createdAtMs != null) return 1;
|
|
1949
|
-
return a.index - b.index;
|
|
1950
|
-
});
|
|
1951
|
-
return [...new Set(candidates.map(({ sha }) => sha))];
|
|
1952
|
-
}
|
|
1953
|
-
async function findLatestReviewBase(notes, workspacePath) {
|
|
1954
|
-
const candidates = getHodorReviewShaCandidates(notes);
|
|
1955
|
-
if (candidates.length === 0) return null;
|
|
1956
|
-
logger.info(`Found ${candidates.length} previous Hodor review marker(s)`);
|
|
1957
|
-
for (const sha of candidates) {
|
|
1958
|
-
try {
|
|
1959
|
-
let objectType;
|
|
1960
|
-
try {
|
|
1961
|
-
({ stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
|
|
1962
|
-
cwd: workspacePath
|
|
1963
|
-
}));
|
|
1964
|
-
} catch {
|
|
1965
|
-
await exec("git", ["fetch", "--quiet", "origin", sha], {
|
|
1966
|
-
cwd: workspacePath
|
|
1967
|
-
});
|
|
1968
|
-
({ stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
|
|
1969
|
-
cwd: workspacePath
|
|
1970
|
-
}));
|
|
1971
|
-
}
|
|
1972
|
-
if (objectType.trim() !== "commit") throw new Error("not a commit");
|
|
1973
|
-
try {
|
|
1974
|
-
await exec("git", ["merge-base", "--is-ancestor", sha, "HEAD"], {
|
|
1975
|
-
cwd: workspacePath
|
|
1976
|
-
});
|
|
1977
|
-
return { sha, mode: "incremental" };
|
|
1978
|
-
} catch {
|
|
1979
|
-
logger.info(
|
|
1980
|
-
`Previous review SHA ${sha.slice(0, 8)} is not an ancestor; using snapshot delta`
|
|
1981
|
-
);
|
|
1982
|
-
return { sha, mode: "snapshot" };
|
|
1983
|
-
}
|
|
1984
|
-
} catch {
|
|
1985
|
-
logger.info(
|
|
1986
|
-
`Skipping previous review SHA ${sha.slice(0, 8)}; commit is unavailable`
|
|
1987
|
-
);
|
|
1988
|
-
}
|
|
1989
|
-
}
|
|
1990
|
-
return null;
|
|
1991
|
-
}
|
|
1992
|
-
function getDiffStats(diff) {
|
|
1993
|
-
let files = 0;
|
|
1994
|
-
let additions = 0;
|
|
1995
|
-
let deletions = 0;
|
|
1996
|
-
for (const line of diff.split("\n")) {
|
|
1997
|
-
if (line.startsWith("diff --git ")) files++;
|
|
1998
|
-
else if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
|
1999
|
-
else if (line.startsWith("-") && !line.startsWith("---")) deletions++;
|
|
2000
|
-
}
|
|
2001
|
-
return {
|
|
2002
|
-
files,
|
|
2003
|
-
additions,
|
|
2004
|
-
deletions,
|
|
2005
|
-
bytes: Buffer.byteLength(diff, "utf-8")
|
|
2006
|
-
};
|
|
2007
|
-
}
|
|
2008
|
-
function getChangedFiles(diff) {
|
|
2009
|
-
const files = [];
|
|
2010
|
-
for (const match of diff.matchAll(/^diff --git a\/(.*?) b\/(.*?)$/gm)) {
|
|
2011
|
-
files.push(match[2]);
|
|
2012
|
-
}
|
|
2013
|
-
return [...new Set(files)];
|
|
2014
|
-
}
|
|
2015
|
-
var DIFF_SKIP_PATTERNS = [
|
|
2016
|
-
/(?:^|\/)testdata\//,
|
|
2017
|
-
/(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|go\.sum|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock)$/,
|
|
2018
|
-
/\.mdx?$/
|
|
2019
|
-
];
|
|
2020
|
-
function filterEmbeddedDiff(rawDiff) {
|
|
2021
|
-
const skippedFiles = [];
|
|
2022
|
-
const sections = rawDiff.split(/(?=^diff --git )/m);
|
|
2023
|
-
const kept = [];
|
|
2024
|
-
for (const section of sections) {
|
|
2025
|
-
const match = section.match(/^diff --git a\/(.*?) b\//);
|
|
2026
|
-
if (!match) {
|
|
2027
|
-
kept.push(section);
|
|
2028
|
-
continue;
|
|
2029
|
-
}
|
|
2030
|
-
const filePath = match[1];
|
|
2031
|
-
if (DIFF_SKIP_PATTERNS.some((pattern) => pattern.test(filePath))) {
|
|
2032
|
-
skippedFiles.push(filePath);
|
|
2033
|
-
} else {
|
|
2034
|
-
kept.push(section);
|
|
2035
|
-
}
|
|
2036
|
-
}
|
|
2037
|
-
return { filtered: kept.join(""), skippedFiles };
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
2138
|
// src/review-cache.ts
|
|
2041
2139
|
import { createHash as createHash2 } from "crypto";
|
|
2042
2140
|
import { gzipSync, gunzipSync } from "zlib";
|
|
@@ -2229,7 +2327,22 @@ async function reviewPr(opts) {
|
|
|
2229
2327
|
);
|
|
2230
2328
|
}
|
|
2231
2329
|
} else if (!piModel) {
|
|
2232
|
-
if (parsed.provider === "
|
|
2330
|
+
if (parsed.provider === "amazon-bedrock") {
|
|
2331
|
+
const inferredBaseModelId = stripBedrockRegionalPrefix(parsed.modelId);
|
|
2332
|
+
const baseModelId = parsed.baseModelId ?? inferredBaseModelId;
|
|
2333
|
+
const baseModel = baseModelId ? modelRuntime.getModel(parsed.provider, baseModelId) : void 0;
|
|
2334
|
+
if (!baseModel) {
|
|
2335
|
+
const hint = parsed.baseModelId ? `Base model "${parsed.baseModelId}" was not found in the installed pi-ai registry.` : `Append "@<base-model-id>" if this is a custom inference profile.`;
|
|
2336
|
+
throw new Error(
|
|
2337
|
+
`Unsupported Bedrock model "${parsed.modelId}". ${hint}`
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1";
|
|
2341
|
+
piModel = buildBedrockArnModel({ arn: parsed.modelId, baseModel, region });
|
|
2342
|
+
logger.info(
|
|
2343
|
+
`Regional bedrock model, region: ${region}, capabilities from ${baseModel.id}`
|
|
2344
|
+
);
|
|
2345
|
+
} else if (parsed.provider === "openrouter") {
|
|
2233
2346
|
piModel = {
|
|
2234
2347
|
id: parsed.modelId,
|
|
2235
2348
|
name: parsed.modelId,
|
|
@@ -2430,7 +2543,14 @@ async function reviewPr(opts) {
|
|
|
2430
2543
|
let diffStats = null;
|
|
2431
2544
|
let changedFiles = [];
|
|
2432
2545
|
try {
|
|
2433
|
-
const diffArgs =
|
|
2546
|
+
const diffArgs = getReviewDiffArgs({
|
|
2547
|
+
platform,
|
|
2548
|
+
targetBranch,
|
|
2549
|
+
diffBaseSha,
|
|
2550
|
+
previousReviewSha,
|
|
2551
|
+
reviewDiffMode: previousReviewBase?.mode,
|
|
2552
|
+
localMode
|
|
2553
|
+
});
|
|
2434
2554
|
const { stdout: rawDiff } = await exec("git", diffArgs, { cwd: workspacePath });
|
|
2435
2555
|
const { filtered: filteredDiff, skippedFiles } = filterEmbeddedDiff(rawDiff);
|
|
2436
2556
|
if (skippedFiles.length > 0) {
|
|
@@ -2567,14 +2687,29 @@ async function reviewPr(opts) {
|
|
|
2567
2687
|
resourceLoader
|
|
2568
2688
|
});
|
|
2569
2689
|
activeSession = session;
|
|
2570
|
-
|
|
2690
|
+
const openAiReasoning = thinkingLevel && isOpenAiBedrockModel(piModel) ? thinkingLevel : void 0;
|
|
2691
|
+
if (parsed.provider === "amazon-bedrock" && (bedrockTags || openAiReasoning)) {
|
|
2571
2692
|
const agent = session.agent;
|
|
2572
2693
|
const originalStreamFn = agent.streamFn;
|
|
2573
2694
|
agent.streamFn = (...args) => {
|
|
2574
2695
|
const options = args[2] ?? {};
|
|
2575
|
-
|
|
2696
|
+
const originalOnPayload = options.onPayload;
|
|
2697
|
+
const onPayload = openAiReasoning ? async (payload, model2) => {
|
|
2698
|
+
const transformed = originalOnPayload ? await originalOnPayload(payload, model2) : void 0;
|
|
2699
|
+
return addOpenAiBedrockReasoning(
|
|
2700
|
+
transformed === void 0 ? payload : transformed,
|
|
2701
|
+
openAiReasoning
|
|
2702
|
+
);
|
|
2703
|
+
} : originalOnPayload;
|
|
2704
|
+
return originalStreamFn(args[0], args[1], {
|
|
2705
|
+
...options,
|
|
2706
|
+
...bedrockTags ? { requestMetadata: bedrockTags } : {},
|
|
2707
|
+
...onPayload ? { onPayload } : {}
|
|
2708
|
+
});
|
|
2576
2709
|
};
|
|
2577
|
-
|
|
2710
|
+
if (bedrockTags) {
|
|
2711
|
+
logger.info(`Bedrock cost allocation tags: ${JSON.stringify(bedrockTags)}`);
|
|
2712
|
+
}
|
|
2578
2713
|
}
|
|
2579
2714
|
let turnCount = 0;
|
|
2580
2715
|
let toolCallCount = 0;
|
|
@@ -2796,4 +2931,4 @@ export {
|
|
|
2796
2931
|
postReviewStructured,
|
|
2797
2932
|
reviewPr
|
|
2798
2933
|
};
|
|
2799
|
-
//# sourceMappingURL=chunk-
|
|
2934
|
+
//# sourceMappingURL=chunk-GISFKKMM.js.map
|