@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/cli.js
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
allowedEffortsForAgent,
|
|
4
|
+
appendEvent,
|
|
5
|
+
closeReviewSession,
|
|
6
|
+
createDetachedWorktree,
|
|
7
|
+
defaultEffortForAgent,
|
|
8
|
+
defaultExec,
|
|
9
|
+
defaultMendrHome,
|
|
10
|
+
defaultModelForAgent,
|
|
11
|
+
ensureMendrHome,
|
|
12
|
+
execOk,
|
|
13
|
+
fetchPullRequestHeadBranch,
|
|
14
|
+
fetchPullRequestHeadRef,
|
|
15
|
+
getRepoRoot,
|
|
16
|
+
isEffortForAgent,
|
|
17
|
+
isTerminalReviewState,
|
|
18
|
+
readEvents,
|
|
19
|
+
readMeta,
|
|
20
|
+
readState,
|
|
21
|
+
removeWorktree,
|
|
22
|
+
reviewDir,
|
|
23
|
+
reviewsDir,
|
|
24
|
+
sessionWorktreePath,
|
|
25
|
+
validatePullRequest,
|
|
26
|
+
worktreesDir,
|
|
27
|
+
writeMeta,
|
|
28
|
+
writeState
|
|
29
|
+
} from "./chunk-EGSZLVR6.js";
|
|
30
|
+
|
|
31
|
+
// src/cli.ts
|
|
32
|
+
import { spawn } from "child_process";
|
|
33
|
+
import { realpathSync } from "fs";
|
|
34
|
+
import { mkdir, readdir } from "fs/promises";
|
|
35
|
+
import { fileURLToPath } from "url";
|
|
36
|
+
import { Command } from "commander";
|
|
37
|
+
import { Box, Text, render, useApp, useInput, useStdin } from "ink";
|
|
38
|
+
import Spinner from "ink-spinner";
|
|
39
|
+
import React, { useEffect, useState } from "react";
|
|
40
|
+
var agents = /* @__PURE__ */ new Set(["claude", "codex"]);
|
|
41
|
+
function parseCliArgs(argv) {
|
|
42
|
+
const args = argv.slice(2);
|
|
43
|
+
if (args[0] === "ls") {
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
command: "ls"
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (args[0] === "view" || args[0] === "kill" || args[0] === "stop") {
|
|
50
|
+
const reviewId = args[1];
|
|
51
|
+
if (!reviewId) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
exitCode: 1,
|
|
55
|
+
error: `Expected a review id for ${args[0]}.`
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
command: args[0],
|
|
61
|
+
reviewId
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (args[0] === "close") {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
exitCode: 1,
|
|
68
|
+
error: "The close command has been renamed to kill."
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const startArgs = args[0] === "start" ? args.slice(1) : args;
|
|
72
|
+
const [agent, prArg, ...flags] = startArgs;
|
|
73
|
+
if (!isAgent(agent)) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
exitCode: 1,
|
|
77
|
+
error: "Unsupported agent. Expected claude or codex."
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const pr = normalizePr(prArg);
|
|
81
|
+
if (!pr) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
exitCode: 1,
|
|
85
|
+
error: "Expected a pull request number or GitHub pull request URL."
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const startOptions = parseStartFlags(agent, flags);
|
|
89
|
+
if (!startOptions.ok) {
|
|
90
|
+
return startOptions;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
command: "start",
|
|
95
|
+
agent,
|
|
96
|
+
pr,
|
|
97
|
+
maxRounds: startOptions.maxRounds,
|
|
98
|
+
...startOptions.model ? { model: startOptions.model } : {},
|
|
99
|
+
...startOptions.effort ? { effort: startOptions.effort } : {}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
async function startReview(options) {
|
|
103
|
+
const exec = options.exec ?? defaultExec;
|
|
104
|
+
const mendrHome = options.mendrHome ?? defaultMendrHome();
|
|
105
|
+
const cwd = options.cwd ?? process.cwd();
|
|
106
|
+
const model = options.model ?? defaultModelForAgent(options.agent);
|
|
107
|
+
const effort = options.effort ?? defaultEffortForAgent(options.agent);
|
|
108
|
+
if (!isEffortForAgent(options.agent, effort)) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Invalid ${options.agent} effort "${effort}". Expected one of: ${allowedEffortsForAgent(options.agent).join(", ")}.`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
await preflight({
|
|
114
|
+
exec,
|
|
115
|
+
cwd,
|
|
116
|
+
agent: options.agent,
|
|
117
|
+
pr: options.pr
|
|
118
|
+
});
|
|
119
|
+
const repo = await getRepoRoot(exec, cwd);
|
|
120
|
+
const { branch, branchPushRemote } = await fetchPullRequestHeadBranch(exec, repo, options.pr);
|
|
121
|
+
await ensureMendrHome(mendrHome);
|
|
122
|
+
const id = options.createId?.() ?? await createReviewId(mendrHome);
|
|
123
|
+
const dir = reviewDir(mendrHome, id);
|
|
124
|
+
const worktreePath = sessionWorktreePath(mendrHome, id, options.pr);
|
|
125
|
+
const worktreeRef = pullRequestHeadRef(options.pr);
|
|
126
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
127
|
+
const initialState = {
|
|
128
|
+
phase: "starting",
|
|
129
|
+
currentStatus: "Starting",
|
|
130
|
+
issuesFound: 0,
|
|
131
|
+
issuesFixed: 0,
|
|
132
|
+
done: false,
|
|
133
|
+
capReached: false
|
|
134
|
+
};
|
|
135
|
+
let worktreeCreated = false;
|
|
136
|
+
try {
|
|
137
|
+
await mkdir(worktreesDir(mendrHome), { recursive: true });
|
|
138
|
+
await fetchPullRequestHeadRef(exec, repo, options.pr, worktreeRef);
|
|
139
|
+
await createDetachedWorktree(exec, repo, worktreePath, worktreeRef);
|
|
140
|
+
worktreeCreated = true;
|
|
141
|
+
await writeMeta(mendrHome, id, {
|
|
142
|
+
id,
|
|
143
|
+
agent: options.agent,
|
|
144
|
+
pr: options.pr,
|
|
145
|
+
repo,
|
|
146
|
+
branch,
|
|
147
|
+
branchPushRemote,
|
|
148
|
+
worktreePath,
|
|
149
|
+
startedAt,
|
|
150
|
+
pid: 0,
|
|
151
|
+
model,
|
|
152
|
+
effort,
|
|
153
|
+
maxRounds: options.maxRounds
|
|
154
|
+
});
|
|
155
|
+
await writeState(mendrHome, id, initialState);
|
|
156
|
+
const daemon = (options.spawnDaemon ?? defaultSpawnDaemon)({
|
|
157
|
+
mendrHome,
|
|
158
|
+
reviewId: id
|
|
159
|
+
});
|
|
160
|
+
daemon.unref();
|
|
161
|
+
await writeMeta(mendrHome, id, {
|
|
162
|
+
id,
|
|
163
|
+
agent: options.agent,
|
|
164
|
+
pr: options.pr,
|
|
165
|
+
repo,
|
|
166
|
+
branch,
|
|
167
|
+
branchPushRemote,
|
|
168
|
+
worktreePath,
|
|
169
|
+
startedAt,
|
|
170
|
+
pid: daemon.pid,
|
|
171
|
+
model,
|
|
172
|
+
effort,
|
|
173
|
+
maxRounds: options.maxRounds
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
id,
|
|
177
|
+
reviewDir: dir
|
|
178
|
+
};
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (worktreeCreated) {
|
|
181
|
+
await removeWorktree(exec, repo, worktreePath);
|
|
182
|
+
}
|
|
183
|
+
await closeReviewSession(mendrHome, id);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function pullRequestHeadRef(pr) {
|
|
188
|
+
return `refs/mendr/pr-${pr}/head`;
|
|
189
|
+
}
|
|
190
|
+
async function renderReviewList(options = {}) {
|
|
191
|
+
const mendrHome = options.mendrHome ?? defaultMendrHome();
|
|
192
|
+
const sessions = await readReviewSessions(mendrHome);
|
|
193
|
+
if (sessions.length === 0) {
|
|
194
|
+
return "No review sessions.";
|
|
195
|
+
}
|
|
196
|
+
const terminalColumns = normalizeTerminalColumns(options.terminalColumns);
|
|
197
|
+
return sessions.map((session) => formatReviewListItem(session, terminalColumns)).join("\n");
|
|
198
|
+
}
|
|
199
|
+
async function renderReviewViewSnapshot(options) {
|
|
200
|
+
const mendrHome = options.mendrHome ?? defaultMendrHome();
|
|
201
|
+
const identity = await resolveReviewSessionId(mendrHome, options.reviewId);
|
|
202
|
+
const [meta, state, events] = await Promise.all([
|
|
203
|
+
readMeta(mendrHome, identity.storageId),
|
|
204
|
+
readState(mendrHome, identity.storageId),
|
|
205
|
+
readEvents(mendrHome, identity.storageId)
|
|
206
|
+
]);
|
|
207
|
+
const recentEvents = events.slice(-5).map((event) => `${event.status}: ${event.detail}`);
|
|
208
|
+
const frame = [
|
|
209
|
+
`Review ${identity.displayId}`,
|
|
210
|
+
`Agent: ${meta.agent}`,
|
|
211
|
+
`PR: ${meta.pr}`,
|
|
212
|
+
`Status: ${state.currentStatus}`,
|
|
213
|
+
`Issues: ${state.issuesFound} found, ${state.issuesFixed} fixed`,
|
|
214
|
+
state.capReached ? "Round cap reached" : "",
|
|
215
|
+
recentEvents.join("\n")
|
|
216
|
+
].filter(Boolean).join("\n");
|
|
217
|
+
const terminal = isTerminalReviewState(state);
|
|
218
|
+
return {
|
|
219
|
+
...state,
|
|
220
|
+
reviewId: identity.displayId,
|
|
221
|
+
agent: meta.agent,
|
|
222
|
+
pr: meta.pr,
|
|
223
|
+
recentEvents,
|
|
224
|
+
frame,
|
|
225
|
+
spinner: terminal ? "" : "."
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
async function closeReview(options) {
|
|
229
|
+
const mendrHome = options.mendrHome ?? defaultMendrHome();
|
|
230
|
+
const exec = options.exec ?? defaultExec;
|
|
231
|
+
const identity = await resolveReviewSessionId(mendrHome, options.reviewId);
|
|
232
|
+
const [meta, state] = await Promise.all([
|
|
233
|
+
readMeta(mendrHome, identity.storageId),
|
|
234
|
+
readState(mendrHome, identity.storageId)
|
|
235
|
+
]);
|
|
236
|
+
if (meta.worktreePath) {
|
|
237
|
+
if (state.phase !== "complete" && state.phase !== "stopped" && state.phase !== "failed") {
|
|
238
|
+
throw new Error("Stop the review or wait for it to complete before closing this session.");
|
|
239
|
+
}
|
|
240
|
+
await removeWorktree(exec, meta.repo, meta.worktreePath);
|
|
241
|
+
}
|
|
242
|
+
await closeReviewSession(mendrHome, identity.storageId);
|
|
243
|
+
}
|
|
244
|
+
async function stopReview(options) {
|
|
245
|
+
const mendrHome = options.mendrHome ?? defaultMendrHome();
|
|
246
|
+
const killProcess = options.killProcess ?? process.kill;
|
|
247
|
+
const identity = await resolveReviewSessionId(mendrHome, options.reviewId);
|
|
248
|
+
const [meta, state] = await Promise.all([
|
|
249
|
+
readMeta(mendrHome, identity.storageId),
|
|
250
|
+
readState(mendrHome, identity.storageId)
|
|
251
|
+
]);
|
|
252
|
+
let detail = "No daemon pid was recorded.";
|
|
253
|
+
if (meta.pid > 0) {
|
|
254
|
+
try {
|
|
255
|
+
killProcess(-meta.pid, "SIGTERM");
|
|
256
|
+
detail = `Sent SIGTERM to daemon process group ${meta.pid}.`;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (error.code === "ESRCH") {
|
|
259
|
+
detail = `Daemon process group ${meta.pid} was not running.`;
|
|
260
|
+
} else {
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
await writeState(mendrHome, identity.storageId, {
|
|
266
|
+
...state,
|
|
267
|
+
phase: "stopped",
|
|
268
|
+
currentStatus: "Stopped",
|
|
269
|
+
done: true
|
|
270
|
+
});
|
|
271
|
+
await appendEvent(mendrHome, identity.storageId, {
|
|
272
|
+
status: "Stopped",
|
|
273
|
+
detail
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
async function startLiveReviewView(options) {
|
|
277
|
+
const app = render(
|
|
278
|
+
React.createElement(ReviewView, {
|
|
279
|
+
mendrHome: options.mendrHome,
|
|
280
|
+
reviewId: options.reviewId,
|
|
281
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
282
|
+
loadSnapshot: options.loadSnapshot
|
|
283
|
+
})
|
|
284
|
+
);
|
|
285
|
+
await app.waitUntilExit();
|
|
286
|
+
}
|
|
287
|
+
function ReviewView(props) {
|
|
288
|
+
const { exit } = useApp();
|
|
289
|
+
const { isRawModeSupported } = useStdin();
|
|
290
|
+
const loadSnapshot = props.loadSnapshot ?? renderReviewViewSnapshot;
|
|
291
|
+
const [snapshot, setSnapshot] = useState();
|
|
292
|
+
const [error, setError] = useState();
|
|
293
|
+
useInput(
|
|
294
|
+
(input, key) => {
|
|
295
|
+
if (input.toLowerCase() === "q" || key.escape) {
|
|
296
|
+
exit();
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
{ isActive: isRawModeSupported }
|
|
300
|
+
);
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
let active = true;
|
|
303
|
+
let shouldExit = false;
|
|
304
|
+
async function refresh() {
|
|
305
|
+
try {
|
|
306
|
+
const next = await loadSnapshot({
|
|
307
|
+
mendrHome: props.mendrHome,
|
|
308
|
+
reviewId: props.reviewId
|
|
309
|
+
});
|
|
310
|
+
if (!active) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
setSnapshot(next);
|
|
314
|
+
setError(void 0);
|
|
315
|
+
if (isTerminalReviewState(next) && !shouldExit) {
|
|
316
|
+
shouldExit = true;
|
|
317
|
+
setTimeout(() => exit(), 0);
|
|
318
|
+
}
|
|
319
|
+
} catch (refreshError) {
|
|
320
|
+
if (!active) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
setError(refreshError instanceof Error ? refreshError.message : String(refreshError));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
void refresh();
|
|
327
|
+
const interval = setInterval(refresh, props.pollIntervalMs ?? 1e3);
|
|
328
|
+
return () => {
|
|
329
|
+
active = false;
|
|
330
|
+
clearInterval(interval);
|
|
331
|
+
};
|
|
332
|
+
}, [exit, loadSnapshot, props.mendrHome, props.pollIntervalMs, props.reviewId]);
|
|
333
|
+
if (error) {
|
|
334
|
+
return React.createElement(Text, { color: "red" }, error);
|
|
335
|
+
}
|
|
336
|
+
if (!snapshot) {
|
|
337
|
+
return React.createElement(
|
|
338
|
+
Text,
|
|
339
|
+
null,
|
|
340
|
+
React.createElement(Spinner, { type: "dots" }),
|
|
341
|
+
" Loading review..."
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
const terminal = isTerminalReviewState(snapshot);
|
|
345
|
+
return React.createElement(
|
|
346
|
+
Box,
|
|
347
|
+
{ flexDirection: "column" },
|
|
348
|
+
React.createElement(Text, { bold: true }, `Review ${snapshot.reviewId}`),
|
|
349
|
+
React.createElement(Text, null, `Agent: ${snapshot.agent}`),
|
|
350
|
+
React.createElement(Text, null, `PR: ${snapshot.pr}`),
|
|
351
|
+
React.createElement(
|
|
352
|
+
Text,
|
|
353
|
+
null,
|
|
354
|
+
terminal ? "" : React.createElement(Spinner, { type: "dots" }),
|
|
355
|
+
terminal ? "" : " ",
|
|
356
|
+
snapshot.currentStatus
|
|
357
|
+
),
|
|
358
|
+
React.createElement(
|
|
359
|
+
Text,
|
|
360
|
+
null,
|
|
361
|
+
`Issues: ${snapshot.issuesFound} found, ${snapshot.issuesFixed} fixed`
|
|
362
|
+
),
|
|
363
|
+
snapshot.capReached ? React.createElement(Text, { color: "yellow" }, "Round cap reached") : null,
|
|
364
|
+
...snapshot.recentEvents.map(
|
|
365
|
+
(event, index) => React.createElement(Text, { key: `${index}:${event}`, dimColor: true }, event)
|
|
366
|
+
)
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
function isAgent(value) {
|
|
370
|
+
return value !== void 0 && agents.has(value);
|
|
371
|
+
}
|
|
372
|
+
function normalizePr(value) {
|
|
373
|
+
if (!value) {
|
|
374
|
+
return void 0;
|
|
375
|
+
}
|
|
376
|
+
if (/^\d+$/.test(value)) {
|
|
377
|
+
return value;
|
|
378
|
+
}
|
|
379
|
+
const match = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)(?:[/?#].*)?$/.exec(value);
|
|
380
|
+
return match?.[1];
|
|
381
|
+
}
|
|
382
|
+
function parseStartFlags(agent, flags) {
|
|
383
|
+
let maxRounds = 3;
|
|
384
|
+
let model;
|
|
385
|
+
let effort;
|
|
386
|
+
for (let index = 0; index < flags.length; index += 1) {
|
|
387
|
+
const parsedFlag = parseOptionFlag(flags[index]);
|
|
388
|
+
const flag = parsedFlag.name;
|
|
389
|
+
if (flag !== "--rounds" && flag !== "-r" && flag !== "--model" && flag !== "-m" && flag !== "--effort" && flag !== "-e") {
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
exitCode: 1,
|
|
393
|
+
error: `Unsupported option: ${flag}`
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const hasInlineValue = parsedFlag.value !== void 0;
|
|
397
|
+
const rawValue = parsedFlag.value ?? flags[index + 1];
|
|
398
|
+
if (!rawValue) {
|
|
399
|
+
return {
|
|
400
|
+
ok: false,
|
|
401
|
+
exitCode: 1,
|
|
402
|
+
error: `Missing value for ${flag}.`
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
if (!hasInlineValue && (flag === "--model" || flag === "-m" || flag === "--effort" || flag === "-e") && rawValue.startsWith("-")) {
|
|
406
|
+
return {
|
|
407
|
+
ok: false,
|
|
408
|
+
exitCode: 1,
|
|
409
|
+
error: `Missing value for ${flag}.`
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (!hasInlineValue) {
|
|
413
|
+
index += 1;
|
|
414
|
+
}
|
|
415
|
+
if (flag === "--rounds" || flag === "-r") {
|
|
416
|
+
const parsed = Number(rawValue);
|
|
417
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
418
|
+
return {
|
|
419
|
+
ok: false,
|
|
420
|
+
exitCode: 1,
|
|
421
|
+
error: "Invalid rounds value. Expected a positive integer."
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
maxRounds = parsed;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (flag === "--model" || flag === "-m") {
|
|
428
|
+
model = rawValue;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (!isEffortForAgent(agent, rawValue)) {
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
exitCode: 1,
|
|
435
|
+
error: `Invalid ${agent} effort. Expected one of: ${allowedEffortsForAgent(agent).join(", ")}.`
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
effort = rawValue;
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
ok: true,
|
|
442
|
+
maxRounds,
|
|
443
|
+
...model ? { model } : {},
|
|
444
|
+
...effort ? { effort } : {}
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
function parseOptionFlag(raw) {
|
|
448
|
+
const equalsIndex = raw.indexOf("=");
|
|
449
|
+
if (equalsIndex === -1) {
|
|
450
|
+
return { name: raw };
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
name: raw.slice(0, equalsIndex),
|
|
454
|
+
value: raw.slice(equalsIndex + 1)
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
async function preflight(input) {
|
|
458
|
+
await assertBinary(input.exec, "git", ["--version"], input.cwd);
|
|
459
|
+
await assertBinary(input.exec, "gh", ["--version"], input.cwd);
|
|
460
|
+
await assertBinary(input.exec, input.agent, ["--version"], input.cwd);
|
|
461
|
+
const auth = await input.exec("gh", ["auth", "status"], { cwd: input.cwd });
|
|
462
|
+
if (auth.exitCode !== 0) {
|
|
463
|
+
throw new Error("GitHub CLI is not authenticated. Run gh auth login before starting mendr.");
|
|
464
|
+
}
|
|
465
|
+
const repo = await getRepoRoot(input.exec, input.cwd);
|
|
466
|
+
await validatePullRequest(input.exec, repo, input.pr);
|
|
467
|
+
}
|
|
468
|
+
async function assertBinary(exec, command, args, cwd) {
|
|
469
|
+
try {
|
|
470
|
+
await execOk(exec, command, args, { cwd });
|
|
471
|
+
} catch (error) {
|
|
472
|
+
if (error.code === "ENOENT") {
|
|
473
|
+
throw new Error(`${command} not found. Install ${command} before starting mendr.`);
|
|
474
|
+
}
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function defaultSpawnDaemon(args) {
|
|
479
|
+
const daemonPath = fileURLToPath(new URL("./daemon.js", import.meta.url));
|
|
480
|
+
const child = spawn(
|
|
481
|
+
process.execPath,
|
|
482
|
+
[daemonPath, "--home", args.mendrHome, "--id", args.reviewId],
|
|
483
|
+
{
|
|
484
|
+
detached: true,
|
|
485
|
+
stdio: "ignore"
|
|
486
|
+
}
|
|
487
|
+
);
|
|
488
|
+
return {
|
|
489
|
+
pid: child.pid ?? 0,
|
|
490
|
+
unref: () => child.unref()
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
async function createReviewId(mendrHome) {
|
|
494
|
+
const sessions = await readReviewSessions(mendrHome);
|
|
495
|
+
let candidate = sessions.length === 0 ? 1 : Math.max(...sessions.map((session) => session.displayId)) + 1;
|
|
496
|
+
for (; ; ) {
|
|
497
|
+
const id = String(candidate);
|
|
498
|
+
try {
|
|
499
|
+
await mkdir(reviewDir(mendrHome, id));
|
|
500
|
+
return id;
|
|
501
|
+
} catch (error) {
|
|
502
|
+
if (error.code === "EEXIST") {
|
|
503
|
+
candidate += 1;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
async function readReviewSessions(mendrHome) {
|
|
511
|
+
let entries;
|
|
512
|
+
try {
|
|
513
|
+
entries = await readdir(reviewsDir(mendrHome));
|
|
514
|
+
} catch (error) {
|
|
515
|
+
if (error.code === "ENOENT") {
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
throw error;
|
|
519
|
+
}
|
|
520
|
+
const records = await Promise.all(
|
|
521
|
+
entries.sort().map(async (id) => {
|
|
522
|
+
try {
|
|
523
|
+
const [meta, state] = await Promise.all([
|
|
524
|
+
readMeta(mendrHome, id),
|
|
525
|
+
readState(mendrHome, id)
|
|
526
|
+
]);
|
|
527
|
+
return {
|
|
528
|
+
storageId: id,
|
|
529
|
+
meta,
|
|
530
|
+
state
|
|
531
|
+
};
|
|
532
|
+
} catch {
|
|
533
|
+
return void 0;
|
|
534
|
+
}
|
|
535
|
+
})
|
|
536
|
+
);
|
|
537
|
+
return assignDisplayIds(records.filter(isReviewSessionRecord));
|
|
538
|
+
}
|
|
539
|
+
function assignDisplayIds(records) {
|
|
540
|
+
const usedIds = new Set(
|
|
541
|
+
records.flatMap((record) => {
|
|
542
|
+
const numericId = parsePositiveInteger(record.storageId);
|
|
543
|
+
return numericId === void 0 ? [] : [numericId];
|
|
544
|
+
})
|
|
545
|
+
);
|
|
546
|
+
let nextLegacyId = 1;
|
|
547
|
+
const sessions = [...records].sort(compareReviewRecords).map((record) => {
|
|
548
|
+
const numericId = parsePositiveInteger(record.storageId);
|
|
549
|
+
if (numericId !== void 0) {
|
|
550
|
+
return {
|
|
551
|
+
...record,
|
|
552
|
+
displayId: numericId
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
while (usedIds.has(nextLegacyId)) {
|
|
556
|
+
nextLegacyId += 1;
|
|
557
|
+
}
|
|
558
|
+
usedIds.add(nextLegacyId);
|
|
559
|
+
return {
|
|
560
|
+
...record,
|
|
561
|
+
displayId: nextLegacyId++
|
|
562
|
+
};
|
|
563
|
+
});
|
|
564
|
+
return sessions.sort((a, b) => a.displayId - b.displayId);
|
|
565
|
+
}
|
|
566
|
+
function isReviewSessionRecord(record) {
|
|
567
|
+
return record !== void 0;
|
|
568
|
+
}
|
|
569
|
+
async function resolveReviewSessionId(mendrHome, requestedId) {
|
|
570
|
+
const sessions = await readReviewSessions(mendrHome);
|
|
571
|
+
const directMatch = sessions.find(
|
|
572
|
+
(session) => session.storageId === requestedId || session.meta.id === requestedId
|
|
573
|
+
);
|
|
574
|
+
if (directMatch) {
|
|
575
|
+
return {
|
|
576
|
+
storageId: directMatch.storageId,
|
|
577
|
+
displayId: String(directMatch.displayId)
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
const numericId = parsePositiveInteger(requestedId);
|
|
581
|
+
const displayMatch = numericId === void 0 ? void 0 : sessions.find((session) => session.displayId === numericId);
|
|
582
|
+
if (displayMatch) {
|
|
583
|
+
return {
|
|
584
|
+
storageId: displayMatch.storageId,
|
|
585
|
+
displayId: String(displayMatch.displayId)
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
storageId: requestedId,
|
|
590
|
+
displayId: requestedId
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
function formatReviewListItem(session, terminalColumns) {
|
|
594
|
+
const summary = [
|
|
595
|
+
`${session.displayId}: ${formatAgentLabel(session.meta)}`,
|
|
596
|
+
`(PR ${session.meta.pr})`,
|
|
597
|
+
`(${session.state.currentStatus})`,
|
|
598
|
+
`(Found: ${session.state.issuesFound})`,
|
|
599
|
+
`(Fixed: ${session.state.issuesFixed})`
|
|
600
|
+
].join(" ");
|
|
601
|
+
if (summary.length <= terminalColumns) {
|
|
602
|
+
return summary;
|
|
603
|
+
}
|
|
604
|
+
return [
|
|
605
|
+
`${session.displayId}: ${formatAgentLabel(session.meta)}`,
|
|
606
|
+
` PR ${session.meta.pr}`,
|
|
607
|
+
` Status: ${session.state.currentStatus}`,
|
|
608
|
+
` Found: ${session.state.issuesFound}`,
|
|
609
|
+
` Fixed: ${session.state.issuesFixed}`
|
|
610
|
+
].join("\n");
|
|
611
|
+
}
|
|
612
|
+
function formatAgentLabel(meta) {
|
|
613
|
+
return meta.effort ? `${meta.agent}(${meta.effort})` : meta.agent;
|
|
614
|
+
}
|
|
615
|
+
function normalizeTerminalColumns(columns) {
|
|
616
|
+
const resolved = columns ?? process.stdout.columns ?? 80;
|
|
617
|
+
return Number.isFinite(resolved) && resolved > 0 ? resolved : 80;
|
|
618
|
+
}
|
|
619
|
+
function compareReviewRecords(a, b) {
|
|
620
|
+
const startedAtDiff = parseTimestamp(a.meta.startedAt) - parseTimestamp(b.meta.startedAt);
|
|
621
|
+
return startedAtDiff || a.storageId.localeCompare(b.storageId);
|
|
622
|
+
}
|
|
623
|
+
function parseTimestamp(value) {
|
|
624
|
+
const timestamp = Date.parse(value);
|
|
625
|
+
return Number.isNaN(timestamp) ? 0 : timestamp;
|
|
626
|
+
}
|
|
627
|
+
function parsePositiveInteger(value) {
|
|
628
|
+
if (!/^[1-9]\d*$/.test(value)) {
|
|
629
|
+
return void 0;
|
|
630
|
+
}
|
|
631
|
+
const parsed = Number(value);
|
|
632
|
+
return Number.isSafeInteger(parsed) ? parsed : void 0;
|
|
633
|
+
}
|
|
634
|
+
async function main(argv) {
|
|
635
|
+
const program = new Command();
|
|
636
|
+
program.name("mendr").description("Run an autonomous agentic review loop on a GitHub pull request.").argument("[agent]", "agent CLI to use: claude or codex").argument("[pr]", "pull request number or GitHub pull request URL").option("-r, --rounds <n>", "maximum review and fix iterations", "3").option("-m, --model <model>", "agent model override").option("-e, --effort <effort>", "agent effort override").action(
|
|
637
|
+
async (agent, prArg, options) => {
|
|
638
|
+
if (!agent && !prArg) {
|
|
639
|
+
program.help();
|
|
640
|
+
}
|
|
641
|
+
if (agent === "close") {
|
|
642
|
+
throw new Error("The close command has been renamed to kill.");
|
|
643
|
+
}
|
|
644
|
+
if (!isAgent(agent)) {
|
|
645
|
+
throw new Error("Unsupported agent. Expected claude or codex.");
|
|
646
|
+
}
|
|
647
|
+
const pr = normalizePr(prArg);
|
|
648
|
+
if (!pr) {
|
|
649
|
+
throw new Error("Expected a pull request number or GitHub pull request URL.");
|
|
650
|
+
}
|
|
651
|
+
const flags = [
|
|
652
|
+
"--rounds",
|
|
653
|
+
options.rounds,
|
|
654
|
+
...options.model ? ["--model", options.model] : [],
|
|
655
|
+
...options.effort ? ["--effort", options.effort] : []
|
|
656
|
+
];
|
|
657
|
+
const parsedOptions = parseStartFlags(agent, flags);
|
|
658
|
+
if (!parsedOptions.ok) {
|
|
659
|
+
throw new Error(parsedOptions.error);
|
|
660
|
+
}
|
|
661
|
+
const result = await startReview({
|
|
662
|
+
agent,
|
|
663
|
+
pr,
|
|
664
|
+
maxRounds: parsedOptions.maxRounds,
|
|
665
|
+
model: parsedOptions.model,
|
|
666
|
+
effort: parsedOptions.effort
|
|
667
|
+
});
|
|
668
|
+
console.log(`Started ${result.id}`);
|
|
669
|
+
console.log(`View status: mendr view ${result.id}`);
|
|
670
|
+
console.log(result.reviewDir);
|
|
671
|
+
}
|
|
672
|
+
);
|
|
673
|
+
program.command("ls").description("List review sessions.").action(async () => {
|
|
674
|
+
console.log(await renderReviewList());
|
|
675
|
+
});
|
|
676
|
+
program.command("view").description("Watch a live review status view.").argument("<id>", "review id").action(async (reviewId) => {
|
|
677
|
+
await startLiveReviewView({ reviewId });
|
|
678
|
+
});
|
|
679
|
+
program.command("stop").description("Stop a running review daemon and keep its state on disk.").argument("<id>", "review id").action(async (reviewId) => {
|
|
680
|
+
await stopReview({ reviewId });
|
|
681
|
+
console.log(`Stopped ${reviewId}`);
|
|
682
|
+
});
|
|
683
|
+
program.command("kill").description("Remove a review session from local state.").argument("<id>", "review id").action(async (reviewId) => {
|
|
684
|
+
await closeReview({ reviewId });
|
|
685
|
+
console.log(`Killed ${reviewId}`);
|
|
686
|
+
});
|
|
687
|
+
await program.parseAsync(argv);
|
|
688
|
+
}
|
|
689
|
+
function isCliEntrypoint(invokedPath, modulePath = fileURLToPath(import.meta.url)) {
|
|
690
|
+
if (!invokedPath) {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
if (invokedPath === modulePath) {
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
return realpathSync(invokedPath) === realpathSync(modulePath);
|
|
698
|
+
} catch {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (isCliEntrypoint(process.argv[1])) {
|
|
703
|
+
main(process.argv).catch((error) => {
|
|
704
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
705
|
+
process.exitCode = 1;
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
export {
|
|
709
|
+
ReviewView,
|
|
710
|
+
closeReview,
|
|
711
|
+
isCliEntrypoint,
|
|
712
|
+
parseCliArgs,
|
|
713
|
+
renderReviewList,
|
|
714
|
+
renderReviewViewSnapshot,
|
|
715
|
+
startLiveReviewView,
|
|
716
|
+
startReview,
|
|
717
|
+
stopReview
|
|
718
|
+
};
|
package/dist/daemon.d.ts
ADDED