@mrkaran/hodor 0.7.3 → 0.7.5
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 +7 -4
- package/dist/{chunk-GISFKKMM.js → chunk-AFEJ4DRL.js} +971 -135
- package/dist/chunk-AFEJ4DRL.js.map +1 -0
- package/dist/cli.js +150 -49
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +17 -2
- package/dist/index.js +2 -5
- package/package.json +1 -1
- package/dist/chunk-AMUK6GDX.js +0 -23
- package/dist/chunk-AMUK6GDX.js.map +0 -1
- package/dist/chunk-DALI4QRT.js +0 -674
- package/dist/chunk-DALI4QRT.js.map +0 -1
- package/dist/chunk-GISFKKMM.js.map +0 -1
- package/dist/codequality-DTJK2LGF.js +0 -42
- package/dist/codequality-DTJK2LGF.js.map +0 -1
- package/dist/gitlab-JSVU4YFQ.js +0 -35
- package/dist/gitlab-JSVU4YFQ.js.map +0 -1
|
@@ -1,25 +1,724 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
1
|
+
// src/utils/logger.ts
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
var currentLevel = "warn";
|
|
4
|
+
var LEVELS = {
|
|
5
|
+
debug: 0,
|
|
6
|
+
info: 1,
|
|
7
|
+
warn: 2,
|
|
8
|
+
error: 3
|
|
9
|
+
};
|
|
10
|
+
function setLogLevel(level) {
|
|
11
|
+
currentLevel = level;
|
|
12
|
+
}
|
|
13
|
+
function shouldLog(level) {
|
|
14
|
+
return LEVELS[level] >= LEVELS[currentLevel];
|
|
15
|
+
}
|
|
16
|
+
function timestamp() {
|
|
17
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
18
|
+
}
|
|
19
|
+
var logger = {
|
|
20
|
+
debug(msg) {
|
|
21
|
+
if (shouldLog("debug")) {
|
|
22
|
+
process.stderr.write(`${chalk.gray(timestamp())} ${chalk.gray("DEBUG")} ${msg}
|
|
23
|
+
`);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
info(msg) {
|
|
27
|
+
if (shouldLog("info")) {
|
|
28
|
+
process.stderr.write(`${chalk.gray(timestamp())} ${chalk.blue("INFO")} ${msg}
|
|
29
|
+
`);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
warn(msg) {
|
|
33
|
+
if (shouldLog("warn")) {
|
|
34
|
+
process.stderr.write(`${chalk.gray(timestamp())} ${chalk.yellow("WARN")} ${msg}
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
error(msg) {
|
|
39
|
+
if (shouldLog("error")) {
|
|
40
|
+
process.stderr.write(`${chalk.gray(timestamp())} ${chalk.red("ERROR")} ${msg}
|
|
41
|
+
`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/render.ts
|
|
47
|
+
var HODOR_REVIEW_MARKER = "<!-- hodor-review -->";
|
|
48
|
+
var HODOR_SUMMARY_MARKER = "<!-- hodor:summary:v1 -->";
|
|
49
|
+
function renderMarkdown(review) {
|
|
50
|
+
const lines = [HODOR_REVIEW_MARKER];
|
|
51
|
+
const critical = [];
|
|
52
|
+
const important = [];
|
|
53
|
+
const minor = [];
|
|
54
|
+
for (const f of review.findings) {
|
|
55
|
+
const p = f.priority;
|
|
56
|
+
if (p <= 1) critical.push(f);
|
|
57
|
+
else if (p === 2) important.push(f);
|
|
58
|
+
else minor.push(f);
|
|
59
|
+
}
|
|
60
|
+
lines.push("### Issues Found");
|
|
61
|
+
lines.push("");
|
|
62
|
+
if (review.findings.length === 0) {
|
|
63
|
+
lines.push("No issues found.");
|
|
64
|
+
lines.push("");
|
|
65
|
+
}
|
|
66
|
+
if (critical.length > 0) {
|
|
67
|
+
lines.push("**Critical (P0/P1)**");
|
|
68
|
+
for (const f of critical) {
|
|
69
|
+
lines.push(formatFinding(f));
|
|
70
|
+
}
|
|
71
|
+
lines.push("");
|
|
72
|
+
}
|
|
73
|
+
if (important.length > 0) {
|
|
74
|
+
lines.push("**Important (P2)**");
|
|
75
|
+
for (const f of important) {
|
|
76
|
+
lines.push(formatFinding(f));
|
|
77
|
+
}
|
|
78
|
+
lines.push("");
|
|
79
|
+
}
|
|
80
|
+
if (minor.length > 0) {
|
|
81
|
+
lines.push("**Minor (P3)**");
|
|
82
|
+
for (const f of minor) {
|
|
83
|
+
lines.push(formatFinding(f));
|
|
84
|
+
}
|
|
85
|
+
lines.push("");
|
|
86
|
+
}
|
|
87
|
+
lines.push("### Summary");
|
|
88
|
+
lines.push(
|
|
89
|
+
`Total issues: ${critical.length} critical, ${important.length} important, ${minor.length} minor.`
|
|
90
|
+
);
|
|
91
|
+
lines.push("");
|
|
92
|
+
lines.push("### Overall Verdict");
|
|
93
|
+
const isCorrect = review.overall_correctness === "patch is correct";
|
|
94
|
+
lines.push(
|
|
95
|
+
`**Status**: ${isCorrect ? "Patch is correct" : "Patch has blocking issues"}`
|
|
96
|
+
);
|
|
97
|
+
lines.push("");
|
|
98
|
+
if (review.overall_explanation) {
|
|
99
|
+
lines.push(`**Explanation**: ${review.overall_explanation}`);
|
|
100
|
+
}
|
|
101
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
102
|
+
}
|
|
103
|
+
function renderSummaryMarkdown(review, options = {}) {
|
|
104
|
+
const lines = [HODOR_REVIEW_MARKER, HODOR_SUMMARY_MARKER];
|
|
105
|
+
lines.push("", "### Hodor review");
|
|
106
|
+
const openFindings = options.openFindings ?? review.findings;
|
|
107
|
+
const fallbackFindings = options.fallbackFindings ?? review.findings;
|
|
108
|
+
const counts = { blocking: 0, important: 0, minor: 0 };
|
|
109
|
+
for (const finding of openFindings) {
|
|
110
|
+
if (finding.priority <= 1) counts.blocking++;
|
|
111
|
+
else if (finding.priority === 2) counts.important++;
|
|
112
|
+
else counts.minor++;
|
|
113
|
+
}
|
|
114
|
+
lines.push("");
|
|
115
|
+
lines.push(
|
|
116
|
+
`**Open findings:** ${counts.blocking} blocking \xB7 ${counts.important} important \xB7 ${counts.minor} minor`
|
|
117
|
+
);
|
|
118
|
+
if (options.inlineCreated != null || options.inlineDeduplicated != null) {
|
|
119
|
+
lines.push("");
|
|
120
|
+
lines.push(
|
|
121
|
+
`**Inline delivery:** ${options.inlineCreated ?? 0} new \xB7 ${options.inlineDeduplicated ?? 0} already open`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const scope = options.reviewMode ? `${options.reviewMode} review` : "Latest review";
|
|
125
|
+
lines.push("");
|
|
126
|
+
lines.push(`**${scope}:** ${review.overall_explanation}`);
|
|
127
|
+
if (fallbackFindings.length > 0) {
|
|
128
|
+
lines.push("");
|
|
129
|
+
lines.push(`### ${options.fallbackHeading ?? "Findings"}`);
|
|
130
|
+
for (const finding of fallbackFindings) {
|
|
131
|
+
lines.push(formatFinding(finding));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
135
|
+
}
|
|
136
|
+
function formatFinding(f) {
|
|
137
|
+
const loc = ` (\`${formatLocation(f.code_location)}\`)`;
|
|
138
|
+
const title = `- **${f.title}**${loc}`;
|
|
139
|
+
const body = ` - ${f.body}`;
|
|
140
|
+
return `${title}
|
|
141
|
+
${body}`;
|
|
142
|
+
}
|
|
143
|
+
function formatLocation(loc) {
|
|
144
|
+
let filePath = loc.absolute_file_path;
|
|
145
|
+
const buildsMatch = filePath.match(/\/builds\/[^/]+\/[^/]+\/(.+)/);
|
|
146
|
+
if (buildsMatch) {
|
|
147
|
+
filePath = buildsMatch[1];
|
|
148
|
+
} else if (filePath.includes("/workspace/")) {
|
|
149
|
+
filePath = filePath.slice(filePath.indexOf("/workspace/") + "/workspace/".length);
|
|
150
|
+
} else {
|
|
151
|
+
filePath = filePath.replace(/^.*\/hodor-review-[^/]+\//, "");
|
|
152
|
+
}
|
|
153
|
+
const { start, end } = loc.line_range;
|
|
154
|
+
return start === end ? `${filePath}:${start}` : `${filePath}:${start}-${end}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/utils/exec.ts
|
|
158
|
+
import { execFile, spawn } from "child_process";
|
|
159
|
+
import { accessSync, constants } from "fs";
|
|
160
|
+
import { delimiter, join } from "path";
|
|
161
|
+
import { promisify } from "util";
|
|
162
|
+
var execFileAsync = promisify(execFile);
|
|
163
|
+
async function exec(cmd, args, opts) {
|
|
164
|
+
if (typeof opts?.input === "string") {
|
|
165
|
+
return new Promise((resolve4, reject) => {
|
|
166
|
+
const child = spawn(cmd, args, {
|
|
167
|
+
cwd: opts.cwd,
|
|
168
|
+
env: opts.env ?? process.env,
|
|
169
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
170
|
+
});
|
|
171
|
+
let stdout2 = "";
|
|
172
|
+
let stderr2 = "";
|
|
173
|
+
child.stdout.on("data", (chunk) => {
|
|
174
|
+
stdout2 += chunk.toString();
|
|
175
|
+
});
|
|
176
|
+
child.stderr.on("data", (chunk) => {
|
|
177
|
+
stderr2 += chunk.toString();
|
|
178
|
+
});
|
|
179
|
+
child.on("error", (error) => {
|
|
180
|
+
reject(error);
|
|
181
|
+
});
|
|
182
|
+
child.on("close", (code, signal) => {
|
|
183
|
+
if (code === 0) {
|
|
184
|
+
resolve4({ stdout: stdout2, stderr: stderr2 });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const parts = [`Command failed: ${cmd} ${args.join(" ")}`];
|
|
188
|
+
if (stderr2.trim()) {
|
|
189
|
+
parts.push(`stderr:
|
|
190
|
+
${stderr2.trim()}`);
|
|
191
|
+
}
|
|
192
|
+
if (stdout2.trim()) {
|
|
193
|
+
parts.push(`stdout:
|
|
194
|
+
${stdout2.trim()}`);
|
|
195
|
+
}
|
|
196
|
+
if (signal) {
|
|
197
|
+
parts.push(`signal: ${signal}`);
|
|
198
|
+
}
|
|
199
|
+
const error = new Error(parts.join("\n"));
|
|
200
|
+
reject(error);
|
|
201
|
+
});
|
|
202
|
+
child.stdin.write(opts.input);
|
|
203
|
+
child.stdin.end();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
const { stdout, stderr } = await execFileAsync(cmd, args, {
|
|
207
|
+
cwd: opts?.cwd,
|
|
208
|
+
env: opts?.env ?? process.env,
|
|
209
|
+
maxBuffer: 50 * 1024 * 1024
|
|
210
|
+
// 50MB
|
|
211
|
+
});
|
|
212
|
+
return { stdout, stderr };
|
|
213
|
+
}
|
|
214
|
+
async function execJson(cmd, args, opts) {
|
|
215
|
+
const { stdout } = await exec(cmd, args, opts);
|
|
216
|
+
return JSON.parse(stdout.trim());
|
|
217
|
+
}
|
|
218
|
+
function commandOnPath(cmd) {
|
|
219
|
+
return (process.env.PATH ?? "").split(delimiter).filter((dir) => dir !== "").some((dir) => {
|
|
220
|
+
try {
|
|
221
|
+
accessSync(join(dir, cmd), constants.X_OK);
|
|
222
|
+
return true;
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/gitlab.ts
|
|
230
|
+
var DEFAULT_GITLAB_HOST = "gitlab.com";
|
|
231
|
+
var HODOR_NOTE_PREFIX_RE = /^\s*<!--\s*hodor[-:]/;
|
|
232
|
+
var HODOR_CACHE_MARKER_RE = /<!--\s*hodor:cache:v1:[A-Za-z0-9_-]+\s*-->\s*/g;
|
|
233
|
+
var HODOR_SHA_PREFIX_RE = /^\s*<!--\s*hodor:sha:[a-f0-9]{40}\s*-->/i;
|
|
234
|
+
function isHodorNote(body, marker = HODOR_REVIEW_MARKER) {
|
|
235
|
+
if (typeof body !== "string") return false;
|
|
236
|
+
if (body.trimStart().startsWith(marker)) return true;
|
|
237
|
+
if (marker === HODOR_REVIEW_MARKER && HODOR_NOTE_PREFIX_RE.test(body)) {
|
|
238
|
+
return body.includes(HODOR_REVIEW_MARKER);
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
function parseGlabPaginatedJson(raw) {
|
|
243
|
+
const trimmed = raw.trim();
|
|
244
|
+
if (!trimmed) return [];
|
|
245
|
+
const chunks = [];
|
|
246
|
+
let depth = 0;
|
|
247
|
+
let inString = false;
|
|
248
|
+
let escaped = false;
|
|
249
|
+
let start = -1;
|
|
250
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
251
|
+
const ch = trimmed[i];
|
|
252
|
+
if (escaped) {
|
|
253
|
+
escaped = false;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (ch === "\\" && inString) {
|
|
257
|
+
escaped = true;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (ch === '"') {
|
|
261
|
+
inString = !inString;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (inString) continue;
|
|
265
|
+
if (ch === "[") {
|
|
266
|
+
if (depth === 0) start = i;
|
|
267
|
+
depth++;
|
|
268
|
+
} else if (ch === "]") {
|
|
269
|
+
depth--;
|
|
270
|
+
if (depth === 0 && start >= 0) {
|
|
271
|
+
chunks.push(trimmed.slice(start, i + 1));
|
|
272
|
+
start = -1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const results = [];
|
|
277
|
+
for (const chunk of chunks) {
|
|
278
|
+
try {
|
|
279
|
+
const parsed = JSON.parse(chunk);
|
|
280
|
+
if (Array.isArray(parsed)) results.push(...parsed);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
logger.warn(
|
|
283
|
+
`Skipping malformed glab pagination chunk: ${err instanceof Error ? err.message : err}`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return results;
|
|
288
|
+
}
|
|
289
|
+
var GitLabAPIError = class extends Error {
|
|
290
|
+
constructor(message) {
|
|
291
|
+
super(message);
|
|
292
|
+
this.name = "GitLabAPIError";
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
function normalizeBaseUrl(host) {
|
|
296
|
+
const candidate = host || process.env.GITLAB_HOST || process.env.CI_SERVER_URL || DEFAULT_GITLAB_HOST;
|
|
297
|
+
const trimmed = candidate.trim() || DEFAULT_GITLAB_HOST;
|
|
298
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
299
|
+
return trimmed.replace(/\/+$/, "");
|
|
300
|
+
}
|
|
301
|
+
return `https://${trimmed}`.replace(/\/+$/, "");
|
|
302
|
+
}
|
|
303
|
+
function encodedProjectPath(owner, repo) {
|
|
304
|
+
const projectPath = [owner.replace(/^\/+|\/+$/g, ""), repo.replace(/^\/+|\/+$/g, "")].filter(Boolean).join("/");
|
|
305
|
+
return encodeURIComponent(projectPath);
|
|
306
|
+
}
|
|
307
|
+
function glabEnv(host) {
|
|
308
|
+
const env = { ...process.env };
|
|
309
|
+
const baseUrl = normalizeBaseUrl(host);
|
|
310
|
+
const hostname = baseUrl.replace(/^https?:\/\//, "");
|
|
311
|
+
env.GITLAB_HOST = hostname;
|
|
312
|
+
return env;
|
|
313
|
+
}
|
|
314
|
+
async function fetchGitlabMrInfo(owner, repo, mrNumber, host, options) {
|
|
315
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
316
|
+
const env = glabEnv(host);
|
|
317
|
+
let mrData;
|
|
318
|
+
try {
|
|
319
|
+
mrData = await execJson(
|
|
320
|
+
"glab",
|
|
321
|
+
["api", `projects/${encoded}/merge_requests/${mrNumber}`],
|
|
322
|
+
{ env }
|
|
323
|
+
);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
326
|
+
throw new GitLabAPIError(`Failed to fetch MR !${mrNumber}: ${msg}`);
|
|
327
|
+
}
|
|
328
|
+
const metadata = {
|
|
329
|
+
title: mrData.title,
|
|
330
|
+
description: mrData.description ?? "",
|
|
331
|
+
source_branch: mrData.source_branch,
|
|
332
|
+
target_branch: mrData.target_branch,
|
|
333
|
+
changes_count: mrData.changes_count,
|
|
334
|
+
labels: mrData.labels,
|
|
335
|
+
author: mrData.author,
|
|
336
|
+
pipeline: mrData.pipeline,
|
|
337
|
+
state: mrData.state
|
|
338
|
+
};
|
|
339
|
+
if (options?.includeComments) {
|
|
340
|
+
try {
|
|
341
|
+
const { stdout: rawNotes } = await exec(
|
|
342
|
+
"glab",
|
|
343
|
+
["api", `projects/${encoded}/merge_requests/${mrNumber}/notes`, "--paginate"],
|
|
344
|
+
{ env }
|
|
345
|
+
);
|
|
346
|
+
const notes = parseGlabPaginatedJson(rawNotes);
|
|
347
|
+
metadata.Notes = notes.map((n) => ({
|
|
348
|
+
body: n.body ?? "",
|
|
349
|
+
author: n.author,
|
|
350
|
+
created_at: n.created_at,
|
|
351
|
+
updated_at: n.updated_at,
|
|
352
|
+
system: n.system
|
|
353
|
+
}));
|
|
354
|
+
} catch (err) {
|
|
355
|
+
logger.warn(`Failed to fetch MR notes: ${err instanceof Error ? err.message : err}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return metadata;
|
|
359
|
+
}
|
|
360
|
+
async function postGitlabMrComment(owner, repo, mrNumber, body, host) {
|
|
361
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
362
|
+
const env = glabEnv(host);
|
|
363
|
+
try {
|
|
364
|
+
await exec(
|
|
365
|
+
"glab",
|
|
366
|
+
[
|
|
367
|
+
"api",
|
|
368
|
+
`projects/${encoded}/merge_requests/${mrNumber}/notes`,
|
|
369
|
+
"--method",
|
|
370
|
+
"POST",
|
|
371
|
+
"-H",
|
|
372
|
+
"Content-Type: application/json",
|
|
373
|
+
"--input",
|
|
374
|
+
"-"
|
|
375
|
+
],
|
|
376
|
+
{ env, input: JSON.stringify({ body }) }
|
|
377
|
+
);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
380
|
+
throw new GitLabAPIError(`Failed to post comment to MR !${mrNumber}: ${msg}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
async function upsertGitlabMrSummary(owner, repo, mrNumber, body, host) {
|
|
384
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
385
|
+
const env = glabEnv(host);
|
|
386
|
+
try {
|
|
387
|
+
const [currentUser, notesResult] = await Promise.all([
|
|
388
|
+
execJson("glab", ["api", "user"], { env }),
|
|
389
|
+
exec(
|
|
390
|
+
"glab",
|
|
391
|
+
[
|
|
392
|
+
"api",
|
|
393
|
+
`projects/${encoded}/merge_requests/${mrNumber}/notes?per_page=100`,
|
|
394
|
+
"--paginate"
|
|
395
|
+
],
|
|
396
|
+
{ env }
|
|
397
|
+
)
|
|
398
|
+
]);
|
|
399
|
+
const username = currentUser.username;
|
|
400
|
+
if (typeof username !== "string" || !username) {
|
|
401
|
+
throw new Error("authenticated GitLab user has no username");
|
|
402
|
+
}
|
|
403
|
+
const candidates = parseGlabPaginatedJson(notesResult.stdout).filter((note) => {
|
|
404
|
+
const noteBody = note.body;
|
|
405
|
+
const author = note.author;
|
|
406
|
+
const authorUsername = author && typeof author === "object" ? author.username : void 0;
|
|
407
|
+
if (typeof noteBody !== "string" || authorUsername !== username || note.system === true || note.type != null || note.position != null) {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
return noteBody.includes(HODOR_SUMMARY_MARKER) || HODOR_SHA_PREFIX_RE.test(noteBody) && noteBody.includes(HODOR_REVIEW_MARKER);
|
|
411
|
+
}).sort((a, b) => {
|
|
412
|
+
const aTime = Date.parse(String(a.updated_at ?? a.created_at ?? ""));
|
|
413
|
+
const bTime = Date.parse(String(b.updated_at ?? b.created_at ?? ""));
|
|
414
|
+
return (Number.isFinite(bTime) ? bTime : 0) - (Number.isFinite(aTime) ? aTime : 0);
|
|
415
|
+
});
|
|
416
|
+
const noteId = candidates[0]?.id;
|
|
417
|
+
if (typeof noteId !== "number" && typeof noteId !== "string") {
|
|
418
|
+
await postGitlabMrComment(owner, repo, mrNumber, body, host);
|
|
419
|
+
return "created";
|
|
420
|
+
}
|
|
421
|
+
await exec(
|
|
422
|
+
"glab",
|
|
423
|
+
[
|
|
424
|
+
"api",
|
|
425
|
+
`projects/${encoded}/merge_requests/${mrNumber}/notes/${noteId}`,
|
|
426
|
+
"--method",
|
|
427
|
+
"PUT",
|
|
428
|
+
"-H",
|
|
429
|
+
"Content-Type: application/json",
|
|
430
|
+
"--input",
|
|
431
|
+
"-"
|
|
432
|
+
],
|
|
433
|
+
{ env, input: JSON.stringify({ body }) }
|
|
434
|
+
);
|
|
435
|
+
return "updated";
|
|
436
|
+
} catch (err) {
|
|
437
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
438
|
+
throw new GitLabAPIError(`Failed to upsert summary for MR !${mrNumber}: ${msg}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function summarizeGitlabNotes(notes, maxEntries = 5) {
|
|
442
|
+
return summarizeNotes(notes, maxEntries, (note) => !isHodorNote(note.body));
|
|
443
|
+
}
|
|
444
|
+
function summarizeHodorNotes(notes, maxEntries = 5) {
|
|
445
|
+
return summarizeNotes(notes, maxEntries, (note) => isHodorNote(note.body));
|
|
446
|
+
}
|
|
447
|
+
function summarizeNotes(notes, maxEntries, include) {
|
|
448
|
+
if (!notes || notes.length === 0) return "";
|
|
449
|
+
const trivialPatterns = /* @__PURE__ */ new Set([
|
|
450
|
+
"lgtm",
|
|
451
|
+
"+1",
|
|
452
|
+
"-1",
|
|
453
|
+
"\u{1F44D}",
|
|
454
|
+
"\u{1F44E}",
|
|
455
|
+
"thanks",
|
|
456
|
+
"thank you",
|
|
457
|
+
"looks good",
|
|
458
|
+
"approved",
|
|
459
|
+
"\u{1F680}",
|
|
460
|
+
"\u2705",
|
|
461
|
+
"\u274C"
|
|
462
|
+
]);
|
|
463
|
+
const filtered = [];
|
|
464
|
+
for (const note of notes) {
|
|
465
|
+
if (!include(note)) continue;
|
|
466
|
+
const body = (note.body ?? "").replace(HODOR_CACHE_MARKER_RE, "").trim();
|
|
467
|
+
if (!body) continue;
|
|
468
|
+
if (note.system) continue;
|
|
469
|
+
if (body.length < 20) continue;
|
|
470
|
+
const bodyLower = body.toLowerCase();
|
|
471
|
+
let isTrivial = false;
|
|
472
|
+
for (const pattern of trivialPatterns) {
|
|
473
|
+
if (bodyLower.includes(pattern) && body.length < 50) {
|
|
474
|
+
isTrivial = true;
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (isTrivial) continue;
|
|
479
|
+
const username = note.author?.username ?? note.author?.name ?? "unknown";
|
|
480
|
+
filtered.push({ username, body, createdAt: note.created_at ?? "" });
|
|
481
|
+
}
|
|
482
|
+
filtered.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
483
|
+
const recent = filtered.slice(-maxEntries);
|
|
484
|
+
const lines = [];
|
|
485
|
+
for (const { username, body, createdAt } of recent) {
|
|
486
|
+
let timestampStr = "";
|
|
487
|
+
if (createdAt) {
|
|
488
|
+
try {
|
|
489
|
+
const dt = new Date(createdAt);
|
|
490
|
+
timestampStr = dt.toISOString().replace("T", " ").slice(0, 16);
|
|
491
|
+
} catch {
|
|
492
|
+
timestampStr = createdAt.slice(0, 10);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const header = timestampStr ? `- ${timestampStr} @${username}:` : `- @${username}:`;
|
|
496
|
+
const boundedBody = body.length > 2e3 ? `${body.slice(0, 1999).trimEnd()}\u2026` : body;
|
|
497
|
+
const indentedBody = boundedBody.split("\n").join("\n ");
|
|
498
|
+
lines.push(`${header}
|
|
499
|
+
${indentedBody}`);
|
|
500
|
+
}
|
|
501
|
+
return lines.join("\n");
|
|
502
|
+
}
|
|
503
|
+
async function getGitlabMrDiffRefs(owner, repo, mrNumber, host) {
|
|
504
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
505
|
+
const env = glabEnv(host);
|
|
506
|
+
let mrData;
|
|
507
|
+
try {
|
|
508
|
+
mrData = await execJson(
|
|
509
|
+
"glab",
|
|
510
|
+
["api", `projects/${encoded}/merge_requests/${mrNumber}`],
|
|
511
|
+
{ env }
|
|
512
|
+
);
|
|
513
|
+
} catch (err) {
|
|
514
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
515
|
+
throw new GitLabAPIError(`Failed to fetch diff refs for MR !${mrNumber}: ${msg}`);
|
|
516
|
+
}
|
|
517
|
+
const diffRefs = mrData.diff_refs;
|
|
518
|
+
const base_sha = diffRefs?.base_sha;
|
|
519
|
+
const head_sha = diffRefs?.head_sha;
|
|
520
|
+
const start_sha = diffRefs?.start_sha;
|
|
521
|
+
if (typeof base_sha !== "string" || typeof head_sha !== "string" || typeof start_sha !== "string" || !base_sha || !head_sha || !start_sha) {
|
|
522
|
+
throw new GitLabAPIError(`MR !${mrNumber} has missing or incomplete diff_refs`);
|
|
523
|
+
}
|
|
524
|
+
return { base_sha, head_sha, start_sha };
|
|
525
|
+
}
|
|
526
|
+
async function createGitlabDraftNote(owner, repo, mrNumber, body, host, opts) {
|
|
527
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
528
|
+
const env = glabEnv(host);
|
|
529
|
+
const endpoint = `projects/${encoded}/merge_requests/${mrNumber}/draft_notes`;
|
|
530
|
+
const payload = {
|
|
531
|
+
note: body
|
|
532
|
+
};
|
|
533
|
+
if (opts?.filePath && typeof opts.line === "number" && opts.diffRefs) {
|
|
534
|
+
payload.position = {
|
|
535
|
+
base_sha: opts.diffRefs.base_sha,
|
|
536
|
+
head_sha: opts.diffRefs.head_sha,
|
|
537
|
+
start_sha: opts.diffRefs.start_sha,
|
|
538
|
+
position_type: "text",
|
|
539
|
+
old_path: opts.filePath,
|
|
540
|
+
new_path: opts.filePath,
|
|
541
|
+
new_line: opts.line
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
return await execJson(
|
|
546
|
+
"glab",
|
|
547
|
+
["api", endpoint, "--method", "POST", "-H", "Content-Type: application/json", "--input", "-"],
|
|
548
|
+
{
|
|
549
|
+
env,
|
|
550
|
+
input: JSON.stringify(payload)
|
|
551
|
+
}
|
|
552
|
+
);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
555
|
+
throw new GitLabAPIError(`Failed to create draft note for MR !${mrNumber}: ${msg}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
async function bulkPublishGitlabDraftNotes(owner, repo, mrNumber, host) {
|
|
559
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
560
|
+
const env = glabEnv(host);
|
|
561
|
+
try {
|
|
562
|
+
await exec(
|
|
563
|
+
"glab",
|
|
564
|
+
[
|
|
565
|
+
"api",
|
|
566
|
+
`projects/${encoded}/merge_requests/${mrNumber}/draft_notes/bulk_publish`,
|
|
567
|
+
"--method",
|
|
568
|
+
"POST"
|
|
569
|
+
],
|
|
570
|
+
{ env }
|
|
571
|
+
);
|
|
572
|
+
} catch (err) {
|
|
573
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
574
|
+
throw new GitLabAPIError(`Failed to bulk publish draft notes for MR !${mrNumber}: ${msg}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function publishGitlabDraftNote(owner, repo, mrNumber, draftNoteId, host) {
|
|
578
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
579
|
+
const env = glabEnv(host);
|
|
580
|
+
try {
|
|
581
|
+
await exec(
|
|
582
|
+
"glab",
|
|
583
|
+
[
|
|
584
|
+
"api",
|
|
585
|
+
`projects/${encoded}/merge_requests/${mrNumber}/draft_notes/${draftNoteId}/publish`,
|
|
586
|
+
"--method",
|
|
587
|
+
"PUT"
|
|
588
|
+
],
|
|
589
|
+
{ env }
|
|
590
|
+
);
|
|
591
|
+
} catch (err) {
|
|
592
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
593
|
+
throw new GitLabAPIError(`Failed to publish draft note ${draftNoteId} for MR !${mrNumber}: ${msg}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async function postGitlabCommitStatus(owner, repo, sha, state, host, opts) {
|
|
597
|
+
const allowedStates = /* @__PURE__ */ new Set([
|
|
598
|
+
"pending",
|
|
599
|
+
"running",
|
|
600
|
+
"success",
|
|
601
|
+
"failed",
|
|
602
|
+
"canceled"
|
|
603
|
+
]);
|
|
604
|
+
if (!allowedStates.has(state)) {
|
|
605
|
+
throw new GitLabAPIError(`Invalid GitLab commit status state: ${state}`);
|
|
606
|
+
}
|
|
607
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
608
|
+
const env = glabEnv(host);
|
|
609
|
+
const endpoint = `projects/${encoded}/statuses/${sha}`;
|
|
610
|
+
const payload = {
|
|
611
|
+
state,
|
|
612
|
+
name: opts?.name ?? "hodor"
|
|
613
|
+
};
|
|
614
|
+
if (opts?.description) {
|
|
615
|
+
payload.description = opts.description;
|
|
616
|
+
}
|
|
617
|
+
if (opts?.targetUrl) {
|
|
618
|
+
payload.target_url = opts.targetUrl;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
await exec(
|
|
622
|
+
"glab",
|
|
623
|
+
["api", endpoint, "--method", "POST", "-H", "Content-Type: application/json", "--input", "-"],
|
|
624
|
+
{
|
|
625
|
+
env,
|
|
626
|
+
input: JSON.stringify(payload)
|
|
627
|
+
}
|
|
628
|
+
);
|
|
629
|
+
} catch (err) {
|
|
630
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
631
|
+
throw new GitLabAPIError(`Failed to post commit status for ${sha}: ${msg}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
async function listHodorDiscussions(owner, repo, mrNumber, host, marker = HODOR_REVIEW_MARKER) {
|
|
635
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
636
|
+
const env = glabEnv(host);
|
|
637
|
+
let discussions;
|
|
638
|
+
try {
|
|
639
|
+
const { stdout: rawDiscussions } = await exec(
|
|
640
|
+
"glab",
|
|
641
|
+
[
|
|
642
|
+
"api",
|
|
643
|
+
`projects/${encoded}/merge_requests/${mrNumber}/discussions?per_page=100`,
|
|
644
|
+
"--paginate"
|
|
645
|
+
],
|
|
646
|
+
{ env }
|
|
647
|
+
);
|
|
648
|
+
discussions = parseGlabPaginatedJson(rawDiscussions);
|
|
649
|
+
} catch (err) {
|
|
650
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
651
|
+
throw new GitLabAPIError(`Failed to list discussions for MR !${mrNumber}: ${msg}`);
|
|
652
|
+
}
|
|
653
|
+
const results = [];
|
|
654
|
+
for (const discussion of discussions) {
|
|
655
|
+
const discussionId = discussion.id;
|
|
656
|
+
if (typeof discussionId !== "string") {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const notes = discussion.notes;
|
|
660
|
+
if (!Array.isArray(notes)) {
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
for (const note of notes) {
|
|
664
|
+
if (!note || typeof note !== "object") {
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
const noteObj = note;
|
|
668
|
+
const noteId = noteObj.id;
|
|
669
|
+
const body = noteObj.body;
|
|
670
|
+
if (typeof noteId !== "number" || typeof body !== "string" || !isHodorNote(body, marker)) {
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const position = noteObj.position && typeof noteObj.position === "object" ? noteObj.position : void 0;
|
|
674
|
+
const filePath = typeof position?.new_path === "string" ? position.new_path : typeof position?.old_path === "string" ? position.old_path : void 0;
|
|
675
|
+
const line = typeof position?.new_line === "number" ? position.new_line : typeof position?.old_line === "number" ? position.old_line : void 0;
|
|
676
|
+
if (noteObj.resolvable !== true) {
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
results.push({
|
|
680
|
+
discussionId,
|
|
681
|
+
noteId,
|
|
682
|
+
body,
|
|
683
|
+
resolved: Boolean(noteObj.resolved),
|
|
684
|
+
filePath,
|
|
685
|
+
line
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return results;
|
|
690
|
+
}
|
|
691
|
+
async function resolveGitlabDiscussions(owner, repo, mrNumber, discussionIds, host) {
|
|
692
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
693
|
+
const env = glabEnv(host);
|
|
694
|
+
let resolvedCount = 0;
|
|
695
|
+
for (const discussionId of discussionIds) {
|
|
696
|
+
try {
|
|
697
|
+
await exec(
|
|
698
|
+
"glab",
|
|
699
|
+
[
|
|
700
|
+
"api",
|
|
701
|
+
`projects/${encoded}/merge_requests/${mrNumber}/discussions/${discussionId}`,
|
|
702
|
+
"--method",
|
|
703
|
+
"PUT",
|
|
704
|
+
"-H",
|
|
705
|
+
"Content-Type: application/json",
|
|
706
|
+
"--input",
|
|
707
|
+
"-"
|
|
708
|
+
],
|
|
709
|
+
{
|
|
710
|
+
env,
|
|
711
|
+
input: JSON.stringify({ resolved: true })
|
|
712
|
+
}
|
|
713
|
+
);
|
|
714
|
+
resolvedCount += 1;
|
|
715
|
+
} catch (err) {
|
|
716
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
717
|
+
logger.warn(`Failed to resolve discussion ${discussionId} on MR !${mrNumber}: ${msg}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return resolvedCount;
|
|
721
|
+
}
|
|
23
722
|
|
|
24
723
|
// src/prompt.ts
|
|
25
724
|
import { readFileSync } from "fs";
|
|
@@ -56,19 +755,19 @@ function getHodorReviewShaCandidates(notes) {
|
|
|
56
755
|
for (const [index, note] of notes.entries()) {
|
|
57
756
|
const match = note.body?.match(HODOR_REVIEW_SHA_RE);
|
|
58
757
|
if (!match) continue;
|
|
59
|
-
const
|
|
758
|
+
const reviewedAtMs = Date.parse(note.updated_at ?? note.created_at ?? "");
|
|
60
759
|
candidates.push({
|
|
61
760
|
sha: match[1],
|
|
62
|
-
|
|
761
|
+
reviewedAtMs: Number.isFinite(reviewedAtMs) ? reviewedAtMs : null,
|
|
63
762
|
index
|
|
64
763
|
});
|
|
65
764
|
}
|
|
66
765
|
candidates.sort((a, b) => {
|
|
67
|
-
if (a.
|
|
68
|
-
return b.
|
|
766
|
+
if (a.reviewedAtMs != null && b.reviewedAtMs != null && a.reviewedAtMs !== b.reviewedAtMs) {
|
|
767
|
+
return b.reviewedAtMs - a.reviewedAtMs;
|
|
69
768
|
}
|
|
70
|
-
if (a.
|
|
71
|
-
if (a.
|
|
769
|
+
if (a.reviewedAtMs != null && b.reviewedAtMs == null) return -1;
|
|
770
|
+
if (a.reviewedAtMs == null && b.reviewedAtMs != null) return 1;
|
|
72
771
|
return a.index - b.index;
|
|
73
772
|
});
|
|
74
773
|
return [...new Set(candidates.map(({ sha }) => sha))];
|
|
@@ -173,7 +872,8 @@ function buildPrReviewPrompt(opts) {
|
|
|
173
872
|
reviewDiffMode,
|
|
174
873
|
changedFiles = [],
|
|
175
874
|
localMode = false,
|
|
176
|
-
singleTurn = false
|
|
875
|
+
singleTurn = false,
|
|
876
|
+
findToolAvailable = false
|
|
177
877
|
} = opts;
|
|
178
878
|
const rebasedGitlabReview = platform === "gitlab" && reviewDiffMode === "snapshot";
|
|
179
879
|
const hasPreviousReviewDelta = Boolean(previousReviewSha && !rebasedGitlabReview);
|
|
@@ -281,12 +981,13 @@ ${changedFiles.map((file) => `- \`${file}\``).join("\n")}
|
|
|
281
981
|
}
|
|
282
982
|
runtimeToolsSection = oneTurn ? "## Runtime Tools\n\n- `submit_review` submits the completed review. It is the only tool available for this review.\n" : `## Runtime Tools
|
|
283
983
|
|
|
984
|
+
This list is exhaustive. No other tool is available.
|
|
985
|
+
|
|
284
986
|
- \`${prDiffCmd}\` lists the changed files when a diff is not embedded.
|
|
285
987
|
- \`${gitDiffCmd} -- path/to/file\` shows the delta for one changed file.
|
|
286
988
|
- \`read\` provides bounded surrounding context.
|
|
287
989
|
- \`grep\` searches for directly relevant code and contracts.
|
|
288
|
-
-
|
|
289
|
-
`;
|
|
990
|
+
` + (findToolAvailable ? "- `find` locates files by glob when the path is unknown.\n" : "") + "- `ls` lists the entries of one directory.\n- `bash` runs the git diff commands above and other read-only shell inspection.\n- `submit_review` submits the completed review.\n";
|
|
290
991
|
return templateText.replace(/\{pr_url\}/g, prUrl).replace(/\{pr_diff_cmd\}/g, prDiffCmd).replace(/\{git_diff_cmd\}/g, gitDiffCmd).replace(/\{mr_context_section\}/g, contextSection).replace(/\{mr_notes_section\}/g, notesSection).replace(/\{mr_reminder_section\}/g, reminderSection).replace(/\{incremental_section\}/g, incrementalSection).replace(/\{embedded_diff_section\}/g, embeddedDiffSection).replace(/\{diff_fetch_instructions\}/g, diffFetchInstructions).replace(/\{runtime_tools_section\}/g, runtimeToolsSection).replace(/\{review_process_section\}/g, reviewProcessSection).replace(/\{start_instruction\}/g, startInstruction);
|
|
291
992
|
}
|
|
292
993
|
function buildMrSections(mrMetadata) {
|
|
@@ -562,7 +1263,7 @@ function getApiKey(model) {
|
|
|
562
1263
|
}
|
|
563
1264
|
|
|
564
1265
|
// src/metrics.ts
|
|
565
|
-
import
|
|
1266
|
+
import chalk2 from "chalk";
|
|
566
1267
|
function tok(value) {
|
|
567
1268
|
if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
|
|
568
1269
|
if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
|
|
@@ -596,9 +1297,9 @@ function formatMetricsMarkdown(metrics) {
|
|
|
596
1297
|
return lines.join("\n");
|
|
597
1298
|
}
|
|
598
1299
|
function printMetrics(metrics, stream = process.stderr) {
|
|
599
|
-
const dim =
|
|
600
|
-
const bold =
|
|
601
|
-
const cyan =
|
|
1300
|
+
const dim = chalk2.dim;
|
|
1301
|
+
const bold = chalk2.bold;
|
|
1302
|
+
const cyan = chalk2.cyan;
|
|
602
1303
|
const write = (line) => stream.write(line + "\n");
|
|
603
1304
|
write("");
|
|
604
1305
|
write(dim("\u2500".repeat(50)));
|
|
@@ -804,7 +1505,7 @@ The selected review instructions and additional instructions are reviewer policy
|
|
|
804
1505
|
|
|
805
1506
|
## Read-Only Review
|
|
806
1507
|
|
|
807
|
-
Analyze only the changed delta and report findings at changed-line locations. Do not modify or create files, commit, install dependencies, run package managers, or write plans or agent instructions. Do not review unrelated files or report issues that exist only because the branch lacks changes already present on the target branch.
|
|
1508
|
+
Analyze only the changed delta and report findings at changed-line locations. Do not modify or create files, commit, install dependencies, run package managers, or write plans or agent instructions. Do not build, compile, run tests, or run linters or formatters. The review environment is a read-only inspection container: language toolchains, compilers, and test runners are not installed, and their absence is never a finding. Establish every finding by reading the delta and the code around it. Do not review unrelated files or report issues that exist only because the branch lacks changes already present on the target branch.
|
|
808
1509
|
|
|
809
1510
|
## Priority Mapping
|
|
810
1511
|
|
|
@@ -817,7 +1518,7 @@ Every finding title begins with its matching [P0], [P1], [P2], or [P3] tag, and
|
|
|
817
1518
|
|
|
818
1519
|
## Tool Discipline and Efficiency
|
|
819
1520
|
|
|
820
|
-
Use available tools only when they establish evidence for the changed delta. Start with the runtime task's supplied diff or changed-file command. Use bounded reads and targeted searches for directly relevant context; avoid redundant reads, searches, and diffs. Scale investigation to the delta size.
|
|
1521
|
+
Use available tools only when they establish evidence for the changed delta. Start with the runtime task's supplied diff or changed-file command. Use bounded reads and targeted searches for directly relevant context; avoid redundant reads, searches, and diffs. Never repeat a read, search, or diff whose result is already in context, and prefer a scoped diff or bounded read over one that returns the whole change or the whole file. Scale investigation to the delta size. The runtime task's tool list is exhaustive: do not call a tool it does not name, and do not probe for executables through the shell to discover what else exists. Do not substitute shell commands for supplied file-search tools.
|
|
821
1522
|
|
|
822
1523
|
## Submission
|
|
823
1524
|
|
|
@@ -969,9 +1670,101 @@ function parsePositiveNumber(raw, kind, prUrl, segment) {
|
|
|
969
1670
|
return number;
|
|
970
1671
|
}
|
|
971
1672
|
|
|
972
|
-
// src/
|
|
1673
|
+
// src/review-state.ts
|
|
973
1674
|
import { createHash } from "crypto";
|
|
974
1675
|
|
|
1676
|
+
// src/utils/path.ts
|
|
1677
|
+
function relativizeWorkspacePath(absolutePath, workspacePrefix) {
|
|
1678
|
+
let filePath = absolutePath;
|
|
1679
|
+
const prefix = workspacePrefix ?? process.env.CI_PROJECT_DIR;
|
|
1680
|
+
if (prefix) {
|
|
1681
|
+
const trimmed = prefix.replace(/\/+$/, "");
|
|
1682
|
+
if (filePath.startsWith(`${trimmed}/`)) {
|
|
1683
|
+
return filePath.slice(trimmed.length + 1);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
const buildsMatch = filePath.match(/\/builds\/[^/]+\/[^/]+\/(.+)/);
|
|
1687
|
+
if (buildsMatch) return buildsMatch[1];
|
|
1688
|
+
if (filePath.includes("/workspace/")) {
|
|
1689
|
+
return filePath.slice(filePath.indexOf("/workspace/") + "/workspace/".length);
|
|
1690
|
+
}
|
|
1691
|
+
const stripped = filePath.replace(/^.*\/hodor-review-[^/]+\//, "");
|
|
1692
|
+
return stripped !== filePath ? stripped : filePath;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// src/review-state.ts
|
|
1696
|
+
var FINDING_MARKER_RE = /<!--\s*hodor:finding:([a-f0-9]{64})\s*-->/i;
|
|
1697
|
+
var FINDING_TITLE_RE = /^\*\*(\[P([0-3])\]\s+.+)\*\*\s*$/m;
|
|
1698
|
+
function getFindingFingerprint(finding, workspacePath) {
|
|
1699
|
+
const path = relativizeWorkspacePath(
|
|
1700
|
+
finding.code_location.absolute_file_path,
|
|
1701
|
+
workspacePath ?? void 0
|
|
1702
|
+
);
|
|
1703
|
+
const title = finding.title.replace(/^\[P[0-3]\]\s*/, "").trim().toLowerCase();
|
|
1704
|
+
return createHash("sha256").update(`${path}
|
|
1705
|
+
${title}`).digest("hex");
|
|
1706
|
+
}
|
|
1707
|
+
function getDiscussionFingerprint(body) {
|
|
1708
|
+
return body.match(FINDING_MARKER_RE)?.[1]?.toLowerCase() ?? null;
|
|
1709
|
+
}
|
|
1710
|
+
function parseDiscussionFinding(discussion) {
|
|
1711
|
+
const fingerprint = getDiscussionFingerprint(discussion.body);
|
|
1712
|
+
const titleMatch = discussion.body.match(FINDING_TITLE_RE);
|
|
1713
|
+
if (!fingerprint || !titleMatch) return null;
|
|
1714
|
+
const titleLineEnd = discussion.body.indexOf("\n", titleMatch.index ?? 0);
|
|
1715
|
+
const remainder = titleLineEnd >= 0 ? discussion.body.slice(titleLineEnd + 1).trim() : "";
|
|
1716
|
+
const suggestionStart = remainder.indexOf("\n\n```suggestion");
|
|
1717
|
+
const body = (suggestionStart >= 0 ? remainder.slice(0, suggestionStart) : remainder).trim();
|
|
1718
|
+
const priority = Number(titleMatch[2]);
|
|
1719
|
+
return {
|
|
1720
|
+
fingerprint,
|
|
1721
|
+
title: titleMatch[1],
|
|
1722
|
+
body,
|
|
1723
|
+
priority,
|
|
1724
|
+
filePath: discussion.filePath,
|
|
1725
|
+
lineRange: discussion.line == null ? void 0 : { start: discussion.line, end: discussion.line }
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
function mergeReviewStateFindings(currentFindings, discussions, workspacePath, options = {}) {
|
|
1729
|
+
const { includeExisting = true, suppressResolvedCurrent = false } = options;
|
|
1730
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1731
|
+
const openFingerprints = /* @__PURE__ */ new Set();
|
|
1732
|
+
const resolvedFingerprints = /* @__PURE__ */ new Set();
|
|
1733
|
+
for (const discussion of discussions) {
|
|
1734
|
+
const fingerprint = getDiscussionFingerprint(discussion.body);
|
|
1735
|
+
if (!fingerprint) continue;
|
|
1736
|
+
if (discussion.resolved) resolvedFingerprints.add(fingerprint);
|
|
1737
|
+
else openFingerprints.add(fingerprint);
|
|
1738
|
+
}
|
|
1739
|
+
for (const finding of currentFindings) {
|
|
1740
|
+
const fingerprint = getFindingFingerprint(finding, workspacePath);
|
|
1741
|
+
if (suppressResolvedCurrent && resolvedFingerprints.has(fingerprint) && !openFingerprints.has(fingerprint)) {
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
merged.set(fingerprint, {
|
|
1745
|
+
fingerprint,
|
|
1746
|
+
title: finding.title,
|
|
1747
|
+
body: finding.body,
|
|
1748
|
+
priority: finding.priority,
|
|
1749
|
+
filePath: relativizeWorkspacePath(
|
|
1750
|
+
finding.code_location.absolute_file_path,
|
|
1751
|
+
workspacePath ?? void 0
|
|
1752
|
+
),
|
|
1753
|
+
lineRange: finding.code_location.line_range
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
if (includeExisting) {
|
|
1757
|
+
for (const discussion of discussions) {
|
|
1758
|
+
if (discussion.resolved) continue;
|
|
1759
|
+
const finding = parseDiscussionFinding(discussion);
|
|
1760
|
+
if (finding && !merged.has(finding.fingerprint)) {
|
|
1761
|
+
merged.set(finding.fingerprint, finding);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
return [...merged.values()];
|
|
1766
|
+
}
|
|
1767
|
+
|
|
975
1768
|
// src/gitea.ts
|
|
976
1769
|
var GiteaAPIError = class extends Error {
|
|
977
1770
|
constructor(message) {
|
|
@@ -979,7 +1772,7 @@ var GiteaAPIError = class extends Error {
|
|
|
979
1772
|
this.name = "GiteaAPIError";
|
|
980
1773
|
}
|
|
981
1774
|
};
|
|
982
|
-
function
|
|
1775
|
+
function normalizeBaseUrl2(host) {
|
|
983
1776
|
const candidate = host || process.env.GITEA_HOST || process.env.FORGEJO_HOST;
|
|
984
1777
|
if (!candidate) {
|
|
985
1778
|
throw new GiteaAPIError(
|
|
@@ -1005,7 +1798,7 @@ function requireGiteaToken() {
|
|
|
1005
1798
|
return token;
|
|
1006
1799
|
}
|
|
1007
1800
|
async function giteaFetch(host, path, options) {
|
|
1008
|
-
const baseUrl =
|
|
1801
|
+
const baseUrl = normalizeBaseUrl2(host);
|
|
1009
1802
|
const url = `${baseUrl}/api/v1/${path}`;
|
|
1010
1803
|
const token = giteaToken();
|
|
1011
1804
|
const headers = {
|
|
@@ -1135,23 +1928,10 @@ async function postGiteaPrComment(owner, repo, prNumber, body, host) {
|
|
|
1135
1928
|
}
|
|
1136
1929
|
|
|
1137
1930
|
// src/publisher.ts
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
const path = relativizeWorkspacePath(
|
|
1141
|
-
finding.code_location.absolute_file_path,
|
|
1142
|
-
workspacePath ?? void 0
|
|
1143
|
-
);
|
|
1144
|
-
const title = finding.title.replace(/^\[P[0-3]\]\s*/, "").trim().toLowerCase();
|
|
1145
|
-
return createHash("sha256").update(`${path}
|
|
1146
|
-
${title}`).digest("hex");
|
|
1147
|
-
}
|
|
1148
|
-
function getDiscussionFingerprint(body) {
|
|
1149
|
-
return body.match(FINDING_MARKER_RE)?.[1]?.toLowerCase() ?? null;
|
|
1150
|
-
}
|
|
1151
|
-
async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
|
|
1152
|
-
const blocking = review.findings.filter((finding) => finding.priority <= 1).length;
|
|
1931
|
+
async function postGitlabReviewCommitStatus(parsed, findings, diffRefs) {
|
|
1932
|
+
const blocking = findings.filter((finding) => finding.priority <= 1).length;
|
|
1153
1933
|
const state = blocking > 0 ? "failed" : "success";
|
|
1154
|
-
const description = blocking > 0 ? `${blocking} blocking issue(s) found` :
|
|
1934
|
+
const description = blocking > 0 ? `${blocking} blocking issue(s) found` : findings.length > 0 ? `${findings.length} non-blocking issue(s)` : "No issues found";
|
|
1155
1935
|
await postGitlabCommitStatus(
|
|
1156
1936
|
parsed.owner,
|
|
1157
1937
|
parsed.repo,
|
|
@@ -1161,23 +1941,38 @@ async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
|
|
|
1161
1941
|
{ description }
|
|
1162
1942
|
);
|
|
1163
1943
|
}
|
|
1944
|
+
function appendReviewDetails(body, model, metricsFooter) {
|
|
1945
|
+
if (!model && !metricsFooter) return body;
|
|
1946
|
+
const details = ["<details>", "<summary>Review details</summary>", ""];
|
|
1947
|
+
if (model) details.push(`- Model: \`${model}\``);
|
|
1948
|
+
if (metricsFooter) {
|
|
1949
|
+
if (model) details.push("");
|
|
1950
|
+
details.push(metricsFooter);
|
|
1951
|
+
}
|
|
1952
|
+
details.push("", "</details>");
|
|
1953
|
+
return `${body.trimEnd()}
|
|
1954
|
+
|
|
1955
|
+
${details.join("\n")}
|
|
1956
|
+
`;
|
|
1957
|
+
}
|
|
1164
1958
|
async function postReviewComment(opts) {
|
|
1165
1959
|
const { prUrl, reviewText, model, metricsFooter, headSha, cacheMarker } = opts;
|
|
1166
1960
|
const platform = detectPlatform(prUrl);
|
|
1167
1961
|
const parsed = parsePrUrl(prUrl);
|
|
1168
1962
|
let body = reviewText;
|
|
1963
|
+
if (platform === "gitlab" && !body.includes(HODOR_SUMMARY_MARKER)) {
|
|
1964
|
+
body = body.replace(
|
|
1965
|
+
HODOR_REVIEW_MARKER,
|
|
1966
|
+
`${HODOR_REVIEW_MARKER}
|
|
1967
|
+
${HODOR_SUMMARY_MARKER}`
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1169
1970
|
if (headSha) body = `<!-- hodor:sha:${headSha} -->
|
|
1170
1971
|
${body}`;
|
|
1171
1972
|
if (cacheMarker) body = body.replace("\n", `
|
|
1172
1973
|
${cacheMarker}
|
|
1173
1974
|
`);
|
|
1174
|
-
|
|
1175
|
-
---
|
|
1176
|
-
|
|
1177
|
-
Review generated by Hodor (model: \`${model}\`)`;
|
|
1178
|
-
if (metricsFooter) body += `
|
|
1179
|
-
|
|
1180
|
-
${metricsFooter}`;
|
|
1975
|
+
body = appendReviewDetails(body, model, metricsFooter);
|
|
1181
1976
|
try {
|
|
1182
1977
|
if (platform === "github") {
|
|
1183
1978
|
await exec("gh", [
|
|
@@ -1202,7 +1997,7 @@ ${metricsFooter}`;
|
|
|
1202
1997
|
);
|
|
1203
1998
|
return { success: true, platform, prNumber: parsed.prNumber };
|
|
1204
1999
|
}
|
|
1205
|
-
await
|
|
2000
|
+
await upsertGitlabMrSummary(
|
|
1206
2001
|
parsed.owner,
|
|
1207
2002
|
parsed.repo,
|
|
1208
2003
|
parsed.prNumber,
|
|
@@ -1233,10 +2028,13 @@ async function postReviewStructured(opts) {
|
|
|
1233
2028
|
workspacePath,
|
|
1234
2029
|
reconcileDiscussions = false,
|
|
1235
2030
|
cacheMarker,
|
|
1236
|
-
skipSummary = false
|
|
2031
|
+
skipSummary = false,
|
|
2032
|
+
existingDiscussions,
|
|
2033
|
+
skipInline = false,
|
|
2034
|
+
reviewMode
|
|
1237
2035
|
} = opts;
|
|
1238
2036
|
const platform = detectPlatform(prUrl);
|
|
1239
|
-
if (platform !== "gitlab"
|
|
2037
|
+
if (platform !== "gitlab") {
|
|
1240
2038
|
return postReviewComment({
|
|
1241
2039
|
prUrl,
|
|
1242
2040
|
reviewText: renderMarkdown(review),
|
|
@@ -1269,13 +2067,17 @@ async function postReviewStructured(opts) {
|
|
|
1269
2067
|
});
|
|
1270
2068
|
}
|
|
1271
2069
|
const existingByFingerprint = /* @__PURE__ */ new Map();
|
|
2070
|
+
let discussions = existingDiscussions ?? [];
|
|
2071
|
+
let discussionListingFailed = false;
|
|
1272
2072
|
try {
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
2073
|
+
if (!existingDiscussions) {
|
|
2074
|
+
discussions = await listHodorDiscussions(
|
|
2075
|
+
parsed.owner,
|
|
2076
|
+
parsed.repo,
|
|
2077
|
+
parsed.prNumber,
|
|
2078
|
+
parsed.host
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
1279
2081
|
for (const discussion of discussions) {
|
|
1280
2082
|
if (discussion.resolved) continue;
|
|
1281
2083
|
const fingerprint = getDiscussionFingerprint(discussion.body);
|
|
@@ -1285,63 +2087,77 @@ async function postReviewStructured(opts) {
|
|
|
1285
2087
|
existingByFingerprint.set(fingerprint, ids);
|
|
1286
2088
|
}
|
|
1287
2089
|
} catch (error) {
|
|
2090
|
+
discussionListingFailed = true;
|
|
1288
2091
|
const message = error instanceof Error ? error.message : String(error);
|
|
1289
|
-
if (reconcileDiscussions) {
|
|
2092
|
+
if (reconcileDiscussions || commitStatus) {
|
|
1290
2093
|
errors.push(`discussion listing: ${message}`);
|
|
1291
2094
|
}
|
|
1292
|
-
logger.warn(`Failed to list open Hodor discussions for
|
|
2095
|
+
logger.warn(`Failed to list open Hodor discussions for review state: ${message}`);
|
|
1293
2096
|
}
|
|
2097
|
+
const reviewFindings = mergeReviewStateFindings(
|
|
2098
|
+
review.findings,
|
|
2099
|
+
discussions,
|
|
2100
|
+
workspacePath,
|
|
2101
|
+
{
|
|
2102
|
+
includeExisting: !reconcileDiscussions,
|
|
2103
|
+
suppressResolvedCurrent: skipInline
|
|
2104
|
+
}
|
|
2105
|
+
);
|
|
1294
2106
|
let inlineCreated = 0;
|
|
1295
2107
|
let inlineFailed = 0;
|
|
1296
2108
|
let inlineDeduplicated = 0;
|
|
1297
2109
|
const draftNoteIds = [];
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
2110
|
+
const failedFindings = [];
|
|
2111
|
+
if (reviewStyle !== "summary" && !skipInline) {
|
|
2112
|
+
for (const finding of review.findings) {
|
|
2113
|
+
const fingerprint = getFindingFingerprint(finding, workspacePath);
|
|
2114
|
+
if (existingByFingerprint.has(fingerprint)) {
|
|
2115
|
+
inlineDeduplicated++;
|
|
2116
|
+
continue;
|
|
2117
|
+
}
|
|
2118
|
+
const relPath = relativizeWorkspacePath(
|
|
2119
|
+
finding.code_location.absolute_file_path,
|
|
2120
|
+
workspacePath ?? void 0
|
|
2121
|
+
);
|
|
2122
|
+
const title = /^\[P[0-3]\]/.test(finding.title) ? finding.title : `[P${finding.priority}] ${finding.title}`;
|
|
2123
|
+
let body = `${HODOR_REVIEW_MARKER}
|
|
1310
2124
|
<!-- hodor:finding:${fingerprint} -->
|
|
1311
2125
|
**${title}**
|
|
1312
2126
|
|
|
1313
2127
|
${finding.body}`;
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
2128
|
+
if (finding.suggestion) {
|
|
2129
|
+
const { start, end } = finding.code_location.line_range;
|
|
2130
|
+
const span = Math.max(0, end - start);
|
|
2131
|
+
body += `
|
|
1318
2132
|
|
|
1319
2133
|
\`\`\`suggestion:-0+${span}
|
|
1320
2134
|
${finding.suggestion}
|
|
1321
2135
|
\`\`\``;
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
2136
|
+
}
|
|
2137
|
+
try {
|
|
2138
|
+
const draftNote = await createGitlabDraftNote(
|
|
2139
|
+
parsed.owner,
|
|
2140
|
+
parsed.repo,
|
|
2141
|
+
parsed.prNumber,
|
|
2142
|
+
body,
|
|
2143
|
+
parsed.host,
|
|
2144
|
+
{
|
|
2145
|
+
filePath: relPath,
|
|
2146
|
+
line: finding.code_location.line_range.start,
|
|
2147
|
+
diffRefs
|
|
2148
|
+
}
|
|
2149
|
+
);
|
|
2150
|
+
if (typeof draftNote.id === "number" || typeof draftNote.id === "string") {
|
|
2151
|
+
draftNoteIds.push(draftNote.id);
|
|
1334
2152
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
2153
|
+
inlineCreated++;
|
|
2154
|
+
} catch (error) {
|
|
2155
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2156
|
+
errors.push(`inline note for ${finding.title}: ${message}`);
|
|
2157
|
+
logger.warn(`Failed to create inline note for "${finding.title}": ${message}`);
|
|
2158
|
+
inlineFailed++;
|
|
2159
|
+
failedFindings.push(finding);
|
|
1338
2160
|
}
|
|
1339
|
-
inlineCreated++;
|
|
1340
|
-
} catch (error) {
|
|
1341
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1342
|
-
errors.push(`inline note for ${finding.title}: ${message}`);
|
|
1343
|
-
logger.warn(`Failed to create inline note for "${finding.title}": ${message}`);
|
|
1344
|
-
inlineFailed++;
|
|
1345
2161
|
}
|
|
1346
2162
|
}
|
|
1347
2163
|
logger.info(
|
|
@@ -1388,22 +2204,24 @@ ${finding.suggestion}
|
|
|
1388
2204
|
}
|
|
1389
2205
|
}
|
|
1390
2206
|
let summaryPosted = false;
|
|
1391
|
-
if (!skipSummary && (reviewStyle === "hybrid" || review.findings.length === 0)) {
|
|
1392
|
-
|
|
2207
|
+
if (!skipSummary && (reviewStyle === "summary" || reviewStyle === "hybrid" || review.findings.length === 0 || failedFindings.length > 0)) {
|
|
2208
|
+
const fallbackFindings = reviewStyle === "summary" ? review.findings : failedFindings;
|
|
2209
|
+
let summaryBody = renderSummaryMarkdown(review, {
|
|
2210
|
+
openFindings: reviewFindings,
|
|
2211
|
+
fallbackFindings,
|
|
2212
|
+
fallbackHeading: reviewStyle === "summary" ? "Findings" : "Findings not posted inline",
|
|
2213
|
+
inlineCreated: reviewStyle === "summary" ? void 0 : inlineCreated,
|
|
2214
|
+
inlineDeduplicated: reviewStyle === "summary" ? void 0 : inlineDeduplicated,
|
|
2215
|
+
reviewMode
|
|
2216
|
+
});
|
|
1393
2217
|
if (headSha) summaryBody = `<!-- hodor:sha:${headSha} -->
|
|
1394
2218
|
${summaryBody}`;
|
|
1395
2219
|
if (cacheMarker) summaryBody = summaryBody.replace("\n", `
|
|
1396
2220
|
${cacheMarker}
|
|
1397
2221
|
`);
|
|
1398
|
-
|
|
1399
|
-
---
|
|
1400
|
-
|
|
1401
|
-
Review generated by Hodor (model: \`${model}\`)`;
|
|
1402
|
-
if (metricsFooter) summaryBody += `
|
|
1403
|
-
|
|
1404
|
-
${metricsFooter}`;
|
|
2222
|
+
summaryBody = appendReviewDetails(summaryBody, model, metricsFooter);
|
|
1405
2223
|
try {
|
|
1406
|
-
await
|
|
2224
|
+
await upsertGitlabMrSummary(
|
|
1407
2225
|
parsed.owner,
|
|
1408
2226
|
parsed.repo,
|
|
1409
2227
|
parsed.prNumber,
|
|
@@ -1414,13 +2232,13 @@ ${metricsFooter}`;
|
|
|
1414
2232
|
} catch (error) {
|
|
1415
2233
|
const message = error instanceof Error ? error.message : String(error);
|
|
1416
2234
|
errors.push(`summary comment: ${message}`);
|
|
1417
|
-
logger.warn(`Failed to
|
|
2235
|
+
logger.warn(`Failed to upsert summary comment: ${message}`);
|
|
1418
2236
|
}
|
|
1419
2237
|
}
|
|
1420
2238
|
let commitStatusPosted = false;
|
|
1421
|
-
if (commitStatus) {
|
|
2239
|
+
if (commitStatus && (!discussionListingFailed || reconcileDiscussions)) {
|
|
1422
2240
|
try {
|
|
1423
|
-
await postGitlabReviewCommitStatus(parsed,
|
|
2241
|
+
await postGitlabReviewCommitStatus(parsed, reviewFindings, diffRefs);
|
|
1424
2242
|
commitStatusPosted = true;
|
|
1425
2243
|
} catch (error) {
|
|
1426
2244
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1429,7 +2247,7 @@ ${metricsFooter}`;
|
|
|
1429
2247
|
}
|
|
1430
2248
|
}
|
|
1431
2249
|
let reconciledDiscussions = 0;
|
|
1432
|
-
const baseDeliveryComplete = reviewStyle === "
|
|
2250
|
+
const baseDeliveryComplete = reviewStyle === "summary" ? summaryPosted || skipSummary : reviewStyle === "hybrid" ? (summaryPosted || skipSummary) && (inlineCreated === 0 || draftsPublished) : (inlineFailed === 0 || summaryPosted) && (inlineCreated === 0 || draftsPublished) && (review.findings.length > 0 || summaryPosted);
|
|
1433
2251
|
if (reconcileDiscussions && baseDeliveryComplete) {
|
|
1434
2252
|
const currentFingerprints = new Set(
|
|
1435
2253
|
review.findings.map((finding) => getFindingFingerprint(finding, workspacePath))
|
|
@@ -1462,13 +2280,15 @@ ${metricsFooter}`;
|
|
|
1462
2280
|
inlineFailed,
|
|
1463
2281
|
draftsPublished,
|
|
1464
2282
|
commitStatusPosted,
|
|
1465
|
-
reconciledDiscussions
|
|
2283
|
+
reconciledDiscussions,
|
|
2284
|
+
reviewStateComplete: !discussionListingFailed || reconcileDiscussions,
|
|
2285
|
+
reviewFindings
|
|
1466
2286
|
};
|
|
1467
2287
|
}
|
|
1468
2288
|
|
|
1469
2289
|
// src/agent.ts
|
|
1470
2290
|
import { existsSync } from "fs";
|
|
1471
|
-
import { join as
|
|
2291
|
+
import { join as join3 } from "path";
|
|
1472
2292
|
import {
|
|
1473
2293
|
createAgentSession,
|
|
1474
2294
|
DefaultResourceLoader,
|
|
@@ -1576,7 +2396,7 @@ function githubCommentsToNotes(comments) {
|
|
|
1576
2396
|
// src/workspace.ts
|
|
1577
2397
|
import { mkdtemp, rm } from "fs/promises";
|
|
1578
2398
|
import { tmpdir } from "os";
|
|
1579
|
-
import { join } from "path";
|
|
2399
|
+
import { join as join2 } from "path";
|
|
1580
2400
|
var WorkspaceError = class extends Error {
|
|
1581
2401
|
constructor(message) {
|
|
1582
2402
|
super(message);
|
|
@@ -1890,7 +2710,7 @@ async function setupWorkspace(opts) {
|
|
|
1890
2710
|
detectedTargetBranch = await getGithubBaseBranch(workspace, prNumber);
|
|
1891
2711
|
}
|
|
1892
2712
|
} else if (!workingDir) {
|
|
1893
|
-
workspace = await mkdtemp(
|
|
2713
|
+
workspace = await mkdtemp(join2(tmpdir(), "hodor-review-"));
|
|
1894
2714
|
isTemporary = true;
|
|
1895
2715
|
logger.info(`Created temporary workspace: ${workspace}`);
|
|
1896
2716
|
} else {
|
|
@@ -2173,7 +2993,7 @@ function buildReviewCacheMarker(key, review, workspacePath) {
|
|
|
2173
2993
|
function findCachedReview(notes, key) {
|
|
2174
2994
|
if (!notes) return null;
|
|
2175
2995
|
const newestFirst = [...notes].sort(
|
|
2176
|
-
(a, b) => Date.parse(b.created_at ?? "") - Date.parse(a.created_at ?? "")
|
|
2996
|
+
(a, b) => Date.parse(b.updated_at ?? b.created_at ?? "") - Date.parse(a.updated_at ?? a.created_at ?? "")
|
|
2177
2997
|
);
|
|
2178
2998
|
for (const note of newestFirst) {
|
|
2179
2999
|
const encoded = note.body?.match(CACHE_MARKER_RE)?.[1];
|
|
@@ -2589,6 +3409,10 @@ async function reviewPr(opts) {
|
|
|
2589
3409
|
`Single-turn fast path: tiny low-risk diff (${diffStats?.files} file(s), ${(diffStats?.additions ?? 0) + (diffStats?.deletions ?? 0)} changed line(s)); exposing only submit_review`
|
|
2590
3410
|
);
|
|
2591
3411
|
}
|
|
3412
|
+
const findToolAvailable = commandOnPath("fd") || commandOnPath("fdfind");
|
|
3413
|
+
if (!findToolAvailable) {
|
|
3414
|
+
logger.warn("fd not found on PATH; disabling the agent's `find` tool");
|
|
3415
|
+
}
|
|
2592
3416
|
const prompt = buildPrReviewPrompt({
|
|
2593
3417
|
prUrl: prUrl ?? `local diff (against ${targetBranch})`,
|
|
2594
3418
|
platform,
|
|
@@ -2600,13 +3424,14 @@ async function reviewPr(opts) {
|
|
|
2600
3424
|
reviewDiffMode: reviewMode,
|
|
2601
3425
|
changedFiles,
|
|
2602
3426
|
localMode,
|
|
2603
|
-
singleTurn
|
|
3427
|
+
singleTurn,
|
|
3428
|
+
findToolAvailable
|
|
2604
3429
|
});
|
|
2605
3430
|
const startTime = Date.now();
|
|
2606
3431
|
const settingsManager = SettingsManager.inMemory({
|
|
2607
3432
|
compaction: { enabled: true }
|
|
2608
3433
|
});
|
|
2609
|
-
const skillPaths = [
|
|
3434
|
+
const skillPaths = [join3(workspacePath, ".agents", "skills")].filter((p) => existsSync(p));
|
|
2610
3435
|
const resourceLoader = new DefaultResourceLoader({
|
|
2611
3436
|
cwd: workspacePath,
|
|
2612
3437
|
agentDir: getAgentDir(),
|
|
@@ -2679,7 +3504,14 @@ async function reviewPr(opts) {
|
|
|
2679
3504
|
// (see _refreshToolRegistry in @earendil-works/pi-coding-agent's
|
|
2680
3505
|
// agent-session.ts). The submit_review custom tool must be named here
|
|
2681
3506
|
// or the LLM never sees it and the agent loop exits without calling it.
|
|
2682
|
-
tools: singleTurn ? ["submit_review"] : [
|
|
3507
|
+
tools: singleTurn ? ["submit_review"] : [
|
|
3508
|
+
"read",
|
|
3509
|
+
"bash",
|
|
3510
|
+
"grep",
|
|
3511
|
+
...findToolAvailable ? ["find"] : [],
|
|
3512
|
+
"ls",
|
|
3513
|
+
"submit_review"
|
|
3514
|
+
],
|
|
2683
3515
|
customTools: [submitReviewTool],
|
|
2684
3516
|
modelRuntime,
|
|
2685
3517
|
sessionManager: SessionManager.inMemory(),
|
|
@@ -2910,6 +3742,10 @@ async function reviewPr(opts) {
|
|
|
2910
3742
|
}
|
|
2911
3743
|
|
|
2912
3744
|
export {
|
|
3745
|
+
setLogLevel,
|
|
3746
|
+
logger,
|
|
3747
|
+
renderMarkdown,
|
|
3748
|
+
listHodorDiscussions,
|
|
2913
3749
|
buildPrReviewPrompt,
|
|
2914
3750
|
parseModelString,
|
|
2915
3751
|
mapReasoningEffort,
|
|
@@ -2926,9 +3762,9 @@ export {
|
|
|
2926
3762
|
loadDefaultReviewInstructions,
|
|
2927
3763
|
detectPlatform,
|
|
2928
3764
|
parsePrUrl,
|
|
2929
|
-
|
|
3765
|
+
mergeReviewStateFindings,
|
|
2930
3766
|
postReviewComment,
|
|
2931
3767
|
postReviewStructured,
|
|
2932
3768
|
reviewPr
|
|
2933
3769
|
};
|
|
2934
|
-
//# sourceMappingURL=chunk-
|
|
3770
|
+
//# sourceMappingURL=chunk-AFEJ4DRL.js.map
|