@difflab/pi 0.1.0 → 0.2.0-rc.202609170958.44c1e9f
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 +36 -1
- package/agents/diffpi-autonomous.md +15 -0
- package/agents/diffpi-copilot.md +22 -0
- package/agents/diffpi-orchestrator.md +23 -0
- package/agents/diffpi-planner.md +15 -0
- package/agents/diffpi-reviewer.md +27 -0
- package/agents/diffpi-tutor.md +20 -0
- package/agents/diffpi-worker.md +19 -0
- package/dist/assets.d.ts +4 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/config.d.ts +28 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/environment.d.ts +40 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/extensions/index.js +2919 -183
- package/dist/forge.d.ts +48 -0
- package/dist/forge.d.ts.map +1 -0
- package/dist/fsx.d.ts +6 -0
- package/dist/fsx.d.ts.map +1 -0
- package/dist/gates.d.ts +12 -0
- package/dist/gates.d.ts.map +1 -0
- package/dist/index.d.ts +26 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2162 -162
- package/dist/modes.d.ts +59 -0
- package/dist/modes.d.ts.map +1 -0
- package/dist/pi.d.ts +21 -5
- package/dist/pi.d.ts.map +1 -1
- package/dist/process.d.ts +2 -0
- package/dist/process.d.ts.map +1 -1
- package/dist/review-backend.d.ts +15 -0
- package/dist/review-backend.d.ts.map +1 -0
- package/dist/review-publication.d.ts +17 -0
- package/dist/review-publication.d.ts.map +1 -0
- package/dist/review-types.d.ts +49 -0
- package/dist/review-types.d.ts.map +1 -0
- package/dist/review.d.ts +62 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/setup.d.ts +11 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/store.d.ts +15 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/templates.d.ts +14 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/tools/index.d.ts +6 -2
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2631 -172
- package/dist/tools/modes.d.ts +4 -0
- package/dist/tools/modes.d.ts.map +1 -0
- package/dist/tools/review.d.ts +7 -0
- package/dist/tools/review.d.ts.map +1 -0
- package/dist/tools/setup.d.ts.map +1 -1
- package/dist/tools/templates.d.ts +3 -0
- package/dist/tools/templates.d.ts.map +1 -0
- package/dist/tuicr.d.ts +55 -0
- package/dist/tuicr.d.ts.map +1 -0
- package/dist/zed.d.ts +11 -0
- package/dist/zed.d.ts.map +1 -0
- package/package.json +4 -2
- package/skills/diffpi-setup/SKILL.md +15 -1
- package/skills/mode/SKILL.md +38 -0
- package/skills/review/SKILL.md +15 -0
- package/skills/review/references/workflows/address.md +10 -0
- package/skills/review/references/workflows/edit.md +9 -0
- package/skills/review/references/workflows/help.md +12 -0
- package/skills/review/references/workflows/merge.md +6 -0
- package/skills/review/references/workflows/new.md +9 -0
- package/skills/review/references/workflows/open.md +7 -0
- package/skills/review/references/workflows/publish.md +8 -0
- package/templates/review/draft-pr.md +18 -0
package/dist/index.js
CHANGED
|
@@ -1,10 +1,1541 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/fsx.ts
|
|
8
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
9
|
+
async function readDirectoryIfExists(path) {
|
|
10
|
+
try {
|
|
11
|
+
return await readdir(path, { withFileTypes: true });
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (isMissingPath(error))
|
|
14
|
+
return [];
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function readTextIfExists(path) {
|
|
19
|
+
try {
|
|
20
|
+
return await readFile(path, "utf8");
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (isMissingPath(error))
|
|
23
|
+
return;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function isMissingPath(error) {
|
|
28
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/config.ts
|
|
32
|
+
var modelReferenceSchema = z.string().trim().min(1);
|
|
33
|
+
var agentConfigSchema = z.object({
|
|
34
|
+
models: z.array(modelReferenceSchema).optional()
|
|
35
|
+
}).strict();
|
|
36
|
+
var diffpiConfigSchema = z.object({
|
|
37
|
+
agents: z.record(z.string(), agentConfigSchema).optional()
|
|
38
|
+
}).strict();
|
|
39
|
+
function diffpiConfigPaths(homeDir = homedir()) {
|
|
40
|
+
const directory = join(homeDir, ".difflab", "diffpi");
|
|
41
|
+
return {
|
|
42
|
+
yaml: join(directory, "config.yaml"),
|
|
43
|
+
json: join(directory, "config.json")
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async function loadDiffpiConfig(options = {}) {
|
|
47
|
+
const paths = diffpiConfigPaths(options.homeDir);
|
|
48
|
+
for (const [format, path] of [
|
|
49
|
+
["yaml", paths.yaml],
|
|
50
|
+
["json", paths.json]
|
|
51
|
+
]) {
|
|
52
|
+
const content = await readTextIfExists(path);
|
|
53
|
+
if (content === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
try {
|
|
56
|
+
const value = format === "yaml" ? parseYamlConfig(content) : JSON.parse(content);
|
|
57
|
+
return { config: diffpiConfigSchema.parse(value ?? {}), path };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
60
|
+
throw new Error(`Invalid Diffpi config at ${path}: ${reason}`, { cause: error });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { config: {} };
|
|
64
|
+
}
|
|
65
|
+
function resolveAgentModelPreferences(agentId, profilePreferences, config) {
|
|
66
|
+
const override = config.agents?.[agentId];
|
|
67
|
+
if (override && Object.hasOwn(override, "models"))
|
|
68
|
+
return [...override.models ?? []];
|
|
69
|
+
return [...profilePreferences];
|
|
70
|
+
}
|
|
71
|
+
function findPreferredModel(models, preference) {
|
|
72
|
+
const normalizedPreference = normalizeModelReference(preference);
|
|
73
|
+
const exactReference = models.find((model) => normalizeModelReference(`${model.provider}/${model.id}`) === normalizedPreference);
|
|
74
|
+
if (exactReference)
|
|
75
|
+
return exactReference;
|
|
76
|
+
const idPreference = preference.includes("/") ? preference.slice(preference.indexOf("/") + 1) : preference;
|
|
77
|
+
const normalizedIdPreference = normalizeModelReference(idPreference);
|
|
78
|
+
const exactId = models.find((model) => normalizeModelReference(model.id) === normalizedIdPreference);
|
|
79
|
+
if (exactId)
|
|
80
|
+
return exactId;
|
|
81
|
+
const preferenceTokens = normalizedIdPreference.split("-").filter(Boolean);
|
|
82
|
+
return models.find((model) => {
|
|
83
|
+
const modelTokens = new Set(normalizeModelReference(model.id).split("-").filter(Boolean));
|
|
84
|
+
return preferenceTokens.every((token) => modelTokens.has(token));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function parseYamlConfig(content) {
|
|
88
|
+
const document = content.replace(/^\uFEFF/, "").replace(/^---[^\S\r\n]*(?:#.*)?(?:\r?\n|$)/, "");
|
|
89
|
+
return parseFrontmatter(`---
|
|
90
|
+
${document}
|
|
91
|
+
---
|
|
92
|
+
`).frontmatter;
|
|
93
|
+
}
|
|
94
|
+
function normalizeModelReference(value) {
|
|
95
|
+
return value.toLowerCase().replace(/^~/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
96
|
+
}
|
|
97
|
+
// src/environment.ts
|
|
98
|
+
import { basename } from "node:path";
|
|
99
|
+
|
|
100
|
+
// src/process.ts
|
|
101
|
+
import { constants } from "node:fs";
|
|
102
|
+
import { access } from "node:fs/promises";
|
|
103
|
+
import { delimiter, join as join2 } from "node:path";
|
|
104
|
+
import { spawn } from "node:child_process";
|
|
105
|
+
var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
|
|
106
|
+
async function findExecutable(name) {
|
|
107
|
+
if (name.includes("/")) {
|
|
108
|
+
try {
|
|
109
|
+
await access(name, constants.X_OK);
|
|
110
|
+
return name;
|
|
111
|
+
} catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
116
|
+
if (!directory)
|
|
117
|
+
continue;
|
|
118
|
+
const candidate = join2(directory, name);
|
|
119
|
+
try {
|
|
120
|
+
await access(candidate, constants.X_OK);
|
|
121
|
+
return candidate;
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
function run(command, args, options = {}) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const child = spawn(command, args, {
|
|
129
|
+
cwd: options.cwd,
|
|
130
|
+
env: options.env ?? process.env,
|
|
131
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
|
|
132
|
+
});
|
|
133
|
+
let stdout = "";
|
|
134
|
+
let stderr = "";
|
|
135
|
+
const stdoutChunks = [];
|
|
136
|
+
const stderrChunks = [];
|
|
137
|
+
const unbounded = options.capture === "unbounded";
|
|
138
|
+
child.stdout?.on("data", (chunk) => {
|
|
139
|
+
const text = chunk.toString();
|
|
140
|
+
if (unbounded)
|
|
141
|
+
stdoutChunks.push(text);
|
|
142
|
+
else
|
|
143
|
+
stdout = appendBounded(stdout, text);
|
|
144
|
+
});
|
|
145
|
+
child.stderr?.on("data", (chunk) => {
|
|
146
|
+
const text = chunk.toString();
|
|
147
|
+
if (unbounded)
|
|
148
|
+
stderrChunks.push(text);
|
|
149
|
+
else
|
|
150
|
+
stderr = appendBounded(stderr, text);
|
|
151
|
+
});
|
|
152
|
+
child.on("error", reject);
|
|
153
|
+
child.on("close", (code) => resolve({
|
|
154
|
+
code: code ?? 1,
|
|
155
|
+
stdout: unbounded ? stdoutChunks.join("") : stdout,
|
|
156
|
+
stderr: unbounded ? stderrChunks.join("") : stderr
|
|
157
|
+
}));
|
|
158
|
+
if (options.input !== undefined && child.stdin)
|
|
159
|
+
child.stdin.end(options.input);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
async function runChecked(command, args, options = {}) {
|
|
163
|
+
const result = await run(command, args, options);
|
|
164
|
+
if (result.code === 0)
|
|
165
|
+
return result;
|
|
166
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
167
|
+
throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
168
|
+
}
|
|
169
|
+
function appendBounded(current, next) {
|
|
170
|
+
const combined = current + next;
|
|
171
|
+
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/zed.ts
|
|
175
|
+
import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
|
|
176
|
+
import { homedir as homedir2 } from "node:os";
|
|
177
|
+
import { dirname, join as join3 } from "node:path";
|
|
178
|
+
var ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
|
|
179
|
+
var REVIEW_KEYBINDING = "cmd-alt-r";
|
|
180
|
+
var REVIEW_TASK = {
|
|
181
|
+
label: ZED_REVIEW_TASK_NAME,
|
|
182
|
+
command: "tuicr",
|
|
183
|
+
args: ["-w"],
|
|
184
|
+
cwd: "$ZED_WORKTREE_ROOT",
|
|
185
|
+
use_new_terminal: true,
|
|
186
|
+
reveal: "always"
|
|
187
|
+
};
|
|
188
|
+
function zedTasksPath(homeDir = homedir2()) {
|
|
189
|
+
return join3(homeDir, ".config", "zed", "tasks.json");
|
|
190
|
+
}
|
|
191
|
+
function zedKeymapPath(homeDir = homedir2()) {
|
|
192
|
+
return join3(homeDir, ".config", "zed", "keymap.json");
|
|
193
|
+
}
|
|
194
|
+
async function ensureZedReviewTask(homeDir = homedir2()) {
|
|
195
|
+
const path = zedTasksPath(homeDir);
|
|
196
|
+
const currentText = await readOptional(path);
|
|
197
|
+
const tasks = parseJsonArray(currentText, path);
|
|
198
|
+
const index = tasks.findIndex((task) => task.label === ZED_REVIEW_TASK_NAME);
|
|
199
|
+
const next = [...tasks];
|
|
200
|
+
if (index >= 0)
|
|
201
|
+
next[index] = { ...tasks[index], ...REVIEW_TASK };
|
|
202
|
+
else
|
|
203
|
+
next.push(REVIEW_TASK);
|
|
204
|
+
const changed = JSON.stringify(tasks) !== JSON.stringify(next);
|
|
205
|
+
if (changed)
|
|
206
|
+
await writeJson(path, next);
|
|
207
|
+
return { path, changed, existed: currentText !== undefined };
|
|
208
|
+
}
|
|
209
|
+
async function ensureZedReviewKeybinding(homeDir = homedir2()) {
|
|
210
|
+
const path = zedKeymapPath(homeDir);
|
|
211
|
+
const currentText = await readOptional(path);
|
|
212
|
+
const entries = parseJsonArray(currentText, path);
|
|
213
|
+
const alreadyBound = entries.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
|
|
214
|
+
if (alreadyBound)
|
|
215
|
+
return { path, changed: false, existed: currentText !== undefined };
|
|
216
|
+
const next = [
|
|
217
|
+
...entries,
|
|
218
|
+
{ context: "Workspace", bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_REVIEW_TASK_NAME }] } }
|
|
219
|
+
];
|
|
220
|
+
await writeJson(path, next);
|
|
221
|
+
return { path, changed: true, existed: currentText !== undefined };
|
|
222
|
+
}
|
|
223
|
+
function bindsReviewTask(payload) {
|
|
224
|
+
return typeof payload === "object" && payload !== null && payload.task_name === ZED_REVIEW_TASK_NAME;
|
|
225
|
+
}
|
|
226
|
+
async function readOptional(path) {
|
|
227
|
+
try {
|
|
228
|
+
return await readFile2(path, "utf8");
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
231
|
+
return;
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function parseJsonArray(content, path) {
|
|
236
|
+
if (!content?.trim())
|
|
237
|
+
return [];
|
|
238
|
+
let value;
|
|
239
|
+
try {
|
|
240
|
+
value = JSON.parse(content);
|
|
241
|
+
} catch {
|
|
242
|
+
throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
|
|
243
|
+
}
|
|
244
|
+
if (!Array.isArray(value))
|
|
245
|
+
throw new Error(`Expected a JSON array in ${path}.`);
|
|
246
|
+
return value;
|
|
247
|
+
}
|
|
248
|
+
async function writeJson(path, value) {
|
|
249
|
+
await mkdir(dirname(path), { recursive: true });
|
|
250
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}
|
|
251
|
+
`, "utf8");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/environment.ts
|
|
255
|
+
function detectIde(env = process.env) {
|
|
256
|
+
const program = (env.TERM_PROGRAM ?? "").toLowerCase();
|
|
257
|
+
if (env.ZED_TERM === "true" || program === "zed")
|
|
258
|
+
return "zed";
|
|
259
|
+
if (env.CURSOR_TRACE_ID || program === "cursor")
|
|
260
|
+
return "cursor";
|
|
261
|
+
if (env.WINDSURF_ENV || program === "windsurf")
|
|
262
|
+
return "windsurf";
|
|
263
|
+
if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
|
|
264
|
+
return "jetbrains";
|
|
265
|
+
if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
|
|
266
|
+
return "vscode";
|
|
267
|
+
return "unknown";
|
|
268
|
+
}
|
|
269
|
+
function detectMux(env = process.env) {
|
|
270
|
+
if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
|
|
271
|
+
return "zellij";
|
|
272
|
+
if (env.TMUX)
|
|
273
|
+
return "tmux";
|
|
274
|
+
if (env.STY)
|
|
275
|
+
return "screen";
|
|
276
|
+
return "none";
|
|
277
|
+
}
|
|
278
|
+
function detectShell(env = process.env) {
|
|
279
|
+
return env.SHELL ? basename(env.SHELL) : "unknown";
|
|
280
|
+
}
|
|
281
|
+
async function detectVcs(cwd) {
|
|
282
|
+
const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
|
|
283
|
+
const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
|
284
|
+
const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
|
|
285
|
+
return { ...parseRemote(remote), branch, root };
|
|
286
|
+
}
|
|
287
|
+
function parseRemote(remote) {
|
|
288
|
+
const empty = { provider: "none", host: "", owner: "", repo: "" };
|
|
289
|
+
if (!remote)
|
|
290
|
+
return empty;
|
|
291
|
+
const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
|
|
292
|
+
const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
293
|
+
const match = scp ?? url;
|
|
294
|
+
if (!match)
|
|
295
|
+
return empty;
|
|
296
|
+
const host = match[1];
|
|
297
|
+
const segments = match[2].split("/").filter(Boolean);
|
|
298
|
+
if (segments.length < 2)
|
|
299
|
+
return { ...empty, host };
|
|
300
|
+
const repo = segments.at(-1) ?? "";
|
|
301
|
+
const owner = segments.slice(0, -1).join("/");
|
|
302
|
+
const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
|
|
303
|
+
return { provider, host, owner, repo };
|
|
304
|
+
}
|
|
305
|
+
async function openInNewTab(command, opts) {
|
|
306
|
+
const env = opts.env ?? process.env;
|
|
307
|
+
const name = opts.name ?? "review";
|
|
308
|
+
const printable = command.join(" ");
|
|
309
|
+
const mux = detectMux(env);
|
|
310
|
+
if (mux !== "none") {
|
|
311
|
+
const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
|
|
312
|
+
if (opened)
|
|
313
|
+
return opened;
|
|
314
|
+
}
|
|
315
|
+
if (detectIde(env) === "zed") {
|
|
316
|
+
try {
|
|
317
|
+
await ensureZedReviewTask(opts.homeDir);
|
|
318
|
+
return {
|
|
319
|
+
launched: false,
|
|
320
|
+
configured: true,
|
|
321
|
+
via: "zed-task",
|
|
322
|
+
command: printable,
|
|
323
|
+
taskName: ZED_REVIEW_TASK_NAME,
|
|
324
|
+
instruction: `Run the Zed task "${ZED_REVIEW_TASK_NAME}".`
|
|
325
|
+
};
|
|
326
|
+
} catch {}
|
|
327
|
+
}
|
|
328
|
+
return { launched: false, via: "print", command: printable };
|
|
329
|
+
}
|
|
330
|
+
async function openMuxTab(mux, command, cwd, name, printable) {
|
|
331
|
+
if (mux === "zellij" && await findExecutable("zellij")) {
|
|
332
|
+
const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
333
|
+
if (result.code === 0)
|
|
334
|
+
return { launched: true, via: "zellij", command: printable };
|
|
335
|
+
const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
336
|
+
if (fallback.code === 0)
|
|
337
|
+
return { launched: true, via: "zellij-run", command: printable };
|
|
338
|
+
}
|
|
339
|
+
if (mux === "tmux" && await findExecutable("tmux")) {
|
|
340
|
+
const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
|
|
341
|
+
if (result.code === 0)
|
|
342
|
+
return { launched: true, via: "tmux", command: printable };
|
|
343
|
+
}
|
|
344
|
+
if (mux === "screen" && await findExecutable("screen")) {
|
|
345
|
+
const result = await run("screen", screenWindowArgs(command, cwd, name));
|
|
346
|
+
if (result.code === 0)
|
|
347
|
+
return { launched: true, via: "screen", command: printable };
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
function screenWindowArgs(command, cwd, name) {
|
|
352
|
+
return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
|
|
353
|
+
}
|
|
354
|
+
// src/forge.ts
|
|
355
|
+
function createForge(vcs) {
|
|
356
|
+
if (vcs.provider === "github")
|
|
357
|
+
return new GithubForge(vcs);
|
|
358
|
+
if (vcs.provider === "gitlab")
|
|
359
|
+
return new GitlabForge(vcs);
|
|
360
|
+
throw new Error("No forge detected from the git remote. Use --local for an offline review.");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
class GithubForge {
|
|
364
|
+
vcs;
|
|
365
|
+
provider = "github";
|
|
366
|
+
constructor(vcs) {
|
|
367
|
+
this.vcs = vcs;
|
|
368
|
+
}
|
|
369
|
+
repoFlag() {
|
|
370
|
+
return ["--repo", `${this.vcs.owner}/${this.vcs.repo}`];
|
|
371
|
+
}
|
|
372
|
+
async createDraftPr(options) {
|
|
373
|
+
const args = [
|
|
374
|
+
"pr",
|
|
375
|
+
"create",
|
|
376
|
+
...this.repoFlag(),
|
|
377
|
+
"--title",
|
|
378
|
+
options.title,
|
|
379
|
+
"--body",
|
|
380
|
+
options.body,
|
|
381
|
+
"--base",
|
|
382
|
+
options.base,
|
|
383
|
+
"--head",
|
|
384
|
+
options.head
|
|
385
|
+
];
|
|
386
|
+
if (options.draft !== false)
|
|
387
|
+
args.push("--draft");
|
|
388
|
+
await runChecked("gh", args);
|
|
389
|
+
const ref = await this.viewPr(options.head);
|
|
390
|
+
if (!ref)
|
|
391
|
+
throw new Error("Draft PR created but could not be resolved.");
|
|
392
|
+
return ref;
|
|
393
|
+
}
|
|
394
|
+
async viewPr(idOrBranch) {
|
|
395
|
+
const args = [
|
|
396
|
+
"pr",
|
|
397
|
+
"view",
|
|
398
|
+
idOrBranch,
|
|
399
|
+
...this.repoFlag(),
|
|
400
|
+
"--json",
|
|
401
|
+
"number,title,url,isDraft,baseRefName,headRefName,headRefOid"
|
|
402
|
+
];
|
|
403
|
+
const result = await run("gh", args);
|
|
404
|
+
if (result.code !== 0) {
|
|
405
|
+
if (isConfirmedMissingChange("github", result.stderr || result.stdout))
|
|
406
|
+
return;
|
|
407
|
+
throw commandFailure("gh", args, result);
|
|
408
|
+
}
|
|
409
|
+
if (!result.stdout.trim())
|
|
410
|
+
throw new Error("GitHub returned an empty pull request response.");
|
|
411
|
+
let data;
|
|
412
|
+
try {
|
|
413
|
+
data = JSON.parse(result.stdout);
|
|
414
|
+
} catch {
|
|
415
|
+
throw new Error("Cannot parse the GitHub pull request response as JSON.");
|
|
416
|
+
}
|
|
417
|
+
if (typeof data.number !== "number" || typeof data.title !== "string" || typeof data.url !== "string" || typeof data.isDraft !== "boolean" || typeof data.baseRefName !== "string" || typeof data.headRefName !== "string") {
|
|
418
|
+
throw new Error("GitHub returned an invalid pull request response.");
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
number: data.number,
|
|
422
|
+
title: data.title,
|
|
423
|
+
url: data.url,
|
|
424
|
+
isDraft: data.isDraft,
|
|
425
|
+
baseRef: data.baseRefName,
|
|
426
|
+
headRef: data.headRefName,
|
|
427
|
+
headSha: data.headRefOid
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
async defaultBranch() {
|
|
431
|
+
const result = await runChecked("gh", [
|
|
432
|
+
"repo",
|
|
433
|
+
"view",
|
|
434
|
+
`${this.vcs.owner}/${this.vcs.repo}`,
|
|
435
|
+
"--json",
|
|
436
|
+
"defaultBranchRef",
|
|
437
|
+
"--jq",
|
|
438
|
+
".defaultBranchRef.name"
|
|
439
|
+
]);
|
|
440
|
+
return requireBranchName(result.stdout, "GitHub");
|
|
441
|
+
}
|
|
442
|
+
async prDiff(id) {
|
|
443
|
+
const result = await runChecked("gh", ["pr", "diff", String(id), ...this.repoFlag()], { capture: "unbounded" });
|
|
444
|
+
return result.stdout;
|
|
445
|
+
}
|
|
446
|
+
async prChecks(id) {
|
|
447
|
+
const result = await run("gh", ["pr", "checks", String(id), ...this.repoFlag()]);
|
|
448
|
+
return result.stdout;
|
|
449
|
+
}
|
|
450
|
+
async markReady(id) {
|
|
451
|
+
await runChecked("gh", ["pr", "ready", String(id), ...this.repoFlag()]);
|
|
452
|
+
}
|
|
453
|
+
async closePr(id, comment) {
|
|
454
|
+
const args = ["pr", "close", String(id), ...this.repoFlag()];
|
|
455
|
+
if (comment)
|
|
456
|
+
args.push("--comment", comment);
|
|
457
|
+
await runChecked("gh", args);
|
|
458
|
+
}
|
|
459
|
+
async mergePr(id, subject) {
|
|
460
|
+
const readiness = await runChecked("gh", [
|
|
461
|
+
"pr",
|
|
462
|
+
"view",
|
|
463
|
+
String(id),
|
|
464
|
+
...this.repoFlag(),
|
|
465
|
+
"--json",
|
|
466
|
+
"isDraft,state,reviewDecision,mergeStateStatus,statusCheckRollup"
|
|
467
|
+
]);
|
|
468
|
+
assertGitHubMergeReady(readiness.stdout);
|
|
469
|
+
await runChecked("gh", ["pr", "merge", String(id), ...this.repoFlag(), "--squash", "--subject", subject]);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
class GitlabForge {
|
|
474
|
+
vcs;
|
|
475
|
+
provider = "gitlab";
|
|
476
|
+
constructor(vcs) {
|
|
477
|
+
this.vcs = vcs;
|
|
478
|
+
}
|
|
479
|
+
project() {
|
|
480
|
+
return `${this.vcs.owner}/${this.vcs.repo}`;
|
|
481
|
+
}
|
|
482
|
+
async createDraftPr(options) {
|
|
483
|
+
await runChecked("glab", [
|
|
484
|
+
"mr",
|
|
485
|
+
"create",
|
|
486
|
+
"--repo",
|
|
487
|
+
this.project(),
|
|
488
|
+
"--title",
|
|
489
|
+
`Draft: ${options.title}`,
|
|
490
|
+
"--description",
|
|
491
|
+
options.body,
|
|
492
|
+
"--target-branch",
|
|
493
|
+
options.base,
|
|
494
|
+
"--source-branch",
|
|
495
|
+
options.head,
|
|
496
|
+
"--yes"
|
|
497
|
+
]);
|
|
498
|
+
const ref = await this.viewPr(options.head);
|
|
499
|
+
if (!ref)
|
|
500
|
+
throw new Error("Draft MR created but could not be resolved.");
|
|
501
|
+
return ref;
|
|
502
|
+
}
|
|
503
|
+
async viewPr(idOrBranch) {
|
|
504
|
+
const args = ["mr", "view", idOrBranch, "--repo", this.project(), "--output", "json"];
|
|
505
|
+
const result = await run("glab", args);
|
|
506
|
+
if (result.code !== 0) {
|
|
507
|
+
if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
|
|
508
|
+
return;
|
|
509
|
+
throw commandFailure("glab", args, result);
|
|
510
|
+
}
|
|
511
|
+
if (!result.stdout.trim())
|
|
512
|
+
throw new Error("GitLab returned an empty merge request response.");
|
|
513
|
+
let data;
|
|
514
|
+
try {
|
|
515
|
+
data = JSON.parse(result.stdout);
|
|
516
|
+
} catch {
|
|
517
|
+
throw new Error("Cannot parse the GitLab merge request response as JSON.");
|
|
518
|
+
}
|
|
519
|
+
if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string") {
|
|
520
|
+
throw new Error("GitLab returned an invalid merge request response.");
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
number: data.iid,
|
|
524
|
+
title: data.title,
|
|
525
|
+
url: data.web_url,
|
|
526
|
+
isDraft: Boolean(data.draft ?? data.work_in_progress),
|
|
527
|
+
baseRef: data.target_branch,
|
|
528
|
+
headRef: data.source_branch,
|
|
529
|
+
headSha: data.sha
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
async defaultBranch() {
|
|
533
|
+
const result = await runChecked("glab", [
|
|
534
|
+
"api",
|
|
535
|
+
`projects/${encodeURIComponent(this.project())}`,
|
|
536
|
+
"--jq",
|
|
537
|
+
".default_branch"
|
|
538
|
+
]);
|
|
539
|
+
return requireBranchName(result.stdout, "GitLab");
|
|
540
|
+
}
|
|
541
|
+
async prDiff(id) {
|
|
542
|
+
return (await runChecked("glab", ["mr", "diff", String(id), "--repo", this.project()], { capture: "unbounded" })).stdout;
|
|
543
|
+
}
|
|
544
|
+
async prChecks(id) {
|
|
545
|
+
return (await runChecked("glab", [
|
|
546
|
+
"api",
|
|
547
|
+
`projects/${encodeURIComponent(this.project())}/merge_requests/${id}/pipelines?per_page=100`
|
|
548
|
+
])).stdout;
|
|
549
|
+
}
|
|
550
|
+
async markReady(id) {
|
|
551
|
+
await runChecked("glab", ["mr", "update", String(id), "--repo", this.project(), "--ready"]);
|
|
552
|
+
}
|
|
553
|
+
async closePr(id, comment) {
|
|
554
|
+
if (comment)
|
|
555
|
+
await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", comment]);
|
|
556
|
+
await runChecked("glab", ["mr", "close", String(id), "--repo", this.project()]);
|
|
557
|
+
}
|
|
558
|
+
async mergePr() {
|
|
559
|
+
throw new Error("Merge is not supported by the GitLab forge adapter.");
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function assertGitHubMergeReady(input) {
|
|
563
|
+
let data;
|
|
564
|
+
try {
|
|
565
|
+
data = JSON.parse(input);
|
|
566
|
+
} catch {
|
|
567
|
+
throw new Error("Merge blocked: GitHub readiness response was not valid JSON.");
|
|
568
|
+
}
|
|
569
|
+
const blockers = [];
|
|
570
|
+
if (data.state !== "OPEN")
|
|
571
|
+
blockers.push(`pull request state is ${data.state ?? "unknown"}`);
|
|
572
|
+
if (data.isDraft)
|
|
573
|
+
blockers.push("pull request is still a draft");
|
|
574
|
+
if (data.reviewDecision !== "APPROVED")
|
|
575
|
+
blockers.push(`review decision is ${data.reviewDecision || "not approved"}`);
|
|
576
|
+
if (data.mergeStateStatus !== "CLEAN")
|
|
577
|
+
blockers.push(`merge state is ${data.mergeStateStatus ?? "unknown"}`);
|
|
578
|
+
for (const check of data.statusCheckRollup ?? []) {
|
|
579
|
+
const name = check.name ?? check.context ?? "unnamed check";
|
|
580
|
+
if (check.__typename === "CheckRun") {
|
|
581
|
+
if (check.status !== "COMPLETED")
|
|
582
|
+
blockers.push(`${name} is ${check.status?.toLowerCase() ?? "pending"}`);
|
|
583
|
+
else if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(check.conclusion ?? "")) {
|
|
584
|
+
blockers.push(`${name} concluded ${(check.conclusion ?? "unknown").toLowerCase()}`);
|
|
585
|
+
}
|
|
586
|
+
} else if (check.state !== "SUCCESS")
|
|
587
|
+
blockers.push(`${name} is ${(check.state ?? "pending").toLowerCase()}`);
|
|
588
|
+
}
|
|
589
|
+
if (blockers.length > 0)
|
|
590
|
+
throw new Error(`Merge blocked: ${blockers.join("; ")}.`);
|
|
591
|
+
}
|
|
592
|
+
function isConfirmedMissingChange(provider, output) {
|
|
593
|
+
const message = output.toLowerCase();
|
|
594
|
+
if (provider === "github") {
|
|
595
|
+
return message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest");
|
|
596
|
+
}
|
|
597
|
+
if (provider === "gitlab") {
|
|
598
|
+
return message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message);
|
|
599
|
+
}
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
function commandFailure(command, args, result) {
|
|
603
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
604
|
+
return new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
605
|
+
}
|
|
606
|
+
function requireBranchName(output, provider) {
|
|
607
|
+
const branch = output.trim();
|
|
608
|
+
if (!branch || branch === "null")
|
|
609
|
+
throw new Error(`${provider} did not return a default branch.`);
|
|
610
|
+
return branch;
|
|
611
|
+
}
|
|
612
|
+
// src/gates.ts
|
|
613
|
+
var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
|
|
614
|
+
var MISE_GATES = ["format:check", "lint", "test"];
|
|
615
|
+
function checkConventionalSubject(subject) {
|
|
616
|
+
const trimmed = subject.trim();
|
|
617
|
+
const ok = CONVENTIONAL_COMMIT.test(trimmed);
|
|
618
|
+
return {
|
|
619
|
+
name: "conventional-subject",
|
|
620
|
+
status: ok ? "pass" : "warn",
|
|
621
|
+
detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
async function runMiseGates(cwd) {
|
|
625
|
+
const tasks = await discoverMiseTasks(cwd);
|
|
626
|
+
const results = [];
|
|
627
|
+
for (const gate of MISE_GATES) {
|
|
628
|
+
const targets = tasks.get(gate) ?? [];
|
|
629
|
+
if (targets.length === 0) {
|
|
630
|
+
results.push({ name: gate, status: "skip", detail: "no mise recipe" });
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
|
|
634
|
+
const result = await run("mise", ["run", ...invocations], { cwd });
|
|
635
|
+
results.push({
|
|
636
|
+
name: gate,
|
|
637
|
+
status: result.code === 0 ? "pass" : "fail",
|
|
638
|
+
detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
return results;
|
|
642
|
+
}
|
|
643
|
+
function ciGate(checksOutput) {
|
|
644
|
+
const text = checksOutput.toLowerCase();
|
|
645
|
+
if (!text.trim())
|
|
646
|
+
return { name: "ci", status: "skip", detail: "no CI output" };
|
|
647
|
+
if (/\bfail|error\b/.test(text))
|
|
648
|
+
return { name: "ci", status: "warn", detail: "CI failing" };
|
|
649
|
+
if (/\bpending|in progress|queued\b/.test(text))
|
|
650
|
+
return { name: "ci", status: "warn", detail: "CI pending" };
|
|
651
|
+
return { name: "ci", status: "pass", detail: "CI green" };
|
|
652
|
+
}
|
|
653
|
+
async function discoverMiseTasks(cwd) {
|
|
654
|
+
const result = await run("mise", ["tasks", "--json", "--all"], { cwd });
|
|
655
|
+
if (result.code !== 0)
|
|
656
|
+
return new Map;
|
|
657
|
+
return parseMiseTasks(result.stdout);
|
|
658
|
+
}
|
|
659
|
+
function parseMiseTasks(input) {
|
|
660
|
+
let tasks;
|
|
661
|
+
try {
|
|
662
|
+
tasks = JSON.parse(input);
|
|
663
|
+
} catch {
|
|
664
|
+
return new Map;
|
|
665
|
+
}
|
|
666
|
+
if (!Array.isArray(tasks))
|
|
667
|
+
return new Map;
|
|
668
|
+
const found = new Map;
|
|
669
|
+
for (const gate of MISE_GATES) {
|
|
670
|
+
const targets = tasks.flatMap((task) => {
|
|
671
|
+
if (typeof task.name !== "string")
|
|
672
|
+
return [];
|
|
673
|
+
return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
|
|
674
|
+
});
|
|
675
|
+
if (targets.length > 0)
|
|
676
|
+
found.set(gate, [...new Set(targets)]);
|
|
677
|
+
}
|
|
678
|
+
return found;
|
|
679
|
+
}
|
|
680
|
+
// src/review-backend.ts
|
|
681
|
+
import { readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
|
|
682
|
+
|
|
683
|
+
// src/review.ts
|
|
684
|
+
import { z as z2 } from "zod";
|
|
685
|
+
var severitySchema = z2.enum(["BLOCKING", "CONSIDER", "NOTE"]);
|
|
686
|
+
var findingSchema = z2.object({
|
|
687
|
+
file: z2.string().min(1),
|
|
688
|
+
line: z2.number().int().nonnegative(),
|
|
689
|
+
severity: severitySchema,
|
|
690
|
+
body: z2.string().min(1),
|
|
691
|
+
reference: z2.string().optional().default("")
|
|
692
|
+
});
|
|
693
|
+
var findingsSchema = z2.array(findingSchema);
|
|
694
|
+
var reviewThreadRecordSchema = z2.object({
|
|
695
|
+
id: z2.string().min(1),
|
|
696
|
+
file: z2.string().optional(),
|
|
697
|
+
line: z2.number().int().positive().optional(),
|
|
698
|
+
body: z2.string(),
|
|
699
|
+
author: z2.string().optional(),
|
|
700
|
+
resolved: z2.boolean(),
|
|
701
|
+
question: z2.boolean(),
|
|
702
|
+
replies: z2.array(z2.string()).optional()
|
|
703
|
+
});
|
|
704
|
+
function reviewSlug(input) {
|
|
705
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
|
|
706
|
+
}
|
|
707
|
+
function yymmdd(date = new Date) {
|
|
708
|
+
const yy = String(date.getFullYear() % 100).padStart(2, "0");
|
|
709
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
710
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
711
|
+
return `${yy}${mm}${dd}`;
|
|
712
|
+
}
|
|
713
|
+
function reviewRecordName(target, date = new Date) {
|
|
714
|
+
return `${yymmdd(date)}-${reviewSlug(target) || "local"}`;
|
|
715
|
+
}
|
|
716
|
+
function dedupeFindings(findings) {
|
|
717
|
+
const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
|
|
718
|
+
const byKey = new Map;
|
|
719
|
+
for (const finding of findings) {
|
|
720
|
+
const key = `${finding.file}:${finding.line}`;
|
|
721
|
+
const existing = byKey.get(key);
|
|
722
|
+
if (!existing || rank[finding.severity] > rank[existing.severity])
|
|
723
|
+
byKey.set(key, finding);
|
|
724
|
+
}
|
|
725
|
+
return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
|
|
726
|
+
}
|
|
727
|
+
function toReviewComments(findings, model) {
|
|
728
|
+
const comments = [];
|
|
729
|
+
for (const finding of findings) {
|
|
730
|
+
if (finding.line <= 0)
|
|
731
|
+
continue;
|
|
732
|
+
const body = renderCommentBody(finding);
|
|
733
|
+
comments.push({
|
|
734
|
+
file: finding.file,
|
|
735
|
+
line: finding.line,
|
|
736
|
+
side: "RIGHT",
|
|
737
|
+
body: model ? withRemoteProvenance(body, model) : body
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
return comments;
|
|
741
|
+
}
|
|
742
|
+
function withRemoteProvenance(body, model) {
|
|
743
|
+
const normalized = body.trimEnd();
|
|
744
|
+
if (/Generated review by Diffpi using `[^`]+`\.$/.test(normalized))
|
|
745
|
+
return body;
|
|
746
|
+
return `${normalized}
|
|
747
|
+
|
|
748
|
+
Generated review by Diffpi using \`${model}\`.`;
|
|
749
|
+
}
|
|
750
|
+
function localReviewAuthor(model) {
|
|
751
|
+
return `Agent: ${model}`;
|
|
752
|
+
}
|
|
753
|
+
function renderReviewDoc(input) {
|
|
754
|
+
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
755
|
+
const lines = [`# Review: ${input.title}`, "", "## Metadata"];
|
|
756
|
+
if (input.number !== undefined)
|
|
757
|
+
lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
|
|
758
|
+
if (input.author)
|
|
759
|
+
lines.push(`- **Author**: ${input.author}`);
|
|
760
|
+
if (input.model)
|
|
761
|
+
lines.push(`- **Review agent**: ${input.model}`);
|
|
762
|
+
if (input.headRef && input.baseRef)
|
|
763
|
+
lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
|
|
764
|
+
if (input.additions !== undefined)
|
|
765
|
+
lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
|
|
766
|
+
lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
|
|
767
|
+
if (input.overallIssues.length > 0) {
|
|
768
|
+
lines.push("## Overall issues", "");
|
|
769
|
+
for (const issue of input.overallIssues)
|
|
770
|
+
lines.push(`- ${issue}`);
|
|
771
|
+
lines.push("");
|
|
772
|
+
}
|
|
773
|
+
lines.push("## Verification", "");
|
|
774
|
+
for (const gate of input.gates)
|
|
775
|
+
lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
|
|
776
|
+
lines.push("");
|
|
777
|
+
if (input.notVerified.length > 0) {
|
|
778
|
+
lines.push("## What was NOT verified", "");
|
|
779
|
+
for (const item of input.notVerified)
|
|
780
|
+
lines.push(`- ${item}`);
|
|
781
|
+
lines.push("");
|
|
782
|
+
}
|
|
783
|
+
lines.push("## Inline Comments", "");
|
|
784
|
+
for (const finding of anchored) {
|
|
785
|
+
lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
|
|
786
|
+
if (finding.reference)
|
|
787
|
+
lines.push(`> **Reference:** ${finding.reference}`, "");
|
|
788
|
+
lines.push("---", "");
|
|
789
|
+
}
|
|
790
|
+
return `${lines.join(`
|
|
791
|
+
`).trimEnd()}
|
|
792
|
+
`;
|
|
793
|
+
}
|
|
794
|
+
function renderThreadArtifact(title, target, threads, options = {}) {
|
|
795
|
+
const records = threads.map((thread) => ({
|
|
796
|
+
id: thread.id,
|
|
797
|
+
file: thread.file,
|
|
798
|
+
line: thread.line,
|
|
799
|
+
body: thread.body,
|
|
800
|
+
author: thread.author,
|
|
801
|
+
resolved: thread.resolved,
|
|
802
|
+
question: thread.question,
|
|
803
|
+
replies: thread.replies
|
|
804
|
+
}));
|
|
805
|
+
const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
|
|
806
|
+
const lines = [
|
|
807
|
+
`<!-- diffpi-threads:${payload} -->`,
|
|
808
|
+
`# Review threads: ${title}`,
|
|
809
|
+
"",
|
|
810
|
+
"## Metadata",
|
|
811
|
+
"",
|
|
812
|
+
`- Target: ${target}`,
|
|
813
|
+
`- Pulled: ${options.timestamp ?? new Date().toISOString()}`
|
|
814
|
+
];
|
|
815
|
+
if (options.number !== undefined)
|
|
816
|
+
lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
|
|
817
|
+
lines.push("", "## Replies", "");
|
|
818
|
+
for (const thread of threads) {
|
|
819
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
820
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
|
|
821
|
+
}
|
|
822
|
+
lines.push("## Source comments", "");
|
|
823
|
+
for (const thread of threads) {
|
|
824
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, "", thread.body, "", "---", "");
|
|
825
|
+
}
|
|
826
|
+
return `${lines.join(`
|
|
827
|
+
`).trimEnd()}
|
|
828
|
+
`;
|
|
829
|
+
}
|
|
830
|
+
function parseThreadArtifact(content) {
|
|
831
|
+
const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
|
|
832
|
+
if (!payload)
|
|
833
|
+
throw new Error("This file is not a Diffpi thread artifact.");
|
|
834
|
+
let threads;
|
|
835
|
+
try {
|
|
836
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
837
|
+
threads = z2.array(reviewThreadRecordSchema).parse(decoded);
|
|
838
|
+
} catch {
|
|
839
|
+
throw new Error("Cannot parse the Diffpi thread artifact payload.");
|
|
840
|
+
}
|
|
841
|
+
const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
|
|
842
|
+
return threads.map((thread) => {
|
|
843
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
844
|
+
const startMarker = `<!-- diffpi-reply-start:${id} -->
|
|
845
|
+
`;
|
|
846
|
+
const endMarker = `
|
|
847
|
+
<!-- diffpi-reply-end:${id} -->`;
|
|
848
|
+
const start = replies.indexOf(startMarker);
|
|
849
|
+
const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
|
|
850
|
+
const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
|
|
851
|
+
return reply ? { ...thread, reply } : thread;
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
function upsertThreadReply(content, threadId, body, question) {
|
|
855
|
+
const threads = parseThreadArtifact(content);
|
|
856
|
+
const thread = threads.find((candidate) => candidate.id === threadId);
|
|
857
|
+
if (!thread)
|
|
858
|
+
throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
|
|
859
|
+
thread.reply = body;
|
|
860
|
+
if (question !== undefined)
|
|
861
|
+
thread.question = question;
|
|
862
|
+
const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
|
|
863
|
+
const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
|
|
864
|
+
const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
|
|
865
|
+
const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
|
|
866
|
+
return renderThreadArtifact(title, target, threads, {
|
|
867
|
+
timestamp,
|
|
868
|
+
number: pr ? Number.parseInt(pr[1], 10) : undefined,
|
|
869
|
+
url: pr?.[2]
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
function renderCommentBody(finding) {
|
|
873
|
+
const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
|
|
874
|
+
const reference = finding.reference ? `
|
|
875
|
+
|
|
876
|
+
> **Reference:** ${finding.reference}` : "";
|
|
877
|
+
return `${prefix}${finding.body}${reference}`;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// src/tuicr.ts
|
|
881
|
+
import { readFile as readFile3, realpath as realpath2 } from "node:fs/promises";
|
|
882
|
+
import { resolve as resolve2 } from "node:path";
|
|
883
|
+
|
|
884
|
+
// src/store.ts
|
|
885
|
+
import { createHash } from "node:crypto";
|
|
886
|
+
import { lstat, mkdir as mkdir2, readlink, realpath, symlink, unlink } from "node:fs/promises";
|
|
887
|
+
import { homedir as homedir3 } from "node:os";
|
|
888
|
+
import { dirname as dirname2, isAbsolute, join as join4, resolve } from "node:path";
|
|
889
|
+
var STORE_LINK = ".diffpi";
|
|
890
|
+
var LEGACY_STORE_LINK = join4(".pi", "diffpi");
|
|
891
|
+
async function gitToplevel(cwd) {
|
|
892
|
+
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
893
|
+
const top = result.stdout.trim();
|
|
894
|
+
return result.code === 0 && top ? top : resolve(cwd);
|
|
895
|
+
}
|
|
896
|
+
async function computeProjectSlug(cwd) {
|
|
897
|
+
const root = await gitToplevel(cwd);
|
|
898
|
+
const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
899
|
+
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
900
|
+
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
901
|
+
const common = commonResult.stdout.trim();
|
|
902
|
+
let commonPath = root;
|
|
903
|
+
if (commonResult.code === 0 && common) {
|
|
904
|
+
const resolvedCommon = isAbsolute(common) ? common : join4(root, common);
|
|
905
|
+
commonPath = resolve(resolvedCommon);
|
|
906
|
+
}
|
|
907
|
+
const canonicalCommon = await canonicalPath(commonPath);
|
|
908
|
+
const identity = remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${canonicalCommon}`;
|
|
909
|
+
const name = remote ? repositoryName(remote) : basename2(resolve(canonicalCommon, "..")) || basename2(root);
|
|
910
|
+
const readable = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
911
|
+
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 12);
|
|
912
|
+
return `${readable}-${digest}`;
|
|
913
|
+
}
|
|
914
|
+
function storeGlobalRoot(homeDir = homedir3()) {
|
|
915
|
+
return join4(homeDir, ".difflab", "diffpi", "projects");
|
|
916
|
+
}
|
|
917
|
+
async function ensureStore(cwd, homeDir = homedir3()) {
|
|
918
|
+
const root = await gitToplevel(cwd);
|
|
919
|
+
const slug = await computeProjectSlug(root);
|
|
920
|
+
const dest = join4(storeGlobalRoot(homeDir), slug);
|
|
921
|
+
const link = join4(root, STORE_LINK);
|
|
922
|
+
await mkdir2(dest, { recursive: true });
|
|
923
|
+
try {
|
|
924
|
+
await assertStoreLink(link, dest);
|
|
925
|
+
} catch (error) {
|
|
926
|
+
if (error.code !== "ENOENT")
|
|
927
|
+
throw error;
|
|
928
|
+
await symlink(dest, link);
|
|
929
|
+
}
|
|
930
|
+
await removeLegacyStoreLink(join4(root, LEGACY_STORE_LINK), dest);
|
|
931
|
+
return { slug, root, dest, link, linked: true };
|
|
932
|
+
}
|
|
933
|
+
async function storeDir(cwd, homeDir = homedir3()) {
|
|
934
|
+
const store = await ensureStore(cwd, homeDir);
|
|
935
|
+
return store.dest;
|
|
936
|
+
}
|
|
937
|
+
async function reviewsDir(cwd, homeDir = homedir3()) {
|
|
938
|
+
const store = await ensureStore(cwd, homeDir);
|
|
939
|
+
const dir = join4(store.link, "reviews");
|
|
940
|
+
await mkdir2(dir, { recursive: true });
|
|
941
|
+
return dir;
|
|
942
|
+
}
|
|
943
|
+
async function sessionsDir(cwd, homeDir = homedir3()) {
|
|
944
|
+
const store = await ensureStore(cwd, homeDir);
|
|
945
|
+
const dir = join4(store.link, "sessions");
|
|
946
|
+
await mkdir2(dir, { recursive: true });
|
|
947
|
+
return dir;
|
|
948
|
+
}
|
|
949
|
+
async function assertStoreLink(path, dest) {
|
|
950
|
+
const entry = await lstat(path);
|
|
951
|
+
if (!entry.isSymbolicLink())
|
|
952
|
+
throw new Error(`${path} exists and is not a symlink.`);
|
|
953
|
+
const target = await symlinkTarget(path);
|
|
954
|
+
if (target !== await canonicalPath(dest))
|
|
955
|
+
throw new Error(`${path} points to ${target}, not ${dest}.`);
|
|
956
|
+
}
|
|
957
|
+
async function removeLegacyStoreLink(path, dest) {
|
|
958
|
+
try {
|
|
959
|
+
const entry = await lstat(path);
|
|
960
|
+
if (!entry.isSymbolicLink())
|
|
961
|
+
return;
|
|
962
|
+
const target = await symlinkTarget(path);
|
|
963
|
+
if (target === await canonicalPath(dest))
|
|
964
|
+
await unlink(path);
|
|
965
|
+
} catch (error) {
|
|
966
|
+
if (error.code !== "ENOENT")
|
|
967
|
+
throw error;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
async function symlinkTarget(path) {
|
|
971
|
+
const target = await readlink(path);
|
|
972
|
+
return canonicalPath(isAbsolute(target) ? target : resolve(dirname2(path), target));
|
|
973
|
+
}
|
|
974
|
+
async function canonicalPath(path) {
|
|
975
|
+
try {
|
|
976
|
+
return await realpath(path);
|
|
977
|
+
} catch {
|
|
978
|
+
return resolve(path);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function normalizeRemote(remote) {
|
|
982
|
+
return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
983
|
+
}
|
|
984
|
+
function repositoryName(remote) {
|
|
985
|
+
const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
986
|
+
return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
|
|
987
|
+
}
|
|
988
|
+
function basename2(path) {
|
|
989
|
+
const parts = resolve(path).split(/[/\\]/).filter(Boolean);
|
|
990
|
+
return parts.at(-1) ?? "";
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// src/tuicr.ts
|
|
994
|
+
async function tuicrAvailable() {
|
|
995
|
+
const result = await run("tuicr", ["--version"]);
|
|
996
|
+
return result.code === 0;
|
|
997
|
+
}
|
|
998
|
+
async function listSessions(repo = ".") {
|
|
999
|
+
const result = await run("tuicr", ["review", "list", "--repo", repo]);
|
|
1000
|
+
if (result.code !== 0 || !result.stdout.trim())
|
|
1001
|
+
return [];
|
|
1002
|
+
let raw;
|
|
1003
|
+
try {
|
|
1004
|
+
raw = JSON.parse(result.stdout);
|
|
1005
|
+
} catch {
|
|
1006
|
+
return [];
|
|
1007
|
+
}
|
|
1008
|
+
return raw.map((entry) => ({
|
|
1009
|
+
slug: entry.slug,
|
|
1010
|
+
kind: entry.kind,
|
|
1011
|
+
path: entry.path,
|
|
1012
|
+
updatedAt: entry.updated_at,
|
|
1013
|
+
commentCount: entry.comment_count,
|
|
1014
|
+
anchor: entry.anchor,
|
|
1015
|
+
active: entry.active
|
|
1016
|
+
}));
|
|
1017
|
+
}
|
|
1018
|
+
async function resolveSession(cwd, branch) {
|
|
1019
|
+
const sessions = await listSessions(cwd);
|
|
1020
|
+
return findMatchingSession(sessions, cwd, branch);
|
|
1021
|
+
}
|
|
1022
|
+
async function resolveReviewSession(cwd, target) {
|
|
1023
|
+
if (!target.workingTree && target.owner && target.repo && target.number !== undefined) {
|
|
1024
|
+
return resolvePrSession(cwd, target.owner, target.repo, target.number);
|
|
1025
|
+
}
|
|
1026
|
+
return resolveSession(cwd, target.branch);
|
|
1027
|
+
}
|
|
1028
|
+
async function resolvePrSession(cwd, owner, repo, number) {
|
|
1029
|
+
const coordinate = `${owner}/${repo}`.toLowerCase();
|
|
1030
|
+
const sessions = await listSessions(cwd);
|
|
1031
|
+
return sessions.find((session) => {
|
|
1032
|
+
const slug = session.slug.toLowerCase();
|
|
1033
|
+
return session.kind === "pr" && slug.includes(coordinate) && (slug.endsWith(`/pr/${number}`) || slug.endsWith(`/mr/${number}`));
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
async function findMatchingSession(sessions, cwd, branch) {
|
|
1037
|
+
const repository = await canonicalPath2(await gitToplevel(cwd));
|
|
1038
|
+
for (const session of sessions) {
|
|
1039
|
+
if (session.kind !== "local")
|
|
1040
|
+
continue;
|
|
1041
|
+
try {
|
|
1042
|
+
const data = await readSession(session.path);
|
|
1043
|
+
if (data.branch_name !== branch || !data.repo_path)
|
|
1044
|
+
continue;
|
|
1045
|
+
if (await canonicalPath2(data.repo_path) === repository)
|
|
1046
|
+
return session;
|
|
1047
|
+
} catch {}
|
|
1048
|
+
}
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
async function readSession(path) {
|
|
1052
|
+
const content = await readFile3(path, "utf8");
|
|
1053
|
+
try {
|
|
1054
|
+
return JSON.parse(content);
|
|
1055
|
+
} catch {
|
|
1056
|
+
throw new Error(`Cannot parse tuicr session JSON: ${path}`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
async function addComment(session, body, opts = {}) {
|
|
1060
|
+
const args = ["review", "add", "--session", session, body];
|
|
1061
|
+
if (opts.targetFile)
|
|
1062
|
+
args.push("--target-file", opts.targetFile);
|
|
1063
|
+
if (opts.line !== undefined)
|
|
1064
|
+
args.push("--line", String(opts.line));
|
|
1065
|
+
if (opts.side)
|
|
1066
|
+
args.push("--side", opts.side);
|
|
1067
|
+
if (opts.username)
|
|
1068
|
+
args.push("--username", opts.username);
|
|
1069
|
+
await runChecked("tuicr", args);
|
|
1070
|
+
}
|
|
1071
|
+
async function launch(cwd, pr) {
|
|
1072
|
+
const command = pr === undefined ? ["tuicr", "-w"] : ["tuicr", "pr", String(pr)];
|
|
1073
|
+
return openInNewTab(command, { cwd, name: "tuicr" });
|
|
1074
|
+
}
|
|
1075
|
+
function toFindings(session, options = {}) {
|
|
1076
|
+
const comments = [];
|
|
1077
|
+
const include = (comment) => !options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ");
|
|
1078
|
+
const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
|
|
1079
|
+
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
1080
|
+
const fileComments = (entry.file_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
|
|
1081
|
+
if (fileComments.length > 0)
|
|
1082
|
+
bodyParts.push(`File: ${file}
|
|
1083
|
+
|
|
1084
|
+
${fileComments.join(`
|
|
1085
|
+
|
|
1086
|
+
`)}`);
|
|
1087
|
+
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
1088
|
+
const line = Number.parseInt(lineKey, 10);
|
|
1089
|
+
if (!Number.isFinite(line))
|
|
1090
|
+
continue;
|
|
1091
|
+
for (const lineComment of lineComments) {
|
|
1092
|
+
if (!include(lineComment))
|
|
1093
|
+
continue;
|
|
1094
|
+
const comment = {
|
|
1095
|
+
file,
|
|
1096
|
+
line,
|
|
1097
|
+
side: lineComment.side === "old" ? "LEFT" : "RIGHT",
|
|
1098
|
+
body: lineComment.content
|
|
1099
|
+
};
|
|
1100
|
+
const author = commentAuthor(lineComment);
|
|
1101
|
+
if (author)
|
|
1102
|
+
comment.author = author;
|
|
1103
|
+
comments.push(comment);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
return {
|
|
1108
|
+
comments,
|
|
1109
|
+
body: bodyParts.join(`
|
|
1110
|
+
|
|
1111
|
+
`)
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
function commentAuthor(comment) {
|
|
1115
|
+
return comment.username ?? comment.author;
|
|
1116
|
+
}
|
|
1117
|
+
async function canonicalPath2(path) {
|
|
1118
|
+
try {
|
|
1119
|
+
return await realpath2(path);
|
|
1120
|
+
} catch {
|
|
1121
|
+
return resolve2(path);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// src/review-backend.ts
|
|
1126
|
+
function createRemoteReviewBackend(vcs, number) {
|
|
1127
|
+
if (vcs.provider === "github")
|
|
1128
|
+
return new GithubReviewBackend(vcs, number);
|
|
1129
|
+
if (vcs.provider === "gitlab")
|
|
1130
|
+
return new GitlabReviewBackend(vcs, number);
|
|
1131
|
+
throw new Error("A remote review backend requires a GitHub or GitLab repository.");
|
|
1132
|
+
}
|
|
1133
|
+
function createLocalReviewBackend(options) {
|
|
1134
|
+
return new LocalReviewBackend(options);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
class LocalReviewBackend {
|
|
1138
|
+
options;
|
|
1139
|
+
kind = "local";
|
|
1140
|
+
constructor(options) {
|
|
1141
|
+
this.options = options;
|
|
1142
|
+
}
|
|
1143
|
+
async stage(draft) {
|
|
1144
|
+
for (const comment of draft.comments) {
|
|
1145
|
+
await addComment(this.options.session, comment.body, {
|
|
1146
|
+
targetFile: comment.file,
|
|
1147
|
+
line: comment.line,
|
|
1148
|
+
side: comment.side === "LEFT" ? "old" : "new",
|
|
1149
|
+
username: this.options.author
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
if (draft.body.trim()) {
|
|
1153
|
+
await addComment(this.options.session, draft.body, { username: this.options.author });
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
async readDraft() {
|
|
1157
|
+
return toFindings(await readSession(this.options.session), { agentOnly: true });
|
|
1158
|
+
}
|
|
1159
|
+
async listThreads() {
|
|
1160
|
+
try {
|
|
1161
|
+
return parseThreadArtifact(await readFile4(this.options.artifactPath, "utf8"));
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
if (error.code === "ENOENT")
|
|
1164
|
+
return [];
|
|
1165
|
+
throw error;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
async reply(input) {
|
|
1169
|
+
const content = await readFile4(this.options.artifactPath, "utf8");
|
|
1170
|
+
await writeFile2(this.options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question), "utf8");
|
|
1171
|
+
}
|
|
1172
|
+
async publish() {
|
|
1173
|
+
throw new Error("Promote a local draft through a remote review backend before publishing it.");
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
class GithubReviewBackend {
|
|
1178
|
+
vcs;
|
|
1179
|
+
number;
|
|
1180
|
+
kind = "remote";
|
|
1181
|
+
constructor(vcs, number) {
|
|
1182
|
+
this.vcs = vcs;
|
|
1183
|
+
this.number = number;
|
|
1184
|
+
}
|
|
1185
|
+
async stage(draft) {
|
|
1186
|
+
const pending = await this.pendingReview();
|
|
1187
|
+
if (!pending) {
|
|
1188
|
+
const payload = {
|
|
1189
|
+
body: draft.body,
|
|
1190
|
+
comments: draft.comments.map((comment) => ({
|
|
1191
|
+
path: comment.file,
|
|
1192
|
+
line: comment.line,
|
|
1193
|
+
side: comment.side ?? "RIGHT",
|
|
1194
|
+
body: comment.body
|
|
1195
|
+
}))
|
|
1196
|
+
};
|
|
1197
|
+
await runChecked("gh", [
|
|
1198
|
+
"api",
|
|
1199
|
+
"--method",
|
|
1200
|
+
"POST",
|
|
1201
|
+
`/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews`,
|
|
1202
|
+
"--input",
|
|
1203
|
+
"-"
|
|
1204
|
+
], { input: JSON.stringify(payload) });
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
if (draft.body.trim()) {
|
|
1208
|
+
await runChecked("gh", [
|
|
1209
|
+
"api",
|
|
1210
|
+
"--method",
|
|
1211
|
+
"PUT",
|
|
1212
|
+
`/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews/${pending.id}`,
|
|
1213
|
+
"-f",
|
|
1214
|
+
`body=${draft.body}`
|
|
1215
|
+
]);
|
|
1216
|
+
}
|
|
1217
|
+
for (const comment of draft.comments) {
|
|
1218
|
+
await runChecked("gh", [
|
|
1219
|
+
"api",
|
|
1220
|
+
"graphql",
|
|
1221
|
+
"-f",
|
|
1222
|
+
`query=${GITHUB_ADD_THREAD_MUTATION}`,
|
|
1223
|
+
"-f",
|
|
1224
|
+
`reviewId=${pending.nodeId}`,
|
|
1225
|
+
"-f",
|
|
1226
|
+
`body=${comment.body}`,
|
|
1227
|
+
"-f",
|
|
1228
|
+
`path=${comment.file}`,
|
|
1229
|
+
"-F",
|
|
1230
|
+
`line=${comment.line}`,
|
|
1231
|
+
"-f",
|
|
1232
|
+
`side=${comment.side ?? "RIGHT"}`
|
|
1233
|
+
]);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
async readDraft() {
|
|
1237
|
+
const pending = await this.pendingReview();
|
|
1238
|
+
if (!pending)
|
|
1239
|
+
return { comments: [], body: "" };
|
|
1240
|
+
const review = await runChecked("gh", [
|
|
1241
|
+
"api",
|
|
1242
|
+
`/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews/${pending.id}`
|
|
1243
|
+
]);
|
|
1244
|
+
const comments = await runChecked("gh", [
|
|
1245
|
+
"api",
|
|
1246
|
+
`/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews/${pending.id}/comments`
|
|
1247
|
+
]);
|
|
1248
|
+
let reviewData;
|
|
1249
|
+
let commentData;
|
|
1250
|
+
try {
|
|
1251
|
+
reviewData = JSON.parse(review.stdout);
|
|
1252
|
+
commentData = JSON.parse(comments.stdout);
|
|
1253
|
+
} catch {
|
|
1254
|
+
throw new Error("Cannot parse the pending GitHub review as JSON.");
|
|
1255
|
+
}
|
|
1256
|
+
return {
|
|
1257
|
+
body: reviewData.body ?? "",
|
|
1258
|
+
comments: commentData.map((comment) => ({
|
|
1259
|
+
file: comment.path,
|
|
1260
|
+
line: comment.line ?? comment.original_line ?? 1,
|
|
1261
|
+
side: comment.side,
|
|
1262
|
+
body: comment.body
|
|
1263
|
+
}))
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
async listThreads() {
|
|
1267
|
+
const threads = [];
|
|
1268
|
+
let cursor;
|
|
1269
|
+
do {
|
|
1270
|
+
const args = [
|
|
1271
|
+
"api",
|
|
1272
|
+
"graphql",
|
|
1273
|
+
"-f",
|
|
1274
|
+
`query=${GITHUB_THREADS_QUERY}`,
|
|
1275
|
+
"-f",
|
|
1276
|
+
`owner=${this.vcs.owner}`,
|
|
1277
|
+
"-f",
|
|
1278
|
+
`repo=${this.vcs.repo}`,
|
|
1279
|
+
"-F",
|
|
1280
|
+
`number=${this.number}`
|
|
1281
|
+
];
|
|
1282
|
+
if (cursor)
|
|
1283
|
+
args.push("-f", `after=${cursor}`);
|
|
1284
|
+
const result = await runChecked("gh", args);
|
|
1285
|
+
let data;
|
|
1286
|
+
try {
|
|
1287
|
+
data = JSON.parse(result.stdout);
|
|
1288
|
+
} catch {
|
|
1289
|
+
throw new Error("Cannot parse GitHub review threads as JSON.");
|
|
1290
|
+
}
|
|
1291
|
+
const connection = data.data?.repository?.pullRequest?.reviewThreads;
|
|
1292
|
+
threads.push(...connection?.nodes ?? []);
|
|
1293
|
+
cursor = connection?.pageInfo?.hasNextPage ? connection.pageInfo.endCursor : undefined;
|
|
1294
|
+
if (connection?.pageInfo?.hasNextPage && !cursor) {
|
|
1295
|
+
throw new Error("GitHub review thread pagination did not return an end cursor.");
|
|
1296
|
+
}
|
|
1297
|
+
} while (cursor);
|
|
1298
|
+
return threads.map((thread) => {
|
|
1299
|
+
const nodes = thread.comments?.nodes ?? [];
|
|
1300
|
+
const comment = nodes[0];
|
|
1301
|
+
const body = comment?.body ?? "";
|
|
1302
|
+
return {
|
|
1303
|
+
id: thread.id,
|
|
1304
|
+
file: thread.path,
|
|
1305
|
+
line: thread.line,
|
|
1306
|
+
body,
|
|
1307
|
+
author: comment?.author?.login,
|
|
1308
|
+
resolved: thread.isResolved,
|
|
1309
|
+
question: /\?\s*$/.test(body.trim()),
|
|
1310
|
+
replies: nodes.slice(1).map((node) => node.body)
|
|
1311
|
+
};
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
async reply(input) {
|
|
1315
|
+
const threads = await this.listThreads();
|
|
1316
|
+
const existing = threads.find((thread) => thread.id === input.threadId);
|
|
1317
|
+
if (!existing?.replies?.includes(input.body)) {
|
|
1318
|
+
await runChecked("gh", [
|
|
1319
|
+
"api",
|
|
1320
|
+
"graphql",
|
|
1321
|
+
"-f",
|
|
1322
|
+
`query=${GITHUB_REPLY_MUTATION}`,
|
|
1323
|
+
"-f",
|
|
1324
|
+
`threadId=${input.threadId}`,
|
|
1325
|
+
"-f",
|
|
1326
|
+
`body=${input.body}`
|
|
1327
|
+
]);
|
|
1328
|
+
}
|
|
1329
|
+
if (input.resolve) {
|
|
1330
|
+
await runChecked("gh", [
|
|
1331
|
+
"api",
|
|
1332
|
+
"graphql",
|
|
1333
|
+
"-f",
|
|
1334
|
+
`query=${GITHUB_RESOLVE_MUTATION}`,
|
|
1335
|
+
"-f",
|
|
1336
|
+
`threadId=${input.threadId}`
|
|
1337
|
+
]);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
async publish(event) {
|
|
1341
|
+
const pending = await this.pendingReview();
|
|
1342
|
+
if (!pending && event === "COMMENT")
|
|
1343
|
+
return;
|
|
1344
|
+
if (!pending && event === "REQUEST_CHANGES") {
|
|
1345
|
+
throw new Error("GitHub requires pending comments before publishing a request-changes review without a body.");
|
|
1346
|
+
}
|
|
1347
|
+
const endpoint = githubReviewSubmissionEndpoint(this.vcs.owner, this.vcs.repo, this.number, pending?.id ?? "");
|
|
1348
|
+
await runChecked("gh", ["api", "--method", "POST", endpoint, "-f", `event=${event}`]);
|
|
1349
|
+
}
|
|
1350
|
+
async pendingReview() {
|
|
1351
|
+
const result = await runChecked("gh", [
|
|
1352
|
+
"api",
|
|
1353
|
+
`/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews`,
|
|
1354
|
+
"--jq",
|
|
1355
|
+
'[.[] | select(.state=="PENDING")] | last | {id: (.id | tostring), nodeId: .node_id}'
|
|
1356
|
+
]);
|
|
1357
|
+
if (!result.stdout.trim())
|
|
1358
|
+
return;
|
|
1359
|
+
let pending;
|
|
1360
|
+
try {
|
|
1361
|
+
pending = JSON.parse(result.stdout);
|
|
1362
|
+
} catch {
|
|
1363
|
+
throw new Error("Cannot parse the pending GitHub review identifier as JSON.");
|
|
1364
|
+
}
|
|
1365
|
+
if (!pending.id || !pending.nodeId)
|
|
1366
|
+
return;
|
|
1367
|
+
return { id: pending.id, nodeId: pending.nodeId };
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
class GitlabReviewBackend {
|
|
1372
|
+
vcs;
|
|
1373
|
+
number;
|
|
1374
|
+
kind = "remote";
|
|
1375
|
+
constructor(vcs, number) {
|
|
1376
|
+
this.vcs = vcs;
|
|
1377
|
+
this.number = number;
|
|
1378
|
+
}
|
|
1379
|
+
async stage(draft) {
|
|
1380
|
+
const endpoint = `${this.mergeRequestEndpoint()}/draft_notes`;
|
|
1381
|
+
if (draft.body.trim()) {
|
|
1382
|
+
await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
1383
|
+
input: JSON.stringify({ note: draft.body })
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
if (draft.comments.length === 0)
|
|
1387
|
+
return;
|
|
1388
|
+
const response = await runChecked("glab", ["api", this.mergeRequestEndpoint()]);
|
|
1389
|
+
const diffRefs = parseGitlabDiffRefs(response.stdout);
|
|
1390
|
+
for (const comment of draft.comments) {
|
|
1391
|
+
const payload = {
|
|
1392
|
+
note: comment.body,
|
|
1393
|
+
position: {
|
|
1394
|
+
...diffRefs,
|
|
1395
|
+
position_type: "text",
|
|
1396
|
+
new_path: comment.file,
|
|
1397
|
+
old_path: comment.file,
|
|
1398
|
+
new_line: comment.side === "LEFT" ? undefined : comment.line,
|
|
1399
|
+
old_line: comment.side === "LEFT" ? comment.line : undefined
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
1403
|
+
input: JSON.stringify(payload)
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
async readDraft() {
|
|
1408
|
+
const result = await runChecked("glab", ["api", `${this.mergeRequestEndpoint()}/draft_notes`]);
|
|
1409
|
+
let notes;
|
|
1410
|
+
try {
|
|
1411
|
+
notes = JSON.parse(result.stdout);
|
|
1412
|
+
} catch {
|
|
1413
|
+
throw new Error("Cannot parse GitLab draft notes as JSON.");
|
|
1414
|
+
}
|
|
1415
|
+
const comments = [];
|
|
1416
|
+
const body = [];
|
|
1417
|
+
for (const note of notes) {
|
|
1418
|
+
if (!note.note)
|
|
1419
|
+
continue;
|
|
1420
|
+
const file = note.position?.new_path ?? note.position?.old_path;
|
|
1421
|
+
const line = note.position?.new_line ?? note.position?.old_line;
|
|
1422
|
+
if (file && line) {
|
|
1423
|
+
comments.push({
|
|
1424
|
+
file,
|
|
1425
|
+
line,
|
|
1426
|
+
side: note.position?.new_line ? "RIGHT" : "LEFT",
|
|
1427
|
+
body: note.note
|
|
1428
|
+
});
|
|
1429
|
+
} else {
|
|
1430
|
+
body.push(note.note);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
return { comments, body: body.join(`
|
|
1434
|
+
|
|
1435
|
+
`) };
|
|
1436
|
+
}
|
|
1437
|
+
async listThreads() {
|
|
1438
|
+
const discussions = [];
|
|
1439
|
+
for (let page = 1;; page += 1) {
|
|
1440
|
+
const result = await runChecked("glab", [
|
|
1441
|
+
"api",
|
|
1442
|
+
`${this.mergeRequestEndpoint()}/discussions?per_page=100&page=${page}`
|
|
1443
|
+
]);
|
|
1444
|
+
let batch;
|
|
1445
|
+
try {
|
|
1446
|
+
batch = JSON.parse(result.stdout);
|
|
1447
|
+
} catch {
|
|
1448
|
+
throw new Error("Cannot parse GitLab review discussions as JSON.");
|
|
1449
|
+
}
|
|
1450
|
+
discussions.push(...batch);
|
|
1451
|
+
if (batch.length < 100)
|
|
1452
|
+
break;
|
|
1453
|
+
}
|
|
1454
|
+
return discussions.map((discussion) => {
|
|
1455
|
+
const notes = discussion.notes ?? [];
|
|
1456
|
+
const note = notes[0];
|
|
1457
|
+
const body = note?.body ?? "";
|
|
1458
|
+
return {
|
|
1459
|
+
id: discussion.id,
|
|
1460
|
+
file: note?.position?.new_path ?? note?.position?.old_path,
|
|
1461
|
+
line: note?.position?.new_line ?? note?.position?.old_line,
|
|
1462
|
+
body,
|
|
1463
|
+
author: note?.author?.username,
|
|
1464
|
+
resolved: Boolean(discussion.resolved),
|
|
1465
|
+
question: /\?\s*$/.test(body.trim()),
|
|
1466
|
+
replies: notes.slice(1).map((reply) => reply.body)
|
|
1467
|
+
};
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
async reply(input) {
|
|
1471
|
+
const endpoint = `${this.mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
|
|
1472
|
+
const threads = await this.listThreads();
|
|
1473
|
+
const existing = threads.find((thread) => thread.id === input.threadId);
|
|
1474
|
+
if (!existing?.replies?.includes(input.body)) {
|
|
1475
|
+
await runChecked("glab", ["api", "--method", "POST", `${endpoint}/notes`, "--input", "-"], {
|
|
1476
|
+
input: JSON.stringify({ body: input.body })
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
if (input.resolve)
|
|
1480
|
+
await runChecked("glab", ["api", "--method", "PUT", `${endpoint}?resolved=true`]);
|
|
1481
|
+
}
|
|
1482
|
+
async publish(event) {
|
|
1483
|
+
assertReviewEventSupported(this.vcs.provider, event);
|
|
1484
|
+
const drafts = await runChecked("glab", ["api", `${this.mergeRequestEndpoint()}/draft_notes`]);
|
|
1485
|
+
if (hasGitlabDraftNotes(drafts.stdout)) {
|
|
1486
|
+
await runChecked("glab", ["api", "--method", "POST", `${this.mergeRequestEndpoint()}/draft_notes/bulk_publish`]);
|
|
1487
|
+
}
|
|
1488
|
+
if (event === "APPROVE") {
|
|
1489
|
+
await runChecked("glab", ["mr", "approve", String(this.number), "--repo", this.project()]);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
project() {
|
|
1493
|
+
return `${this.vcs.owner}/${this.vcs.repo}`;
|
|
1494
|
+
}
|
|
1495
|
+
mergeRequestEndpoint() {
|
|
1496
|
+
return `projects/${encodeURIComponent(this.project())}/merge_requests/${this.number}`;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
function assertReviewEventSupported(provider, event) {
|
|
1500
|
+
if (provider === "gitlab" && event === "REQUEST_CHANGES") {
|
|
1501
|
+
throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
|
|
1505
|
+
return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
|
|
1506
|
+
}
|
|
1507
|
+
function parseGitlabDiffRefs(input) {
|
|
1508
|
+
let data;
|
|
1509
|
+
try {
|
|
1510
|
+
data = JSON.parse(input);
|
|
1511
|
+
} catch {
|
|
1512
|
+
throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
|
|
1513
|
+
}
|
|
1514
|
+
const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
|
|
1515
|
+
if (!base_sha || !start_sha || !head_sha) {
|
|
1516
|
+
throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
|
|
1517
|
+
}
|
|
1518
|
+
return { base_sha, start_sha, head_sha };
|
|
1519
|
+
}
|
|
1520
|
+
function hasGitlabDraftNotes(input) {
|
|
1521
|
+
try {
|
|
1522
|
+
const data = JSON.parse(input);
|
|
1523
|
+
return Array.isArray(data) && data.length > 0;
|
|
1524
|
+
} catch {
|
|
1525
|
+
throw new Error("Cannot publish the GitLab review: the draft notes response was not valid JSON.");
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
var GITHUB_THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){nodes{id,isResolved,path,line,comments(first:100){nodes{body,author{login}}}}pageInfo{hasNextPage,endCursor}}}}}`;
|
|
1529
|
+
var GITHUB_ADD_THREAD_MUTATION = `mutation($reviewId:ID!,$body:String!,$path:String!,$line:Int!,$side:DiffSide!){addPullRequestReviewThread(input:{pullRequestReviewId:$reviewId,body:$body,path:$path,line:$line,side:$side}){thread{id}}}`;
|
|
1530
|
+
var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
|
|
1531
|
+
var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
|
|
1
1532
|
// src/mcp.ts
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
1533
|
+
import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
|
|
1534
|
+
import { homedir as homedir4 } from "node:os";
|
|
1535
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
5
1536
|
var mcp = {
|
|
6
|
-
globalConfigPath(homeDir =
|
|
7
|
-
return
|
|
1537
|
+
globalConfigPath(homeDir = homedir4()) {
|
|
1538
|
+
return join5(homeDir, ".config", "mcp", "mcp.json");
|
|
8
1539
|
},
|
|
9
1540
|
async serversEnsure(servers, options = {}) {
|
|
10
1541
|
const path = options.path ?? mcp.globalConfigPath();
|
|
@@ -17,8 +1548,8 @@ var mcp = {
|
|
|
17
1548
|
const next = { ...current, mcpServers: nextServers };
|
|
18
1549
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
19
1550
|
if (changed && !options.dryRun) {
|
|
20
|
-
await
|
|
21
|
-
await
|
|
1551
|
+
await mkdir3(dirname3(path), { recursive: true });
|
|
1552
|
+
await writeFile3(path, `${JSON.stringify(next, null, 2)}
|
|
22
1553
|
`, "utf8");
|
|
23
1554
|
}
|
|
24
1555
|
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
@@ -47,7 +1578,7 @@ function getParsedConfig(content, path) {
|
|
|
47
1578
|
}
|
|
48
1579
|
async function getOptionalFile(path) {
|
|
49
1580
|
try {
|
|
50
|
-
return await
|
|
1581
|
+
return await readFile5(path, "utf8");
|
|
51
1582
|
} catch (error) {
|
|
52
1583
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
53
1584
|
return;
|
|
@@ -58,68 +1589,9 @@ function isRecord(value) {
|
|
|
58
1589
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
59
1590
|
}
|
|
60
1591
|
// src/mise.ts
|
|
61
|
-
import { mkdir as
|
|
62
|
-
import { homedir as
|
|
63
|
-
import { basename, dirname as
|
|
64
|
-
|
|
65
|
-
// src/process.ts
|
|
66
|
-
import { constants } from "node:fs";
|
|
67
|
-
import { access } from "node:fs/promises";
|
|
68
|
-
import { delimiter, join as join2 } from "node:path";
|
|
69
|
-
import { spawn } from "node:child_process";
|
|
70
|
-
var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
|
|
71
|
-
async function findExecutable(name) {
|
|
72
|
-
if (name.includes("/")) {
|
|
73
|
-
try {
|
|
74
|
-
await access(name, constants.X_OK);
|
|
75
|
-
return name;
|
|
76
|
-
} catch {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
81
|
-
if (!directory)
|
|
82
|
-
continue;
|
|
83
|
-
const candidate = join2(directory, name);
|
|
84
|
-
try {
|
|
85
|
-
await access(candidate, constants.X_OK);
|
|
86
|
-
return candidate;
|
|
87
|
-
} catch {}
|
|
88
|
-
}
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
function run(command, args, options = {}) {
|
|
92
|
-
return new Promise((resolve, reject) => {
|
|
93
|
-
const child = spawn(command, args, {
|
|
94
|
-
cwd: options.cwd,
|
|
95
|
-
env: options.env ?? process.env,
|
|
96
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
97
|
-
});
|
|
98
|
-
let stdout = "";
|
|
99
|
-
let stderr = "";
|
|
100
|
-
child.stdout.on("data", (chunk) => {
|
|
101
|
-
stdout = appendBounded(stdout, chunk.toString());
|
|
102
|
-
});
|
|
103
|
-
child.stderr.on("data", (chunk) => {
|
|
104
|
-
stderr = appendBounded(stderr, chunk.toString());
|
|
105
|
-
});
|
|
106
|
-
child.on("error", reject);
|
|
107
|
-
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
async function runChecked(command, args, options = {}) {
|
|
111
|
-
const result = await run(command, args, options);
|
|
112
|
-
if (result.code === 0)
|
|
113
|
-
return result;
|
|
114
|
-
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
115
|
-
throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
116
|
-
}
|
|
117
|
-
function appendBounded(current, next) {
|
|
118
|
-
const combined = current + next;
|
|
119
|
-
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// src/mise.ts
|
|
1592
|
+
import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
|
|
1593
|
+
import { homedir as homedir5 } from "node:os";
|
|
1594
|
+
import { basename as basename3, dirname as dirname4, join as join6 } from "node:path";
|
|
123
1595
|
var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
|
|
124
1596
|
var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
|
|
125
1597
|
var mise = {
|
|
@@ -127,11 +1599,11 @@ var mise = {
|
|
|
127
1599
|
return findExecutable(name);
|
|
128
1600
|
},
|
|
129
1601
|
async install(options = {}) {
|
|
130
|
-
const homeDir = options.homeDir ??
|
|
1602
|
+
const homeDir = options.homeDir ?? homedir5();
|
|
131
1603
|
const platform = options.platform ?? process.platform;
|
|
132
1604
|
if (platform === "win32")
|
|
133
1605
|
throw new Error("Automatic mise installation supports macOS and Linux only.");
|
|
134
|
-
const installedPath =
|
|
1606
|
+
const installedPath = join6(homeDir, ".local", "bin", "mise");
|
|
135
1607
|
if (options.dryRun)
|
|
136
1608
|
return installedPath;
|
|
137
1609
|
await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
|
|
@@ -141,8 +1613,8 @@ var mise = {
|
|
|
141
1613
|
return executable;
|
|
142
1614
|
},
|
|
143
1615
|
async hookEnsure(executable, options = {}) {
|
|
144
|
-
const homeDir = options.homeDir ??
|
|
145
|
-
const hook = getShellHook(
|
|
1616
|
+
const homeDir = options.homeDir ?? homedir5();
|
|
1617
|
+
const hook = getShellHook(basename3(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
|
|
146
1618
|
const current = await getOptionalFile2(hook.path);
|
|
147
1619
|
if (current.includes(MISE_HOOK_START))
|
|
148
1620
|
return { path: hook.path, changed: false, planned: false };
|
|
@@ -151,8 +1623,8 @@ var mise = {
|
|
|
151
1623
|
const separator = current.length === 0 || current.endsWith(`
|
|
152
1624
|
`) ? "" : `
|
|
153
1625
|
`;
|
|
154
|
-
await
|
|
155
|
-
await
|
|
1626
|
+
await mkdir4(dirname4(hook.path), { recursive: true });
|
|
1627
|
+
await writeFile4(hook.path, `${current}${separator}${hook.content}`, "utf8");
|
|
156
1628
|
return { path: hook.path, changed: true, planned: false };
|
|
157
1629
|
},
|
|
158
1630
|
async toolCheckGlobal(executable, tool, minimumVersion) {
|
|
@@ -169,7 +1641,7 @@ var mise = {
|
|
|
169
1641
|
async toolInstallLocal(executable, specification, cwd = process.cwd()) {
|
|
170
1642
|
await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
|
|
171
1643
|
},
|
|
172
|
-
async toolUpdateAllGlobal(executable, homeDir =
|
|
1644
|
+
async toolUpdateAllGlobal(executable, homeDir = homedir5()) {
|
|
173
1645
|
await runChecked(executable, ["upgrade"], { cwd: homeDir });
|
|
174
1646
|
}
|
|
175
1647
|
};
|
|
@@ -178,7 +1650,7 @@ function getShellHook(shell, executable, homeDir) {
|
|
|
178
1650
|
switch (shell.toLowerCase()) {
|
|
179
1651
|
case "zsh":
|
|
180
1652
|
return {
|
|
181
|
-
path:
|
|
1653
|
+
path: join6(homeDir, ".zshrc"),
|
|
182
1654
|
content: `${MISE_HOOK_START}
|
|
183
1655
|
eval "$(${command} activate zsh)"
|
|
184
1656
|
${MISE_HOOK_END}
|
|
@@ -186,7 +1658,7 @@ ${MISE_HOOK_END}
|
|
|
186
1658
|
};
|
|
187
1659
|
case "fish":
|
|
188
1660
|
return {
|
|
189
|
-
path:
|
|
1661
|
+
path: join6(homeDir, ".config", "fish", "config.fish"),
|
|
190
1662
|
content: `${MISE_HOOK_START}
|
|
191
1663
|
${command} activate fish | source
|
|
192
1664
|
${MISE_HOOK_END}
|
|
@@ -195,7 +1667,7 @@ ${MISE_HOOK_END}
|
|
|
195
1667
|
case "nu":
|
|
196
1668
|
case "nushell":
|
|
197
1669
|
return {
|
|
198
|
-
path:
|
|
1670
|
+
path: join6(homeDir, ".config", "nushell", "config.nu"),
|
|
199
1671
|
content: `${MISE_HOOK_START}
|
|
200
1672
|
let mise_bin = ${command}
|
|
201
1673
|
let mise_path = $nu.default-config-dir | path join mise.nu
|
|
@@ -206,7 +1678,7 @@ ${MISE_HOOK_END}
|
|
|
206
1678
|
};
|
|
207
1679
|
case "xonsh":
|
|
208
1680
|
return {
|
|
209
|
-
path:
|
|
1681
|
+
path: join6(homeDir, ".xonshrc"),
|
|
210
1682
|
content: `${MISE_HOOK_START}
|
|
211
1683
|
execx($(${command} activate xonsh))
|
|
212
1684
|
${MISE_HOOK_END}
|
|
@@ -214,7 +1686,7 @@ ${MISE_HOOK_END}
|
|
|
214
1686
|
};
|
|
215
1687
|
case "elvish":
|
|
216
1688
|
return {
|
|
217
|
-
path:
|
|
1689
|
+
path: join6(homeDir, ".config", "elvish", "rc.elv"),
|
|
218
1690
|
content: `${MISE_HOOK_START}
|
|
219
1691
|
var mise: = (ns [&])
|
|
220
1692
|
eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
|
|
@@ -225,7 +1697,7 @@ ${MISE_HOOK_END}
|
|
|
225
1697
|
case "pwsh":
|
|
226
1698
|
case "powershell":
|
|
227
1699
|
return {
|
|
228
|
-
path:
|
|
1700
|
+
path: join6(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
|
|
229
1701
|
content: `${MISE_HOOK_START}
|
|
230
1702
|
(& ${command} activate pwsh) | Out-String | Invoke-Expression
|
|
231
1703
|
${MISE_HOOK_END}
|
|
@@ -234,7 +1706,7 @@ ${MISE_HOOK_END}
|
|
|
234
1706
|
case "bash":
|
|
235
1707
|
default:
|
|
236
1708
|
return {
|
|
237
|
-
path:
|
|
1709
|
+
path: join6(homeDir, ".bashrc"),
|
|
238
1710
|
content: `${MISE_HOOK_START}
|
|
239
1711
|
eval "$(${command} activate bash)"
|
|
240
1712
|
${MISE_HOOK_END}
|
|
@@ -273,7 +1745,7 @@ function isVersionAtLeast(version, minimumVersion) {
|
|
|
273
1745
|
}
|
|
274
1746
|
async function getOptionalFile2(path) {
|
|
275
1747
|
try {
|
|
276
|
-
return await
|
|
1748
|
+
return await readFile6(path, "utf8");
|
|
277
1749
|
} catch (error) {
|
|
278
1750
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
279
1751
|
return "";
|
|
@@ -283,80 +1755,356 @@ async function getOptionalFile2(path) {
|
|
|
283
1755
|
function getShellQuoted(value) {
|
|
284
1756
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
285
1757
|
}
|
|
286
|
-
// src/
|
|
287
|
-
import {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
1758
|
+
// src/modes.ts
|
|
1759
|
+
import {
|
|
1760
|
+
getAgentDir,
|
|
1761
|
+
parseFrontmatter as parseFrontmatter2
|
|
1762
|
+
} from "@earendil-works/pi-coding-agent";
|
|
1763
|
+
import { readFile as readFile7 } from "node:fs/promises";
|
|
1764
|
+
import { homedir as homedir6 } from "node:os";
|
|
1765
|
+
import { basename as basename4, extname, join as join8 } from "node:path";
|
|
1766
|
+
|
|
1767
|
+
// src/assets.ts
|
|
1768
|
+
import { existsSync } from "node:fs";
|
|
1769
|
+
import { dirname as dirname5, join as join7 } from "node:path";
|
|
1770
|
+
import { fileURLToPath } from "node:url";
|
|
1771
|
+
function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
|
|
1772
|
+
const moduleDir = dirname5(fileURLToPath(moduleUrl));
|
|
1773
|
+
const candidates = [join7(moduleDir, name), join7(moduleDir, "..", name), join7(moduleDir, "..", "..", name)];
|
|
1774
|
+
return candidates.find((path) => existsSync(path)) ?? candidates[1];
|
|
1775
|
+
}
|
|
1776
|
+
function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
|
|
1777
|
+
return resolveBundledAssetDir("agents", moduleUrl);
|
|
1778
|
+
}
|
|
1779
|
+
function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
|
|
1780
|
+
return resolveBundledAssetDir("templates", moduleUrl);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// src/modes.ts
|
|
1784
|
+
async function discoverAgentModes(options) {
|
|
1785
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
1786
|
+
const homeDir = options.homeDir ?? homedir6();
|
|
1787
|
+
const modes = new Map;
|
|
1788
|
+
const diagnostics = [];
|
|
1789
|
+
await loadAgentModes(options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR, "diffpi agent", modes, diagnostics);
|
|
1790
|
+
await loadAgentModes(join8(agentDir, "agents"), "user agent", modes, diagnostics);
|
|
1791
|
+
if (options.includeSkills) {
|
|
1792
|
+
await loadSkillModes(join8(homeDir, ".agents", "skills"), "user skill", modes, diagnostics);
|
|
1793
|
+
await loadSkillModes(join8(agentDir, "skills"), "pi user skill", modes, diagnostics);
|
|
1794
|
+
}
|
|
1795
|
+
if (options.projectTrusted === true) {
|
|
1796
|
+
if (options.includeSkills) {
|
|
1797
|
+
await loadSkillModes(join8(options.cwd, ".agents", "skills"), "project skill", modes, diagnostics);
|
|
1798
|
+
await loadSkillModes(join8(options.cwd, ".pi", "skills"), "pi project skill", modes, diagnostics);
|
|
313
1799
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
1800
|
+
await loadAgentModes(join8(options.cwd, ".agents", "agents"), "project agent", modes, diagnostics);
|
|
1801
|
+
await loadAgentModes(join8(options.cwd, ".pi", "agents"), "pi project agent", modes, diagnostics);
|
|
1802
|
+
}
|
|
1803
|
+
const userConfig = await loadDiffpiConfig({ homeDir });
|
|
1804
|
+
const configuredModes = [...modes.values()].map((mode) => ({
|
|
1805
|
+
...mode,
|
|
1806
|
+
modelPreferences: resolveAgentModelPreferences(mode.id, mode.modelPreferences, userConfig.config)
|
|
1807
|
+
}));
|
|
1808
|
+
return {
|
|
1809
|
+
modes: configuredModes.sort((left, right) => left.id.localeCompare(right.id)),
|
|
1810
|
+
diagnostics
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
function resolveAgentMode(modes, requested) {
|
|
1814
|
+
const name = requested.trim();
|
|
1815
|
+
if (!name)
|
|
1816
|
+
return { ok: false, message: "Agent name is required." };
|
|
1817
|
+
const exact = modes.find((mode) => mode.id === name);
|
|
1818
|
+
if (exact)
|
|
1819
|
+
return { ok: true, active: exact, message: `Active inline agent: ${exact.id}.` };
|
|
1820
|
+
const lowerName = name.toLowerCase();
|
|
1821
|
+
const matches = modes.filter((mode) => mode.id.toLowerCase() === lowerName);
|
|
1822
|
+
if (matches.length === 1) {
|
|
1823
|
+
const active = matches[0];
|
|
1824
|
+
return { ok: true, active, message: `Active inline agent: ${active.id}.` };
|
|
1825
|
+
}
|
|
1826
|
+
if (matches.length > 1) {
|
|
1827
|
+
return {
|
|
1828
|
+
ok: false,
|
|
1829
|
+
message: `Inline agent "${name}" is ambiguous. Use one of: ${matches.map((mode) => mode.id).join(", ")}.`
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
return { ok: false, message: `Unknown inline agent "${name}". Run /skill:mode or diffpi_modes_list.` };
|
|
1833
|
+
}
|
|
1834
|
+
function createModeController(pi, options = {}) {
|
|
1835
|
+
let active;
|
|
1836
|
+
let baseline;
|
|
1837
|
+
const updateStatus = (ctx) => {
|
|
1838
|
+
ctx.ui.setStatus(MODE_STATUS_KEY, active ? `mode: ${active.id}` : undefined);
|
|
1839
|
+
};
|
|
1840
|
+
const list = (ctx, listOptions = {}) => discoverAgentModes({
|
|
1841
|
+
cwd: ctx.cwd,
|
|
1842
|
+
agentDir: options.agentDir,
|
|
1843
|
+
bundledAgentsDir: options.bundledAgentsDir,
|
|
1844
|
+
homeDir: options.homeDir,
|
|
1845
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
1846
|
+
includeSkills: listOptions.includeSkills
|
|
1847
|
+
});
|
|
1848
|
+
return {
|
|
1849
|
+
list,
|
|
1850
|
+
async set(agent, ctx) {
|
|
1851
|
+
const catalog = await list(ctx, { includeSkills: agent.includes(":") });
|
|
1852
|
+
const result = resolveAgentMode(catalog.modes, agent);
|
|
1853
|
+
if (!result.ok || !result.active)
|
|
1854
|
+
return result;
|
|
1855
|
+
baseline ??= captureRuntime(pi, ctx);
|
|
1856
|
+
if (active && baseline)
|
|
1857
|
+
await restoreRuntime(pi, baseline, ctx);
|
|
1858
|
+
active = result.active;
|
|
1859
|
+
const runtimeMessage = await applyModeRuntime(pi, active, ctx);
|
|
1860
|
+
pi.appendEntry(MODE_STATE_ENTRY, { active, baseline });
|
|
1861
|
+
updateStatus(ctx);
|
|
1862
|
+
return { ...result, message: `${result.message} ${runtimeMessage}` };
|
|
1863
|
+
},
|
|
1864
|
+
async unset(ctx) {
|
|
1865
|
+
if (!active)
|
|
1866
|
+
return { ok: true, message: "Inline agent is already clear." };
|
|
1867
|
+
if (baseline)
|
|
1868
|
+
await restoreRuntime(pi, baseline, ctx);
|
|
1869
|
+
active = undefined;
|
|
1870
|
+
pi.appendEntry(MODE_STATE_ENTRY, { active: null });
|
|
1871
|
+
baseline = undefined;
|
|
1872
|
+
updateStatus(ctx);
|
|
1873
|
+
return { ok: true, message: "Inline agent cleared. The previous model, thinking, tools, and prompt resume." };
|
|
1874
|
+
},
|
|
1875
|
+
async restore(ctx) {
|
|
1876
|
+
const previousActive = active;
|
|
1877
|
+
const previousBaseline = baseline;
|
|
1878
|
+
const entry = [...ctx.sessionManager.getBranch()].reverse().find((candidate) => candidate.type === "custom" && candidate.customType === MODE_STATE_ENTRY);
|
|
1879
|
+
const restored = entry?.data?.active;
|
|
1880
|
+
const restoredBaseline = entry?.data?.baseline;
|
|
1881
|
+
if (isAgentModeSnapshot(restored)) {
|
|
1882
|
+
active = restored;
|
|
1883
|
+
baseline = isModeBaseline(restoredBaseline) ? restoredBaseline : previousBaseline;
|
|
1884
|
+
await applyModeRuntime(pi, active, ctx);
|
|
1885
|
+
} else {
|
|
1886
|
+
if (previousActive && previousBaseline)
|
|
1887
|
+
pi.setActiveTools(previousBaseline.tools);
|
|
1888
|
+
active = undefined;
|
|
1889
|
+
baseline = undefined;
|
|
1890
|
+
}
|
|
1891
|
+
updateStatus(ctx);
|
|
1892
|
+
},
|
|
1893
|
+
apply(systemPrompt) {
|
|
1894
|
+
if (!active)
|
|
1895
|
+
return systemPrompt;
|
|
1896
|
+
if (active.promptStrategy === "replace")
|
|
1897
|
+
return active.systemPrompt;
|
|
1898
|
+
return `${systemPrompt}
|
|
1899
|
+
|
|
1900
|
+
## Active inline agent: ${active.label}
|
|
1901
|
+
|
|
1902
|
+
${active.systemPrompt}`;
|
|
1903
|
+
},
|
|
1904
|
+
getActive() {
|
|
1905
|
+
return active;
|
|
1906
|
+
}
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
var MODE_STATE_ENTRY = "diffpi-mode-state";
|
|
1910
|
+
var MODE_STATUS_KEY = "diffpi-mode";
|
|
1911
|
+
var MODE_CONTROL_TOOLS = ["ask_user_question", "diffpi_modes_list", "diffpi_modes_set", "diffpi_modes_unset"];
|
|
1912
|
+
var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
|
|
1913
|
+
var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
1914
|
+
async function applyModeRuntime(pi, mode, ctx) {
|
|
1915
|
+
let selectedModel;
|
|
1916
|
+
if (mode.modelPreferences.length > 0) {
|
|
1917
|
+
const scoped = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : undefined;
|
|
1918
|
+
const availableModels = scoped ?? ctx.modelRegistry.getAvailable();
|
|
1919
|
+
for (const preference of mode.modelPreferences) {
|
|
1920
|
+
const model = findPreferredModel(availableModels, preference);
|
|
1921
|
+
if (model && await pi.setModel(model)) {
|
|
1922
|
+
selectedModel = `${model.provider}/${model.id}`;
|
|
1923
|
+
break;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
if (mode.thinkingLevel)
|
|
1928
|
+
pi.setThinkingLevel(mode.thinkingLevel);
|
|
1929
|
+
if (mode.tools.length > 0) {
|
|
1930
|
+
const availableTools = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
1931
|
+
const selectedTools = [...new Set([...mode.tools, ...MODE_CONTROL_TOOLS])].filter((tool) => availableTools.has(tool));
|
|
1932
|
+
if (selectedTools.length > 0)
|
|
1933
|
+
pi.setActiveTools(selectedTools);
|
|
1934
|
+
}
|
|
1935
|
+
const parts = [];
|
|
1936
|
+
if (mode.modelPreferences.length > 0) {
|
|
1937
|
+
parts.push(selectedModel ? `Model: ${selectedModel}.` : "No preferred model was available; kept the current model.");
|
|
1938
|
+
}
|
|
1939
|
+
if (mode.thinkingLevel)
|
|
1940
|
+
parts.push(`Thinking: ${mode.thinkingLevel}.`);
|
|
1941
|
+
if (mode.tools.length > 0)
|
|
1942
|
+
parts.push("Applied the profile tool set.");
|
|
1943
|
+
return parts.join(" ") || "The profile changes the prompt only.";
|
|
1944
|
+
}
|
|
1945
|
+
async function restoreRuntime(pi, state, ctx) {
|
|
1946
|
+
if (state.model) {
|
|
1947
|
+
const model = ctx.modelRegistry.find(state.model.provider, state.model.id);
|
|
1948
|
+
if (model)
|
|
1949
|
+
await pi.setModel(model);
|
|
1950
|
+
}
|
|
1951
|
+
pi.setThinkingLevel(state.thinkingLevel);
|
|
1952
|
+
pi.setActiveTools(state.tools);
|
|
1953
|
+
}
|
|
1954
|
+
function captureRuntime(pi, ctx) {
|
|
1955
|
+
return {
|
|
1956
|
+
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
|
|
1957
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
1958
|
+
tools: pi.getActiveTools()
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
async function loadSkillModes(skillsDir, source, modes, diagnostics) {
|
|
1962
|
+
const entries = await readDirectoryIfExists(skillsDir);
|
|
1963
|
+
for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1964
|
+
await loadAgentModes(join8(skillsDir, entry.name, "agents"), `${source} ${entry.name}`, modes, diagnostics, entry.name);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
async function loadAgentModes(directory, source, modes, diagnostics, skillName) {
|
|
1968
|
+
const entries = await readDirectoryIfExists(directory);
|
|
1969
|
+
for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1970
|
+
const path = join8(directory, entry.name);
|
|
1971
|
+
try {
|
|
1972
|
+
const content = await readFile7(path, "utf8");
|
|
1973
|
+
const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
1974
|
+
if (frontmatter.enabled === false || frontmatter.inline === false)
|
|
1975
|
+
continue;
|
|
1976
|
+
const name = getFrontmatterText(frontmatter.name) ?? basename4(path, extname(path));
|
|
1977
|
+
const systemPrompt = body.trim();
|
|
1978
|
+
if (!name || name.includes(":") || !systemPrompt) {
|
|
1979
|
+
diagnostics.push(`Skipped ${path}: agent name must not contain ":" and prompt body is required.`);
|
|
1980
|
+
continue;
|
|
1981
|
+
}
|
|
1982
|
+
const id = skillName ? `${skillName}:${name}` : name;
|
|
1983
|
+
modes.set(id, {
|
|
1984
|
+
id,
|
|
1985
|
+
label: getFrontmatterText(frontmatter.display_name) ?? name,
|
|
1986
|
+
description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${basename4(path)}`,
|
|
1987
|
+
systemPrompt,
|
|
1988
|
+
promptStrategy: frontmatter.prompt_mode === "append" ? "append" : "replace",
|
|
1989
|
+
modelPreferences: [
|
|
1990
|
+
...getFrontmatterList(frontmatter.model),
|
|
1991
|
+
...getFrontmatterList(frontmatter.model_fallbacks)
|
|
1992
|
+
],
|
|
1993
|
+
thinkingLevel: getThinkingLevel(frontmatter.thinking),
|
|
1994
|
+
tools: getFrontmatterList(frontmatter.tools),
|
|
1995
|
+
source,
|
|
1996
|
+
sourcePath: path
|
|
1997
|
+
});
|
|
1998
|
+
} catch (error) {
|
|
1999
|
+
diagnostics.push(`Skipped ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
343
2000
|
}
|
|
344
|
-
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
345
2001
|
}
|
|
2002
|
+
}
|
|
2003
|
+
function getFrontmatterText(value) {
|
|
2004
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
2005
|
+
}
|
|
2006
|
+
function getFrontmatterList(value) {
|
|
2007
|
+
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
2008
|
+
return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
2009
|
+
}
|
|
2010
|
+
function getThinkingLevel(value) {
|
|
2011
|
+
const level = getFrontmatterText(value);
|
|
2012
|
+
return level && THINKING_LEVELS.has(level) ? level : undefined;
|
|
2013
|
+
}
|
|
2014
|
+
function isAgentModeSnapshot(value) {
|
|
2015
|
+
if (!value || typeof value !== "object")
|
|
2016
|
+
return false;
|
|
2017
|
+
const candidate = value;
|
|
2018
|
+
return typeof candidate.id === "string" && typeof candidate.label === "string" && typeof candidate.description === "string" && typeof candidate.systemPrompt === "string" && (candidate.promptStrategy === "append" || candidate.promptStrategy === "replace") && Array.isArray(candidate.modelPreferences) && (candidate.thinkingLevel === undefined || THINKING_LEVELS.has(candidate.thinkingLevel)) && Array.isArray(candidate.tools) && typeof candidate.source === "string" && typeof candidate.sourcePath === "string";
|
|
2019
|
+
}
|
|
2020
|
+
function isModeBaseline(value) {
|
|
2021
|
+
if (!value || typeof value !== "object")
|
|
2022
|
+
return false;
|
|
2023
|
+
const candidate = value;
|
|
2024
|
+
const model = candidate.model;
|
|
2025
|
+
return (model === undefined || typeof model.provider === "string" && typeof model.id === "string") && candidate.thinkingLevel !== undefined && THINKING_LEVELS.has(candidate.thinkingLevel) && Array.isArray(candidate.tools) && candidate.tools.every((tool) => typeof tool === "string");
|
|
2026
|
+
}
|
|
2027
|
+
// src/pi.ts
|
|
2028
|
+
import { mkdir as mkdir5, writeFile as writeFile5 } from "node:fs/promises";
|
|
2029
|
+
import { homedir as homedir7 } from "node:os";
|
|
2030
|
+
import { dirname as dirname6, join as join9 } from "node:path";
|
|
2031
|
+
var pi = {
|
|
2032
|
+
executableCheck: findPiExecutable,
|
|
2033
|
+
packageList: listPiPackages,
|
|
2034
|
+
packageCheck: hasPiPackage,
|
|
2035
|
+
packageInstall: installPiPackage,
|
|
2036
|
+
agentDir: resolvePiAgentDir,
|
|
2037
|
+
agentEnsure: ensurePiAgent,
|
|
2038
|
+
skillCheckGlobal: checkGlobalPiSkill,
|
|
2039
|
+
skillInstallGlobal: installGlobalPiSkills,
|
|
2040
|
+
configEnsure: ensurePiConfig
|
|
346
2041
|
};
|
|
347
|
-
function
|
|
348
|
-
|
|
2042
|
+
async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
|
|
2043
|
+
const path = join9(agentDir, "agents", filename);
|
|
2044
|
+
const currentText = await readTextIfExists(path);
|
|
2045
|
+
const changed = currentText !== content;
|
|
2046
|
+
if (changed && !dryRun) {
|
|
2047
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2048
|
+
await writeFile5(path, content, "utf8");
|
|
2049
|
+
}
|
|
2050
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
349
2051
|
}
|
|
350
|
-
async function
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
2052
|
+
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join9(homedir7(), ".agents", "skills")) {
|
|
2053
|
+
const roots = [join9(agentDir, "skills"), sharedSkillsDir];
|
|
2054
|
+
for (const root of roots) {
|
|
2055
|
+
if (await readTextIfExists(join9(root, name, "SKILL.md")) !== undefined)
|
|
2056
|
+
return true;
|
|
2057
|
+
}
|
|
2058
|
+
return false;
|
|
2059
|
+
}
|
|
2060
|
+
async function installGlobalPiSkills(miseExecutable, source, names) {
|
|
2061
|
+
const selection = names.flatMap((name) => ["--skill", name]);
|
|
2062
|
+
await runChecked(miseExecutable, [
|
|
2063
|
+
"x",
|
|
2064
|
+
"node@22",
|
|
2065
|
+
"--",
|
|
2066
|
+
"npx",
|
|
2067
|
+
"-y",
|
|
2068
|
+
"skills",
|
|
2069
|
+
"add",
|
|
2070
|
+
source,
|
|
2071
|
+
...selection,
|
|
2072
|
+
"--global",
|
|
2073
|
+
"--agent",
|
|
2074
|
+
"pi",
|
|
2075
|
+
"--yes"
|
|
2076
|
+
]);
|
|
2077
|
+
}
|
|
2078
|
+
async function ensurePiConfig(path, update, dryRun = false) {
|
|
2079
|
+
const currentText = await readTextIfExists(path);
|
|
2080
|
+
const current = parseJsonObject(currentText, path);
|
|
2081
|
+
const next = update(current);
|
|
2082
|
+
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
2083
|
+
if (changed && !dryRun) {
|
|
2084
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2085
|
+
await writeFile5(path, `${JSON.stringify(next, null, 2)}
|
|
2086
|
+
`, "utf8");
|
|
357
2087
|
}
|
|
2088
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
2089
|
+
}
|
|
2090
|
+
async function findPiExecutable() {
|
|
2091
|
+
return findExecutable("pi");
|
|
2092
|
+
}
|
|
2093
|
+
async function listPiPackages(executable) {
|
|
2094
|
+
return (await runChecked(executable, ["list"])).stdout;
|
|
2095
|
+
}
|
|
2096
|
+
function hasPiPackage(listOutput, source) {
|
|
2097
|
+
if (listOutput.includes(source))
|
|
2098
|
+
return true;
|
|
2099
|
+
return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
|
|
2100
|
+
}
|
|
2101
|
+
async function installPiPackage(executable, source) {
|
|
2102
|
+
await runChecked(executable, ["install", source]);
|
|
2103
|
+
}
|
|
2104
|
+
function resolvePiAgentDir(homeDir = homedir7()) {
|
|
2105
|
+
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join9(process.env.XDG_CONFIG_HOME, "pi") : join9(homeDir, ".pi", "agent"));
|
|
358
2106
|
}
|
|
359
|
-
function
|
|
2107
|
+
function parseJsonObject(content, path) {
|
|
360
2108
|
if (!content?.trim())
|
|
361
2109
|
return {};
|
|
362
2110
|
try {
|
|
@@ -366,9 +2114,56 @@ function getParsedObject(content, path) {
|
|
|
366
2114
|
} catch {}
|
|
367
2115
|
throw new Error(`Expected valid JSON object in ${path}.`);
|
|
368
2116
|
}
|
|
2117
|
+
// src/review-publication.ts
|
|
2118
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
2119
|
+
import { readFile as readFile8, rename, writeFile as writeFile6 } from "node:fs/promises";
|
|
2120
|
+
import { join as join10 } from "node:path";
|
|
2121
|
+
import { z as z3 } from "zod";
|
|
2122
|
+
var reviewPublicationStateSchema = z3.object({
|
|
2123
|
+
target: z3.string().optional(),
|
|
2124
|
+
comments: z3.array(z3.string()).default([]),
|
|
2125
|
+
replies: z3.array(z3.string()).default([]),
|
|
2126
|
+
overlayPath: z3.string().optional()
|
|
2127
|
+
});
|
|
2128
|
+
function reviewCommentFingerprint(comment) {
|
|
2129
|
+
return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
|
|
2130
|
+
}
|
|
2131
|
+
function reviewReplyFingerprint(threadId, body) {
|
|
2132
|
+
return digest(`${threadId}\x00${body}`);
|
|
2133
|
+
}
|
|
2134
|
+
function unpublishedReviewComments(comments, knownFingerprints) {
|
|
2135
|
+
return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
|
|
2136
|
+
}
|
|
2137
|
+
async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
|
|
2138
|
+
const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
|
|
2139
|
+
const path = join10(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
|
|
2140
|
+
try {
|
|
2141
|
+
const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile8(path, "utf8")));
|
|
2142
|
+
return { path, state: { ...parsed, target } };
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
if (error.code === "ENOENT") {
|
|
2145
|
+
return { path, state: { target, comments: [], replies: [] } };
|
|
2146
|
+
}
|
|
2147
|
+
if (error instanceof SyntaxError || error instanceof z3.ZodError) {
|
|
2148
|
+
throw new Error(`Cannot parse review publication state: ${path}`);
|
|
2149
|
+
}
|
|
2150
|
+
throw error;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
async function saveReviewPublicationState(path, state) {
|
|
2154
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
2155
|
+
await writeFile6(temp, `${JSON.stringify(state, null, 2)}
|
|
2156
|
+
`, "utf8");
|
|
2157
|
+
await rename(temp, path);
|
|
2158
|
+
}
|
|
2159
|
+
function digest(value) {
|
|
2160
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
2161
|
+
}
|
|
369
2162
|
// src/setup.ts
|
|
370
|
-
import {
|
|
371
|
-
import {
|
|
2163
|
+
import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
|
|
2164
|
+
import { readdir as readdir2, readFile as readFile9 } from "node:fs/promises";
|
|
2165
|
+
import { homedir as homedir8 } from "node:os";
|
|
2166
|
+
import { basename as basename5, join as join11 } from "node:path";
|
|
372
2167
|
var MISE_DEPENDENCIES = [
|
|
373
2168
|
{ name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
|
|
374
2169
|
{ name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
|
|
@@ -399,9 +2194,14 @@ var PI_SKILL_SOURCES = [
|
|
|
399
2194
|
{ repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
|
|
400
2195
|
];
|
|
401
2196
|
var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
|
|
2197
|
+
var BUNDLED_AGENTS_DIR2 = resolveBundledAgentsDir();
|
|
2198
|
+
var FORGE_DEPENDENCIES = {
|
|
2199
|
+
github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
|
|
2200
|
+
gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
|
|
2201
|
+
};
|
|
402
2202
|
async function ensureMise(options = {}) {
|
|
403
|
-
const homeDir = options.homeDir ??
|
|
404
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
2203
|
+
const homeDir = options.homeDir ?? homedir8();
|
|
2204
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join11(homeDir, ".local", "bin", "mise"));
|
|
405
2205
|
if (current)
|
|
406
2206
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
407
2207
|
reportProgress(options, "Installing mise");
|
|
@@ -430,7 +2230,10 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
|
|
|
430
2230
|
async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
431
2231
|
const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
|
|
432
2232
|
const actions = [];
|
|
433
|
-
|
|
2233
|
+
const dependencies = [...MISE_DEPENDENCIES];
|
|
2234
|
+
if (options.forge && options.forge !== "none")
|
|
2235
|
+
dependencies.push(FORGE_DEPENDENCIES[options.forge]);
|
|
2236
|
+
for (const dependency of dependencies) {
|
|
434
2237
|
const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
|
|
435
2238
|
if (installed) {
|
|
436
2239
|
actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
|
|
@@ -446,18 +2249,33 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
446
2249
|
async function ensurePiPlugins(options = {}) {
|
|
447
2250
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
448
2251
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
449
|
-
const webSearch = await pi.configEnsure(
|
|
2252
|
+
const webSearch = await pi.configEnsure(join11(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
450
2253
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
451
|
-
const lsp = await pi.configEnsure(
|
|
2254
|
+
const lsp = await pi.configEnsure(join11(agentDir, "pi-lsp.json"), (config) => ({
|
|
452
2255
|
...config,
|
|
453
2256
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
454
2257
|
}), options.dryRun);
|
|
455
2258
|
actions.push(getConfigSetupAction("pi-lsp settings", lsp));
|
|
456
2259
|
return actions;
|
|
457
2260
|
}
|
|
2261
|
+
async function ensurePiAgents(options = {}) {
|
|
2262
|
+
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
2263
|
+
const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
|
|
2264
|
+
const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
|
|
2265
|
+
const entries = (await readdir2(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
|
|
2266
|
+
const actions = [];
|
|
2267
|
+
for (const entry of entries) {
|
|
2268
|
+
const id = basename5(entry.name, ".md").replace(/^diffpi-/, "");
|
|
2269
|
+
const source = await readFile9(join11(bundledAgentsDir, entry.name), "utf8");
|
|
2270
|
+
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
2271
|
+
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
2272
|
+
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
2273
|
+
}
|
|
2274
|
+
return actions;
|
|
2275
|
+
}
|
|
458
2276
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
459
2277
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
460
|
-
const sharedSkillsDir =
|
|
2278
|
+
const sharedSkillsDir = join11(options.homeDir ?? homedir8(), ".agents", "skills");
|
|
461
2279
|
const actions = [];
|
|
462
2280
|
for (const source of PI_SKILL_SOURCES) {
|
|
463
2281
|
const missing = [];
|
|
@@ -501,6 +2319,12 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
|
|
|
501
2319
|
} else if (options.issueTracker === "jira") {
|
|
502
2320
|
servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
|
|
503
2321
|
}
|
|
2322
|
+
if (options.forge === "github") {
|
|
2323
|
+
servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
|
|
2324
|
+
} else if (options.forge === "gitlab") {
|
|
2325
|
+
const host = (await detectVcs(projectDir)).host || "gitlab.com";
|
|
2326
|
+
servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
|
|
2327
|
+
}
|
|
504
2328
|
const result = await mcp.serversEnsure(servers, {
|
|
505
2329
|
dryRun: options.dryRun,
|
|
506
2330
|
path: mcp.globalConfigPath(options.homeDir)
|
|
@@ -514,13 +2338,81 @@ async function setupPi(options = {}) {
|
|
|
514
2338
|
actions.push(await ensureMiseHooks(miseResult.executable, options));
|
|
515
2339
|
actions.push(...await ensureMiseDeps(miseResult.executable, options));
|
|
516
2340
|
actions.push(...await ensurePiPlugins(options));
|
|
2341
|
+
actions.push(...await ensurePiAgents(options));
|
|
517
2342
|
actions.push(...await ensurePiSkills(miseResult.executable, options));
|
|
518
2343
|
actions.push(...await ensureMcpAdapters(miseResult.executable, options));
|
|
2344
|
+
if (options.bindZedKey)
|
|
2345
|
+
actions.push(...await ensureZedIntegration(options));
|
|
519
2346
|
return {
|
|
520
2347
|
actions,
|
|
521
|
-
restartPi: actions
|
|
2348
|
+
restartPi: setupRequiresRestart(actions)
|
|
522
2349
|
};
|
|
523
2350
|
}
|
|
2351
|
+
function setupRequiresRestart(actions) {
|
|
2352
|
+
return actions.some((item) => (item.status === "installed" || item.status === "updated") && (item.name.startsWith("pi package ") || item.name.startsWith("pi agent ") || item.name.startsWith("pi skill ") || item.name === "MCP configuration" || item.name === "web search settings" || item.name === "pi-lsp settings"));
|
|
2353
|
+
}
|
|
2354
|
+
function materializeAgentModels(content, agentId, config, availableModels) {
|
|
2355
|
+
const { frontmatter } = parseFrontmatter3(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
2356
|
+
const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
|
|
2357
|
+
const preferences = resolveAgentModelPreferences(agentId, profilePreferences, config);
|
|
2358
|
+
let selectedIndex = availableModels === undefined && preferences.length > 0 ? 0 : -1;
|
|
2359
|
+
let selectedModel = selectedIndex === 0 ? preferences[0] : undefined;
|
|
2360
|
+
if (availableModels) {
|
|
2361
|
+
for (const [index, preference] of preferences.entries()) {
|
|
2362
|
+
const match = findPreferredModel(availableModels, preference);
|
|
2363
|
+
if (!match)
|
|
2364
|
+
continue;
|
|
2365
|
+
selectedIndex = index;
|
|
2366
|
+
selectedModel = `${match.provider}/${match.id}`;
|
|
2367
|
+
break;
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
const fallbacks = preferences.filter((_preference, index) => index !== selectedIndex);
|
|
2371
|
+
return replaceAgentModelFields(content, selectedModel, fallbacks);
|
|
2372
|
+
}
|
|
2373
|
+
function replaceAgentModelFields(content, model, fallbacks) {
|
|
2374
|
+
const newline = content.includes(`\r
|
|
2375
|
+
`) ? `\r
|
|
2376
|
+
` : `
|
|
2377
|
+
`;
|
|
2378
|
+
const lines = content.replaceAll(`\r
|
|
2379
|
+
`, `
|
|
2380
|
+
`).split(`
|
|
2381
|
+
`);
|
|
2382
|
+
const closingDelimiter = lines.indexOf("---", 1);
|
|
2383
|
+
if (lines[0] !== "---" || closingDelimiter < 0)
|
|
2384
|
+
return content;
|
|
2385
|
+
const frontmatter = lines.slice(1, closingDelimiter).filter((line) => !/^model(?:_fallbacks)?:/.test(line));
|
|
2386
|
+
if (model)
|
|
2387
|
+
frontmatter.push(`model: ${model}`);
|
|
2388
|
+
if (fallbacks.length > 0)
|
|
2389
|
+
frontmatter.push(`model_fallbacks: ${fallbacks.join(", ")}`);
|
|
2390
|
+
return ["---", ...frontmatter, "---", ...lines.slice(closingDelimiter + 1)].join(newline);
|
|
2391
|
+
}
|
|
2392
|
+
async function ensureZedIntegration(options = {}) {
|
|
2393
|
+
if (options.dryRun) {
|
|
2394
|
+
const actions = [createSetupAction("Zed review task", "planned", "tasks.json")];
|
|
2395
|
+
if (options.bindZedKey)
|
|
2396
|
+
actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
|
|
2397
|
+
return actions;
|
|
2398
|
+
}
|
|
2399
|
+
const actions = [];
|
|
2400
|
+
try {
|
|
2401
|
+
const task = await ensureZedReviewTask(options.homeDir);
|
|
2402
|
+
actions.push(createSetupAction("Zed review task", task.changed ? "installed" : "ready", task.path));
|
|
2403
|
+
} catch (error) {
|
|
2404
|
+
actions.push(createSetupAction("Zed review task", "skipped", error instanceof Error ? error.message : String(error)));
|
|
2405
|
+
}
|
|
2406
|
+
if (options.bindZedKey) {
|
|
2407
|
+
try {
|
|
2408
|
+
const key = await ensureZedReviewKeybinding(options.homeDir);
|
|
2409
|
+
actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
|
|
2410
|
+
} catch (error) {
|
|
2411
|
+
actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
return actions;
|
|
2415
|
+
}
|
|
524
2416
|
async function ensurePiPackages(packages, options) {
|
|
525
2417
|
const executable = await pi.executableCheck();
|
|
526
2418
|
if (!executable && !options.dryRun)
|
|
@@ -542,6 +2434,10 @@ ${source}`;
|
|
|
542
2434
|
}
|
|
543
2435
|
return actions;
|
|
544
2436
|
}
|
|
2437
|
+
function getTextList(value) {
|
|
2438
|
+
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
2439
|
+
return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
2440
|
+
}
|
|
545
2441
|
function getConfigSetupAction(name, result) {
|
|
546
2442
|
if (!result.changed)
|
|
547
2443
|
return createSetupAction(name, "ready", result.path);
|
|
@@ -558,15 +2454,119 @@ function reportProgress(options, message) {
|
|
|
558
2454
|
function getRecord(value) {
|
|
559
2455
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
560
2456
|
}
|
|
2457
|
+
// src/templates.ts
|
|
2458
|
+
import { readFile as readFile10 } from "node:fs/promises";
|
|
2459
|
+
import { homedir as homedir9 } from "node:os";
|
|
2460
|
+
import { join as join12, normalize } from "node:path";
|
|
2461
|
+
async function loadTemplate(name, options = {}) {
|
|
2462
|
+
const relative = templateRelativePath(name);
|
|
2463
|
+
const userPath = join12(options.homeDir ?? homedir9(), ".difflab", "diffpi", "templates", relative);
|
|
2464
|
+
const bundledPath = join12(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
2465
|
+
const user = await readOptionalFile(userPath);
|
|
2466
|
+
if (user !== undefined)
|
|
2467
|
+
return { name, path: userPath, source: "user", content: user };
|
|
2468
|
+
const bundled = await readOptionalFile(bundledPath);
|
|
2469
|
+
if (bundled !== undefined)
|
|
2470
|
+
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
2471
|
+
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
2472
|
+
}
|
|
2473
|
+
function renderTemplate(content, variables) {
|
|
2474
|
+
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
2475
|
+
}
|
|
2476
|
+
function templateRelativePath(name) {
|
|
2477
|
+
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
2478
|
+
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
2479
|
+
throw new Error(`Invalid template name: ${name}`);
|
|
2480
|
+
}
|
|
2481
|
+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
2482
|
+
}
|
|
2483
|
+
async function readOptionalFile(path) {
|
|
2484
|
+
try {
|
|
2485
|
+
return await readFile10(path, "utf8");
|
|
2486
|
+
} catch (error) {
|
|
2487
|
+
if (error.code === "ENOENT")
|
|
2488
|
+
return;
|
|
2489
|
+
throw error;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
561
2492
|
export {
|
|
2493
|
+
CONVENTIONAL_COMMIT,
|
|
2494
|
+
ZED_REVIEW_TASK_NAME,
|
|
2495
|
+
addComment,
|
|
2496
|
+
assertGitHubMergeReady,
|
|
2497
|
+
assertReviewEventSupported,
|
|
2498
|
+
checkConventionalSubject,
|
|
2499
|
+
ciGate,
|
|
2500
|
+
computeProjectSlug,
|
|
2501
|
+
createForge,
|
|
2502
|
+
createLocalReviewBackend,
|
|
2503
|
+
createModeController,
|
|
2504
|
+
createRemoteReviewBackend,
|
|
2505
|
+
dedupeFindings,
|
|
2506
|
+
detectIde,
|
|
2507
|
+
detectMux,
|
|
2508
|
+
detectShell,
|
|
2509
|
+
detectVcs,
|
|
2510
|
+
diffpiConfigPaths,
|
|
2511
|
+
discoverAgentModes,
|
|
562
2512
|
ensureMcpAdapters,
|
|
563
2513
|
ensureMise,
|
|
564
2514
|
ensureMiseDeps,
|
|
565
2515
|
ensureMiseHooks,
|
|
2516
|
+
ensurePiAgents,
|
|
566
2517
|
ensurePiPlugins,
|
|
567
2518
|
ensurePiSkills,
|
|
2519
|
+
ensureStore,
|
|
2520
|
+
ensureZedReviewKeybinding,
|
|
2521
|
+
ensureZedReviewTask,
|
|
2522
|
+
findPreferredModel,
|
|
2523
|
+
findingSchema,
|
|
2524
|
+
findingsSchema,
|
|
2525
|
+
gitToplevel,
|
|
2526
|
+
githubReviewSubmissionEndpoint,
|
|
2527
|
+
hasGitlabDraftNotes,
|
|
2528
|
+
launch,
|
|
2529
|
+
listSessions,
|
|
2530
|
+
loadDiffpiConfig,
|
|
2531
|
+
loadReviewPublicationState,
|
|
2532
|
+
loadTemplate,
|
|
2533
|
+
localReviewAuthor,
|
|
568
2534
|
mcp,
|
|
569
2535
|
mise,
|
|
2536
|
+
openInNewTab,
|
|
2537
|
+
parseGitlabDiffRefs,
|
|
2538
|
+
parseRemote,
|
|
2539
|
+
parseThreadArtifact,
|
|
570
2540
|
pi,
|
|
571
|
-
|
|
2541
|
+
readSession,
|
|
2542
|
+
renderReviewDoc,
|
|
2543
|
+
renderTemplate,
|
|
2544
|
+
renderThreadArtifact,
|
|
2545
|
+
resolveAgentMode,
|
|
2546
|
+
resolveAgentModelPreferences,
|
|
2547
|
+
resolvePrSession,
|
|
2548
|
+
resolveReviewSession,
|
|
2549
|
+
resolveSession,
|
|
2550
|
+
reviewCommentFingerprint,
|
|
2551
|
+
reviewRecordName,
|
|
2552
|
+
reviewReplyFingerprint,
|
|
2553
|
+
reviewSlug,
|
|
2554
|
+
reviewsDir,
|
|
2555
|
+
runMiseGates,
|
|
2556
|
+
saveReviewPublicationState,
|
|
2557
|
+
sessionsDir,
|
|
2558
|
+
setupPi,
|
|
2559
|
+
severitySchema,
|
|
2560
|
+
storeDir,
|
|
2561
|
+
storeGlobalRoot,
|
|
2562
|
+
templateRelativePath,
|
|
2563
|
+
toFindings,
|
|
2564
|
+
toReviewComments,
|
|
2565
|
+
tuicrAvailable,
|
|
2566
|
+
unpublishedReviewComments,
|
|
2567
|
+
upsertThreadReply,
|
|
2568
|
+
withRemoteProvenance,
|
|
2569
|
+
yymmdd,
|
|
2570
|
+
zedKeymapPath,
|
|
2571
|
+
zedTasksPath
|
|
572
2572
|
};
|