@henryqw/pi-pr 4.0.2 → 4.0.4
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 +12 -10
- package/extensions/pr-ci.ts +0 -1
- package/extensions/pr-command.ts +36 -27
- package/extensions/pr-comment-sweep.ts +0 -27
- package/extensions/pr-create.ts +76 -230
- package/extensions/pr-execution.ts +28 -163
- package/extensions/pr-github.ts +339 -193
- package/extensions/pr-merge.ts +24 -111
- package/extensions/pr-routing.ts +6 -2
- package/extensions/pr-ui.ts +1 -2
- package/extensions/pr-update-branch.ts +28 -90
- package/extensions/pr.ts +36 -58
- package/package.json +6 -2
- package/skills/pi-pr-create/SKILL.md +5 -5
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { createHash
|
|
2
|
-
import { lstat, realpath } from "node:fs/promises";
|
|
3
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { extensionConfigDir } from "@henryqw/pi-config-store";
|
|
6
|
+
import { lock } from "proper-lockfile";
|
|
6
7
|
|
|
7
8
|
export const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
|
|
8
9
|
export const DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024;
|
|
@@ -13,10 +14,7 @@ export const MAX_CONFLICT_PATHS_BYTES = 32 * 1024;
|
|
|
13
14
|
const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
14
15
|
const GIT_OPERATION_STATES = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
|
|
15
16
|
const PROCESS_TERMINATION_GRACE_MS = 250;
|
|
16
|
-
const
|
|
17
|
-
const LOCK_NONCE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
18
|
-
const LOCK_OWNER_PAYLOAD = /^pid=([1-9][0-9]*)\nnonce=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\n$/;
|
|
19
|
-
const LOCK_REF_PREFIX = "refs/pi-pr/worktree-locks";
|
|
17
|
+
const WORKTREE_LOCK_OPTIONS = { realpath: false, stale: 30_000, update: 5_000, retries: 0 } as const;
|
|
20
18
|
|
|
21
19
|
export type ExecResult = {
|
|
22
20
|
stdout: string;
|
|
@@ -265,135 +263,7 @@ export async function inspectWorktree(exec: Exec, options: ExecOptions): Promise
|
|
|
265
263
|
return status.stdout === "" ? "clean" : "dirty";
|
|
266
264
|
}
|
|
267
265
|
|
|
268
|
-
|
|
269
|
-
oid: string;
|
|
270
|
-
pid: number;
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
function gitFailure(args: string[], result: ExecResult): Error {
|
|
274
|
-
const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was killed" : `exit code ${result.code}`);
|
|
275
|
-
return new Error(`${commandText("git", args)} failed: ${detail}`);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function oidLine(output: string, label: string): string {
|
|
279
|
-
const normalized = output.replace(/\r\n/g, "\n");
|
|
280
|
-
if (!normalized.endsWith("\n") || normalized.slice(0, -1).includes("\n")) {
|
|
281
|
-
throw new Error(`${label} returned invalid output`);
|
|
282
|
-
}
|
|
283
|
-
return requiredOid(normalized.slice(0, -1), label);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function lockOwnerPid(payload: string): number {
|
|
287
|
-
const match = LOCK_OWNER_PAYLOAD.exec(payload);
|
|
288
|
-
if (!match || match[0] !== payload) throw new Error("Git worktree lock owner object is invalid");
|
|
289
|
-
const pid = Number(match[1]);
|
|
290
|
-
if (!Number.isSafeInteger(pid) || pid <= 0 || String(pid) !== match[1]) {
|
|
291
|
-
throw new Error("Git worktree lock owner PID is invalid");
|
|
292
|
-
}
|
|
293
|
-
return pid;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function ownerIsDead(pid: number): boolean {
|
|
297
|
-
positiveInteger(pid, "Git worktree lock owner PID");
|
|
298
|
-
try {
|
|
299
|
-
process.kill(pid, 0);
|
|
300
|
-
return false;
|
|
301
|
-
} catch (error) {
|
|
302
|
-
return !!error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ESRCH";
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function zeroOid(oid: string): string {
|
|
307
|
-
return requiredOid(oid, "Git worktree lock owner OID").replace(/[0-9a-f]/g, "0");
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
async function readWorktreeLock(cwd: string, lockRef: string, signal?: AbortSignal): Promise<WorktreeLockOwner | null> {
|
|
311
|
-
const symbolicArgs = ["symbolic-ref", "-q", lockRef];
|
|
312
|
-
const symbolic = await spawnBounded("git", symbolicArgs, { cwd, signal });
|
|
313
|
-
if (symbolic.killed || (symbolic.code !== 0 && symbolic.code !== 1)) throw gitFailure(symbolicArgs, symbolic);
|
|
314
|
-
if (symbolic.code === 0) throw new Error("Git worktree lock ref is symbolic");
|
|
315
|
-
if (symbolic.stdout !== "" || symbolic.stderr !== "") throw new Error("Git worktree lock symbolic-ref check returned invalid output");
|
|
316
|
-
|
|
317
|
-
const refArgs = ["rev-parse", "--verify", "--quiet", lockRef];
|
|
318
|
-
const ref = await spawnBounded("git", refArgs, { cwd, signal });
|
|
319
|
-
if (ref.killed || (ref.code !== 0 && ref.code !== 1)) throw gitFailure(refArgs, ref);
|
|
320
|
-
if (ref.code === 1) {
|
|
321
|
-
if (ref.stdout !== "" || ref.stderr !== "") throw new Error("Git worktree lock ref lookup returned invalid output");
|
|
322
|
-
return null;
|
|
323
|
-
}
|
|
324
|
-
if (ref.stderr !== "") throw new Error("Git worktree lock ref lookup returned invalid output");
|
|
325
|
-
const oid = oidLine(ref.stdout, "Git worktree lock ref");
|
|
326
|
-
|
|
327
|
-
const objectArgs = ["cat-file", "blob", oid];
|
|
328
|
-
const object = await runChecked(spawnBounded, "git", objectArgs, {
|
|
329
|
-
cwd,
|
|
330
|
-
signal,
|
|
331
|
-
stdoutLimitBytes: LOCK_OWNER_MAX_BYTES,
|
|
332
|
-
stderrLimitBytes: LOCK_OWNER_MAX_BYTES,
|
|
333
|
-
});
|
|
334
|
-
if (object.stderr !== "") throw new Error("Git worktree lock owner object returned invalid output");
|
|
335
|
-
return { oid, pid: lockOwnerPid(object.stdout) };
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
async function updateWorktreeLock(cwd: string, args: string[]): Promise<boolean> {
|
|
339
|
-
const result = await spawnBounded("git", args, { cwd });
|
|
340
|
-
if (result.killed) throw gitFailure(args, result);
|
|
341
|
-
return result.code === 0;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
async function createWorktreeLock(cwd: string, lockRef: string, ownerOid: string): Promise<boolean> {
|
|
345
|
-
return await updateWorktreeLock(cwd, ["update-ref", "--no-deref", lockRef, ownerOid, zeroOid(ownerOid)]);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
async function deleteWorktreeLock(cwd: string, lockRef: string, ownerOid: string): Promise<boolean> {
|
|
349
|
-
return await updateWorktreeLock(cwd, ["update-ref", "--no-deref", "-d", lockRef, ownerOid]);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
async function releaseWorktreeLock(cwd: string, lockRef: string, ownerOid: string): Promise<void> {
|
|
353
|
-
const args = ["update-ref", "--no-deref", "-d", lockRef, ownerOid];
|
|
354
|
-
const result = await spawnBounded("git", args, { cwd });
|
|
355
|
-
if (result.killed || result.code !== 0) throw gitFailure(args, result);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
async function acquireWorktreeLock(
|
|
359
|
-
cwd: string,
|
|
360
|
-
canonical: string,
|
|
361
|
-
lockRef: string,
|
|
362
|
-
ownerOid: string,
|
|
363
|
-
signal?: AbortSignal,
|
|
364
|
-
): Promise<void> {
|
|
365
|
-
signal?.throwIfAborted();
|
|
366
|
-
if (await createWorktreeLock(cwd, lockRef, ownerOid)) return;
|
|
367
|
-
signal?.throwIfAborted();
|
|
368
|
-
const observed = await readWorktreeLock(cwd, lockRef, signal);
|
|
369
|
-
if (!observed) throw new Error("Git worktree lock acquisition failed without a competing owner");
|
|
370
|
-
if (!ownerIsDead(observed.pid)) throw new Error(`Another pi-pr mutation is active for ${canonical}`);
|
|
371
|
-
signal?.throwIfAborted();
|
|
372
|
-
const reclaimed = await deleteWorktreeLock(cwd, lockRef, observed.oid);
|
|
373
|
-
signal?.throwIfAborted();
|
|
374
|
-
if (await createWorktreeLock(cwd, lockRef, ownerOid)) return;
|
|
375
|
-
const replacement = await readWorktreeLock(cwd, lockRef, signal);
|
|
376
|
-
if (!replacement) throw new Error("Git worktree lock disappeared during stale owner recovery");
|
|
377
|
-
if (!ownerIsDead(replacement.pid)) throw new Error(`Another pi-pr mutation is active for ${canonical}`);
|
|
378
|
-
if (replacement.oid !== observed.oid) throw new Error("Git worktree lock changed during stale owner recovery");
|
|
379
|
-
if (!reclaimed) throw new Error("Git worktree stale lock owner could not be reclaimed");
|
|
380
|
-
throw new Error("Git worktree stale lock owner remained after reclamation");
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
async function writeWorktreeLockOwner(cwd: string, signal?: AbortSignal): Promise<string> {
|
|
384
|
-
const pid = positiveInteger(process.pid, "process PID");
|
|
385
|
-
const nonce = randomUUID();
|
|
386
|
-
if (!LOCK_NONCE.test(nonce)) throw new Error("Generated Git worktree lock nonce is invalid");
|
|
387
|
-
const result = await runChecked(spawnBounded, "git", ["hash-object", "-w", "--stdin", "--no-filters"], {
|
|
388
|
-
cwd,
|
|
389
|
-
signal,
|
|
390
|
-
stdin: `pid=${pid}\nnonce=${nonce}\n`,
|
|
391
|
-
});
|
|
392
|
-
if (result.stderr !== "") throw new Error("Git worktree lock owner creation returned invalid output");
|
|
393
|
-
return oidLine(result.stdout, "Git worktree lock owner");
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/** Exclude concurrent PR mutations for one canonical worktree and pi-pr namespace with a Git-ref lease. */
|
|
266
|
+
/** Exclude concurrent PR mutations for one canonical worktree and pi-pr namespace. */
|
|
397
267
|
export async function withWorktreeLock<T>(
|
|
398
268
|
cwd: string,
|
|
399
269
|
operation: () => Promise<T>,
|
|
@@ -409,17 +279,29 @@ export async function withWorktreeLock<T>(
|
|
|
409
279
|
if (rootLines.length !== 1 || !rootLines[0]) throw new Error("Git worktree root resolution returned invalid output");
|
|
410
280
|
const canonical = requiredText(await realpath(rootLines[0]), "canonical Git worktree root");
|
|
411
281
|
const lockNamespace = resolve(extensionConfigDir("pi-pr", options.agentDir));
|
|
412
|
-
const
|
|
413
|
-
const
|
|
414
|
-
const
|
|
415
|
-
|
|
282
|
+
const lockDirectory = join(lockNamespace, "worktree-locks");
|
|
283
|
+
const identity = createHash("sha256").update(canonical).digest("hex");
|
|
284
|
+
const lockPath = join(lockDirectory, identity);
|
|
285
|
+
options.signal?.throwIfAborted();
|
|
286
|
+
await mkdir(lockDirectory, { recursive: true, mode: 0o700 });
|
|
287
|
+
options.signal?.throwIfAborted();
|
|
288
|
+
let release: () => Promise<void>;
|
|
289
|
+
try {
|
|
290
|
+
release = await lock(lockPath, {
|
|
291
|
+
...WORKTREE_LOCK_OPTIONS,
|
|
292
|
+
lockfilePath: `${lockPath}.lock`,
|
|
293
|
+
});
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ELOCKED") {
|
|
296
|
+
throw new Error("Another pi-pr mutation is active", { cause: error });
|
|
297
|
+
}
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
416
300
|
try {
|
|
417
|
-
await acquireWorktreeLock(canonical, canonical, lockRef, ownerOid, options.signal);
|
|
418
|
-
acquired = true;
|
|
419
301
|
options.signal?.throwIfAborted();
|
|
420
302
|
return await operation();
|
|
421
303
|
} finally {
|
|
422
|
-
|
|
304
|
+
await release();
|
|
423
305
|
}
|
|
424
306
|
}
|
|
425
307
|
|
|
@@ -535,24 +417,7 @@ export async function resolveRepositoryFetchSource(
|
|
|
535
417
|
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) throw new TypeError("repository must be OWNER/REPOSITORY");
|
|
536
418
|
const protocol = (await runChecked(exec, "gh", ["config", "get", "git_protocol", "--host", host], options)).stdout.trim();
|
|
537
419
|
if (protocol !== "https" && protocol !== "ssh") throw new Error("GitHub CLI git protocol must be https or ssh");
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
"-H", "X-GitHub-Api-Version: 2022-11-28",
|
|
542
|
-
`repos/${repository}`,
|
|
543
|
-
], options);
|
|
544
|
-
let value: unknown;
|
|
545
|
-
try {
|
|
546
|
-
value = JSON.parse(response.stdout);
|
|
547
|
-
} catch {
|
|
548
|
-
throw new Error("Read repository URLs returned invalid JSON");
|
|
549
|
-
}
|
|
550
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Read repository URLs returned invalid JSON");
|
|
551
|
-
const record = value as Record<string, unknown>;
|
|
552
|
-
const expectedHttps = `https://${host}/${repository}.git`;
|
|
553
|
-
const expectedSsh = `git@${host}:${repository}.git`;
|
|
554
|
-
if (record.clone_url !== expectedHttps || record.ssh_url !== expectedSsh) {
|
|
555
|
-
throw new Error("Read repository URLs did not match frozen repository authority");
|
|
556
|
-
}
|
|
557
|
-
return protocol === "https" ? expectedHttps : expectedSsh;
|
|
420
|
+
return protocol === "https"
|
|
421
|
+
? `https://${host}/${repository}.git`
|
|
422
|
+
: `git@${host}:${repository}.git`;
|
|
558
423
|
}
|