@henryqw/pi-pr 3.1.10 → 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.
@@ -0,0 +1,684 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ branchTrackingRef,
4
+ fetchBranchTrackingRef,
5
+ findExactHeadPullRequests,
6
+ loadCurrentPullRequest,
7
+ loadPullRequestPublication,
8
+ readPullRequestBaseRefOid,
9
+ readTrackingOid,
10
+ readValidatedRemoteAuthority,
11
+ setBranchUpstream,
12
+ verifyBranchUpstream,
13
+ type BranchUpstreamTarget,
14
+ type PullRequestLoadContext,
15
+ type PullRequestPublication,
16
+ } from "./pr-github.ts";
17
+ import type { PullRequestTarget } from "./pr-routing.ts";
18
+ import {
19
+ assertOnlyDeclaredStatusChanged,
20
+ inspectWorktree,
21
+ isAncestor,
22
+ parseNulPaths,
23
+ readHead,
24
+ readRemoteOid,
25
+ requiredOid,
26
+ requiredText,
27
+ resolveRepositoryFetchSource,
28
+ runChecked,
29
+ spawnBounded,
30
+ withWorktreeLock,
31
+ type AttemptState,
32
+ type Exec,
33
+ type ExecOptions,
34
+ } from "./pr-execution.ts";
35
+
36
+ const BASE_RERUN = "Rerun /pr --base=<host>/<owner>/<repository>:<ref>";
37
+ const MAX_REMOTE_BRANCHES = 128;
38
+ const MAX_TITLE_BYTES = 256;
39
+ const MAX_BODY_BYTES = 64 * 1024;
40
+ const CREATE_AUTHORITY_QUERY = "query($baseOwner:String!,$baseName:String!,$headOwner:String!,$headName:String!){base:repository(owner:$baseOwner,name:$baseName){id nameWithOwner}head:repository(owner:$headOwner,name:$headName){id nameWithOwner owner{__typename}}createInput:__type(name:\"CreatePullRequestInput\"){inputFields{name}}}";
41
+ const CREATE_PULL_REQUEST_MUTATION = "mutation($repositoryId:ID!,$baseRefName:String!,$headRepositoryId:ID!,$headRefName:String!,$title:String!,$body:String!){createPullRequest(input:{repositoryId:$repositoryId,baseRefName:$baseRefName,headRepositoryId:$headRepositoryId,headRefName:$headRefName,title:$title,body:$body}){pullRequest{url}}}";
42
+
43
+ type Load = typeof loadCurrentPullRequest;
44
+
45
+ export type CreateBaseAuthority = {
46
+ host: string;
47
+ repository: string;
48
+ ref: string;
49
+ oid: string;
50
+ fetchSource: string;
51
+ };
52
+
53
+ export type CreatePhase = "unprepared" | "prepared" | "conflict-awaiting-user" | "verified" | "pushed" | "published" | "blocked";
54
+
55
+ type CrossRepositoryCreateAuthority = {
56
+ baseRepositoryId: string;
57
+ headRepositoryId: string;
58
+ headOwnerType: "Organization" | "User";
59
+ };
60
+
61
+ export type CreatePullRequestState = {
62
+ phase: CreatePhase;
63
+ attempts: {
64
+ fetchBase: AttemptState;
65
+ merge: AttemptState;
66
+ stage: AttemptState;
67
+ continueMerge: AttemptState;
68
+ push: AttemptState;
69
+ fetchTracking: AttemptState;
70
+ setUpstream: AttemptState;
71
+ pullRequest: AttemptState;
72
+ };
73
+ base?: CreateBaseAuthority;
74
+ createAuthority?: CrossRepositoryCreateAuthority;
75
+ mergeHead?: string;
76
+ publicationHead?: string;
77
+ conflict?: { paths: string[]; statusBaseline: string; originalHead: string };
78
+ url?: string;
79
+ };
80
+
81
+ export type CreatePullRequestResult =
82
+ | { kind: "prepared"; base: CreateBaseAuthority; mergeBase: string }
83
+ | { kind: "verified"; head: string; fastForward: boolean }
84
+ | { kind: "conflict"; paths: string[] }
85
+ | { kind: "pushed"; head: string }
86
+ | { kind: "published"; url: string };
87
+
88
+ export type CreatePullRequestOptions = {
89
+ cwd: string;
90
+ target: PullRequestTarget;
91
+ signal?: AbortSignal;
92
+ agentDir?: string;
93
+ exec?: Exec;
94
+ loadCurrentPullRequest?: Load;
95
+ };
96
+
97
+ function sameTarget(left: PullRequestTarget, right: PullRequestTarget, expectedRemoteOid = left.remoteOid): boolean {
98
+ return left.branch === right.branch && left.remote === right.remote && left.ref === right.ref &&
99
+ left.repository.toLowerCase() === right.repository.toLowerCase() && left.host === right.host &&
100
+ left.fetchSource === right.fetchSource && right.remoteOid === expectedRemoteOid;
101
+ }
102
+
103
+ function line(output: string, label: string): string {
104
+ const normalized = output.replace(/\r\n/g, "\n");
105
+ const values = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
106
+ if (values.length !== 1 || !values[0]) throw new Error(`${label} returned invalid output`);
107
+ return values[0];
108
+ }
109
+
110
+ function configuredValues(output: string): string[] {
111
+ if (output === "") return [];
112
+ const normalized = output.replace(/\r\n/g, "\n");
113
+ const values = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
114
+ if (values.some((value) => !value) || new Set(values).size !== values.length) {
115
+ throw new Error("Git configuration returned invalid values");
116
+ }
117
+ return values;
118
+ }
119
+
120
+ function isRecord(value: unknown): value is Record<string, unknown> {
121
+ return typeof value === "object" && value !== null && !Array.isArray(value);
122
+ }
123
+
124
+ function graphQlResponse(output: string, action: string): Record<string, unknown> {
125
+ let value: unknown;
126
+ try {
127
+ value = JSON.parse(output);
128
+ } catch {
129
+ throw new Error(`${action} returned invalid GraphQL output`);
130
+ }
131
+ if (!isRecord(value)) throw new Error(`${action} returned invalid GraphQL output`);
132
+ const errors = value.errors;
133
+ if (errors !== undefined) {
134
+ if (!Array.isArray(errors) || errors.some((error) => !isRecord(error) || typeof error.message !== "string" || !error.message)) {
135
+ throw new Error(`${action} returned invalid GraphQL errors`);
136
+ }
137
+ if (errors.length) throw new Error(`${action} failed: ${errors.map((error) => error.message).join("; ")}`);
138
+ }
139
+ return value;
140
+ }
141
+
142
+ function parseCreateAuthority(
143
+ output: string,
144
+ baseRepository: string,
145
+ headRepository: string,
146
+ ): CrossRepositoryCreateAuthority {
147
+ const response = graphQlResponse(output, "PR creation preflight");
148
+ const data = response.data;
149
+ const base = isRecord(data) ? data.base : undefined;
150
+ const head = isRecord(data) ? data.head : undefined;
151
+ const owner = isRecord(head) ? head.owner : undefined;
152
+ const createInput = isRecord(data) ? data.createInput : undefined;
153
+ if (!isRecord(base) || !isRecord(head) || !isRecord(owner)) {
154
+ throw new Error("PR creation preflight returned invalid repository authority");
155
+ }
156
+ const baseName = requiredText(base.nameWithOwner, "base repository name");
157
+ const headName = requiredText(head.nameWithOwner, "head repository name");
158
+ const baseRepositoryId = requiredText(base.id, "base repository id");
159
+ const headRepositoryId = requiredText(head.id, "head repository id");
160
+ const headOwnerType = owner.__typename;
161
+ if (baseName.toLowerCase() !== baseRepository.toLowerCase() || headName.toLowerCase() !== headRepository.toLowerCase() ||
162
+ (headOwnerType !== "Organization" && headOwnerType !== "User")) {
163
+ throw new Error("PR creation preflight returned different repository authority");
164
+ }
165
+ if (headOwnerType === "Organization") {
166
+ if (!isRecord(createInput)) throw new Error("PR creation preflight returned invalid API capability");
167
+ const inputFields = createInput.inputFields;
168
+ if (!Array.isArray(inputFields)) throw new Error("PR creation preflight returned invalid API capability");
169
+ const names = new Set(inputFields.map((field) => isRecord(field) ? field.name : undefined));
170
+ for (const name of ["repositoryId", "baseRefName", "headRepositoryId", "headRefName", "title", "body"]) {
171
+ if (!names.has(name)) throw new Error("GitHub API cannot create an exact organization-owned cross-repository pull request");
172
+ }
173
+ }
174
+ return { baseRepositoryId, headRepositoryId, headOwnerType };
175
+ }
176
+
177
+ function parseCreatedUrl(output: string, host: string, baseRepository: string): URL {
178
+ const response = graphQlResponse(output, "Create pull request");
179
+ const data = response.data;
180
+ const mutation = isRecord(data) ? data.createPullRequest : undefined;
181
+ const pullRequest = isRecord(mutation) ? mutation.pullRequest : undefined;
182
+ const value = isRecord(pullRequest) ? pullRequest.url : undefined;
183
+ if (typeof value !== "string") throw new Error("Create pull request returned invalid GraphQL output");
184
+ const url = new URL(value);
185
+ const prefix = `/${baseRepository.toLowerCase()}/pull/`;
186
+ if (url.protocol !== "https:" || url.hostname.toLowerCase() !== host.toLowerCase() ||
187
+ !url.pathname.toLowerCase().startsWith(prefix) || !/^[1-9]\d*$/.test(url.pathname.slice(prefix.length)) ||
188
+ url.search || url.hash || url.username || url.password) {
189
+ throw new Error("Create pull request returned a different repository URL");
190
+ }
191
+ return url;
192
+ }
193
+
194
+ function parseExplicitBase(value: string): Omit<CreateBaseAuthority, "oid" | "fetchSource"> {
195
+ const match = /^([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+):(.+)$/.exec(value);
196
+ if (!match || match[2] === "." || match[2] === ".." || match[3] === "." || match[3] === "..") {
197
+ throw new Error(`Invalid base authority. ${BASE_RERUN}`);
198
+ }
199
+ return { host: match[1]!.toLowerCase(), repository: `${match[2]}/${match[3]}`, ref: requiredText(match[4], "base ref") };
200
+ }
201
+
202
+ function resolvedPaths(paths: readonly string[], expected: readonly string[]): string[] {
203
+ if (!Array.isArray(paths)) throw new TypeError("resolvedPaths must be an array");
204
+ const parsed = parseNulPaths(`${paths.join("\0")}${paths.length ? "\0" : ""}`, "Resolved conflict paths");
205
+ if (expected.some((path) => !parsed.includes(path))) {
206
+ throw new Error("Resolved paths must include every original conflict path");
207
+ }
208
+ return parsed;
209
+ }
210
+
211
+ export class PullRequestCreator {
212
+ readonly state: CreatePullRequestState = {
213
+ phase: "unprepared",
214
+ attempts: {
215
+ fetchBase: "none", merge: "none", stage: "none", continueMerge: "none", push: "none",
216
+ fetchTracking: "none", setUpstream: "none", pullRequest: "none",
217
+ },
218
+ };
219
+
220
+ private readonly cwd: string;
221
+ private readonly target: PullRequestTarget;
222
+ private readonly noTarget: boolean;
223
+ private readonly signal?: AbortSignal;
224
+ private readonly agentDir?: string;
225
+ private readonly exec: Exec;
226
+ private readonly load: Load;
227
+
228
+ constructor(options: CreatePullRequestOptions) {
229
+ if (!options.target || options.target.remoteOid !== null && !requiredOid(options.target.remoteOid, "remote OID")) {
230
+ throw new TypeError("PR creation requires a validated creation target");
231
+ }
232
+ this.cwd = options.cwd;
233
+ this.target = { ...options.target };
234
+ this.noTarget = options.target.provenance === "inferred" && options.target.remoteOid === null;
235
+ this.signal = options.signal;
236
+ this.agentDir = options.agentDir;
237
+ this.exec = options.exec ?? spawnBounded;
238
+ this.load = options.loadCurrentPullRequest ?? loadCurrentPullRequest;
239
+ }
240
+
241
+ private options(extra: Partial<ExecOptions> = {}): ExecOptions {
242
+ return { cwd: this.cwd, signal: this.signal, ...extra };
243
+ }
244
+
245
+ private pi(): Pick<ExtensionAPI, "exec"> {
246
+ return {
247
+ exec: (command, args, options) => this.exec(command, args, {
248
+ cwd: options?.cwd ?? this.cwd,
249
+ signal: options?.signal ?? this.signal,
250
+ timeoutMs: options?.timeout,
251
+ }),
252
+ } as Pick<ExtensionAPI, "exec">;
253
+ }
254
+
255
+ private context(): PullRequestLoadContext {
256
+ return { cwd: this.cwd, signal: this.signal ?? new AbortController().signal };
257
+ }
258
+
259
+ private async freshNone(): Promise<void> {
260
+ const discovery = await this.load(this.pi(), this.context());
261
+ if (discovery.kind !== "none" || !sameTarget(this.target, discovery.creationTarget)) {
262
+ throw new Error("PR creation cancelled: fresh complete discovery is no longer none");
263
+ }
264
+ const branch = line((await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout, "current branch");
265
+ if (branch !== this.target.branch) throw new Error("PR creation cancelled: current branch changed");
266
+ }
267
+
268
+ private async originAuthority() {
269
+ return await readValidatedRemoteAuthority(this.pi(), this.context(), "origin");
270
+ }
271
+
272
+ private async inferBaseRef(): Promise<{ authority: Awaited<ReturnType<PullRequestCreator["originAuthority"]>>; ref: string }> {
273
+ const authority = await this.originAuthority();
274
+ await runChecked(this.exec, "git", [
275
+ "fetch", "--prune", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules",
276
+ authority.fetchSource, "+refs/heads/*:refs/remotes/origin/*",
277
+ ], this.options());
278
+ const refs = await runChecked(this.exec, "git", [
279
+ "for-each-ref", "--format=%(refname)%09%(objectname)%09%(symref)", "refs/remotes/origin",
280
+ ], this.options());
281
+ const rows = configuredValues(refs.stdout);
282
+ if (rows.length > MAX_REMOTE_BRANCHES) throw new Error(`${BASE_RERUN}; more than ${MAX_REMOTE_BRANCHES} base candidates exist`);
283
+ const originOwnsTarget = authority.host === this.target.host &&
284
+ authority.repository.toLowerCase() === this.target.repository.toLowerCase();
285
+ const candidates: Array<{ ref: string; score: number }> = [];
286
+ for (const row of rows) {
287
+ const parts = row.split("\t");
288
+ if (parts.length !== 3) throw new Error(`Base inference is unsafe. ${BASE_RERUN}`);
289
+ const prefix = "refs/remotes/origin/";
290
+ if (!parts[0]!.startsWith(prefix)) throw new Error(`Base inference is unsafe. ${BASE_RERUN}`);
291
+ const ref = parts[0]!.slice(prefix.length);
292
+ if (ref === "HEAD" || originOwnsTarget && ref === this.target.ref) continue;
293
+ if (!ref || parts[2] !== "") throw new Error(`Base inference is unsafe. ${BASE_RERUN}`);
294
+ const oid = requiredOid(parts[1], "remote base OID");
295
+ const distance = line((await runChecked(this.exec, "git", ["rev-list", "--left-right", "--count", `HEAD...${oid}`], this.options())).stdout, "base distance");
296
+ const counts = /^(\d+)\s+(\d+)$/.exec(distance);
297
+ if (!counts) throw new Error(`Base inference is unsafe. ${BASE_RERUN}`);
298
+ const score = Number(counts[1]) + Number(counts[2]);
299
+ if (!Number.isSafeInteger(score)) throw new Error(`Base inference is unsafe. ${BASE_RERUN}`);
300
+ candidates.push({ ref, score });
301
+ }
302
+ if (!candidates.length) throw new Error(`Base cannot be inferred. ${BASE_RERUN}`);
303
+ const minimum = Math.min(...candidates.map(({ score }) => score));
304
+ const nearest = candidates.filter(({ score }) => score === minimum);
305
+ if (nearest.length !== 1) throw new Error(`Base is ambiguous. ${BASE_RERUN}`);
306
+ return { authority, ref: nearest[0]!.ref };
307
+ }
308
+
309
+ private async resolveBase(explicit?: string): Promise<Omit<CreateBaseAuthority, "oid">> {
310
+ if (explicit !== undefined) {
311
+ const parsed = parseExplicitBase(explicit);
312
+ if (parsed.host !== this.target.host.toLowerCase()) {
313
+ throw new Error("PR creation base and head must use the same GitHub host");
314
+ }
315
+ await runChecked(this.exec, "git", ["check-ref-format", "--branch", parsed.ref], this.options());
316
+ return { ...parsed, fetchSource: await resolveRepositoryFetchSource(this.exec, this.options(), parsed) };
317
+ }
318
+ const configured = await runChecked(this.exec, "git", [
319
+ "config", "--get-all", `branch.${this.target.branch}.gh-merge-base`,
320
+ ], this.options(), [0, 1]);
321
+ const values = configured.code === 1 && configured.stdout === "" ? [] : configuredValues(configured.stdout);
322
+ if (values.length > 1) throw new Error(`Configured base is ambiguous. ${BASE_RERUN}`);
323
+ if (values.length === 1) {
324
+ await runChecked(this.exec, "git", ["check-ref-format", "--branch", values[0]!], this.options());
325
+ const authority = await this.originAuthority();
326
+ return { ...authority, ref: values[0]! };
327
+ }
328
+ const inferred = await this.inferBaseRef();
329
+ await runChecked(this.exec, "git", ["check-ref-format", "--branch", inferred.ref], this.options());
330
+ return { ...inferred.authority, ref: inferred.ref };
331
+ }
332
+
333
+ private async liveBase(): Promise<string> {
334
+ if (!this.state.base) throw new Error("PR creation base is unavailable");
335
+ return await readPullRequestBaseRefOid(this.pi(), this.context(), this.state.base);
336
+ }
337
+
338
+ private async preflightCrossRepositoryCreation(): Promise<void> {
339
+ if (!this.state.base || this.state.base.repository.toLowerCase() === this.target.repository.toLowerCase()) return;
340
+ const [baseOwner, baseName] = this.state.base.repository.split("/");
341
+ const [headOwner, headName] = this.target.repository.split("/");
342
+ const result = await runChecked(this.exec, "gh", [
343
+ "api", "graphql", "--hostname", this.state.base.host,
344
+ "-f", `query=${CREATE_AUTHORITY_QUERY}`,
345
+ "-F", `baseOwner=${baseOwner}`,
346
+ "-F", `baseName=${baseName}`,
347
+ "-F", `headOwner=${headOwner}`,
348
+ "-F", `headName=${headName}`,
349
+ ], this.options());
350
+ this.state.createAuthority = parseCreateAuthority(result.stdout, this.state.base.repository, this.target.repository);
351
+ }
352
+
353
+ private async requireCleanHead(): Promise<string> {
354
+ if (await inspectWorktree(this.exec, this.options()) !== "clean") {
355
+ throw new Error("PR creation requires a clean worktree with no Git operation in progress");
356
+ }
357
+ return await readHead(this.exec, this.options());
358
+ }
359
+
360
+ async prepare(explicitBase?: string): Promise<CreatePullRequestResult> {
361
+ if (this.state.phase !== "unprepared" || this.state.attempts.fetchBase !== "none") {
362
+ throw new Error("PR creation prepare action was already consumed");
363
+ }
364
+ return await withWorktreeLock(this.cwd, async () => {
365
+ await this.freshNone();
366
+ const base = await this.resolveBase(explicitBase);
367
+ if (base.host !== this.target.host.toLowerCase()) {
368
+ throw new Error("PR creation base and head must use the same GitHub host");
369
+ }
370
+ const oid = await readPullRequestBaseRefOid(this.pi(), this.context(), base);
371
+ this.state.base = { ...base, oid };
372
+ this.state.attempts.fetchBase = "attempting";
373
+ try {
374
+ await runChecked(this.exec, "git", [
375
+ "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", base.fetchSource, oid,
376
+ ], this.options());
377
+ await runChecked(this.exec, "git", ["cat-file", "-e", `${oid}^{commit}`], this.options());
378
+ this.state.attempts.fetchBase = "applied";
379
+ } catch (error) {
380
+ this.state.attempts.fetchBase = "unknown";
381
+ throw error;
382
+ }
383
+ await this.freshNone();
384
+ if (await this.liveBase() !== oid) throw new Error("PR creation cancelled: base ref moved during prepare");
385
+ const mergeBase = requiredOid(line((await runChecked(this.exec, "git", ["merge-base", "HEAD", oid], this.options())).stdout, "merge base"), "merge base");
386
+ this.state.phase = "prepared";
387
+ return { kind: "prepared", base: { ...this.state.base }, mergeBase };
388
+ }, { agentDir: this.agentDir, signal: this.signal });
389
+ }
390
+
391
+ private async verifyMerge(originalHead: string): Promise<{ head: string; fastForward: boolean }> {
392
+ const base = this.state.base!;
393
+ const head = await readHead(this.exec, this.options());
394
+ const commits = line((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", "HEAD"], this.options())).stdout, "merge parents")
395
+ .split(" ").map((value, index) => requiredOid(value, index ? "merge parent" : "merged HEAD"));
396
+ if (commits[0] !== head) throw new Error("PR creation merge verification returned a different HEAD");
397
+ let fastForward = false;
398
+ if (head === base.oid && commits.length >= 2 && await isAncestor(this.exec, this.options(), originalHead, head)) fastForward = true;
399
+ else if (commits.length !== 3 || commits[1] !== originalHead || commits[2] !== base.oid) {
400
+ throw new Error("PR creation did not produce the exact configured fast-forward or two-parent merge");
401
+ }
402
+ await this.requireCleanHead();
403
+ this.state.phase = "verified";
404
+ this.state.mergeHead = head;
405
+ delete this.state.conflict;
406
+ return { head, fastForward };
407
+ }
408
+
409
+ private async captureConflict(originalHead: string): Promise<string[]> {
410
+ const base = this.state.base!;
411
+ const mergeHead = requiredOid(line((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.options())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
412
+ if (mergeHead !== base.oid) throw new Error("Failed merge did not retain the frozen base");
413
+ const paths = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.options())).stdout, "Unmerged paths");
414
+ if (!paths.length) throw new Error("git merge failed without bounded unmerged paths");
415
+ const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options());
416
+ this.state.phase = "conflict-awaiting-user";
417
+ this.state.conflict = { paths, statusBaseline: status.stdout, originalHead };
418
+ return paths;
419
+ }
420
+
421
+ async merge(): Promise<CreatePullRequestResult> {
422
+ if (this.state.phase !== "prepared" || !this.state.base || this.state.attempts.merge !== "none") {
423
+ throw new Error("PR creation is not prepared for merge");
424
+ }
425
+ return await withWorktreeLock(this.cwd, async () => {
426
+ await this.freshNone();
427
+ if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
428
+ const originalHead = await this.requireCleanHead();
429
+ if (await isAncestor(this.exec, this.options(), this.state.base!.oid, originalHead)) {
430
+ this.state.phase = "verified";
431
+ this.state.mergeHead = originalHead;
432
+ return { kind: "verified", head: originalHead, fastForward: false };
433
+ }
434
+ this.state.attempts.merge = "attempting";
435
+ let result;
436
+ try {
437
+ result = await this.exec("git", ["merge", "--no-edit", this.state.base!.oid], this.options());
438
+ } catch (error) {
439
+ this.state.attempts.merge = "unknown";
440
+ throw error;
441
+ }
442
+ if (result.killed) {
443
+ this.state.attempts.merge = "unknown";
444
+ throw new Error("git merge was killed; its outcome is unknown");
445
+ }
446
+ if (result.code === 0) {
447
+ try {
448
+ const verified = await this.verifyMerge(originalHead);
449
+ this.state.attempts.merge = "applied";
450
+ return { kind: "verified", ...verified };
451
+ } catch (error) {
452
+ this.state.attempts.merge = "unknown";
453
+ throw error;
454
+ }
455
+ }
456
+ try {
457
+ const paths = await this.captureConflict(originalHead);
458
+ this.state.attempts.merge = "applied";
459
+ return { kind: "conflict", paths };
460
+ } catch (error) {
461
+ this.state.attempts.merge = "blocked";
462
+ this.state.phase = "blocked";
463
+ throw new Error(`git merge failed: ${result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`}; ${error instanceof Error ? error.message : String(error)}`);
464
+ }
465
+ }, { agentDir: this.agentDir, signal: this.signal });
466
+ }
467
+
468
+ async continue(pathsInput: readonly string[]): Promise<CreatePullRequestResult> {
469
+ if (this.state.phase !== "conflict-awaiting-user" || !this.state.conflict || !this.state.base) {
470
+ throw new Error("PR creation has no conflict awaiting continuation");
471
+ }
472
+ if (this.state.attempts.stage !== "none" || this.state.attempts.continueMerge !== "none") {
473
+ throw new Error("PR creation conflict continuation was already consumed");
474
+ }
475
+ const paths = resolvedPaths(pathsInput, this.state.conflict.paths);
476
+ return await withWorktreeLock(this.cwd, async () => {
477
+ await this.freshNone();
478
+ if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
479
+ if (await readHead(this.exec, this.options()) !== this.state.conflict!.originalHead) throw new Error("PR creation merge HEAD changed");
480
+ const mergeHead = requiredOid(line((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.options())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
481
+ if (mergeHead !== this.state.base!.oid) throw new Error("PR creation merge context changed");
482
+ const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options());
483
+ assertOnlyDeclaredStatusChanged(this.state.conflict!.statusBaseline, status.stdout, paths);
484
+ this.state.attempts.stage = "attempting";
485
+ try {
486
+ await runChecked(this.exec, "git", ["add", "--", ...paths], this.options());
487
+ this.state.attempts.stage = "applied";
488
+ } catch (error) {
489
+ this.state.attempts.stage = "unknown";
490
+ throw error;
491
+ }
492
+ const unmerged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.options())).stdout, "Unmerged paths");
493
+ if (unmerged.length) throw new Error(`Conflict paths remain unresolved: ${unmerged.join(", ")}`);
494
+ this.state.attempts.continueMerge = "attempting";
495
+ try {
496
+ await runChecked(this.exec, "git", ["-c", "core.editor=true", "merge", "--continue"], this.options());
497
+ const verified = await this.verifyMerge(this.state.conflict!.originalHead);
498
+ this.state.attempts.continueMerge = "applied";
499
+ return { kind: "verified", ...verified };
500
+ } catch (error) {
501
+ this.state.attempts.continueMerge = "unknown";
502
+ throw error;
503
+ }
504
+ }, { agentDir: this.agentDir, signal: this.signal });
505
+ }
506
+
507
+ private async configureNoTargetUpstream(head: string): Promise<void> {
508
+ const target: BranchUpstreamTarget = {
509
+ branch: this.target.branch,
510
+ remote: this.target.remote,
511
+ ref: this.target.ref,
512
+ fetchSource: this.target.fetchSource,
513
+ remoteOid: head,
514
+ };
515
+ this.state.attempts.fetchTracking = "attempting";
516
+ try {
517
+ await fetchBranchTrackingRef(this.pi(), this.context(), target);
518
+ if (await readTrackingOid(this.pi(), this.context(), branchTrackingRef(target)) !== head) {
519
+ throw new Error("Fetched tracking ref did not match published HEAD");
520
+ }
521
+ this.state.attempts.fetchTracking = "applied";
522
+ } catch (error) {
523
+ this.state.attempts.fetchTracking = "unknown";
524
+ throw error;
525
+ }
526
+ this.state.attempts.setUpstream = "attempting";
527
+ try {
528
+ await setBranchUpstream(this.pi(), this.context(), target);
529
+ await verifyBranchUpstream(this.pi(), this.context(), target);
530
+ this.state.attempts.setUpstream = "applied";
531
+ } catch (error) {
532
+ this.state.attempts.setUpstream = "unknown";
533
+ throw error;
534
+ }
535
+ }
536
+
537
+ async push(): Promise<CreatePullRequestResult> {
538
+ if (this.state.phase !== "verified" || !this.state.base || this.state.attempts.push !== "none") {
539
+ throw new Error("PR creation is not ready to push");
540
+ }
541
+ return await withWorktreeLock(this.cwd, async () => {
542
+ await this.freshNone();
543
+ if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
544
+ const head = await this.requireCleanHead();
545
+ if (!(await isAncestor(this.exec, this.options(), this.state.base!.oid, head))) {
546
+ throw new Error("PR creation HEAD does not contain the frozen base");
547
+ }
548
+ const original = this.target.remoteOid;
549
+ if (original !== null && !(await isAncestor(this.exec, this.options(), original, head))) {
550
+ throw new Error("PR creation push would not fast-forward the frozen remote OID");
551
+ }
552
+ await this.preflightCrossRepositoryCreation();
553
+ await this.freshNone();
554
+ if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
555
+ if (await this.requireCleanHead() !== head) throw new Error("PR creation cancelled: local HEAD changed before push");
556
+ this.state.publicationHead = head;
557
+ this.state.attempts.push = "attempting";
558
+ try {
559
+ await runChecked(this.exec, "git", [
560
+ "push", "--porcelain", `--force-with-lease=refs/heads/${this.target.ref}:${original ?? ""}`,
561
+ "--recurse-submodules=no", "--", this.target.fetchSource, `${head}:refs/heads/${this.target.ref}`,
562
+ ], this.options());
563
+ if (await readRemoteOid(this.exec, this.options(), this.target.fetchSource, this.target.ref) !== head) {
564
+ throw new Error("Published remote ref did not match captured HEAD");
565
+ }
566
+ this.state.attempts.push = "applied";
567
+ } catch (error) {
568
+ this.state.attempts.push = "unknown";
569
+ throw error;
570
+ }
571
+ this.state.phase = "pushed";
572
+ if (this.noTarget) await this.configureNoTargetUpstream(head);
573
+ return { kind: "pushed", head };
574
+ }, { agentDir: this.agentDir, signal: this.signal });
575
+ }
576
+
577
+ private async publishedAuthority(): Promise<void> {
578
+ const head = this.state.publicationHead!;
579
+ if (this.noTarget && this.state.attempts.setUpstream !== "applied") {
580
+ const branch = line((await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout, "current branch");
581
+ const authority = await readValidatedRemoteAuthority(this.pi(), this.context(), this.target.remote);
582
+ if (branch !== this.target.branch || authority.host !== this.target.host ||
583
+ authority.repository.toLowerCase() !== this.target.repository.toLowerCase() ||
584
+ authority.fetchSource !== this.target.fetchSource ||
585
+ await readRemoteOid(this.exec, this.options(), this.target.fetchSource, this.target.ref) !== head) {
586
+ throw new Error("Published target authority changed");
587
+ }
588
+ return;
589
+ }
590
+ const discovery = await this.load(this.pi(), this.context());
591
+ if (discovery.kind === "none") {
592
+ if (!sameTarget(this.target, discovery.creationTarget, head)) throw new Error("Published target authority changed");
593
+ return;
594
+ }
595
+ if (discovery.kind !== "current" || !sameTarget(this.target, discovery.pullRequest.target, head) ||
596
+ discovery.pullRequest.head.oid !== head) throw new Error("Published pull request authority changed");
597
+ }
598
+
599
+ private async exactCandidate(): Promise<PullRequestPublication | null> {
600
+ const candidates = await findExactHeadPullRequests(this.pi(), this.context(), {
601
+ host: this.target.host,
602
+ repository: this.target.repository,
603
+ ref: this.target.ref,
604
+ });
605
+ if (candidates.length > 1) throw new Error("Multiple exact-head pull requests exist");
606
+ if (!candidates.length) return null;
607
+ if (candidates[0]!.headOid !== this.state.publicationHead) throw new Error("Exact-head pull request has the wrong OID");
608
+ const publication = await loadPullRequestPublication(this.pi(), this.context(), candidates[0]!.url);
609
+ if (publication.lifecycle !== "open" || publication.head.repository.toLowerCase() !== this.target.repository.toLowerCase() ||
610
+ publication.head.ref !== this.target.ref || publication.head.oid !== this.state.publicationHead) {
611
+ throw new Error("Exact-head pull request metadata is not canonical");
612
+ }
613
+ return publication;
614
+ }
615
+
616
+ async publish(titleInput: string, body: string): Promise<CreatePullRequestResult> {
617
+ if (this.state.phase !== "pushed" || !this.state.base || !this.state.publicationHead || this.state.attempts.pullRequest !== "none") {
618
+ throw new Error("PR creation is not ready to publish metadata");
619
+ }
620
+ const title = requiredText(titleInput, "pull request title");
621
+ if (Buffer.byteLength(title, "utf8") > MAX_TITLE_BYTES) throw new Error(`Pull request title exceeds ${MAX_TITLE_BYTES} bytes`);
622
+ if (typeof body !== "string" || body.includes("\0") || Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) {
623
+ throw new Error(`Pull request body must be at most ${MAX_BODY_BYTES} bytes without NUL`);
624
+ }
625
+ return await withWorktreeLock(this.cwd, async () => {
626
+ await this.publishedAuthority();
627
+ if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
628
+ const before = await this.exactCandidate();
629
+ if (before && (before.base.repository.toLowerCase() !== this.state.base!.repository.toLowerCase() || before.base.ref !== this.state.base!.ref)) {
630
+ throw new Error("Exact-head pull request targets a different base");
631
+ }
632
+ const repository = `${this.state.base!.host}/${this.state.base!.repository}`;
633
+ const sameRepository = this.state.base!.repository.toLowerCase() === this.target.repository.toLowerCase();
634
+ const organizationAuthority = !sameRepository && this.state.createAuthority?.headOwnerType === "Organization"
635
+ ? this.state.createAuthority
636
+ : undefined;
637
+ if (!sameRepository && !this.state.createAuthority) {
638
+ throw new Error("Cross-repository PR creation was not preflighted before push");
639
+ }
640
+ const args = before
641
+ ? ["pr", "edit", String(before.number), "--repo", repository, "--title", title, "--body-file", "-"]
642
+ : organizationAuthority
643
+ ? [
644
+ "api", "graphql", "--hostname", this.state.base!.host,
645
+ "-f", `query=${CREATE_PULL_REQUEST_MUTATION}`,
646
+ "-f", `repositoryId=${organizationAuthority.baseRepositoryId}`,
647
+ "-f", `baseRefName=${this.state.base!.ref}`,
648
+ "-f", `headRepositoryId=${organizationAuthority.headRepositoryId}`,
649
+ "-f", `headRefName=${this.target.ref}`,
650
+ "-f", `title=${title}`,
651
+ "-f", `body=${body}`,
652
+ ]
653
+ : [
654
+ "pr", "create", "--repo", repository, "--head",
655
+ sameRepository ? this.target.ref : `${this.target.repository.split("/")[0]}:${this.target.ref}`,
656
+ "--base", this.state.base!.ref, "--title", title, "--body-file", "-",
657
+ ];
658
+ this.state.attempts.pullRequest = "attempting";
659
+ try {
660
+ const created = await runChecked(this.exec, "gh", args, this.options({ stdin: organizationAuthority ? undefined : body }));
661
+ if (!before && organizationAuthority) {
662
+ parseCreatedUrl(created.stdout, this.state.base!.host, this.state.base!.repository);
663
+ }
664
+ } catch (error) {
665
+ this.state.attempts.pullRequest = "unknown";
666
+ throw error;
667
+ }
668
+ try {
669
+ const after = await this.exactCandidate();
670
+ if (!after || after.base.repository.toLowerCase() !== this.state.base!.repository.toLowerCase() ||
671
+ after.base.ref !== this.state.base!.ref || after.title !== title || after.body !== body) {
672
+ throw new Error("Published pull request did not retain canonical identity, title, and body");
673
+ }
674
+ this.state.attempts.pullRequest = "applied";
675
+ this.state.phase = "published";
676
+ this.state.url = after.url.href;
677
+ return { kind: "published", url: after.url.href };
678
+ } catch (error) {
679
+ this.state.attempts.pullRequest = "unknown";
680
+ throw error;
681
+ }
682
+ }, { agentDir: this.agentDir, signal: this.signal });
683
+ }
684
+ }