@henryqw/pi-pr 3.1.9 → 4.0.2

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.
@@ -1,24 +1,11 @@
1
- import { lstat } from "node:fs/promises";
2
- import { resolve } from "node:path";
1
+ import { inspectWorktree, type Exec, type ExecResult } from "./pr-execution.ts";
3
2
  import type { LocalMergeSafety } from "./pr-routing.ts";
4
3
 
5
- export type ExecResult = {
6
- stdout: string;
7
- stderr: string;
8
- code: number;
9
- killed?: boolean;
10
- };
11
-
12
- export type Exec = (
13
- command: string,
14
- args: string[],
15
- options: { cwd: string },
16
- ) => Promise<ExecResult>;
4
+ export type { Exec, ExecResult } from "./pr-execution.ts";
17
5
 
18
6
  export type MergeMethod = "merge" | "rebase" | "squash";
19
7
 
20
8
  const MERGE_PULL_REQUEST_MUTATION = "mutation($pullRequestId:ID!,$expectedHeadOid:GitObjectID!,$mergeMethod:PullRequestMergeMethod!){mergePullRequest(input:{pullRequestId:$pullRequestId,expectedHeadOid:$expectedHeadOid,mergeMethod:$mergeMethod}){pullRequest{id state}}}";
21
- const GIT_OPERATION_STATES = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
22
9
  const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
23
10
 
24
11
  export type InspectLocalMergeSafetyInput = {
@@ -111,33 +98,6 @@ function validateInspectionInput(input: InspectLocalMergeSafetyInput): void {
111
98
  if (typeof input.exec !== "function") throw new TypeError("exec must be a function");
112
99
  }
113
100
 
114
- async function inspectWorktree(exec: Exec, cwd: string): Promise<"clean" | "dirty"> {
115
- const status = await runCommand(exec, cwd, "git", ["status", "--porcelain=v1", "--untracked-files=all"]);
116
- const stateOutput = requiredOutput(
117
- await runCommand(exec, cwd, "git", [
118
- "rev-parse",
119
- ...GIT_OPERATION_STATES.flatMap((state) => ["--git-path", state]),
120
- ]),
121
- "Git operation state paths",
122
- );
123
- const statePaths = stateOutput.replace(/\r\n/g, "\n").split("\n");
124
- if (statePaths.length !== GIT_OPERATION_STATES.length || statePaths.some((path) => !path)) {
125
- throw new Error("Git operation state path resolution returned invalid output");
126
- }
127
- let operationInProgress = false;
128
- for (const [index, path] of statePaths.entries()) {
129
- try {
130
- await lstat(resolve(cwd, path));
131
- operationInProgress = true;
132
- } catch (error) {
133
- if (isRecord(error) && error.code === "ENOENT") continue;
134
- const detail = isRecord(error) && typeof error.code === "string" ? error.code : errorText(error);
135
- throw new Error(`Git operation state inspection failed for ${GIT_OPERATION_STATES[index]}: ${detail}`);
136
- }
137
- }
138
- return status.stdout.length === 0 && !operationInProgress ? "clean" : "dirty";
139
- }
140
-
141
101
  async function readLocalHead(exec: Exec, cwd: string): Promise<string> {
142
102
  return requiredOid(
143
103
  requiredOutput(await runCommand(exec, cwd, "git", ["rev-parse", "--verify", "HEAD^{commit}"]), "local HEAD"),
@@ -149,7 +109,7 @@ async function readLocalHead(exec: Exec, cwd: string): Promise<string> {
149
109
  export async function inspectLocalMergeSafety(input: InspectLocalMergeSafetyInput): Promise<InspectedLocalMergeSafety> {
150
110
  validateInspectionInput(input);
151
111
 
152
- const worktree = await inspectWorktree(input.exec, input.cwd);
112
+ const worktree = await inspectWorktree(input.exec, { cwd: input.cwd });
153
113
  await runCommand(input.exec, input.cwd, "git", [
154
114
  "fetch",
155
115
  "--no-write-fetch-head",
@@ -262,7 +222,7 @@ export async function executeGitHubMerge(input: ExecuteGitHubMergeInput): Promis
262
222
  throw new Error(`Local merge safety check failed: worktree is ${local.worktree}, HEAD is ${local.head}`);
263
223
  }
264
224
  await input.revalidateReadiness(local);
265
- const finalWorktree = await inspectWorktree(input.exec, input.cwd);
225
+ const finalWorktree = await inspectWorktree(input.exec, { cwd: input.cwd });
266
226
  if (finalWorktree !== "clean") {
267
227
  throw new Error("Final local merge safety check failed: worktree is dirty");
268
228
  }
@@ -1,5 +1,5 @@
1
1
  export type PullRequestLifecycle = "open" | "merged" | "closed";
2
- export type CiStatus = "none" | "running" | "success" | "failure";
2
+ export type CiStatus = "none" | "running" | "success" | "failure" | "failure-blocked";
3
3
  export type ReviewReadiness = "ready" | "pending";
4
4
  export type PolicyReadiness = "ready" | "pending";
5
5
  export type LocalWorktree = "clean" | "dirty";
@@ -71,6 +71,7 @@ export function derivePullRequestNextStep(pullRequest: PullRequest): Exclude<Nex
71
71
  return localMutationSafe(local) ? "update-branch" : "none";
72
72
  }
73
73
  if (conditions.ci === "failure") return localMutationSafe(local) ? "fix-ci" : "none";
74
+ if (conditions.ci === "failure-blocked") return "none";
74
75
  if (conditions.changesRequested || conditions.unresolvedThreads > 0) {
75
76
  return localMutationSafe(local) ? "sweep" : "none";
76
77
  }
@@ -82,7 +82,7 @@ function footerStatus(input: PrDisplayInput): Pick<PrFooter, "text" | "color"> {
82
82
  if (conditions.draft) return { text: "draft", color: "warning" };
83
83
  if (conditions.conflict) return { text: "merge conflict", color: "error" };
84
84
  if (conditions.baseUpdateRequired) return { text: "base update required", color: "warning" };
85
- if (conditions.ci === "failure") return { text: "CI failed", color: "error" };
85
+ if (conditions.ci === "failure" || conditions.ci === "failure-blocked") return { text: "CI failed", color: "error" };
86
86
  if (conditions.unresolvedThreads > 0) return { text: `${conditions.unresolvedThreads} unresolved`, color: "warning" };
87
87
  if (conditions.changesRequested) return { text: "changes requested", color: "error" };
88
88
  if (conditions.ci === "running") return { text: "CI running", color: "warning" };
@@ -0,0 +1,327 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ loadCurrentPullRequest,
4
+ samePullRequestSnapshot,
5
+ type CurrentPullRequest,
6
+ type PullRequestLoadContext,
7
+ } from "./pr-github.ts";
8
+ import {
9
+ assertOnlyDeclaredStatusChanged,
10
+ inspectWorktree,
11
+ isAncestor,
12
+ parseNulPaths,
13
+ readHead,
14
+ readRemoteOid,
15
+ requiredOid,
16
+ resolveRepositoryFetchSource,
17
+ runChecked,
18
+ spawnBounded,
19
+ withWorktreeLock,
20
+ type AttemptState,
21
+ type Exec,
22
+ type ExecOptions,
23
+ } from "./pr-execution.ts";
24
+
25
+ export type UpdateBranchPhase = "ready" | "conflict-awaiting-user" | "verified" | "published" | "blocked";
26
+
27
+ export type UpdateBranchState = {
28
+ phase: UpdateBranchPhase;
29
+ attempts: {
30
+ fetchBase: AttemptState;
31
+ merge: AttemptState;
32
+ stage: AttemptState;
33
+ continueMerge: AttemptState;
34
+ push: AttemptState;
35
+ };
36
+ verifiedHead?: string;
37
+ conflict?: { paths: string[]; statusBaseline: string };
38
+ };
39
+
40
+ export type UpdateBranchResult =
41
+ | { kind: "verified"; head: string; fastForward: boolean }
42
+ | { kind: "conflict"; paths: string[] }
43
+ | { kind: "published"; head: string };
44
+
45
+ type Load = typeof loadCurrentPullRequest;
46
+
47
+ export type UpdateBranchOptions = {
48
+ cwd: string;
49
+ authority: CurrentPullRequest;
50
+ signal?: AbortSignal;
51
+ agentDir?: string;
52
+ exec?: Exec;
53
+ loadCurrentPullRequest?: Load;
54
+ };
55
+
56
+ function cloneAuthority(value: CurrentPullRequest): CurrentPullRequest {
57
+ return {
58
+ ...value,
59
+ url: new URL(value.url.href),
60
+ conditions: { ...value.conditions },
61
+ local: { ...value.local },
62
+ base: { ...value.base },
63
+ head: { ...value.head },
64
+ target: { ...value.target },
65
+ merge: value.merge ? { ...value.merge, allowedMergeMethods: [...value.merge.allowedMergeMethods] } : null,
66
+ };
67
+ }
68
+
69
+ function sameAuthority(frozen: CurrentPullRequest, fresh: CurrentPullRequest): boolean {
70
+ return samePullRequestSnapshot(frozen, fresh) && frozen.base.oid === fresh.base.oid &&
71
+ fresh.lifecycle === "open" && (fresh.conditions.baseUpdateRequired || fresh.conditions.conflict);
72
+ }
73
+
74
+ function singleLine(output: string, label: string): string {
75
+ const normalized = output.replace(/\r\n/g, "\n");
76
+ const lines = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
77
+ if (lines.length !== 1 || !lines[0]) throw new Error(`${label} returned invalid output`);
78
+ return lines[0];
79
+ }
80
+
81
+ function validateDeclaredPaths(paths: readonly string[], expected: readonly string[]): string[] {
82
+ if (!Array.isArray(paths)) throw new TypeError("resolvedPaths must be an array");
83
+ const encoded = `${paths.join("\0")}${paths.length ? "\0" : ""}`;
84
+ const parsed = parseNulPaths(encoded, "Resolved conflict paths");
85
+ if (expected.some((path) => !parsed.includes(path))) {
86
+ throw new Error("Resolved paths must include every original conflict path");
87
+ }
88
+ return parsed;
89
+ }
90
+
91
+ export class PullRequestBranchUpdater {
92
+ readonly state: UpdateBranchState = {
93
+ phase: "ready",
94
+ attempts: { fetchBase: "none", merge: "none", stage: "none", continueMerge: "none", push: "none" },
95
+ };
96
+
97
+ private readonly cwd: string;
98
+ private readonly authority: CurrentPullRequest;
99
+ private readonly signal?: AbortSignal;
100
+ private readonly agentDir?: string;
101
+ private readonly exec: Exec;
102
+ private readonly load: Load;
103
+
104
+ constructor(options: UpdateBranchOptions) {
105
+ if (!options.authority || options.authority.lifecycle !== "open" || options.authority.target.provenance !== "configured") {
106
+ throw new TypeError("Branch update requires a configured open pull request");
107
+ }
108
+ if (!options.authority.conditions.baseUpdateRequired && !options.authority.conditions.conflict) {
109
+ throw new TypeError("Pull request does not require a branch update");
110
+ }
111
+ this.cwd = options.cwd;
112
+ this.authority = cloneAuthority(options.authority);
113
+ this.signal = options.signal;
114
+ this.agentDir = options.agentDir;
115
+ this.exec = options.exec ?? spawnBounded;
116
+ this.load = options.loadCurrentPullRequest ?? loadCurrentPullRequest;
117
+ }
118
+
119
+ private execOptions(extra: Partial<ExecOptions> = {}): ExecOptions {
120
+ return { cwd: this.cwd, signal: this.signal, ...extra };
121
+ }
122
+
123
+ private pi(): Pick<ExtensionAPI, "exec"> {
124
+ return {
125
+ exec: (command, args, options) => this.exec(command, args, {
126
+ cwd: options?.cwd ?? this.cwd,
127
+ signal: options?.signal ?? this.signal,
128
+ timeoutMs: options?.timeout,
129
+ }),
130
+ } as Pick<ExtensionAPI, "exec">;
131
+ }
132
+
133
+ private context(): PullRequestLoadContext {
134
+ return { cwd: this.cwd, signal: this.signal ?? new AbortController().signal };
135
+ }
136
+
137
+ private async freshAuthority(expectedHead: string, requireClean: boolean): Promise<CurrentPullRequest> {
138
+ const discovery = await this.load(this.pi(), this.context());
139
+ if (discovery.kind !== "current" || !sameAuthority(this.authority, discovery.pullRequest)) {
140
+ throw new Error("Branch update cancelled: frozen pull request authority changed");
141
+ }
142
+ const branch = singleLine((await runChecked(this.exec, "git", ["branch", "--show-current"], this.execOptions())).stdout, "current branch");
143
+ if (branch !== this.authority.target.branch) throw new Error("Branch update cancelled: current branch changed");
144
+ if (requireClean && await inspectWorktree(this.exec, this.execOptions()) !== "clean") {
145
+ throw new Error("Branch update cancelled: worktree is dirty or a Git operation is in progress");
146
+ }
147
+ const head = await readHead(this.exec, this.execOptions());
148
+ if (head !== expectedHead) throw new Error("Branch update cancelled: local HEAD changed");
149
+ return discovery.pullRequest;
150
+ }
151
+
152
+ private async liveBaseMatches(): Promise<void> {
153
+ const discovery = await this.load(this.pi(), this.context());
154
+ if (discovery.kind !== "current" || !sameAuthority(this.authority, discovery.pullRequest)) {
155
+ throw new Error("Branch update cancelled: live base or pull request authority changed");
156
+ }
157
+ }
158
+
159
+ private async verifyMerge(originalHead: string): Promise<{ head: string; fastForward: boolean }> {
160
+ const head = await readHead(this.exec, this.execOptions());
161
+ const parentsOutput = singleLine((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", "HEAD"], this.execOptions())).stdout, "merge parents");
162
+ const commits = parentsOutput.split(" ").map((value, index) => requiredOid(value, index === 0 ? "merged HEAD" : "merge parent"));
163
+ if (commits[0] !== head) throw new Error("Branch update merge verification returned a different HEAD");
164
+ let fastForward = false;
165
+ if (head === this.authority.base.oid && commits.length >= 2 && await isAncestor(this.exec, this.execOptions(), originalHead, head)) {
166
+ fastForward = true;
167
+ } else if (commits.length !== 3 || commits[1] !== originalHead || commits[2] !== this.authority.base.oid) {
168
+ throw new Error("Branch update did not produce the exact configured fast-forward or two-parent merge");
169
+ }
170
+ if (await inspectWorktree(this.exec, this.execOptions()) !== "clean") {
171
+ throw new Error("Branch update merge left a dirty worktree or Git operation in progress");
172
+ }
173
+ this.state.phase = "verified";
174
+ this.state.verifiedHead = head;
175
+ delete this.state.conflict;
176
+ return { head, fastForward };
177
+ }
178
+
179
+ private async captureConflict(): Promise<string[]> {
180
+ const mergeHead = requiredOid(singleLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.execOptions())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
181
+ if (mergeHead !== this.authority.base.oid) throw new Error("Failed merge did not retain the frozen base");
182
+ const paths = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.execOptions())).stdout, "Unmerged paths");
183
+ if (!paths.length) throw new Error("git merge failed without bounded unmerged paths");
184
+ const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions());
185
+ this.state.phase = "conflict-awaiting-user";
186
+ this.state.conflict = { paths, statusBaseline: status.stdout };
187
+ return paths;
188
+ }
189
+
190
+ async merge(): Promise<UpdateBranchResult> {
191
+ if (this.state.phase !== "ready" || this.state.attempts.fetchBase !== "none" || this.state.attempts.merge !== "none") {
192
+ throw new Error("Branch update merge action was already consumed");
193
+ }
194
+ return await withWorktreeLock(this.cwd, async () => {
195
+ await this.freshAuthority(this.authority.head.oid, true);
196
+ const source = await resolveRepositoryFetchSource(this.exec, this.execOptions(), {
197
+ host: this.authority.host,
198
+ repository: this.authority.base.repository,
199
+ });
200
+ this.state.attempts.fetchBase = "attempting";
201
+ try {
202
+ await runChecked(this.exec, "git", [
203
+ "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", source, this.authority.base.oid,
204
+ ], this.execOptions());
205
+ await runChecked(this.exec, "git", ["cat-file", "-e", `${this.authority.base.oid}^{commit}`], this.execOptions());
206
+ this.state.attempts.fetchBase = "applied";
207
+ } catch (error) {
208
+ this.state.attempts.fetchBase = "unknown";
209
+ throw error;
210
+ }
211
+ await this.freshAuthority(this.authority.head.oid, true);
212
+ if (await isAncestor(this.exec, this.execOptions(), this.authority.base.oid, this.authority.head.oid)) {
213
+ this.state.phase = "verified";
214
+ this.state.verifiedHead = this.authority.head.oid;
215
+ return { kind: "verified", head: this.authority.head.oid, fastForward: false };
216
+ }
217
+
218
+ this.state.attempts.merge = "attempting";
219
+ let result;
220
+ try {
221
+ result = await this.exec("git", ["merge", "--no-edit", this.authority.base.oid], this.execOptions());
222
+ } catch (error) {
223
+ this.state.attempts.merge = "unknown";
224
+ throw error;
225
+ }
226
+ if (result.killed) {
227
+ this.state.attempts.merge = "unknown";
228
+ throw new Error("git merge was killed; its outcome is unknown");
229
+ }
230
+ if (result.code === 0) {
231
+ try {
232
+ const verified = await this.verifyMerge(this.authority.head.oid);
233
+ this.state.attempts.merge = "applied";
234
+ return { kind: "verified", ...verified };
235
+ } catch (error) {
236
+ this.state.attempts.merge = "unknown";
237
+ throw error;
238
+ }
239
+ }
240
+ try {
241
+ const paths = await this.captureConflict();
242
+ this.state.attempts.merge = "applied";
243
+ return { kind: "conflict", paths: [...paths] };
244
+ } catch (error) {
245
+ this.state.attempts.merge = "blocked";
246
+ this.state.phase = "blocked";
247
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
248
+ throw new Error(`git merge failed: ${detail}; ${error instanceof Error ? error.message : String(error)}`);
249
+ }
250
+ }, { agentDir: this.agentDir, signal: this.signal });
251
+ }
252
+
253
+ async continue(resolvedPaths: readonly string[]): Promise<UpdateBranchResult> {
254
+ if (this.state.phase !== "conflict-awaiting-user" || !this.state.conflict) {
255
+ throw new Error("Branch update has no conflict awaiting continuation");
256
+ }
257
+ const paths = validateDeclaredPaths(resolvedPaths, this.state.conflict.paths);
258
+ return await withWorktreeLock(this.cwd, async () => {
259
+ await this.freshAuthority(this.authority.head.oid, false);
260
+ const mergeHead = requiredOid(singleLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.execOptions())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
261
+ if (mergeHead !== this.authority.base.oid) throw new Error("Branch update merge context changed");
262
+ const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions());
263
+ assertOnlyDeclaredStatusChanged(this.state.conflict!.statusBaseline, status.stdout, paths);
264
+
265
+ if (this.state.attempts.stage !== "none") throw new Error("Conflict staging action was already consumed");
266
+ this.state.attempts.stage = "attempting";
267
+ try {
268
+ await runChecked(this.exec, "git", ["add", "--", ...paths], this.execOptions());
269
+ this.state.attempts.stage = "applied";
270
+ } catch (error) {
271
+ this.state.attempts.stage = "unknown";
272
+ throw error;
273
+ }
274
+ const unmerged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.execOptions())).stdout, "Unmerged paths");
275
+ if (unmerged.length) {
276
+ this.state.phase = "blocked";
277
+ throw new Error(`Conflict paths remain unresolved: ${unmerged.join(", ")}`);
278
+ }
279
+
280
+ this.state.attempts.continueMerge = "attempting";
281
+ try {
282
+ await runChecked(this.exec, "git", ["-c", "core.editor=true", "merge", "--continue"], this.execOptions());
283
+ const verified = await this.verifyMerge(this.authority.head.oid);
284
+ this.state.attempts.continueMerge = "applied";
285
+ return { kind: "verified", ...verified };
286
+ } catch (error) {
287
+ this.state.attempts.continueMerge = "unknown";
288
+ throw error;
289
+ }
290
+ }, { agentDir: this.agentDir, signal: this.signal });
291
+ }
292
+
293
+ async publish(): Promise<UpdateBranchResult> {
294
+ if (this.state.phase !== "verified" || !this.state.verifiedHead || this.state.attempts.push !== "none") {
295
+ throw new Error("Branch update is not ready to publish");
296
+ }
297
+ const head = this.state.verifiedHead;
298
+ return await withWorktreeLock(this.cwd, async () => {
299
+ await this.freshAuthority(head, true);
300
+ if (!(await isAncestor(this.exec, this.execOptions(), this.authority.base.oid, head))) {
301
+ throw new Error("Verified branch no longer contains the frozen base");
302
+ }
303
+ const original = this.authority.target.remoteOid;
304
+ if (original === null) throw new Error("Current pull request remote ref is absent");
305
+ if (!(await isAncestor(this.exec, this.execOptions(), original, head))) {
306
+ throw new Error("Published branch would not be a fast-forward of the frozen remote OID");
307
+ }
308
+ await this.freshAuthority(head, true);
309
+ this.state.attempts.push = "attempting";
310
+ try {
311
+ await runChecked(this.exec, "git", [
312
+ "push", "--porcelain", `--force-with-lease=refs/heads/${this.authority.target.ref}:${original}`,
313
+ "--recurse-submodules=no", "--", this.authority.target.fetchSource,
314
+ `${head}:refs/heads/${this.authority.target.ref}`,
315
+ ], this.execOptions());
316
+ const remote = await readRemoteOid(this.exec, this.execOptions(), this.authority.target.fetchSource, this.authority.target.ref);
317
+ if (remote !== head) throw new Error("Published remote ref did not match verified HEAD");
318
+ this.state.attempts.push = "applied";
319
+ this.state.phase = "published";
320
+ return { kind: "published", head };
321
+ } catch (error) {
322
+ this.state.attempts.push = "unknown";
323
+ throw error;
324
+ }
325
+ }, { agentDir: this.agentDir, signal: this.signal });
326
+ }
327
+ }