@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/index.js
CHANGED
|
@@ -94,76 +94,14 @@ ${document}
|
|
|
94
94
|
function normalizeModelReference(value) {
|
|
95
95
|
return value.toLowerCase().replace(/^~/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
96
96
|
}
|
|
97
|
-
// src/
|
|
98
|
-
import {
|
|
99
|
-
import { homedir as homedir2 } from "node:os";
|
|
100
|
-
import { dirname, join as join2 } from "node:path";
|
|
101
|
-
var mcp = {
|
|
102
|
-
globalConfigPath(homeDir = homedir2()) {
|
|
103
|
-
return join2(homeDir, ".config", "mcp", "mcp.json");
|
|
104
|
-
},
|
|
105
|
-
async serversEnsure(servers, options = {}) {
|
|
106
|
-
const path = options.path ?? mcp.globalConfigPath();
|
|
107
|
-
const currentText = await getOptionalFile(path);
|
|
108
|
-
const current = getParsedConfig(currentText, path);
|
|
109
|
-
const nextServers = { ...current.mcpServers };
|
|
110
|
-
for (const [name, entry] of Object.entries(servers)) {
|
|
111
|
-
nextServers[name] = mergeEntry(nextServers[name], entry);
|
|
112
|
-
}
|
|
113
|
-
const next = { ...current, mcpServers: nextServers };
|
|
114
|
-
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
115
|
-
if (changed && !options.dryRun) {
|
|
116
|
-
await mkdir(dirname(path), { recursive: true });
|
|
117
|
-
await writeFile(path, `${JSON.stringify(next, null, 2)}
|
|
118
|
-
`, "utf8");
|
|
119
|
-
}
|
|
120
|
-
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
function mergeEntry(current, required) {
|
|
124
|
-
const merged = { ...current, ...required };
|
|
125
|
-
if (current?.env || required.env)
|
|
126
|
-
merged.env = { ...current?.env, ...required.env };
|
|
127
|
-
return merged;
|
|
128
|
-
}
|
|
129
|
-
function getParsedConfig(content, path) {
|
|
130
|
-
if (!content?.trim())
|
|
131
|
-
return { mcpServers: {} };
|
|
132
|
-
try {
|
|
133
|
-
const value = JSON.parse(content);
|
|
134
|
-
if (!isRecord(value))
|
|
135
|
-
throw new Error("not an object");
|
|
136
|
-
const servers = value.mcpServers;
|
|
137
|
-
if (servers !== undefined && !isRecord(servers))
|
|
138
|
-
throw new Error("mcpServers is not an object");
|
|
139
|
-
return { ...value, mcpServers: servers ?? {} };
|
|
140
|
-
} catch {
|
|
141
|
-
throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
async function getOptionalFile(path) {
|
|
145
|
-
try {
|
|
146
|
-
return await readFile2(path, "utf8");
|
|
147
|
-
} catch (error) {
|
|
148
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
149
|
-
return;
|
|
150
|
-
throw error;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
function isRecord(value) {
|
|
154
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
155
|
-
}
|
|
156
|
-
// src/mise.ts
|
|
157
|
-
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
|
|
158
|
-
import { homedir as homedir3 } from "node:os";
|
|
159
|
-
import { basename, dirname as dirname2, join as join4 } from "node:path";
|
|
97
|
+
// src/environment.ts
|
|
98
|
+
import { basename } from "node:path";
|
|
160
99
|
|
|
161
|
-
// src/
|
|
100
|
+
// src/extensions/processx.ts
|
|
101
|
+
import { spawn } from "node:child_process";
|
|
162
102
|
import { constants } from "node:fs";
|
|
163
103
|
import { access } from "node:fs/promises";
|
|
164
|
-
import { delimiter, join as
|
|
165
|
-
import { spawn } from "node:child_process";
|
|
166
|
-
var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
|
|
104
|
+
import { delimiter, join as join2 } from "node:path";
|
|
167
105
|
async function findExecutable(name) {
|
|
168
106
|
if (name.includes("/")) {
|
|
169
107
|
try {
|
|
@@ -176,7 +114,7 @@ async function findExecutable(name) {
|
|
|
176
114
|
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
177
115
|
if (!directory)
|
|
178
116
|
continue;
|
|
179
|
-
const candidate =
|
|
117
|
+
const candidate = join2(directory, name);
|
|
180
118
|
try {
|
|
181
119
|
await access(candidate, constants.X_OK);
|
|
182
120
|
return candidate;
|
|
@@ -189,18 +127,35 @@ function run(command, args, options = {}) {
|
|
|
189
127
|
const child = spawn(command, args, {
|
|
190
128
|
cwd: options.cwd,
|
|
191
129
|
env: options.env ?? process.env,
|
|
192
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
130
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
|
|
193
131
|
});
|
|
194
132
|
let stdout = "";
|
|
195
133
|
let stderr = "";
|
|
196
|
-
|
|
197
|
-
|
|
134
|
+
const stdoutChunks = [];
|
|
135
|
+
const stderrChunks = [];
|
|
136
|
+
const unbounded = options.capture === "unbounded";
|
|
137
|
+
child.stdout?.on("data", (chunk) => {
|
|
138
|
+
const text = chunk.toString();
|
|
139
|
+
if (unbounded)
|
|
140
|
+
stdoutChunks.push(text);
|
|
141
|
+
else
|
|
142
|
+
stdout = appendBounded(stdout, text);
|
|
198
143
|
});
|
|
199
|
-
child.stderr
|
|
200
|
-
|
|
144
|
+
child.stderr?.on("data", (chunk) => {
|
|
145
|
+
const text = chunk.toString();
|
|
146
|
+
if (unbounded)
|
|
147
|
+
stderrChunks.push(text);
|
|
148
|
+
else
|
|
149
|
+
stderr = appendBounded(stderr, text);
|
|
201
150
|
});
|
|
202
151
|
child.on("error", reject);
|
|
203
|
-
child.on("close", (code) => resolve({
|
|
152
|
+
child.on("close", (code) => resolve({
|
|
153
|
+
code: code ?? 1,
|
|
154
|
+
stdout: unbounded ? stdoutChunks.join("") : stdout,
|
|
155
|
+
stderr: unbounded ? stderrChunks.join("") : stderr
|
|
156
|
+
}));
|
|
157
|
+
if (options.input !== undefined && child.stdin)
|
|
158
|
+
child.stdin.end(options.input);
|
|
204
159
|
});
|
|
205
160
|
}
|
|
206
161
|
async function runChecked(command, args, options = {}) {
|
|
@@ -210,24 +165,555 @@ async function runChecked(command, args, options = {}) {
|
|
|
210
165
|
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
211
166
|
throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
212
167
|
}
|
|
168
|
+
var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
|
|
213
169
|
function appendBounded(current, next) {
|
|
214
170
|
const combined = current + next;
|
|
215
171
|
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
216
172
|
}
|
|
217
173
|
|
|
218
|
-
// src/
|
|
174
|
+
// src/extensions/zedx.ts
|
|
175
|
+
import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
|
|
176
|
+
import { homedir as homedir2 } from "node:os";
|
|
177
|
+
import { dirname, join as join3 } from "node:path";
|
|
178
|
+
var ZED_LOCAL_REVIEW_TASK_NAME = "diffpi: tuicr local review";
|
|
179
|
+
var ZED_REVIEW_TASK_NAME = ZED_LOCAL_REVIEW_TASK_NAME;
|
|
180
|
+
var ZED_PR_REVIEW_TASK_NAME = "diffpi: tuicr PR review";
|
|
181
|
+
var LEGACY_ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
|
|
182
|
+
var REVIEW_KEYBINDING = "cmd-alt-r";
|
|
183
|
+
function zedTasksPath(homeDir = homedir2()) {
|
|
184
|
+
return join3(homeDir, ".config", "zed", "tasks.json");
|
|
185
|
+
}
|
|
186
|
+
function zedKeymapPath(homeDir = homedir2()) {
|
|
187
|
+
return join3(homeDir, ".config", "zed", "keymap.json");
|
|
188
|
+
}
|
|
189
|
+
async function ensureZedReviewTask(homeDir = homedir2(), _command = ["tuicr", "-w", "-r", "main..HEAD"]) {
|
|
190
|
+
const path = zedTasksPath(homeDir);
|
|
191
|
+
const currentText = await readOptional(path);
|
|
192
|
+
const tasks = parseJsonArray(currentText, path);
|
|
193
|
+
const migrated = tasks.filter((task) => task.label !== LEGACY_ZED_REVIEW_TASK_NAME);
|
|
194
|
+
const next = [...migrated];
|
|
195
|
+
for (const task of [localReviewTask(), prReviewTask()]) {
|
|
196
|
+
const index = next.findIndex((existing) => existing.label === task.label);
|
|
197
|
+
if (index >= 0)
|
|
198
|
+
next[index] = { ...next[index], ...task };
|
|
199
|
+
else
|
|
200
|
+
next.push(task);
|
|
201
|
+
}
|
|
202
|
+
const changed = JSON.stringify(tasks) !== JSON.stringify(next);
|
|
203
|
+
if (changed)
|
|
204
|
+
await writeJson(path, next);
|
|
205
|
+
return { path, changed, existed: currentText !== undefined };
|
|
206
|
+
}
|
|
207
|
+
async function ensureZedReviewKeybinding(homeDir = homedir2()) {
|
|
208
|
+
const path = zedKeymapPath(homeDir);
|
|
209
|
+
const currentText = await readOptional(path);
|
|
210
|
+
const entries = parseJsonArray(currentText, path);
|
|
211
|
+
const migrated = entries.map(migrateReviewKeymapEntry);
|
|
212
|
+
const alreadyBound = migrated.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
|
|
213
|
+
const next = alreadyBound ? migrated : [
|
|
214
|
+
...migrated,
|
|
215
|
+
{
|
|
216
|
+
context: "Workspace",
|
|
217
|
+
bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_LOCAL_REVIEW_TASK_NAME }] }
|
|
218
|
+
}
|
|
219
|
+
];
|
|
220
|
+
const changed = JSON.stringify(entries) !== JSON.stringify(next);
|
|
221
|
+
if (changed)
|
|
222
|
+
await writeJson(path, next);
|
|
223
|
+
return { path, changed, existed: currentText !== undefined };
|
|
224
|
+
}
|
|
225
|
+
function zedReviewTaskName(command) {
|
|
226
|
+
return command[0] === "tuicr" && command[1] === "pr" ? ZED_PR_REVIEW_TASK_NAME : ZED_LOCAL_REVIEW_TASK_NAME;
|
|
227
|
+
}
|
|
228
|
+
var LOCAL_REVIEW_SCRIPT = `set -eu
|
|
229
|
+
remote=$(git remote get-url origin 2>/dev/null || true)
|
|
230
|
+
branch=$(git branch --show-current)
|
|
231
|
+
base=
|
|
232
|
+
case "$remote" in
|
|
233
|
+
*github.com*)
|
|
234
|
+
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)
|
|
235
|
+
[ -n "$base" ] || base=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null || true)
|
|
236
|
+
;;
|
|
237
|
+
*gitlab*)
|
|
238
|
+
mr=$(glab mr list --source-branch "$branch" --order created_at --sort desc --per-page 100 --output json 2>/dev/null || true)
|
|
239
|
+
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{}})')
|
|
240
|
+
[ -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)
|
|
241
|
+
;;
|
|
242
|
+
esac
|
|
243
|
+
[ -n "$base" ] || { echo 'diffpi: could not resolve a GitHub/GitLab review base' >&2; exit 1; }
|
|
244
|
+
exec tuicr -w -r "$base..HEAD"`;
|
|
245
|
+
var PR_REVIEW_SCRIPT = `set -eu
|
|
246
|
+
remote=$(git remote get-url origin 2>/dev/null || true)
|
|
247
|
+
branch=$(git branch --show-current)
|
|
248
|
+
case "$remote" in
|
|
249
|
+
*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) ;;
|
|
250
|
+
*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) ;;
|
|
251
|
+
*) number= ;;
|
|
252
|
+
esac
|
|
253
|
+
[ -n "$number" ] || { echo 'diffpi: could not resolve the current GitHub PR or GitLab MR' >&2; exit 1; }
|
|
254
|
+
exec tuicr pr "$number"`;
|
|
255
|
+
function staticReviewTask(label, script) {
|
|
256
|
+
return {
|
|
257
|
+
label,
|
|
258
|
+
command: "sh",
|
|
259
|
+
args: ["-lc", script],
|
|
260
|
+
cwd: "$ZED_WORKTREE_ROOT",
|
|
261
|
+
use_new_terminal: true,
|
|
262
|
+
reveal: "always",
|
|
263
|
+
reveal_target: "center"
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function localReviewTask() {
|
|
267
|
+
return staticReviewTask(ZED_LOCAL_REVIEW_TASK_NAME, LOCAL_REVIEW_SCRIPT);
|
|
268
|
+
}
|
|
269
|
+
function prReviewTask() {
|
|
270
|
+
return staticReviewTask(ZED_PR_REVIEW_TASK_NAME, PR_REVIEW_SCRIPT);
|
|
271
|
+
}
|
|
272
|
+
function bindsReviewTask(payload) {
|
|
273
|
+
return typeof payload === "object" && payload !== null && payload.task_name === ZED_LOCAL_REVIEW_TASK_NAME;
|
|
274
|
+
}
|
|
275
|
+
function migrateReviewKeymapEntry(entry) {
|
|
276
|
+
const bindings = Object.fromEntries(Object.entries(entry.bindings ?? {}).map(([key, action]) => {
|
|
277
|
+
if (Array.isArray(action) && action[0] === "task::Spawn" && typeof action[1] === "object" && action[1] !== null && action[1].task_name === LEGACY_ZED_REVIEW_TASK_NAME) {
|
|
278
|
+
return [key, ["task::Spawn", { ...action[1], task_name: ZED_LOCAL_REVIEW_TASK_NAME }]];
|
|
279
|
+
}
|
|
280
|
+
return [key, action];
|
|
281
|
+
}));
|
|
282
|
+
return entry.bindings ? { ...entry, bindings } : entry;
|
|
283
|
+
}
|
|
284
|
+
async function readOptional(path) {
|
|
285
|
+
try {
|
|
286
|
+
return await readFile2(path, "utf8");
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
289
|
+
return;
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function parseJsonArray(content, path) {
|
|
294
|
+
if (!content?.trim())
|
|
295
|
+
return [];
|
|
296
|
+
let value;
|
|
297
|
+
try {
|
|
298
|
+
value = JSON.parse(content);
|
|
299
|
+
} catch {
|
|
300
|
+
throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
|
|
301
|
+
}
|
|
302
|
+
if (!Array.isArray(value))
|
|
303
|
+
throw new Error(`Expected a JSON array in ${path}.`);
|
|
304
|
+
return value;
|
|
305
|
+
}
|
|
306
|
+
async function writeJson(path, value) {
|
|
307
|
+
await mkdir(dirname(path), { recursive: true });
|
|
308
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}
|
|
309
|
+
`, "utf8");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/environment.ts
|
|
313
|
+
function detectIde(env = process.env) {
|
|
314
|
+
const program = (env.TERM_PROGRAM ?? "").toLowerCase();
|
|
315
|
+
if (env.ZED_TERM === "true" || program === "zed")
|
|
316
|
+
return "zed";
|
|
317
|
+
if (env.CURSOR_TRACE_ID || program === "cursor")
|
|
318
|
+
return "cursor";
|
|
319
|
+
if (env.WINDSURF_ENV || program === "windsurf")
|
|
320
|
+
return "windsurf";
|
|
321
|
+
if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
|
|
322
|
+
return "jetbrains";
|
|
323
|
+
if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
|
|
324
|
+
return "vscode";
|
|
325
|
+
return "unknown";
|
|
326
|
+
}
|
|
327
|
+
function detectMux(env = process.env) {
|
|
328
|
+
if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
|
|
329
|
+
return "zellij";
|
|
330
|
+
if (env.TMUX)
|
|
331
|
+
return "tmux";
|
|
332
|
+
if (env.STY)
|
|
333
|
+
return "screen";
|
|
334
|
+
return "none";
|
|
335
|
+
}
|
|
336
|
+
function detectShell(env = process.env) {
|
|
337
|
+
return env.SHELL ? basename(env.SHELL) : "unknown";
|
|
338
|
+
}
|
|
339
|
+
async function detectVcs(cwd) {
|
|
340
|
+
const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
|
|
341
|
+
const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
|
342
|
+
const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
|
|
343
|
+
return { ...parseRemote(remote), branch, root };
|
|
344
|
+
}
|
|
345
|
+
function parseRemote(remote) {
|
|
346
|
+
const empty = { provider: "none", host: "", owner: "", repo: "" };
|
|
347
|
+
if (!remote)
|
|
348
|
+
return empty;
|
|
349
|
+
const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
|
|
350
|
+
const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
351
|
+
const match = scp ?? url;
|
|
352
|
+
if (!match)
|
|
353
|
+
return empty;
|
|
354
|
+
const host = match[1];
|
|
355
|
+
const segments = match[2].split("/").filter(Boolean);
|
|
356
|
+
if (segments.length < 2)
|
|
357
|
+
return { ...empty, host };
|
|
358
|
+
const repo = segments.at(-1) ?? "";
|
|
359
|
+
const owner = segments.slice(0, -1).join("/");
|
|
360
|
+
const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
|
|
361
|
+
return { provider, host, owner, repo };
|
|
362
|
+
}
|
|
363
|
+
async function openInNewTab(command, opts) {
|
|
364
|
+
const env = opts.env ?? process.env;
|
|
365
|
+
const name = opts.name ?? "review";
|
|
366
|
+
const printable = command.join(" ");
|
|
367
|
+
const mux = detectMux(env);
|
|
368
|
+
if (mux !== "none") {
|
|
369
|
+
const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
|
|
370
|
+
if (opened)
|
|
371
|
+
return opened;
|
|
372
|
+
}
|
|
373
|
+
if (detectIde(env) === "zed") {
|
|
374
|
+
try {
|
|
375
|
+
const taskName = zedReviewTaskName(command);
|
|
376
|
+
await ensureZedReviewTask(opts.homeDir, command);
|
|
377
|
+
return {
|
|
378
|
+
launched: false,
|
|
379
|
+
configured: true,
|
|
380
|
+
via: "zed-task",
|
|
381
|
+
command: printable,
|
|
382
|
+
taskName,
|
|
383
|
+
instruction: `Run the Zed task "${taskName}".`
|
|
384
|
+
};
|
|
385
|
+
} catch {}
|
|
386
|
+
}
|
|
387
|
+
return { launched: false, via: "print", command: printable };
|
|
388
|
+
}
|
|
389
|
+
function screenWindowArgs(command, cwd, name) {
|
|
390
|
+
return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
|
|
391
|
+
}
|
|
392
|
+
async function openMuxTab(mux, command, cwd, name, printable) {
|
|
393
|
+
if (mux === "zellij" && await findExecutable("zellij")) {
|
|
394
|
+
const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
395
|
+
if (result.code === 0)
|
|
396
|
+
return { launched: true, via: "zellij", command: printable };
|
|
397
|
+
const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
398
|
+
if (fallback.code === 0)
|
|
399
|
+
return { launched: true, via: "zellij-run", command: printable };
|
|
400
|
+
}
|
|
401
|
+
if (mux === "tmux" && await findExecutable("tmux")) {
|
|
402
|
+
const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
|
|
403
|
+
if (result.code === 0)
|
|
404
|
+
return { launched: true, via: "tmux", command: printable };
|
|
405
|
+
}
|
|
406
|
+
if (mux === "screen" && await findExecutable("screen")) {
|
|
407
|
+
const result = await run("screen", screenWindowArgs(command, cwd, name));
|
|
408
|
+
if (result.code === 0)
|
|
409
|
+
return { launched: true, via: "screen", command: printable };
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
// src/extensions/gitx.ts
|
|
414
|
+
import { realpath } from "node:fs/promises";
|
|
415
|
+
import { basename as basename2, isAbsolute, join as join4, resolve } from "node:path";
|
|
416
|
+
async function gitToplevel(cwd) {
|
|
417
|
+
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
418
|
+
const top = result.stdout.trim();
|
|
419
|
+
return result.code === 0 && top ? top : resolve(cwd);
|
|
420
|
+
}
|
|
421
|
+
async function inspectGitRepository(cwd) {
|
|
422
|
+
const root = await gitToplevel(cwd);
|
|
423
|
+
const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
424
|
+
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
425
|
+
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
426
|
+
const common = commonResult.stdout.trim();
|
|
427
|
+
const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join4(root, common)) : root;
|
|
428
|
+
const commonDir = await canonicalPath(commonPath);
|
|
429
|
+
return {
|
|
430
|
+
root,
|
|
431
|
+
commonDir,
|
|
432
|
+
...remote ? { remote } : {},
|
|
433
|
+
name: remote ? repositoryName(remote) : basename2(resolve(commonDir, "..")) || basename2(root),
|
|
434
|
+
identity: remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${commonDir}`
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
function normalizeRemote(remote) {
|
|
438
|
+
return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
439
|
+
}
|
|
440
|
+
function repositoryName(remote) {
|
|
441
|
+
const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
442
|
+
return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
|
|
443
|
+
}
|
|
444
|
+
async function canonicalPath(path) {
|
|
445
|
+
try {
|
|
446
|
+
return await realpath(path);
|
|
447
|
+
} catch {
|
|
448
|
+
return resolve(path);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// src/extensions/ghx.ts
|
|
452
|
+
function gh(args, options) {
|
|
453
|
+
return run("gh", args, options);
|
|
454
|
+
}
|
|
455
|
+
function ghChecked(args, options) {
|
|
456
|
+
return runChecked("gh", args, options);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// src/vcs/helpers.ts
|
|
460
|
+
function isConfirmedMissingChange(provider, output) {
|
|
461
|
+
const message = output.toLowerCase();
|
|
462
|
+
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;
|
|
463
|
+
}
|
|
464
|
+
function parseJson(value, name) {
|
|
465
|
+
try {
|
|
466
|
+
return JSON.parse(value);
|
|
467
|
+
} catch {
|
|
468
|
+
throw new Error(`Cannot parse the ${name} response as JSON.`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
function commandFailure(command, args, result) {
|
|
472
|
+
return new Error(`${command} ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`}`);
|
|
473
|
+
}
|
|
474
|
+
function requireBranchName(output, provider) {
|
|
475
|
+
const branch = output.trim();
|
|
476
|
+
if (!branch || branch === "null")
|
|
477
|
+
throw new Error(`${provider} did not return a default branch.`);
|
|
478
|
+
return branch;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/vcs/github.ts
|
|
482
|
+
function GitHubVcsBackend(vcs) {
|
|
483
|
+
const repo = ["--repo", `${vcs.owner}/${vcs.repo}`];
|
|
484
|
+
const viewPr = async (idOrBranch) => {
|
|
485
|
+
const args = [
|
|
486
|
+
"pr",
|
|
487
|
+
"view",
|
|
488
|
+
idOrBranch,
|
|
489
|
+
...repo,
|
|
490
|
+
"--json",
|
|
491
|
+
"number,title,url,isDraft,baseRefName,headRefName,headRefOid"
|
|
492
|
+
];
|
|
493
|
+
const result = await gh(args);
|
|
494
|
+
if (result.code !== 0) {
|
|
495
|
+
if (isConfirmedMissingChange("github", result.stderr || result.stdout))
|
|
496
|
+
return;
|
|
497
|
+
throw commandFailure("gh", args, result);
|
|
498
|
+
}
|
|
499
|
+
if (!result.stdout.trim())
|
|
500
|
+
throw new Error("GitHub returned an empty pull request response.");
|
|
501
|
+
const data = parseJson(result.stdout, "GitHub pull request");
|
|
502
|
+
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")
|
|
503
|
+
throw new Error("GitHub returned an invalid pull request response.");
|
|
504
|
+
return {
|
|
505
|
+
number: data.number,
|
|
506
|
+
title: data.title,
|
|
507
|
+
url: data.url,
|
|
508
|
+
isDraft: data.isDraft,
|
|
509
|
+
baseRef: data.baseRefName,
|
|
510
|
+
headRef: data.headRefName,
|
|
511
|
+
headSha: data.headRefOid
|
|
512
|
+
};
|
|
513
|
+
};
|
|
514
|
+
return {
|
|
515
|
+
provider: "github",
|
|
516
|
+
async createDraftPr(options) {
|
|
517
|
+
const args = [
|
|
518
|
+
"pr",
|
|
519
|
+
"create",
|
|
520
|
+
...repo,
|
|
521
|
+
"--title",
|
|
522
|
+
options.title,
|
|
523
|
+
"--body",
|
|
524
|
+
options.body,
|
|
525
|
+
"--base",
|
|
526
|
+
options.base,
|
|
527
|
+
"--head",
|
|
528
|
+
options.head
|
|
529
|
+
];
|
|
530
|
+
if (options.draft !== false)
|
|
531
|
+
args.push("--draft");
|
|
532
|
+
await ghChecked(args);
|
|
533
|
+
const ref = await viewPr(options.head);
|
|
534
|
+
if (!ref)
|
|
535
|
+
throw new Error("Draft PR created but could not be resolved.");
|
|
536
|
+
return ref;
|
|
537
|
+
},
|
|
538
|
+
viewPr,
|
|
539
|
+
async defaultBranch() {
|
|
540
|
+
return requireBranchName((await ghChecked([
|
|
541
|
+
"repo",
|
|
542
|
+
"view",
|
|
543
|
+
`${vcs.owner}/${vcs.repo}`,
|
|
544
|
+
"--json",
|
|
545
|
+
"defaultBranchRef",
|
|
546
|
+
"--jq",
|
|
547
|
+
".defaultBranchRef.name"
|
|
548
|
+
])).stdout, "GitHub");
|
|
549
|
+
},
|
|
550
|
+
async prDiff(id) {
|
|
551
|
+
return (await ghChecked(["pr", "diff", String(id), ...repo], { capture: "unbounded" })).stdout;
|
|
552
|
+
},
|
|
553
|
+
async prChecks(id) {
|
|
554
|
+
return (await gh(["pr", "checks", String(id), ...repo])).stdout;
|
|
555
|
+
},
|
|
556
|
+
async markReady(id) {
|
|
557
|
+
await ghChecked(["pr", "ready", String(id), ...repo]);
|
|
558
|
+
},
|
|
559
|
+
async closePr(id, comment) {
|
|
560
|
+
const args = ["pr", "close", String(id), ...repo];
|
|
561
|
+
if (comment)
|
|
562
|
+
args.push("--comment", comment);
|
|
563
|
+
await ghChecked(args);
|
|
564
|
+
},
|
|
565
|
+
async mergePr(id, subject) {
|
|
566
|
+
const readiness = await ghChecked([
|
|
567
|
+
"pr",
|
|
568
|
+
"view",
|
|
569
|
+
String(id),
|
|
570
|
+
...repo,
|
|
571
|
+
"--json",
|
|
572
|
+
"isDraft,state,reviewDecision,mergeStateStatus,statusCheckRollup"
|
|
573
|
+
]);
|
|
574
|
+
assertGitHubMergeReady(readiness.stdout);
|
|
575
|
+
await ghChecked(["pr", "merge", String(id), ...repo, "--squash", "--subject", subject]);
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function assertGitHubMergeReady(input) {
|
|
580
|
+
const data = parseJson(input, "GitHub readiness");
|
|
581
|
+
const blockers = [];
|
|
582
|
+
if (data.state !== "OPEN")
|
|
583
|
+
blockers.push(`pull request state is ${data.state ?? "unknown"}`);
|
|
584
|
+
if (data.isDraft)
|
|
585
|
+
blockers.push("pull request is still a draft");
|
|
586
|
+
if (data.reviewDecision !== "APPROVED")
|
|
587
|
+
blockers.push(`review decision is ${data.reviewDecision || "not approved"}`);
|
|
588
|
+
if (data.mergeStateStatus !== "CLEAN")
|
|
589
|
+
blockers.push(`merge state is ${data.mergeStateStatus ?? "unknown"}`);
|
|
590
|
+
for (const check of data.statusCheckRollup ?? []) {
|
|
591
|
+
const name = check.name ?? check.context ?? "unnamed check";
|
|
592
|
+
if (check.__typename === "CheckRun") {
|
|
593
|
+
if (check.status !== "COMPLETED")
|
|
594
|
+
blockers.push(`${name} is ${check.status?.toLowerCase() ?? "pending"}`);
|
|
595
|
+
else if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(check.conclusion ?? ""))
|
|
596
|
+
blockers.push(`${name} concluded ${(check.conclusion ?? "unknown").toLowerCase()}`);
|
|
597
|
+
} else if (check.state !== "SUCCESS")
|
|
598
|
+
blockers.push(`${name} is ${(check.state ?? "pending").toLowerCase()}`);
|
|
599
|
+
}
|
|
600
|
+
if (blockers.length > 0)
|
|
601
|
+
throw new Error(`Merge blocked: ${blockers.join("; ")}.`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/extensions/glabx.ts
|
|
605
|
+
function glab(args, options) {
|
|
606
|
+
return run("glab", args, options);
|
|
607
|
+
}
|
|
608
|
+
function glabChecked(args, options) {
|
|
609
|
+
return runChecked("glab", args, options);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// src/vcs/gitlab.ts
|
|
613
|
+
function GitLabVcsBackend(vcs) {
|
|
614
|
+
const project = `${vcs.owner}/${vcs.repo}`;
|
|
615
|
+
const viewPr = async (idOrBranch) => {
|
|
616
|
+
const args = ["mr", "view", idOrBranch, "--repo", project, "--output", "json"];
|
|
617
|
+
const result = await glab(args);
|
|
618
|
+
if (result.code !== 0) {
|
|
619
|
+
if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
|
|
620
|
+
return;
|
|
621
|
+
throw commandFailure("glab", args, result);
|
|
622
|
+
}
|
|
623
|
+
if (!result.stdout.trim())
|
|
624
|
+
throw new Error("GitLab returned an empty merge request response.");
|
|
625
|
+
const data = parseJson(result.stdout, "GitLab merge request");
|
|
626
|
+
if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string")
|
|
627
|
+
throw new Error("GitLab returned an invalid merge request response.");
|
|
628
|
+
return {
|
|
629
|
+
number: data.iid,
|
|
630
|
+
title: data.title,
|
|
631
|
+
url: data.web_url,
|
|
632
|
+
isDraft: Boolean(data.draft ?? data.work_in_progress),
|
|
633
|
+
baseRef: data.target_branch,
|
|
634
|
+
headRef: data.source_branch,
|
|
635
|
+
headSha: data.sha
|
|
636
|
+
};
|
|
637
|
+
};
|
|
638
|
+
return {
|
|
639
|
+
provider: "gitlab",
|
|
640
|
+
async createDraftPr(options) {
|
|
641
|
+
await glabChecked([
|
|
642
|
+
"mr",
|
|
643
|
+
"create",
|
|
644
|
+
"--repo",
|
|
645
|
+
project,
|
|
646
|
+
"--title",
|
|
647
|
+
`Draft: ${options.title}`,
|
|
648
|
+
"--description",
|
|
649
|
+
options.body,
|
|
650
|
+
"--target-branch",
|
|
651
|
+
options.base,
|
|
652
|
+
"--source-branch",
|
|
653
|
+
options.head,
|
|
654
|
+
"--yes"
|
|
655
|
+
]);
|
|
656
|
+
const ref = await viewPr(options.head);
|
|
657
|
+
if (!ref)
|
|
658
|
+
throw new Error("Draft MR created but could not be resolved.");
|
|
659
|
+
return ref;
|
|
660
|
+
},
|
|
661
|
+
viewPr,
|
|
662
|
+
async defaultBranch() {
|
|
663
|
+
return requireBranchName((await glabChecked(["api", `projects/${encodeURIComponent(project)}`, "--jq", ".default_branch"])).stdout, "GitLab");
|
|
664
|
+
},
|
|
665
|
+
async prDiff(id) {
|
|
666
|
+
return (await glabChecked(["mr", "diff", String(id), "--repo", project], { capture: "unbounded" })).stdout;
|
|
667
|
+
},
|
|
668
|
+
async prChecks(id) {
|
|
669
|
+
return (await glabChecked([
|
|
670
|
+
"api",
|
|
671
|
+
`projects/${encodeURIComponent(project)}/merge_requests/${id}/pipelines?per_page=100`
|
|
672
|
+
])).stdout;
|
|
673
|
+
},
|
|
674
|
+
async markReady(id) {
|
|
675
|
+
await glabChecked(["mr", "update", String(id), "--repo", project, "--ready"]);
|
|
676
|
+
},
|
|
677
|
+
async closePr(id, comment) {
|
|
678
|
+
if (comment)
|
|
679
|
+
await glabChecked(["mr", "note", String(id), "--repo", project, "--message", comment]);
|
|
680
|
+
await glabChecked(["mr", "close", String(id), "--repo", project]);
|
|
681
|
+
},
|
|
682
|
+
async mergePr() {
|
|
683
|
+
throw new Error("Merge is not supported by the GitLab forge adapter.");
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// src/vcs/forge-backend.ts
|
|
689
|
+
function createForgeBackend(vcs) {
|
|
690
|
+
if (vcs.provider === "github")
|
|
691
|
+
return GitHubVcsBackend(vcs);
|
|
692
|
+
if (vcs.provider === "gitlab")
|
|
693
|
+
return GitLabVcsBackend(vcs);
|
|
694
|
+
throw new Error("No supported forge detected from the git remote. Use --local for an offline review.");
|
|
695
|
+
}
|
|
696
|
+
var createVcsBackend = createForgeBackend;
|
|
697
|
+
var createForge = createForgeBackend;
|
|
698
|
+
// src/extensions/misex.ts
|
|
699
|
+
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
|
|
700
|
+
import { homedir as homedir3 } from "node:os";
|
|
701
|
+
import { basename as basename3, dirname as dirname2, join as join5 } from "node:path";
|
|
219
702
|
var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
|
|
220
703
|
var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
|
|
221
704
|
var mise = {
|
|
222
705
|
async executableCheck(name = "mise") {
|
|
223
706
|
return findExecutable(name);
|
|
224
707
|
},
|
|
708
|
+
async run(args, options = {}) {
|
|
709
|
+
return run("mise", args, options);
|
|
710
|
+
},
|
|
225
711
|
async install(options = {}) {
|
|
226
712
|
const homeDir = options.homeDir ?? homedir3();
|
|
227
713
|
const platform = options.platform ?? process.platform;
|
|
228
714
|
if (platform === "win32")
|
|
229
715
|
throw new Error("Automatic mise installation supports macOS and Linux only.");
|
|
230
|
-
const installedPath =
|
|
716
|
+
const installedPath = join5(homeDir, ".local", "bin", "mise");
|
|
231
717
|
if (options.dryRun)
|
|
232
718
|
return installedPath;
|
|
233
719
|
await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
|
|
@@ -238,8 +724,8 @@ var mise = {
|
|
|
238
724
|
},
|
|
239
725
|
async hookEnsure(executable, options = {}) {
|
|
240
726
|
const homeDir = options.homeDir ?? homedir3();
|
|
241
|
-
const hook = getShellHook(
|
|
242
|
-
const current = await
|
|
727
|
+
const hook = getShellHook(basename3(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
|
|
728
|
+
const current = await getOptionalFile(hook.path);
|
|
243
729
|
if (current.includes(MISE_HOOK_START))
|
|
244
730
|
return { path: hook.path, changed: false, planned: false };
|
|
245
731
|
if (options.dryRun)
|
|
@@ -274,7 +760,7 @@ function getShellHook(shell, executable, homeDir) {
|
|
|
274
760
|
switch (shell.toLowerCase()) {
|
|
275
761
|
case "zsh":
|
|
276
762
|
return {
|
|
277
|
-
path:
|
|
763
|
+
path: join5(homeDir, ".zshrc"),
|
|
278
764
|
content: `${MISE_HOOK_START}
|
|
279
765
|
eval "$(${command} activate zsh)"
|
|
280
766
|
${MISE_HOOK_END}
|
|
@@ -282,7 +768,7 @@ ${MISE_HOOK_END}
|
|
|
282
768
|
};
|
|
283
769
|
case "fish":
|
|
284
770
|
return {
|
|
285
|
-
path:
|
|
771
|
+
path: join5(homeDir, ".config", "fish", "config.fish"),
|
|
286
772
|
content: `${MISE_HOOK_START}
|
|
287
773
|
${command} activate fish | source
|
|
288
774
|
${MISE_HOOK_END}
|
|
@@ -291,7 +777,7 @@ ${MISE_HOOK_END}
|
|
|
291
777
|
case "nu":
|
|
292
778
|
case "nushell":
|
|
293
779
|
return {
|
|
294
|
-
path:
|
|
780
|
+
path: join5(homeDir, ".config", "nushell", "config.nu"),
|
|
295
781
|
content: `${MISE_HOOK_START}
|
|
296
782
|
let mise_bin = ${command}
|
|
297
783
|
let mise_path = $nu.default-config-dir | path join mise.nu
|
|
@@ -302,7 +788,7 @@ ${MISE_HOOK_END}
|
|
|
302
788
|
};
|
|
303
789
|
case "xonsh":
|
|
304
790
|
return {
|
|
305
|
-
path:
|
|
791
|
+
path: join5(homeDir, ".xonshrc"),
|
|
306
792
|
content: `${MISE_HOOK_START}
|
|
307
793
|
execx($(${command} activate xonsh))
|
|
308
794
|
${MISE_HOOK_END}
|
|
@@ -310,7 +796,7 @@ ${MISE_HOOK_END}
|
|
|
310
796
|
};
|
|
311
797
|
case "elvish":
|
|
312
798
|
return {
|
|
313
|
-
path:
|
|
799
|
+
path: join5(homeDir, ".config", "elvish", "rc.elv"),
|
|
314
800
|
content: `${MISE_HOOK_START}
|
|
315
801
|
var mise: = (ns [&])
|
|
316
802
|
eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
|
|
@@ -321,7 +807,7 @@ ${MISE_HOOK_END}
|
|
|
321
807
|
case "pwsh":
|
|
322
808
|
case "powershell":
|
|
323
809
|
return {
|
|
324
|
-
path:
|
|
810
|
+
path: join5(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
|
|
325
811
|
content: `${MISE_HOOK_START}
|
|
326
812
|
(& ${command} activate pwsh) | Out-String | Invoke-Expression
|
|
327
813
|
${MISE_HOOK_END}
|
|
@@ -330,7 +816,7 @@ ${MISE_HOOK_END}
|
|
|
330
816
|
case "bash":
|
|
331
817
|
default:
|
|
332
818
|
return {
|
|
333
|
-
path:
|
|
819
|
+
path: join5(homeDir, ".bashrc"),
|
|
334
820
|
content: `${MISE_HOOK_START}
|
|
335
821
|
eval "$(${command} activate bash)"
|
|
336
822
|
${MISE_HOOK_END}
|
|
@@ -367,7 +853,7 @@ function isVersionAtLeast(version, minimumVersion) {
|
|
|
367
853
|
}
|
|
368
854
|
return true;
|
|
369
855
|
}
|
|
370
|
-
async function
|
|
856
|
+
async function getOptionalFile(path) {
|
|
371
857
|
try {
|
|
372
858
|
return await readFile3(path, "utf8");
|
|
373
859
|
} catch (error) {
|
|
@@ -379,48 +865,1055 @@ async function getOptionalFile2(path) {
|
|
|
379
865
|
function getShellQuoted(value) {
|
|
380
866
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
381
867
|
}
|
|
868
|
+
|
|
869
|
+
// src/gates.ts
|
|
870
|
+
var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
|
|
871
|
+
var MISE_GATES = ["format:check", "lint", "test"];
|
|
872
|
+
function checkConventionalSubject(subject) {
|
|
873
|
+
const trimmed = subject.trim();
|
|
874
|
+
const ok = CONVENTIONAL_COMMIT.test(trimmed);
|
|
875
|
+
return {
|
|
876
|
+
name: "conventional-subject",
|
|
877
|
+
status: ok ? "pass" : "warn",
|
|
878
|
+
detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
async function runMiseGates(cwd) {
|
|
882
|
+
const tasks = await discoverMiseTasks(cwd);
|
|
883
|
+
const results = [];
|
|
884
|
+
for (const gate of MISE_GATES) {
|
|
885
|
+
const targets = tasks.get(gate) ?? [];
|
|
886
|
+
if (targets.length === 0) {
|
|
887
|
+
results.push({ name: gate, status: "skip", detail: "no mise recipe" });
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
|
|
891
|
+
const result = await mise.run(["run", ...invocations], { cwd });
|
|
892
|
+
results.push({
|
|
893
|
+
name: gate,
|
|
894
|
+
status: result.code === 0 ? "pass" : "fail",
|
|
895
|
+
detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return results;
|
|
899
|
+
}
|
|
900
|
+
function ciGate(checksOutput) {
|
|
901
|
+
const text = checksOutput.toLowerCase();
|
|
902
|
+
if (!text.trim())
|
|
903
|
+
return { name: "ci", status: "skip", detail: "no CI output" };
|
|
904
|
+
if (/\bfail|error\b/.test(text))
|
|
905
|
+
return { name: "ci", status: "warn", detail: "CI failing" };
|
|
906
|
+
if (/\bpending|in progress|queued\b/.test(text))
|
|
907
|
+
return { name: "ci", status: "warn", detail: "CI pending" };
|
|
908
|
+
return { name: "ci", status: "pass", detail: "CI green" };
|
|
909
|
+
}
|
|
910
|
+
function parseMiseTasks(input) {
|
|
911
|
+
let tasks;
|
|
912
|
+
try {
|
|
913
|
+
tasks = JSON.parse(input);
|
|
914
|
+
} catch {
|
|
915
|
+
return new Map;
|
|
916
|
+
}
|
|
917
|
+
if (!Array.isArray(tasks))
|
|
918
|
+
return new Map;
|
|
919
|
+
const found = new Map;
|
|
920
|
+
for (const gate of MISE_GATES) {
|
|
921
|
+
const targets = tasks.flatMap((task) => {
|
|
922
|
+
if (typeof task.name !== "string")
|
|
923
|
+
return [];
|
|
924
|
+
return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
|
|
925
|
+
});
|
|
926
|
+
if (targets.length > 0)
|
|
927
|
+
found.set(gate, [...new Set(targets)]);
|
|
928
|
+
}
|
|
929
|
+
return found;
|
|
930
|
+
}
|
|
931
|
+
async function discoverMiseTasks(cwd) {
|
|
932
|
+
const result = await mise.run(["tasks", "--json", "--all"], { cwd });
|
|
933
|
+
if (result.code !== 0)
|
|
934
|
+
return new Map;
|
|
935
|
+
return parseMiseTasks(result.stdout);
|
|
936
|
+
}
|
|
937
|
+
// src/review/types.ts
|
|
938
|
+
import { z as z2 } from "zod";
|
|
939
|
+
var severitySchema = z2.enum(["BLOCKING", "CONSIDER", "NOTE"]);
|
|
940
|
+
var findingSchema = z2.object({
|
|
941
|
+
file: z2.string().min(1),
|
|
942
|
+
line: z2.number().int().nonnegative(),
|
|
943
|
+
severity: severitySchema,
|
|
944
|
+
body: z2.string().min(1),
|
|
945
|
+
reference: z2.string().optional().default("")
|
|
946
|
+
});
|
|
947
|
+
var findingsSchema = z2.array(findingSchema);
|
|
948
|
+
function reviewSlug(input) {
|
|
949
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
|
|
950
|
+
}
|
|
951
|
+
function yymmdd(date = new Date) {
|
|
952
|
+
const yy = String(date.getFullYear() % 100).padStart(2, "0");
|
|
953
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
954
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
955
|
+
return `${yy}${mm}${dd}`;
|
|
956
|
+
}
|
|
957
|
+
function reviewRecordName(target, date = new Date) {
|
|
958
|
+
return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
|
|
959
|
+
}
|
|
960
|
+
function isLocalResponse(body) {
|
|
961
|
+
return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
|
|
962
|
+
}
|
|
963
|
+
function dedupeFindings(findings) {
|
|
964
|
+
const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
|
|
965
|
+
const byKey = new Map;
|
|
966
|
+
for (const finding of findings) {
|
|
967
|
+
const key = `${finding.file}:${finding.line}`;
|
|
968
|
+
const existing = byKey.get(key);
|
|
969
|
+
if (!existing || rank[finding.severity] > rank[existing.severity])
|
|
970
|
+
byKey.set(key, finding);
|
|
971
|
+
}
|
|
972
|
+
return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
|
|
973
|
+
}
|
|
974
|
+
function toReviewComments(findings, model) {
|
|
975
|
+
const comments = [];
|
|
976
|
+
for (const finding of findings) {
|
|
977
|
+
if (finding.line <= 0)
|
|
978
|
+
continue;
|
|
979
|
+
const body = renderCommentBody(finding);
|
|
980
|
+
comments.push({
|
|
981
|
+
file: finding.file,
|
|
982
|
+
line: finding.line,
|
|
983
|
+
side: "RIGHT",
|
|
984
|
+
body: model ? withRemoteProvenance(body, model) : body
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
return comments;
|
|
988
|
+
}
|
|
989
|
+
function withRemoteProvenance(body, model) {
|
|
990
|
+
const normalized = body.trimEnd();
|
|
991
|
+
if (/Generated review by Diffpi using `[^`]+`\.$/.test(normalized))
|
|
992
|
+
return body;
|
|
993
|
+
return `${normalized}
|
|
994
|
+
|
|
995
|
+
Generated review by Diffpi using \`${model}\`.`;
|
|
996
|
+
}
|
|
997
|
+
function localReviewAuthor(model) {
|
|
998
|
+
return `Agent: ${model}`;
|
|
999
|
+
}
|
|
1000
|
+
function renderCommentBody(finding) {
|
|
1001
|
+
const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
|
|
1002
|
+
const reference = finding.reference ? `
|
|
1003
|
+
|
|
1004
|
+
> **Reference:** ${finding.reference}` : "";
|
|
1005
|
+
return `${prefix}${finding.body}${reference}`;
|
|
1006
|
+
}
|
|
1007
|
+
// src/review/review-markdown.ts
|
|
1008
|
+
import { z as z3 } from "zod";
|
|
1009
|
+
var reviewThreadRecordSchema = z3.object({
|
|
1010
|
+
id: z3.string().min(1),
|
|
1011
|
+
file: z3.string().optional(),
|
|
1012
|
+
line: z3.number().int().positive().optional(),
|
|
1013
|
+
body: z3.string(),
|
|
1014
|
+
author: z3.string().optional(),
|
|
1015
|
+
resolved: z3.boolean(),
|
|
1016
|
+
addressed: z3.boolean().optional(),
|
|
1017
|
+
question: z3.boolean(),
|
|
1018
|
+
replies: z3.array(z3.string()).optional()
|
|
1019
|
+
});
|
|
1020
|
+
function renderReviewDoc(input) {
|
|
1021
|
+
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
1022
|
+
const lines = [`# Review: ${input.title}`, "", "## Metadata"];
|
|
1023
|
+
if (input.number !== undefined)
|
|
1024
|
+
lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
|
|
1025
|
+
if (input.author)
|
|
1026
|
+
lines.push(`- **Author**: ${input.author}`);
|
|
1027
|
+
if (input.model)
|
|
1028
|
+
lines.push(`- **Review agent**: ${input.model}`);
|
|
1029
|
+
if (input.headRef && input.baseRef)
|
|
1030
|
+
lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
|
|
1031
|
+
if (input.additions !== undefined)
|
|
1032
|
+
lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
|
|
1033
|
+
lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
|
|
1034
|
+
if (input.overallIssues.length > 0) {
|
|
1035
|
+
lines.push("## Overall issues", "");
|
|
1036
|
+
for (const issue of input.overallIssues)
|
|
1037
|
+
lines.push(`- ${issue}`);
|
|
1038
|
+
lines.push("");
|
|
1039
|
+
}
|
|
1040
|
+
lines.push("## Verification", "");
|
|
1041
|
+
for (const gate of input.gates)
|
|
1042
|
+
lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
|
|
1043
|
+
lines.push("");
|
|
1044
|
+
if (input.notVerified.length > 0) {
|
|
1045
|
+
lines.push("## What was NOT verified", "");
|
|
1046
|
+
for (const item of input.notVerified)
|
|
1047
|
+
lines.push(`- ${item}`);
|
|
1048
|
+
lines.push("");
|
|
1049
|
+
}
|
|
1050
|
+
lines.push("## Inline Comments", "");
|
|
1051
|
+
for (const finding of anchored) {
|
|
1052
|
+
lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
|
|
1053
|
+
if (finding.reference)
|
|
1054
|
+
lines.push(`> **Reference:** ${finding.reference}`, "");
|
|
1055
|
+
lines.push("---", "");
|
|
1056
|
+
}
|
|
1057
|
+
return `${lines.join(`
|
|
1058
|
+
`).trimEnd()}
|
|
1059
|
+
`;
|
|
1060
|
+
}
|
|
1061
|
+
function renderThreadArtifact(title, target, threads, options = {}) {
|
|
1062
|
+
const records = threads.map((thread) => ({
|
|
1063
|
+
id: thread.id,
|
|
1064
|
+
file: thread.file,
|
|
1065
|
+
line: thread.line,
|
|
1066
|
+
body: thread.body,
|
|
1067
|
+
author: thread.author,
|
|
1068
|
+
resolved: thread.resolved,
|
|
1069
|
+
addressed: thread.addressed,
|
|
1070
|
+
question: thread.question,
|
|
1071
|
+
replies: thread.replies
|
|
1072
|
+
}));
|
|
1073
|
+
const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
|
|
1074
|
+
const lines = [
|
|
1075
|
+
`<!-- diffpi-threads:${payload} -->`,
|
|
1076
|
+
`# Review threads: ${title}`,
|
|
1077
|
+
"",
|
|
1078
|
+
"## Metadata",
|
|
1079
|
+
"",
|
|
1080
|
+
`- Target: ${target}`,
|
|
1081
|
+
`- Pulled: ${options.timestamp ?? new Date().toISOString()}`
|
|
1082
|
+
];
|
|
1083
|
+
if (options.number !== undefined)
|
|
1084
|
+
lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
|
|
1085
|
+
lines.push("", "## Replies", "");
|
|
1086
|
+
for (const thread of threads) {
|
|
1087
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
1088
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
|
|
1089
|
+
}
|
|
1090
|
+
lines.push("## Source comments", "");
|
|
1091
|
+
for (const thread of threads) {
|
|
1092
|
+
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, "", "---", "");
|
|
1093
|
+
}
|
|
1094
|
+
return `${lines.join(`
|
|
1095
|
+
`).trimEnd()}
|
|
1096
|
+
`;
|
|
1097
|
+
}
|
|
1098
|
+
function parseThreadArtifact(content) {
|
|
1099
|
+
const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
|
|
1100
|
+
if (!payload)
|
|
1101
|
+
throw new Error("This file is not a Diffpi thread artifact.");
|
|
1102
|
+
let threads;
|
|
1103
|
+
try {
|
|
1104
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1105
|
+
threads = z3.array(reviewThreadRecordSchema).parse(decoded);
|
|
1106
|
+
} catch {
|
|
1107
|
+
throw new Error("Cannot parse the Diffpi thread artifact payload.");
|
|
1108
|
+
}
|
|
1109
|
+
const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
|
|
1110
|
+
return threads.map((thread) => {
|
|
1111
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
1112
|
+
const startMarker = `<!-- diffpi-reply-start:${id} -->
|
|
1113
|
+
`;
|
|
1114
|
+
const endMarker = `
|
|
1115
|
+
<!-- diffpi-reply-end:${id} -->`;
|
|
1116
|
+
const start = replies.indexOf(startMarker);
|
|
1117
|
+
const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
|
|
1118
|
+
const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
|
|
1119
|
+
return reply ? { ...thread, reply } : thread;
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
function upsertThreadReply(content, threadId, body, question, resolved) {
|
|
1123
|
+
const threads = parseThreadArtifact(content);
|
|
1124
|
+
const thread = threads.find((candidate) => candidate.id === threadId);
|
|
1125
|
+
if (!thread)
|
|
1126
|
+
throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
|
|
1127
|
+
thread.reply = body;
|
|
1128
|
+
thread.addressed = true;
|
|
1129
|
+
if (question !== undefined)
|
|
1130
|
+
thread.question = question;
|
|
1131
|
+
if (resolved !== undefined)
|
|
1132
|
+
thread.resolved = resolved;
|
|
1133
|
+
const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
|
|
1134
|
+
const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
|
|
1135
|
+
const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
|
|
1136
|
+
const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
|
|
1137
|
+
return renderThreadArtifact(title, target, threads, {
|
|
1138
|
+
timestamp,
|
|
1139
|
+
number: pr ? Number.parseInt(pr[1], 10) : undefined,
|
|
1140
|
+
url: pr?.[2]
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
// src/review/github-review-backend.ts
|
|
1144
|
+
function GitHubReviewBackend(vcs, number) {
|
|
1145
|
+
const pendingReview = async () => {
|
|
1146
|
+
const result = await ghChecked([
|
|
1147
|
+
"api",
|
|
1148
|
+
`/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews`,
|
|
1149
|
+
"--jq",
|
|
1150
|
+
'[.[] | select(.state=="PENDING")] | last | {id: (.id | tostring), nodeId: .node_id}'
|
|
1151
|
+
]);
|
|
1152
|
+
if (!result.stdout.trim())
|
|
1153
|
+
return;
|
|
1154
|
+
let pending;
|
|
1155
|
+
try {
|
|
1156
|
+
pending = JSON.parse(result.stdout);
|
|
1157
|
+
} catch {
|
|
1158
|
+
throw new Error("Cannot parse the pending GitHub review identifier as JSON.");
|
|
1159
|
+
}
|
|
1160
|
+
if (!pending.id || !pending.nodeId)
|
|
1161
|
+
return;
|
|
1162
|
+
return { id: pending.id, nodeId: pending.nodeId };
|
|
1163
|
+
};
|
|
1164
|
+
const listThreads = async () => {
|
|
1165
|
+
const threads = [];
|
|
1166
|
+
let cursor;
|
|
1167
|
+
do {
|
|
1168
|
+
const args = [
|
|
1169
|
+
"api",
|
|
1170
|
+
"graphql",
|
|
1171
|
+
"-f",
|
|
1172
|
+
`query=${GITHUB_THREADS_QUERY}`,
|
|
1173
|
+
"-f",
|
|
1174
|
+
`owner=${vcs.owner}`,
|
|
1175
|
+
"-f",
|
|
1176
|
+
`repo=${vcs.repo}`,
|
|
1177
|
+
"-F",
|
|
1178
|
+
`number=${number}`
|
|
1179
|
+
];
|
|
1180
|
+
if (cursor)
|
|
1181
|
+
args.push("-f", `after=${cursor}`);
|
|
1182
|
+
const result = await ghChecked(args);
|
|
1183
|
+
let data;
|
|
1184
|
+
try {
|
|
1185
|
+
data = JSON.parse(result.stdout);
|
|
1186
|
+
} catch {
|
|
1187
|
+
throw new Error("Cannot parse GitHub review threads as JSON.");
|
|
1188
|
+
}
|
|
1189
|
+
const connection = data.data?.repository?.pullRequest?.reviewThreads;
|
|
1190
|
+
threads.push(...connection?.nodes ?? []);
|
|
1191
|
+
cursor = connection?.pageInfo?.hasNextPage ? connection.pageInfo.endCursor : undefined;
|
|
1192
|
+
if (connection?.pageInfo?.hasNextPage && !cursor) {
|
|
1193
|
+
throw new Error("GitHub review thread pagination did not return an end cursor.");
|
|
1194
|
+
}
|
|
1195
|
+
} while (cursor);
|
|
1196
|
+
return threads.map((thread) => {
|
|
1197
|
+
const nodes = thread.comments?.nodes ?? [];
|
|
1198
|
+
const comment = nodes[0];
|
|
1199
|
+
const body = comment?.body ?? "";
|
|
1200
|
+
return {
|
|
1201
|
+
id: thread.id,
|
|
1202
|
+
file: thread.path,
|
|
1203
|
+
line: thread.line,
|
|
1204
|
+
body,
|
|
1205
|
+
author: comment?.author?.login,
|
|
1206
|
+
resolved: thread.isResolved,
|
|
1207
|
+
question: /\?\s*$/.test(body.trim()),
|
|
1208
|
+
replies: nodes.slice(1).map((node) => node.body)
|
|
1209
|
+
};
|
|
1210
|
+
});
|
|
1211
|
+
};
|
|
1212
|
+
return {
|
|
1213
|
+
kind: "remote",
|
|
1214
|
+
async stage(draft) {
|
|
1215
|
+
const pending = await pendingReview();
|
|
1216
|
+
if (!pending) {
|
|
1217
|
+
const payload = {
|
|
1218
|
+
body: draft.body,
|
|
1219
|
+
comments: draft.comments.map((comment) => ({
|
|
1220
|
+
path: comment.file,
|
|
1221
|
+
line: comment.line,
|
|
1222
|
+
side: comment.side ?? "RIGHT",
|
|
1223
|
+
body: comment.body
|
|
1224
|
+
}))
|
|
1225
|
+
};
|
|
1226
|
+
await ghChecked(["api", "--method", "POST", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews`, "--input", "-"], { input: JSON.stringify(payload) });
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (draft.body.trim()) {
|
|
1230
|
+
await ghChecked([
|
|
1231
|
+
"api",
|
|
1232
|
+
"--method",
|
|
1233
|
+
"PUT",
|
|
1234
|
+
`/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`,
|
|
1235
|
+
"-f",
|
|
1236
|
+
`body=${draft.body}`
|
|
1237
|
+
]);
|
|
1238
|
+
}
|
|
1239
|
+
for (const comment of draft.comments) {
|
|
1240
|
+
await ghChecked([
|
|
1241
|
+
"api",
|
|
1242
|
+
"graphql",
|
|
1243
|
+
"-f",
|
|
1244
|
+
`query=${GITHUB_ADD_THREAD_MUTATION}`,
|
|
1245
|
+
"-f",
|
|
1246
|
+
`reviewId=${pending.nodeId}`,
|
|
1247
|
+
"-f",
|
|
1248
|
+
`body=${comment.body}`,
|
|
1249
|
+
"-f",
|
|
1250
|
+
`path=${comment.file}`,
|
|
1251
|
+
"-F",
|
|
1252
|
+
`line=${comment.line}`,
|
|
1253
|
+
"-f",
|
|
1254
|
+
`side=${comment.side ?? "RIGHT"}`
|
|
1255
|
+
]);
|
|
1256
|
+
}
|
|
1257
|
+
},
|
|
1258
|
+
async readDraft() {
|
|
1259
|
+
const pending = await pendingReview();
|
|
1260
|
+
if (!pending)
|
|
1261
|
+
return { comments: [], body: "" };
|
|
1262
|
+
const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
|
|
1263
|
+
const comments = await ghChecked([
|
|
1264
|
+
"api",
|
|
1265
|
+
`/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
|
|
1266
|
+
]);
|
|
1267
|
+
let reviewData;
|
|
1268
|
+
let commentData;
|
|
1269
|
+
try {
|
|
1270
|
+
reviewData = JSON.parse(review.stdout);
|
|
1271
|
+
commentData = JSON.parse(comments.stdout);
|
|
1272
|
+
} catch {
|
|
1273
|
+
throw new Error("Cannot parse the pending GitHub review as JSON.");
|
|
1274
|
+
}
|
|
1275
|
+
return {
|
|
1276
|
+
body: reviewData.body ?? "",
|
|
1277
|
+
comments: commentData.map((comment) => ({
|
|
1278
|
+
file: comment.path,
|
|
1279
|
+
line: comment.line ?? comment.original_line ?? 1,
|
|
1280
|
+
side: comment.side,
|
|
1281
|
+
body: comment.body
|
|
1282
|
+
}))
|
|
1283
|
+
};
|
|
1284
|
+
},
|
|
1285
|
+
listThreads,
|
|
1286
|
+
async reply(input) {
|
|
1287
|
+
const threads = await listThreads();
|
|
1288
|
+
const existing = threads.find((thread) => thread.id === input.threadId);
|
|
1289
|
+
if (!existing?.replies?.includes(input.body)) {
|
|
1290
|
+
await ghChecked([
|
|
1291
|
+
"api",
|
|
1292
|
+
"graphql",
|
|
1293
|
+
"-f",
|
|
1294
|
+
`query=${GITHUB_REPLY_MUTATION}`,
|
|
1295
|
+
"-f",
|
|
1296
|
+
`threadId=${input.threadId}`,
|
|
1297
|
+
"-f",
|
|
1298
|
+
`body=${input.body}`
|
|
1299
|
+
]);
|
|
1300
|
+
}
|
|
1301
|
+
if (input.resolve) {
|
|
1302
|
+
await ghChecked([
|
|
1303
|
+
"api",
|
|
1304
|
+
"graphql",
|
|
1305
|
+
"-f",
|
|
1306
|
+
`query=${GITHUB_RESOLVE_MUTATION}`,
|
|
1307
|
+
"-f",
|
|
1308
|
+
`threadId=${input.threadId}`
|
|
1309
|
+
]);
|
|
1310
|
+
}
|
|
1311
|
+
},
|
|
1312
|
+
async publish(event) {
|
|
1313
|
+
const pending = await pendingReview();
|
|
1314
|
+
if (!pending && event === "COMMENT")
|
|
1315
|
+
return;
|
|
1316
|
+
if (!pending && event === "REQUEST_CHANGES") {
|
|
1317
|
+
throw new Error("GitHub requires pending comments before publishing a request-changes review without a body.");
|
|
1318
|
+
}
|
|
1319
|
+
const endpoint = githubReviewSubmissionEndpoint(vcs.owner, vcs.repo, number, pending?.id ?? "");
|
|
1320
|
+
await ghChecked(["api", "--method", "POST", endpoint, "-f", `event=${event}`]);
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
|
|
1325
|
+
return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
|
|
1326
|
+
}
|
|
1327
|
+
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}}}}}`;
|
|
1328
|
+
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}}}`;
|
|
1329
|
+
var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
|
|
1330
|
+
var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
|
|
1331
|
+
|
|
1332
|
+
// src/review/gitlab-review-backend.ts
|
|
1333
|
+
function GitLabReviewBackend(vcs, number) {
|
|
1334
|
+
const project = () => `${vcs.owner}/${vcs.repo}`;
|
|
1335
|
+
const mergeRequestEndpoint = () => `projects/${encodeURIComponent(project())}/merge_requests/${number}`;
|
|
1336
|
+
const listThreads = async () => {
|
|
1337
|
+
const discussions = [];
|
|
1338
|
+
for (let page = 1;; page += 1) {
|
|
1339
|
+
const result = await glabChecked(["api", `${mergeRequestEndpoint()}/discussions?per_page=100&page=${page}`]);
|
|
1340
|
+
let batch;
|
|
1341
|
+
try {
|
|
1342
|
+
batch = JSON.parse(result.stdout);
|
|
1343
|
+
} catch {
|
|
1344
|
+
throw new Error("Cannot parse GitLab review discussions as JSON.");
|
|
1345
|
+
}
|
|
1346
|
+
discussions.push(...batch);
|
|
1347
|
+
if (batch.length < 100)
|
|
1348
|
+
break;
|
|
1349
|
+
}
|
|
1350
|
+
return discussions.map((discussion) => {
|
|
1351
|
+
const notes = discussion.notes ?? [];
|
|
1352
|
+
const note = notes[0];
|
|
1353
|
+
const body = note?.body ?? "";
|
|
1354
|
+
return {
|
|
1355
|
+
id: discussion.id,
|
|
1356
|
+
file: note?.position?.new_path ?? note?.position?.old_path,
|
|
1357
|
+
line: note?.position?.new_line ?? note?.position?.old_line,
|
|
1358
|
+
body,
|
|
1359
|
+
author: note?.author?.username,
|
|
1360
|
+
resolved: Boolean(discussion.resolved),
|
|
1361
|
+
question: /\?\s*$/.test(body.trim()),
|
|
1362
|
+
replies: notes.slice(1).map((reply) => reply.body)
|
|
1363
|
+
};
|
|
1364
|
+
});
|
|
1365
|
+
};
|
|
1366
|
+
return {
|
|
1367
|
+
kind: "remote",
|
|
1368
|
+
async stage(draft) {
|
|
1369
|
+
const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
|
|
1370
|
+
if (draft.body.trim()) {
|
|
1371
|
+
await glabChecked(["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
1372
|
+
input: JSON.stringify({ note: draft.body })
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
if (draft.comments.length === 0)
|
|
1376
|
+
return;
|
|
1377
|
+
const response = await glabChecked(["api", mergeRequestEndpoint()]);
|
|
1378
|
+
const diffRefs = parseGitlabDiffRefs(response.stdout);
|
|
1379
|
+
for (const comment of draft.comments) {
|
|
1380
|
+
const payload = {
|
|
1381
|
+
note: comment.body,
|
|
1382
|
+
position: {
|
|
1383
|
+
...diffRefs,
|
|
1384
|
+
position_type: "text",
|
|
1385
|
+
new_path: comment.file,
|
|
1386
|
+
old_path: comment.file,
|
|
1387
|
+
new_line: comment.side === "LEFT" ? undefined : comment.line,
|
|
1388
|
+
old_line: comment.side === "LEFT" ? comment.line : undefined
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
await glabChecked(["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
1392
|
+
input: JSON.stringify(payload)
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
},
|
|
1396
|
+
async readDraft() {
|
|
1397
|
+
const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
|
|
1398
|
+
let notes;
|
|
1399
|
+
try {
|
|
1400
|
+
notes = JSON.parse(result.stdout);
|
|
1401
|
+
} catch {
|
|
1402
|
+
throw new Error("Cannot parse GitLab draft notes as JSON.");
|
|
1403
|
+
}
|
|
1404
|
+
const comments = [];
|
|
1405
|
+
const body = [];
|
|
1406
|
+
for (const note of notes) {
|
|
1407
|
+
if (!note.note)
|
|
1408
|
+
continue;
|
|
1409
|
+
const file = note.position?.new_path ?? note.position?.old_path;
|
|
1410
|
+
const line = note.position?.new_line ?? note.position?.old_line;
|
|
1411
|
+
if (file && line) {
|
|
1412
|
+
comments.push({
|
|
1413
|
+
file,
|
|
1414
|
+
line,
|
|
1415
|
+
side: note.position?.new_line ? "RIGHT" : "LEFT",
|
|
1416
|
+
body: note.note
|
|
1417
|
+
});
|
|
1418
|
+
} else {
|
|
1419
|
+
body.push(note.note);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return { comments, body: body.join(`
|
|
1423
|
+
|
|
1424
|
+
`) };
|
|
1425
|
+
},
|
|
1426
|
+
listThreads,
|
|
1427
|
+
async reply(input) {
|
|
1428
|
+
const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
|
|
1429
|
+
const threads = await listThreads();
|
|
1430
|
+
const existing = threads.find((thread) => thread.id === input.threadId);
|
|
1431
|
+
if (!existing?.replies?.includes(input.body)) {
|
|
1432
|
+
await glabChecked(["api", "--method", "POST", `${endpoint}/notes`, "--input", "-"], {
|
|
1433
|
+
input: JSON.stringify({ body: input.body })
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
if (input.resolve)
|
|
1437
|
+
await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=true`]);
|
|
1438
|
+
},
|
|
1439
|
+
async publish(event) {
|
|
1440
|
+
assertReviewEventSupported(vcs.provider, event);
|
|
1441
|
+
const drafts = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
|
|
1442
|
+
if (hasGitlabDraftNotes(drafts.stdout)) {
|
|
1443
|
+
await glabChecked(["api", "--method", "POST", `${mergeRequestEndpoint()}/draft_notes/bulk_publish`]);
|
|
1444
|
+
}
|
|
1445
|
+
if (event === "APPROVE")
|
|
1446
|
+
await glabChecked(["mr", "approve", String(number), "--repo", project()]);
|
|
1447
|
+
}
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
function assertReviewEventSupported(provider, event) {
|
|
1451
|
+
if (provider === "gitlab" && event === "REQUEST_CHANGES") {
|
|
1452
|
+
throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
function parseGitlabDiffRefs(input) {
|
|
1456
|
+
let data;
|
|
1457
|
+
try {
|
|
1458
|
+
data = JSON.parse(input);
|
|
1459
|
+
} catch {
|
|
1460
|
+
throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
|
|
1461
|
+
}
|
|
1462
|
+
const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
|
|
1463
|
+
if (!base_sha || !start_sha || !head_sha) {
|
|
1464
|
+
throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
|
|
1465
|
+
}
|
|
1466
|
+
return { base_sha, start_sha, head_sha };
|
|
1467
|
+
}
|
|
1468
|
+
function hasGitlabDraftNotes(input) {
|
|
1469
|
+
try {
|
|
1470
|
+
const data = JSON.parse(input);
|
|
1471
|
+
return Array.isArray(data) && data.length > 0;
|
|
1472
|
+
} catch {
|
|
1473
|
+
throw new Error("Cannot publish the GitLab review: the draft notes response was not valid JSON.");
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// src/review/local-review-backend.ts
|
|
1478
|
+
import { readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
|
|
1479
|
+
|
|
1480
|
+
// src/extensions/tuicrx.ts
|
|
1481
|
+
import { readFile as readFile4, realpath as realpath2 } from "node:fs/promises";
|
|
1482
|
+
import { resolve as resolve2 } from "node:path";
|
|
1483
|
+
async function tuicrAvailable() {
|
|
1484
|
+
return Boolean(await findExecutable("tuicr"));
|
|
1485
|
+
}
|
|
1486
|
+
async function listSessions(repo = ".") {
|
|
1487
|
+
await requireTuicr();
|
|
1488
|
+
const result = await run("tuicr", ["review", "list", "--repo", repo]);
|
|
1489
|
+
if (result.code !== 0 || !result.stdout.trim())
|
|
1490
|
+
return [];
|
|
1491
|
+
try {
|
|
1492
|
+
const raw = JSON.parse(result.stdout);
|
|
1493
|
+
return raw.map((entry) => ({
|
|
1494
|
+
slug: entry.slug,
|
|
1495
|
+
kind: entry.kind,
|
|
1496
|
+
path: entry.path,
|
|
1497
|
+
updatedAt: entry.updated_at,
|
|
1498
|
+
commentCount: entry.comment_count,
|
|
1499
|
+
anchor: entry.anchor,
|
|
1500
|
+
active: entry.active
|
|
1501
|
+
}));
|
|
1502
|
+
} catch {
|
|
1503
|
+
throw new Error("Cannot parse tuicr review list output.");
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
async function addComment(session, body, opts = {}) {
|
|
1507
|
+
await requireTuicr();
|
|
1508
|
+
const args = ["review", "add", "--session", session, body];
|
|
1509
|
+
if (opts.targetFile)
|
|
1510
|
+
args.push("--target-file", opts.targetFile);
|
|
1511
|
+
if (opts.line !== undefined)
|
|
1512
|
+
args.push("--line", String(opts.line));
|
|
1513
|
+
if (opts.side)
|
|
1514
|
+
args.push("--side", opts.side);
|
|
1515
|
+
if (opts.username)
|
|
1516
|
+
args.push("--username", opts.username);
|
|
1517
|
+
await runChecked("tuicr", args);
|
|
1518
|
+
}
|
|
1519
|
+
async function launch(cwd, pr, localBase) {
|
|
1520
|
+
let command;
|
|
1521
|
+
if (pr !== undefined)
|
|
1522
|
+
command = ["tuicr", "pr", String(pr)];
|
|
1523
|
+
else if (localBase)
|
|
1524
|
+
command = ["tuicr", "-w", "-r", `${localBase}..HEAD`];
|
|
1525
|
+
else
|
|
1526
|
+
throw new Error("Cannot launch a local tuicr review: no base branch could be determined.");
|
|
1527
|
+
if (!await tuicrAvailable()) {
|
|
1528
|
+
return {
|
|
1529
|
+
launched: false,
|
|
1530
|
+
via: "print",
|
|
1531
|
+
command: command.join(" "),
|
|
1532
|
+
reason: "tuicr is not installed or is not available on PATH.",
|
|
1533
|
+
instruction: `Install tuicr, then run: ${command.join(" ")}`
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
return openInNewTab(command, { cwd, name: "tuicr" });
|
|
1537
|
+
}
|
|
1538
|
+
async function resolveSession(cwd, branch) {
|
|
1539
|
+
const sessions = await listSessions(cwd);
|
|
1540
|
+
return findMatchingSession(sessions, cwd, branch);
|
|
1541
|
+
}
|
|
1542
|
+
async function resolveReviewSession(cwd, target) {
|
|
1543
|
+
if (!target.workingTree && target.owner && target.repo && target.number !== undefined) {
|
|
1544
|
+
return resolvePrSession(cwd, target.owner, target.repo, target.number);
|
|
1545
|
+
}
|
|
1546
|
+
return resolveSession(cwd, target.branch);
|
|
1547
|
+
}
|
|
1548
|
+
async function resolvePrSession(cwd, owner, repo, number) {
|
|
1549
|
+
const coordinate = `${owner}/${repo}`.toLowerCase();
|
|
1550
|
+
const sessions = await listSessions(cwd);
|
|
1551
|
+
return sessions.find((session) => {
|
|
1552
|
+
const slug = session.slug.toLowerCase();
|
|
1553
|
+
return session.kind === "pr" && slug.includes(coordinate) && (slug.endsWith(`/pr/${number}`) || slug.endsWith(`/mr/${number}`));
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
async function findMatchingSession(sessions, cwd, branch) {
|
|
1557
|
+
const repository = await canonicalPath2(await gitToplevel(cwd));
|
|
1558
|
+
for (const session of sessions) {
|
|
1559
|
+
if (session.kind !== "local")
|
|
1560
|
+
continue;
|
|
1561
|
+
try {
|
|
1562
|
+
const data = await readSession(session.path);
|
|
1563
|
+
if (data.branch_name !== branch || !data.repo_path)
|
|
1564
|
+
continue;
|
|
1565
|
+
if (await canonicalPath2(data.repo_path) === repository)
|
|
1566
|
+
return session;
|
|
1567
|
+
} catch {}
|
|
1568
|
+
}
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
async function readSession(path) {
|
|
1572
|
+
const content = await readFile4(path, "utf8");
|
|
1573
|
+
try {
|
|
1574
|
+
return JSON.parse(content);
|
|
1575
|
+
} catch {
|
|
1576
|
+
throw new Error(`Cannot parse tuicr session JSON: ${path}`);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
function toFindings(session, options = {}) {
|
|
1580
|
+
const comments = [];
|
|
1581
|
+
const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
|
|
1582
|
+
const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
|
|
1583
|
+
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
1584
|
+
const fileComments = (entry.file_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
|
|
1585
|
+
if (fileComments.length > 0)
|
|
1586
|
+
bodyParts.push(`File: ${file}
|
|
1587
|
+
|
|
1588
|
+
${fileComments.join(`
|
|
1589
|
+
|
|
1590
|
+
`)}`);
|
|
1591
|
+
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
1592
|
+
const line = Number.parseInt(lineKey, 10);
|
|
1593
|
+
if (!Number.isFinite(line))
|
|
1594
|
+
continue;
|
|
1595
|
+
for (const lineComment of lineComments) {
|
|
1596
|
+
if (!include(lineComment))
|
|
1597
|
+
continue;
|
|
1598
|
+
const comment = {
|
|
1599
|
+
file,
|
|
1600
|
+
line,
|
|
1601
|
+
side: lineComment.side === "old" ? "LEFT" : "RIGHT",
|
|
1602
|
+
body: lineComment.content
|
|
1603
|
+
};
|
|
1604
|
+
const author = commentAuthor(lineComment);
|
|
1605
|
+
if (author)
|
|
1606
|
+
comment.author = author;
|
|
1607
|
+
comments.push(comment);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
return { comments, body: bodyParts.join(`
|
|
1612
|
+
|
|
1613
|
+
`) };
|
|
1614
|
+
}
|
|
1615
|
+
async function requireTuicr() {
|
|
1616
|
+
if (!await tuicrAvailable())
|
|
1617
|
+
throw new Error("tuicr is not installed or is not available on PATH.");
|
|
1618
|
+
}
|
|
1619
|
+
function commentAuthor(comment) {
|
|
1620
|
+
return comment.username ?? comment.author;
|
|
1621
|
+
}
|
|
1622
|
+
async function canonicalPath2(path) {
|
|
1623
|
+
try {
|
|
1624
|
+
return await realpath2(path);
|
|
1625
|
+
} catch {
|
|
1626
|
+
return resolve2(path);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// src/review/local-review-backend.ts
|
|
1631
|
+
function LocalReviewBackend(options) {
|
|
1632
|
+
return {
|
|
1633
|
+
kind: "local",
|
|
1634
|
+
async stage(draft) {
|
|
1635
|
+
for (const comment of draft.comments) {
|
|
1636
|
+
await addComment(options.session, comment.body, {
|
|
1637
|
+
targetFile: comment.file,
|
|
1638
|
+
line: comment.line,
|
|
1639
|
+
side: comment.side === "LEFT" ? "old" : "new",
|
|
1640
|
+
username: options.author
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
if (draft.body.trim())
|
|
1644
|
+
await addComment(options.session, draft.body, { username: options.author });
|
|
1645
|
+
},
|
|
1646
|
+
async readDraft() {
|
|
1647
|
+
return toFindings(await readSession(options.session), { agentOnly: true, excludeLocalResponses: true });
|
|
1648
|
+
},
|
|
1649
|
+
async listThreads() {
|
|
1650
|
+
try {
|
|
1651
|
+
return parseThreadArtifact(await readFile5(options.artifactPath, "utf8"));
|
|
1652
|
+
} catch (error) {
|
|
1653
|
+
if (error.code === "ENOENT")
|
|
1654
|
+
return [];
|
|
1655
|
+
throw error;
|
|
1656
|
+
}
|
|
1657
|
+
},
|
|
1658
|
+
async reply(input) {
|
|
1659
|
+
const content = await readFile5(options.artifactPath, "utf8");
|
|
1660
|
+
await writeFile3(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
|
|
1661
|
+
},
|
|
1662
|
+
async publish() {
|
|
1663
|
+
throw new Error("Promote a local draft through a remote review backend before publishing it.");
|
|
1664
|
+
}
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// src/review/review-backend.ts
|
|
1669
|
+
function createRemoteReviewBackend(vcs, number) {
|
|
1670
|
+
if (vcs.provider === "github")
|
|
1671
|
+
return GitHubReviewBackend(vcs, number);
|
|
1672
|
+
if (vcs.provider === "gitlab")
|
|
1673
|
+
return GitLabReviewBackend(vcs, number);
|
|
1674
|
+
throw new Error("A remote review backend requires a GitHub or GitLab repository.");
|
|
1675
|
+
}
|
|
1676
|
+
function createLocalReviewBackend(options) {
|
|
1677
|
+
return LocalReviewBackend(options);
|
|
1678
|
+
}
|
|
1679
|
+
// src/review/review-state.ts
|
|
1680
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1681
|
+
import { readFile as readFile6, rename, writeFile as writeFile4 } from "node:fs/promises";
|
|
1682
|
+
import { join as join7 } from "node:path";
|
|
1683
|
+
import { z as z4 } from "zod";
|
|
1684
|
+
|
|
1685
|
+
// src/store.ts
|
|
1686
|
+
import { createHash } from "node:crypto";
|
|
1687
|
+
import { lstat, mkdir as mkdir3, readlink, realpath as realpath3, symlink, unlink } from "node:fs/promises";
|
|
1688
|
+
import { homedir as homedir4 } from "node:os";
|
|
1689
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join6, resolve as resolve3 } from "node:path";
|
|
1690
|
+
var STORE_LINK = ".diffpi";
|
|
1691
|
+
var LEGACY_STORE_LINK = join6(".pi", "diffpi");
|
|
1692
|
+
async function computeProjectSlug(cwd) {
|
|
1693
|
+
return projectSlug(await inspectGitRepository(cwd));
|
|
1694
|
+
}
|
|
1695
|
+
function storeGlobalRoot(homeDir = homedir4()) {
|
|
1696
|
+
return join6(homeDir, ".difflab", "diffpi", "projects");
|
|
1697
|
+
}
|
|
1698
|
+
async function ensureStore(cwd, homeDir = homedir4()) {
|
|
1699
|
+
const repository = await inspectGitRepository(cwd);
|
|
1700
|
+
const root = repository.root;
|
|
1701
|
+
const slug = projectSlug(repository);
|
|
1702
|
+
const dest = join6(storeGlobalRoot(homeDir), slug);
|
|
1703
|
+
const link = join6(root, STORE_LINK);
|
|
1704
|
+
await mkdir3(dest, { recursive: true });
|
|
1705
|
+
try {
|
|
1706
|
+
await assertStoreLink(link, dest);
|
|
1707
|
+
} catch (error) {
|
|
1708
|
+
if (error.code !== "ENOENT")
|
|
1709
|
+
throw error;
|
|
1710
|
+
await symlink(dest, link);
|
|
1711
|
+
}
|
|
1712
|
+
await removeLegacyStoreLink(join6(root, LEGACY_STORE_LINK), dest);
|
|
1713
|
+
return { slug, root, dest, link, linked: true };
|
|
1714
|
+
}
|
|
1715
|
+
async function storeDir(cwd, homeDir = homedir4()) {
|
|
1716
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1717
|
+
return store.dest;
|
|
1718
|
+
}
|
|
1719
|
+
async function reviewsDir(cwd, homeDir = homedir4()) {
|
|
1720
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1721
|
+
const dir = join6(store.link, "review");
|
|
1722
|
+
await mkdir3(dir, { recursive: true });
|
|
1723
|
+
return dir;
|
|
1724
|
+
}
|
|
1725
|
+
async function sessionsDir(cwd, homeDir = homedir4()) {
|
|
1726
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1727
|
+
const dir = join6(store.link, "sessions");
|
|
1728
|
+
await mkdir3(dir, { recursive: true });
|
|
1729
|
+
return dir;
|
|
1730
|
+
}
|
|
1731
|
+
async function assertStoreLink(path, dest) {
|
|
1732
|
+
const entry = await lstat(path);
|
|
1733
|
+
if (!entry.isSymbolicLink())
|
|
1734
|
+
throw new Error(`${path} exists and is not a symlink.`);
|
|
1735
|
+
const target = await symlinkTarget(path);
|
|
1736
|
+
if (target !== await canonicalPath3(dest))
|
|
1737
|
+
throw new Error(`${path} points to ${target}, not ${dest}.`);
|
|
1738
|
+
}
|
|
1739
|
+
async function removeLegacyStoreLink(path, dest) {
|
|
1740
|
+
try {
|
|
1741
|
+
const entry = await lstat(path);
|
|
1742
|
+
if (!entry.isSymbolicLink())
|
|
1743
|
+
return;
|
|
1744
|
+
const target = await symlinkTarget(path);
|
|
1745
|
+
if (target === await canonicalPath3(dest))
|
|
1746
|
+
await unlink(path);
|
|
1747
|
+
} catch (error) {
|
|
1748
|
+
if (error.code !== "ENOENT")
|
|
1749
|
+
throw error;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
async function symlinkTarget(path) {
|
|
1753
|
+
const target = await readlink(path);
|
|
1754
|
+
return canonicalPath3(isAbsolute2(target) ? target : resolve3(dirname3(path), target));
|
|
1755
|
+
}
|
|
1756
|
+
async function canonicalPath3(path) {
|
|
1757
|
+
try {
|
|
1758
|
+
return await realpath3(path);
|
|
1759
|
+
} catch {
|
|
1760
|
+
return resolve3(path);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
function projectSlug(repository) {
|
|
1764
|
+
const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
1765
|
+
const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
|
|
1766
|
+
return `${readable}-${digest}`;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// src/review/review-state.ts
|
|
1770
|
+
var reviewPublicationStateSchema = z4.object({
|
|
1771
|
+
target: z4.string().optional(),
|
|
1772
|
+
bodies: z4.array(z4.string()).default([]),
|
|
1773
|
+
comments: z4.array(z4.string()).default([]),
|
|
1774
|
+
replies: z4.array(z4.string()).default([]),
|
|
1775
|
+
overlayPath: z4.string().optional()
|
|
1776
|
+
});
|
|
1777
|
+
function reviewBodyFingerprint(body) {
|
|
1778
|
+
return digest(body);
|
|
1779
|
+
}
|
|
1780
|
+
function reviewCommentFingerprint(comment) {
|
|
1781
|
+
return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
|
|
1782
|
+
}
|
|
1783
|
+
function reviewReplyFingerprint(threadId, body) {
|
|
1784
|
+
return digest(`${threadId}\x00${body}`);
|
|
1785
|
+
}
|
|
1786
|
+
function unpublishedReviewComments(comments, knownFingerprints) {
|
|
1787
|
+
return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
|
|
1788
|
+
}
|
|
1789
|
+
async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
|
|
1790
|
+
const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
|
|
1791
|
+
const path = join7(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
|
|
1792
|
+
try {
|
|
1793
|
+
const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile6(path, "utf8")));
|
|
1794
|
+
return { path, state: { ...parsed, target } };
|
|
1795
|
+
} catch (error) {
|
|
1796
|
+
if (error.code === "ENOENT") {
|
|
1797
|
+
return { path, state: { target, bodies: [], comments: [], replies: [] } };
|
|
1798
|
+
}
|
|
1799
|
+
if (error instanceof SyntaxError || error instanceof z4.ZodError) {
|
|
1800
|
+
throw new Error(`Cannot parse review publication state: ${path}`);
|
|
1801
|
+
}
|
|
1802
|
+
throw error;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
async function saveReviewPublicationState(path, state) {
|
|
1806
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
1807
|
+
await writeFile4(temp, `${JSON.stringify(state, null, 2)}
|
|
1808
|
+
`, "utf8");
|
|
1809
|
+
await rename(temp, path);
|
|
1810
|
+
}
|
|
1811
|
+
function digest(value) {
|
|
1812
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
1813
|
+
}
|
|
1814
|
+
// src/mcp.ts
|
|
1815
|
+
import { mkdir as mkdir4, readFile as readFile7, writeFile as writeFile5 } from "node:fs/promises";
|
|
1816
|
+
import { homedir as homedir5 } from "node:os";
|
|
1817
|
+
import { dirname as dirname4, join as join8 } from "node:path";
|
|
1818
|
+
var mcp = {
|
|
1819
|
+
globalConfigPath(homeDir = homedir5()) {
|
|
1820
|
+
return join8(homeDir, ".config", "mcp", "mcp.json");
|
|
1821
|
+
},
|
|
1822
|
+
async serversEnsure(servers, options = {}) {
|
|
1823
|
+
const path = options.path ?? mcp.globalConfigPath();
|
|
1824
|
+
const currentText = await getOptionalFile2(path);
|
|
1825
|
+
const current = getParsedConfig(currentText, path);
|
|
1826
|
+
const nextServers = { ...current.mcpServers };
|
|
1827
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
1828
|
+
nextServers[name] = mergeEntry(nextServers[name], entry);
|
|
1829
|
+
}
|
|
1830
|
+
const next = { ...current, mcpServers: nextServers };
|
|
1831
|
+
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
1832
|
+
if (changed && !options.dryRun) {
|
|
1833
|
+
await mkdir4(dirname4(path), { recursive: true });
|
|
1834
|
+
await writeFile5(path, `${JSON.stringify(next, null, 2)}
|
|
1835
|
+
`, "utf8");
|
|
1836
|
+
}
|
|
1837
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
1838
|
+
}
|
|
1839
|
+
};
|
|
1840
|
+
function mergeEntry(current, required) {
|
|
1841
|
+
const merged = { ...current, ...required };
|
|
1842
|
+
if (current?.env || required.env)
|
|
1843
|
+
merged.env = { ...current?.env, ...required.env };
|
|
1844
|
+
return merged;
|
|
1845
|
+
}
|
|
1846
|
+
function getParsedConfig(content, path) {
|
|
1847
|
+
if (!content?.trim())
|
|
1848
|
+
return { mcpServers: {} };
|
|
1849
|
+
try {
|
|
1850
|
+
const value = JSON.parse(content);
|
|
1851
|
+
if (!isRecord(value))
|
|
1852
|
+
throw new Error("not an object");
|
|
1853
|
+
const servers = value.mcpServers;
|
|
1854
|
+
if (servers !== undefined && !isRecord(servers))
|
|
1855
|
+
throw new Error("mcpServers is not an object");
|
|
1856
|
+
return { ...value, mcpServers: servers ?? {} };
|
|
1857
|
+
} catch {
|
|
1858
|
+
throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
async function getOptionalFile2(path) {
|
|
1862
|
+
try {
|
|
1863
|
+
return await readFile7(path, "utf8");
|
|
1864
|
+
} catch (error) {
|
|
1865
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
1866
|
+
return;
|
|
1867
|
+
throw error;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
function isRecord(value) {
|
|
1871
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1872
|
+
}
|
|
382
1873
|
// src/modes.ts
|
|
383
1874
|
import {
|
|
384
1875
|
getAgentDir,
|
|
385
1876
|
parseFrontmatter as parseFrontmatter2
|
|
386
1877
|
} from "@earendil-works/pi-coding-agent";
|
|
387
|
-
import { readFile as
|
|
388
|
-
import { homedir as
|
|
389
|
-
import { basename as
|
|
1878
|
+
import { readFile as readFile8 } from "node:fs/promises";
|
|
1879
|
+
import { homedir as homedir6 } from "node:os";
|
|
1880
|
+
import { basename as basename4, extname, join as join10 } from "node:path";
|
|
390
1881
|
|
|
391
1882
|
// src/assets.ts
|
|
392
1883
|
import { existsSync } from "node:fs";
|
|
393
|
-
import { dirname as
|
|
1884
|
+
import { dirname as dirname5, join as join9 } from "node:path";
|
|
394
1885
|
import { fileURLToPath } from "node:url";
|
|
395
|
-
function
|
|
396
|
-
const moduleDir =
|
|
397
|
-
const candidates = [
|
|
398
|
-
join5(moduleDir, "agents"),
|
|
399
|
-
join5(moduleDir, "..", "agents"),
|
|
400
|
-
join5(moduleDir, "..", "..", "agents")
|
|
401
|
-
];
|
|
1886
|
+
function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
|
|
1887
|
+
const moduleDir = dirname5(fileURLToPath(moduleUrl));
|
|
1888
|
+
const candidates = [join9(moduleDir, name), join9(moduleDir, "..", name), join9(moduleDir, "..", "..", name)];
|
|
402
1889
|
return candidates.find((path) => existsSync(path)) ?? candidates[1];
|
|
403
1890
|
}
|
|
1891
|
+
function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
|
|
1892
|
+
return resolveBundledAssetDir("agents", moduleUrl);
|
|
1893
|
+
}
|
|
1894
|
+
function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
|
|
1895
|
+
return resolveBundledAssetDir("templates", moduleUrl);
|
|
1896
|
+
}
|
|
404
1897
|
|
|
405
1898
|
// src/modes.ts
|
|
406
1899
|
async function discoverAgentModes(options) {
|
|
407
1900
|
const agentDir = options.agentDir ?? getAgentDir();
|
|
408
|
-
const homeDir = options.homeDir ??
|
|
1901
|
+
const homeDir = options.homeDir ?? homedir6();
|
|
409
1902
|
const modes = new Map;
|
|
410
1903
|
const diagnostics = [];
|
|
411
1904
|
await loadAgentModes(options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR, "diffpi agent", modes, diagnostics);
|
|
412
|
-
await loadAgentModes(
|
|
1905
|
+
await loadAgentModes(join10(agentDir, "agents"), "user agent", modes, diagnostics);
|
|
413
1906
|
if (options.includeSkills) {
|
|
414
|
-
await loadSkillModes(
|
|
415
|
-
await loadSkillModes(
|
|
1907
|
+
await loadSkillModes(join10(homeDir, ".agents", "skills"), "user skill", modes, diagnostics);
|
|
1908
|
+
await loadSkillModes(join10(agentDir, "skills"), "pi user skill", modes, diagnostics);
|
|
416
1909
|
}
|
|
417
1910
|
if (options.projectTrusted === true) {
|
|
418
1911
|
if (options.includeSkills) {
|
|
419
|
-
await loadSkillModes(
|
|
420
|
-
await loadSkillModes(
|
|
1912
|
+
await loadSkillModes(join10(options.cwd, ".agents", "skills"), "project skill", modes, diagnostics);
|
|
1913
|
+
await loadSkillModes(join10(options.cwd, ".pi", "skills"), "pi project skill", modes, diagnostics);
|
|
421
1914
|
}
|
|
422
|
-
await loadAgentModes(
|
|
423
|
-
await loadAgentModes(
|
|
1915
|
+
await loadAgentModes(join10(options.cwd, ".agents", "agents"), "project agent", modes, diagnostics);
|
|
1916
|
+
await loadAgentModes(join10(options.cwd, ".pi", "agents"), "pi project agent", modes, diagnostics);
|
|
424
1917
|
}
|
|
425
1918
|
const userConfig = await loadDiffpiConfig({ homeDir });
|
|
426
1919
|
const configuredModes = [...modes.values()].map((mode) => ({
|
|
@@ -457,7 +1950,11 @@ function createModeController(pi, options = {}) {
|
|
|
457
1950
|
let active;
|
|
458
1951
|
let baseline;
|
|
459
1952
|
const updateStatus = (ctx) => {
|
|
460
|
-
ctx.
|
|
1953
|
+
const model = ctx.model ? ` · ${ctx.model.provider}/${ctx.model.id}` : "";
|
|
1954
|
+
const label = active?.label.toUpperCase() ?? "DEFAULT";
|
|
1955
|
+
const badge = `AGENT ${label}${model}`;
|
|
1956
|
+
ctx.ui.setStatus(MODE_STATUS_KEY, undefined);
|
|
1957
|
+
ctx.ui.setWidget?.(MODE_WIDGET_KEY, [badge], { placement: "belowEditor" });
|
|
461
1958
|
};
|
|
462
1959
|
const list = (ctx, listOptions = {}) => discoverAgentModes({
|
|
463
1960
|
cwd: ctx.cwd,
|
|
@@ -512,6 +2009,9 @@ function createModeController(pi, options = {}) {
|
|
|
512
2009
|
}
|
|
513
2010
|
updateStatus(ctx);
|
|
514
2011
|
},
|
|
2012
|
+
refresh(ctx) {
|
|
2013
|
+
updateStatus(ctx);
|
|
2014
|
+
},
|
|
515
2015
|
apply(systemPrompt) {
|
|
516
2016
|
if (!active)
|
|
517
2017
|
return systemPrompt;
|
|
@@ -530,6 +2030,7 @@ ${active.systemPrompt}`;
|
|
|
530
2030
|
}
|
|
531
2031
|
var MODE_STATE_ENTRY = "diffpi-mode-state";
|
|
532
2032
|
var MODE_STATUS_KEY = "diffpi-mode";
|
|
2033
|
+
var MODE_WIDGET_KEY = "diffpi-mode-badge";
|
|
533
2034
|
var MODE_CONTROL_TOOLS = ["ask_user_question", "diffpi_modes_list", "diffpi_modes_set", "diffpi_modes_unset"];
|
|
534
2035
|
var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
|
|
535
2036
|
var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
@@ -583,19 +2084,19 @@ function captureRuntime(pi, ctx) {
|
|
|
583
2084
|
async function loadSkillModes(skillsDir, source, modes, diagnostics) {
|
|
584
2085
|
const entries = await readDirectoryIfExists(skillsDir);
|
|
585
2086
|
for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
586
|
-
await loadAgentModes(
|
|
2087
|
+
await loadAgentModes(join10(skillsDir, entry.name, "agents"), `${source} ${entry.name}`, modes, diagnostics, entry.name);
|
|
587
2088
|
}
|
|
588
2089
|
}
|
|
589
2090
|
async function loadAgentModes(directory, source, modes, diagnostics, skillName) {
|
|
590
2091
|
const entries = await readDirectoryIfExists(directory);
|
|
591
2092
|
for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
592
|
-
const path =
|
|
2093
|
+
const path = join10(directory, entry.name);
|
|
593
2094
|
try {
|
|
594
|
-
const content = await
|
|
2095
|
+
const content = await readFile8(path, "utf8");
|
|
595
2096
|
const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
596
2097
|
if (frontmatter.enabled === false || frontmatter.inline === false)
|
|
597
2098
|
continue;
|
|
598
|
-
const name = getFrontmatterText(frontmatter.name) ??
|
|
2099
|
+
const name = getFrontmatterText(frontmatter.name) ?? basename4(path, extname(path));
|
|
599
2100
|
const systemPrompt = body.trim();
|
|
600
2101
|
if (!name || name.includes(":") || !systemPrompt) {
|
|
601
2102
|
diagnostics.push(`Skipped ${path}: agent name must not contain ":" and prompt body is required.`);
|
|
@@ -605,7 +2106,7 @@ async function loadAgentModes(directory, source, modes, diagnostics, skillName)
|
|
|
605
2106
|
modes.set(id, {
|
|
606
2107
|
id,
|
|
607
2108
|
label: getFrontmatterText(frontmatter.display_name) ?? name,
|
|
608
|
-
description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${
|
|
2109
|
+
description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${basename4(path)}`,
|
|
609
2110
|
systemPrompt,
|
|
610
2111
|
promptStrategy: frontmatter.prompt_mode === "append" ? "append" : "replace",
|
|
611
2112
|
modelPreferences: [
|
|
@@ -647,9 +2148,9 @@ function isModeBaseline(value) {
|
|
|
647
2148
|
return (model === undefined || typeof model.provider === "string" && typeof model.id === "string") && candidate.thinkingLevel !== undefined && THINKING_LEVELS.has(candidate.thinkingLevel) && Array.isArray(candidate.tools) && candidate.tools.every((tool) => typeof tool === "string");
|
|
648
2149
|
}
|
|
649
2150
|
// src/pi.ts
|
|
650
|
-
import { mkdir as
|
|
651
|
-
import { homedir as
|
|
652
|
-
import { dirname as
|
|
2151
|
+
import { mkdir as mkdir5, writeFile as writeFile6 } from "node:fs/promises";
|
|
2152
|
+
import { homedir as homedir7 } from "node:os";
|
|
2153
|
+
import { dirname as dirname6, join as join11 } from "node:path";
|
|
653
2154
|
var pi = {
|
|
654
2155
|
executableCheck: findPiExecutable,
|
|
655
2156
|
packageList: listPiPackages,
|
|
@@ -662,19 +2163,19 @@ var pi = {
|
|
|
662
2163
|
configEnsure: ensurePiConfig
|
|
663
2164
|
};
|
|
664
2165
|
async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
|
|
665
|
-
const path =
|
|
2166
|
+
const path = join11(agentDir, "agents", filename);
|
|
666
2167
|
const currentText = await readTextIfExists(path);
|
|
667
2168
|
const changed = currentText !== content;
|
|
668
2169
|
if (changed && !dryRun) {
|
|
669
|
-
await
|
|
670
|
-
await
|
|
2170
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2171
|
+
await writeFile6(path, content, "utf8");
|
|
671
2172
|
}
|
|
672
2173
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
673
2174
|
}
|
|
674
|
-
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir =
|
|
675
|
-
const roots = [
|
|
2175
|
+
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join11(homedir7(), ".agents", "skills")) {
|
|
2176
|
+
const roots = [join11(agentDir, "skills"), sharedSkillsDir];
|
|
676
2177
|
for (const root of roots) {
|
|
677
|
-
if (await readTextIfExists(
|
|
2178
|
+
if (await readTextIfExists(join11(root, name, "SKILL.md")) !== undefined)
|
|
678
2179
|
return true;
|
|
679
2180
|
}
|
|
680
2181
|
return false;
|
|
@@ -703,8 +2204,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
|
|
|
703
2204
|
const next = update(current);
|
|
704
2205
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
705
2206
|
if (changed && !dryRun) {
|
|
706
|
-
await
|
|
707
|
-
await
|
|
2207
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2208
|
+
await writeFile6(path, `${JSON.stringify(next, null, 2)}
|
|
708
2209
|
`, "utf8");
|
|
709
2210
|
}
|
|
710
2211
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
@@ -723,8 +2224,8 @@ function hasPiPackage(listOutput, source) {
|
|
|
723
2224
|
async function installPiPackage(executable, source) {
|
|
724
2225
|
await runChecked(executable, ["install", source]);
|
|
725
2226
|
}
|
|
726
|
-
function resolvePiAgentDir(homeDir =
|
|
727
|
-
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ?
|
|
2227
|
+
function resolvePiAgentDir(homeDir = homedir7()) {
|
|
2228
|
+
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join11(process.env.XDG_CONFIG_HOME, "pi") : join11(homeDir, ".pi", "agent"));
|
|
728
2229
|
}
|
|
729
2230
|
function parseJsonObject(content, path) {
|
|
730
2231
|
if (!content?.trim())
|
|
@@ -738,9 +2239,9 @@ function parseJsonObject(content, path) {
|
|
|
738
2239
|
}
|
|
739
2240
|
// src/setup.ts
|
|
740
2241
|
import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
|
|
741
|
-
import { readdir as readdir2, readFile as
|
|
742
|
-
import { homedir as
|
|
743
|
-
import { basename as
|
|
2242
|
+
import { readdir as readdir2, readFile as readFile9 } from "node:fs/promises";
|
|
2243
|
+
import { homedir as homedir8 } from "node:os";
|
|
2244
|
+
import { basename as basename5, join as join12 } from "node:path";
|
|
744
2245
|
var MISE_DEPENDENCIES = [
|
|
745
2246
|
{ name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
|
|
746
2247
|
{ name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
|
|
@@ -768,13 +2269,18 @@ var PI_PACKAGES = [
|
|
|
768
2269
|
];
|
|
769
2270
|
var PI_SKILL_SOURCES = [
|
|
770
2271
|
{ repository: "arabold/docs-mcp-server", skills: ["docs-manage", "docs-search", "fetch-url"] },
|
|
771
|
-
{ repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
|
|
2272
|
+
{ repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] },
|
|
2273
|
+
{ repository: "cloudvoyant/codevoyant", skills: ["git"] }
|
|
772
2274
|
];
|
|
773
2275
|
var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
|
|
774
2276
|
var BUNDLED_AGENTS_DIR2 = resolveBundledAgentsDir();
|
|
2277
|
+
var FORGE_DEPENDENCIES = {
|
|
2278
|
+
github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
|
|
2279
|
+
gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
|
|
2280
|
+
};
|
|
775
2281
|
async function ensureMise(options = {}) {
|
|
776
|
-
const homeDir = options.homeDir ??
|
|
777
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
2282
|
+
const homeDir = options.homeDir ?? homedir8();
|
|
2283
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join12(homeDir, ".local", "bin", "mise"));
|
|
778
2284
|
if (current)
|
|
779
2285
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
780
2286
|
reportProgress(options, "Installing mise");
|
|
@@ -803,7 +2309,11 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
|
|
|
803
2309
|
async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
804
2310
|
const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
|
|
805
2311
|
const actions = [];
|
|
806
|
-
|
|
2312
|
+
const dependencies = [
|
|
2313
|
+
...MISE_DEPENDENCIES,
|
|
2314
|
+
...configuredForges(options).map((forge) => FORGE_DEPENDENCIES[forge])
|
|
2315
|
+
];
|
|
2316
|
+
for (const dependency of dependencies) {
|
|
807
2317
|
const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
|
|
808
2318
|
if (installed) {
|
|
809
2319
|
actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
|
|
@@ -819,9 +2329,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
819
2329
|
async function ensurePiPlugins(options = {}) {
|
|
820
2330
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
821
2331
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
822
|
-
const webSearch = await pi.configEnsure(
|
|
2332
|
+
const webSearch = await pi.configEnsure(join12(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
823
2333
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
824
|
-
const lsp = await pi.configEnsure(
|
|
2334
|
+
const lsp = await pi.configEnsure(join12(agentDir, "pi-lsp.json"), (config) => ({
|
|
825
2335
|
...config,
|
|
826
2336
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
827
2337
|
}), options.dryRun);
|
|
@@ -835,8 +2345,8 @@ async function ensurePiAgents(options = {}) {
|
|
|
835
2345
|
const entries = (await readdir2(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
|
|
836
2346
|
const actions = [];
|
|
837
2347
|
for (const entry of entries) {
|
|
838
|
-
const id =
|
|
839
|
-
const source = await
|
|
2348
|
+
const id = basename5(entry.name, ".md").replace(/^diffpi-/, "");
|
|
2349
|
+
const source = await readFile9(join12(bundledAgentsDir, entry.name), "utf8");
|
|
840
2350
|
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
841
2351
|
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
842
2352
|
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
@@ -845,7 +2355,7 @@ async function ensurePiAgents(options = {}) {
|
|
|
845
2355
|
}
|
|
846
2356
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
847
2357
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
848
|
-
const sharedSkillsDir =
|
|
2358
|
+
const sharedSkillsDir = join12(options.homeDir ?? homedir8(), ".agents", "skills");
|
|
849
2359
|
const actions = [];
|
|
850
2360
|
for (const source of PI_SKILL_SOURCES) {
|
|
851
2361
|
const missing = [];
|
|
@@ -889,6 +2399,14 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
|
|
|
889
2399
|
} else if (options.issueTracker === "jira") {
|
|
890
2400
|
servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
|
|
891
2401
|
}
|
|
2402
|
+
for (const forge of configuredForges(options)) {
|
|
2403
|
+
if (forge === "github") {
|
|
2404
|
+
servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
|
|
2405
|
+
continue;
|
|
2406
|
+
}
|
|
2407
|
+
const host = await gitlabMcpHost(projectDir);
|
|
2408
|
+
servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
|
|
2409
|
+
}
|
|
892
2410
|
const result = await mcp.serversEnsure(servers, {
|
|
893
2411
|
dryRun: options.dryRun,
|
|
894
2412
|
path: mcp.globalConfigPath(options.homeDir)
|
|
@@ -905,6 +2423,8 @@ async function setupPi(options = {}) {
|
|
|
905
2423
|
actions.push(...await ensurePiAgents(options));
|
|
906
2424
|
actions.push(...await ensurePiSkills(miseResult.executable, options));
|
|
907
2425
|
actions.push(...await ensureMcpAdapters(miseResult.executable, options));
|
|
2426
|
+
if (options.bindZedKey)
|
|
2427
|
+
actions.push(...await ensureZedIntegration(options));
|
|
908
2428
|
return {
|
|
909
2429
|
actions,
|
|
910
2430
|
restartPi: setupRequiresRestart(actions)
|
|
@@ -913,6 +2433,40 @@ async function setupPi(options = {}) {
|
|
|
913
2433
|
function setupRequiresRestart(actions) {
|
|
914
2434
|
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"));
|
|
915
2435
|
}
|
|
2436
|
+
async function ensureZedIntegration(options = {}) {
|
|
2437
|
+
if (options.dryRun) {
|
|
2438
|
+
const actions = [
|
|
2439
|
+
createSetupAction("Zed review tasks", "planned", "global static runtime-resolver tasks in tasks.json")
|
|
2440
|
+
];
|
|
2441
|
+
if (options.bindZedKey)
|
|
2442
|
+
actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
|
|
2443
|
+
return actions;
|
|
2444
|
+
}
|
|
2445
|
+
const actions = [];
|
|
2446
|
+
try {
|
|
2447
|
+
const tasks = await ensureZedReviewTask(options.homeDir);
|
|
2448
|
+
actions.push(createSetupAction("Zed review tasks", tasks.changed ? "installed" : "ready", tasks.path));
|
|
2449
|
+
} catch (error) {
|
|
2450
|
+
actions.push(createSetupAction("Zed review tasks", "skipped", error instanceof Error ? error.message : String(error)));
|
|
2451
|
+
}
|
|
2452
|
+
if (options.bindZedKey) {
|
|
2453
|
+
try {
|
|
2454
|
+
const key = await ensureZedReviewKeybinding(options.homeDir);
|
|
2455
|
+
actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
|
|
2456
|
+
} catch (error) {
|
|
2457
|
+
actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
return actions;
|
|
2461
|
+
}
|
|
2462
|
+
async function gitlabMcpHost(projectDir) {
|
|
2463
|
+
try {
|
|
2464
|
+
const vcs = await detectVcs(projectDir);
|
|
2465
|
+
return vcs.provider === "gitlab" && vcs.host ? vcs.host : "gitlab.com";
|
|
2466
|
+
} catch {
|
|
2467
|
+
return "gitlab.com";
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
916
2470
|
function materializeAgentModels(content, agentId, config, availableModels) {
|
|
917
2471
|
const { frontmatter } = parseFrontmatter3(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
918
2472
|
const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
|
|
@@ -972,6 +2526,10 @@ ${source}`;
|
|
|
972
2526
|
}
|
|
973
2527
|
return actions;
|
|
974
2528
|
}
|
|
2529
|
+
function configuredForges(options) {
|
|
2530
|
+
const selected = options.forges ?? (options.forge && options.forge !== "none" ? [options.forge] : []);
|
|
2531
|
+
return [...new Set(selected)];
|
|
2532
|
+
}
|
|
975
2533
|
function getTextList(value) {
|
|
976
2534
|
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
977
2535
|
return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
@@ -992,8 +2550,65 @@ function reportProgress(options, message) {
|
|
|
992
2550
|
function getRecord(value) {
|
|
993
2551
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
994
2552
|
}
|
|
2553
|
+
// src/templates.ts
|
|
2554
|
+
import { readFile as readFile10 } from "node:fs/promises";
|
|
2555
|
+
import { homedir as homedir9 } from "node:os";
|
|
2556
|
+
import { join as join13, normalize } from "node:path";
|
|
2557
|
+
async function loadTemplate(name, options = {}) {
|
|
2558
|
+
const relative = templateRelativePath(name);
|
|
2559
|
+
const userPath = join13(options.homeDir ?? homedir9(), ".difflab", "diffpi", "templates", relative);
|
|
2560
|
+
const bundledPath = join13(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
2561
|
+
const user = await readOptionalFile(userPath);
|
|
2562
|
+
if (user !== undefined)
|
|
2563
|
+
return { name, path: userPath, source: "user", content: user };
|
|
2564
|
+
const bundled = await readOptionalFile(bundledPath);
|
|
2565
|
+
if (bundled !== undefined)
|
|
2566
|
+
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
2567
|
+
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
2568
|
+
}
|
|
2569
|
+
function renderTemplate(content, variables) {
|
|
2570
|
+
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
2571
|
+
}
|
|
2572
|
+
function templateRelativePath(name) {
|
|
2573
|
+
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
2574
|
+
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
2575
|
+
throw new Error(`Invalid template name: ${name}`);
|
|
2576
|
+
}
|
|
2577
|
+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
2578
|
+
}
|
|
2579
|
+
async function readOptionalFile(path) {
|
|
2580
|
+
try {
|
|
2581
|
+
return await readFile10(path, "utf8");
|
|
2582
|
+
} catch (error) {
|
|
2583
|
+
if (error.code === "ENOENT")
|
|
2584
|
+
return;
|
|
2585
|
+
throw error;
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
995
2588
|
export {
|
|
2589
|
+
CONVENTIONAL_COMMIT,
|
|
2590
|
+
GitHubVcsBackend,
|
|
2591
|
+
GitLabVcsBackend,
|
|
2592
|
+
ZED_LOCAL_REVIEW_TASK_NAME,
|
|
2593
|
+
ZED_PR_REVIEW_TASK_NAME,
|
|
2594
|
+
ZED_REVIEW_TASK_NAME,
|
|
2595
|
+
addComment,
|
|
2596
|
+
assertGitHubMergeReady,
|
|
2597
|
+
assertReviewEventSupported,
|
|
2598
|
+
checkConventionalSubject,
|
|
2599
|
+
ciGate,
|
|
2600
|
+
computeProjectSlug,
|
|
2601
|
+
createForge,
|
|
2602
|
+
createForgeBackend,
|
|
2603
|
+
createLocalReviewBackend,
|
|
996
2604
|
createModeController,
|
|
2605
|
+
createRemoteReviewBackend,
|
|
2606
|
+
createVcsBackend,
|
|
2607
|
+
dedupeFindings,
|
|
2608
|
+
detectIde,
|
|
2609
|
+
detectMux,
|
|
2610
|
+
detectShell,
|
|
2611
|
+
detectVcs,
|
|
997
2612
|
diffpiConfigPaths,
|
|
998
2613
|
discoverAgentModes,
|
|
999
2614
|
ensureMcpAdapters,
|
|
@@ -1003,12 +2618,59 @@ export {
|
|
|
1003
2618
|
ensurePiAgents,
|
|
1004
2619
|
ensurePiPlugins,
|
|
1005
2620
|
ensurePiSkills,
|
|
2621
|
+
ensureStore,
|
|
2622
|
+
ensureZedReviewKeybinding,
|
|
2623
|
+
ensureZedReviewTask,
|
|
1006
2624
|
findPreferredModel,
|
|
2625
|
+
findingSchema,
|
|
2626
|
+
findingsSchema,
|
|
2627
|
+
gitToplevel,
|
|
2628
|
+
githubReviewSubmissionEndpoint,
|
|
2629
|
+
hasGitlabDraftNotes,
|
|
2630
|
+
launch,
|
|
2631
|
+
listSessions,
|
|
1007
2632
|
loadDiffpiConfig,
|
|
2633
|
+
loadReviewPublicationState,
|
|
2634
|
+
loadTemplate,
|
|
2635
|
+
localReviewAuthor,
|
|
1008
2636
|
mcp,
|
|
1009
2637
|
mise,
|
|
2638
|
+
openInNewTab,
|
|
2639
|
+
parseGitlabDiffRefs,
|
|
2640
|
+
parseRemote,
|
|
2641
|
+
parseThreadArtifact,
|
|
1010
2642
|
pi,
|
|
2643
|
+
readSession,
|
|
2644
|
+
renderReviewDoc,
|
|
2645
|
+
renderTemplate,
|
|
2646
|
+
renderThreadArtifact,
|
|
1011
2647
|
resolveAgentMode,
|
|
1012
2648
|
resolveAgentModelPreferences,
|
|
1013
|
-
|
|
2649
|
+
resolvePrSession,
|
|
2650
|
+
resolveReviewSession,
|
|
2651
|
+
resolveSession,
|
|
2652
|
+
reviewBodyFingerprint,
|
|
2653
|
+
reviewCommentFingerprint,
|
|
2654
|
+
reviewRecordName,
|
|
2655
|
+
reviewReplyFingerprint,
|
|
2656
|
+
reviewSlug,
|
|
2657
|
+
reviewsDir,
|
|
2658
|
+
runMiseGates,
|
|
2659
|
+
saveReviewPublicationState,
|
|
2660
|
+
sessionsDir,
|
|
2661
|
+
setupPi,
|
|
2662
|
+
severitySchema,
|
|
2663
|
+
storeDir,
|
|
2664
|
+
storeGlobalRoot,
|
|
2665
|
+
templateRelativePath,
|
|
2666
|
+
toFindings,
|
|
2667
|
+
toReviewComments,
|
|
2668
|
+
tuicrAvailable,
|
|
2669
|
+
unpublishedReviewComments,
|
|
2670
|
+
upsertThreadReply,
|
|
2671
|
+
withRemoteProvenance,
|
|
2672
|
+
yymmdd,
|
|
2673
|
+
zedKeymapPath,
|
|
2674
|
+
zedReviewTaskName,
|
|
2675
|
+
zedTasksPath
|
|
1014
2676
|
};
|