@henryqw/pi-pr 4.0.3 → 4.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/extensions/pr-ci.ts +0 -1
- package/extensions/pr-command.ts +2 -14
- package/extensions/pr-comment-sweep.ts +0 -27
- package/extensions/pr-create.ts +75 -219
- package/extensions/pr-execution.ts +28 -163
- package/extensions/pr-github.ts +58 -207
- package/extensions/pr-merge.ts +24 -111
- package/extensions/pr-update-branch.ts +28 -90
- package/extensions/pr.ts +22 -38
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -72,9 +72,9 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
|
|
|
72
72
|
| External check or commit status failed | Show `CI failed` as a no-action blocker. |
|
|
73
73
|
| Changes requested or unresolved review threads | Run the package comment sweep when the same local prerequisite holds. |
|
|
74
74
|
| No-action state | Report the state without taking action. |
|
|
75
|
-
| Merge-ready pull request | Ask for final confirmation, recheck fresh state, and merge
|
|
75
|
+
| Merge-ready pull request | Ask for final confirmation, recheck fresh state, and squash-merge if confirmed. |
|
|
76
76
|
|
|
77
|
-
`pi-pr-create` selects its base in this order: the leading `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then the default branch of validated `origin`. It captures the selected base OID and merge-base. Creation requires at least one committed change ahead. Dirty work alone does not enable creation.
|
|
77
|
+
`pi-pr-create` selects its base in this order: the leading `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then the default branch of validated `origin`. It captures the selected base OID and merge-base. Creation requires at least one committed change ahead. Dirty work alone does not enable creation. If the current branch is the selected base, pi-pr stays silent because GitHub cannot create a pull request from a ref to itself.
|
|
78
78
|
|
|
79
79
|
The base always comes from validated `origin`. The head may use that repository or a fork with the same GitHub source. Base and head must use the same GitHub host. Other fork relationships stop before mutation.
|
|
80
80
|
|
|
@@ -154,8 +154,8 @@ The GitHub response must match the observed URL, host, repository, head ref, hea
|
|
|
154
154
|
- It does not rebase the local branch, overwrite concurrent remote updates, delete branches, or clean up worktrees. Creation uses exact leases plus ancestry checks; an empty lease is only an atomic absence check.
|
|
155
155
|
- Creation, discovery, and comment-sweep pushes require one unambiguous push URL for the configured destination.
|
|
156
156
|
- Presentation fetches use that exact push URL and exact advertised OID. They do not use shared fetch state.
|
|
157
|
-
-
|
|
158
|
-
-
|
|
157
|
+
- A pull request that GitHub reports as behind requires a base update.
|
|
158
|
+
- Direct merges always use squash. GitHub rejects the mutation if repository policy does not allow it.
|
|
159
159
|
- Before merge, `/pr` fetches the exact head OID from the validated push URL without shared fetch state.
|
|
160
160
|
- A merge, rebase, cherry-pick, revert, or sequencer state blocks direct merge, even when `git status` is empty.
|
|
161
161
|
- A branch update resolves the base repository ref directly. It stops if that ref moves before merge or push.
|
package/extensions/pr-ci.ts
CHANGED
|
@@ -149,7 +149,6 @@ function cloneAuthority(value: CurrentPullRequest): CurrentPullRequest {
|
|
|
149
149
|
base: { ...value.base },
|
|
150
150
|
head: { ...value.head },
|
|
151
151
|
target: { ...value.target },
|
|
152
|
-
merge: value.merge ? { ...value.merge, allowedMergeMethods: [...value.merge.allowedMergeMethods] } : null,
|
|
153
152
|
};
|
|
154
153
|
}
|
|
155
154
|
|
package/extensions/pr-command.ts
CHANGED
|
@@ -2,10 +2,7 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionCommandContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import {
|
|
6
|
-
executeGitHubMerge,
|
|
7
|
-
selectMergeMethod,
|
|
8
|
-
} from "./pr-merge.ts";
|
|
5
|
+
import { executeGitHubMerge } from "./pr-merge.ts";
|
|
9
6
|
import {
|
|
10
7
|
linkInferredPullRequest,
|
|
11
8
|
loadCurrentPullRequest,
|
|
@@ -184,11 +181,9 @@ async function mergePullRequest(
|
|
|
184
181
|
current: CurrentPullRequest,
|
|
185
182
|
load: typeof loadCurrentPullRequest,
|
|
186
183
|
): Promise<boolean> {
|
|
187
|
-
if (!current.merge) throw new Error(`PR #${current.number} merge failed: merge capabilities are unavailable`);
|
|
188
|
-
const method = selectMergeMethod(current.merge);
|
|
189
184
|
const confirmed = await ctx.ui.confirm(
|
|
190
185
|
`Merge PR #${current.number}?`,
|
|
191
|
-
|
|
186
|
+
"Method: squash.",
|
|
192
187
|
);
|
|
193
188
|
if (!confirmed) return false;
|
|
194
189
|
|
|
@@ -204,8 +199,6 @@ async function mergePullRequest(
|
|
|
204
199
|
expectedHead: current.head.oid,
|
|
205
200
|
expectedBase: current.base,
|
|
206
201
|
headFetchSource: current.headFetchSource,
|
|
207
|
-
allowedMergeMethods: current.merge.allowedMergeMethods,
|
|
208
|
-
viewerDefaultMergeMethod: current.merge.viewerDefaultMergeMethod,
|
|
209
202
|
revalidateReadiness: async (local) => {
|
|
210
203
|
const discovery = await load(pi, ctx, local);
|
|
211
204
|
if (discovery.kind !== "current") {
|
|
@@ -218,11 +211,6 @@ async function mergePullRequest(
|
|
|
218
211
|
if (deriveNextStep(discovery) !== "merge") {
|
|
219
212
|
throw new Error(`PR #${fresh.number} merge cancelled: pull request is no longer merge-ready`);
|
|
220
213
|
}
|
|
221
|
-
if (!fresh.merge) throw new Error(`PR #${fresh.number} merge failed: merge capabilities are unavailable`);
|
|
222
|
-
const freshMethod = selectMergeMethod(fresh.merge);
|
|
223
|
-
if (freshMethod !== method) {
|
|
224
|
-
throw new Error(`PR #${fresh.number} merge cancelled: merge method changed from ${method} to ${freshMethod}`);
|
|
225
|
-
}
|
|
226
214
|
},
|
|
227
215
|
});
|
|
228
216
|
return true;
|
|
@@ -42,10 +42,6 @@ import {
|
|
|
42
42
|
type ExecOptions,
|
|
43
43
|
} from "./pr-execution.ts";
|
|
44
44
|
|
|
45
|
-
export const PR_COMMENT_SWEEP_ACTIONS = [
|
|
46
|
-
"start", "resume", "show", "record", "publish", "refresh", "resolve", "finalize",
|
|
47
|
-
] as const;
|
|
48
|
-
export type PrCommentSweepAction = (typeof PR_COMMENT_SWEEP_ACTIONS)[number];
|
|
49
45
|
export const SWEEP_RECOVERY_MAX_BYTES = 1024 * 1024;
|
|
50
46
|
|
|
51
47
|
const STATE_VERSION = 1;
|
|
@@ -140,16 +136,6 @@ export type PullRequestCommentSweepOptions = {
|
|
|
140
136
|
newRunId?: () => string;
|
|
141
137
|
pause?: (milliseconds: number) => Promise<void>;
|
|
142
138
|
};
|
|
143
|
-
export type PrCommentSweepRequest =
|
|
144
|
-
| { action: "start" }
|
|
145
|
-
| { action: "resume" }
|
|
146
|
-
| { action: "show"; guard: SweepRunGuard; id: string }
|
|
147
|
-
| { action: "record"; guard: SweepRunGuard; ledger: SweepLedgerEntry[]; ownedPaths?: string[] }
|
|
148
|
-
| { action: "publish"; guard: SweepRunGuard }
|
|
149
|
-
| { action: "refresh"; guard: SweepRunGuard }
|
|
150
|
-
| { action: "resolve"; guard: SweepRunGuard; threadIds: string[] }
|
|
151
|
-
| { action: "finalize"; guard: SweepRunGuard; projection: SweepFinalProjection; checks: SweepCheck[] };
|
|
152
|
-
|
|
153
139
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
154
140
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
155
141
|
}
|
|
@@ -1086,17 +1072,4 @@ export class PullRequestCommentSweep {
|
|
|
1086
1072
|
return { kind: "finalized", pullRequestUrl: state.authority.url, head: state.publicationHead, checks: checks.length };
|
|
1087
1073
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
1088
1074
|
}
|
|
1089
|
-
|
|
1090
|
-
async action(request: PrCommentSweepRequest): Promise<SweepStatus | FeedbackItem | { kind: "finalized"; pullRequestUrl: string; head: string; checks: number }> {
|
|
1091
|
-
switch (request.action) {
|
|
1092
|
-
case "start": return await this.start();
|
|
1093
|
-
case "resume": return await this.resume();
|
|
1094
|
-
case "show": return await this.show(request.guard, request.id);
|
|
1095
|
-
case "record": return await this.record(request.guard, request.ledger, request.ownedPaths);
|
|
1096
|
-
case "publish": return await this.publish(request.guard);
|
|
1097
|
-
case "refresh": return await this.refresh(request.guard);
|
|
1098
|
-
case "resolve": return await this.resolve(request.guard, request.threadIds);
|
|
1099
|
-
case "finalize": return await this.finalize(request.guard, request.projection, request.checks);
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
1075
|
}
|
package/extensions/pr-create.ts
CHANGED
|
@@ -6,14 +6,11 @@ import {
|
|
|
6
6
|
loadCurrentPullRequest,
|
|
7
7
|
loadPullRequestPublication,
|
|
8
8
|
preflightPullRequestCreation,
|
|
9
|
-
readBranchUpstreamConfiguration,
|
|
10
9
|
readPullRequestBaseRefOid,
|
|
11
10
|
readTrackingOid,
|
|
12
11
|
readValidatedRemoteAuthority,
|
|
13
|
-
restoreBranchUpstreamConfiguration,
|
|
14
12
|
setBranchUpstream,
|
|
15
13
|
verifyBranchUpstream,
|
|
16
|
-
type BranchUpstreamConfiguration,
|
|
17
14
|
type BranchUpstreamTarget,
|
|
18
15
|
type PullRequestLoadContext,
|
|
19
16
|
type PullRequestPublication,
|
|
@@ -31,7 +28,6 @@ import {
|
|
|
31
28
|
runChecked,
|
|
32
29
|
spawnBounded,
|
|
33
30
|
withWorktreeLock,
|
|
34
|
-
type AttemptState,
|
|
35
31
|
type Exec,
|
|
36
32
|
type ExecOptions,
|
|
37
33
|
} from "./pr-execution.ts";
|
|
@@ -61,22 +57,11 @@ type CrossRepositoryCreateAuthority = {
|
|
|
61
57
|
|
|
62
58
|
export type CreatePullRequestState = {
|
|
63
59
|
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
60
|
base?: CreateBaseAuthority;
|
|
75
61
|
createAuthority?: CrossRepositoryCreateAuthority;
|
|
76
62
|
mergeHead?: string;
|
|
77
63
|
publicationHead?: string;
|
|
78
64
|
conflict?: { paths: string[]; statusBaseline: string; originalHead: string };
|
|
79
|
-
originalUpstream?: BranchUpstreamConfiguration;
|
|
80
65
|
url?: string;
|
|
81
66
|
};
|
|
82
67
|
|
|
@@ -102,11 +87,6 @@ function sameTarget(left: PullRequestTarget, right: PullRequestTarget, expectedR
|
|
|
102
87
|
left.fetchSource === right.fetchSource && right.remoteOid === expectedRemoteOid;
|
|
103
88
|
}
|
|
104
89
|
|
|
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
90
|
function line(output: string, label: string): string {
|
|
111
91
|
const normalized = output.replace(/\r\n/g, "\n");
|
|
112
92
|
const values = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
|
|
@@ -198,17 +178,12 @@ function resolvedPaths(paths: readonly string[], expected: readonly string[]): s
|
|
|
198
178
|
}
|
|
199
179
|
|
|
200
180
|
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
|
-
};
|
|
181
|
+
readonly state: CreatePullRequestState = { phase: "unprepared" };
|
|
208
182
|
|
|
209
183
|
private readonly cwd: string;
|
|
210
184
|
private readonly target: PullRequestTarget;
|
|
211
185
|
private readonly noTarget: boolean;
|
|
186
|
+
private noTargetUpstreamConfigured = false;
|
|
212
187
|
private readonly signal?: AbortSignal;
|
|
213
188
|
private readonly agentDir?: string;
|
|
214
189
|
private readonly exec: Exec;
|
|
@@ -283,7 +258,7 @@ export class PullRequestCreator {
|
|
|
283
258
|
}
|
|
284
259
|
|
|
285
260
|
async prepare(explicitBase?: string): Promise<CreatePullRequestResult> {
|
|
286
|
-
if (this.state.phase !== "unprepared"
|
|
261
|
+
if (this.state.phase !== "unprepared") {
|
|
287
262
|
throw new Error("PR creation prepare action was already consumed");
|
|
288
263
|
}
|
|
289
264
|
return await withWorktreeLock(this.cwd, async () => {
|
|
@@ -306,17 +281,11 @@ export class PullRequestCreator {
|
|
|
306
281
|
oid: base.oid,
|
|
307
282
|
fetchSource: base.fetchSource,
|
|
308
283
|
};
|
|
309
|
-
this.state.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
}
|
|
284
|
+
this.state.phase = "blocked";
|
|
285
|
+
await runChecked(this.exec, "git", [
|
|
286
|
+
"fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", base.fetchSource, base.oid,
|
|
287
|
+
], this.options());
|
|
288
|
+
await runChecked(this.exec, "git", ["cat-file", "-e", `${base.oid}^{commit}`], this.options());
|
|
320
289
|
await this.freshNone();
|
|
321
290
|
if (await readHead(this.exec, this.options()) !== preflight.head) {
|
|
322
291
|
throw new Error("PR creation cancelled: local HEAD changed during prepare");
|
|
@@ -358,7 +327,7 @@ export class PullRequestCreator {
|
|
|
358
327
|
}
|
|
359
328
|
|
|
360
329
|
async merge(): Promise<CreatePullRequestResult> {
|
|
361
|
-
if (this.state.phase !== "prepared" || !this.state.base
|
|
330
|
+
if (this.state.phase !== "prepared" || !this.state.base) {
|
|
362
331
|
throw new Error("PR creation is not prepared for merge");
|
|
363
332
|
}
|
|
364
333
|
return await withWorktreeLock(this.cwd, async () => {
|
|
@@ -370,35 +339,17 @@ export class PullRequestCreator {
|
|
|
370
339
|
this.state.mergeHead = originalHead;
|
|
371
340
|
return { kind: "verified", head: originalHead, fastForward: false };
|
|
372
341
|
}
|
|
373
|
-
this.state.
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
}
|
|
342
|
+
this.state.phase = "blocked";
|
|
343
|
+
const result = await this.exec("git", ["merge", "--no-edit", this.state.base!.oid], this.options());
|
|
344
|
+
if (result.killed) throw new Error("git merge was killed; its outcome is unknown");
|
|
385
345
|
if (result.code === 0) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
this.state.attempts.merge = "applied";
|
|
389
|
-
return { kind: "verified", ...verified };
|
|
390
|
-
} catch (error) {
|
|
391
|
-
this.state.attempts.merge = "unknown";
|
|
392
|
-
throw error;
|
|
393
|
-
}
|
|
346
|
+
const verified = await this.verifyMerge(originalHead);
|
|
347
|
+
return { kind: "verified", ...verified };
|
|
394
348
|
}
|
|
395
349
|
try {
|
|
396
350
|
const paths = await this.captureConflict(originalHead);
|
|
397
|
-
this.state.attempts.merge = "applied";
|
|
398
351
|
return { kind: "conflict", paths };
|
|
399
352
|
} catch (error) {
|
|
400
|
-
this.state.attempts.merge = "blocked";
|
|
401
|
-
this.state.phase = "blocked";
|
|
402
353
|
throw new Error(`git merge failed: ${result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`}; ${error instanceof Error ? error.message : String(error)}`);
|
|
403
354
|
}
|
|
404
355
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
@@ -408,9 +359,6 @@ export class PullRequestCreator {
|
|
|
408
359
|
if (this.state.phase !== "conflict-awaiting-user" || !this.state.conflict || !this.state.base) {
|
|
409
360
|
throw new Error("PR creation has no conflict awaiting continuation");
|
|
410
361
|
}
|
|
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
362
|
const paths = resolvedPaths(pathsInput, this.state.conflict.paths);
|
|
415
363
|
return await withWorktreeLock(this.cwd, async () => {
|
|
416
364
|
await this.freshNone();
|
|
@@ -420,94 +368,34 @@ export class PullRequestCreator {
|
|
|
420
368
|
if (mergeHead !== this.state.base!.oid) throw new Error("PR creation merge context changed");
|
|
421
369
|
const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options());
|
|
422
370
|
assertOnlyDeclaredStatusChanged(this.state.conflict!.statusBaseline, status.stdout, paths);
|
|
423
|
-
this.state.
|
|
424
|
-
|
|
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
|
-
}
|
|
371
|
+
this.state.phase = "blocked";
|
|
372
|
+
await runChecked(this.exec, "git", ["add", "--", ...paths], this.options());
|
|
431
373
|
const unmerged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.options())).stdout, "Unmerged paths");
|
|
432
374
|
if (unmerged.length) throw new Error(`Conflict paths remain unresolved: ${unmerged.join(", ")}`);
|
|
433
|
-
this.
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
}
|
|
375
|
+
await runChecked(this.exec, "git", ["-c", "core.editor=true", "merge", "--continue"], this.options());
|
|
376
|
+
const verified = await this.verifyMerge(this.state.conflict!.originalHead);
|
|
377
|
+
return { kind: "verified", ...verified };
|
|
443
378
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
444
379
|
}
|
|
445
380
|
|
|
446
|
-
private
|
|
447
|
-
|
|
381
|
+
private async configureNoTargetUpstream(head: string): Promise<void> {
|
|
382
|
+
const target: BranchUpstreamTarget = {
|
|
448
383
|
branch: this.target.branch,
|
|
449
384
|
remote: this.target.remote,
|
|
450
385
|
ref: this.target.ref,
|
|
451
386
|
fetchSource: this.target.fetchSource,
|
|
452
387
|
remoteOid: head,
|
|
453
388
|
};
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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;
|
|
389
|
+
await fetchBranchTrackingRef(this.pi(), this.context(), target);
|
|
390
|
+
if (await readTrackingOid(this.pi(), this.context(), branchTrackingRef(target)) !== head) {
|
|
391
|
+
throw new Error("Fetched tracking ref did not match published HEAD");
|
|
506
392
|
}
|
|
393
|
+
await setBranchUpstream(this.pi(), this.context(), target);
|
|
394
|
+
await verifyBranchUpstream(this.pi(), this.context(), target);
|
|
507
395
|
}
|
|
508
396
|
|
|
509
397
|
async push(): Promise<CreatePullRequestResult> {
|
|
510
|
-
if (this.state.phase !== "verified" || !this.state.base
|
|
398
|
+
if (this.state.phase !== "verified" || !this.state.base) {
|
|
511
399
|
throw new Error("PR creation is not ready to push");
|
|
512
400
|
}
|
|
513
401
|
return await withWorktreeLock(this.cwd, async () => {
|
|
@@ -525,31 +413,27 @@ export class PullRequestCreator {
|
|
|
525
413
|
await this.freshNone();
|
|
526
414
|
if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
|
|
527
415
|
if (await this.requireCleanHead() !== head) throw new Error("PR creation cancelled: local HEAD changed before push");
|
|
528
|
-
if (this.noTarget) await this.captureNoTargetUpstream();
|
|
529
416
|
this.state.publicationHead = head;
|
|
530
|
-
this.state.
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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;
|
|
417
|
+
this.state.phase = "blocked";
|
|
418
|
+
await runChecked(this.exec, "git", [
|
|
419
|
+
"push", "--porcelain", `--force-with-lease=refs/heads/${this.target.ref}:${original ?? ""}`,
|
|
420
|
+
"--recurse-submodules=no", "--", this.target.fetchSource, `${head}:refs/heads/${this.target.ref}`,
|
|
421
|
+
], this.options());
|
|
422
|
+
if (await readRemoteOid(this.exec, this.options(), this.target.fetchSource, this.target.ref) !== head) {
|
|
423
|
+
throw new Error("Published remote ref did not match captured HEAD");
|
|
543
424
|
}
|
|
544
425
|
this.state.phase = "pushed";
|
|
545
|
-
if (this.noTarget)
|
|
426
|
+
if (this.noTarget) {
|
|
427
|
+
await this.configureNoTargetUpstream(head);
|
|
428
|
+
this.noTargetUpstreamConfigured = true;
|
|
429
|
+
}
|
|
546
430
|
return { kind: "pushed", head };
|
|
547
431
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
548
432
|
}
|
|
549
433
|
|
|
550
434
|
private async publishedAuthority(): Promise<void> {
|
|
551
435
|
const head = this.state.publicationHead!;
|
|
552
|
-
if (this.noTarget && this.
|
|
436
|
+
if (this.noTarget && !this.noTargetUpstreamConfigured) {
|
|
553
437
|
const branch = line((await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout, "current branch");
|
|
554
438
|
const authority = await readValidatedRemoteAuthority(this.pi(), this.context(), this.target.remote);
|
|
555
439
|
if (branch !== this.target.branch || authority.host !== this.target.host ||
|
|
@@ -587,16 +471,9 @@ export class PullRequestCreator {
|
|
|
587
471
|
}
|
|
588
472
|
|
|
589
473
|
async publish(titleInput: string, body: string): Promise<CreatePullRequestResult> {
|
|
590
|
-
|
|
591
|
-
if (
|
|
592
|
-
this.state.phase !== "pushed" || !this.state.base || !this.state.publicationHead ||
|
|
593
|
-
(this.state.attempts.pullRequest !== "none" && !retryingUpstream)
|
|
594
|
-
) {
|
|
474
|
+
if (this.state.phase !== "pushed" || !this.state.base || !this.state.publicationHead) {
|
|
595
475
|
throw new Error("PR creation is not ready to publish metadata");
|
|
596
476
|
}
|
|
597
|
-
if (this.noTarget && !this.state.originalUpstream) {
|
|
598
|
-
throw new Error("PR creation original branch upstream is unavailable");
|
|
599
|
-
}
|
|
600
477
|
const title = requiredText(titleInput, "pull request title");
|
|
601
478
|
if (Buffer.byteLength(title, "utf8") > MAX_TITLE_BYTES) throw new Error(`Pull request title exceeds ${MAX_TITLE_BYTES} bytes`);
|
|
602
479
|
if (typeof body !== "string" || body.includes("\0") || Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) {
|
|
@@ -609,63 +486,42 @@ export class PullRequestCreator {
|
|
|
609
486
|
if (before && (before.base.repository.toLowerCase() !== this.state.base!.repository.toLowerCase() || before.base.ref !== this.state.base!.ref)) {
|
|
610
487
|
throw new Error("Exact-head pull request targets a different base");
|
|
611
488
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
this.state.
|
|
646
|
-
|
|
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
|
-
}
|
|
489
|
+
const repository = `${this.state.base!.host}/${this.state.base!.repository}`;
|
|
490
|
+
const sameRepository = this.state.base!.repository.toLowerCase() === this.target.repository.toLowerCase();
|
|
491
|
+
const organizationAuthority = !sameRepository && this.state.createAuthority?.headOwnerType === "Organization"
|
|
492
|
+
? this.state.createAuthority
|
|
493
|
+
: undefined;
|
|
494
|
+
if (!sameRepository && !this.state.createAuthority) {
|
|
495
|
+
throw new Error("Cross-repository PR creation was not preflighted before push");
|
|
496
|
+
}
|
|
497
|
+
const args = before
|
|
498
|
+
? ["pr", "edit", String(before.number), "--repo", repository, "--title", title, "--body-file", "-"]
|
|
499
|
+
: organizationAuthority
|
|
500
|
+
? [
|
|
501
|
+
"api", "graphql", "--hostname", this.state.base!.host,
|
|
502
|
+
"-f", `query=${CREATE_PULL_REQUEST_MUTATION}`,
|
|
503
|
+
"-f", `repositoryId=${organizationAuthority.baseRepositoryId}`,
|
|
504
|
+
"-f", `baseRefName=${this.state.base!.ref}`,
|
|
505
|
+
"-f", `headRepositoryId=${organizationAuthority.headRepositoryId}`,
|
|
506
|
+
"-f", `headRefName=${this.target.ref}`,
|
|
507
|
+
"-f", `title=${title}`,
|
|
508
|
+
"-f", `body=${body}`,
|
|
509
|
+
]
|
|
510
|
+
: [
|
|
511
|
+
"pr", "create", "--repo", repository, "--head",
|
|
512
|
+
sameRepository ? this.target.ref : `${this.target.repository.split("/")[0]}:${this.target.ref}`,
|
|
513
|
+
"--base", this.state.base!.ref, "--title", title, "--body-file", "-",
|
|
514
|
+
];
|
|
515
|
+
this.state.phase = "blocked";
|
|
516
|
+
const created = await runChecked(this.exec, "gh", args, this.options({ stdin: organizationAuthority ? undefined : body }));
|
|
517
|
+
if (!before && organizationAuthority) {
|
|
518
|
+
parseCreatedUrl(created.stdout, this.state.base!.host, this.state.base!.repository);
|
|
519
|
+
}
|
|
520
|
+
const after = await this.exactCandidate();
|
|
521
|
+
if (!after || after.base.repository.toLowerCase() !== this.state.base!.repository.toLowerCase() ||
|
|
522
|
+
after.base.ref !== this.state.base!.ref || after.title !== title || after.body !== body) {
|
|
523
|
+
throw new Error("Published pull request did not retain canonical identity, title, and body");
|
|
667
524
|
}
|
|
668
|
-
if (this.noTarget) await this.configureNoTargetUpstream(this.state.publicationHead!);
|
|
669
525
|
this.state.phase = "published";
|
|
670
526
|
this.state.url = after.url.href;
|
|
671
527
|
return { kind: "published", url: after.url.href };
|