@henryqw/pi-pr 4.0.3 → 4.0.5
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 +4 -4
- package/extensions/pr-ci.ts +0 -1
- package/extensions/pr-command.ts +2 -14
- package/extensions/pr-comment-sweep.ts +0 -27
- package/extensions/pr-create.ts +75 -219
- package/extensions/pr-execution.ts +28 -163
- package/extensions/pr-github.ts +58 -207
- package/extensions/pr-merge.ts +24 -111
- package/extensions/pr-update-branch.ts +28 -90
- package/extensions/pr.ts +22 -38
- package/package.json +6 -2
|
@@ -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
|
}
|
package/extensions/pr-github.ts
CHANGED
|
@@ -15,7 +15,6 @@ import type {
|
|
|
15
15
|
PullRequestLifecycle,
|
|
16
16
|
PullRequestTarget,
|
|
17
17
|
ReviewReadiness,
|
|
18
|
-
PolicyReadiness,
|
|
19
18
|
} from "./pr-routing.ts";
|
|
20
19
|
|
|
21
20
|
const EXEC_TIMEOUT_MS = 10_000;
|
|
@@ -27,7 +26,6 @@ const PR_PUBLICATION_FIELDS = "number,url,state,baseRefName,headRefName,headRefO
|
|
|
27
26
|
const PR_DISCOVERY_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!,$endCursor:String){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name associatedPullRequests(first:100,after:$endCursor){totalCount edges{cursor node{__typename number url state baseRepository{nameWithOwner}headRepository{nameWithOwner}headRefName headRefOid}}pageInfo{hasNextPage startCursor endCursor}}}}}";
|
|
28
27
|
const REVIEW_THREADS_QUERY = "query($id:ID!,$endCursor:String){node(id:$id){...on PullRequest{reviewThreads(first:100,after:$endCursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}";
|
|
29
28
|
const BASE_REF_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name target{oid}}}}";
|
|
30
|
-
const BASE_BRANCH_POLICY_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name branchProtectionRule{requiresStrictStatusChecks}}}}";
|
|
31
29
|
const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
32
30
|
const FAILED_CHECK_STATES = new Set([
|
|
33
31
|
"ACTION_REQUIRED",
|
|
@@ -60,7 +58,6 @@ const MERGE_STATE_VALUES = new Set([
|
|
|
60
58
|
"UNSTABLE",
|
|
61
59
|
]);
|
|
62
60
|
const REVIEW_DECISION_VALUES = new Set(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]);
|
|
63
|
-
const MERGE_METHODS: MergeMethod[] = ["merge", "rebase", "squash"];
|
|
64
61
|
|
|
65
62
|
export class PullRequestLoadError extends Error {
|
|
66
63
|
constructor(message: string) {
|
|
@@ -75,13 +72,6 @@ export type PullRequestRef = {
|
|
|
75
72
|
oid: string;
|
|
76
73
|
};
|
|
77
74
|
|
|
78
|
-
export type MergeMethod = "merge" | "rebase" | "squash";
|
|
79
|
-
|
|
80
|
-
export type PullRequestMerge = {
|
|
81
|
-
allowedMergeMethods: MergeMethod[];
|
|
82
|
-
viewerDefaultMergeMethod: MergeMethod;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
75
|
export type CurrentPullRequest = PullRequest & {
|
|
86
76
|
id: string;
|
|
87
77
|
number: number;
|
|
@@ -92,7 +82,6 @@ export type CurrentPullRequest = PullRequest & {
|
|
|
92
82
|
head: PullRequestRef;
|
|
93
83
|
headFetchSource: string;
|
|
94
84
|
target: PullRequestTarget;
|
|
95
|
-
merge: PullRequestMerge | null;
|
|
96
85
|
};
|
|
97
86
|
|
|
98
87
|
export type CurrentPullRequestDiscovery = PullRequestDiscovery<CurrentPullRequest>;
|
|
@@ -118,6 +107,10 @@ export type PullRequestCreationPreflight = {
|
|
|
118
107
|
ahead: number;
|
|
119
108
|
};
|
|
120
109
|
|
|
110
|
+
type CreationPreflightResult =
|
|
111
|
+
| { kind: "same-ref" }
|
|
112
|
+
| { kind: "distinct-ref"; preflight: PullRequestCreationPreflight };
|
|
113
|
+
|
|
121
114
|
type CreationIdentity = {
|
|
122
115
|
target: PullRequestTarget;
|
|
123
116
|
head: string;
|
|
@@ -225,11 +218,6 @@ type SearchPage = {
|
|
|
225
218
|
endCursor: string | null;
|
|
226
219
|
};
|
|
227
220
|
|
|
228
|
-
type RulesetBranchPolicy = {
|
|
229
|
-
requiresStrictStatusChecks: boolean;
|
|
230
|
-
allowedMergeMethods: MergeMethod[] | null;
|
|
231
|
-
};
|
|
232
|
-
|
|
233
221
|
type CheckState = {
|
|
234
222
|
state: string;
|
|
235
223
|
diagnosableFailure: boolean;
|
|
@@ -894,7 +882,6 @@ function ciStatus(checks: CheckState[]): CiStatus {
|
|
|
894
882
|
function conditions(
|
|
895
883
|
candidate: ListedPullRequest,
|
|
896
884
|
unresolvedThreads: number,
|
|
897
|
-
requiresStrictStatusChecks: boolean,
|
|
898
885
|
): PullRequestConditions {
|
|
899
886
|
if (
|
|
900
887
|
(candidate.mergeable === "MERGEABLE" && candidate.mergeStateStatus === "DIRTY") ||
|
|
@@ -904,19 +891,17 @@ function conditions(
|
|
|
904
891
|
? "pending"
|
|
905
892
|
: "ready";
|
|
906
893
|
const behind = candidate.mergeStateStatus === "BEHIND";
|
|
907
|
-
const policy: PolicyReadiness = candidate.mergeable === "MERGEABLE" &&
|
|
908
|
-
(candidate.mergeStateStatus === "CLEAN" || (behind && !requiresStrictStatusChecks))
|
|
909
|
-
? "ready"
|
|
910
|
-
: "pending";
|
|
911
894
|
return {
|
|
912
895
|
draft: candidate.isDraft,
|
|
913
|
-
baseUpdateRequired: behind
|
|
896
|
+
baseUpdateRequired: behind,
|
|
914
897
|
conflict: candidate.mergeable === "CONFLICTING" || candidate.mergeStateStatus === "DIRTY",
|
|
915
898
|
changesRequested: candidate.reviewDecision === "CHANGES_REQUESTED",
|
|
916
899
|
unresolvedThreads,
|
|
917
900
|
ci: ciStatus(candidate.checkStates),
|
|
918
901
|
review,
|
|
919
|
-
policy
|
|
902
|
+
policy: candidate.mergeable === "MERGEABLE" && candidate.mergeStateStatus === "CLEAN"
|
|
903
|
+
? "ready"
|
|
904
|
+
: "pending",
|
|
920
905
|
};
|
|
921
906
|
}
|
|
922
907
|
|
|
@@ -983,100 +968,27 @@ function parseBaseRefOid(output: string, candidate: ListedPullRequest): string {
|
|
|
983
968
|
return parseBaseRefAuthority(output, candidate.base);
|
|
984
969
|
}
|
|
985
970
|
|
|
986
|
-
function
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
if (
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
return rule.requiresStrictStatusChecks;
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
function parseRulesetBaseBranchPolicy(output: string): RulesetBranchPolicy {
|
|
1011
|
-
const pages = parseJson(output, "Read base branch rulesets");
|
|
1012
|
-
if (!Array.isArray(pages) || !pages.length) fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
1013
|
-
let requiresStrictStatusChecks = false;
|
|
1014
|
-
let allowedMergeMethods: Set<MergeMethod> | null = null;
|
|
1015
|
-
for (const page of pages) {
|
|
1016
|
-
if (!Array.isArray(page)) fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
1017
|
-
for (const rule of page) {
|
|
1018
|
-
if (!isRecord(rule)) fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
1019
|
-
const type = text(rule.type, "Read base branch rulesets", "rule type");
|
|
1020
|
-
if (type === "required_status_checks") {
|
|
1021
|
-
if (!isRecord(rule.parameters) || typeof rule.parameters.strict_required_status_checks_policy !== "boolean") {
|
|
1022
|
-
fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
1023
|
-
}
|
|
1024
|
-
requiresStrictStatusChecks ||= rule.parameters.strict_required_status_checks_policy;
|
|
1025
|
-
}
|
|
1026
|
-
if (type === "pull_request") {
|
|
1027
|
-
if (!isRecord(rule.parameters) || !Array.isArray(rule.parameters.allowed_merge_methods)) {
|
|
1028
|
-
fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
1029
|
-
}
|
|
1030
|
-
const methods = rule.parameters.allowed_merge_methods;
|
|
1031
|
-
if (
|
|
1032
|
-
methods.some((method) => typeof method !== "string" || !MERGE_METHODS.includes(method as MergeMethod)) ||
|
|
1033
|
-
new Set(methods).size !== methods.length
|
|
1034
|
-
) fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
1035
|
-
const restriction = new Set<MergeMethod>(methods as MergeMethod[]);
|
|
1036
|
-
allowedMergeMethods = allowedMergeMethods === null
|
|
1037
|
-
? restriction
|
|
1038
|
-
: new Set<MergeMethod>([...allowedMergeMethods].filter((method: MergeMethod) => restriction.has(method)));
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
return {
|
|
1043
|
-
requiresStrictStatusChecks,
|
|
1044
|
-
allowedMergeMethods: allowedMergeMethods === null
|
|
1045
|
-
? null
|
|
1046
|
-
: MERGE_METHODS.filter((method) => allowedMergeMethods.has(method)),
|
|
1047
|
-
};
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
function parseMergeMethodSettings(output: string, rulesetMethods: MergeMethod[] | null): PullRequestMerge {
|
|
1051
|
-
const value = parseJson(output, "Read merge methods");
|
|
1052
|
-
if (!isRecord(value)) fail("Read merge methods", "invalid GitHub CLI output");
|
|
1053
|
-
const { mergeCommitAllowed, rebaseMergeAllowed, squashMergeAllowed } = value;
|
|
1054
|
-
if (
|
|
1055
|
-
typeof mergeCommitAllowed !== "boolean" || typeof rebaseMergeAllowed !== "boolean" ||
|
|
1056
|
-
typeof squashMergeAllowed !== "boolean"
|
|
1057
|
-
) fail("Read merge methods", "invalid GitHub CLI output");
|
|
1058
|
-
let allowedMergeMethods: MergeMethod[] = [];
|
|
1059
|
-
if (mergeCommitAllowed) allowedMergeMethods.push("merge");
|
|
1060
|
-
if (rebaseMergeAllowed) allowedMergeMethods.push("rebase");
|
|
1061
|
-
if (squashMergeAllowed) allowedMergeMethods.push("squash");
|
|
1062
|
-
if (!allowedMergeMethods.length) fail("Read merge methods", "repository allows no merge method");
|
|
1063
|
-
const viewerDefaultMergeMethod = value.viewerDefaultMergeMethod === "MERGE"
|
|
1064
|
-
? "merge"
|
|
1065
|
-
: value.viewerDefaultMergeMethod === "REBASE"
|
|
1066
|
-
? "rebase"
|
|
1067
|
-
: value.viewerDefaultMergeMethod === "SQUASH"
|
|
1068
|
-
? "squash"
|
|
1069
|
-
: fail("Read merge methods", "invalid viewerDefaultMergeMethod");
|
|
1070
|
-
if (!allowedMergeMethods.includes(viewerDefaultMergeMethod)) {
|
|
1071
|
-
fail("Read merge methods", "viewerDefaultMergeMethod is not allowed");
|
|
1072
|
-
}
|
|
1073
|
-
if (rulesetMethods !== null) {
|
|
1074
|
-
allowedMergeMethods = allowedMergeMethods.filter((method) => rulesetMethods.includes(method));
|
|
1075
|
-
}
|
|
1076
|
-
if (!allowedMergeMethods.length) {
|
|
1077
|
-
fail("Read merge methods", "repository and applicable rules allow no common merge method");
|
|
1078
|
-
}
|
|
1079
|
-
return { allowedMergeMethods, viewerDefaultMergeMethod };
|
|
971
|
+
export async function hasLocalCommit(
|
|
972
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
973
|
+
context: PullRequestLoadContext,
|
|
974
|
+
): Promise<boolean> {
|
|
975
|
+
const branch = singleLine(
|
|
976
|
+
(await execute(pi, context, "Read current branch", "git", ["branch", "--show-current"])).stdout,
|
|
977
|
+
"Read current branch",
|
|
978
|
+
"branch",
|
|
979
|
+
);
|
|
980
|
+
const output = (await execute(pi, context, "Read branch history", "git", [
|
|
981
|
+
"reflog",
|
|
982
|
+
"show",
|
|
983
|
+
"--format=%H",
|
|
984
|
+
`refs/heads/${branch}`,
|
|
985
|
+
])).stdout.replace(/\r\n/g, "\n");
|
|
986
|
+
const entries = output.split("\n");
|
|
987
|
+
if (entries.at(-1) === "") entries.pop();
|
|
988
|
+
if (!entries.length) fail("Read branch history", "missing branch creation entry");
|
|
989
|
+
const commits = entries.map((entry) => oid(entry, "Read branch history", "commit"));
|
|
990
|
+
// ponytail: reflog expiry can hide old branch history; resolve the PR base if this becomes observable.
|
|
991
|
+
return commits[0] !== commits.at(-1);
|
|
1080
992
|
}
|
|
1081
993
|
|
|
1082
994
|
function validatedCreationTarget(target: PullRequestTarget): PullRequestTarget {
|
|
@@ -1196,19 +1108,18 @@ function parseCreationRepositoryLineage(output: string, expected: PushRepository
|
|
|
1196
1108
|
return normalizeRepository(sourceName);
|
|
1197
1109
|
}
|
|
1198
1110
|
|
|
1199
|
-
async function
|
|
1111
|
+
async function inspectCreationRepositoryRelation(
|
|
1200
1112
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1201
1113
|
context: PullRequestLoadContext,
|
|
1202
1114
|
target: PullRequestTarget,
|
|
1203
1115
|
origin: { repository: PushRepository },
|
|
1204
1116
|
baseRef: string,
|
|
1205
|
-
): Promise<
|
|
1117
|
+
): Promise<"same-ref" | "distinct-ref"> {
|
|
1206
1118
|
if (target.host !== origin.repository.host) {
|
|
1207
1119
|
fail("Read creation repository", "base and head hosts do not match");
|
|
1208
1120
|
}
|
|
1209
1121
|
if (normalizeRepository(target.repository) === origin.repository.normalizedName) {
|
|
1210
|
-
|
|
1211
|
-
return;
|
|
1122
|
+
return target.ref === baseRef ? "same-ref" : "distinct-ref";
|
|
1212
1123
|
}
|
|
1213
1124
|
const read = async (repository: PushRepository): Promise<string> => {
|
|
1214
1125
|
const [owner, name] = repository.nameWithOwner.split("/");
|
|
@@ -1225,6 +1136,7 @@ async function assertCreationRepositoryRelation(
|
|
|
1225
1136
|
host: target.host,
|
|
1226
1137
|
});
|
|
1227
1138
|
if (originSource !== targetSource) fail("Read creation repository", "base and head are unrelated");
|
|
1139
|
+
return "distinct-ref";
|
|
1228
1140
|
}
|
|
1229
1141
|
|
|
1230
1142
|
function parseCreationAhead(output: string): number {
|
|
@@ -1241,7 +1153,7 @@ async function preflightCreation(
|
|
|
1241
1153
|
target: PullRequestTarget,
|
|
1242
1154
|
explicitBaseRef: string | undefined,
|
|
1243
1155
|
identity: CreationIdentity,
|
|
1244
|
-
): Promise<
|
|
1156
|
+
): Promise<CreationPreflightResult> {
|
|
1245
1157
|
const validatedTarget = validatedCreationTarget(target);
|
|
1246
1158
|
if (!sameCreationTarget(identity.target, validatedTarget)) {
|
|
1247
1159
|
fail("Read creation target", "target changed");
|
|
@@ -1252,7 +1164,8 @@ async function preflightCreation(
|
|
|
1252
1164
|
? await readConfiguredCreationBaseRef(pi, context, identity.target.branch)
|
|
1253
1165
|
: await validateCreationRef(pi, context, explicitBaseRef);
|
|
1254
1166
|
const baseRef = configuredBaseRef ?? await readDefaultCreationBaseRef(pi, context, origin);
|
|
1255
|
-
await
|
|
1167
|
+
const relation = await inspectCreationRepositoryRelation(pi, context, identity.target, origin, baseRef);
|
|
1168
|
+
if (relation === "same-ref") return { kind: relation };
|
|
1256
1169
|
const trackingRef = `refs/remotes/origin/${baseRef}`;
|
|
1257
1170
|
await execute(pi, context, "Fetch creation base", "git", [
|
|
1258
1171
|
"fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", "--",
|
|
@@ -1272,16 +1185,19 @@ async function preflightCreation(
|
|
|
1272
1185
|
"rev-list", "--count", `${mergeBase}..${identity.head}`,
|
|
1273
1186
|
])).stdout);
|
|
1274
1187
|
return {
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1188
|
+
kind: relation,
|
|
1189
|
+
preflight: {
|
|
1190
|
+
head: identity.head,
|
|
1191
|
+
base: {
|
|
1192
|
+
host: origin.repository.host,
|
|
1193
|
+
repository: origin.repository.nameWithOwner,
|
|
1194
|
+
fetchSource: origin.fetchSource,
|
|
1195
|
+
ref: baseRef,
|
|
1196
|
+
oid: baseOid,
|
|
1197
|
+
mergeBase,
|
|
1198
|
+
},
|
|
1199
|
+
ahead,
|
|
1283
1200
|
},
|
|
1284
|
-
ahead,
|
|
1285
1201
|
};
|
|
1286
1202
|
}
|
|
1287
1203
|
|
|
@@ -1292,7 +1208,9 @@ export async function preflightPullRequestCreation(
|
|
|
1292
1208
|
explicitBaseRef?: string,
|
|
1293
1209
|
): Promise<PullRequestCreationPreflight> {
|
|
1294
1210
|
const identity = await captureCreationIdentity(pi, context, target);
|
|
1295
|
-
|
|
1211
|
+
const result = await preflightCreation(pi, context, target, explicitBaseRef, identity);
|
|
1212
|
+
if (result.kind === "same-ref") fail("Read creation repository", "head and base refs match");
|
|
1213
|
+
return result.preflight;
|
|
1296
1214
|
}
|
|
1297
1215
|
|
|
1298
1216
|
async function creationDiscovery(
|
|
@@ -1303,8 +1221,12 @@ async function creationDiscovery(
|
|
|
1303
1221
|
identity?: CreationIdentity,
|
|
1304
1222
|
): Promise<CurrentPullRequestDiscovery> {
|
|
1305
1223
|
const captured = identity ?? await captureCreationIdentity(pi, context, target);
|
|
1306
|
-
const
|
|
1307
|
-
return {
|
|
1224
|
+
const result = await preflightCreation(pi, context, target, explicitBaseRef, captured);
|
|
1225
|
+
return {
|
|
1226
|
+
kind: "none",
|
|
1227
|
+
creationTarget: target,
|
|
1228
|
+
branch: { ahead: result.kind === "same-ref" ? 0 : result.preflight.ahead },
|
|
1229
|
+
};
|
|
1308
1230
|
}
|
|
1309
1231
|
|
|
1310
1232
|
async function readRemoteAuthority(
|
|
@@ -1690,66 +1612,6 @@ export async function readPullRequestBaseRefOid(
|
|
|
1690
1612
|
return parseBaseRefAuthority(result.stdout, { repository, ref });
|
|
1691
1613
|
}
|
|
1692
1614
|
|
|
1693
|
-
async function readLegacyBaseBranchPolicy(
|
|
1694
|
-
pi: Pick<ExtensionAPI, "exec">,
|
|
1695
|
-
context: PullRequestLoadContext,
|
|
1696
|
-
candidate: ListedPullRequest,
|
|
1697
|
-
): Promise<boolean> {
|
|
1698
|
-
const [owner, name] = candidate.base.repository.split("/");
|
|
1699
|
-
const result = await execute(pi, context, "Read base branch policy", "gh", [
|
|
1700
|
-
"api",
|
|
1701
|
-
"graphql",
|
|
1702
|
-
"--hostname",
|
|
1703
|
-
candidate.url.hostname,
|
|
1704
|
-
"-f",
|
|
1705
|
-
`query=${BASE_BRANCH_POLICY_QUERY}`,
|
|
1706
|
-
"-F",
|
|
1707
|
-
`owner=${owner}`,
|
|
1708
|
-
"-F",
|
|
1709
|
-
`name=${name}`,
|
|
1710
|
-
"-F",
|
|
1711
|
-
`qualifiedName=refs/heads/${candidate.base.ref}`,
|
|
1712
|
-
]);
|
|
1713
|
-
return parseLegacyBaseBranchPolicy(result.stdout, candidate);
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
|
-
async function readRulesetBaseBranchPolicy(
|
|
1717
|
-
pi: Pick<ExtensionAPI, "exec">,
|
|
1718
|
-
context: PullRequestLoadContext,
|
|
1719
|
-
candidate: ListedPullRequest,
|
|
1720
|
-
): Promise<RulesetBranchPolicy> {
|
|
1721
|
-
const [owner, name] = candidate.base.repository.split("/");
|
|
1722
|
-
const result = await execute(pi, context, "Read base branch rulesets", "gh", [
|
|
1723
|
-
"api",
|
|
1724
|
-
"--hostname",
|
|
1725
|
-
candidate.url.hostname,
|
|
1726
|
-
"--paginate",
|
|
1727
|
-
"--slurp",
|
|
1728
|
-
"-H",
|
|
1729
|
-
"Accept: application/vnd.github+json",
|
|
1730
|
-
"-H",
|
|
1731
|
-
"X-GitHub-Api-Version: 2022-11-28",
|
|
1732
|
-
`repos/${owner}/${name}/rules/branches/${encodeURIComponent(candidate.base.ref)}`,
|
|
1733
|
-
]);
|
|
1734
|
-
return parseRulesetBaseBranchPolicy(result.stdout);
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
|
-
async function readMergeMethods(
|
|
1738
|
-
pi: Pick<ExtensionAPI, "exec">,
|
|
1739
|
-
context: PullRequestLoadContext,
|
|
1740
|
-
candidate: ListedPullRequest,
|
|
1741
|
-
rulesetMethods: MergeMethod[] | null,
|
|
1742
|
-
): Promise<PullRequestMerge> {
|
|
1743
|
-
const result = await execute(pi, context, "Read merge methods", "gh", [
|
|
1744
|
-
"repo",
|
|
1745
|
-
"view",
|
|
1746
|
-
`${candidate.url.hostname}/${candidate.base.repository}`,
|
|
1747
|
-
"--json",
|
|
1748
|
-
"mergeCommitAllowed,rebaseMergeAllowed,squashMergeAllowed,viewerDefaultMergeMethod",
|
|
1749
|
-
]);
|
|
1750
|
-
return parseMergeMethodSettings(result.stdout, rulesetMethods);
|
|
1751
|
-
}
|
|
1752
|
-
|
|
1753
1615
|
async function loadPullRequestDetails(
|
|
1754
1616
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1755
1617
|
context: PullRequestLoadContext,
|
|
@@ -1768,17 +1630,7 @@ async function loadPullRequestDetails(
|
|
|
1768
1630
|
const liveBaseOid = candidate.lifecycle === "open"
|
|
1769
1631
|
? await readBaseRefOid(pi, context, candidate)
|
|
1770
1632
|
: null;
|
|
1771
|
-
const
|
|
1772
|
-
? await readRulesetBaseBranchPolicy(pi, context, candidate)
|
|
1773
|
-
: null;
|
|
1774
|
-
const legacyStrict = candidate.lifecycle === "open" && candidate.mergeStateStatus === "BEHIND"
|
|
1775
|
-
? await readLegacyBaseBranchPolicy(pi, context, candidate)
|
|
1776
|
-
: false;
|
|
1777
|
-
const requiresStrictStatusChecks = legacyStrict || (rulesetPolicy?.requiresStrictStatusChecks ?? false);
|
|
1778
|
-
const pullRequestConditions = conditions(candidate, unresolvedThreads, requiresStrictStatusChecks);
|
|
1779
|
-
const merge = candidate.lifecycle === "open"
|
|
1780
|
-
? await readMergeMethods(pi, context, candidate, rulesetPolicy?.allowedMergeMethods ?? null)
|
|
1781
|
-
: null;
|
|
1633
|
+
const pullRequestConditions = conditions(candidate, unresolvedThreads);
|
|
1782
1634
|
const inspected = inspectedLocal ?? await inspectLocalMergeSafety({
|
|
1783
1635
|
exec: (command, args, options) => pi.exec(command, args, {
|
|
1784
1636
|
...options,
|
|
@@ -1803,7 +1655,6 @@ async function loadPullRequestDetails(
|
|
|
1803
1655
|
head: candidate.head,
|
|
1804
1656
|
headFetchSource: pushTarget.fetchSource,
|
|
1805
1657
|
target: publicTarget(pushTarget),
|
|
1806
|
-
merge,
|
|
1807
1658
|
};
|
|
1808
1659
|
}
|
|
1809
1660
|
|