@difflab/pi 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -4
- package/agents/diffpi-orchestrator.md +7 -5
- package/agents/diffpi-reviewer.md +36 -0
- package/dist/assets.d.ts +2 -0
- package/dist/assets.d.ts.map +1 -1
- package/dist/commands/index.d.ts +8 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/mode.d.ts +5 -0
- package/dist/commands/mode.d.ts.map +1 -0
- package/dist/commands/reload.d.ts +4 -0
- package/dist/commands/reload.d.ts.map +1 -0
- package/dist/commands/review.d.ts +5 -0
- package/dist/commands/review.d.ts.map +1 -0
- package/dist/environment.d.ts +41 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/extensions/ghx.d.ts +5 -0
- package/dist/extensions/ghx.d.ts.map +1 -0
- package/dist/extensions/gitx.d.ts +10 -0
- package/dist/extensions/gitx.d.ts.map +1 -0
- package/dist/extensions/glabx.d.ts +5 -0
- package/dist/extensions/glabx.d.ts.map +1 -0
- package/dist/extensions/index.js +2732 -189
- package/dist/{mise.d.ts → extensions/misex.d.ts} +3 -1
- package/dist/extensions/misex.d.ts.map +1 -0
- package/dist/{process.d.ts → extensions/processx.d.ts} +3 -1
- package/dist/extensions/processx.d.ts.map +1 -0
- package/dist/extensions/tuicrx.d.ts +59 -0
- package/dist/extensions/tuicrx.d.ts.map +1 -0
- package/dist/extensions/zedx.d.ts +16 -0
- package/dist/extensions/zedx.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 +24 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1799 -137
- package/dist/modes.d.ts +1 -0
- package/dist/modes.d.ts.map +1 -1
- package/dist/review/github-review-backend.d.ts +5 -0
- package/dist/review/github-review-backend.d.ts.map +1 -0
- package/dist/review/gitlab-review-backend.d.ts +12 -0
- package/dist/review/gitlab-review-backend.d.ts.map +1 -0
- package/dist/review/index.d.ts +8 -0
- package/dist/review/index.d.ts.map +1 -0
- package/dist/review/local-review-backend.d.ts +3 -0
- package/dist/review/local-review-backend.d.ts.map +1 -0
- package/dist/review/review-backend.d.ts +8 -0
- package/dist/review/review-backend.d.ts.map +1 -0
- package/dist/review/review-markdown.d.ts +6 -0
- package/dist/review/review-markdown.d.ts.map +1 -0
- package/dist/review/review-state.d.ts +19 -0
- package/dist/review/review-state.d.ts.map +1 -0
- package/dist/review/types.d.ts +107 -0
- package/dist/review/types.d.ts.map +1 -0
- package/dist/setup.d.ts +7 -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 +2 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2722 -299
- 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/vcs/forge-backend.d.ts +7 -0
- package/dist/vcs/forge-backend.d.ts.map +1 -0
- package/dist/vcs/github.d.ts +5 -0
- package/dist/vcs/github.d.ts.map +1 -0
- package/dist/vcs/gitlab.d.ts +4 -0
- package/dist/vcs/gitlab.d.ts.map +1 -0
- package/dist/vcs/helpers.d.ts +7 -0
- package/dist/vcs/helpers.d.ts.map +1 -0
- package/dist/vcs/index.d.ts +6 -0
- package/dist/vcs/index.d.ts.map +1 -0
- package/dist/vcs/types.d.ts +32 -0
- package/dist/vcs/types.d.ts.map +1 -0
- package/package.json +3 -2
- package/skills/diffpi-setup/SKILL.md +3 -1
- package/skills/review/SKILL.md +17 -0
- package/skills/review/references/workflows/address.md +11 -0
- package/skills/review/references/workflows/auto.md +18 -0
- package/skills/review/references/workflows/complete.md +7 -0
- package/skills/review/references/workflows/edit.md +7 -0
- package/skills/review/references/workflows/help.md +30 -0
- package/skills/review/references/workflows/merge.md +6 -0
- package/skills/review/references/workflows/new.md +19 -0
- package/skills/review/references/workflows/open.md +3 -0
- package/skills/review/references/workflows/publish.md +8 -0
- package/templates/review/draft-pr.md +25 -0
- package/dist/mise.d.ts.map +0 -1
- package/dist/process.d.ts.map +0 -1
package/dist/tools/index.js
CHANGED
|
@@ -124,41 +124,2589 @@ function sanitize(value) {
|
|
|
124
124
|
return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// src/tools/review.ts
|
|
128
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
129
|
+
import { mkdir as mkdir4, readdir, readFile as readFile7, rename as rename2, stat, unlink as unlink2, writeFile as writeFile5 } from "node:fs/promises";
|
|
130
|
+
import { join as join9 } from "node:path";
|
|
131
|
+
import { defineTool as defineTool3 } from "@earendil-works/pi-coding-agent";
|
|
132
|
+
import { z as z6 } from "zod";
|
|
133
|
+
|
|
134
|
+
// src/environment.ts
|
|
135
|
+
import { basename } from "node:path";
|
|
136
|
+
|
|
137
|
+
// src/extensions/processx.ts
|
|
138
|
+
import { spawn } from "node:child_process";
|
|
139
|
+
import { constants } from "node:fs";
|
|
140
|
+
import { access } from "node:fs/promises";
|
|
141
|
+
import { delimiter, join } from "node:path";
|
|
142
|
+
async function findExecutable(name) {
|
|
143
|
+
if (name.includes("/")) {
|
|
144
|
+
try {
|
|
145
|
+
await access(name, constants.X_OK);
|
|
146
|
+
return name;
|
|
147
|
+
} catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
152
|
+
if (!directory)
|
|
153
|
+
continue;
|
|
154
|
+
const candidate = join(directory, name);
|
|
155
|
+
try {
|
|
156
|
+
await access(candidate, constants.X_OK);
|
|
157
|
+
return candidate;
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
function run(command, args, options = {}) {
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const child = spawn(command, args, {
|
|
165
|
+
cwd: options.cwd,
|
|
166
|
+
env: options.env ?? process.env,
|
|
167
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
|
|
168
|
+
});
|
|
169
|
+
let stdout = "";
|
|
170
|
+
let stderr = "";
|
|
171
|
+
const stdoutChunks = [];
|
|
172
|
+
const stderrChunks = [];
|
|
173
|
+
const unbounded = options.capture === "unbounded";
|
|
174
|
+
child.stdout?.on("data", (chunk) => {
|
|
175
|
+
const text = chunk.toString();
|
|
176
|
+
if (unbounded)
|
|
177
|
+
stdoutChunks.push(text);
|
|
178
|
+
else
|
|
179
|
+
stdout = appendBounded(stdout, text);
|
|
180
|
+
});
|
|
181
|
+
child.stderr?.on("data", (chunk) => {
|
|
182
|
+
const text = chunk.toString();
|
|
183
|
+
if (unbounded)
|
|
184
|
+
stderrChunks.push(text);
|
|
185
|
+
else
|
|
186
|
+
stderr = appendBounded(stderr, text);
|
|
187
|
+
});
|
|
188
|
+
child.on("error", reject);
|
|
189
|
+
child.on("close", (code) => resolve({
|
|
190
|
+
code: code ?? 1,
|
|
191
|
+
stdout: unbounded ? stdoutChunks.join("") : stdout,
|
|
192
|
+
stderr: unbounded ? stderrChunks.join("") : stderr
|
|
193
|
+
}));
|
|
194
|
+
if (options.input !== undefined && child.stdin)
|
|
195
|
+
child.stdin.end(options.input);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
async function runChecked(command, args, options = {}) {
|
|
199
|
+
const result = await run(command, args, options);
|
|
200
|
+
if (result.code === 0)
|
|
201
|
+
return result;
|
|
202
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
203
|
+
throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
204
|
+
}
|
|
205
|
+
var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
|
|
206
|
+
function appendBounded(current, next) {
|
|
207
|
+
const combined = current + next;
|
|
208
|
+
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/extensions/zedx.ts
|
|
212
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
213
|
+
import { homedir } from "node:os";
|
|
214
|
+
import { dirname, join as join2 } from "node:path";
|
|
215
|
+
var ZED_LOCAL_REVIEW_TASK_NAME = "diffpi: tuicr local review";
|
|
216
|
+
var ZED_PR_REVIEW_TASK_NAME = "diffpi: tuicr PR review";
|
|
217
|
+
var LEGACY_ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
|
|
218
|
+
var REVIEW_KEYBINDING = "cmd-alt-r";
|
|
219
|
+
function zedTasksPath(homeDir = homedir()) {
|
|
220
|
+
return join2(homeDir, ".config", "zed", "tasks.json");
|
|
221
|
+
}
|
|
222
|
+
function zedKeymapPath(homeDir = homedir()) {
|
|
223
|
+
return join2(homeDir, ".config", "zed", "keymap.json");
|
|
224
|
+
}
|
|
225
|
+
async function ensureZedReviewTask(homeDir = homedir(), _command = ["tuicr", "-w", "-r", "main..HEAD"]) {
|
|
226
|
+
const path = zedTasksPath(homeDir);
|
|
227
|
+
const currentText = await readOptional(path);
|
|
228
|
+
const tasks = parseJsonArray(currentText, path);
|
|
229
|
+
const migrated = tasks.filter((task) => task.label !== LEGACY_ZED_REVIEW_TASK_NAME);
|
|
230
|
+
const next = [...migrated];
|
|
231
|
+
for (const task of [localReviewTask(), prReviewTask()]) {
|
|
232
|
+
const index = next.findIndex((existing) => existing.label === task.label);
|
|
233
|
+
if (index >= 0)
|
|
234
|
+
next[index] = { ...next[index], ...task };
|
|
235
|
+
else
|
|
236
|
+
next.push(task);
|
|
237
|
+
}
|
|
238
|
+
const changed = JSON.stringify(tasks) !== JSON.stringify(next);
|
|
239
|
+
if (changed)
|
|
240
|
+
await writeJson(path, next);
|
|
241
|
+
return { path, changed, existed: currentText !== undefined };
|
|
242
|
+
}
|
|
243
|
+
async function ensureZedReviewKeybinding(homeDir = homedir()) {
|
|
244
|
+
const path = zedKeymapPath(homeDir);
|
|
245
|
+
const currentText = await readOptional(path);
|
|
246
|
+
const entries = parseJsonArray(currentText, path);
|
|
247
|
+
const migrated = entries.map(migrateReviewKeymapEntry);
|
|
248
|
+
const alreadyBound = migrated.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
|
|
249
|
+
const next = alreadyBound ? migrated : [
|
|
250
|
+
...migrated,
|
|
251
|
+
{
|
|
252
|
+
context: "Workspace",
|
|
253
|
+
bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_LOCAL_REVIEW_TASK_NAME }] }
|
|
254
|
+
}
|
|
255
|
+
];
|
|
256
|
+
const changed = JSON.stringify(entries) !== JSON.stringify(next);
|
|
257
|
+
if (changed)
|
|
258
|
+
await writeJson(path, next);
|
|
259
|
+
return { path, changed, existed: currentText !== undefined };
|
|
260
|
+
}
|
|
261
|
+
function zedReviewTaskName(command) {
|
|
262
|
+
return command[0] === "tuicr" && command[1] === "pr" ? ZED_PR_REVIEW_TASK_NAME : ZED_LOCAL_REVIEW_TASK_NAME;
|
|
263
|
+
}
|
|
264
|
+
var LOCAL_REVIEW_SCRIPT = `set -eu
|
|
265
|
+
remote=$(git remote get-url origin 2>/dev/null || true)
|
|
266
|
+
branch=$(git branch --show-current)
|
|
267
|
+
base=
|
|
268
|
+
case "$remote" in
|
|
269
|
+
*github.com*)
|
|
270
|
+
base=$(gh pr list --head "$branch" --state open --limit 100 --json baseRefName,createdAt --jq 'sort_by(.createdAt) | reverse | .[0].baseRefName // empty' 2>/dev/null || true)
|
|
271
|
+
[ -n "$base" ] || base=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null || true)
|
|
272
|
+
;;
|
|
273
|
+
*gitlab*)
|
|
274
|
+
mr=$(glab mr list --source-branch "$branch" --order created_at --sort desc --per-page 100 --output json 2>/dev/null || true)
|
|
275
|
+
base=$(printf '%s' "$mr" | node -e 'let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{try{let a=JSON.parse(s);process.stdout.write(a[0]?.target_branch||"")}catch{}})')
|
|
276
|
+
[ -n "$base" ] || base=$(glab repo view --output json 2>/dev/null | node -e 'let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(JSON.parse(s).default_branch||"")}catch{}})' || true)
|
|
277
|
+
;;
|
|
278
|
+
esac
|
|
279
|
+
[ -n "$base" ] || { echo 'diffpi: could not resolve a GitHub/GitLab review base' >&2; exit 1; }
|
|
280
|
+
exec tuicr -w -r "$base..HEAD"`;
|
|
281
|
+
var PR_REVIEW_SCRIPT = `set -eu
|
|
282
|
+
remote=$(git remote get-url origin 2>/dev/null || true)
|
|
283
|
+
branch=$(git branch --show-current)
|
|
284
|
+
case "$remote" in
|
|
285
|
+
*github.com*) number=$(gh pr list --head "$branch" --state open --limit 100 --json number,createdAt --jq 'sort_by(.createdAt) | reverse | .[0].number // empty' 2>/dev/null || true) ;;
|
|
286
|
+
*gitlab*) number=$(glab mr list --source-branch "$branch" --order created_at --sort desc --per-page 100 --output json 2>/dev/null | node -e 'let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{try{let a=JSON.parse(s);process.stdout.write(String(a[0]?.iid||a[0]?.number||""))}catch{}})' || true) ;;
|
|
287
|
+
*) number= ;;
|
|
288
|
+
esac
|
|
289
|
+
[ -n "$number" ] || { echo 'diffpi: could not resolve the current GitHub PR or GitLab MR' >&2; exit 1; }
|
|
290
|
+
exec tuicr pr "$number"`;
|
|
291
|
+
function staticReviewTask(label, script) {
|
|
292
|
+
return {
|
|
293
|
+
label,
|
|
294
|
+
command: "sh",
|
|
295
|
+
args: ["-lc", script],
|
|
296
|
+
cwd: "$ZED_WORKTREE_ROOT",
|
|
297
|
+
use_new_terminal: true,
|
|
298
|
+
reveal: "always",
|
|
299
|
+
reveal_target: "center"
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function localReviewTask() {
|
|
303
|
+
return staticReviewTask(ZED_LOCAL_REVIEW_TASK_NAME, LOCAL_REVIEW_SCRIPT);
|
|
304
|
+
}
|
|
305
|
+
function prReviewTask() {
|
|
306
|
+
return staticReviewTask(ZED_PR_REVIEW_TASK_NAME, PR_REVIEW_SCRIPT);
|
|
307
|
+
}
|
|
308
|
+
function bindsReviewTask(payload) {
|
|
309
|
+
return typeof payload === "object" && payload !== null && payload.task_name === ZED_LOCAL_REVIEW_TASK_NAME;
|
|
310
|
+
}
|
|
311
|
+
function migrateReviewKeymapEntry(entry) {
|
|
312
|
+
const bindings = Object.fromEntries(Object.entries(entry.bindings ?? {}).map(([key, action]) => {
|
|
313
|
+
if (Array.isArray(action) && action[0] === "task::Spawn" && typeof action[1] === "object" && action[1] !== null && action[1].task_name === LEGACY_ZED_REVIEW_TASK_NAME) {
|
|
314
|
+
return [key, ["task::Spawn", { ...action[1], task_name: ZED_LOCAL_REVIEW_TASK_NAME }]];
|
|
315
|
+
}
|
|
316
|
+
return [key, action];
|
|
317
|
+
}));
|
|
318
|
+
return entry.bindings ? { ...entry, bindings } : entry;
|
|
319
|
+
}
|
|
320
|
+
async function readOptional(path) {
|
|
321
|
+
try {
|
|
322
|
+
return await readFile(path, "utf8");
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
325
|
+
return;
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function parseJsonArray(content, path) {
|
|
330
|
+
if (!content?.trim())
|
|
331
|
+
return [];
|
|
332
|
+
let value;
|
|
333
|
+
try {
|
|
334
|
+
value = JSON.parse(content);
|
|
335
|
+
} catch {
|
|
336
|
+
throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
|
|
337
|
+
}
|
|
338
|
+
if (!Array.isArray(value))
|
|
339
|
+
throw new Error(`Expected a JSON array in ${path}.`);
|
|
340
|
+
return value;
|
|
341
|
+
}
|
|
342
|
+
async function writeJson(path, value) {
|
|
343
|
+
await mkdir(dirname(path), { recursive: true });
|
|
344
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}
|
|
345
|
+
`, "utf8");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/environment.ts
|
|
349
|
+
function detectIde(env = process.env) {
|
|
350
|
+
const program = (env.TERM_PROGRAM ?? "").toLowerCase();
|
|
351
|
+
if (env.ZED_TERM === "true" || program === "zed")
|
|
352
|
+
return "zed";
|
|
353
|
+
if (env.CURSOR_TRACE_ID || program === "cursor")
|
|
354
|
+
return "cursor";
|
|
355
|
+
if (env.WINDSURF_ENV || program === "windsurf")
|
|
356
|
+
return "windsurf";
|
|
357
|
+
if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
|
|
358
|
+
return "jetbrains";
|
|
359
|
+
if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
|
|
360
|
+
return "vscode";
|
|
361
|
+
return "unknown";
|
|
362
|
+
}
|
|
363
|
+
function detectMux(env = process.env) {
|
|
364
|
+
if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
|
|
365
|
+
return "zellij";
|
|
366
|
+
if (env.TMUX)
|
|
367
|
+
return "tmux";
|
|
368
|
+
if (env.STY)
|
|
369
|
+
return "screen";
|
|
370
|
+
return "none";
|
|
371
|
+
}
|
|
372
|
+
function detectShell(env = process.env) {
|
|
373
|
+
return env.SHELL ? basename(env.SHELL) : "unknown";
|
|
374
|
+
}
|
|
375
|
+
async function detectVcs(cwd) {
|
|
376
|
+
const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
|
|
377
|
+
const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
|
378
|
+
const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
|
|
379
|
+
return { ...parseRemote(remote), branch, root };
|
|
380
|
+
}
|
|
381
|
+
function parseRemote(remote) {
|
|
382
|
+
const empty = { provider: "none", host: "", owner: "", repo: "" };
|
|
383
|
+
if (!remote)
|
|
384
|
+
return empty;
|
|
385
|
+
const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
|
|
386
|
+
const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
387
|
+
const match = scp ?? url;
|
|
388
|
+
if (!match)
|
|
389
|
+
return empty;
|
|
390
|
+
const host = match[1];
|
|
391
|
+
const segments = match[2].split("/").filter(Boolean);
|
|
392
|
+
if (segments.length < 2)
|
|
393
|
+
return { ...empty, host };
|
|
394
|
+
const repo = segments.at(-1) ?? "";
|
|
395
|
+
const owner = segments.slice(0, -1).join("/");
|
|
396
|
+
const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
|
|
397
|
+
return { provider, host, owner, repo };
|
|
398
|
+
}
|
|
399
|
+
async function openInNewTab(command, opts) {
|
|
400
|
+
const env = opts.env ?? process.env;
|
|
401
|
+
const name = opts.name ?? "review";
|
|
402
|
+
const printable = command.join(" ");
|
|
403
|
+
const mux = detectMux(env);
|
|
404
|
+
if (mux !== "none") {
|
|
405
|
+
const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
|
|
406
|
+
if (opened)
|
|
407
|
+
return opened;
|
|
408
|
+
}
|
|
409
|
+
if (detectIde(env) === "zed") {
|
|
410
|
+
try {
|
|
411
|
+
const taskName = zedReviewTaskName(command);
|
|
412
|
+
await ensureZedReviewTask(opts.homeDir, command);
|
|
413
|
+
return {
|
|
414
|
+
launched: false,
|
|
415
|
+
configured: true,
|
|
416
|
+
via: "zed-task",
|
|
417
|
+
command: printable,
|
|
418
|
+
taskName,
|
|
419
|
+
instruction: `Run the Zed task "${taskName}".`
|
|
420
|
+
};
|
|
421
|
+
} catch {}
|
|
422
|
+
}
|
|
423
|
+
return { launched: false, via: "print", command: printable };
|
|
424
|
+
}
|
|
425
|
+
function screenWindowArgs(command, cwd, name) {
|
|
426
|
+
return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
|
|
427
|
+
}
|
|
428
|
+
async function openMuxTab(mux, command, cwd, name, printable) {
|
|
429
|
+
if (mux === "zellij" && await findExecutable("zellij")) {
|
|
430
|
+
const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
431
|
+
if (result.code === 0)
|
|
432
|
+
return { launched: true, via: "zellij", command: printable };
|
|
433
|
+
const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
434
|
+
if (fallback.code === 0)
|
|
435
|
+
return { launched: true, via: "zellij-run", command: printable };
|
|
436
|
+
}
|
|
437
|
+
if (mux === "tmux" && await findExecutable("tmux")) {
|
|
438
|
+
const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
|
|
439
|
+
if (result.code === 0)
|
|
440
|
+
return { launched: true, via: "tmux", command: printable };
|
|
441
|
+
}
|
|
442
|
+
if (mux === "screen" && await findExecutable("screen")) {
|
|
443
|
+
const result = await run("screen", screenWindowArgs(command, cwd, name));
|
|
444
|
+
if (result.code === 0)
|
|
445
|
+
return { launched: true, via: "screen", command: printable };
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/extensions/ghx.ts
|
|
451
|
+
function gh(args, options) {
|
|
452
|
+
return run("gh", args, options);
|
|
453
|
+
}
|
|
454
|
+
function ghChecked(args, options) {
|
|
455
|
+
return runChecked("gh", args, options);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/vcs/helpers.ts
|
|
459
|
+
function isConfirmedMissingChange(provider, output) {
|
|
460
|
+
const message = output.toLowerCase();
|
|
461
|
+
return provider === "github" ? message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest") : provider === "gitlab" ? message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message) : false;
|
|
462
|
+
}
|
|
463
|
+
function parseJson(value, name) {
|
|
464
|
+
try {
|
|
465
|
+
return JSON.parse(value);
|
|
466
|
+
} catch {
|
|
467
|
+
throw new Error(`Cannot parse the ${name} response as JSON.`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function commandFailure(command, args, result) {
|
|
471
|
+
return new Error(`${command} ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`}`);
|
|
472
|
+
}
|
|
473
|
+
function requireBranchName(output, provider) {
|
|
474
|
+
const branch = output.trim();
|
|
475
|
+
if (!branch || branch === "null")
|
|
476
|
+
throw new Error(`${provider} did not return a default branch.`);
|
|
477
|
+
return branch;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// src/vcs/github.ts
|
|
481
|
+
function GitHubVcsBackend(vcs) {
|
|
482
|
+
const repo = ["--repo", `${vcs.owner}/${vcs.repo}`];
|
|
483
|
+
const viewPr = async (idOrBranch) => {
|
|
484
|
+
const args = [
|
|
485
|
+
"pr",
|
|
486
|
+
"view",
|
|
487
|
+
idOrBranch,
|
|
488
|
+
...repo,
|
|
489
|
+
"--json",
|
|
490
|
+
"number,title,url,isDraft,baseRefName,headRefName,headRefOid"
|
|
491
|
+
];
|
|
492
|
+
const result = await gh(args);
|
|
493
|
+
if (result.code !== 0) {
|
|
494
|
+
if (isConfirmedMissingChange("github", result.stderr || result.stdout))
|
|
495
|
+
return;
|
|
496
|
+
throw commandFailure("gh", args, result);
|
|
497
|
+
}
|
|
498
|
+
if (!result.stdout.trim())
|
|
499
|
+
throw new Error("GitHub returned an empty pull request response.");
|
|
500
|
+
const data = parseJson(result.stdout, "GitHub pull request");
|
|
501
|
+
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")
|
|
502
|
+
throw new Error("GitHub returned an invalid pull request response.");
|
|
503
|
+
return {
|
|
504
|
+
number: data.number,
|
|
505
|
+
title: data.title,
|
|
506
|
+
url: data.url,
|
|
507
|
+
isDraft: data.isDraft,
|
|
508
|
+
baseRef: data.baseRefName,
|
|
509
|
+
headRef: data.headRefName,
|
|
510
|
+
headSha: data.headRefOid
|
|
511
|
+
};
|
|
512
|
+
};
|
|
513
|
+
return {
|
|
514
|
+
provider: "github",
|
|
515
|
+
async createDraftPr(options) {
|
|
516
|
+
const args = [
|
|
517
|
+
"pr",
|
|
518
|
+
"create",
|
|
519
|
+
...repo,
|
|
520
|
+
"--title",
|
|
521
|
+
options.title,
|
|
522
|
+
"--body",
|
|
523
|
+
options.body,
|
|
524
|
+
"--base",
|
|
525
|
+
options.base,
|
|
526
|
+
"--head",
|
|
527
|
+
options.head
|
|
528
|
+
];
|
|
529
|
+
if (options.draft !== false)
|
|
530
|
+
args.push("--draft");
|
|
531
|
+
await ghChecked(args);
|
|
532
|
+
const ref = await viewPr(options.head);
|
|
533
|
+
if (!ref)
|
|
534
|
+
throw new Error("Draft PR created but could not be resolved.");
|
|
535
|
+
return ref;
|
|
536
|
+
},
|
|
537
|
+
viewPr,
|
|
538
|
+
async defaultBranch() {
|
|
539
|
+
return requireBranchName((await ghChecked([
|
|
540
|
+
"repo",
|
|
541
|
+
"view",
|
|
542
|
+
`${vcs.owner}/${vcs.repo}`,
|
|
543
|
+
"--json",
|
|
544
|
+
"defaultBranchRef",
|
|
545
|
+
"--jq",
|
|
546
|
+
".defaultBranchRef.name"
|
|
547
|
+
])).stdout, "GitHub");
|
|
548
|
+
},
|
|
549
|
+
async prDiff(id) {
|
|
550
|
+
return (await ghChecked(["pr", "diff", String(id), ...repo], { capture: "unbounded" })).stdout;
|
|
551
|
+
},
|
|
552
|
+
async prChecks(id) {
|
|
553
|
+
return (await gh(["pr", "checks", String(id), ...repo])).stdout;
|
|
554
|
+
},
|
|
555
|
+
async markReady(id) {
|
|
556
|
+
await ghChecked(["pr", "ready", String(id), ...repo]);
|
|
557
|
+
},
|
|
558
|
+
async closePr(id, comment) {
|
|
559
|
+
const args = ["pr", "close", String(id), ...repo];
|
|
560
|
+
if (comment)
|
|
561
|
+
args.push("--comment", comment);
|
|
562
|
+
await ghChecked(args);
|
|
563
|
+
},
|
|
564
|
+
async mergePr(id, subject) {
|
|
565
|
+
const readiness = await ghChecked([
|
|
566
|
+
"pr",
|
|
567
|
+
"view",
|
|
568
|
+
String(id),
|
|
569
|
+
...repo,
|
|
570
|
+
"--json",
|
|
571
|
+
"isDraft,state,reviewDecision,mergeStateStatus,statusCheckRollup"
|
|
572
|
+
]);
|
|
573
|
+
assertGitHubMergeReady(readiness.stdout);
|
|
574
|
+
await ghChecked(["pr", "merge", String(id), ...repo, "--squash", "--subject", subject]);
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function assertGitHubMergeReady(input) {
|
|
579
|
+
const data = parseJson(input, "GitHub readiness");
|
|
580
|
+
const blockers = [];
|
|
581
|
+
if (data.state !== "OPEN")
|
|
582
|
+
blockers.push(`pull request state is ${data.state ?? "unknown"}`);
|
|
583
|
+
if (data.isDraft)
|
|
584
|
+
blockers.push("pull request is still a draft");
|
|
585
|
+
if (data.reviewDecision !== "APPROVED")
|
|
586
|
+
blockers.push(`review decision is ${data.reviewDecision || "not approved"}`);
|
|
587
|
+
if (data.mergeStateStatus !== "CLEAN")
|
|
588
|
+
blockers.push(`merge state is ${data.mergeStateStatus ?? "unknown"}`);
|
|
589
|
+
for (const check of data.statusCheckRollup ?? []) {
|
|
590
|
+
const name = check.name ?? check.context ?? "unnamed check";
|
|
591
|
+
if (check.__typename === "CheckRun") {
|
|
592
|
+
if (check.status !== "COMPLETED")
|
|
593
|
+
blockers.push(`${name} is ${check.status?.toLowerCase() ?? "pending"}`);
|
|
594
|
+
else if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(check.conclusion ?? ""))
|
|
595
|
+
blockers.push(`${name} concluded ${(check.conclusion ?? "unknown").toLowerCase()}`);
|
|
596
|
+
} else if (check.state !== "SUCCESS")
|
|
597
|
+
blockers.push(`${name} is ${(check.state ?? "pending").toLowerCase()}`);
|
|
598
|
+
}
|
|
599
|
+
if (blockers.length > 0)
|
|
600
|
+
throw new Error(`Merge blocked: ${blockers.join("; ")}.`);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// src/extensions/glabx.ts
|
|
604
|
+
function glab(args, options) {
|
|
605
|
+
return run("glab", args, options);
|
|
606
|
+
}
|
|
607
|
+
function glabChecked(args, options) {
|
|
608
|
+
return runChecked("glab", args, options);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/vcs/gitlab.ts
|
|
612
|
+
function GitLabVcsBackend(vcs) {
|
|
613
|
+
const project = `${vcs.owner}/${vcs.repo}`;
|
|
614
|
+
const viewPr = async (idOrBranch) => {
|
|
615
|
+
const args = ["mr", "view", idOrBranch, "--repo", project, "--output", "json"];
|
|
616
|
+
const result = await glab(args);
|
|
617
|
+
if (result.code !== 0) {
|
|
618
|
+
if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
|
|
619
|
+
return;
|
|
620
|
+
throw commandFailure("glab", args, result);
|
|
621
|
+
}
|
|
622
|
+
if (!result.stdout.trim())
|
|
623
|
+
throw new Error("GitLab returned an empty merge request response.");
|
|
624
|
+
const data = parseJson(result.stdout, "GitLab merge request");
|
|
625
|
+
if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string")
|
|
626
|
+
throw new Error("GitLab returned an invalid merge request response.");
|
|
627
|
+
return {
|
|
628
|
+
number: data.iid,
|
|
629
|
+
title: data.title,
|
|
630
|
+
url: data.web_url,
|
|
631
|
+
isDraft: Boolean(data.draft ?? data.work_in_progress),
|
|
632
|
+
baseRef: data.target_branch,
|
|
633
|
+
headRef: data.source_branch,
|
|
634
|
+
headSha: data.sha
|
|
635
|
+
};
|
|
636
|
+
};
|
|
637
|
+
return {
|
|
638
|
+
provider: "gitlab",
|
|
639
|
+
async createDraftPr(options) {
|
|
640
|
+
await glabChecked([
|
|
641
|
+
"mr",
|
|
642
|
+
"create",
|
|
643
|
+
"--repo",
|
|
644
|
+
project,
|
|
645
|
+
"--title",
|
|
646
|
+
`Draft: ${options.title}`,
|
|
647
|
+
"--description",
|
|
648
|
+
options.body,
|
|
649
|
+
"--target-branch",
|
|
650
|
+
options.base,
|
|
651
|
+
"--source-branch",
|
|
652
|
+
options.head,
|
|
653
|
+
"--yes"
|
|
654
|
+
]);
|
|
655
|
+
const ref = await viewPr(options.head);
|
|
656
|
+
if (!ref)
|
|
657
|
+
throw new Error("Draft MR created but could not be resolved.");
|
|
658
|
+
return ref;
|
|
659
|
+
},
|
|
660
|
+
viewPr,
|
|
661
|
+
async defaultBranch() {
|
|
662
|
+
return requireBranchName((await glabChecked(["api", `projects/${encodeURIComponent(project)}`, "--jq", ".default_branch"])).stdout, "GitLab");
|
|
663
|
+
},
|
|
664
|
+
async prDiff(id) {
|
|
665
|
+
return (await glabChecked(["mr", "diff", String(id), "--repo", project], { capture: "unbounded" })).stdout;
|
|
666
|
+
},
|
|
667
|
+
async prChecks(id) {
|
|
668
|
+
return (await glabChecked([
|
|
669
|
+
"api",
|
|
670
|
+
`projects/${encodeURIComponent(project)}/merge_requests/${id}/pipelines?per_page=100`
|
|
671
|
+
])).stdout;
|
|
672
|
+
},
|
|
673
|
+
async markReady(id) {
|
|
674
|
+
await glabChecked(["mr", "update", String(id), "--repo", project, "--ready"]);
|
|
675
|
+
},
|
|
676
|
+
async closePr(id, comment) {
|
|
677
|
+
if (comment)
|
|
678
|
+
await glabChecked(["mr", "note", String(id), "--repo", project, "--message", comment]);
|
|
679
|
+
await glabChecked(["mr", "close", String(id), "--repo", project]);
|
|
680
|
+
},
|
|
681
|
+
async mergePr() {
|
|
682
|
+
throw new Error("Merge is not supported by the GitLab forge adapter.");
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/vcs/forge-backend.ts
|
|
688
|
+
function createForgeBackend(vcs) {
|
|
689
|
+
if (vcs.provider === "github")
|
|
690
|
+
return GitHubVcsBackend(vcs);
|
|
691
|
+
if (vcs.provider === "gitlab")
|
|
692
|
+
return GitLabVcsBackend(vcs);
|
|
693
|
+
throw new Error("No supported forge detected from the git remote. Use --local for an offline review.");
|
|
694
|
+
}
|
|
695
|
+
var createVcsBackend = createForgeBackend;
|
|
696
|
+
// src/extensions/misex.ts
|
|
697
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
698
|
+
import { homedir as homedir2 } from "node:os";
|
|
699
|
+
import { basename as basename2, dirname as dirname2, join as join3 } from "node:path";
|
|
700
|
+
var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
|
|
701
|
+
var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
|
|
702
|
+
var mise = {
|
|
703
|
+
async executableCheck(name = "mise") {
|
|
704
|
+
return findExecutable(name);
|
|
705
|
+
},
|
|
706
|
+
async run(args, options = {}) {
|
|
707
|
+
return run("mise", args, options);
|
|
708
|
+
},
|
|
709
|
+
async install(options = {}) {
|
|
710
|
+
const homeDir = options.homeDir ?? homedir2();
|
|
711
|
+
const platform = options.platform ?? process.platform;
|
|
712
|
+
if (platform === "win32")
|
|
713
|
+
throw new Error("Automatic mise installation supports macOS and Linux only.");
|
|
714
|
+
const installedPath = join3(homeDir, ".local", "bin", "mise");
|
|
715
|
+
if (options.dryRun)
|
|
716
|
+
return installedPath;
|
|
717
|
+
await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
|
|
718
|
+
const executable = await findExecutable(installedPath) ?? await findExecutable("mise");
|
|
719
|
+
if (!executable)
|
|
720
|
+
throw new Error(`mise installation completed, but ${installedPath} was not found.`);
|
|
721
|
+
return executable;
|
|
722
|
+
},
|
|
723
|
+
async hookEnsure(executable, options = {}) {
|
|
724
|
+
const homeDir = options.homeDir ?? homedir2();
|
|
725
|
+
const hook = getShellHook(basename2(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
|
|
726
|
+
const current = await getOptionalFile(hook.path);
|
|
727
|
+
if (current.includes(MISE_HOOK_START))
|
|
728
|
+
return { path: hook.path, changed: false, planned: false };
|
|
729
|
+
if (options.dryRun)
|
|
730
|
+
return { path: hook.path, changed: true, planned: true };
|
|
731
|
+
const separator = current.length === 0 || current.endsWith(`
|
|
732
|
+
`) ? "" : `
|
|
733
|
+
`;
|
|
734
|
+
await mkdir2(dirname2(hook.path), { recursive: true });
|
|
735
|
+
await writeFile2(hook.path, `${current}${separator}${hook.content}`, "utf8");
|
|
736
|
+
return { path: hook.path, changed: true, planned: false };
|
|
737
|
+
},
|
|
738
|
+
async toolCheckGlobal(executable, tool, minimumVersion) {
|
|
739
|
+
const result = await run(executable, ["ls", "--global", "--installed", tool, "--json"]);
|
|
740
|
+
return result.code === 0 && isToolInstalled(result.stdout, minimumVersion);
|
|
741
|
+
},
|
|
742
|
+
async toolInstallGlobal(executable, specification) {
|
|
743
|
+
await runChecked(executable, ["use", "--global", specification]);
|
|
744
|
+
},
|
|
745
|
+
async toolCheckLocal(executable, tool, cwd = process.cwd()) {
|
|
746
|
+
const result = await run(executable, ["ls", "--local", "--installed", tool, "--json"], { cwd });
|
|
747
|
+
return result.code === 0 && isToolInstalled(result.stdout);
|
|
748
|
+
},
|
|
749
|
+
async toolInstallLocal(executable, specification, cwd = process.cwd()) {
|
|
750
|
+
await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
|
|
751
|
+
},
|
|
752
|
+
async toolUpdateAllGlobal(executable, homeDir = homedir2()) {
|
|
753
|
+
await runChecked(executable, ["upgrade"], { cwd: homeDir });
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
function getShellHook(shell, executable, homeDir) {
|
|
757
|
+
const command = getShellQuoted(executable);
|
|
758
|
+
switch (shell.toLowerCase()) {
|
|
759
|
+
case "zsh":
|
|
760
|
+
return {
|
|
761
|
+
path: join3(homeDir, ".zshrc"),
|
|
762
|
+
content: `${MISE_HOOK_START}
|
|
763
|
+
eval "$(${command} activate zsh)"
|
|
764
|
+
${MISE_HOOK_END}
|
|
765
|
+
`
|
|
766
|
+
};
|
|
767
|
+
case "fish":
|
|
768
|
+
return {
|
|
769
|
+
path: join3(homeDir, ".config", "fish", "config.fish"),
|
|
770
|
+
content: `${MISE_HOOK_START}
|
|
771
|
+
${command} activate fish | source
|
|
772
|
+
${MISE_HOOK_END}
|
|
773
|
+
`
|
|
774
|
+
};
|
|
775
|
+
case "nu":
|
|
776
|
+
case "nushell":
|
|
777
|
+
return {
|
|
778
|
+
path: join3(homeDir, ".config", "nushell", "config.nu"),
|
|
779
|
+
content: `${MISE_HOOK_START}
|
|
780
|
+
let mise_bin = ${command}
|
|
781
|
+
let mise_path = $nu.default-config-dir | path join mise.nu
|
|
782
|
+
^$mise_bin activate nu | save $mise_path --force
|
|
783
|
+
use ($nu.default-config-dir | path join mise.nu)
|
|
784
|
+
${MISE_HOOK_END}
|
|
785
|
+
`
|
|
786
|
+
};
|
|
787
|
+
case "xonsh":
|
|
788
|
+
return {
|
|
789
|
+
path: join3(homeDir, ".xonshrc"),
|
|
790
|
+
content: `${MISE_HOOK_START}
|
|
791
|
+
execx($(${command} activate xonsh))
|
|
792
|
+
${MISE_HOOK_END}
|
|
793
|
+
`
|
|
794
|
+
};
|
|
795
|
+
case "elvish":
|
|
796
|
+
return {
|
|
797
|
+
path: join3(homeDir, ".config", "elvish", "rc.elv"),
|
|
798
|
+
content: `${MISE_HOOK_START}
|
|
799
|
+
var mise: = (ns [&])
|
|
800
|
+
eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
|
|
801
|
+
mise:activate
|
|
802
|
+
${MISE_HOOK_END}
|
|
803
|
+
`
|
|
804
|
+
};
|
|
805
|
+
case "pwsh":
|
|
806
|
+
case "powershell":
|
|
807
|
+
return {
|
|
808
|
+
path: join3(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
|
|
809
|
+
content: `${MISE_HOOK_START}
|
|
810
|
+
(& ${command} activate pwsh) | Out-String | Invoke-Expression
|
|
811
|
+
${MISE_HOOK_END}
|
|
812
|
+
`
|
|
813
|
+
};
|
|
814
|
+
case "bash":
|
|
815
|
+
default:
|
|
816
|
+
return {
|
|
817
|
+
path: join3(homeDir, ".bashrc"),
|
|
818
|
+
content: `${MISE_HOOK_START}
|
|
819
|
+
eval "$(${command} activate bash)"
|
|
820
|
+
${MISE_HOOK_END}
|
|
821
|
+
`
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
function isToolInstalled(output, minimumVersion) {
|
|
826
|
+
try {
|
|
827
|
+
const value = JSON.parse(output);
|
|
828
|
+
if (!Array.isArray(value))
|
|
829
|
+
return false;
|
|
830
|
+
return value.some((entry) => {
|
|
831
|
+
if (!entry || typeof entry !== "object" || !("installed" in entry) || entry.installed !== true)
|
|
832
|
+
return false;
|
|
833
|
+
if (!minimumVersion)
|
|
834
|
+
return true;
|
|
835
|
+
if (!("version" in entry) || typeof entry.version !== "string")
|
|
836
|
+
return false;
|
|
837
|
+
return isVersionAtLeast(entry.version, minimumVersion);
|
|
838
|
+
});
|
|
839
|
+
} catch {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
function isVersionAtLeast(version, minimumVersion) {
|
|
844
|
+
const current = version.match(/^v?(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number);
|
|
845
|
+
const minimum = minimumVersion.match(/^v?(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number);
|
|
846
|
+
if (!current || !minimum)
|
|
847
|
+
return false;
|
|
848
|
+
for (let index = 0;index < minimum.length; index += 1) {
|
|
849
|
+
if (current[index] !== minimum[index])
|
|
850
|
+
return current[index] > minimum[index];
|
|
851
|
+
}
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
async function getOptionalFile(path) {
|
|
855
|
+
try {
|
|
856
|
+
return await readFile2(path, "utf8");
|
|
857
|
+
} catch (error) {
|
|
858
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
859
|
+
return "";
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
function getShellQuoted(value) {
|
|
864
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/gates.ts
|
|
868
|
+
var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
|
|
869
|
+
var MISE_GATES = ["format:check", "lint", "test"];
|
|
870
|
+
function checkConventionalSubject(subject) {
|
|
871
|
+
const trimmed = subject.trim();
|
|
872
|
+
const ok = CONVENTIONAL_COMMIT.test(trimmed);
|
|
873
|
+
return {
|
|
874
|
+
name: "conventional-subject",
|
|
875
|
+
status: ok ? "pass" : "warn",
|
|
876
|
+
detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
async function runMiseGates(cwd) {
|
|
880
|
+
const tasks = await discoverMiseTasks(cwd);
|
|
881
|
+
const results = [];
|
|
882
|
+
for (const gate of MISE_GATES) {
|
|
883
|
+
const targets = tasks.get(gate) ?? [];
|
|
884
|
+
if (targets.length === 0) {
|
|
885
|
+
results.push({ name: gate, status: "skip", detail: "no mise recipe" });
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
|
|
889
|
+
const result = await mise.run(["run", ...invocations], { cwd });
|
|
890
|
+
results.push({
|
|
891
|
+
name: gate,
|
|
892
|
+
status: result.code === 0 ? "pass" : "fail",
|
|
893
|
+
detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
return results;
|
|
897
|
+
}
|
|
898
|
+
function ciGate(checksOutput) {
|
|
899
|
+
const text = checksOutput.toLowerCase();
|
|
900
|
+
if (!text.trim())
|
|
901
|
+
return { name: "ci", status: "skip", detail: "no CI output" };
|
|
902
|
+
if (/\bfail|error\b/.test(text))
|
|
903
|
+
return { name: "ci", status: "warn", detail: "CI failing" };
|
|
904
|
+
if (/\bpending|in progress|queued\b/.test(text))
|
|
905
|
+
return { name: "ci", status: "warn", detail: "CI pending" };
|
|
906
|
+
return { name: "ci", status: "pass", detail: "CI green" };
|
|
907
|
+
}
|
|
908
|
+
function parseMiseTasks(input) {
|
|
909
|
+
let tasks;
|
|
910
|
+
try {
|
|
911
|
+
tasks = JSON.parse(input);
|
|
912
|
+
} catch {
|
|
913
|
+
return new Map;
|
|
914
|
+
}
|
|
915
|
+
if (!Array.isArray(tasks))
|
|
916
|
+
return new Map;
|
|
917
|
+
const found = new Map;
|
|
918
|
+
for (const gate of MISE_GATES) {
|
|
919
|
+
const targets = tasks.flatMap((task) => {
|
|
920
|
+
if (typeof task.name !== "string")
|
|
921
|
+
return [];
|
|
922
|
+
return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
|
|
923
|
+
});
|
|
924
|
+
if (targets.length > 0)
|
|
925
|
+
found.set(gate, [...new Set(targets)]);
|
|
926
|
+
}
|
|
927
|
+
return found;
|
|
928
|
+
}
|
|
929
|
+
async function discoverMiseTasks(cwd) {
|
|
930
|
+
const result = await mise.run(["tasks", "--json", "--all"], { cwd });
|
|
931
|
+
if (result.code !== 0)
|
|
932
|
+
return new Map;
|
|
933
|
+
return parseMiseTasks(result.stdout);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// src/review/types.ts
|
|
937
|
+
import { z as z3 } from "zod";
|
|
938
|
+
var severitySchema = z3.enum(["BLOCKING", "CONSIDER", "NOTE"]);
|
|
939
|
+
var findingSchema = z3.object({
|
|
940
|
+
file: z3.string().min(1),
|
|
941
|
+
line: z3.number().int().nonnegative(),
|
|
942
|
+
severity: severitySchema,
|
|
943
|
+
body: z3.string().min(1),
|
|
944
|
+
reference: z3.string().optional().default("")
|
|
945
|
+
});
|
|
946
|
+
var findingsSchema = z3.array(findingSchema);
|
|
947
|
+
function reviewSlug(input) {
|
|
948
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
|
|
949
|
+
}
|
|
950
|
+
function yymmdd(date = new Date) {
|
|
951
|
+
const yy = String(date.getFullYear() % 100).padStart(2, "0");
|
|
952
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
953
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
954
|
+
return `${yy}${mm}${dd}`;
|
|
955
|
+
}
|
|
956
|
+
function reviewRecordName(target, date = new Date) {
|
|
957
|
+
return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
|
|
958
|
+
}
|
|
959
|
+
function localResponseMarker(threadId) {
|
|
960
|
+
return `<!-- diffpi -->`;
|
|
961
|
+
}
|
|
962
|
+
function isLocalResponse(body) {
|
|
963
|
+
return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
|
|
964
|
+
}
|
|
965
|
+
function dedupeFindings(findings) {
|
|
966
|
+
const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
|
|
967
|
+
const byKey = new Map;
|
|
968
|
+
for (const finding of findings) {
|
|
969
|
+
const key = `${finding.file}:${finding.line}`;
|
|
970
|
+
const existing = byKey.get(key);
|
|
971
|
+
if (!existing || rank[finding.severity] > rank[existing.severity])
|
|
972
|
+
byKey.set(key, finding);
|
|
973
|
+
}
|
|
974
|
+
return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
|
|
975
|
+
}
|
|
976
|
+
function toReviewComments(findings, model) {
|
|
977
|
+
const comments = [];
|
|
978
|
+
for (const finding of findings) {
|
|
979
|
+
if (finding.line <= 0)
|
|
980
|
+
continue;
|
|
981
|
+
const body = renderCommentBody(finding);
|
|
982
|
+
comments.push({
|
|
983
|
+
file: finding.file,
|
|
984
|
+
line: finding.line,
|
|
985
|
+
side: "RIGHT",
|
|
986
|
+
body: model ? withRemoteProvenance(body, model) : body
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
return comments;
|
|
990
|
+
}
|
|
991
|
+
function withRemoteProvenance(body, model) {
|
|
992
|
+
const normalized = body.trimEnd();
|
|
993
|
+
if (/Generated review by Diffpi using `[^`]+`\.$/.test(normalized))
|
|
994
|
+
return body;
|
|
995
|
+
return `${normalized}
|
|
996
|
+
|
|
997
|
+
Generated review by Diffpi using \`${model}\`.`;
|
|
998
|
+
}
|
|
999
|
+
function localReviewAuthor(model) {
|
|
1000
|
+
return `Agent: ${model}`;
|
|
1001
|
+
}
|
|
1002
|
+
function renderCommentBody(finding) {
|
|
1003
|
+
const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
|
|
1004
|
+
const reference = finding.reference ? `
|
|
1005
|
+
|
|
1006
|
+
> **Reference:** ${finding.reference}` : "";
|
|
1007
|
+
return `${prefix}${finding.body}${reference}`;
|
|
1008
|
+
}
|
|
1009
|
+
// src/review/review-markdown.ts
|
|
1010
|
+
import { z as z4 } from "zod";
|
|
1011
|
+
var reviewThreadRecordSchema = z4.object({
|
|
1012
|
+
id: z4.string().min(1),
|
|
1013
|
+
file: z4.string().optional(),
|
|
1014
|
+
line: z4.number().int().positive().optional(),
|
|
1015
|
+
body: z4.string(),
|
|
1016
|
+
author: z4.string().optional(),
|
|
1017
|
+
resolved: z4.boolean(),
|
|
1018
|
+
addressed: z4.boolean().optional(),
|
|
1019
|
+
question: z4.boolean(),
|
|
1020
|
+
replies: z4.array(z4.string()).optional()
|
|
1021
|
+
});
|
|
1022
|
+
function renderReviewDoc(input) {
|
|
1023
|
+
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
1024
|
+
const lines = [`# Review: ${input.title}`, "", "## Metadata"];
|
|
1025
|
+
if (input.number !== undefined)
|
|
1026
|
+
lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
|
|
1027
|
+
if (input.author)
|
|
1028
|
+
lines.push(`- **Author**: ${input.author}`);
|
|
1029
|
+
if (input.model)
|
|
1030
|
+
lines.push(`- **Review agent**: ${input.model}`);
|
|
1031
|
+
if (input.headRef && input.baseRef)
|
|
1032
|
+
lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
|
|
1033
|
+
if (input.additions !== undefined)
|
|
1034
|
+
lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
|
|
1035
|
+
lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
|
|
1036
|
+
if (input.overallIssues.length > 0) {
|
|
1037
|
+
lines.push("## Overall issues", "");
|
|
1038
|
+
for (const issue of input.overallIssues)
|
|
1039
|
+
lines.push(`- ${issue}`);
|
|
1040
|
+
lines.push("");
|
|
1041
|
+
}
|
|
1042
|
+
lines.push("## Verification", "");
|
|
1043
|
+
for (const gate of input.gates)
|
|
1044
|
+
lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
|
|
1045
|
+
lines.push("");
|
|
1046
|
+
if (input.notVerified.length > 0) {
|
|
1047
|
+
lines.push("## What was NOT verified", "");
|
|
1048
|
+
for (const item of input.notVerified)
|
|
1049
|
+
lines.push(`- ${item}`);
|
|
1050
|
+
lines.push("");
|
|
1051
|
+
}
|
|
1052
|
+
lines.push("## Inline Comments", "");
|
|
1053
|
+
for (const finding of anchored) {
|
|
1054
|
+
lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
|
|
1055
|
+
if (finding.reference)
|
|
1056
|
+
lines.push(`> **Reference:** ${finding.reference}`, "");
|
|
1057
|
+
lines.push("---", "");
|
|
1058
|
+
}
|
|
1059
|
+
return `${lines.join(`
|
|
1060
|
+
`).trimEnd()}
|
|
1061
|
+
`;
|
|
1062
|
+
}
|
|
1063
|
+
function renderThreadArtifact(title, target, threads, options = {}) {
|
|
1064
|
+
const records = threads.map((thread) => ({
|
|
1065
|
+
id: thread.id,
|
|
1066
|
+
file: thread.file,
|
|
1067
|
+
line: thread.line,
|
|
1068
|
+
body: thread.body,
|
|
1069
|
+
author: thread.author,
|
|
1070
|
+
resolved: thread.resolved,
|
|
1071
|
+
addressed: thread.addressed,
|
|
1072
|
+
question: thread.question,
|
|
1073
|
+
replies: thread.replies
|
|
1074
|
+
}));
|
|
1075
|
+
const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
|
|
1076
|
+
const lines = [
|
|
1077
|
+
`<!-- diffpi-threads:${payload} -->`,
|
|
1078
|
+
`# Review threads: ${title}`,
|
|
1079
|
+
"",
|
|
1080
|
+
"## Metadata",
|
|
1081
|
+
"",
|
|
1082
|
+
`- Target: ${target}`,
|
|
1083
|
+
`- Pulled: ${options.timestamp ?? new Date().toISOString()}`
|
|
1084
|
+
];
|
|
1085
|
+
if (options.number !== undefined)
|
|
1086
|
+
lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
|
|
1087
|
+
lines.push("", "## Replies", "");
|
|
1088
|
+
for (const thread of threads) {
|
|
1089
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
1090
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
|
|
1091
|
+
}
|
|
1092
|
+
lines.push("## Source comments", "");
|
|
1093
|
+
for (const thread of threads) {
|
|
1094
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, `Status: ${thread.resolved ? "resolved" : thread.addressed ? "addressed" : "open"}`, "", thread.body, "", "---", "");
|
|
1095
|
+
}
|
|
1096
|
+
return `${lines.join(`
|
|
1097
|
+
`).trimEnd()}
|
|
1098
|
+
`;
|
|
1099
|
+
}
|
|
1100
|
+
function parseThreadArtifact(content) {
|
|
1101
|
+
const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
|
|
1102
|
+
if (!payload)
|
|
1103
|
+
throw new Error("This file is not a Diffpi thread artifact.");
|
|
1104
|
+
let threads;
|
|
1105
|
+
try {
|
|
1106
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1107
|
+
threads = z4.array(reviewThreadRecordSchema).parse(decoded);
|
|
1108
|
+
} catch {
|
|
1109
|
+
throw new Error("Cannot parse the Diffpi thread artifact payload.");
|
|
1110
|
+
}
|
|
1111
|
+
const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
|
|
1112
|
+
return threads.map((thread) => {
|
|
1113
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
1114
|
+
const startMarker = `<!-- diffpi-reply-start:${id} -->
|
|
1115
|
+
`;
|
|
1116
|
+
const endMarker = `
|
|
1117
|
+
<!-- diffpi-reply-end:${id} -->`;
|
|
1118
|
+
const start = replies.indexOf(startMarker);
|
|
1119
|
+
const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
|
|
1120
|
+
const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
|
|
1121
|
+
return reply ? { ...thread, reply } : thread;
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
function upsertThreadReply(content, threadId, body, question, resolved) {
|
|
1125
|
+
const threads = parseThreadArtifact(content);
|
|
1126
|
+
const thread = threads.find((candidate) => candidate.id === threadId);
|
|
1127
|
+
if (!thread)
|
|
1128
|
+
throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
|
|
1129
|
+
thread.reply = body;
|
|
1130
|
+
thread.addressed = true;
|
|
1131
|
+
if (question !== undefined)
|
|
1132
|
+
thread.question = question;
|
|
1133
|
+
if (resolved !== undefined)
|
|
1134
|
+
thread.resolved = resolved;
|
|
1135
|
+
const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
|
|
1136
|
+
const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
|
|
1137
|
+
const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
|
|
1138
|
+
const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
|
|
1139
|
+
return renderThreadArtifact(title, target, threads, {
|
|
1140
|
+
timestamp,
|
|
1141
|
+
number: pr ? Number.parseInt(pr[1], 10) : undefined,
|
|
1142
|
+
url: pr?.[2]
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
// src/review/github-review-backend.ts
|
|
1146
|
+
function GitHubReviewBackend(vcs, number) {
|
|
1147
|
+
const pendingReview = async () => {
|
|
1148
|
+
const result = await ghChecked([
|
|
1149
|
+
"api",
|
|
1150
|
+
`/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews`,
|
|
1151
|
+
"--jq",
|
|
1152
|
+
'[.[] | select(.state=="PENDING")] | last | {id: (.id | tostring), nodeId: .node_id}'
|
|
1153
|
+
]);
|
|
1154
|
+
if (!result.stdout.trim())
|
|
1155
|
+
return;
|
|
1156
|
+
let pending;
|
|
1157
|
+
try {
|
|
1158
|
+
pending = JSON.parse(result.stdout);
|
|
1159
|
+
} catch {
|
|
1160
|
+
throw new Error("Cannot parse the pending GitHub review identifier as JSON.");
|
|
1161
|
+
}
|
|
1162
|
+
if (!pending.id || !pending.nodeId)
|
|
1163
|
+
return;
|
|
1164
|
+
return { id: pending.id, nodeId: pending.nodeId };
|
|
1165
|
+
};
|
|
1166
|
+
const listThreads = async () => {
|
|
1167
|
+
const threads = [];
|
|
1168
|
+
let cursor;
|
|
1169
|
+
do {
|
|
1170
|
+
const args = [
|
|
1171
|
+
"api",
|
|
1172
|
+
"graphql",
|
|
1173
|
+
"-f",
|
|
1174
|
+
`query=${GITHUB_THREADS_QUERY}`,
|
|
1175
|
+
"-f",
|
|
1176
|
+
`owner=${vcs.owner}`,
|
|
1177
|
+
"-f",
|
|
1178
|
+
`repo=${vcs.repo}`,
|
|
1179
|
+
"-F",
|
|
1180
|
+
`number=${number}`
|
|
1181
|
+
];
|
|
1182
|
+
if (cursor)
|
|
1183
|
+
args.push("-f", `after=${cursor}`);
|
|
1184
|
+
const result = await ghChecked(args);
|
|
1185
|
+
let data;
|
|
1186
|
+
try {
|
|
1187
|
+
data = JSON.parse(result.stdout);
|
|
1188
|
+
} catch {
|
|
1189
|
+
throw new Error("Cannot parse GitHub review threads as JSON.");
|
|
1190
|
+
}
|
|
1191
|
+
const connection = data.data?.repository?.pullRequest?.reviewThreads;
|
|
1192
|
+
threads.push(...connection?.nodes ?? []);
|
|
1193
|
+
cursor = connection?.pageInfo?.hasNextPage ? connection.pageInfo.endCursor : undefined;
|
|
1194
|
+
if (connection?.pageInfo?.hasNextPage && !cursor) {
|
|
1195
|
+
throw new Error("GitHub review thread pagination did not return an end cursor.");
|
|
1196
|
+
}
|
|
1197
|
+
} while (cursor);
|
|
1198
|
+
return threads.map((thread) => {
|
|
1199
|
+
const nodes = thread.comments?.nodes ?? [];
|
|
1200
|
+
const comment = nodes[0];
|
|
1201
|
+
const body = comment?.body ?? "";
|
|
1202
|
+
return {
|
|
1203
|
+
id: thread.id,
|
|
1204
|
+
file: thread.path,
|
|
1205
|
+
line: thread.line,
|
|
1206
|
+
body,
|
|
1207
|
+
author: comment?.author?.login,
|
|
1208
|
+
resolved: thread.isResolved,
|
|
1209
|
+
question: /\?\s*$/.test(body.trim()),
|
|
1210
|
+
replies: nodes.slice(1).map((node) => node.body)
|
|
1211
|
+
};
|
|
1212
|
+
});
|
|
1213
|
+
};
|
|
1214
|
+
return {
|
|
1215
|
+
kind: "remote",
|
|
1216
|
+
async stage(draft) {
|
|
1217
|
+
const pending = await pendingReview();
|
|
1218
|
+
if (!pending) {
|
|
1219
|
+
const payload = {
|
|
1220
|
+
body: draft.body,
|
|
1221
|
+
comments: draft.comments.map((comment) => ({
|
|
1222
|
+
path: comment.file,
|
|
1223
|
+
line: comment.line,
|
|
1224
|
+
side: comment.side ?? "RIGHT",
|
|
1225
|
+
body: comment.body
|
|
1226
|
+
}))
|
|
1227
|
+
};
|
|
1228
|
+
await ghChecked(["api", "--method", "POST", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews`, "--input", "-"], { input: JSON.stringify(payload) });
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
if (draft.body.trim()) {
|
|
1232
|
+
await ghChecked([
|
|
1233
|
+
"api",
|
|
1234
|
+
"--method",
|
|
1235
|
+
"PUT",
|
|
1236
|
+
`/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`,
|
|
1237
|
+
"-f",
|
|
1238
|
+
`body=${draft.body}`
|
|
1239
|
+
]);
|
|
1240
|
+
}
|
|
1241
|
+
for (const comment of draft.comments) {
|
|
1242
|
+
await ghChecked([
|
|
1243
|
+
"api",
|
|
1244
|
+
"graphql",
|
|
1245
|
+
"-f",
|
|
1246
|
+
`query=${GITHUB_ADD_THREAD_MUTATION}`,
|
|
1247
|
+
"-f",
|
|
1248
|
+
`reviewId=${pending.nodeId}`,
|
|
1249
|
+
"-f",
|
|
1250
|
+
`body=${comment.body}`,
|
|
1251
|
+
"-f",
|
|
1252
|
+
`path=${comment.file}`,
|
|
1253
|
+
"-F",
|
|
1254
|
+
`line=${comment.line}`,
|
|
1255
|
+
"-f",
|
|
1256
|
+
`side=${comment.side ?? "RIGHT"}`
|
|
1257
|
+
]);
|
|
1258
|
+
}
|
|
1259
|
+
},
|
|
1260
|
+
async readDraft() {
|
|
1261
|
+
const pending = await pendingReview();
|
|
1262
|
+
if (!pending)
|
|
1263
|
+
return { comments: [], body: "" };
|
|
1264
|
+
const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
|
|
1265
|
+
const comments = await ghChecked([
|
|
1266
|
+
"api",
|
|
1267
|
+
`/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
|
|
1268
|
+
]);
|
|
1269
|
+
let reviewData;
|
|
1270
|
+
let commentData;
|
|
1271
|
+
try {
|
|
1272
|
+
reviewData = JSON.parse(review.stdout);
|
|
1273
|
+
commentData = JSON.parse(comments.stdout);
|
|
1274
|
+
} catch {
|
|
1275
|
+
throw new Error("Cannot parse the pending GitHub review as JSON.");
|
|
1276
|
+
}
|
|
1277
|
+
return {
|
|
1278
|
+
body: reviewData.body ?? "",
|
|
1279
|
+
comments: commentData.map((comment) => ({
|
|
1280
|
+
file: comment.path,
|
|
1281
|
+
line: comment.line ?? comment.original_line ?? 1,
|
|
1282
|
+
side: comment.side,
|
|
1283
|
+
body: comment.body
|
|
1284
|
+
}))
|
|
1285
|
+
};
|
|
1286
|
+
},
|
|
1287
|
+
listThreads,
|
|
1288
|
+
async reply(input) {
|
|
1289
|
+
const threads = await listThreads();
|
|
1290
|
+
const existing = threads.find((thread) => thread.id === input.threadId);
|
|
1291
|
+
if (!existing?.replies?.includes(input.body)) {
|
|
1292
|
+
await ghChecked([
|
|
1293
|
+
"api",
|
|
1294
|
+
"graphql",
|
|
1295
|
+
"-f",
|
|
1296
|
+
`query=${GITHUB_REPLY_MUTATION}`,
|
|
1297
|
+
"-f",
|
|
1298
|
+
`threadId=${input.threadId}`,
|
|
1299
|
+
"-f",
|
|
1300
|
+
`body=${input.body}`
|
|
1301
|
+
]);
|
|
1302
|
+
}
|
|
1303
|
+
if (input.resolve) {
|
|
1304
|
+
await ghChecked([
|
|
1305
|
+
"api",
|
|
1306
|
+
"graphql",
|
|
1307
|
+
"-f",
|
|
1308
|
+
`query=${GITHUB_RESOLVE_MUTATION}`,
|
|
1309
|
+
"-f",
|
|
1310
|
+
`threadId=${input.threadId}`
|
|
1311
|
+
]);
|
|
1312
|
+
}
|
|
1313
|
+
},
|
|
1314
|
+
async publish(event) {
|
|
1315
|
+
const pending = await pendingReview();
|
|
1316
|
+
if (!pending && event === "COMMENT")
|
|
1317
|
+
return;
|
|
1318
|
+
if (!pending && event === "REQUEST_CHANGES") {
|
|
1319
|
+
throw new Error("GitHub requires pending comments before publishing a request-changes review without a body.");
|
|
1320
|
+
}
|
|
1321
|
+
const endpoint = githubReviewSubmissionEndpoint(vcs.owner, vcs.repo, number, pending?.id ?? "");
|
|
1322
|
+
await ghChecked(["api", "--method", "POST", endpoint, "-f", `event=${event}`]);
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
|
|
1327
|
+
return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
|
|
1328
|
+
}
|
|
1329
|
+
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}}}}}`;
|
|
1330
|
+
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}}}`;
|
|
1331
|
+
var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
|
|
1332
|
+
var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
|
|
1333
|
+
|
|
1334
|
+
// src/review/gitlab-review-backend.ts
|
|
1335
|
+
function GitLabReviewBackend(vcs, number) {
|
|
1336
|
+
const project = () => `${vcs.owner}/${vcs.repo}`;
|
|
1337
|
+
const mergeRequestEndpoint = () => `projects/${encodeURIComponent(project())}/merge_requests/${number}`;
|
|
1338
|
+
const listThreads = async () => {
|
|
1339
|
+
const discussions = [];
|
|
1340
|
+
for (let page = 1;; page += 1) {
|
|
1341
|
+
const result = await glabChecked(["api", `${mergeRequestEndpoint()}/discussions?per_page=100&page=${page}`]);
|
|
1342
|
+
let batch;
|
|
1343
|
+
try {
|
|
1344
|
+
batch = JSON.parse(result.stdout);
|
|
1345
|
+
} catch {
|
|
1346
|
+
throw new Error("Cannot parse GitLab review discussions as JSON.");
|
|
1347
|
+
}
|
|
1348
|
+
discussions.push(...batch);
|
|
1349
|
+
if (batch.length < 100)
|
|
1350
|
+
break;
|
|
1351
|
+
}
|
|
1352
|
+
return discussions.map((discussion) => {
|
|
1353
|
+
const notes = discussion.notes ?? [];
|
|
1354
|
+
const note = notes[0];
|
|
1355
|
+
const body = note?.body ?? "";
|
|
1356
|
+
return {
|
|
1357
|
+
id: discussion.id,
|
|
1358
|
+
file: note?.position?.new_path ?? note?.position?.old_path,
|
|
1359
|
+
line: note?.position?.new_line ?? note?.position?.old_line,
|
|
1360
|
+
body,
|
|
1361
|
+
author: note?.author?.username,
|
|
1362
|
+
resolved: Boolean(discussion.resolved),
|
|
1363
|
+
question: /\?\s*$/.test(body.trim()),
|
|
1364
|
+
replies: notes.slice(1).map((reply) => reply.body)
|
|
1365
|
+
};
|
|
1366
|
+
});
|
|
1367
|
+
};
|
|
1368
|
+
return {
|
|
1369
|
+
kind: "remote",
|
|
1370
|
+
async stage(draft) {
|
|
1371
|
+
const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
|
|
1372
|
+
if (draft.body.trim()) {
|
|
1373
|
+
await glabChecked(["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
1374
|
+
input: JSON.stringify({ note: draft.body })
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
if (draft.comments.length === 0)
|
|
1378
|
+
return;
|
|
1379
|
+
const response = await glabChecked(["api", mergeRequestEndpoint()]);
|
|
1380
|
+
const diffRefs = parseGitlabDiffRefs(response.stdout);
|
|
1381
|
+
for (const comment of draft.comments) {
|
|
1382
|
+
const payload = {
|
|
1383
|
+
note: comment.body,
|
|
1384
|
+
position: {
|
|
1385
|
+
...diffRefs,
|
|
1386
|
+
position_type: "text",
|
|
1387
|
+
new_path: comment.file,
|
|
1388
|
+
old_path: comment.file,
|
|
1389
|
+
new_line: comment.side === "LEFT" ? undefined : comment.line,
|
|
1390
|
+
old_line: comment.side === "LEFT" ? comment.line : undefined
|
|
1391
|
+
}
|
|
1392
|
+
};
|
|
1393
|
+
await glabChecked(["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
1394
|
+
input: JSON.stringify(payload)
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
},
|
|
1398
|
+
async readDraft() {
|
|
1399
|
+
const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
|
|
1400
|
+
let notes;
|
|
1401
|
+
try {
|
|
1402
|
+
notes = JSON.parse(result.stdout);
|
|
1403
|
+
} catch {
|
|
1404
|
+
throw new Error("Cannot parse GitLab draft notes as JSON.");
|
|
1405
|
+
}
|
|
1406
|
+
const comments = [];
|
|
1407
|
+
const body = [];
|
|
1408
|
+
for (const note of notes) {
|
|
1409
|
+
if (!note.note)
|
|
1410
|
+
continue;
|
|
1411
|
+
const file = note.position?.new_path ?? note.position?.old_path;
|
|
1412
|
+
const line = note.position?.new_line ?? note.position?.old_line;
|
|
1413
|
+
if (file && line) {
|
|
1414
|
+
comments.push({
|
|
1415
|
+
file,
|
|
1416
|
+
line,
|
|
1417
|
+
side: note.position?.new_line ? "RIGHT" : "LEFT",
|
|
1418
|
+
body: note.note
|
|
1419
|
+
});
|
|
1420
|
+
} else {
|
|
1421
|
+
body.push(note.note);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
return { comments, body: body.join(`
|
|
1425
|
+
|
|
1426
|
+
`) };
|
|
1427
|
+
},
|
|
1428
|
+
listThreads,
|
|
1429
|
+
async reply(input) {
|
|
1430
|
+
const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
|
|
1431
|
+
const threads = await listThreads();
|
|
1432
|
+
const existing = threads.find((thread) => thread.id === input.threadId);
|
|
1433
|
+
if (!existing?.replies?.includes(input.body)) {
|
|
1434
|
+
await glabChecked(["api", "--method", "POST", `${endpoint}/notes`, "--input", "-"], {
|
|
1435
|
+
input: JSON.stringify({ body: input.body })
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
if (input.resolve)
|
|
1439
|
+
await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=true`]);
|
|
1440
|
+
},
|
|
1441
|
+
async publish(event) {
|
|
1442
|
+
assertReviewEventSupported(vcs.provider, event);
|
|
1443
|
+
const drafts = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
|
|
1444
|
+
if (hasGitlabDraftNotes(drafts.stdout)) {
|
|
1445
|
+
await glabChecked(["api", "--method", "POST", `${mergeRequestEndpoint()}/draft_notes/bulk_publish`]);
|
|
1446
|
+
}
|
|
1447
|
+
if (event === "APPROVE")
|
|
1448
|
+
await glabChecked(["mr", "approve", String(number), "--repo", project()]);
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
function assertReviewEventSupported(provider, event) {
|
|
1453
|
+
if (provider === "gitlab" && event === "REQUEST_CHANGES") {
|
|
1454
|
+
throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
function parseGitlabDiffRefs(input) {
|
|
1458
|
+
let data;
|
|
1459
|
+
try {
|
|
1460
|
+
data = JSON.parse(input);
|
|
1461
|
+
} catch {
|
|
1462
|
+
throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
|
|
1463
|
+
}
|
|
1464
|
+
const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
|
|
1465
|
+
if (!base_sha || !start_sha || !head_sha) {
|
|
1466
|
+
throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
|
|
1467
|
+
}
|
|
1468
|
+
return { base_sha, start_sha, head_sha };
|
|
1469
|
+
}
|
|
1470
|
+
function hasGitlabDraftNotes(input) {
|
|
1471
|
+
try {
|
|
1472
|
+
const data = JSON.parse(input);
|
|
1473
|
+
return Array.isArray(data) && data.length > 0;
|
|
1474
|
+
} catch {
|
|
1475
|
+
throw new Error("Cannot publish the GitLab review: the draft notes response was not valid JSON.");
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// src/review/local-review-backend.ts
|
|
1480
|
+
import { readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
|
|
1481
|
+
|
|
1482
|
+
// src/extensions/tuicrx.ts
|
|
1483
|
+
import { readFile as readFile3, realpath as realpath2 } from "node:fs/promises";
|
|
1484
|
+
import { resolve as resolve2 } from "node:path";
|
|
1485
|
+
|
|
1486
|
+
// src/extensions/gitx.ts
|
|
1487
|
+
import { realpath } from "node:fs/promises";
|
|
1488
|
+
import { basename as basename3, isAbsolute, join as join4, resolve } from "node:path";
|
|
1489
|
+
async function gitToplevel(cwd) {
|
|
1490
|
+
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
1491
|
+
const top = result.stdout.trim();
|
|
1492
|
+
return result.code === 0 && top ? top : resolve(cwd);
|
|
1493
|
+
}
|
|
1494
|
+
async function inspectGitRepository(cwd) {
|
|
1495
|
+
const root = await gitToplevel(cwd);
|
|
1496
|
+
const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
1497
|
+
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
1498
|
+
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
1499
|
+
const common = commonResult.stdout.trim();
|
|
1500
|
+
const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join4(root, common)) : root;
|
|
1501
|
+
const commonDir = await canonicalPath(commonPath);
|
|
1502
|
+
return {
|
|
1503
|
+
root,
|
|
1504
|
+
commonDir,
|
|
1505
|
+
...remote ? { remote } : {},
|
|
1506
|
+
name: remote ? repositoryName(remote) : basename3(resolve(commonDir, "..")) || basename3(root),
|
|
1507
|
+
identity: remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${commonDir}`
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
function normalizeRemote(remote) {
|
|
1511
|
+
return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
1512
|
+
}
|
|
1513
|
+
function repositoryName(remote) {
|
|
1514
|
+
const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
1515
|
+
return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
|
|
1516
|
+
}
|
|
1517
|
+
async function canonicalPath(path) {
|
|
1518
|
+
try {
|
|
1519
|
+
return await realpath(path);
|
|
1520
|
+
} catch {
|
|
1521
|
+
return resolve(path);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// src/extensions/tuicrx.ts
|
|
1526
|
+
async function tuicrAvailable() {
|
|
1527
|
+
return Boolean(await findExecutable("tuicr"));
|
|
1528
|
+
}
|
|
1529
|
+
async function listSessions(repo = ".") {
|
|
1530
|
+
await requireTuicr();
|
|
1531
|
+
const result = await run("tuicr", ["review", "list", "--repo", repo]);
|
|
1532
|
+
if (result.code !== 0 || !result.stdout.trim())
|
|
1533
|
+
return [];
|
|
1534
|
+
try {
|
|
1535
|
+
const raw = JSON.parse(result.stdout);
|
|
1536
|
+
return raw.map((entry) => ({
|
|
1537
|
+
slug: entry.slug,
|
|
1538
|
+
kind: entry.kind,
|
|
1539
|
+
path: entry.path,
|
|
1540
|
+
updatedAt: entry.updated_at,
|
|
1541
|
+
commentCount: entry.comment_count,
|
|
1542
|
+
anchor: entry.anchor,
|
|
1543
|
+
active: entry.active
|
|
1544
|
+
}));
|
|
1545
|
+
} catch {
|
|
1546
|
+
throw new Error("Cannot parse tuicr review list output.");
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
async function addComment(session, body, opts = {}) {
|
|
1550
|
+
await requireTuicr();
|
|
1551
|
+
const args = ["review", "add", "--session", session, body];
|
|
1552
|
+
if (opts.targetFile)
|
|
1553
|
+
args.push("--target-file", opts.targetFile);
|
|
1554
|
+
if (opts.line !== undefined)
|
|
1555
|
+
args.push("--line", String(opts.line));
|
|
1556
|
+
if (opts.side)
|
|
1557
|
+
args.push("--side", opts.side);
|
|
1558
|
+
if (opts.username)
|
|
1559
|
+
args.push("--username", opts.username);
|
|
1560
|
+
await runChecked("tuicr", args);
|
|
1561
|
+
}
|
|
1562
|
+
async function launch(cwd, pr, localBase) {
|
|
1563
|
+
let command;
|
|
1564
|
+
if (pr !== undefined)
|
|
1565
|
+
command = ["tuicr", "pr", String(pr)];
|
|
1566
|
+
else if (localBase)
|
|
1567
|
+
command = ["tuicr", "-w", "-r", `${localBase}..HEAD`];
|
|
1568
|
+
else
|
|
1569
|
+
throw new Error("Cannot launch a local tuicr review: no base branch could be determined.");
|
|
1570
|
+
if (!await tuicrAvailable()) {
|
|
1571
|
+
return {
|
|
1572
|
+
launched: false,
|
|
1573
|
+
via: "print",
|
|
1574
|
+
command: command.join(" "),
|
|
1575
|
+
reason: "tuicr is not installed or is not available on PATH.",
|
|
1576
|
+
instruction: `Install tuicr, then run: ${command.join(" ")}`
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
return openInNewTab(command, { cwd, name: "tuicr" });
|
|
1580
|
+
}
|
|
1581
|
+
async function resolveSession(cwd, branch) {
|
|
1582
|
+
const sessions = await listSessions(cwd);
|
|
1583
|
+
return findMatchingSession(sessions, cwd, branch);
|
|
1584
|
+
}
|
|
1585
|
+
async function resolveReviewSession(cwd, target) {
|
|
1586
|
+
if (!target.workingTree && target.owner && target.repo && target.number !== undefined) {
|
|
1587
|
+
return resolvePrSession(cwd, target.owner, target.repo, target.number);
|
|
1588
|
+
}
|
|
1589
|
+
return resolveSession(cwd, target.branch);
|
|
1590
|
+
}
|
|
1591
|
+
async function resolvePrSession(cwd, owner, repo, number) {
|
|
1592
|
+
const coordinate = `${owner}/${repo}`.toLowerCase();
|
|
1593
|
+
const sessions = await listSessions(cwd);
|
|
1594
|
+
return sessions.find((session) => {
|
|
1595
|
+
const slug = session.slug.toLowerCase();
|
|
1596
|
+
return session.kind === "pr" && slug.includes(coordinate) && (slug.endsWith(`/pr/${number}`) || slug.endsWith(`/mr/${number}`));
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
async function findMatchingSession(sessions, cwd, branch) {
|
|
1600
|
+
const repository = await canonicalPath2(await gitToplevel(cwd));
|
|
1601
|
+
for (const session of sessions) {
|
|
1602
|
+
if (session.kind !== "local")
|
|
1603
|
+
continue;
|
|
1604
|
+
try {
|
|
1605
|
+
const data = await readSession(session.path);
|
|
1606
|
+
if (data.branch_name !== branch || !data.repo_path)
|
|
1607
|
+
continue;
|
|
1608
|
+
if (await canonicalPath2(data.repo_path) === repository)
|
|
1609
|
+
return session;
|
|
1610
|
+
} catch {}
|
|
1611
|
+
}
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
async function readSession(path) {
|
|
1615
|
+
const content = await readFile3(path, "utf8");
|
|
1616
|
+
try {
|
|
1617
|
+
return JSON.parse(content);
|
|
1618
|
+
} catch {
|
|
1619
|
+
throw new Error(`Cannot parse tuicr session JSON: ${path}`);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
function toLocalReviewThreads(session) {
|
|
1623
|
+
const threads = [];
|
|
1624
|
+
const add = (comment, location = {}) => {
|
|
1625
|
+
if (isLocalResponse(comment.content))
|
|
1626
|
+
return;
|
|
1627
|
+
const author = commentAuthor(comment);
|
|
1628
|
+
threads.push({
|
|
1629
|
+
id: comment.id ?? `local-${threads.length + 1}`,
|
|
1630
|
+
...location,
|
|
1631
|
+
body: comment.content,
|
|
1632
|
+
...author ? { author } : {},
|
|
1633
|
+
resolved: false,
|
|
1634
|
+
question: /\?\s*$/.test(comment.content.trim())
|
|
1635
|
+
});
|
|
1636
|
+
};
|
|
1637
|
+
for (const comment of session.review_comments ?? [])
|
|
1638
|
+
add(comment);
|
|
1639
|
+
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
1640
|
+
for (const comment of entry.file_comments ?? [])
|
|
1641
|
+
add(comment, { file });
|
|
1642
|
+
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
1643
|
+
const line = Number.parseInt(lineKey, 10);
|
|
1644
|
+
if (!Number.isFinite(line))
|
|
1645
|
+
continue;
|
|
1646
|
+
for (const comment of lineComments)
|
|
1647
|
+
add(comment, { file, line });
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
return threads;
|
|
1651
|
+
}
|
|
1652
|
+
function toFindings(session, options = {}) {
|
|
1653
|
+
const comments = [];
|
|
1654
|
+
const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
|
|
1655
|
+
const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
|
|
1656
|
+
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
1657
|
+
const fileComments = (entry.file_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
|
|
1658
|
+
if (fileComments.length > 0)
|
|
1659
|
+
bodyParts.push(`File: ${file}
|
|
1660
|
+
|
|
1661
|
+
${fileComments.join(`
|
|
1662
|
+
|
|
1663
|
+
`)}`);
|
|
1664
|
+
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
1665
|
+
const line = Number.parseInt(lineKey, 10);
|
|
1666
|
+
if (!Number.isFinite(line))
|
|
1667
|
+
continue;
|
|
1668
|
+
for (const lineComment of lineComments) {
|
|
1669
|
+
if (!include(lineComment))
|
|
1670
|
+
continue;
|
|
1671
|
+
const comment = {
|
|
1672
|
+
file,
|
|
1673
|
+
line,
|
|
1674
|
+
side: lineComment.side === "old" ? "LEFT" : "RIGHT",
|
|
1675
|
+
body: lineComment.content
|
|
1676
|
+
};
|
|
1677
|
+
const author = commentAuthor(lineComment);
|
|
1678
|
+
if (author)
|
|
1679
|
+
comment.author = author;
|
|
1680
|
+
comments.push(comment);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
return { comments, body: bodyParts.join(`
|
|
1685
|
+
|
|
1686
|
+
`) };
|
|
1687
|
+
}
|
|
1688
|
+
async function requireTuicr() {
|
|
1689
|
+
if (!await tuicrAvailable())
|
|
1690
|
+
throw new Error("tuicr is not installed or is not available on PATH.");
|
|
1691
|
+
}
|
|
1692
|
+
function commentAuthor(comment) {
|
|
1693
|
+
return comment.username ?? comment.author;
|
|
1694
|
+
}
|
|
1695
|
+
async function canonicalPath2(path) {
|
|
1696
|
+
try {
|
|
1697
|
+
return await realpath2(path);
|
|
1698
|
+
} catch {
|
|
1699
|
+
return resolve2(path);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// src/review/local-review-backend.ts
|
|
1704
|
+
function LocalReviewBackend(options) {
|
|
1705
|
+
return {
|
|
1706
|
+
kind: "local",
|
|
1707
|
+
async stage(draft) {
|
|
1708
|
+
for (const comment of draft.comments) {
|
|
1709
|
+
await addComment(options.session, comment.body, {
|
|
1710
|
+
targetFile: comment.file,
|
|
1711
|
+
line: comment.line,
|
|
1712
|
+
side: comment.side === "LEFT" ? "old" : "new",
|
|
1713
|
+
username: options.author
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
if (draft.body.trim())
|
|
1717
|
+
await addComment(options.session, draft.body, { username: options.author });
|
|
1718
|
+
},
|
|
1719
|
+
async readDraft() {
|
|
1720
|
+
return toFindings(await readSession(options.session), { agentOnly: true, excludeLocalResponses: true });
|
|
1721
|
+
},
|
|
1722
|
+
async listThreads() {
|
|
1723
|
+
try {
|
|
1724
|
+
return parseThreadArtifact(await readFile4(options.artifactPath, "utf8"));
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
if (error.code === "ENOENT")
|
|
1727
|
+
return [];
|
|
1728
|
+
throw error;
|
|
1729
|
+
}
|
|
1730
|
+
},
|
|
1731
|
+
async reply(input) {
|
|
1732
|
+
const content = await readFile4(options.artifactPath, "utf8");
|
|
1733
|
+
await writeFile3(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
|
|
1734
|
+
},
|
|
1735
|
+
async publish() {
|
|
1736
|
+
throw new Error("Promote a local draft through a remote review backend before publishing it.");
|
|
1737
|
+
}
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// src/review/review-backend.ts
|
|
1742
|
+
function createRemoteReviewBackend(vcs, number) {
|
|
1743
|
+
if (vcs.provider === "github")
|
|
1744
|
+
return GitHubReviewBackend(vcs, number);
|
|
1745
|
+
if (vcs.provider === "gitlab")
|
|
1746
|
+
return GitLabReviewBackend(vcs, number);
|
|
1747
|
+
throw new Error("A remote review backend requires a GitHub or GitLab repository.");
|
|
1748
|
+
}
|
|
1749
|
+
function createLocalReviewBackend(options) {
|
|
1750
|
+
return LocalReviewBackend(options);
|
|
1751
|
+
}
|
|
1752
|
+
// src/review/review-state.ts
|
|
1753
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1754
|
+
import { readFile as readFile5, rename, writeFile as writeFile4 } from "node:fs/promises";
|
|
1755
|
+
import { join as join6 } from "node:path";
|
|
1756
|
+
import { z as z5 } from "zod";
|
|
1757
|
+
|
|
1758
|
+
// src/store.ts
|
|
1759
|
+
import { createHash } from "node:crypto";
|
|
1760
|
+
import { lstat, mkdir as mkdir3, readlink, realpath as realpath3, symlink, unlink } from "node:fs/promises";
|
|
1761
|
+
import { homedir as homedir3 } from "node:os";
|
|
1762
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join5, resolve as resolve3 } from "node:path";
|
|
1763
|
+
var STORE_LINK = ".diffpi";
|
|
1764
|
+
var LEGACY_STORE_LINK = join5(".pi", "diffpi");
|
|
1765
|
+
function storeGlobalRoot(homeDir = homedir3()) {
|
|
1766
|
+
return join5(homeDir, ".difflab", "diffpi", "projects");
|
|
1767
|
+
}
|
|
1768
|
+
async function ensureStore(cwd, homeDir = homedir3()) {
|
|
1769
|
+
const repository = await inspectGitRepository(cwd);
|
|
1770
|
+
const root = repository.root;
|
|
1771
|
+
const slug = projectSlug(repository);
|
|
1772
|
+
const dest = join5(storeGlobalRoot(homeDir), slug);
|
|
1773
|
+
const link = join5(root, STORE_LINK);
|
|
1774
|
+
await mkdir3(dest, { recursive: true });
|
|
1775
|
+
try {
|
|
1776
|
+
await assertStoreLink(link, dest);
|
|
1777
|
+
} catch (error) {
|
|
1778
|
+
if (error.code !== "ENOENT")
|
|
1779
|
+
throw error;
|
|
1780
|
+
await symlink(dest, link);
|
|
1781
|
+
}
|
|
1782
|
+
await removeLegacyStoreLink(join5(root, LEGACY_STORE_LINK), dest);
|
|
1783
|
+
return { slug, root, dest, link, linked: true };
|
|
1784
|
+
}
|
|
1785
|
+
async function reviewsDir(cwd, homeDir = homedir3()) {
|
|
1786
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1787
|
+
const dir = join5(store.link, "review");
|
|
1788
|
+
await mkdir3(dir, { recursive: true });
|
|
1789
|
+
return dir;
|
|
1790
|
+
}
|
|
1791
|
+
async function completedReviewsDir(cwd, homeDir = homedir3()) {
|
|
1792
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1793
|
+
const dir = join5(store.link, "reviews");
|
|
1794
|
+
await mkdir3(dir, { recursive: true });
|
|
1795
|
+
return dir;
|
|
1796
|
+
}
|
|
1797
|
+
async function sessionsDir(cwd, homeDir = homedir3()) {
|
|
1798
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1799
|
+
const dir = join5(store.link, "sessions");
|
|
1800
|
+
await mkdir3(dir, { recursive: true });
|
|
1801
|
+
return dir;
|
|
1802
|
+
}
|
|
1803
|
+
async function assertStoreLink(path, dest) {
|
|
1804
|
+
const entry = await lstat(path);
|
|
1805
|
+
if (!entry.isSymbolicLink())
|
|
1806
|
+
throw new Error(`${path} exists and is not a symlink.`);
|
|
1807
|
+
const target = await symlinkTarget(path);
|
|
1808
|
+
if (target !== await canonicalPath3(dest))
|
|
1809
|
+
throw new Error(`${path} points to ${target}, not ${dest}.`);
|
|
1810
|
+
}
|
|
1811
|
+
async function removeLegacyStoreLink(path, dest) {
|
|
1812
|
+
try {
|
|
1813
|
+
const entry = await lstat(path);
|
|
1814
|
+
if (!entry.isSymbolicLink())
|
|
1815
|
+
return;
|
|
1816
|
+
const target = await symlinkTarget(path);
|
|
1817
|
+
if (target === await canonicalPath3(dest))
|
|
1818
|
+
await unlink(path);
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
if (error.code !== "ENOENT")
|
|
1821
|
+
throw error;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
async function symlinkTarget(path) {
|
|
1825
|
+
const target = await readlink(path);
|
|
1826
|
+
return canonicalPath3(isAbsolute2(target) ? target : resolve3(dirname3(path), target));
|
|
1827
|
+
}
|
|
1828
|
+
async function canonicalPath3(path) {
|
|
1829
|
+
try {
|
|
1830
|
+
return await realpath3(path);
|
|
1831
|
+
} catch {
|
|
1832
|
+
return resolve3(path);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
function projectSlug(repository) {
|
|
1836
|
+
const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
1837
|
+
const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
|
|
1838
|
+
return `${readable}-${digest}`;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// src/review/review-state.ts
|
|
1842
|
+
var reviewPublicationStateSchema = z5.object({
|
|
1843
|
+
target: z5.string().optional(),
|
|
1844
|
+
bodies: z5.array(z5.string()).default([]),
|
|
1845
|
+
comments: z5.array(z5.string()).default([]),
|
|
1846
|
+
replies: z5.array(z5.string()).default([]),
|
|
1847
|
+
overlayPath: z5.string().optional()
|
|
1848
|
+
});
|
|
1849
|
+
function reviewBodyFingerprint(body) {
|
|
1850
|
+
return digest(body);
|
|
1851
|
+
}
|
|
1852
|
+
function reviewCommentFingerprint(comment) {
|
|
1853
|
+
return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
|
|
1854
|
+
}
|
|
1855
|
+
function reviewReplyFingerprint(threadId, body) {
|
|
1856
|
+
return digest(`${threadId}\x00${body}`);
|
|
1857
|
+
}
|
|
1858
|
+
function unpublishedReviewComments(comments, knownFingerprints) {
|
|
1859
|
+
return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
|
|
1860
|
+
}
|
|
1861
|
+
async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
|
|
1862
|
+
const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
|
|
1863
|
+
const path = join6(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
|
|
1864
|
+
try {
|
|
1865
|
+
const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile5(path, "utf8")));
|
|
1866
|
+
return { path, state: { ...parsed, target } };
|
|
1867
|
+
} catch (error) {
|
|
1868
|
+
if (error.code === "ENOENT") {
|
|
1869
|
+
return { path, state: { target, bodies: [], comments: [], replies: [] } };
|
|
1870
|
+
}
|
|
1871
|
+
if (error instanceof SyntaxError || error instanceof z5.ZodError) {
|
|
1872
|
+
throw new Error(`Cannot parse review publication state: ${path}`);
|
|
1873
|
+
}
|
|
1874
|
+
throw error;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
async function saveReviewPublicationState(path, state) {
|
|
1878
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
1879
|
+
await writeFile4(temp, `${JSON.stringify(state, null, 2)}
|
|
1880
|
+
`, "utf8");
|
|
1881
|
+
await rename(temp, path);
|
|
1882
|
+
}
|
|
1883
|
+
function digest(value) {
|
|
1884
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
1885
|
+
}
|
|
1886
|
+
// src/templates.ts
|
|
1887
|
+
import { readFile as readFile6 } from "node:fs/promises";
|
|
1888
|
+
import { homedir as homedir4 } from "node:os";
|
|
1889
|
+
import { join as join8, normalize } from "node:path";
|
|
1890
|
+
|
|
1891
|
+
// src/assets.ts
|
|
1892
|
+
import { existsSync } from "node:fs";
|
|
1893
|
+
import { dirname as dirname4, join as join7 } from "node:path";
|
|
1894
|
+
import { fileURLToPath } from "node:url";
|
|
1895
|
+
function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
|
|
1896
|
+
const moduleDir = dirname4(fileURLToPath(moduleUrl));
|
|
1897
|
+
const candidates = [join7(moduleDir, name), join7(moduleDir, "..", name), join7(moduleDir, "..", "..", name)];
|
|
1898
|
+
return candidates.find((path) => existsSync(path)) ?? candidates[1];
|
|
1899
|
+
}
|
|
1900
|
+
function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
|
|
1901
|
+
return resolveBundledAssetDir("agents", moduleUrl);
|
|
1902
|
+
}
|
|
1903
|
+
function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
|
|
1904
|
+
return resolveBundledAssetDir("templates", moduleUrl);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// src/templates.ts
|
|
1908
|
+
async function loadTemplate(name, options = {}) {
|
|
1909
|
+
const relative = templateRelativePath(name);
|
|
1910
|
+
const userPath = join8(options.homeDir ?? homedir4(), ".difflab", "diffpi", "templates", relative);
|
|
1911
|
+
const bundledPath = join8(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
1912
|
+
const user = await readOptionalFile(userPath);
|
|
1913
|
+
if (user !== undefined)
|
|
1914
|
+
return { name, path: userPath, source: "user", content: user };
|
|
1915
|
+
const bundled = await readOptionalFile(bundledPath);
|
|
1916
|
+
if (bundled !== undefined)
|
|
1917
|
+
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
1918
|
+
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
1919
|
+
}
|
|
1920
|
+
function renderTemplate(content, variables) {
|
|
1921
|
+
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
1922
|
+
}
|
|
1923
|
+
function templateRelativePath(name) {
|
|
1924
|
+
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
1925
|
+
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
1926
|
+
throw new Error(`Invalid template name: ${name}`);
|
|
1927
|
+
}
|
|
1928
|
+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
1929
|
+
}
|
|
1930
|
+
async function readOptionalFile(path) {
|
|
1931
|
+
try {
|
|
1932
|
+
return await readFile6(path, "utf8");
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
if (error.code === "ENOENT")
|
|
1935
|
+
return;
|
|
1936
|
+
throw error;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// src/tools/review.ts
|
|
1941
|
+
var contextSchema = z6.object({
|
|
1942
|
+
cwd: z6.string().optional(),
|
|
1943
|
+
target: z6.string().optional(),
|
|
1944
|
+
workingTree: z6.boolean().optional()
|
|
1945
|
+
});
|
|
1946
|
+
var localSchema = contextSchema.extend({ local: z6.boolean().optional() });
|
|
1947
|
+
var openSchema = localSchema.extend({
|
|
1948
|
+
title: z6.string().optional(),
|
|
1949
|
+
intent: z6.string().optional(),
|
|
1950
|
+
issueUrl: z6.string().url().optional(),
|
|
1951
|
+
base: z6.string().optional()
|
|
1952
|
+
});
|
|
1953
|
+
var submitSchema = localSchema.extend({
|
|
1954
|
+
findings: findingsSchema,
|
|
1955
|
+
overallIssues: z6.array(z6.string()).optional(),
|
|
1956
|
+
notVerified: z6.array(z6.string()).optional(),
|
|
1957
|
+
title: z6.string().optional()
|
|
1958
|
+
});
|
|
1959
|
+
var addCommentSchema = localSchema.extend({
|
|
1960
|
+
body: z6.string().min(1),
|
|
1961
|
+
file: z6.string().min(1),
|
|
1962
|
+
line: z6.number().int().positive(),
|
|
1963
|
+
side: z6.enum(["LEFT", "RIGHT"]).optional()
|
|
1964
|
+
});
|
|
1965
|
+
var respondSchema = localSchema.extend({
|
|
1966
|
+
threadId: z6.string().min(1),
|
|
1967
|
+
body: z6.string().min(1),
|
|
1968
|
+
question: z6.boolean().optional(),
|
|
1969
|
+
resolve: z6.boolean().optional()
|
|
1970
|
+
});
|
|
1971
|
+
var publishSchema = localSchema.extend({
|
|
1972
|
+
status: z6.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
|
|
1973
|
+
});
|
|
1974
|
+
var completeSchema = localSchema.extend({
|
|
1975
|
+
action: z6.enum(["approve", "reject", "abandon"]).optional()
|
|
1976
|
+
});
|
|
1977
|
+
function hasReviewDraft(comments, body) {
|
|
1978
|
+
return comments.length > 0 || body.trim().length > 0;
|
|
1979
|
+
}
|
|
1980
|
+
async function workingTreeDiff(cwd) {
|
|
1981
|
+
const tracked = await run("git", ["-C", cwd, "diff", "HEAD"], { capture: "unbounded" });
|
|
1982
|
+
if (tracked.code !== 0)
|
|
1983
|
+
throw new Error(tracked.stderr || "Cannot read tracked working-tree changes.");
|
|
1984
|
+
const untracked = await runChecked("git", ["-C", cwd, "ls-files", "--others", "--exclude-standard", "-z"]);
|
|
1985
|
+
const patches = [tracked.stdout];
|
|
1986
|
+
for (const file of untracked.stdout.split("\x00").filter(Boolean)) {
|
|
1987
|
+
const patch = await run("git", ["-C", cwd, "diff", "--no-index", "--", "/dev/null", file], {
|
|
1988
|
+
capture: "unbounded"
|
|
1989
|
+
});
|
|
1990
|
+
if (patch.code > 1)
|
|
1991
|
+
throw new Error(patch.stderr || `Cannot read untracked file diff: ${file}`);
|
|
1992
|
+
patches.push(patch.stdout);
|
|
1993
|
+
}
|
|
1994
|
+
return patches.filter(Boolean).join(`
|
|
1995
|
+
`);
|
|
1996
|
+
}
|
|
1997
|
+
function createReviewTools() {
|
|
1998
|
+
return [
|
|
1999
|
+
defineTool3({
|
|
2000
|
+
name: "review_context",
|
|
2001
|
+
label: "review context",
|
|
2002
|
+
description: "Orient to the target, backend, forge, environment, shared store, PR/MR, and tuicr session.",
|
|
2003
|
+
promptSnippet: "Call review_context first",
|
|
2004
|
+
promptGuidelines: ["Call this before every review workflow."],
|
|
2005
|
+
parameters: parameters(localSchema),
|
|
2006
|
+
executionMode: "parallel",
|
|
2007
|
+
async execute(_id, input) {
|
|
2008
|
+
const params = localSchema.parse(input);
|
|
2009
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2010
|
+
const store = await ensureStore(review.cwd);
|
|
2011
|
+
const env = { ide: detectIde(), mux: detectMux(), shell: detectShell() };
|
|
2012
|
+
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
|
|
2013
|
+
const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
|
|
2014
|
+
const backend = params.local || params.workingTree ? "tuicr" : review.forge?.provider ?? "unsupported";
|
|
2015
|
+
return result([
|
|
2016
|
+
`Backend: ${backend}`,
|
|
2017
|
+
`Forge: ${review.vcs.provider}${review.vcs.provider === "none" ? "" : ` (${review.vcs.owner}/${review.vcs.repo})`}`,
|
|
2018
|
+
`Branch: ${review.vcs.branch} → ${baseRef}`,
|
|
2019
|
+
`Env: ide=${env.ide} mux=${env.mux} shell=${env.shell}`,
|
|
2020
|
+
`Store: ${store.link} → ${store.dest}`,
|
|
2021
|
+
review.pr ? `PR/MR: #${review.pr.number} ${review.pr.url}` : "PR/MR: none",
|
|
2022
|
+
session ? `tuicr: ${session.slug} (${session.commentCount} comments)` : "tuicr: none"
|
|
2023
|
+
].join(`
|
|
2024
|
+
`), { ...review, env, store, session, baseRef, backend });
|
|
2025
|
+
}
|
|
2026
|
+
}),
|
|
2027
|
+
defineTool3({
|
|
2028
|
+
name: "review_new",
|
|
2029
|
+
label: "review new",
|
|
2030
|
+
description: "Create a new local tuicr review or a remote draft PR/MR and open it in tuicr.",
|
|
2031
|
+
promptSnippet: "Call review_new to start",
|
|
2032
|
+
promptGuidelines: ["Use local to select tuicr as the review backend."],
|
|
2033
|
+
parameters: parameters(openSchema),
|
|
2034
|
+
executionMode: "sequential",
|
|
2035
|
+
async execute(_id, input) {
|
|
2036
|
+
const params = openSchema.parse(input);
|
|
2037
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2038
|
+
await ensureStore(review.cwd);
|
|
2039
|
+
if (params.local || params.workingTree)
|
|
2040
|
+
return launchLocalReview(review, true, params.base);
|
|
2041
|
+
if (!review.forge)
|
|
2042
|
+
return result(unsupportedForgeMessage());
|
|
2043
|
+
if (review.pr)
|
|
2044
|
+
return result(`A remote review already exists for this branch: ${review.pr.url}. Use review_edit to open it.`);
|
|
2045
|
+
await assertRemoteBranchReady(review.cwd);
|
|
2046
|
+
const base = params.base ?? await review.forge.defaultBranch();
|
|
2047
|
+
const template = await loadTemplate("review/draft-pr");
|
|
2048
|
+
const body = renderTemplate(template.content, {
|
|
2049
|
+
intent: params.intent ?? "<!-- Describe why this change is needed. -->",
|
|
2050
|
+
issue_url: params.issueUrl ?? "<!-- Add issue tracker URL. -->",
|
|
2051
|
+
head: review.vcs.branch,
|
|
2052
|
+
base
|
|
2053
|
+
});
|
|
2054
|
+
const pr = await review.forge.createDraftPr({
|
|
2055
|
+
title: params.title ?? deriveTitle(review.vcs.branch),
|
|
2056
|
+
body,
|
|
2057
|
+
base,
|
|
2058
|
+
head: review.vcs.branch
|
|
2059
|
+
});
|
|
2060
|
+
const launched = await launch(review.cwd, pr.number);
|
|
2061
|
+
const openMessage = launched.launched ? `Opened PR/MR #${pr.number} in tuicr (${launched.via}).` : launched.instruction ?? `Run: ${launched.command}`;
|
|
2062
|
+
return result(`Draft PR/MR created from ${template.source} template: ${pr.url}
|
|
2063
|
+
${openMessage}`, {
|
|
2064
|
+
pr,
|
|
2065
|
+
template,
|
|
2066
|
+
launched
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
}),
|
|
2070
|
+
defineTool3({
|
|
2071
|
+
name: "review_edit",
|
|
2072
|
+
label: "review edit",
|
|
2073
|
+
description: "Open an existing local tuicr session or remote PR/MR in tuicr without generating findings.",
|
|
2074
|
+
promptSnippet: "Call review_edit to continue an existing review",
|
|
2075
|
+
promptGuidelines: ["This tool never creates review findings or a PR/MR."],
|
|
2076
|
+
parameters: parameters(localSchema),
|
|
2077
|
+
executionMode: "sequential",
|
|
2078
|
+
async execute(_id, input) {
|
|
2079
|
+
const params = localSchema.parse(input);
|
|
2080
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2081
|
+
await ensureStore(review.cwd);
|
|
2082
|
+
if (params.local || params.workingTree) {
|
|
2083
|
+
if (!await resolveTuicrSession(review, true))
|
|
2084
|
+
return result("No local tuicr review exists. Use review_new with local=true to create one.");
|
|
2085
|
+
return launchLocalReview(review, true);
|
|
2086
|
+
}
|
|
2087
|
+
if (!review.forge)
|
|
2088
|
+
return result(unsupportedForgeMessage());
|
|
2089
|
+
if (!review.pr)
|
|
2090
|
+
return result("No remote PR/MR exists. Use review_new to create a draft review first.");
|
|
2091
|
+
return launchLocalReview(review, false);
|
|
2092
|
+
}
|
|
2093
|
+
}),
|
|
2094
|
+
defineTool3({
|
|
2095
|
+
name: "review_diff",
|
|
2096
|
+
label: "review diff",
|
|
2097
|
+
description: "Fetch the target PR/MR diff or auto-detected local working-tree diff.",
|
|
2098
|
+
promptSnippet: "Call review_diff for the code under review",
|
|
2099
|
+
promptGuidelines: ["Ground findings in this diff."],
|
|
2100
|
+
parameters: parameters(localSchema),
|
|
2101
|
+
executionMode: "parallel",
|
|
2102
|
+
async execute(_id, input) {
|
|
2103
|
+
const params = localSchema.parse(input);
|
|
2104
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2105
|
+
if (params.local || params.workingTree) {
|
|
2106
|
+
const diff = await workingTreeDiff(review.cwd);
|
|
2107
|
+
return result(diff || "No working-tree changes.", { diff, target: "local" });
|
|
2108
|
+
}
|
|
2109
|
+
if (!review.forge)
|
|
2110
|
+
return result(unsupportedForgeMessage());
|
|
2111
|
+
if (!review.pr)
|
|
2112
|
+
return result("No PR/MR matches this remote review target.");
|
|
2113
|
+
const diff = await review.forge.prDiff(review.pr.number);
|
|
2114
|
+
return result(diff || "Empty diff.", { diff, pr: review.pr });
|
|
2115
|
+
}
|
|
2116
|
+
}),
|
|
2117
|
+
defineTool3({
|
|
2118
|
+
name: "review_gates",
|
|
2119
|
+
label: "review gates",
|
|
2120
|
+
description: "Run format, lint, test, conventional-subject, and available CI checks.",
|
|
2121
|
+
promptSnippet: "Call review_gates before submitting findings",
|
|
2122
|
+
promptGuidelines: ["Report skipped gates as skipped."],
|
|
2123
|
+
parameters: parameters(localSchema),
|
|
2124
|
+
executionMode: "parallel",
|
|
2125
|
+
async execute(_id, input) {
|
|
2126
|
+
const params = localSchema.parse(input);
|
|
2127
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2128
|
+
const gates = await runMiseGates(review.cwd);
|
|
2129
|
+
const commit = await run("git", ["-C", review.cwd, "log", "-1", "--format=%s"]);
|
|
2130
|
+
const subject = commit.stdout.trim();
|
|
2131
|
+
if (subject)
|
|
2132
|
+
gates.push(checkConventionalSubject(subject));
|
|
2133
|
+
if (!params.local && review.pr && review.forge)
|
|
2134
|
+
gates.push(ciGate(await review.forge.prChecks(review.pr.number)));
|
|
2135
|
+
return result(gates.map((gate) => `- ${gate.name}: ${gate.status} — ${gate.detail}`).join(`
|
|
2136
|
+
`), {
|
|
2137
|
+
results: gates
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
}),
|
|
2141
|
+
defineTool3({
|
|
2142
|
+
name: "review_submit",
|
|
2143
|
+
label: "review submit",
|
|
2144
|
+
description: "Write the review artifact and stage comments in the selected local or remote backend.",
|
|
2145
|
+
promptSnippet: "Call review_submit with the findings JSON",
|
|
2146
|
+
promptGuidelines: ["Remote comments remain pending until review_publish."],
|
|
2147
|
+
parameters: parameters(submitSchema),
|
|
2148
|
+
executionMode: "sequential",
|
|
2149
|
+
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
2150
|
+
const params = submitSchema.parse(input);
|
|
2151
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2152
|
+
const model = modelRoute(ctx);
|
|
2153
|
+
const useLocalBackend = Boolean(params.local || params.workingTree);
|
|
2154
|
+
if (!useLocalBackend && !review.forge)
|
|
2155
|
+
return result(unsupportedForgeMessage());
|
|
2156
|
+
const findings = dedupeFindings(params.findings);
|
|
2157
|
+
const gates = await runMiseGates(review.cwd);
|
|
2158
|
+
const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
|
|
2159
|
+
const artifact = await newReviewArtifactPath(review.cwd, params.local || params.workingTree || !review.pr ? "uncommitted" : await reviewTargetId(review));
|
|
2160
|
+
await writeFile5(artifact, renderReviewDoc({
|
|
2161
|
+
title: params.title ?? review.pr?.title ?? review.vcs.branch,
|
|
2162
|
+
number: useLocalBackend ? undefined : review.pr?.number,
|
|
2163
|
+
url: useLocalBackend ? undefined : review.pr?.url,
|
|
2164
|
+
model,
|
|
2165
|
+
headRef: review.vcs.branch,
|
|
2166
|
+
baseRef,
|
|
2167
|
+
findings,
|
|
2168
|
+
overallIssues: params.overallIssues ?? [],
|
|
2169
|
+
gates,
|
|
2170
|
+
notVerified: params.notVerified ?? []
|
|
2171
|
+
}), "utf8");
|
|
2172
|
+
const comments = toReviewComments(findings, useLocalBackend ? undefined : model);
|
|
2173
|
+
const body = (params.overallIssues ?? []).join(`
|
|
2174
|
+
`);
|
|
2175
|
+
if (useLocalBackend) {
|
|
2176
|
+
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
|
|
2177
|
+
if (!session) {
|
|
2178
|
+
return result(`Review written: ${artifact}. Open tuicr, then call review_submit again to seed comments.`, {
|
|
2179
|
+
artifact,
|
|
2180
|
+
count: findings.length
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2183
|
+
const backend = createLocalReviewBackend({
|
|
2184
|
+
session: session.path,
|
|
2185
|
+
artifactPath: artifact,
|
|
2186
|
+
author: localReviewAuthor(model)
|
|
2187
|
+
});
|
|
2188
|
+
await backend.stage({ comments, body });
|
|
2189
|
+
return result(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
|
|
2190
|
+
}
|
|
2191
|
+
if (!review.pr)
|
|
2192
|
+
return result(`Review written: ${artifact}. No PR/MR matches this remote target.`, { artifact });
|
|
2193
|
+
const hasDraft = hasReviewDraft(comments, body);
|
|
2194
|
+
if (hasDraft) {
|
|
2195
|
+
await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body });
|
|
2196
|
+
}
|
|
2197
|
+
return result(`${hasDraft ? "Pending review staged" : "Clean review recorded"} on #${review.pr.number}. Artifact: ${artifact}`, {
|
|
2198
|
+
artifact,
|
|
2199
|
+
pr: review.pr,
|
|
2200
|
+
count: findings.length
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
}),
|
|
2204
|
+
defineTool3({
|
|
2205
|
+
name: "review_add_comment",
|
|
2206
|
+
label: "review add comment",
|
|
2207
|
+
description: "Add one provenance-marked comment through tuicr or the remote pending-review backend.",
|
|
2208
|
+
promptSnippet: "Call review_add_comment for incremental comments",
|
|
2209
|
+
promptGuidelines: ["Pass local=true when tuicr owns the draft."],
|
|
2210
|
+
parameters: parameters(addCommentSchema),
|
|
2211
|
+
executionMode: "sequential",
|
|
2212
|
+
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
2213
|
+
const params = addCommentSchema.parse(input);
|
|
2214
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2215
|
+
const model = modelRoute(ctx);
|
|
2216
|
+
const useLocalBackend = Boolean(params.local || params.workingTree);
|
|
2217
|
+
if (!useLocalBackend && !review.forge)
|
|
2218
|
+
return result(unsupportedForgeMessage());
|
|
2219
|
+
const comment = {
|
|
2220
|
+
file: params.file,
|
|
2221
|
+
line: params.line,
|
|
2222
|
+
side: params.side ?? "RIGHT",
|
|
2223
|
+
body: useLocalBackend ? params.body : withRemoteProvenance(params.body, model)
|
|
2224
|
+
};
|
|
2225
|
+
if (useLocalBackend) {
|
|
2226
|
+
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
|
|
2227
|
+
if (!session)
|
|
2228
|
+
return result("No matching tuicr session. Open review_new or review_launch_ui first.");
|
|
2229
|
+
const backend = createLocalReviewBackend({
|
|
2230
|
+
session: session.path,
|
|
2231
|
+
artifactPath: "",
|
|
2232
|
+
author: localReviewAuthor(model)
|
|
2233
|
+
});
|
|
2234
|
+
await backend.stage({ comments: [comment], body: "" });
|
|
2235
|
+
return result(`Comment added to tuicr session ${session.slug}.`, { session, comment });
|
|
2236
|
+
}
|
|
2237
|
+
if (!review.pr)
|
|
2238
|
+
return result("No PR/MR matches this remote review target.");
|
|
2239
|
+
await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments: [comment], body: "" });
|
|
2240
|
+
return result(`Draft comment added to #${review.pr.number}.`, { pr: review.pr, comment });
|
|
2241
|
+
}
|
|
2242
|
+
}),
|
|
2243
|
+
defineTool3({
|
|
2244
|
+
name: "review_comments",
|
|
2245
|
+
label: "review comments",
|
|
2246
|
+
description: "Pull review threads or local tuicr comments and write a target-named artifact.",
|
|
2247
|
+
promptSnippet: "Call review_comments before addressing findings",
|
|
2248
|
+
promptGuidelines: ["Pass local=true to prepare the local reply overlay."],
|
|
2249
|
+
parameters: parameters(localSchema),
|
|
2250
|
+
executionMode: "sequential",
|
|
2251
|
+
async execute(_id, input) {
|
|
2252
|
+
const params = localSchema.parse(input);
|
|
2253
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2254
|
+
let threads;
|
|
2255
|
+
let artifact;
|
|
2256
|
+
if (params.local || params.workingTree) {
|
|
2257
|
+
const session = await resolveTuicrSession(review, true);
|
|
2258
|
+
if (!session)
|
|
2259
|
+
return result("No matching tuicr session found.");
|
|
2260
|
+
artifact = await localThreadArtifactPath(review.cwd, session.slug);
|
|
2261
|
+
threads = await syncLocalThreadArtifact(artifact, review.pr?.title ?? review.vcs.branch, session.slug, toLocalReviewThreads(await readSession(session.path)));
|
|
2262
|
+
} else if (review.pr && review.forge) {
|
|
2263
|
+
threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
|
|
2264
|
+
const target = await reviewTargetId(review);
|
|
2265
|
+
artifact = await newReviewArtifactPath(review.cwd, target);
|
|
2266
|
+
await writeFile5(artifact, renderThreadArtifact(review.pr.title, target, threads, { number: review.pr.number, url: review.pr.url }), "utf8");
|
|
2267
|
+
} else {
|
|
2268
|
+
return result(review.forge ? "No PR/MR matches this remote review target." : unsupportedForgeMessage());
|
|
2269
|
+
}
|
|
2270
|
+
return result(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
|
|
2271
|
+
`) || "No comments.", { artifact, threads });
|
|
2272
|
+
}
|
|
2273
|
+
}),
|
|
2274
|
+
defineTool3({
|
|
2275
|
+
name: "review_respond",
|
|
2276
|
+
label: "review respond",
|
|
2277
|
+
description: "Post local question replies to tuicr and record their publish overlay, or reply to remote threads.",
|
|
2278
|
+
promptSnippet: "Call review_respond after addressing a comment",
|
|
2279
|
+
promptGuidelines: ["Question replies remain unresolved."],
|
|
2280
|
+
parameters: parameters(respondSchema),
|
|
2281
|
+
executionMode: "sequential",
|
|
2282
|
+
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
2283
|
+
const params = respondSchema.parse(input);
|
|
2284
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2285
|
+
const model = modelRoute(ctx);
|
|
2286
|
+
if (params.local) {
|
|
2287
|
+
const tuicrSession = await resolveTuicrSession(review, true);
|
|
2288
|
+
if (!tuicrSession)
|
|
2289
|
+
return result("No matching tuicr session. Open review_new or review_launch_ui first.");
|
|
2290
|
+
const artifact = await localThreadArtifactPath(review.cwd, tuicrSession.slug);
|
|
2291
|
+
if (!existsSync2(artifact))
|
|
2292
|
+
return result("No local review artifact. Run review_comments with local=true first.");
|
|
2293
|
+
const threads = await readThreadArtifact(artifact);
|
|
2294
|
+
const known = threads.find((thread) => thread.id === params.threadId);
|
|
2295
|
+
const question = known?.question === true || params.question === true || !known && params.question === undefined;
|
|
2296
|
+
const awaitUserDeletion = !question && params.resolve === true;
|
|
2297
|
+
await addComment(tuicrSession.path, `${localResponseMarker(params.threadId)}
|
|
2298
|
+
${params.body}`, {
|
|
2299
|
+
targetFile: known?.file,
|
|
2300
|
+
line: known?.line,
|
|
2301
|
+
side: "new",
|
|
2302
|
+
username: localReviewAuthor(model)
|
|
2303
|
+
});
|
|
2304
|
+
await createLocalReviewBackend({
|
|
2305
|
+
session: tuicrSession.path,
|
|
2306
|
+
artifactPath: artifact,
|
|
2307
|
+
author: localReviewAuthor(model)
|
|
2308
|
+
}).reply({
|
|
2309
|
+
threadId: params.threadId,
|
|
2310
|
+
body: params.body,
|
|
2311
|
+
resolve: false,
|
|
2312
|
+
question
|
|
2313
|
+
});
|
|
2314
|
+
return result(`Local ${question ? "answer" : "response"} posted to tuicr and recorded in ${artifact}${awaitUserDeletion ? "; delete the source comment in tuicr to resolve it" : ""}.`, { artifact, question, awaitUserDeletion, session: tuicrSession });
|
|
2315
|
+
}
|
|
2316
|
+
if (!review.pr || !review.forge)
|
|
2317
|
+
return result("No remote PR/MR for this reply.");
|
|
2318
|
+
const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
|
|
2319
|
+
const remoteThreads = await remote.listThreads();
|
|
2320
|
+
const known = remoteThreads.find((thread) => thread.id === params.threadId);
|
|
2321
|
+
const question = known?.question === true || params.question === true || !known && params.question === undefined;
|
|
2322
|
+
const resolve = question ? false : params.resolve ?? true;
|
|
2323
|
+
await remote.reply({
|
|
2324
|
+
threadId: params.threadId,
|
|
2325
|
+
body: withRemoteProvenance(params.body, model),
|
|
2326
|
+
resolve,
|
|
2327
|
+
question
|
|
2328
|
+
});
|
|
2329
|
+
return result(`Replied to ${params.threadId}${resolve ? " and resolved it" : " and left it open"}.`, {
|
|
2330
|
+
pr: review.pr,
|
|
2331
|
+
resolve
|
|
2332
|
+
});
|
|
2333
|
+
}
|
|
2334
|
+
}),
|
|
2335
|
+
defineTool3({
|
|
2336
|
+
name: "review_publish",
|
|
2337
|
+
label: "review publish",
|
|
2338
|
+
description: "Publish pending remote draft PR/MR review comments and status, or promote a local tuicr draft before publishing.",
|
|
2339
|
+
promptSnippet: "Call review_publish to make remote review work public",
|
|
2340
|
+
promptGuidelines: ["Statuses are COMMENT, APPROVE, REQUEST_CHANGES, or CLOSE."],
|
|
2341
|
+
parameters: parameters(publishSchema),
|
|
2342
|
+
executionMode: "sequential",
|
|
2343
|
+
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
2344
|
+
const params = publishSchema.parse(input);
|
|
2345
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2346
|
+
if (!review.pr || !review.forge)
|
|
2347
|
+
return result("No remote PR/MR to publish.");
|
|
2348
|
+
return publishResolvedReview(review, params, modelRoute(ctx));
|
|
2349
|
+
}
|
|
2350
|
+
}),
|
|
2351
|
+
defineTool3({
|
|
2352
|
+
name: "review_complete",
|
|
2353
|
+
label: "review complete",
|
|
2354
|
+
description: "Approve, reject, or abandon a remote review, or archive a local review artifact.",
|
|
2355
|
+
promptSnippet: "Call review_complete to finish a review without merging it.",
|
|
2356
|
+
promptGuidelines: ["A local completion archives the Diffpi artifact and deletes the matching tuicr session."],
|
|
2357
|
+
parameters: parameters(completeSchema),
|
|
2358
|
+
executionMode: "sequential",
|
|
2359
|
+
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
2360
|
+
const params = completeSchema.parse(input);
|
|
2361
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2362
|
+
if (params.local) {
|
|
2363
|
+
const session = await resolveTuicrSession(review, true);
|
|
2364
|
+
if (!session)
|
|
2365
|
+
return result("No matching local tuicr session to close.");
|
|
2366
|
+
const artifact = await localThreadArtifactPath(review.cwd, session.slug);
|
|
2367
|
+
if (!existsSync2(artifact))
|
|
2368
|
+
return result("No local review artifact. Run review_comments with local=true first.");
|
|
2369
|
+
const archived = await uniqueRecordPath(await completedReviewsDir(review.cwd), reviewSlug(session.slug));
|
|
2370
|
+
await rename2(artifact, archived);
|
|
2371
|
+
await unlink2(session.path);
|
|
2372
|
+
return result(`Archived ${archived} and deleted the tuicr session ${session.slug}.`, {
|
|
2373
|
+
artifact: archived,
|
|
2374
|
+
session
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
if (!review.pr || !review.forge)
|
|
2378
|
+
return result("No remote PR/MR to complete.");
|
|
2379
|
+
if (!params.action)
|
|
2380
|
+
return result("Choose a complete action: approve, reject, or abandon.");
|
|
2381
|
+
const status = { approve: "APPROVE", reject: "REQUEST_CHANGES", abandon: "CLOSE" }[params.action];
|
|
2382
|
+
return publishResolvedReview(review, { ...params, status }, modelRoute(ctx));
|
|
2383
|
+
}
|
|
2384
|
+
}),
|
|
2385
|
+
defineTool3({
|
|
2386
|
+
name: "review_merge",
|
|
2387
|
+
label: "review merge",
|
|
2388
|
+
description: "Squash-merge an approved GitHub PR after checking its conventional subject.",
|
|
2389
|
+
promptSnippet: "Call review_merge only after review_publish APPROVE",
|
|
2390
|
+
promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
|
|
2391
|
+
parameters: parameters(contextSchema.extend({ subject: z6.string().optional() })),
|
|
2392
|
+
executionMode: "sequential",
|
|
2393
|
+
async execute(_id, input) {
|
|
2394
|
+
const params = contextSchema.extend({ subject: z6.string().optional() }).parse(input);
|
|
2395
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2396
|
+
if (review.vcs.provider !== "github" || !review.forge)
|
|
2397
|
+
return result("review_merge currently supports GitHub only.");
|
|
2398
|
+
if (!review.pr)
|
|
2399
|
+
return result("No open PR/MR for this branch.");
|
|
2400
|
+
const subject = params.subject ?? review.pr.title;
|
|
2401
|
+
const guard = checkConventionalSubject(subject);
|
|
2402
|
+
if (guard.status !== "pass")
|
|
2403
|
+
return result(`Merge blocked: ${guard.detail}`, { pr: review.pr, guard });
|
|
2404
|
+
await review.forge.mergePr(review.pr.number, subject);
|
|
2405
|
+
return result(`Merged #${review.pr.number} with subject: ${subject}.`, { pr: review.pr, guard });
|
|
2406
|
+
}
|
|
2407
|
+
}),
|
|
2408
|
+
defineTool3({
|
|
2409
|
+
name: "review_launch_ui",
|
|
2410
|
+
label: "review launch UI",
|
|
2411
|
+
description: "Open a tuicr UI in a mux tab, configure Zed, or return the command to run.",
|
|
2412
|
+
promptSnippet: "Call review_launch_ui for the interactive tuicr TUI",
|
|
2413
|
+
promptGuidelines: ["Show the returned command when launch cannot open a tab."],
|
|
2414
|
+
parameters: parameters(localSchema),
|
|
2415
|
+
executionMode: "sequential",
|
|
2416
|
+
async execute(_id, input) {
|
|
2417
|
+
const params = localSchema.parse(input);
|
|
2418
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2419
|
+
const workingTree = Boolean(params.local || params.workingTree);
|
|
2420
|
+
if (!workingTree && !review.forge)
|
|
2421
|
+
return result(unsupportedForgeMessage());
|
|
2422
|
+
return launchLocalReview(review, workingTree);
|
|
2423
|
+
}
|
|
2424
|
+
})
|
|
2425
|
+
];
|
|
2426
|
+
}
|
|
2427
|
+
async function syncLocalThreadArtifact(path, title, sessionSlug, threads) {
|
|
2428
|
+
let previous = [];
|
|
2429
|
+
try {
|
|
2430
|
+
previous = parseThreadArtifact(await readFile7(path, "utf8"));
|
|
2431
|
+
} catch (error) {
|
|
2432
|
+
if (error.code !== "ENOENT")
|
|
2433
|
+
throw error;
|
|
2434
|
+
}
|
|
2435
|
+
const previousById = new Map(previous.map((thread) => [thread.id, thread]));
|
|
2436
|
+
const currentIds = new Set(threads.map((thread) => thread.id));
|
|
2437
|
+
const current = threads.map((thread) => {
|
|
2438
|
+
const existing = previousById.get(thread.id);
|
|
2439
|
+
return {
|
|
2440
|
+
...thread,
|
|
2441
|
+
resolved: false,
|
|
2442
|
+
addressed: existing?.addressed ?? Boolean(existing?.reply),
|
|
2443
|
+
reply: existing?.reply
|
|
2444
|
+
};
|
|
2445
|
+
});
|
|
2446
|
+
const removed = previous.filter((thread) => !currentIds.has(thread.id)).map((thread) => ({ ...thread, resolved: true }));
|
|
2447
|
+
const merged = [...current, ...removed];
|
|
2448
|
+
await writeFile5(path, renderThreadArtifact(title, sessionSlug, merged), "utf8");
|
|
2449
|
+
return merged;
|
|
2450
|
+
}
|
|
2451
|
+
function parameters(schema) {
|
|
2452
|
+
return z6.toJSONSchema(schema, { io: "input" });
|
|
2453
|
+
}
|
|
2454
|
+
function resolveWorkingDirectory(params) {
|
|
2455
|
+
return params.cwd ?? process.cwd();
|
|
2456
|
+
}
|
|
2457
|
+
function result(text, details = {}) {
|
|
2458
|
+
return { content: [{ type: "text", text }], details };
|
|
2459
|
+
}
|
|
2460
|
+
function unsupportedForgeMessage() {
|
|
2461
|
+
return "No supported GitHub or GitLab remote was detected. Use local=true for an offline tuicr review.";
|
|
2462
|
+
}
|
|
2463
|
+
function modelRoute(ctx) {
|
|
2464
|
+
if (!ctx.model)
|
|
2465
|
+
throw new Error("Cannot record review provenance because Pi did not provide an active model route.");
|
|
2466
|
+
return `${ctx.model.provider}/${ctx.model.id}`;
|
|
2467
|
+
}
|
|
2468
|
+
async function publishResolvedReview(review, params, model) {
|
|
2469
|
+
const status = params.status ?? "COMMENT";
|
|
2470
|
+
const event = status === "CLOSE" ? "COMMENT" : status;
|
|
2471
|
+
assertReviewEventSupported(review.vcs.provider, event);
|
|
2472
|
+
const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
|
|
2473
|
+
const promotion = params.local ? await promoteLocalReview(review, remote, model, true) : undefined;
|
|
2474
|
+
if (status !== "CLOSE" && review.pr.isDraft)
|
|
2475
|
+
await review.forge.markReady(review.pr.number);
|
|
2476
|
+
await remote.publish(event);
|
|
2477
|
+
if (promotion) {
|
|
2478
|
+
promotion.publication.state.bodies = [
|
|
2479
|
+
...new Set([...promotion.publication.state.bodies, ...promotion.bodyFingerprints])
|
|
2480
|
+
];
|
|
2481
|
+
promotion.publication.state.comments = [
|
|
2482
|
+
...new Set([...promotion.publication.state.comments, ...promotion.commentFingerprints])
|
|
2483
|
+
];
|
|
2484
|
+
await saveReviewPublicationState(promotion.publication.path, promotion.publication.state);
|
|
2485
|
+
}
|
|
2486
|
+
if (status === "CLOSE")
|
|
2487
|
+
await review.forge.closePr(review.pr.number);
|
|
2488
|
+
const finalPr = await review.forge.viewPr(String(review.pr.number));
|
|
2489
|
+
const promotedBodies = promotion?.promotedBodies ?? 0;
|
|
2490
|
+
const promotedComments = promotion?.promotedComments ?? 0;
|
|
2491
|
+
const promotedReplies = promotion?.promotedReplies ?? 0;
|
|
2492
|
+
return result(`Published #${review.pr.number} (${status}); promoted ${promotedBodies} review bodies, ${promotedComments} comments, and ${promotedReplies} replies.`, { pr: finalPr ?? review.pr, status, promotedBodies, promotedComments, promotedReplies });
|
|
2493
|
+
}
|
|
2494
|
+
async function promoteLocalReview(review, remote, model, workingTree) {
|
|
2495
|
+
const publication = await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number);
|
|
2496
|
+
const session = await resolveTuicrSession(review, workingTree);
|
|
2497
|
+
if (!session)
|
|
2498
|
+
throw new Error("No matching tuicr session to publish.");
|
|
2499
|
+
const local = createLocalReviewBackend({
|
|
2500
|
+
session: session.path,
|
|
2501
|
+
artifactPath: "",
|
|
2502
|
+
author: localReviewAuthor(model)
|
|
2503
|
+
});
|
|
2504
|
+
const draft = await local.readDraft();
|
|
2505
|
+
const comments = draft.comments.map((comment) => ({
|
|
2506
|
+
...comment,
|
|
2507
|
+
body: withRemoteProvenance(comment.body, comment.author?.replace(/^Agent:\s*/, "") || model)
|
|
2508
|
+
}));
|
|
2509
|
+
const body = draft.body.trim() ? withRemoteProvenance(draft.body, model) : "";
|
|
2510
|
+
const bodyFingerprints = body ? [reviewBodyFingerprint(body)] : [];
|
|
2511
|
+
const commentFingerprints = comments.map(reviewCommentFingerprint);
|
|
2512
|
+
const remoteDraft = await remote.readDraft();
|
|
2513
|
+
const knownBodies = new Set(publication.state.bodies);
|
|
2514
|
+
if (remoteDraft.body.trim())
|
|
2515
|
+
knownBodies.add(reviewBodyFingerprint(remoteDraft.body));
|
|
2516
|
+
const unpublishedBody = body && !knownBodies.has(reviewBodyFingerprint(body)) ? body : "";
|
|
2517
|
+
const known = new Set([...publication.state.comments, ...remoteDraft.comments.map(reviewCommentFingerprint)]);
|
|
2518
|
+
const unpublished = unpublishedReviewComments(comments, known);
|
|
2519
|
+
if (unpublished.length > 0 || unpublishedBody)
|
|
2520
|
+
await remote.stage({ comments: unpublished, body: unpublishedBody });
|
|
2521
|
+
const promotedReplies = await promoteLocalReplies(review, remote, publication, model, workingTree);
|
|
2522
|
+
return {
|
|
2523
|
+
publication,
|
|
2524
|
+
bodyFingerprints,
|
|
2525
|
+
commentFingerprints,
|
|
2526
|
+
promotedBodies: unpublishedBody ? 1 : 0,
|
|
2527
|
+
promotedComments: unpublished.length,
|
|
2528
|
+
promotedReplies
|
|
2529
|
+
};
|
|
2530
|
+
}
|
|
2531
|
+
async function promoteLocalReplies(review, remote, publication, model, workingTree) {
|
|
2532
|
+
const target = workingTree ? "uncommitted" : await reviewTargetId(review);
|
|
2533
|
+
const artifact = workingTree ? await latestThreadArtifact(review.cwd, review, target) : publication.state.overlayPath ?? await latestThreadArtifact(review.cwd, review, target);
|
|
2534
|
+
if (!artifact)
|
|
2535
|
+
return 0;
|
|
2536
|
+
if (!workingTree)
|
|
2537
|
+
publication.state.overlayPath = artifact;
|
|
2538
|
+
let count = 0;
|
|
2539
|
+
for (const thread of await readThreadArtifact(artifact)) {
|
|
2540
|
+
if (!thread.reply)
|
|
2541
|
+
continue;
|
|
2542
|
+
const body = withRemoteProvenance(thread.reply, model);
|
|
2543
|
+
const fingerprint = reviewReplyFingerprint(thread.id, body);
|
|
2544
|
+
if (publication.state.replies.includes(fingerprint))
|
|
2545
|
+
continue;
|
|
2546
|
+
await remote.reply({
|
|
2547
|
+
threadId: thread.id,
|
|
2548
|
+
body,
|
|
2549
|
+
resolve: !thread.question,
|
|
2550
|
+
question: thread.question
|
|
2551
|
+
});
|
|
2552
|
+
publication.state.replies.push(fingerprint);
|
|
2553
|
+
await saveReviewPublicationState(publication.path, publication.state);
|
|
2554
|
+
count += 1;
|
|
2555
|
+
}
|
|
2556
|
+
return count;
|
|
2557
|
+
}
|
|
2558
|
+
async function resolveReviewContext(cwd, target) {
|
|
2559
|
+
const vcs = await detectVcs(cwd);
|
|
2560
|
+
const forge = vcs.provider === "none" ? undefined : createVcsBackend(vcs);
|
|
2561
|
+
const requested = target ? normalizeTarget(target) : vcs.branch;
|
|
2562
|
+
const pr = forge ? await forge.viewPr(requested) : undefined;
|
|
2563
|
+
return { cwd, vcs, forge, pr };
|
|
2564
|
+
}
|
|
2565
|
+
function normalizeTarget(target) {
|
|
2566
|
+
return target.match(/\/(?:pull|merge_requests)\/(\d+)(?:\/|$)/)?.[1] ?? target;
|
|
2567
|
+
}
|
|
2568
|
+
function resolveTuicrSession(review, workingTree = false) {
|
|
2569
|
+
return resolveReviewSession(review.cwd, {
|
|
2570
|
+
branch: review.vcs.branch,
|
|
2571
|
+
workingTree,
|
|
2572
|
+
owner: review.vcs.provider === "none" ? undefined : review.vcs.owner,
|
|
2573
|
+
repo: review.vcs.provider === "none" ? undefined : review.vcs.repo,
|
|
2574
|
+
number: review.pr?.number
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
async function launchLocalReview(review, workingTree, requestedBase) {
|
|
2578
|
+
const base = workingTree ? requestedBase ?? review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : undefined) : undefined;
|
|
2579
|
+
const launched = await launch(review.cwd, workingTree ? undefined : review.pr?.number, base);
|
|
2580
|
+
const commandTarget = workingTree || !review.pr ? "full branch" : `PR/MR #${review.pr.number}`;
|
|
2581
|
+
return result(launched.launched ? `Opened ${commandTarget} in tuicr (${launched.via}).` : launched.instruction ?? `Run: ${launched.command}`, { launched, pr: review.pr, target: commandTarget });
|
|
2582
|
+
}
|
|
2583
|
+
async function assertRemoteBranchReady(cwd) {
|
|
2584
|
+
const dirty = await runChecked("git", ["-C", cwd, "status", "--porcelain"]);
|
|
2585
|
+
if (dirty.stdout.trim())
|
|
2586
|
+
throw new Error("Commit and push all changes before opening a remote review.");
|
|
2587
|
+
const upstream = await run("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
|
|
2588
|
+
if (upstream.code !== 0 || !upstream.stdout.trim())
|
|
2589
|
+
throw new Error("Push the current branch and set its upstream before opening a remote review.");
|
|
2590
|
+
const [head, pushed] = await Promise.all([
|
|
2591
|
+
runChecked("git", ["-C", cwd, "rev-parse", "HEAD"]),
|
|
2592
|
+
runChecked("git", ["-C", cwd, "rev-parse", "@{upstream}"])
|
|
2593
|
+
]);
|
|
2594
|
+
if (head.stdout.trim() !== pushed.stdout.trim())
|
|
2595
|
+
throw new Error("Push the current branch before opening a remote review.");
|
|
2596
|
+
}
|
|
2597
|
+
async function reviewTargetId(review) {
|
|
2598
|
+
if (!review.pr)
|
|
2599
|
+
return "uncommitted";
|
|
2600
|
+
if (review.pr.headSha)
|
|
2601
|
+
return review.pr.headSha.slice(0, 12);
|
|
2602
|
+
if (review.pr.headRef === review.vcs.branch)
|
|
2603
|
+
return headSha(review.cwd);
|
|
2604
|
+
const remote = await run("git", ["-C", review.cwd, "ls-remote", "origin", `refs/heads/${review.pr.headRef}`]);
|
|
2605
|
+
return remote.stdout.trim().split(/\s+/)[0]?.slice(0, 12) || headSha(review.cwd);
|
|
2606
|
+
}
|
|
2607
|
+
async function headSha(cwd) {
|
|
2608
|
+
const result = await runChecked("git", ["-C", cwd, "rev-parse", "--short=12", "HEAD"]);
|
|
2609
|
+
return result.stdout.trim();
|
|
2610
|
+
}
|
|
2611
|
+
async function newReviewArtifactPath(cwd, target) {
|
|
2612
|
+
const dir = await reviewsDir(cwd);
|
|
2613
|
+
await mkdir4(dir, { recursive: true });
|
|
2614
|
+
return uniqueRecordPath(dir, reviewRecordName(target));
|
|
2615
|
+
}
|
|
2616
|
+
async function localThreadArtifactPath(cwd, sessionSlug) {
|
|
2617
|
+
const dir = await reviewsDir(cwd);
|
|
2618
|
+
return join9(dir, `${reviewSlug(sessionSlug) || "local-review"}.md`);
|
|
2619
|
+
}
|
|
2620
|
+
async function readThreadArtifact(path) {
|
|
2621
|
+
try {
|
|
2622
|
+
return parseThreadArtifact(await readFile7(path, "utf8"));
|
|
2623
|
+
} catch (error) {
|
|
2624
|
+
if (error.code === "ENOENT") {
|
|
2625
|
+
throw new Error(`Expected local reply overlay is missing: ${path}`);
|
|
2626
|
+
}
|
|
2627
|
+
throw error;
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
async function latestThreadArtifact(cwd, review, target) {
|
|
2631
|
+
try {
|
|
2632
|
+
return await findLatestThreadArtifact(cwd, review, target);
|
|
2633
|
+
} catch (error) {
|
|
2634
|
+
throw new Error(`Cannot locate the latest review thread artifact for ${target}.`, { cause: error });
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
async function findLatestThreadArtifact(cwd, review, target) {
|
|
2638
|
+
const dir = await reviewsDir(cwd);
|
|
2639
|
+
const suffix = reviewSlug(target) || "uncommitted";
|
|
2640
|
+
const names = await listReviewArtifactNames(dir);
|
|
2641
|
+
const candidates = await Promise.all(names.flatMap((name) => {
|
|
2642
|
+
if (!name.endsWith(".md"))
|
|
2643
|
+
return [];
|
|
2644
|
+
return [
|
|
2645
|
+
(async () => {
|
|
2646
|
+
const path = join9(dir, name);
|
|
2647
|
+
const info = await stat(path);
|
|
2648
|
+
return { path, name, content: await readFile7(path, "utf8"), modified: info.mtimeMs };
|
|
2649
|
+
})()
|
|
2650
|
+
];
|
|
2651
|
+
}));
|
|
2652
|
+
return candidates.filter(({ name, content }) => {
|
|
2653
|
+
if (!content.startsWith("<!-- diffpi-threads:"))
|
|
2654
|
+
return false;
|
|
2655
|
+
if (review.pr && target !== "local" && target !== "uncommitted")
|
|
2656
|
+
return content.includes(`- PR/MR: #${review.pr.number}`);
|
|
2657
|
+
return artifactNameMatches(name, suffix);
|
|
2658
|
+
}).sort((a, b) => b.modified - a.modified)[0]?.path;
|
|
2659
|
+
}
|
|
2660
|
+
async function listReviewArtifactNames(dir) {
|
|
2661
|
+
try {
|
|
2662
|
+
return await readdir(dir);
|
|
2663
|
+
} catch (error) {
|
|
2664
|
+
throw new Error(`Cannot read review artifacts in ${dir}.`, { cause: error });
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
function deriveTitle(branch) {
|
|
2668
|
+
return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
|
|
2669
|
+
}
|
|
2670
|
+
function uniqueRecordPath(dir, base) {
|
|
2671
|
+
let path = join9(dir, `${base}.md`);
|
|
2672
|
+
let count = 2;
|
|
2673
|
+
while (existsSync2(path))
|
|
2674
|
+
path = join9(dir, `${base}-${count++}.md`);
|
|
2675
|
+
return path;
|
|
2676
|
+
}
|
|
2677
|
+
function artifactNameMatches(name, suffix) {
|
|
2678
|
+
if (!name.endsWith(".md"))
|
|
2679
|
+
return false;
|
|
2680
|
+
const stem = name.slice(0, -3);
|
|
2681
|
+
const marker = `-${suffix}`;
|
|
2682
|
+
const markerIndex = stem.lastIndexOf(marker);
|
|
2683
|
+
if (markerIndex < 0)
|
|
2684
|
+
return false;
|
|
2685
|
+
const tail = stem.slice(markerIndex + marker.length);
|
|
2686
|
+
return tail === "" || /^-\d+$/.test(tail);
|
|
2687
|
+
}
|
|
2688
|
+
|
|
127
2689
|
// src/tools/setup.ts
|
|
128
|
-
import { defineTool as
|
|
129
|
-
import { z as
|
|
2690
|
+
import { defineTool as defineTool4 } from "@earendil-works/pi-coding-agent";
|
|
2691
|
+
import { z as z8 } from "zod";
|
|
130
2692
|
|
|
131
2693
|
// src/setup.ts
|
|
132
2694
|
import { parseFrontmatter as parseFrontmatter2 } from "@earendil-works/pi-coding-agent";
|
|
133
|
-
import { readdir as
|
|
134
|
-
import { homedir as
|
|
135
|
-
import { basename as
|
|
136
|
-
|
|
137
|
-
// src/assets.ts
|
|
138
|
-
import { existsSync } from "node:fs";
|
|
139
|
-
import { dirname, join } from "node:path";
|
|
140
|
-
import { fileURLToPath } from "node:url";
|
|
141
|
-
function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
|
|
142
|
-
const moduleDir = dirname(fileURLToPath(moduleUrl));
|
|
143
|
-
const candidates = [
|
|
144
|
-
join(moduleDir, "agents"),
|
|
145
|
-
join(moduleDir, "..", "agents"),
|
|
146
|
-
join(moduleDir, "..", "..", "agents")
|
|
147
|
-
];
|
|
148
|
-
return candidates.find((path) => existsSync(path)) ?? candidates[1];
|
|
149
|
-
}
|
|
2695
|
+
import { readdir as readdir3, readFile as readFile10 } from "node:fs/promises";
|
|
2696
|
+
import { homedir as homedir8 } from "node:os";
|
|
2697
|
+
import { basename as basename4, join as join13 } from "node:path";
|
|
150
2698
|
|
|
151
2699
|
// src/config.ts
|
|
152
2700
|
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
153
|
-
import { z as
|
|
154
|
-
import { homedir } from "node:os";
|
|
155
|
-
import { join as
|
|
2701
|
+
import { z as z7 } from "zod";
|
|
2702
|
+
import { homedir as homedir5 } from "node:os";
|
|
2703
|
+
import { join as join10 } from "node:path";
|
|
156
2704
|
|
|
157
2705
|
// src/fsx.ts
|
|
158
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
2706
|
+
import { readdir as readdir2, readFile as readFile8 } from "node:fs/promises";
|
|
159
2707
|
async function readTextIfExists(path) {
|
|
160
2708
|
try {
|
|
161
|
-
return await
|
|
2709
|
+
return await readFile8(path, "utf8");
|
|
162
2710
|
} catch (error) {
|
|
163
2711
|
if (isMissingPath(error))
|
|
164
2712
|
return;
|
|
@@ -170,18 +2718,18 @@ function isMissingPath(error) {
|
|
|
170
2718
|
}
|
|
171
2719
|
|
|
172
2720
|
// src/config.ts
|
|
173
|
-
var modelReferenceSchema =
|
|
174
|
-
var agentConfigSchema =
|
|
175
|
-
models:
|
|
2721
|
+
var modelReferenceSchema = z7.string().trim().min(1);
|
|
2722
|
+
var agentConfigSchema = z7.object({
|
|
2723
|
+
models: z7.array(modelReferenceSchema).optional()
|
|
176
2724
|
}).strict();
|
|
177
|
-
var diffpiConfigSchema =
|
|
178
|
-
agents:
|
|
2725
|
+
var diffpiConfigSchema = z7.object({
|
|
2726
|
+
agents: z7.record(z7.string(), agentConfigSchema).optional()
|
|
179
2727
|
}).strict();
|
|
180
|
-
function diffpiConfigPaths(homeDir =
|
|
181
|
-
const directory =
|
|
2728
|
+
function diffpiConfigPaths(homeDir = homedir5()) {
|
|
2729
|
+
const directory = join10(homeDir, ".difflab", "diffpi");
|
|
182
2730
|
return {
|
|
183
|
-
yaml:
|
|
184
|
-
json:
|
|
2731
|
+
yaml: join10(directory, "config.yaml"),
|
|
2732
|
+
json: join10(directory, "config.json")
|
|
185
2733
|
};
|
|
186
2734
|
}
|
|
187
2735
|
async function loadDiffpiConfig(options = {}) {
|
|
@@ -237,16 +2785,16 @@ function normalizeModelReference(value) {
|
|
|
237
2785
|
}
|
|
238
2786
|
|
|
239
2787
|
// src/mcp.ts
|
|
240
|
-
import { mkdir, readFile as
|
|
241
|
-
import { homedir as
|
|
242
|
-
import { dirname as
|
|
2788
|
+
import { mkdir as mkdir5, readFile as readFile9, writeFile as writeFile6 } from "node:fs/promises";
|
|
2789
|
+
import { homedir as homedir6 } from "node:os";
|
|
2790
|
+
import { dirname as dirname5, join as join11 } from "node:path";
|
|
243
2791
|
var mcp = {
|
|
244
|
-
globalConfigPath(homeDir =
|
|
245
|
-
return
|
|
2792
|
+
globalConfigPath(homeDir = homedir6()) {
|
|
2793
|
+
return join11(homeDir, ".config", "mcp", "mcp.json");
|
|
246
2794
|
},
|
|
247
2795
|
async serversEnsure(servers, options = {}) {
|
|
248
2796
|
const path = options.path ?? mcp.globalConfigPath();
|
|
249
|
-
const currentText = await
|
|
2797
|
+
const currentText = await getOptionalFile2(path);
|
|
250
2798
|
const current = getParsedConfig(currentText, path);
|
|
251
2799
|
const nextServers = { ...current.mcpServers };
|
|
252
2800
|
for (const [name, entry] of Object.entries(servers)) {
|
|
@@ -255,8 +2803,8 @@ var mcp = {
|
|
|
255
2803
|
const next = { ...current, mcpServers: nextServers };
|
|
256
2804
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
257
2805
|
if (changed && !options.dryRun) {
|
|
258
|
-
await
|
|
259
|
-
await
|
|
2806
|
+
await mkdir5(dirname5(path), { recursive: true });
|
|
2807
|
+
await writeFile6(path, `${JSON.stringify(next, null, 2)}
|
|
260
2808
|
`, "utf8");
|
|
261
2809
|
}
|
|
262
2810
|
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
@@ -283,9 +2831,9 @@ function getParsedConfig(content, path) {
|
|
|
283
2831
|
throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
|
|
284
2832
|
}
|
|
285
2833
|
}
|
|
286
|
-
async function
|
|
2834
|
+
async function getOptionalFile2(path) {
|
|
287
2835
|
try {
|
|
288
|
-
return await
|
|
2836
|
+
return await readFile9(path, "utf8");
|
|
289
2837
|
} catch (error) {
|
|
290
2838
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
291
2839
|
return;
|
|
@@ -296,237 +2844,10 @@ function isRecord(value) {
|
|
|
296
2844
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
297
2845
|
}
|
|
298
2846
|
|
|
299
|
-
// src/mise.ts
|
|
300
|
-
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
|
|
301
|
-
import { homedir as homedir3 } from "node:os";
|
|
302
|
-
import { basename, dirname as dirname3, join as join5 } from "node:path";
|
|
303
|
-
|
|
304
|
-
// src/process.ts
|
|
305
|
-
import { constants } from "node:fs";
|
|
306
|
-
import { access } from "node:fs/promises";
|
|
307
|
-
import { delimiter, join as join4 } from "node:path";
|
|
308
|
-
import { spawn } from "node:child_process";
|
|
309
|
-
var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
|
|
310
|
-
async function findExecutable(name) {
|
|
311
|
-
if (name.includes("/")) {
|
|
312
|
-
try {
|
|
313
|
-
await access(name, constants.X_OK);
|
|
314
|
-
return name;
|
|
315
|
-
} catch {
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
320
|
-
if (!directory)
|
|
321
|
-
continue;
|
|
322
|
-
const candidate = join4(directory, name);
|
|
323
|
-
try {
|
|
324
|
-
await access(candidate, constants.X_OK);
|
|
325
|
-
return candidate;
|
|
326
|
-
} catch {}
|
|
327
|
-
}
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
function run(command, args, options = {}) {
|
|
331
|
-
return new Promise((resolve, reject) => {
|
|
332
|
-
const child = spawn(command, args, {
|
|
333
|
-
cwd: options.cwd,
|
|
334
|
-
env: options.env ?? process.env,
|
|
335
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
336
|
-
});
|
|
337
|
-
let stdout = "";
|
|
338
|
-
let stderr = "";
|
|
339
|
-
child.stdout.on("data", (chunk) => {
|
|
340
|
-
stdout = appendBounded(stdout, chunk.toString());
|
|
341
|
-
});
|
|
342
|
-
child.stderr.on("data", (chunk) => {
|
|
343
|
-
stderr = appendBounded(stderr, chunk.toString());
|
|
344
|
-
});
|
|
345
|
-
child.on("error", reject);
|
|
346
|
-
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
async function runChecked(command, args, options = {}) {
|
|
350
|
-
const result = await run(command, args, options);
|
|
351
|
-
if (result.code === 0)
|
|
352
|
-
return result;
|
|
353
|
-
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
354
|
-
throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
355
|
-
}
|
|
356
|
-
function appendBounded(current, next) {
|
|
357
|
-
const combined = current + next;
|
|
358
|
-
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// src/mise.ts
|
|
362
|
-
var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
|
|
363
|
-
var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
|
|
364
|
-
var mise = {
|
|
365
|
-
async executableCheck(name = "mise") {
|
|
366
|
-
return findExecutable(name);
|
|
367
|
-
},
|
|
368
|
-
async install(options = {}) {
|
|
369
|
-
const homeDir = options.homeDir ?? homedir3();
|
|
370
|
-
const platform = options.platform ?? process.platform;
|
|
371
|
-
if (platform === "win32")
|
|
372
|
-
throw new Error("Automatic mise installation supports macOS and Linux only.");
|
|
373
|
-
const installedPath = join5(homeDir, ".local", "bin", "mise");
|
|
374
|
-
if (options.dryRun)
|
|
375
|
-
return installedPath;
|
|
376
|
-
await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
|
|
377
|
-
const executable = await findExecutable(installedPath) ?? await findExecutable("mise");
|
|
378
|
-
if (!executable)
|
|
379
|
-
throw new Error(`mise installation completed, but ${installedPath} was not found.`);
|
|
380
|
-
return executable;
|
|
381
|
-
},
|
|
382
|
-
async hookEnsure(executable, options = {}) {
|
|
383
|
-
const homeDir = options.homeDir ?? homedir3();
|
|
384
|
-
const hook = getShellHook(basename(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
|
|
385
|
-
const current = await getOptionalFile2(hook.path);
|
|
386
|
-
if (current.includes(MISE_HOOK_START))
|
|
387
|
-
return { path: hook.path, changed: false, planned: false };
|
|
388
|
-
if (options.dryRun)
|
|
389
|
-
return { path: hook.path, changed: true, planned: true };
|
|
390
|
-
const separator = current.length === 0 || current.endsWith(`
|
|
391
|
-
`) ? "" : `
|
|
392
|
-
`;
|
|
393
|
-
await mkdir2(dirname3(hook.path), { recursive: true });
|
|
394
|
-
await writeFile2(hook.path, `${current}${separator}${hook.content}`, "utf8");
|
|
395
|
-
return { path: hook.path, changed: true, planned: false };
|
|
396
|
-
},
|
|
397
|
-
async toolCheckGlobal(executable, tool, minimumVersion) {
|
|
398
|
-
const result = await run(executable, ["ls", "--global", "--installed", tool, "--json"]);
|
|
399
|
-
return result.code === 0 && isToolInstalled(result.stdout, minimumVersion);
|
|
400
|
-
},
|
|
401
|
-
async toolInstallGlobal(executable, specification) {
|
|
402
|
-
await runChecked(executable, ["use", "--global", specification]);
|
|
403
|
-
},
|
|
404
|
-
async toolCheckLocal(executable, tool, cwd = process.cwd()) {
|
|
405
|
-
const result = await run(executable, ["ls", "--local", "--installed", tool, "--json"], { cwd });
|
|
406
|
-
return result.code === 0 && isToolInstalled(result.stdout);
|
|
407
|
-
},
|
|
408
|
-
async toolInstallLocal(executable, specification, cwd = process.cwd()) {
|
|
409
|
-
await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
|
|
410
|
-
},
|
|
411
|
-
async toolUpdateAllGlobal(executable, homeDir = homedir3()) {
|
|
412
|
-
await runChecked(executable, ["upgrade"], { cwd: homeDir });
|
|
413
|
-
}
|
|
414
|
-
};
|
|
415
|
-
function getShellHook(shell, executable, homeDir) {
|
|
416
|
-
const command = getShellQuoted(executable);
|
|
417
|
-
switch (shell.toLowerCase()) {
|
|
418
|
-
case "zsh":
|
|
419
|
-
return {
|
|
420
|
-
path: join5(homeDir, ".zshrc"),
|
|
421
|
-
content: `${MISE_HOOK_START}
|
|
422
|
-
eval "$(${command} activate zsh)"
|
|
423
|
-
${MISE_HOOK_END}
|
|
424
|
-
`
|
|
425
|
-
};
|
|
426
|
-
case "fish":
|
|
427
|
-
return {
|
|
428
|
-
path: join5(homeDir, ".config", "fish", "config.fish"),
|
|
429
|
-
content: `${MISE_HOOK_START}
|
|
430
|
-
${command} activate fish | source
|
|
431
|
-
${MISE_HOOK_END}
|
|
432
|
-
`
|
|
433
|
-
};
|
|
434
|
-
case "nu":
|
|
435
|
-
case "nushell":
|
|
436
|
-
return {
|
|
437
|
-
path: join5(homeDir, ".config", "nushell", "config.nu"),
|
|
438
|
-
content: `${MISE_HOOK_START}
|
|
439
|
-
let mise_bin = ${command}
|
|
440
|
-
let mise_path = $nu.default-config-dir | path join mise.nu
|
|
441
|
-
^$mise_bin activate nu | save $mise_path --force
|
|
442
|
-
use ($nu.default-config-dir | path join mise.nu)
|
|
443
|
-
${MISE_HOOK_END}
|
|
444
|
-
`
|
|
445
|
-
};
|
|
446
|
-
case "xonsh":
|
|
447
|
-
return {
|
|
448
|
-
path: join5(homeDir, ".xonshrc"),
|
|
449
|
-
content: `${MISE_HOOK_START}
|
|
450
|
-
execx($(${command} activate xonsh))
|
|
451
|
-
${MISE_HOOK_END}
|
|
452
|
-
`
|
|
453
|
-
};
|
|
454
|
-
case "elvish":
|
|
455
|
-
return {
|
|
456
|
-
path: join5(homeDir, ".config", "elvish", "rc.elv"),
|
|
457
|
-
content: `${MISE_HOOK_START}
|
|
458
|
-
var mise: = (ns [&])
|
|
459
|
-
eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
|
|
460
|
-
mise:activate
|
|
461
|
-
${MISE_HOOK_END}
|
|
462
|
-
`
|
|
463
|
-
};
|
|
464
|
-
case "pwsh":
|
|
465
|
-
case "powershell":
|
|
466
|
-
return {
|
|
467
|
-
path: join5(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
|
|
468
|
-
content: `${MISE_HOOK_START}
|
|
469
|
-
(& ${command} activate pwsh) | Out-String | Invoke-Expression
|
|
470
|
-
${MISE_HOOK_END}
|
|
471
|
-
`
|
|
472
|
-
};
|
|
473
|
-
case "bash":
|
|
474
|
-
default:
|
|
475
|
-
return {
|
|
476
|
-
path: join5(homeDir, ".bashrc"),
|
|
477
|
-
content: `${MISE_HOOK_START}
|
|
478
|
-
eval "$(${command} activate bash)"
|
|
479
|
-
${MISE_HOOK_END}
|
|
480
|
-
`
|
|
481
|
-
};
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
function isToolInstalled(output, minimumVersion) {
|
|
485
|
-
try {
|
|
486
|
-
const value = JSON.parse(output);
|
|
487
|
-
if (!Array.isArray(value))
|
|
488
|
-
return false;
|
|
489
|
-
return value.some((entry) => {
|
|
490
|
-
if (!entry || typeof entry !== "object" || !("installed" in entry) || entry.installed !== true)
|
|
491
|
-
return false;
|
|
492
|
-
if (!minimumVersion)
|
|
493
|
-
return true;
|
|
494
|
-
if (!("version" in entry) || typeof entry.version !== "string")
|
|
495
|
-
return false;
|
|
496
|
-
return isVersionAtLeast(entry.version, minimumVersion);
|
|
497
|
-
});
|
|
498
|
-
} catch {
|
|
499
|
-
return false;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
function isVersionAtLeast(version, minimumVersion) {
|
|
503
|
-
const current = version.match(/^v?(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number);
|
|
504
|
-
const minimum = minimumVersion.match(/^v?(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number);
|
|
505
|
-
if (!current || !minimum)
|
|
506
|
-
return false;
|
|
507
|
-
for (let index = 0;index < minimum.length; index += 1) {
|
|
508
|
-
if (current[index] !== minimum[index])
|
|
509
|
-
return current[index] > minimum[index];
|
|
510
|
-
}
|
|
511
|
-
return true;
|
|
512
|
-
}
|
|
513
|
-
async function getOptionalFile2(path) {
|
|
514
|
-
try {
|
|
515
|
-
return await readFile3(path, "utf8");
|
|
516
|
-
} catch (error) {
|
|
517
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
518
|
-
return "";
|
|
519
|
-
throw error;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
function getShellQuoted(value) {
|
|
523
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
2847
|
// src/pi.ts
|
|
527
|
-
import { mkdir as
|
|
528
|
-
import { homedir as
|
|
529
|
-
import { dirname as
|
|
2848
|
+
import { mkdir as mkdir6, writeFile as writeFile7 } from "node:fs/promises";
|
|
2849
|
+
import { homedir as homedir7 } from "node:os";
|
|
2850
|
+
import { dirname as dirname6, join as join12 } from "node:path";
|
|
530
2851
|
var pi = {
|
|
531
2852
|
executableCheck: findPiExecutable,
|
|
532
2853
|
packageList: listPiPackages,
|
|
@@ -539,19 +2860,19 @@ var pi = {
|
|
|
539
2860
|
configEnsure: ensurePiConfig
|
|
540
2861
|
};
|
|
541
2862
|
async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
|
|
542
|
-
const path =
|
|
2863
|
+
const path = join12(agentDir, "agents", filename);
|
|
543
2864
|
const currentText = await readTextIfExists(path);
|
|
544
2865
|
const changed = currentText !== content;
|
|
545
2866
|
if (changed && !dryRun) {
|
|
546
|
-
await
|
|
547
|
-
await
|
|
2867
|
+
await mkdir6(dirname6(path), { recursive: true });
|
|
2868
|
+
await writeFile7(path, content, "utf8");
|
|
548
2869
|
}
|
|
549
2870
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
550
2871
|
}
|
|
551
|
-
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir =
|
|
552
|
-
const roots = [
|
|
2872
|
+
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join12(homedir7(), ".agents", "skills")) {
|
|
2873
|
+
const roots = [join12(agentDir, "skills"), sharedSkillsDir];
|
|
553
2874
|
for (const root of roots) {
|
|
554
|
-
if (await readTextIfExists(
|
|
2875
|
+
if (await readTextIfExists(join12(root, name, "SKILL.md")) !== undefined)
|
|
555
2876
|
return true;
|
|
556
2877
|
}
|
|
557
2878
|
return false;
|
|
@@ -580,8 +2901,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
|
|
|
580
2901
|
const next = update(current);
|
|
581
2902
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
582
2903
|
if (changed && !dryRun) {
|
|
583
|
-
await
|
|
584
|
-
await
|
|
2904
|
+
await mkdir6(dirname6(path), { recursive: true });
|
|
2905
|
+
await writeFile7(path, `${JSON.stringify(next, null, 2)}
|
|
585
2906
|
`, "utf8");
|
|
586
2907
|
}
|
|
587
2908
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
@@ -600,8 +2921,8 @@ function hasPiPackage(listOutput, source) {
|
|
|
600
2921
|
async function installPiPackage(executable, source) {
|
|
601
2922
|
await runChecked(executable, ["install", source]);
|
|
602
2923
|
}
|
|
603
|
-
function resolvePiAgentDir(homeDir =
|
|
604
|
-
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ?
|
|
2924
|
+
function resolvePiAgentDir(homeDir = homedir7()) {
|
|
2925
|
+
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join12(process.env.XDG_CONFIG_HOME, "pi") : join12(homeDir, ".pi", "agent"));
|
|
605
2926
|
}
|
|
606
2927
|
function parseJsonObject(content, path) {
|
|
607
2928
|
if (!content?.trim())
|
|
@@ -642,13 +2963,18 @@ var PI_PACKAGES = [
|
|
|
642
2963
|
];
|
|
643
2964
|
var PI_SKILL_SOURCES = [
|
|
644
2965
|
{ repository: "arabold/docs-mcp-server", skills: ["docs-manage", "docs-search", "fetch-url"] },
|
|
645
|
-
{ repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
|
|
2966
|
+
{ repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] },
|
|
2967
|
+
{ repository: "cloudvoyant/codevoyant", skills: ["git"] }
|
|
646
2968
|
];
|
|
647
2969
|
var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
|
|
648
2970
|
var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
|
|
2971
|
+
var FORGE_DEPENDENCIES = {
|
|
2972
|
+
github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
|
|
2973
|
+
gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
|
|
2974
|
+
};
|
|
649
2975
|
async function ensureMise(options = {}) {
|
|
650
|
-
const homeDir = options.homeDir ??
|
|
651
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
2976
|
+
const homeDir = options.homeDir ?? homedir8();
|
|
2977
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join13(homeDir, ".local", "bin", "mise"));
|
|
652
2978
|
if (current)
|
|
653
2979
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
654
2980
|
reportProgress(options, "Installing mise");
|
|
@@ -677,7 +3003,11 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
|
|
|
677
3003
|
async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
678
3004
|
const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
|
|
679
3005
|
const actions = [];
|
|
680
|
-
|
|
3006
|
+
const dependencies = [
|
|
3007
|
+
...MISE_DEPENDENCIES,
|
|
3008
|
+
...configuredForges(options).map((forge) => FORGE_DEPENDENCIES[forge])
|
|
3009
|
+
];
|
|
3010
|
+
for (const dependency of dependencies) {
|
|
681
3011
|
const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
|
|
682
3012
|
if (installed) {
|
|
683
3013
|
actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
|
|
@@ -693,9 +3023,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
693
3023
|
async function ensurePiPlugins(options = {}) {
|
|
694
3024
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
695
3025
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
696
|
-
const webSearch = await pi.configEnsure(
|
|
3026
|
+
const webSearch = await pi.configEnsure(join13(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
697
3027
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
698
|
-
const lsp = await pi.configEnsure(
|
|
3028
|
+
const lsp = await pi.configEnsure(join13(agentDir, "pi-lsp.json"), (config) => ({
|
|
699
3029
|
...config,
|
|
700
3030
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
701
3031
|
}), options.dryRun);
|
|
@@ -706,11 +3036,11 @@ async function ensurePiAgents(options = {}) {
|
|
|
706
3036
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
707
3037
|
const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR;
|
|
708
3038
|
const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
|
|
709
|
-
const entries = (await
|
|
3039
|
+
const entries = (await readdir3(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
|
|
710
3040
|
const actions = [];
|
|
711
3041
|
for (const entry of entries) {
|
|
712
|
-
const id =
|
|
713
|
-
const source = await
|
|
3042
|
+
const id = basename4(entry.name, ".md").replace(/^diffpi-/, "");
|
|
3043
|
+
const source = await readFile10(join13(bundledAgentsDir, entry.name), "utf8");
|
|
714
3044
|
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
715
3045
|
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
716
3046
|
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
@@ -719,7 +3049,7 @@ async function ensurePiAgents(options = {}) {
|
|
|
719
3049
|
}
|
|
720
3050
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
721
3051
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
722
|
-
const sharedSkillsDir =
|
|
3052
|
+
const sharedSkillsDir = join13(options.homeDir ?? homedir8(), ".agents", "skills");
|
|
723
3053
|
const actions = [];
|
|
724
3054
|
for (const source of PI_SKILL_SOURCES) {
|
|
725
3055
|
const missing = [];
|
|
@@ -763,6 +3093,14 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
|
|
|
763
3093
|
} else if (options.issueTracker === "jira") {
|
|
764
3094
|
servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
|
|
765
3095
|
}
|
|
3096
|
+
for (const forge of configuredForges(options)) {
|
|
3097
|
+
if (forge === "github") {
|
|
3098
|
+
servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
|
|
3099
|
+
continue;
|
|
3100
|
+
}
|
|
3101
|
+
const host = await gitlabMcpHost(projectDir);
|
|
3102
|
+
servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
|
|
3103
|
+
}
|
|
766
3104
|
const result = await mcp.serversEnsure(servers, {
|
|
767
3105
|
dryRun: options.dryRun,
|
|
768
3106
|
path: mcp.globalConfigPath(options.homeDir)
|
|
@@ -779,6 +3117,8 @@ async function setupPi(options = {}) {
|
|
|
779
3117
|
actions.push(...await ensurePiAgents(options));
|
|
780
3118
|
actions.push(...await ensurePiSkills(miseResult.executable, options));
|
|
781
3119
|
actions.push(...await ensureMcpAdapters(miseResult.executable, options));
|
|
3120
|
+
if (options.bindZedKey)
|
|
3121
|
+
actions.push(...await ensureZedIntegration(options));
|
|
782
3122
|
return {
|
|
783
3123
|
actions,
|
|
784
3124
|
restartPi: setupRequiresRestart(actions)
|
|
@@ -787,6 +3127,40 @@ async function setupPi(options = {}) {
|
|
|
787
3127
|
function setupRequiresRestart(actions) {
|
|
788
3128
|
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"));
|
|
789
3129
|
}
|
|
3130
|
+
async function ensureZedIntegration(options = {}) {
|
|
3131
|
+
if (options.dryRun) {
|
|
3132
|
+
const actions = [
|
|
3133
|
+
createSetupAction("Zed review tasks", "planned", "global static runtime-resolver tasks in tasks.json")
|
|
3134
|
+
];
|
|
3135
|
+
if (options.bindZedKey)
|
|
3136
|
+
actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
|
|
3137
|
+
return actions;
|
|
3138
|
+
}
|
|
3139
|
+
const actions = [];
|
|
3140
|
+
try {
|
|
3141
|
+
const tasks = await ensureZedReviewTask(options.homeDir);
|
|
3142
|
+
actions.push(createSetupAction("Zed review tasks", tasks.changed ? "installed" : "ready", tasks.path));
|
|
3143
|
+
} catch (error) {
|
|
3144
|
+
actions.push(createSetupAction("Zed review tasks", "skipped", error instanceof Error ? error.message : String(error)));
|
|
3145
|
+
}
|
|
3146
|
+
if (options.bindZedKey) {
|
|
3147
|
+
try {
|
|
3148
|
+
const key = await ensureZedReviewKeybinding(options.homeDir);
|
|
3149
|
+
actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
|
|
3150
|
+
} catch (error) {
|
|
3151
|
+
actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
return actions;
|
|
3155
|
+
}
|
|
3156
|
+
async function gitlabMcpHost(projectDir) {
|
|
3157
|
+
try {
|
|
3158
|
+
const vcs = await detectVcs(projectDir);
|
|
3159
|
+
return vcs.provider === "gitlab" && vcs.host ? vcs.host : "gitlab.com";
|
|
3160
|
+
} catch {
|
|
3161
|
+
return "gitlab.com";
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
790
3164
|
function materializeAgentModels(content, agentId, config, availableModels) {
|
|
791
3165
|
const { frontmatter } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
792
3166
|
const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
|
|
@@ -846,6 +3220,10 @@ ${source}`;
|
|
|
846
3220
|
}
|
|
847
3221
|
return actions;
|
|
848
3222
|
}
|
|
3223
|
+
function configuredForges(options) {
|
|
3224
|
+
const selected = options.forges ?? (options.forge && options.forge !== "none" ? [options.forge] : []);
|
|
3225
|
+
return [...new Set(selected)];
|
|
3226
|
+
}
|
|
849
3227
|
function getTextList(value) {
|
|
850
3228
|
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
851
3229
|
return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
@@ -868,11 +3246,14 @@ function getRecord(value) {
|
|
|
868
3246
|
}
|
|
869
3247
|
|
|
870
3248
|
// src/tools/setup.ts
|
|
871
|
-
var setupParametersSchema =
|
|
872
|
-
issueTracker:
|
|
3249
|
+
var setupParametersSchema = z8.object({
|
|
3250
|
+
issueTracker: z8.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
|
|
3251
|
+
forges: z8.array(z8.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
|
|
3252
|
+
forge: z8.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers."),
|
|
3253
|
+
bindZedKey: z8.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
|
|
873
3254
|
});
|
|
874
|
-
var setupParameters =
|
|
875
|
-
var diffpiSetupTool =
|
|
3255
|
+
var setupParameters = z8.toJSONSchema(setupParametersSchema, { io: "input" });
|
|
3256
|
+
var diffpiSetupTool = defineTool4({
|
|
876
3257
|
name: "diffpi_setup",
|
|
877
3258
|
label: "diffpi setup",
|
|
878
3259
|
description: "Install or repair the @difflab/pi environment. This mutates user-level tool installations and configuration files.",
|
|
@@ -889,6 +3270,9 @@ var diffpiSetupTool = defineTool3({
|
|
|
889
3270
|
const params = setupParametersSchema.parse(input);
|
|
890
3271
|
const result = await setupPi({
|
|
891
3272
|
issueTracker: params.issueTracker,
|
|
3273
|
+
forges: params.forges,
|
|
3274
|
+
forge: params.forge,
|
|
3275
|
+
bindZedKey: params.bindZedKey,
|
|
892
3276
|
installMiseHook: true,
|
|
893
3277
|
availableModels: ctx.modelRegistry.getAvailable(),
|
|
894
3278
|
onProgress(message) {
|
|
@@ -898,7 +3282,7 @@ var diffpiSetupTool = defineTool3({
|
|
|
898
3282
|
return formatResult(result, "Setup complete.");
|
|
899
3283
|
}
|
|
900
3284
|
});
|
|
901
|
-
var diffpiValidateTool =
|
|
3285
|
+
var diffpiValidateTool = defineTool4({
|
|
902
3286
|
name: "diffpi_validate",
|
|
903
3287
|
label: "diffpi validate",
|
|
904
3288
|
description: "Inspect the @difflab/pi environment without installing software or changing configuration files.",
|
|
@@ -914,6 +3298,9 @@ var diffpiValidateTool = defineTool3({
|
|
|
914
3298
|
const params = setupParametersSchema.parse(input);
|
|
915
3299
|
const result = await setupPi({
|
|
916
3300
|
issueTracker: params.issueTracker,
|
|
3301
|
+
forges: params.forges,
|
|
3302
|
+
forge: params.forge,
|
|
3303
|
+
bindZedKey: params.bindZedKey,
|
|
917
3304
|
installMiseHook: true,
|
|
918
3305
|
dryRun: true,
|
|
919
3306
|
availableModels: ctx.modelRegistry.getAvailable()
|
|
@@ -936,14 +3323,50 @@ ${lines.join(`
|
|
|
936
3323
|
};
|
|
937
3324
|
}
|
|
938
3325
|
|
|
3326
|
+
// src/tools/templates.ts
|
|
3327
|
+
import { defineTool as defineTool5 } from "@earendil-works/pi-coding-agent";
|
|
3328
|
+
import { z as z9 } from "zod";
|
|
3329
|
+
var templateSchema = z9.object({
|
|
3330
|
+
name: z9.string().min(1),
|
|
3331
|
+
variables: z9.record(z9.string(), z9.string()).optional(),
|
|
3332
|
+
homeDir: z9.string().optional()
|
|
3333
|
+
});
|
|
3334
|
+
var diffpiTemplateTool = defineTool5({
|
|
3335
|
+
name: "diffpi_template",
|
|
3336
|
+
label: "diffpi template",
|
|
3337
|
+
description: "Load a bundled Diffpi template or a user override and render named variables.",
|
|
3338
|
+
promptSnippet: "Use diffpi_template for package workflow templates",
|
|
3339
|
+
promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
|
|
3340
|
+
parameters: z9.toJSONSchema(templateSchema, { io: "input" }),
|
|
3341
|
+
executionMode: "parallel",
|
|
3342
|
+
async execute(_id, input) {
|
|
3343
|
+
const params = templateSchema.parse(input);
|
|
3344
|
+
const template = await loadTemplate(params.name, { homeDir: params.homeDir });
|
|
3345
|
+
const content = renderTemplate(template.content, params.variables ?? {});
|
|
3346
|
+
return {
|
|
3347
|
+
content: [{ type: "text", text: content }],
|
|
3348
|
+
details: { name: params.name, path: template.path, source: template.source }
|
|
3349
|
+
};
|
|
3350
|
+
}
|
|
3351
|
+
});
|
|
3352
|
+
|
|
939
3353
|
// src/tools/index.ts
|
|
940
3354
|
function createPiTools(pi, modes) {
|
|
941
|
-
return [
|
|
3355
|
+
return [
|
|
3356
|
+
diffpiSetupTool,
|
|
3357
|
+
diffpiValidateTool,
|
|
3358
|
+
createDiffpiReloadTool(pi),
|
|
3359
|
+
diffpiTemplateTool,
|
|
3360
|
+
...createModeTools(modes),
|
|
3361
|
+
...createReviewTools()
|
|
3362
|
+
];
|
|
942
3363
|
}
|
|
943
3364
|
export {
|
|
944
3365
|
createDiffpiReloadTool,
|
|
945
3366
|
createModeTools,
|
|
946
3367
|
createPiTools,
|
|
3368
|
+
createReviewTools,
|
|
947
3369
|
diffpiSetupTool,
|
|
3370
|
+
diffpiTemplateTool,
|
|
948
3371
|
diffpiValidateTool
|
|
949
3372
|
};
|