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