@pepps233/mendr 0.1.0
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/LICENSE +21 -0
- package/README.md +152 -0
- package/dist/chunk-EGSZLVR6.js +1051 -0
- package/dist/cli.d.ts +113 -0
- package/dist/cli.js +718 -0
- package/dist/daemon.d.ts +3 -0
- package/dist/daemon.js +1060 -0
- package/package.json +57 -0
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allowedEffortsForAgent,
|
|
3
|
+
appendEvent,
|
|
4
|
+
appendFixAttempt,
|
|
5
|
+
appendIssueRecord,
|
|
6
|
+
commitStaged,
|
|
7
|
+
createAgentDriver,
|
|
8
|
+
dedupeIssues,
|
|
9
|
+
defaultEffortForAgent,
|
|
10
|
+
defaultExec,
|
|
11
|
+
defaultMendrHome,
|
|
12
|
+
defaultModelForAgent,
|
|
13
|
+
ensureMergeableWithRef,
|
|
14
|
+
fetchPullRequestDetails,
|
|
15
|
+
fetchPullRequestDiff,
|
|
16
|
+
fetchPullRequestReadinessRefs,
|
|
17
|
+
fetchRemoteBranch,
|
|
18
|
+
getHeadCommitSha,
|
|
19
|
+
getPorcelainStatus,
|
|
20
|
+
isEffortForAgent,
|
|
21
|
+
issueFingerprint,
|
|
22
|
+
postPullRequestComment,
|
|
23
|
+
pushHeadToBranch,
|
|
24
|
+
readMeta,
|
|
25
|
+
readState,
|
|
26
|
+
renderReviewMarkdown,
|
|
27
|
+
resetWorktreeToCommit,
|
|
28
|
+
reviewDir,
|
|
29
|
+
stageAll,
|
|
30
|
+
waitForPullRequestChecks,
|
|
31
|
+
writeState
|
|
32
|
+
} from "./chunk-EGSZLVR6.js";
|
|
33
|
+
|
|
34
|
+
// src/orchestrator.ts
|
|
35
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
36
|
+
import { join } from "path";
|
|
37
|
+
import { setTimeout as delay } from "timers/promises";
|
|
38
|
+
|
|
39
|
+
// src/report.ts
|
|
40
|
+
import { Buffer } from "buffer";
|
|
41
|
+
var REPORT_HEADING = "## Summary by Mendr";
|
|
42
|
+
var LEGACY_REPORT_HEADING = "## Summary";
|
|
43
|
+
var RESOLVED_ISSUES_HEADING = "### Resolved Issues";
|
|
44
|
+
var UNRESOLVED_ISSUES_HEADING = "### Unresolved Issues";
|
|
45
|
+
var ROUND_CAP_HEADING = "### Round Cap";
|
|
46
|
+
var ISSUE_FINGERPRINT_PREFIX = "<!-- mendr-issue-fingerprint: ";
|
|
47
|
+
var ISSUE_FINGERPRINT_SUFFIX = " -->";
|
|
48
|
+
function appendResolvedIssue(report, entry) {
|
|
49
|
+
const original = ensureSummary(report);
|
|
50
|
+
let normalized = removeUnresolvedIssue(original, entry.issue);
|
|
51
|
+
const issueLine = `#### ${entry.issue.title}`;
|
|
52
|
+
const fingerprintLine = issueFingerprintLine(entry.issue);
|
|
53
|
+
const shaLine = `**Commit:** ${entry.sha}`;
|
|
54
|
+
const legacyBacktickedShaLine = `**Commit:** \`${entry.sha}\``;
|
|
55
|
+
const legacyIssueLine = `- Issue: ${entry.issue.title}`;
|
|
56
|
+
const legacyShaLine = `- Resolved by: ${entry.sha}`;
|
|
57
|
+
if (hasResolvedIssueEntry(normalized, entry) || normalized.includes(`${issueLine}
|
|
58
|
+
${shaLine}`) || normalized.includes(`${issueLine}
|
|
59
|
+
${legacyBacktickedShaLine}`) || normalized.includes(`${legacyIssueLine}
|
|
60
|
+
${legacyShaLine}`)) {
|
|
61
|
+
return normalized === original ? report : normalized;
|
|
62
|
+
}
|
|
63
|
+
normalized = ensureSection(normalized, RESOLVED_ISSUES_HEADING);
|
|
64
|
+
return appendBlock(normalized, [issueLine, fingerprintLine, shaLine, entry.summary]);
|
|
65
|
+
}
|
|
66
|
+
function appendIssueResult(report, entry) {
|
|
67
|
+
return appendResolvedIssue(report, entry);
|
|
68
|
+
}
|
|
69
|
+
function appendUnresolvedIssue(report, entry) {
|
|
70
|
+
let normalized = ensureSummary(report);
|
|
71
|
+
const issueLine = `#### ${entry.issue.title}`;
|
|
72
|
+
const fingerprintLine = issueFingerprintLine(entry.issue);
|
|
73
|
+
if (hasResolvedIssue(normalized, entry.issue) || hasUnresolvedIssue(normalized, entry.issue)) {
|
|
74
|
+
return report;
|
|
75
|
+
}
|
|
76
|
+
normalized = ensureSection(normalized, UNRESOLVED_ISSUES_HEADING);
|
|
77
|
+
return appendBlock(normalized, [issueLine, fingerprintLine, entry.summary]);
|
|
78
|
+
}
|
|
79
|
+
function appendNoIssuesFound(report) {
|
|
80
|
+
const normalized = ensureSummary(report);
|
|
81
|
+
const line = "- No changed-scope issues found.";
|
|
82
|
+
if (normalized.includes(line)) {
|
|
83
|
+
return report;
|
|
84
|
+
}
|
|
85
|
+
return appendBlock(normalized, [line]);
|
|
86
|
+
}
|
|
87
|
+
function appendFailureNote(report, message) {
|
|
88
|
+
const normalized = ensureSummary(report);
|
|
89
|
+
const line = `- Failure: ${message}`;
|
|
90
|
+
if (normalized.includes(line)) {
|
|
91
|
+
return report;
|
|
92
|
+
}
|
|
93
|
+
return appendBlock(normalized, [line]);
|
|
94
|
+
}
|
|
95
|
+
function appendRoundCapNote(report, note) {
|
|
96
|
+
let normalized = ensureSummary(report);
|
|
97
|
+
const round = note.maxRounds === 1 ? "round" : "rounds";
|
|
98
|
+
const issue = note.openIssues.length === 1 ? "issue" : "issues";
|
|
99
|
+
const capLine = `Reached after ${note.maxRounds} ${round} with ${note.openIssues.length} open ${issue}:`;
|
|
100
|
+
const legacyCapLine = `- Round cap reached after ${note.maxRounds} ${round} with ${note.openIssues.length} open ${issue}.`;
|
|
101
|
+
const openIssueLines = note.openIssues.map((openIssue) => `- ${openIssue.title}`);
|
|
102
|
+
if (normalized.includes(capLine) || normalized.includes(legacyCapLine)) {
|
|
103
|
+
return report;
|
|
104
|
+
}
|
|
105
|
+
normalized = ensureSection(normalized, ROUND_CAP_HEADING);
|
|
106
|
+
return appendBlock(normalized, [capLine, ...openIssueLines]);
|
|
107
|
+
}
|
|
108
|
+
function ensureSummary(report) {
|
|
109
|
+
const trimmed = report.trim();
|
|
110
|
+
if (trimmed.length === 0) {
|
|
111
|
+
return `${REPORT_HEADING}
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
if (hasLine(trimmed, REPORT_HEADING)) {
|
|
115
|
+
return `${trimmed}
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
if (hasLine(trimmed, LEGACY_REPORT_HEADING)) {
|
|
119
|
+
return `${trimmed.replace(/^## Summary$/m, REPORT_HEADING)}
|
|
120
|
+
`;
|
|
121
|
+
}
|
|
122
|
+
return `${REPORT_HEADING}
|
|
123
|
+
${trimmed}
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
126
|
+
function ensureSection(report, sectionHeading) {
|
|
127
|
+
if (hasLine(report, sectionHeading)) {
|
|
128
|
+
return report;
|
|
129
|
+
}
|
|
130
|
+
return appendBlock(report, [sectionHeading]);
|
|
131
|
+
}
|
|
132
|
+
function appendBlock(report, lines) {
|
|
133
|
+
const base = report.trimEnd();
|
|
134
|
+
const separator = base.length === 0 ? "" : "\n\n";
|
|
135
|
+
return `${base}${separator}${lines.join("\n")}
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
function hasResolvedIssue(report, issue) {
|
|
139
|
+
return hasIssueBlock(report, RESOLVED_ISSUES_HEADING, issue);
|
|
140
|
+
}
|
|
141
|
+
function hasResolvedIssueEntry(report, entry) {
|
|
142
|
+
const shaLine = `**Commit:** ${entry.sha}`;
|
|
143
|
+
const legacyBacktickedShaLine = `**Commit:** \`${entry.sha}\``;
|
|
144
|
+
return readHeadingBlocks(report, RESOLVED_ISSUES_HEADING).some((block) => {
|
|
145
|
+
const blockText = block.join("\n");
|
|
146
|
+
return hasIssueFingerprint(block, entry.issue) && (hasLine(blockText, shaLine) || hasLine(blockText, legacyBacktickedShaLine));
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function hasUnresolvedIssue(report, issue) {
|
|
150
|
+
return hasIssueBlock(report, UNRESOLVED_ISSUES_HEADING, issue);
|
|
151
|
+
}
|
|
152
|
+
function hasIssueBlock(report, sectionHeading, issue) {
|
|
153
|
+
return readHeadingBlocks(report, sectionHeading).some(
|
|
154
|
+
(block) => hasIssueFingerprint(block, issue)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
function hasIssueFingerprint(block, issue) {
|
|
158
|
+
return hasLine(block.join("\n"), issueFingerprintLine(issue));
|
|
159
|
+
}
|
|
160
|
+
function removeUnresolvedIssue(report, issue) {
|
|
161
|
+
return removeHeadingBlocks(
|
|
162
|
+
report,
|
|
163
|
+
UNRESOLVED_ISSUES_HEADING,
|
|
164
|
+
(block) => hasIssueFingerprint(block, issue)
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
function removeHeadingBlocks(report, sectionHeading, shouldRemoveBlock) {
|
|
168
|
+
const lines = report.split("\n");
|
|
169
|
+
const sectionIndex = lines.findIndex((line) => line === sectionHeading);
|
|
170
|
+
if (sectionIndex === -1) {
|
|
171
|
+
return report;
|
|
172
|
+
}
|
|
173
|
+
const ranges = [];
|
|
174
|
+
let index = sectionIndex + 1;
|
|
175
|
+
while (index < lines.length && !isSectionHeading(lines[index])) {
|
|
176
|
+
if (!isIssueHeading(lines[index])) {
|
|
177
|
+
index += 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const blockStart = index;
|
|
181
|
+
const blockEnd = findHeadingBlockEnd(lines, blockStart);
|
|
182
|
+
if (shouldRemoveBlock(lines.slice(blockStart, blockEnd))) {
|
|
183
|
+
ranges.push({ start: blockStart, end: blockEnd });
|
|
184
|
+
}
|
|
185
|
+
index = blockEnd;
|
|
186
|
+
}
|
|
187
|
+
if (ranges.length === 0) {
|
|
188
|
+
return report;
|
|
189
|
+
}
|
|
190
|
+
const next = [...lines];
|
|
191
|
+
for (const range of ranges.reverse()) {
|
|
192
|
+
next.splice(range.start, range.end - range.start);
|
|
193
|
+
}
|
|
194
|
+
return removeEmptySection(`${next.join("\n").trimEnd()}
|
|
195
|
+
`, sectionHeading);
|
|
196
|
+
}
|
|
197
|
+
function removeEmptySection(report, sectionHeading) {
|
|
198
|
+
const lines = report.split("\n");
|
|
199
|
+
const sectionIndex = lines.findIndex((line) => line === sectionHeading);
|
|
200
|
+
const nextSectionIndex = lines.findIndex(
|
|
201
|
+
(line, index) => index > sectionIndex && isSectionHeading(line)
|
|
202
|
+
);
|
|
203
|
+
const endIndex = nextSectionIndex === -1 ? lines.length : nextSectionIndex;
|
|
204
|
+
const hasBlock = lines.slice(sectionIndex + 1, endIndex).some((line) => isIssueHeading(line));
|
|
205
|
+
if (hasBlock) {
|
|
206
|
+
return report;
|
|
207
|
+
}
|
|
208
|
+
return `${[...lines.slice(0, sectionIndex), ...lines.slice(endIndex)].join("\n").trimEnd()}
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
function readHeadingBlocks(report, sectionHeading) {
|
|
212
|
+
const lines = report.split("\n");
|
|
213
|
+
const sectionIndex = lines.findIndex((line) => line === sectionHeading);
|
|
214
|
+
if (sectionIndex === -1) {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
const blocks = [];
|
|
218
|
+
let index = sectionIndex + 1;
|
|
219
|
+
while (index < lines.length && !isSectionHeading(lines[index])) {
|
|
220
|
+
if (!isIssueHeading(lines[index])) {
|
|
221
|
+
index += 1;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const blockStart = index;
|
|
225
|
+
const blockEnd = findHeadingBlockEnd(lines, blockStart);
|
|
226
|
+
blocks.push(lines.slice(blockStart, blockEnd));
|
|
227
|
+
index = blockEnd;
|
|
228
|
+
}
|
|
229
|
+
return blocks;
|
|
230
|
+
}
|
|
231
|
+
function issueFingerprintLine(issue) {
|
|
232
|
+
const encodedFingerprint = encodeIssueFingerprint(issueFingerprint(issue));
|
|
233
|
+
return `${ISSUE_FINGERPRINT_PREFIX}${encodedFingerprint}${ISSUE_FINGERPRINT_SUFFIX}`;
|
|
234
|
+
}
|
|
235
|
+
function encodeIssueFingerprint(fingerprint) {
|
|
236
|
+
return Buffer.from(fingerprint, "utf8").toString("base64url");
|
|
237
|
+
}
|
|
238
|
+
function isSectionHeading(line) {
|
|
239
|
+
return /^### /.test(line);
|
|
240
|
+
}
|
|
241
|
+
function isIssueHeading(line) {
|
|
242
|
+
return /^#### /.test(line);
|
|
243
|
+
}
|
|
244
|
+
function findHeadingBlockEnd(lines, blockStart) {
|
|
245
|
+
for (let index = blockStart + 1; index < lines.length; index += 1) {
|
|
246
|
+
if (isHeading(lines[index])) {
|
|
247
|
+
return index;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return lines.length;
|
|
251
|
+
}
|
|
252
|
+
function isHeading(line) {
|
|
253
|
+
return /^#{1,4} /.test(line);
|
|
254
|
+
}
|
|
255
|
+
function hasLine(text, line) {
|
|
256
|
+
const escaped = line.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
257
|
+
return new RegExp(`^${escaped}$`, "m").test(text);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/orchestrator.ts
|
|
261
|
+
var PullRequestHeadChangedError = class extends Error {
|
|
262
|
+
constructor(message) {
|
|
263
|
+
super(message);
|
|
264
|
+
this.name = "PullRequestHeadChangedError";
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
var PR_HEAD_PROPAGATION_TIMEOUT_MS = 3e4;
|
|
268
|
+
var PR_HEAD_PROPAGATION_POLL_MS = 2e3;
|
|
269
|
+
async function runOrchestrator(options) {
|
|
270
|
+
const exec = options.exec ?? defaultExec;
|
|
271
|
+
const dir = reviewDir(options.mendrHome, options.reviewId);
|
|
272
|
+
const reviewPath = join(dir, "review.md");
|
|
273
|
+
const reportPath = join(dir, "report.md");
|
|
274
|
+
let state = {
|
|
275
|
+
phase: "starting",
|
|
276
|
+
currentStatus: "Starting",
|
|
277
|
+
issuesFound: 0,
|
|
278
|
+
issuesFixed: 0,
|
|
279
|
+
done: false,
|
|
280
|
+
capReached: false
|
|
281
|
+
};
|
|
282
|
+
try {
|
|
283
|
+
await mkdir(dir, { recursive: true });
|
|
284
|
+
await writeState(options.mendrHome, options.reviewId, state);
|
|
285
|
+
const meta = await readMeta(options.mendrHome, options.reviewId);
|
|
286
|
+
const agent = parseAgentName(meta.agent);
|
|
287
|
+
const model = meta.model ?? defaultModelForAgent(agent);
|
|
288
|
+
const effort = resolveEffort(agent, meta.effort);
|
|
289
|
+
const sessionRepo = meta.worktreePath ?? meta.repo;
|
|
290
|
+
const agentDriver = options.agentDriver ?? createAgentDriver({
|
|
291
|
+
agent,
|
|
292
|
+
exec,
|
|
293
|
+
outputDir: join(dir, "agent-io")
|
|
294
|
+
});
|
|
295
|
+
const details = await fetchPullRequestDetails(exec, sessionRepo, meta.pr);
|
|
296
|
+
await writeFile(reviewPath, renderReviewMarkdown(meta.pr, details), "utf8");
|
|
297
|
+
let report = await readReport(reportPath);
|
|
298
|
+
let openIssues = [];
|
|
299
|
+
const attemptedIssueFingerprints = /* @__PURE__ */ new Set();
|
|
300
|
+
for (let round = 1; round <= meta.maxRounds; round += 1) {
|
|
301
|
+
state = await updateStatus(options, state, {
|
|
302
|
+
phase: "reviewing",
|
|
303
|
+
currentStatus: "Discovering bugs"
|
|
304
|
+
});
|
|
305
|
+
await appendEvent(options.mendrHome, options.reviewId, {
|
|
306
|
+
status: "Discovering bugs",
|
|
307
|
+
detail: `review round ${round}`
|
|
308
|
+
});
|
|
309
|
+
const diff = await fetchPullRequestDiff(exec, sessionRepo, meta.pr);
|
|
310
|
+
const reviewMarkdown = await readFile(reviewPath, "utf8");
|
|
311
|
+
const ctx = buildContext({
|
|
312
|
+
repo: sessionRepo,
|
|
313
|
+
pr: meta.pr,
|
|
314
|
+
model,
|
|
315
|
+
effort,
|
|
316
|
+
diff,
|
|
317
|
+
reviewMarkdown,
|
|
318
|
+
reportMarkdown: report
|
|
319
|
+
});
|
|
320
|
+
let issues = [];
|
|
321
|
+
try {
|
|
322
|
+
issues = dedupeIssues(await agentDriver.review(ctx));
|
|
323
|
+
} catch (error) {
|
|
324
|
+
await fail(options, state, "Review failed", error);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
await persistIssueRecords(options, round, issues);
|
|
328
|
+
state = await updateStatus(options, state, {
|
|
329
|
+
issuesFound: state.issuesFound + issues.length
|
|
330
|
+
});
|
|
331
|
+
if (issues.length === 0) {
|
|
332
|
+
if (report.trim().length === 0) {
|
|
333
|
+
report = appendNoIssuesFound(report);
|
|
334
|
+
await writeFile(reportPath, report, "utf8");
|
|
335
|
+
}
|
|
336
|
+
openIssues = [];
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
openIssues = issues;
|
|
340
|
+
const issueAttempts = issues.map((issue, index) => ({
|
|
341
|
+
issue,
|
|
342
|
+
round,
|
|
343
|
+
issueIndex: index + 1
|
|
344
|
+
}));
|
|
345
|
+
const newIssues = issueAttempts.filter(
|
|
346
|
+
(attempt) => !attemptedIssueFingerprints.has(issueFingerprint(attempt.issue))
|
|
347
|
+
);
|
|
348
|
+
const repeatedIssues = issueAttempts.filter(
|
|
349
|
+
(attempt) => attemptedIssueFingerprints.has(issueFingerprint(attempt.issue))
|
|
350
|
+
);
|
|
351
|
+
state = await updateStatus(options, state, {
|
|
352
|
+
phase: "fixing",
|
|
353
|
+
currentStatus: "Resolving issues"
|
|
354
|
+
});
|
|
355
|
+
await appendEvent(options.mendrHome, options.reviewId, {
|
|
356
|
+
status: "Resolving issues",
|
|
357
|
+
detail: newIssues.length > 0 ? `fix round ${round} with ${newIssues.length} new issues` : `fix round ${round} skipped because all ${issues.length} issues were already attempted`
|
|
358
|
+
});
|
|
359
|
+
for (const { issue } of repeatedIssues) {
|
|
360
|
+
await appendEvent(options.mendrHome, options.reviewId, {
|
|
361
|
+
status: "Issue still open",
|
|
362
|
+
detail: `${issue.title} was already sent to the fixer in an earlier round`
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (newIssues.length > 0) {
|
|
366
|
+
for (const { issue } of newIssues) {
|
|
367
|
+
attemptedIssueFingerprints.add(issueFingerprint(issue));
|
|
368
|
+
}
|
|
369
|
+
const outcome = await runFixRound({
|
|
370
|
+
options,
|
|
371
|
+
exec,
|
|
372
|
+
agentDriver,
|
|
373
|
+
ctx: {
|
|
374
|
+
...ctx,
|
|
375
|
+
reportMarkdown: report
|
|
376
|
+
},
|
|
377
|
+
issues: newIssues,
|
|
378
|
+
report,
|
|
379
|
+
reportPath,
|
|
380
|
+
branch: meta.branch,
|
|
381
|
+
branchPushRemote: normalizeBranchPushRemote(meta.branchPushRemote),
|
|
382
|
+
state,
|
|
383
|
+
setState: (nextState) => {
|
|
384
|
+
state = nextState;
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
report = outcome.report;
|
|
388
|
+
state = outcome.state;
|
|
389
|
+
}
|
|
390
|
+
if (round === meta.maxRounds) {
|
|
391
|
+
report = appendRoundCapNote(report, {
|
|
392
|
+
maxRounds: meta.maxRounds,
|
|
393
|
+
openIssues
|
|
394
|
+
});
|
|
395
|
+
await writeFile(reportPath, report, "utf8");
|
|
396
|
+
state = await updateStatus(options, state, {
|
|
397
|
+
capReached: true
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (openIssues.length === 0) {
|
|
402
|
+
state = await updateStatus(options, state, {
|
|
403
|
+
capReached: false
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const validationResult = await validatePullRequestReadyForSummary(
|
|
407
|
+
options,
|
|
408
|
+
exec,
|
|
409
|
+
sessionRepo,
|
|
410
|
+
meta.pr,
|
|
411
|
+
state
|
|
412
|
+
);
|
|
413
|
+
state = validationResult.state;
|
|
414
|
+
if (validationResult.failure) {
|
|
415
|
+
report = appendValidationFailureNote(report, validationResult.failure);
|
|
416
|
+
await writeFile(reportPath, report, "utf8");
|
|
417
|
+
}
|
|
418
|
+
state = await postReportWithRetry(options, exec, sessionRepo, meta.pr, reportPath, state);
|
|
419
|
+
if (validationResult.failure) {
|
|
420
|
+
await fail(
|
|
421
|
+
options,
|
|
422
|
+
state,
|
|
423
|
+
validationResult.failure.status,
|
|
424
|
+
validationResult.failure.error
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
await updateStatus(options, state, {
|
|
428
|
+
phase: "complete",
|
|
429
|
+
currentStatus: "Complete",
|
|
430
|
+
done: true
|
|
431
|
+
});
|
|
432
|
+
await appendEvent(options.mendrHome, options.reviewId, {
|
|
433
|
+
status: "Complete",
|
|
434
|
+
detail: "done"
|
|
435
|
+
});
|
|
436
|
+
} catch (error) {
|
|
437
|
+
if (isAlreadyRecordedFailure(state, error)) {
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
await fail(options, state, "Orchestrator failed", error);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async function runFixRound(input) {
|
|
444
|
+
let report = input.report;
|
|
445
|
+
let fixedCount = 0;
|
|
446
|
+
let lastSuccessfulSha = await getHeadCommitSha(input.exec, input.ctx.repo);
|
|
447
|
+
let state = input.state;
|
|
448
|
+
for (const attempt of input.issues) {
|
|
449
|
+
const outcome = await runSingleIssueFix({
|
|
450
|
+
...input,
|
|
451
|
+
ctx: {
|
|
452
|
+
...input.ctx,
|
|
453
|
+
reportMarkdown: report
|
|
454
|
+
},
|
|
455
|
+
attempt,
|
|
456
|
+
lastSuccessfulSha,
|
|
457
|
+
state
|
|
458
|
+
});
|
|
459
|
+
if (outcome.status === "fixed" && outcome.sha) {
|
|
460
|
+
report = appendIssueResult(report, {
|
|
461
|
+
issue: attempt.issue,
|
|
462
|
+
sha: outcome.sha,
|
|
463
|
+
summary: outcome.summary
|
|
464
|
+
});
|
|
465
|
+
} else {
|
|
466
|
+
report = appendUnresolvedIssue(report, {
|
|
467
|
+
issue: attempt.issue,
|
|
468
|
+
summary: outcome.summary
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
await appendFixAttempt(input.options.mendrHome, input.options.reviewId, {
|
|
472
|
+
sessionId: input.options.reviewId,
|
|
473
|
+
round: attempt.round,
|
|
474
|
+
issueIndex: attempt.issueIndex,
|
|
475
|
+
fingerprint: issueFingerprint(attempt.issue),
|
|
476
|
+
title: attempt.issue.title,
|
|
477
|
+
status: outcome.status,
|
|
478
|
+
summary: outcome.summary,
|
|
479
|
+
...outcome.sha ? { commitSha: outcome.sha } : {}
|
|
480
|
+
});
|
|
481
|
+
await writeFile(input.reportPath, report, "utf8");
|
|
482
|
+
if (outcome.status === "fixed" && outcome.sha) {
|
|
483
|
+
fixedCount += 1;
|
|
484
|
+
lastSuccessfulSha = outcome.sha;
|
|
485
|
+
state = await updateStatus(input.options, state, {
|
|
486
|
+
issuesFixed: state.issuesFixed + 1
|
|
487
|
+
});
|
|
488
|
+
input.setState(state);
|
|
489
|
+
} else {
|
|
490
|
+
await appendEvent(input.options.mendrHome, input.options.reviewId, {
|
|
491
|
+
status: "Fix failed",
|
|
492
|
+
detail: `${attempt.issue.title}: ${outcome.summary}`
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (fixedCount > 0) {
|
|
497
|
+
try {
|
|
498
|
+
await pushWithRetry(input.exec, input.ctx.repo, input.branchPushRemote, input.branch);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
const message = errorToMessage(error);
|
|
501
|
+
const failedReport = appendFailureNote(report, `push failed: ${message}`);
|
|
502
|
+
await writeFile(input.reportPath, failedReport, "utf8");
|
|
503
|
+
await fail(input.options, state, "Push failed", error);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
report,
|
|
508
|
+
fixedCount,
|
|
509
|
+
pushed: fixedCount > 0,
|
|
510
|
+
state
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
async function runSingleIssueFix(input) {
|
|
514
|
+
const { attempt } = input;
|
|
515
|
+
let rawResults;
|
|
516
|
+
try {
|
|
517
|
+
rawResults = await input.agentDriver.fix([attempt.issue], input.ctx);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
await resetFailedIssue(input);
|
|
520
|
+
return {
|
|
521
|
+
status: "failed",
|
|
522
|
+
summary: fixerCrashedSummary(errorToMessage(error))
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
const movedHeadOutcome = await failIfFixerMovedHead(input);
|
|
526
|
+
if (movedHeadOutcome) {
|
|
527
|
+
return movedHeadOutcome;
|
|
528
|
+
}
|
|
529
|
+
const result = normalizeFixResults([attempt.issue], rawResults)[0];
|
|
530
|
+
if (result.status === "failed") {
|
|
531
|
+
await resetFailedIssue(input);
|
|
532
|
+
return {
|
|
533
|
+
status: "failed",
|
|
534
|
+
summary: result.summary
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const commitMessage = validateCommitMessage(result.commitMessage);
|
|
538
|
+
if (!commitMessage.valid) {
|
|
539
|
+
await resetFailedIssue(input);
|
|
540
|
+
return {
|
|
541
|
+
status: "failed",
|
|
542
|
+
summary: invalidCommitMessageSummary(commitMessage.reason)
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
const status = await getPorcelainStatus(input.exec, input.ctx.repo);
|
|
546
|
+
if (status.length === 0) {
|
|
547
|
+
await resetFailedIssue(input);
|
|
548
|
+
return {
|
|
549
|
+
status: "failed",
|
|
550
|
+
summary: noDiffSummary()
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
await stageAll(input.exec, input.ctx.repo);
|
|
555
|
+
const sha = await commitStaged(
|
|
556
|
+
input.exec,
|
|
557
|
+
input.ctx.repo,
|
|
558
|
+
commitMessage.message
|
|
559
|
+
);
|
|
560
|
+
return {
|
|
561
|
+
status: "fixed",
|
|
562
|
+
summary: result.summary,
|
|
563
|
+
sha
|
|
564
|
+
};
|
|
565
|
+
} catch (error) {
|
|
566
|
+
return fail(input.options, input.state, "Commit failed", error);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async function resetFailedIssue(input) {
|
|
570
|
+
try {
|
|
571
|
+
await resetWorktreeToCommit(input.exec, input.ctx.repo, input.lastSuccessfulSha);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
await fail(input.options, input.state, "Worktree reset failed", error);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async function failIfFixerMovedHead(input) {
|
|
577
|
+
const currentHead = await getHeadCommitSha(input.exec, input.ctx.repo);
|
|
578
|
+
if (currentHead === input.lastSuccessfulSha) {
|
|
579
|
+
return void 0;
|
|
580
|
+
}
|
|
581
|
+
await resetFailedIssue(input);
|
|
582
|
+
return {
|
|
583
|
+
status: "failed",
|
|
584
|
+
summary: fixerMovedHeadSummary(input.lastSuccessfulSha, currentHead)
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
function fixerCrashedSummary(message) {
|
|
588
|
+
return `The fixer failed before returning structured results. ${message}`;
|
|
589
|
+
}
|
|
590
|
+
function fixerMovedHeadSummary(expectedSha, actualSha) {
|
|
591
|
+
return `The fixer moved HEAD from ${expectedSha} to ${actualSha} before returning. mendr reset the worktree so unrecorded fixer commits are not pushed.`;
|
|
592
|
+
}
|
|
593
|
+
function noDiffSummary() {
|
|
594
|
+
return "The fixer reported this issue as fixed, but did not leave any file changes to commit. Manual follow-up is required to determine whether the issue still needs a code change.";
|
|
595
|
+
}
|
|
596
|
+
function appendValidationFailureNote(report, failure) {
|
|
597
|
+
return appendFailureNote(report, `${failure.status}: ${errorToMessage(failure.error)}`);
|
|
598
|
+
}
|
|
599
|
+
function validateCommitMessage(message) {
|
|
600
|
+
const normalized = message?.replace(/\r\n?/g, "\n").trim();
|
|
601
|
+
if (!normalized) {
|
|
602
|
+
return {
|
|
603
|
+
valid: false,
|
|
604
|
+
reason: "the fixer did not provide a commit message"
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
if (normalized.includes("\0")) {
|
|
608
|
+
return {
|
|
609
|
+
valid: false,
|
|
610
|
+
reason: "commit messages must not contain NUL bytes"
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
const forbiddenReason = forbiddenCommitMessageReason(normalized);
|
|
614
|
+
if (forbiddenReason) {
|
|
615
|
+
return {
|
|
616
|
+
valid: false,
|
|
617
|
+
reason: forbiddenReason
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
const lines = normalized.split("\n");
|
|
621
|
+
if (lines.length !== 4) {
|
|
622
|
+
return {
|
|
623
|
+
valid: false,
|
|
624
|
+
reason: "commit messages must contain a subject, a blank line, and exactly two bullet lines"
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
if (lines[1] !== "") {
|
|
628
|
+
return {
|
|
629
|
+
valid: false,
|
|
630
|
+
reason: "commit messages must separate the subject from the body with a blank line"
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
const subject = parseCommitSubject(lines[0]);
|
|
634
|
+
if (!subject) {
|
|
635
|
+
return {
|
|
636
|
+
valid: false,
|
|
637
|
+
reason: "commit message subjects must match <type>(<scope>): <short imperative summary>"
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
if (subject.summary.endsWith(".")) {
|
|
641
|
+
return {
|
|
642
|
+
valid: false,
|
|
643
|
+
reason: "commit message summaries must not end with a period"
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
if (looksNonImperative(subject.summary)) {
|
|
647
|
+
return {
|
|
648
|
+
valid: false,
|
|
649
|
+
reason: "commit message summaries must be imperative"
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
if (!lines[2].startsWith("- ") || lines[2].trim() === "-") {
|
|
653
|
+
return {
|
|
654
|
+
valid: false,
|
|
655
|
+
reason: "commit message bodies must use a non-empty first bullet"
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
if (!lines[3].startsWith("- ") || lines[3].trim() === "-") {
|
|
659
|
+
return {
|
|
660
|
+
valid: false,
|
|
661
|
+
reason: "commit message bodies must use a non-empty second bullet"
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
valid: true,
|
|
666
|
+
message: normalized
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
function invalidCommitMessageSummary(reason) {
|
|
670
|
+
return `The fixer reported this issue as fixed, but its commit message is invalid: ${reason}. Manual follow-up is required before Mendr can safely record and push the fix.`;
|
|
671
|
+
}
|
|
672
|
+
function forbiddenCommitMessageReason(message) {
|
|
673
|
+
const lines = message.split("\n");
|
|
674
|
+
if (lines.some((line) => /^co-authored-by\s*:/i.test(line.trim()))) {
|
|
675
|
+
return "commit messages must not include co-author lines";
|
|
676
|
+
}
|
|
677
|
+
if (/\b(?:ai|a\.i\.|openai|chatgpt|claude|anthropic|codex|providers?)\b/i.test(message)) {
|
|
678
|
+
return "commit messages must not include AI or provider references";
|
|
679
|
+
}
|
|
680
|
+
return void 0;
|
|
681
|
+
}
|
|
682
|
+
function parseCommitSubject(line) {
|
|
683
|
+
const match = /^([a-z][a-z0-9-]*)\(([a-z0-9._/-]+)\): ([A-Za-z][^\n]*)$/.exec(line);
|
|
684
|
+
if (!match) {
|
|
685
|
+
return void 0;
|
|
686
|
+
}
|
|
687
|
+
const [, type, scope, summary] = match;
|
|
688
|
+
if (!summary.trim()) {
|
|
689
|
+
return void 0;
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
type,
|
|
693
|
+
scope,
|
|
694
|
+
summary
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
function looksNonImperative(summary) {
|
|
698
|
+
const firstWord = summary.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
699
|
+
return [
|
|
700
|
+
"added",
|
|
701
|
+
"adding",
|
|
702
|
+
"adds",
|
|
703
|
+
"changed",
|
|
704
|
+
"changing",
|
|
705
|
+
"changes",
|
|
706
|
+
"created",
|
|
707
|
+
"creating",
|
|
708
|
+
"creates",
|
|
709
|
+
"fixed",
|
|
710
|
+
"fixing",
|
|
711
|
+
"fixes",
|
|
712
|
+
"handled",
|
|
713
|
+
"handling",
|
|
714
|
+
"handles",
|
|
715
|
+
"made",
|
|
716
|
+
"makes",
|
|
717
|
+
"prevented",
|
|
718
|
+
"preventing",
|
|
719
|
+
"prevents",
|
|
720
|
+
"recorded",
|
|
721
|
+
"recording",
|
|
722
|
+
"records",
|
|
723
|
+
"rejected",
|
|
724
|
+
"rejecting",
|
|
725
|
+
"rejects",
|
|
726
|
+
"updated",
|
|
727
|
+
"updating",
|
|
728
|
+
"updates",
|
|
729
|
+
"used",
|
|
730
|
+
"using",
|
|
731
|
+
"uses",
|
|
732
|
+
"validated",
|
|
733
|
+
"validating",
|
|
734
|
+
"validates"
|
|
735
|
+
].includes(firstWord);
|
|
736
|
+
}
|
|
737
|
+
async function pushWithRetry(exec, repo, remote, branch) {
|
|
738
|
+
try {
|
|
739
|
+
await pushHeadToBranch(exec, repo, remote, branch);
|
|
740
|
+
} catch {
|
|
741
|
+
await pushHeadToBranch(exec, repo, remote, branch);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function normalizeBranchPushRemote(remote) {
|
|
745
|
+
const normalized = remote?.trim();
|
|
746
|
+
return normalized && normalized.length > 0 ? normalized : "origin";
|
|
747
|
+
}
|
|
748
|
+
async function postReportWithRetry(options, exec, repo, pr, reportPath, state) {
|
|
749
|
+
const postingState = await updateStatus(options, state, {
|
|
750
|
+
phase: "posting",
|
|
751
|
+
currentStatus: "Posting review"
|
|
752
|
+
});
|
|
753
|
+
await appendEvent(options.mendrHome, options.reviewId, {
|
|
754
|
+
status: "Posting review",
|
|
755
|
+
detail: "posting report comment"
|
|
756
|
+
});
|
|
757
|
+
try {
|
|
758
|
+
await postPullRequestComment(exec, repo, pr, reportPath);
|
|
759
|
+
} catch {
|
|
760
|
+
try {
|
|
761
|
+
await postPullRequestComment(exec, repo, pr, reportPath);
|
|
762
|
+
} catch (error) {
|
|
763
|
+
await fail(options, postingState, "Posting review failed", error);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return postingState;
|
|
767
|
+
}
|
|
768
|
+
async function validatePullRequestReadyForSummary(options, exec, repo, pr, state) {
|
|
769
|
+
const validationState = await updateStatus(options, state, {
|
|
770
|
+
phase: "validating",
|
|
771
|
+
currentStatus: "Validating PR"
|
|
772
|
+
});
|
|
773
|
+
await appendEvent(options.mendrHome, options.reviewId, {
|
|
774
|
+
status: "Validating PR",
|
|
775
|
+
detail: "checking merge conflicts and CI"
|
|
776
|
+
});
|
|
777
|
+
let expectedHeadSha = "";
|
|
778
|
+
try {
|
|
779
|
+
expectedHeadSha = (await validateCurrentPullRequestMergeability(exec, repo, pr, {
|
|
780
|
+
waitForLocalHead: true
|
|
781
|
+
})).headSha;
|
|
782
|
+
} catch (error) {
|
|
783
|
+
return mergeabilityValidationResult(options, validationState, error);
|
|
784
|
+
}
|
|
785
|
+
try {
|
|
786
|
+
await waitForPullRequestChecks(exec, repo, pr);
|
|
787
|
+
} catch (error) {
|
|
788
|
+
return {
|
|
789
|
+
state: validationState,
|
|
790
|
+
failure: {
|
|
791
|
+
status: "CI failed",
|
|
792
|
+
error
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
try {
|
|
797
|
+
await validateCurrentPullRequestMergeability(exec, repo, pr, {
|
|
798
|
+
expectedHeadSha
|
|
799
|
+
});
|
|
800
|
+
} catch (error) {
|
|
801
|
+
return mergeabilityValidationResult(options, validationState, error);
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
state: validationState
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
async function validateCurrentPullRequestMergeability(exec, repo, pr, options = {}) {
|
|
808
|
+
const localHeadSha = await getHeadCommitSha(exec, repo);
|
|
809
|
+
const readinessRefs = options.waitForLocalHead ? await waitForPullRequestHeadToMatchLocal(exec, repo, pr, localHeadSha) : await fetchPullRequestReadinessRefs(exec, repo, pr);
|
|
810
|
+
if (options.expectedHeadSha !== void 0) {
|
|
811
|
+
assertPullRequestHeadUnchanged(options.expectedHeadSha, readinessRefs.headSha);
|
|
812
|
+
}
|
|
813
|
+
assertPullRequestHeadMatchesLocal(localHeadSha, readinessRefs.headSha);
|
|
814
|
+
const baseRef = await fetchRemoteBranch(exec, repo, "origin", readinessRefs.baseBranch);
|
|
815
|
+
await ensureMergeableWithRef(exec, repo, baseRef);
|
|
816
|
+
return readinessRefs;
|
|
817
|
+
}
|
|
818
|
+
async function waitForPullRequestHeadToMatchLocal(exec, repo, pr, localHeadSha) {
|
|
819
|
+
const deadline = Date.now() + PR_HEAD_PROPAGATION_TIMEOUT_MS;
|
|
820
|
+
let attempt = 0;
|
|
821
|
+
let lastObservedHeadSha = "";
|
|
822
|
+
while (Date.now() <= deadline) {
|
|
823
|
+
const readinessRefs = await fetchPullRequestReadinessRefs(exec, repo, pr);
|
|
824
|
+
lastObservedHeadSha = readinessRefs.headSha;
|
|
825
|
+
if (readinessRefs.headSha === localHeadSha) {
|
|
826
|
+
return readinessRefs;
|
|
827
|
+
}
|
|
828
|
+
const remainingMs = deadline - Date.now();
|
|
829
|
+
if (remainingMs <= 0) {
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
const waitMs = attempt === 0 ? 0 : Math.min(PR_HEAD_PROPAGATION_POLL_MS, remainingMs);
|
|
833
|
+
attempt += 1;
|
|
834
|
+
if (waitMs > 0) {
|
|
835
|
+
await delay(waitMs);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
throw new PullRequestHeadChangedError(
|
|
839
|
+
`Local session HEAD ${localHeadSha} does not match current PR head ${lastObservedHeadSha} after waiting for GitHub to report the pushed head. Re-run Mendr so the final summary is posted only after validating the current PR head.`
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
async function mergeabilityValidationResult(options, state, error) {
|
|
843
|
+
if (error instanceof PullRequestHeadChangedError) {
|
|
844
|
+
await fail(options, state, "PR head changed", error);
|
|
845
|
+
}
|
|
846
|
+
return {
|
|
847
|
+
state,
|
|
848
|
+
failure: {
|
|
849
|
+
status: "Merge conflict check failed",
|
|
850
|
+
error
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
function assertPullRequestHeadMatchesLocal(localHeadSha, prHeadSha) {
|
|
855
|
+
if (localHeadSha === prHeadSha) {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
throw new PullRequestHeadChangedError(
|
|
859
|
+
`Local session HEAD ${localHeadSha} does not match current PR head ${prHeadSha}. Re-run Mendr so the final summary is posted only after validating the current PR head.`
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
function assertPullRequestHeadUnchanged(expectedHeadSha, currentHeadSha) {
|
|
863
|
+
if (expectedHeadSha === currentHeadSha) {
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
throw new PullRequestHeadChangedError(
|
|
867
|
+
`Pull request head changed from ${expectedHeadSha} to ${currentHeadSha} while Mendr was validating readiness. Re-run Mendr so the final summary is posted only after validating the current PR head.`
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
async function persistIssueRecords(options, round, issues) {
|
|
871
|
+
for (const [index, issue] of issues.entries()) {
|
|
872
|
+
await appendIssueRecord(options.mendrHome, options.reviewId, {
|
|
873
|
+
sessionId: options.reviewId,
|
|
874
|
+
round,
|
|
875
|
+
issueIndex: index + 1,
|
|
876
|
+
fingerprint: issueFingerprint(issue),
|
|
877
|
+
title: issue.title,
|
|
878
|
+
file: issue.file,
|
|
879
|
+
line: issue.line,
|
|
880
|
+
severity: issue.severity,
|
|
881
|
+
description: issue.description
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
function normalizeFixResults(issues, results) {
|
|
886
|
+
const byFingerprint = new Map(results.map((result) => [result.fingerprint, result]));
|
|
887
|
+
return issues.map((issue) => {
|
|
888
|
+
const fingerprint = issueFingerprint(issue);
|
|
889
|
+
const byExactFingerprint = byFingerprint.get(fingerprint);
|
|
890
|
+
const byTitle = results.find((result2) => result2.title === issue.title);
|
|
891
|
+
const result = byExactFingerprint ?? byTitle;
|
|
892
|
+
if (result) {
|
|
893
|
+
return {
|
|
894
|
+
...result,
|
|
895
|
+
fingerprint
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
return {
|
|
899
|
+
title: issue.title,
|
|
900
|
+
fingerprint,
|
|
901
|
+
status: "failed",
|
|
902
|
+
summary: "The fixer did not return a result for this issue. Manual follow-up is required."
|
|
903
|
+
};
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
async function updateStatus(options, previous, patch) {
|
|
907
|
+
const next = {
|
|
908
|
+
...previous,
|
|
909
|
+
...patch
|
|
910
|
+
};
|
|
911
|
+
await writeState(options.mendrHome, options.reviewId, next);
|
|
912
|
+
return next;
|
|
913
|
+
}
|
|
914
|
+
async function fail(options, state, status, error) {
|
|
915
|
+
const message = errorToMessage(error);
|
|
916
|
+
await writeState(options.mendrHome, options.reviewId, {
|
|
917
|
+
...state,
|
|
918
|
+
phase: "failed",
|
|
919
|
+
currentStatus: status,
|
|
920
|
+
done: false,
|
|
921
|
+
error: message
|
|
922
|
+
});
|
|
923
|
+
await appendEvent(options.mendrHome, options.reviewId, {
|
|
924
|
+
status,
|
|
925
|
+
detail: message
|
|
926
|
+
});
|
|
927
|
+
const recordedError = error instanceof Error ? error : new Error(message);
|
|
928
|
+
Object.defineProperty(recordedError, "mendrFailureRecorded", {
|
|
929
|
+
value: true,
|
|
930
|
+
configurable: true
|
|
931
|
+
});
|
|
932
|
+
throw recordedError;
|
|
933
|
+
}
|
|
934
|
+
async function readReport(path) {
|
|
935
|
+
try {
|
|
936
|
+
return await readFile(path, "utf8");
|
|
937
|
+
} catch (error) {
|
|
938
|
+
if (error.code === "ENOENT") {
|
|
939
|
+
return "";
|
|
940
|
+
}
|
|
941
|
+
throw error;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
function buildContext(ctx) {
|
|
945
|
+
return ctx;
|
|
946
|
+
}
|
|
947
|
+
function parseAgentName(agent) {
|
|
948
|
+
if (agent === "claude" || agent === "codex") {
|
|
949
|
+
return agent;
|
|
950
|
+
}
|
|
951
|
+
throw new Error(`Unsupported agent: ${agent}`);
|
|
952
|
+
}
|
|
953
|
+
function resolveEffort(agent, effort) {
|
|
954
|
+
if (!effort) {
|
|
955
|
+
return defaultEffortForAgent(agent);
|
|
956
|
+
}
|
|
957
|
+
if (isEffortForAgent(agent, effort)) {
|
|
958
|
+
return effort;
|
|
959
|
+
}
|
|
960
|
+
throw new Error(
|
|
961
|
+
`Invalid ${agent} effort "${effort}" in review metadata. Expected one of: ${allowedEffortsForAgent(agent).join(", ")}.`
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
function errorToMessage(error) {
|
|
965
|
+
return error instanceof Error ? error.message : String(error);
|
|
966
|
+
}
|
|
967
|
+
function isAlreadyRecordedFailure(state, error) {
|
|
968
|
+
return state.phase === "failed" || typeof error === "object" && error !== null && "mendrFailureRecorded" in error && error.mendrFailureRecorded === true;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// src/daemon.ts
|
|
972
|
+
async function runDaemon(argv = process.argv) {
|
|
973
|
+
const parsed = parseDaemonArgs(argv);
|
|
974
|
+
if (!parsed.ok) {
|
|
975
|
+
throw new Error(parsed.error);
|
|
976
|
+
}
|
|
977
|
+
try {
|
|
978
|
+
await runOrchestrator({
|
|
979
|
+
mendrHome: parsed.mendrHome,
|
|
980
|
+
reviewId: parsed.reviewId
|
|
981
|
+
});
|
|
982
|
+
} catch (error) {
|
|
983
|
+
await recordDaemonFailure(parsed.mendrHome, parsed.reviewId, error);
|
|
984
|
+
throw error;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
function parseDaemonArgs(argv) {
|
|
988
|
+
const args = argv.slice(2);
|
|
989
|
+
let mendrHome = defaultMendrHome();
|
|
990
|
+
let reviewId;
|
|
991
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
992
|
+
const arg = args[index];
|
|
993
|
+
const value = args[index + 1];
|
|
994
|
+
if (arg === "--home" && value) {
|
|
995
|
+
mendrHome = value;
|
|
996
|
+
index += 1;
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
if (arg === "--id" && value) {
|
|
1000
|
+
reviewId = value;
|
|
1001
|
+
index += 1;
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
return {
|
|
1005
|
+
ok: false,
|
|
1006
|
+
error: `Unsupported daemon argument: ${arg}`
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
if (!reviewId) {
|
|
1010
|
+
return {
|
|
1011
|
+
ok: false,
|
|
1012
|
+
error: "Expected --id for daemon review session."
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
return {
|
|
1016
|
+
ok: true,
|
|
1017
|
+
mendrHome,
|
|
1018
|
+
reviewId
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
async function recordDaemonFailure(mendrHome, reviewId, error) {
|
|
1022
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1023
|
+
let state = {
|
|
1024
|
+
phase: "failed",
|
|
1025
|
+
currentStatus: "Daemon failed",
|
|
1026
|
+
issuesFound: 0,
|
|
1027
|
+
issuesFixed: 0,
|
|
1028
|
+
done: false,
|
|
1029
|
+
capReached: false,
|
|
1030
|
+
error: message
|
|
1031
|
+
};
|
|
1032
|
+
try {
|
|
1033
|
+
const existing = await readState(mendrHome, reviewId);
|
|
1034
|
+
if (existing.phase === "failed" && existing.error) {
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
state = {
|
|
1038
|
+
...existing,
|
|
1039
|
+
phase: "failed",
|
|
1040
|
+
currentStatus: "Daemon failed",
|
|
1041
|
+
done: false,
|
|
1042
|
+
error: message
|
|
1043
|
+
};
|
|
1044
|
+
} catch {
|
|
1045
|
+
}
|
|
1046
|
+
await writeState(mendrHome, reviewId, state);
|
|
1047
|
+
await appendEvent(mendrHome, reviewId, {
|
|
1048
|
+
status: "Daemon failed",
|
|
1049
|
+
detail: message
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
if (process.argv[1] && process.argv[1].endsWith("daemon.js")) {
|
|
1053
|
+
runDaemon().catch((error) => {
|
|
1054
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1055
|
+
process.exitCode = 1;
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
export {
|
|
1059
|
+
runDaemon
|
|
1060
|
+
};
|