@henryqw/pi-pr 4.0.2 → 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 +9 -7
- package/extensions/pr-command.ts +34 -13
- package/extensions/pr-create.ts +140 -150
- package/extensions/pr-github.ts +327 -18
- package/extensions/pr-routing.ts +6 -2
- package/extensions/pr-ui.ts +1 -2
- package/extensions/pr.ts +14 -20
- package/package.json +1 -1
- package/skills/pi-pr-create/SKILL.md +5 -5
package/README.md
CHANGED
|
@@ -42,11 +42,11 @@ GitHub, use the network, or write files.
|
|
|
42
42
|
|
|
43
43
|
Run `/pr` in a GitHub checkout. It reads fresh pull request and local state, then runs one route. The PR hostname selects its GitHub API host, and the extension works outside Herdr.
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
For creation, put an optional base branch first. For example, run `/pr --base release/2026 Keep the title concise.` The base is a branch name, not a host or repository. Only creation accepts the base and remaining guidance. Other routes reject them instead of ignoring them.
|
|
46
46
|
|
|
47
47
|
| Surface | Type | Purpose |
|
|
48
48
|
| --- | --- | --- |
|
|
49
|
-
| `/pr [--base
|
|
49
|
+
| `/pr [--base BRANCH] [creation instructions]` | command | Run the current pull request's next safe route. |
|
|
50
50
|
| Footer | ui | Show a linked `PR #number` and one plain-language status. |
|
|
51
51
|
| Widget | ui | Show one action hint or transient routing status. |
|
|
52
52
|
|
|
@@ -64,7 +64,7 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
|
|
|
64
64
|
|
|
65
65
|
| Current condition | `/pr` route |
|
|
66
66
|
| --- | --- |
|
|
67
|
-
| No current-branch pull request, no published matching ref,
|
|
67
|
+
| No current-branch pull request, no published matching ref, safe Git push configuration, and a commit ahead of the selected base | Start pull-request creation. |
|
|
68
68
|
| One open pull request inferred from a published matching ref | Confirm the exact `remote/ref`, then link the local branch. |
|
|
69
69
|
| Ambiguous or unsafe discovery | Show the blocked reason and do not mutate Git or GitHub. |
|
|
70
70
|
| Base update required or merge conflict | Update from the base branch's current target when the tree is clean and local HEAD equals the PR head. |
|
|
@@ -74,11 +74,13 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
|
|
|
74
74
|
| No-action state | Report the state without taking action. |
|
|
75
75
|
| Merge-ready pull request | Ask for final confirmation, recheck fresh state, and merge directly if confirmed. |
|
|
76
76
|
|
|
77
|
-
`pi-pr-create`
|
|
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.
|
|
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
|
|
|
81
|
-
It
|
|
81
|
+
It merges the captured base commit before validation and push. It resolves clear conflicts and stops when the base or conflict intent is ambiguous.
|
|
82
|
+
|
|
83
|
+
A configured target never changes branch upstream settings. Without a target, the helper pushes the captured OID to the local branch ref on validated `origin` and fetches its tracking ref. It leaves upstream unset. It creates or updates and validates the exact PR before it sets and verifies upstream. A failed setup rolls back only unchanged helper-owned settings. If configuration changed concurrently, it stops without overwriting it. Retrying `publish` resumes setup without another push or PR mutation.
|
|
82
84
|
|
|
83
85
|
Without a configured push target, discovery checks validated remotes for the same branch ref. One exact open PR becomes an inferred target. `/pr` names the exact `remote/ref` and asks before linking it. The extension revalidates the branch, PR, remote OID, and Git configuration before mutation. It rolls back its upstream and remote-tracking changes if final verification fails.
|
|
84
86
|
|
|
@@ -145,7 +147,7 @@ The GitHub response must match the observed URL, host, repository, head ref, hea
|
|
|
145
147
|
|
|
146
148
|
## Limits and recovery
|
|
147
149
|
|
|
148
|
-
- `/pr` accepts only
|
|
150
|
+
- `/pr` accepts creation syntax only as a leading `--base BRANCH`, followed by optional creation guidance. It does not open a browser.
|
|
149
151
|
- It does not run `/done` or `/sweep`.
|
|
150
152
|
- Polling does not auto-triage comments or start a workflow. The package comment sweep runs only when an explicit `/pr` selects it.
|
|
151
153
|
- It does not enable auto-merge or add a merge queue.
|
package/extensions/pr-command.ts
CHANGED
|
@@ -26,8 +26,6 @@ const WORKFLOWS: Record<WorkflowNextStep, { command: string; action: string }> =
|
|
|
26
26
|
sweep: { command: "skill:pi-pr-comment-sweep", action: "start" },
|
|
27
27
|
"fix-ci": { command: "skill:pi-pr-fix-ci", action: "collect" },
|
|
28
28
|
};
|
|
29
|
-
const CREATE_BASE = /^--base=([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[^\s\0]+)$/;
|
|
30
|
-
|
|
31
29
|
export type WorkflowReservation =
|
|
32
30
|
| { route: "create"; target: PullRequestTarget; base?: string }
|
|
33
31
|
| { route: Exclude<WorkflowNextStep, "create">; pullRequest: CurrentPullRequest };
|
|
@@ -62,21 +60,30 @@ export type PrCommandDependencies = {
|
|
|
62
60
|
releaseWorkflow?: (runId: string, invocation?: PrCommandInvocation) => void;
|
|
63
61
|
};
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
type ParsedPrArguments = {
|
|
64
|
+
base?: string;
|
|
65
|
+
instructions: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function parsePrArguments(args: string): ParsedPrArguments {
|
|
69
|
+
const leading = args.trimStart();
|
|
70
|
+
if (leading.startsWith("--base=")) throw new Error("/pr base syntax is --base <branch>");
|
|
71
|
+
if (!leading.startsWith("--base") || !/^--base(?:\s|$)/.test(leading)) {
|
|
72
|
+
return { instructions: args.trim() };
|
|
73
|
+
}
|
|
74
|
+
const value = /^--base\s+(\S+)/.exec(leading);
|
|
75
|
+
if (!value) throw new Error("/pr --base requires a branch");
|
|
76
|
+
return { base: value[1]!, instructions: leading.slice(value[0].length).trim() };
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
function workflowReservation(
|
|
73
80
|
nextStep: WorkflowNextStep,
|
|
74
81
|
discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
|
|
82
|
+
base: string | undefined,
|
|
75
83
|
instructions: string,
|
|
76
84
|
): WorkflowReservation {
|
|
77
85
|
if (nextStep === "create") {
|
|
78
86
|
if (discovery.kind !== "none") throw new Error("/pr create failed: creation target is unavailable");
|
|
79
|
-
const base = createBase(instructions);
|
|
80
87
|
return { route: "create", target: discovery.creationTarget, ...(base === undefined ? {} : { base }) };
|
|
81
88
|
}
|
|
82
89
|
if (instructions) throw new Error("The current /pr helper route does not accept instructions");
|
|
@@ -104,6 +111,7 @@ async function dispatchWorkflow(
|
|
|
104
111
|
reserve: NonNullable<PrCommandDependencies["reserveWorkflow"]>,
|
|
105
112
|
markPromptQueued: NonNullable<PrCommandDependencies["markWorkflowPromptQueued"]>,
|
|
106
113
|
release: NonNullable<PrCommandDependencies["releaseWorkflow"]>,
|
|
114
|
+
instructions: string,
|
|
107
115
|
): Promise<void> {
|
|
108
116
|
const workflow = packageWorkflowCommand(pi, route);
|
|
109
117
|
let runId: string | undefined;
|
|
@@ -117,7 +125,7 @@ async function dispatchWorkflow(
|
|
|
117
125
|
? { deliverAs: "followUp" as const, expandPromptTemplates: true }
|
|
118
126
|
: { expandPromptTemplates: true };
|
|
119
127
|
invocation?.assertCurrent();
|
|
120
|
-
pi.sendUserMessage(`/${identity.skill} runId=${identity.runId} action=${identity.action}`, options);
|
|
128
|
+
pi.sendUserMessage(`/${identity.skill} runId=${identity.runId} action=${identity.action}${instructions ? ` ${instructions}` : ""}`, options);
|
|
121
129
|
} catch (error) {
|
|
122
130
|
if (runId !== undefined) release(runId, invocation);
|
|
123
131
|
throw error;
|
|
@@ -258,11 +266,14 @@ export function createPrCommandHandler(
|
|
|
258
266
|
const commandInvocation = onRouteResolved && "assertCurrent" in onRouteResolved
|
|
259
267
|
? onRouteResolved as PrCommandInvocation
|
|
260
268
|
: undefined;
|
|
261
|
-
const instructions = args
|
|
262
|
-
const discovery = await load(pi, ctx);
|
|
269
|
+
const { base, instructions } = parsePrArguments(args);
|
|
270
|
+
const discovery = await load(pi, ctx, undefined, undefined, base);
|
|
263
271
|
commandInvocation?.assertCurrent();
|
|
264
272
|
const nextStep = deriveNextStep(discovery);
|
|
265
273
|
onRouteResolved?.(nextStep);
|
|
274
|
+
if (base !== undefined && nextStep !== "create") {
|
|
275
|
+
throw new Error("/pr --base is accepted only for pull request creation");
|
|
276
|
+
}
|
|
266
277
|
if (instructions && !(nextStep in WORKFLOWS)) {
|
|
267
278
|
throw new Error("The current /pr route does not accept instructions");
|
|
268
279
|
}
|
|
@@ -289,8 +300,18 @@ export function createPrCommandHandler(
|
|
|
289
300
|
|
|
290
301
|
if (!(nextStep in WORKFLOWS)) throw new Error(`/pr cannot dispatch route ${nextStep}`);
|
|
291
302
|
const route = nextStep as WorkflowNextStep;
|
|
292
|
-
const reservation = workflowReservation(route, discovery, instructions);
|
|
293
|
-
await dispatchWorkflow(
|
|
303
|
+
const reservation = workflowReservation(route, discovery, base, instructions);
|
|
304
|
+
await dispatchWorkflow(
|
|
305
|
+
pi,
|
|
306
|
+
ctx,
|
|
307
|
+
route,
|
|
308
|
+
reservation,
|
|
309
|
+
commandInvocation,
|
|
310
|
+
reserve,
|
|
311
|
+
markPromptQueued,
|
|
312
|
+
release,
|
|
313
|
+
route === "create" ? instructions : "",
|
|
314
|
+
);
|
|
294
315
|
return nextStep;
|
|
295
316
|
};
|
|
296
317
|
}
|
package/extensions/pr-create.ts
CHANGED
|
@@ -5,11 +5,15 @@ import {
|
|
|
5
5
|
findExactHeadPullRequests,
|
|
6
6
|
loadCurrentPullRequest,
|
|
7
7
|
loadPullRequestPublication,
|
|
8
|
+
preflightPullRequestCreation,
|
|
9
|
+
readBranchUpstreamConfiguration,
|
|
8
10
|
readPullRequestBaseRefOid,
|
|
9
11
|
readTrackingOid,
|
|
10
12
|
readValidatedRemoteAuthority,
|
|
13
|
+
restoreBranchUpstreamConfiguration,
|
|
11
14
|
setBranchUpstream,
|
|
12
15
|
verifyBranchUpstream,
|
|
16
|
+
type BranchUpstreamConfiguration,
|
|
13
17
|
type BranchUpstreamTarget,
|
|
14
18
|
type PullRequestLoadContext,
|
|
15
19
|
type PullRequestPublication,
|
|
@@ -24,7 +28,6 @@ import {
|
|
|
24
28
|
readRemoteOid,
|
|
25
29
|
requiredOid,
|
|
26
30
|
requiredText,
|
|
27
|
-
resolveRepositoryFetchSource,
|
|
28
31
|
runChecked,
|
|
29
32
|
spawnBounded,
|
|
30
33
|
withWorktreeLock,
|
|
@@ -33,8 +36,6 @@ import {
|
|
|
33
36
|
type ExecOptions,
|
|
34
37
|
} from "./pr-execution.ts";
|
|
35
38
|
|
|
36
|
-
const BASE_RERUN = "Rerun /pr --base=<host>/<owner>/<repository>:<ref>";
|
|
37
|
-
const MAX_REMOTE_BRANCHES = 128;
|
|
38
39
|
const MAX_TITLE_BYTES = 256;
|
|
39
40
|
const MAX_BODY_BYTES = 64 * 1024;
|
|
40
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}}}";
|
|
@@ -75,6 +76,7 @@ export type CreatePullRequestState = {
|
|
|
75
76
|
mergeHead?: string;
|
|
76
77
|
publicationHead?: string;
|
|
77
78
|
conflict?: { paths: string[]; statusBaseline: string; originalHead: string };
|
|
79
|
+
originalUpstream?: BranchUpstreamConfiguration;
|
|
78
80
|
url?: string;
|
|
79
81
|
};
|
|
80
82
|
|
|
@@ -100,6 +102,11 @@ function sameTarget(left: PullRequestTarget, right: PullRequestTarget, expectedR
|
|
|
100
102
|
left.fetchSource === right.fetchSource && right.remoteOid === expectedRemoteOid;
|
|
101
103
|
}
|
|
102
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
|
+
|
|
103
110
|
function line(output: string, label: string): string {
|
|
104
111
|
const normalized = output.replace(/\r\n/g, "\n");
|
|
105
112
|
const values = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
|
|
@@ -107,16 +114,6 @@ function line(output: string, label: string): string {
|
|
|
107
114
|
return values[0];
|
|
108
115
|
}
|
|
109
116
|
|
|
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
117
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
121
118
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
122
119
|
}
|
|
@@ -191,14 +188,6 @@ function parseCreatedUrl(output: string, host: string, baseRepository: string):
|
|
|
191
188
|
return url;
|
|
192
189
|
}
|
|
193
190
|
|
|
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
191
|
function resolvedPaths(paths: readonly string[], expected: readonly string[]): string[] {
|
|
203
192
|
if (!Array.isArray(paths)) throw new TypeError("resolvedPaths must be an array");
|
|
204
193
|
const parsed = parseNulPaths(`${paths.join("\0")}${paths.length ? "\0" : ""}`, "Resolved conflict paths");
|
|
@@ -224,6 +213,7 @@ export class PullRequestCreator {
|
|
|
224
213
|
private readonly agentDir?: string;
|
|
225
214
|
private readonly exec: Exec;
|
|
226
215
|
private readonly load: Load;
|
|
216
|
+
private explicitBase?: string;
|
|
227
217
|
|
|
228
218
|
constructor(options: CreatePullRequestOptions) {
|
|
229
219
|
if (!options.target || options.target.remoteOid !== null && !requiredOid(options.target.remoteOid, "remote OID")) {
|
|
@@ -257,7 +247,7 @@ export class PullRequestCreator {
|
|
|
257
247
|
}
|
|
258
248
|
|
|
259
249
|
private async freshNone(): Promise<void> {
|
|
260
|
-
const discovery = await this.load(this.pi(), this.context());
|
|
250
|
+
const discovery = await this.load(this.pi(), this.context(), undefined, undefined, this.explicitBase);
|
|
261
251
|
if (discovery.kind !== "none" || !sameTarget(this.target, discovery.creationTarget)) {
|
|
262
252
|
throw new Error("PR creation cancelled: fresh complete discovery is no longer none");
|
|
263
253
|
}
|
|
@@ -265,71 +255,6 @@ export class PullRequestCreator {
|
|
|
265
255
|
if (branch !== this.target.branch) throw new Error("PR creation cancelled: current branch changed");
|
|
266
256
|
}
|
|
267
257
|
|
|
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
258
|
private async liveBase(): Promise<string> {
|
|
334
259
|
if (!this.state.base) throw new Error("PR creation base is unavailable");
|
|
335
260
|
return await readPullRequestBaseRefOid(this.pi(), this.context(), this.state.base);
|
|
@@ -362,36 +287,50 @@ export class PullRequestCreator {
|
|
|
362
287
|
throw new Error("PR creation prepare action was already consumed");
|
|
363
288
|
}
|
|
364
289
|
return await withWorktreeLock(this.cwd, async () => {
|
|
290
|
+
this.explicitBase = explicitBase;
|
|
365
291
|
await this.freshNone();
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
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");
|
|
369
300
|
}
|
|
370
|
-
const
|
|
371
|
-
this.state.base = {
|
|
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
|
+
};
|
|
372
309
|
this.state.attempts.fetchBase = "attempting";
|
|
373
310
|
try {
|
|
374
311
|
await runChecked(this.exec, "git", [
|
|
375
|
-
"fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", base.fetchSource, oid,
|
|
312
|
+
"fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", base.fetchSource, base.oid,
|
|
376
313
|
], this.options());
|
|
377
|
-
await runChecked(this.exec, "git", ["cat-file", "-e", `${oid}^{commit}`], this.options());
|
|
314
|
+
await runChecked(this.exec, "git", ["cat-file", "-e", `${base.oid}^{commit}`], this.options());
|
|
378
315
|
this.state.attempts.fetchBase = "applied";
|
|
379
316
|
} catch (error) {
|
|
380
317
|
this.state.attempts.fetchBase = "unknown";
|
|
381
318
|
throw error;
|
|
382
319
|
}
|
|
383
320
|
await this.freshNone();
|
|
384
|
-
if (await this.
|
|
385
|
-
|
|
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");
|
|
386
325
|
this.state.phase = "prepared";
|
|
387
|
-
return { kind: "prepared", base: { ...this.state.base }, mergeBase };
|
|
326
|
+
return { kind: "prepared", base: { ...this.state.base }, mergeBase: base.mergeBase };
|
|
388
327
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
389
328
|
}
|
|
390
329
|
|
|
391
330
|
private async verifyMerge(originalHead: string): Promise<{ head: string; fastForward: boolean }> {
|
|
392
331
|
const base = this.state.base!;
|
|
393
332
|
const head = await readHead(this.exec, this.options());
|
|
394
|
-
const commits = line((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1",
|
|
333
|
+
const commits = line((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", head], this.options())).stdout, "merge parents")
|
|
395
334
|
.split(" ").map((value, index) => requiredOid(value, index ? "merge parent" : "merged HEAD"));
|
|
396
335
|
if (commits[0] !== head) throw new Error("PR creation merge verification returned a different HEAD");
|
|
397
336
|
let fastForward = false;
|
|
@@ -504,14 +443,27 @@ export class PullRequestCreator {
|
|
|
504
443
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
505
444
|
}
|
|
506
445
|
|
|
507
|
-
private
|
|
508
|
-
|
|
446
|
+
private upstreamTarget(head: string): BranchUpstreamTarget {
|
|
447
|
+
return {
|
|
509
448
|
branch: this.target.branch,
|
|
510
449
|
remote: this.target.remote,
|
|
511
450
|
ref: this.target.ref,
|
|
512
451
|
fetchSource: this.target.fetchSource,
|
|
513
452
|
remoteOid: head,
|
|
514
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);
|
|
515
467
|
this.state.attempts.fetchTracking = "attempting";
|
|
516
468
|
try {
|
|
517
469
|
await fetchBranchTrackingRef(this.pi(), this.context(), target);
|
|
@@ -523,6 +475,19 @@ export class PullRequestCreator {
|
|
|
523
475
|
this.state.attempts.fetchTracking = "unknown";
|
|
524
476
|
throw error;
|
|
525
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);
|
|
526
491
|
this.state.attempts.setUpstream = "attempting";
|
|
527
492
|
try {
|
|
528
493
|
await setBranchUpstream(this.pi(), this.context(), target);
|
|
@@ -530,6 +495,13 @@ export class PullRequestCreator {
|
|
|
530
495
|
this.state.attempts.setUpstream = "applied";
|
|
531
496
|
} catch (error) {
|
|
532
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
|
+
}
|
|
533
505
|
throw error;
|
|
534
506
|
}
|
|
535
507
|
}
|
|
@@ -553,6 +525,7 @@ export class PullRequestCreator {
|
|
|
553
525
|
await this.freshNone();
|
|
554
526
|
if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
|
|
555
527
|
if (await this.requireCleanHead() !== head) throw new Error("PR creation cancelled: local HEAD changed before push");
|
|
528
|
+
if (this.noTarget) await this.captureNoTargetUpstream();
|
|
556
529
|
this.state.publicationHead = head;
|
|
557
530
|
this.state.attempts.push = "attempting";
|
|
558
531
|
try {
|
|
@@ -569,7 +542,7 @@ export class PullRequestCreator {
|
|
|
569
542
|
throw error;
|
|
570
543
|
}
|
|
571
544
|
this.state.phase = "pushed";
|
|
572
|
-
if (this.noTarget) await this.
|
|
545
|
+
if (this.noTarget) await this.fetchNoTargetTracking(head);
|
|
573
546
|
return { kind: "pushed", head };
|
|
574
547
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
575
548
|
}
|
|
@@ -587,7 +560,7 @@ export class PullRequestCreator {
|
|
|
587
560
|
}
|
|
588
561
|
return;
|
|
589
562
|
}
|
|
590
|
-
const discovery = await this.load(this.pi(), this.context());
|
|
563
|
+
const discovery = await this.load(this.pi(), this.context(), undefined, undefined, this.explicitBase);
|
|
591
564
|
if (discovery.kind === "none") {
|
|
592
565
|
if (!sameTarget(this.target, discovery.creationTarget, head)) throw new Error("Published target authority changed");
|
|
593
566
|
return;
|
|
@@ -614,9 +587,16 @@ export class PullRequestCreator {
|
|
|
614
587
|
}
|
|
615
588
|
|
|
616
589
|
async publish(titleInput: string, body: string): Promise<CreatePullRequestResult> {
|
|
617
|
-
|
|
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
|
+
) {
|
|
618
595
|
throw new Error("PR creation is not ready to publish metadata");
|
|
619
596
|
}
|
|
597
|
+
if (this.noTarget && !this.state.originalUpstream) {
|
|
598
|
+
throw new Error("PR creation original branch upstream is unavailable");
|
|
599
|
+
}
|
|
620
600
|
const title = requiredText(titleInput, "pull request title");
|
|
621
601
|
if (Buffer.byteLength(title, "utf8") > MAX_TITLE_BYTES) throw new Error(`Pull request title exceeds ${MAX_TITLE_BYTES} bytes`);
|
|
622
602
|
if (typeof body !== "string" || body.includes("\0") || Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) {
|
|
@@ -629,56 +609,66 @@ export class PullRequestCreator {
|
|
|
629
609
|
if (before && (before.base.repository.toLowerCase() !== this.state.base!.repository.toLowerCase() || before.base.ref !== this.state.base!.ref)) {
|
|
630
610
|
throw new Error("Exact-head pull request targets a different base");
|
|
631
611
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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);
|
|
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");
|
|
663
616
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
throw new Error("
|
|
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;
|
|
673
666
|
}
|
|
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
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 };
|
|
682
672
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
683
673
|
}
|
|
684
674
|
}
|
package/extensions/pr-github.ts
CHANGED
|
@@ -105,6 +105,24 @@ export type PullRequestObservation = {
|
|
|
105
105
|
|
|
106
106
|
export type PullRequestLoadContext = Pick<ExtensionContext, "cwd" | "signal">;
|
|
107
107
|
|
|
108
|
+
export type PullRequestCreationPreflight = {
|
|
109
|
+
head: string;
|
|
110
|
+
base: {
|
|
111
|
+
host: string;
|
|
112
|
+
repository: string;
|
|
113
|
+
fetchSource: string;
|
|
114
|
+
ref: string;
|
|
115
|
+
oid: string;
|
|
116
|
+
mergeBase: string;
|
|
117
|
+
};
|
|
118
|
+
ahead: number;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
type CreationIdentity = {
|
|
122
|
+
target: PullRequestTarget;
|
|
123
|
+
head: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
108
126
|
type CommandOutput = {
|
|
109
127
|
stdout: string;
|
|
110
128
|
stderr: string;
|
|
@@ -187,6 +205,11 @@ export type BranchUpstreamTarget = {
|
|
|
187
205
|
remoteOid: string;
|
|
188
206
|
};
|
|
189
207
|
|
|
208
|
+
export type BranchUpstreamConfiguration = {
|
|
209
|
+
remote: string[];
|
|
210
|
+
merge: string[];
|
|
211
|
+
};
|
|
212
|
+
|
|
190
213
|
type SearchSelection =
|
|
191
214
|
| { kind: "candidate"; candidate: SearchPullRequest; pullRequest: ListedPullRequest | null }
|
|
192
215
|
| { kind: "none" }
|
|
@@ -332,6 +355,12 @@ function parseCommandOutput(value: unknown, action: string): CommandOutput {
|
|
|
332
355
|
return { stdout, stderr, code, killed };
|
|
333
356
|
}
|
|
334
357
|
|
|
358
|
+
function hasExactKeys(value: Record<string, unknown>, keys: string[]): boolean {
|
|
359
|
+
const actual = Object.keys(value).sort();
|
|
360
|
+
const expected = [...keys].sort();
|
|
361
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
362
|
+
}
|
|
363
|
+
|
|
335
364
|
async function invoke(
|
|
336
365
|
pi: Pick<ExtensionAPI, "exec">,
|
|
337
366
|
context: PullRequestLoadContext,
|
|
@@ -1050,27 +1079,232 @@ function parseMergeMethodSettings(output: string, rulesetMethods: MergeMethod[]
|
|
|
1050
1079
|
return { allowedMergeMethods, viewerDefaultMergeMethod };
|
|
1051
1080
|
}
|
|
1052
1081
|
|
|
1053
|
-
|
|
1082
|
+
function validatedCreationTarget(target: PullRequestTarget): PullRequestTarget {
|
|
1083
|
+
if (!isRecord(target)) fail("Read creation target", "invalid target");
|
|
1084
|
+
if (target.provenance !== "configured" && target.provenance !== "inferred") {
|
|
1085
|
+
fail("Read creation target", "invalid provenance");
|
|
1086
|
+
}
|
|
1087
|
+
return {
|
|
1088
|
+
provenance: target.provenance,
|
|
1089
|
+
branch: text(target.branch, "Read creation target", "branch"),
|
|
1090
|
+
remote: text(target.remote, "Read creation target", "remote"),
|
|
1091
|
+
ref: text(target.ref, "Read creation target", "ref"),
|
|
1092
|
+
repository: repositoryName(target.repository, "Read creation target", "repository"),
|
|
1093
|
+
host: text(target.host, "Read creation target", "host").toLowerCase(),
|
|
1094
|
+
fetchSource: text(target.fetchSource, "Read creation target", "fetch source"),
|
|
1095
|
+
remoteOid: target.remoteOid === null ? null : oid(target.remoteOid, "Read creation target", "remote OID"),
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function sameCreationTarget(left: PullRequestTarget, right: PullRequestTarget): boolean {
|
|
1100
|
+
return left.provenance === right.provenance && left.branch === right.branch &&
|
|
1101
|
+
left.remote === right.remote && left.ref === right.ref &&
|
|
1102
|
+
normalizeRepository(left.repository) === normalizeRepository(right.repository) &&
|
|
1103
|
+
left.host === right.host && left.fetchSource === right.fetchSource && left.remoteOid === right.remoteOid;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
async function validateCreationRef(
|
|
1054
1107
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1055
1108
|
context: PullRequestLoadContext,
|
|
1056
|
-
|
|
1109
|
+
ref: string,
|
|
1110
|
+
): Promise<string> {
|
|
1111
|
+
const requested = text(ref, "Validate creation base", "ref");
|
|
1112
|
+
const checked = singleLine(
|
|
1113
|
+
(await execute(pi, context, "Validate creation base", "git", ["check-ref-format", "--branch", requested])).stdout,
|
|
1114
|
+
"Validate creation base",
|
|
1115
|
+
"ref",
|
|
1116
|
+
);
|
|
1117
|
+
if (checked !== requested) fail("Validate creation base", "ref changed");
|
|
1118
|
+
return requested;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
async function captureCreationIdentity(
|
|
1122
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1123
|
+
context: PullRequestLoadContext,
|
|
1124
|
+
target: PullRequestTarget,
|
|
1125
|
+
): Promise<CreationIdentity> {
|
|
1126
|
+
const validatedTarget = validatedCreationTarget(target);
|
|
1057
1127
|
const branch = singleLine(
|
|
1058
|
-
(await execute(pi, context, "Read
|
|
1059
|
-
"Read
|
|
1128
|
+
(await execute(pi, context, "Read creation branch", "git", ["branch", "--show-current"])).stdout,
|
|
1129
|
+
"Read creation branch",
|
|
1060
1130
|
"branch",
|
|
1061
1131
|
);
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
"--
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1132
|
+
if (branch !== validatedTarget.branch) fail("Read creation branch", "branch changed");
|
|
1133
|
+
await validateCreationRef(pi, context, branch);
|
|
1134
|
+
const head = oid(singleLine(
|
|
1135
|
+
(await execute(pi, context, "Read creation HEAD", "git", ["rev-parse", "--verify", "HEAD^{commit}"])).stdout,
|
|
1136
|
+
"Read creation HEAD",
|
|
1137
|
+
"OID",
|
|
1138
|
+
), "Read creation HEAD", "OID");
|
|
1139
|
+
return { target: validatedTarget, head };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async function readConfiguredCreationBaseRef(
|
|
1143
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1144
|
+
context: PullRequestLoadContext,
|
|
1145
|
+
branch: string,
|
|
1146
|
+
): Promise<string | null> {
|
|
1147
|
+
const result = await invoke(pi, context, "Read creation base configuration", "git", [
|
|
1148
|
+
"config", "--get-all", `branch.${branch}.gh-merge-base`,
|
|
1149
|
+
]);
|
|
1150
|
+
if (result.killed) commandFailure("Read creation base configuration", result);
|
|
1151
|
+
if (result.code === 1 && result.stdout === "" && result.stderr === "") return null;
|
|
1152
|
+
if (result.code !== 0) commandFailure("Read creation base configuration", result);
|
|
1153
|
+
if (result.stderr !== "") fail("Read creation base configuration", "unexpected diagnostic");
|
|
1154
|
+
const values = lines(result.stdout, "Read creation base configuration", "base ref");
|
|
1155
|
+
if (values.length !== 1) fail("Read creation base configuration", "multiple base refs");
|
|
1156
|
+
return await validateCreationRef(pi, context, values[0]!);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function parseDefaultCreationBaseRef(output: string): string {
|
|
1160
|
+
const value = parseJson(output, "Read creation default branch");
|
|
1161
|
+
if (!isRecord(value) || !hasExactKeys(value, ["defaultBranchRef"]) || !isRecord(value.defaultBranchRef) ||
|
|
1162
|
+
!hasExactKeys(value.defaultBranchRef, ["name"])) {
|
|
1163
|
+
fail("Read creation default branch", "invalid GitHub CLI output");
|
|
1164
|
+
}
|
|
1165
|
+
return text(value.defaultBranchRef.name, "Read creation default branch", "default branch ref");
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function readDefaultCreationBaseRef(
|
|
1169
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1170
|
+
context: PullRequestLoadContext,
|
|
1171
|
+
origin: { repository: PushRepository },
|
|
1172
|
+
): Promise<string> {
|
|
1173
|
+
const result = await execute(pi, context, "Read creation default branch", "gh", [
|
|
1174
|
+
"repo", "view", `${origin.repository.host}/${origin.repository.nameWithOwner}`, "--json", "defaultBranchRef",
|
|
1175
|
+
]);
|
|
1176
|
+
if (result.stderr !== "") fail("Read creation default branch", "unexpected diagnostic");
|
|
1177
|
+
return await validateCreationRef(pi, context, parseDefaultCreationBaseRef(result.stdout));
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function parseCreationRepositoryLineage(output: string, expected: PushRepository): string {
|
|
1181
|
+
const value = parseJson(output, "Read creation repository");
|
|
1182
|
+
if (!isRecord(value)) fail("Read creation repository", "invalid GitHub CLI output");
|
|
1183
|
+
const fullName = repositoryName(value.full_name, "Read creation repository", "full_name");
|
|
1184
|
+
const url = parseHttpUrl(value.html_url, "Read creation repository", "html_url");
|
|
1185
|
+
if (
|
|
1186
|
+
normalizeRepository(fullName) !== expected.normalizedName || url.protocol !== "https:" || url.port ||
|
|
1187
|
+
url.hostname.toLowerCase() !== expected.host || url.pathname.toLowerCase() !== `/${expected.normalizedName}`
|
|
1188
|
+
) fail("Read creation repository", "response does not match repository");
|
|
1189
|
+
const source = value.source;
|
|
1190
|
+
if (source !== undefined && source !== null && !isRecord(source)) {
|
|
1191
|
+
fail("Read creation repository", "invalid source");
|
|
1192
|
+
}
|
|
1193
|
+
const sourceName = source === undefined || source === null
|
|
1194
|
+
? fullName
|
|
1195
|
+
: repositoryName(source.full_name, "Read creation repository", "source.full_name");
|
|
1196
|
+
return normalizeRepository(sourceName);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
async function assertCreationRepositoryRelation(
|
|
1200
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1201
|
+
context: PullRequestLoadContext,
|
|
1202
|
+
target: PullRequestTarget,
|
|
1203
|
+
origin: { repository: PushRepository },
|
|
1204
|
+
baseRef: string,
|
|
1205
|
+
): Promise<void> {
|
|
1206
|
+
if (target.host !== origin.repository.host) {
|
|
1207
|
+
fail("Read creation repository", "base and head hosts do not match");
|
|
1208
|
+
}
|
|
1209
|
+
if (normalizeRepository(target.repository) === origin.repository.normalizedName) {
|
|
1210
|
+
if (target.ref === baseRef) fail("Read creation repository", "head and base refs match");
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const read = async (repository: PushRepository): Promise<string> => {
|
|
1214
|
+
const [owner, name] = repository.nameWithOwner.split("/");
|
|
1215
|
+
const result = await execute(pi, context, "Read creation repository", "gh", [
|
|
1216
|
+
"api", "--hostname", origin.repository.host, `repos/${owner}/${name}`,
|
|
1217
|
+
]);
|
|
1218
|
+
if (result.stderr !== "") fail("Read creation repository", "unexpected diagnostic");
|
|
1219
|
+
return parseCreationRepositoryLineage(result.stdout, repository);
|
|
1220
|
+
};
|
|
1221
|
+
const originSource = await read(origin.repository);
|
|
1222
|
+
const targetSource = await read({
|
|
1223
|
+
nameWithOwner: target.repository,
|
|
1224
|
+
normalizedName: normalizeRepository(target.repository),
|
|
1225
|
+
host: target.host,
|
|
1226
|
+
});
|
|
1227
|
+
if (originSource !== targetSource) fail("Read creation repository", "base and head are unrelated");
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function parseCreationAhead(output: string): number {
|
|
1231
|
+
const value = singleLine(output, "Count creation commits", "ahead count");
|
|
1232
|
+
if (!/^(?:0|[1-9][0-9]*)$/.test(value)) fail("Count creation commits", "invalid ahead count");
|
|
1233
|
+
const ahead = Number(value);
|
|
1234
|
+
if (!Number.isSafeInteger(ahead) || ahead < 0) fail("Count creation commits", "invalid ahead count");
|
|
1235
|
+
return ahead;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
async function preflightCreation(
|
|
1239
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1240
|
+
context: PullRequestLoadContext,
|
|
1241
|
+
target: PullRequestTarget,
|
|
1242
|
+
explicitBaseRef: string | undefined,
|
|
1243
|
+
identity: CreationIdentity,
|
|
1244
|
+
): Promise<PullRequestCreationPreflight> {
|
|
1245
|
+
const validatedTarget = validatedCreationTarget(target);
|
|
1246
|
+
if (!sameCreationTarget(identity.target, validatedTarget)) {
|
|
1247
|
+
fail("Read creation target", "target changed");
|
|
1248
|
+
}
|
|
1249
|
+
const origin = await readRemoteAuthority(pi, context, "origin", true);
|
|
1250
|
+
if (!origin) fail("Read creation repository", "origin is unavailable");
|
|
1251
|
+
const configuredBaseRef = explicitBaseRef === undefined
|
|
1252
|
+
? await readConfiguredCreationBaseRef(pi, context, identity.target.branch)
|
|
1253
|
+
: await validateCreationRef(pi, context, explicitBaseRef);
|
|
1254
|
+
const baseRef = configuredBaseRef ?? await readDefaultCreationBaseRef(pi, context, origin);
|
|
1255
|
+
await assertCreationRepositoryRelation(pi, context, identity.target, origin, baseRef);
|
|
1256
|
+
const trackingRef = `refs/remotes/origin/${baseRef}`;
|
|
1257
|
+
await execute(pi, context, "Fetch creation base", "git", [
|
|
1258
|
+
"fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", "--",
|
|
1259
|
+
origin.fetchSource, `+refs/heads/${baseRef}:${trackingRef}`,
|
|
1260
|
+
]);
|
|
1261
|
+
const baseOid = oid(singleLine(
|
|
1262
|
+
(await execute(pi, context, "Read creation base", "git", ["rev-parse", "--verify", `${trackingRef}^{commit}`])).stdout,
|
|
1263
|
+
"Read creation base",
|
|
1264
|
+
"OID",
|
|
1265
|
+
), "Read creation base", "OID");
|
|
1266
|
+
const mergeBase = oid(singleLine(
|
|
1267
|
+
(await execute(pi, context, "Find creation merge base", "git", ["merge-base", identity.head, baseOid])).stdout,
|
|
1268
|
+
"Find creation merge base",
|
|
1269
|
+
"OID",
|
|
1270
|
+
), "Find creation merge base", "OID");
|
|
1271
|
+
const ahead = parseCreationAhead((await execute(pi, context, "Count creation commits", "git", [
|
|
1272
|
+
"rev-list", "--count", `${mergeBase}..${identity.head}`,
|
|
1273
|
+
])).stdout);
|
|
1274
|
+
return {
|
|
1275
|
+
head: identity.head,
|
|
1276
|
+
base: {
|
|
1277
|
+
host: origin.repository.host,
|
|
1278
|
+
repository: origin.repository.nameWithOwner,
|
|
1279
|
+
fetchSource: origin.fetchSource,
|
|
1280
|
+
ref: baseRef,
|
|
1281
|
+
oid: baseOid,
|
|
1282
|
+
mergeBase,
|
|
1283
|
+
},
|
|
1284
|
+
ahead,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
export async function preflightPullRequestCreation(
|
|
1289
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1290
|
+
context: PullRequestLoadContext,
|
|
1291
|
+
target: PullRequestTarget,
|
|
1292
|
+
explicitBaseRef?: string,
|
|
1293
|
+
): Promise<PullRequestCreationPreflight> {
|
|
1294
|
+
const identity = await captureCreationIdentity(pi, context, target);
|
|
1295
|
+
return await preflightCreation(pi, context, target, explicitBaseRef, identity);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
async function creationDiscovery(
|
|
1299
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1300
|
+
context: PullRequestLoadContext,
|
|
1301
|
+
target: PullRequestTarget,
|
|
1302
|
+
explicitBaseRef: string | undefined,
|
|
1303
|
+
identity?: CreationIdentity,
|
|
1304
|
+
): Promise<CurrentPullRequestDiscovery> {
|
|
1305
|
+
const captured = identity ?? await captureCreationIdentity(pi, context, target);
|
|
1306
|
+
const preflight = await preflightCreation(pi, context, target, explicitBaseRef, captured);
|
|
1307
|
+
return { kind: "none", creationTarget: target, branch: { ahead: preflight.ahead } };
|
|
1074
1308
|
}
|
|
1075
1309
|
|
|
1076
1310
|
async function readRemoteAuthority(
|
|
@@ -1167,6 +1401,22 @@ async function readConfigValues(
|
|
|
1167
1401
|
}
|
|
1168
1402
|
}
|
|
1169
1403
|
|
|
1404
|
+
export async function readBranchUpstreamConfiguration(
|
|
1405
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1406
|
+
context: PullRequestLoadContext,
|
|
1407
|
+
branch: string,
|
|
1408
|
+
): Promise<BranchUpstreamConfiguration> {
|
|
1409
|
+
const checkedBranch = text(branch, "Read branch upstream", "branch");
|
|
1410
|
+
const [remote, merge] = await Promise.all([
|
|
1411
|
+
readConfigValues(pi, context, `branch.${checkedBranch}.remote`),
|
|
1412
|
+
readConfigValues(pi, context, `branch.${checkedBranch}.merge`),
|
|
1413
|
+
]);
|
|
1414
|
+
if (remote === null || merge === null) {
|
|
1415
|
+
throw new Error("Read branch upstream failed: invalid Git configuration");
|
|
1416
|
+
}
|
|
1417
|
+
return { remote, merge };
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1170
1420
|
async function readBooleanConfigValues(
|
|
1171
1421
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1172
1422
|
context: PullRequestLoadContext,
|
|
@@ -1694,6 +1944,18 @@ export async function loadCurrentPullRequest(
|
|
|
1694
1944
|
context: PullRequestLoadContext,
|
|
1695
1945
|
inspectedLocal?: LocalMergeSafety,
|
|
1696
1946
|
observed?: unknown,
|
|
1947
|
+
explicitCreationBase?: string,
|
|
1948
|
+
): Promise<CurrentPullRequestDiscovery> {
|
|
1949
|
+
return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed, explicitCreationBase);
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
async function loadCurrentPullRequestInternal(
|
|
1953
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1954
|
+
context: PullRequestLoadContext,
|
|
1955
|
+
inspectedLocal: LocalMergeSafety | undefined,
|
|
1956
|
+
observed: unknown,
|
|
1957
|
+
explicitCreationBase: string | undefined,
|
|
1958
|
+
creationIdentity?: CreationIdentity,
|
|
1697
1959
|
): Promise<CurrentPullRequestDiscovery> {
|
|
1698
1960
|
const read = await readPushTarget(pi, context);
|
|
1699
1961
|
if (read.kind === "inactive") return { kind: "inactive" };
|
|
@@ -1714,10 +1976,18 @@ export async function loadCurrentPullRequest(
|
|
|
1714
1976
|
};
|
|
1715
1977
|
}
|
|
1716
1978
|
if (inferred.kind === "none") {
|
|
1979
|
+
const target = publicTarget(inferred.target);
|
|
1980
|
+
if (creationIdentity === undefined) {
|
|
1981
|
+
const captured = await captureCreationIdentity(pi, context, target);
|
|
1982
|
+
return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed, explicitCreationBase, captured);
|
|
1983
|
+
}
|
|
1984
|
+
if (!sameCreationTarget(creationIdentity.target, validatedCreationTarget(target))) {
|
|
1985
|
+
fail("Read creation target", "target changed");
|
|
1986
|
+
}
|
|
1717
1987
|
if (!canLinkTarget(await readLinkConfiguration(pi, context, inferred.target), inferred.target)) {
|
|
1718
1988
|
return { kind: "blocked", issue: { kind: "link-configuration", remote: inferred.target.remote } };
|
|
1719
1989
|
}
|
|
1720
|
-
return
|
|
1990
|
+
return await creationDiscovery(pi, context, target, explicitCreationBase, creationIdentity);
|
|
1721
1991
|
}
|
|
1722
1992
|
pushTarget = inferred.target;
|
|
1723
1993
|
} else {
|
|
@@ -1814,7 +2084,10 @@ export async function loadCurrentPullRequest(
|
|
|
1814
2084
|
}
|
|
1815
2085
|
throw error;
|
|
1816
2086
|
}
|
|
1817
|
-
if (candidate === null)
|
|
2087
|
+
if (candidate === null) {
|
|
2088
|
+
if (creationIdentity !== undefined) fail("Read creation target", "target changed");
|
|
2089
|
+
return await creationDiscovery(pi, context, publicTarget(pushTarget), explicitCreationBase);
|
|
2090
|
+
}
|
|
1818
2091
|
}
|
|
1819
2092
|
|
|
1820
2093
|
return {
|
|
@@ -1942,6 +2215,42 @@ function sameConfigValues(left: readonly string[], right: readonly string[]): bo
|
|
|
1942
2215
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1943
2216
|
}
|
|
1944
2217
|
|
|
2218
|
+
export async function restoreBranchUpstreamConfiguration(
|
|
2219
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
2220
|
+
context: PullRequestLoadContext,
|
|
2221
|
+
target: Pick<BranchUpstreamTarget, "branch" | "remote" | "ref">,
|
|
2222
|
+
original: BranchUpstreamConfiguration,
|
|
2223
|
+
): Promise<void> {
|
|
2224
|
+
let incomplete = false;
|
|
2225
|
+
for (const [key, expected, values] of [
|
|
2226
|
+
[`branch.${target.branch}.remote`, target.remote, original.remote],
|
|
2227
|
+
[`branch.${target.branch}.merge`, `refs/heads/${target.ref}`, original.merge],
|
|
2228
|
+
] as const) {
|
|
2229
|
+
try {
|
|
2230
|
+
const current = await readConfigValues(pi, context, key);
|
|
2231
|
+
if (current === null) incomplete = true;
|
|
2232
|
+
else if (sameConfigValues(current, values)) continue;
|
|
2233
|
+
else if (current.length === 1 && current[0] === expected) {
|
|
2234
|
+
await restoreConfigValue(pi, context, key, expected, values);
|
|
2235
|
+
} else incomplete = true;
|
|
2236
|
+
} catch {
|
|
2237
|
+
incomplete = true;
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
for (const [key, values] of [
|
|
2241
|
+
[`branch.${target.branch}.remote`, original.remote],
|
|
2242
|
+
[`branch.${target.branch}.merge`, original.merge],
|
|
2243
|
+
] as const) {
|
|
2244
|
+
try {
|
|
2245
|
+
const current = await readConfigValues(pi, context, key);
|
|
2246
|
+
if (current === null || !sameConfigValues(current, values)) incomplete = true;
|
|
2247
|
+
} catch {
|
|
2248
|
+
incomplete = true;
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
if (incomplete) throw new Error("Restore branch upstream failed and rollback was incomplete");
|
|
2252
|
+
}
|
|
2253
|
+
|
|
1945
2254
|
async function restoreLinkState(
|
|
1946
2255
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1947
2256
|
context: PullRequestLoadContext,
|
package/extensions/pr-routing.ts
CHANGED
|
@@ -38,6 +38,10 @@ export type PullRequestTarget = {
|
|
|
38
38
|
remoteOid: string | null;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
export type BranchCreationState = {
|
|
42
|
+
ahead: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
41
45
|
export type DiscoveryIssue =
|
|
42
46
|
| { kind: "detached-head" }
|
|
43
47
|
| { kind: "target-invalid" }
|
|
@@ -50,7 +54,7 @@ export type DiscoveryIssue =
|
|
|
50
54
|
|
|
51
55
|
export type PullRequestDiscovery<T extends PullRequest = PullRequest> =
|
|
52
56
|
| { kind: "current"; pullRequest: T }
|
|
53
|
-
| { kind: "none"; creationTarget: PullRequestTarget }
|
|
57
|
+
| { kind: "none"; creationTarget: PullRequestTarget; branch: BranchCreationState }
|
|
54
58
|
| { kind: "blocked"; issue: DiscoveryIssue }
|
|
55
59
|
| { kind: "inactive" };
|
|
56
60
|
|
|
@@ -87,7 +91,7 @@ export function derivePullRequestNextStep(pullRequest: PullRequest): Exclude<Nex
|
|
|
87
91
|
export function deriveNextStep(discovery: PullRequestDiscovery<PullRequest & { target: PullRequestTarget }>): NextStep {
|
|
88
92
|
if (discovery.kind === "inactive") return "none";
|
|
89
93
|
if (discovery.kind === "blocked") return "blocked";
|
|
90
|
-
if (discovery.kind === "none") return "create";
|
|
94
|
+
if (discovery.kind === "none") return discovery.branch.ahead > 0 ? "create" : "none";
|
|
91
95
|
if (discovery.pullRequest.target.provenance === "inferred") {
|
|
92
96
|
return discovery.pullRequest.lifecycle === "open" ? "link-branch" : "none";
|
|
93
97
|
}
|
package/extensions/pr-ui.ts
CHANGED
|
@@ -114,7 +114,6 @@ function widgetText(input: PrDisplayInput, nextStep: NextStep): string | undefin
|
|
|
114
114
|
|
|
115
115
|
export function projectPrDisplay(
|
|
116
116
|
discovery: PullRequestDiscovery<PrDisplayInput>,
|
|
117
|
-
hasLocalCommit = false,
|
|
118
117
|
): PrDisplay {
|
|
119
118
|
const nextStep = deriveNextStep(discovery);
|
|
120
119
|
if (discovery.kind === "inactive") return { nextStep };
|
|
@@ -132,7 +131,7 @@ export function projectPrDisplay(
|
|
|
132
131
|
if (discovery.kind === "none") {
|
|
133
132
|
return {
|
|
134
133
|
nextStep,
|
|
135
|
-
widget:
|
|
134
|
+
widget: nextStep === "create" ? "Run /pr to create pull request" : undefined,
|
|
136
135
|
};
|
|
137
136
|
}
|
|
138
137
|
|
package/extensions/pr.ts
CHANGED
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
} from "./pr-command.ts";
|
|
20
20
|
import { PullRequestCreator, type CreatePullRequestOptions } from "./pr-create.ts";
|
|
21
21
|
import {
|
|
22
|
-
hasLocalCommit,
|
|
23
22
|
loadCurrentPullRequest,
|
|
24
23
|
parsePullRequestObservation,
|
|
25
24
|
pullRequestObservation,
|
|
@@ -161,7 +160,6 @@ type WorkflowContext =
|
|
|
161
160
|
|
|
162
161
|
type PullRequestExtensionDependencies = {
|
|
163
162
|
loadCurrentPullRequest?: typeof loadCurrentPullRequest;
|
|
164
|
-
hasLocalCommit?: typeof hasLocalCommit;
|
|
165
163
|
createPrCommandHandler?: typeof createPrCommandHandler;
|
|
166
164
|
createBranchUpdater?: (options: UpdateBranchOptions) => UpdateBranchWorkflow;
|
|
167
165
|
createPullRequestCreator?: (options: CreatePullRequestOptions) => CreateWorkflow;
|
|
@@ -255,7 +253,6 @@ export default function pullRequestExtension(
|
|
|
255
253
|
};
|
|
256
254
|
|
|
257
255
|
const discover = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
|
|
258
|
-
const detectLocalCommit = dependencies.hasLocalCommit ?? hasLocalCommit;
|
|
259
256
|
const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
|
|
260
257
|
const createBranchUpdater = dependencies.createBranchUpdater ?? ((options) => new PullRequestBranchUpdater(options));
|
|
261
258
|
const createPullRequestCreator = dependencies.createPullRequestCreator ?? ((options) => new PullRequestCreator(options));
|
|
@@ -265,9 +262,15 @@ export default function pullRequestExtension(
|
|
|
265
262
|
const newRunId = dependencies.newRunId ?? randomUUID;
|
|
266
263
|
let context: ExtensionContext | undefined;
|
|
267
264
|
let observation: PullRequestObservation | undefined;
|
|
268
|
-
const load: typeof loadCurrentPullRequest = async (
|
|
265
|
+
const load: typeof loadCurrentPullRequest = async (
|
|
266
|
+
api,
|
|
267
|
+
loadContext,
|
|
268
|
+
inspectedLocal,
|
|
269
|
+
_observed,
|
|
270
|
+
explicitCreationBase,
|
|
271
|
+
) => {
|
|
269
272
|
const generation = sessionGeneration;
|
|
270
|
-
const discovery = await discover(api, loadContext, inspectedLocal, observation);
|
|
273
|
+
const discovery = await discover(api, loadContext, inspectedLocal, observation, explicitCreationBase);
|
|
271
274
|
if (generation !== sessionGeneration) return discovery;
|
|
272
275
|
if (discovery.kind === "current") {
|
|
273
276
|
const current = pullRequestObservation(discovery.pullRequest);
|
|
@@ -288,7 +291,6 @@ export default function pullRequestExtension(
|
|
|
288
291
|
let lastBlockedIssueKey: string | undefined;
|
|
289
292
|
let delegatedWorkPending = false;
|
|
290
293
|
let pendingWorkspaceRename = false;
|
|
291
|
-
let mergeCompleted = false;
|
|
292
294
|
let displayedWidget: PrDisplay | undefined;
|
|
293
295
|
let commandGeneration = 0;
|
|
294
296
|
let workflowContext: WorkflowContext | undefined;
|
|
@@ -573,7 +575,6 @@ export default function pullRequestExtension(
|
|
|
573
575
|
const render = (
|
|
574
576
|
ctx: ExtensionContext,
|
|
575
577
|
discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
|
|
576
|
-
localCommit: boolean,
|
|
577
578
|
): void => {
|
|
578
579
|
if (discovery.kind === "inactive") {
|
|
579
580
|
if (timer !== undefined) clearInterval(timer);
|
|
@@ -585,7 +586,7 @@ export default function pullRequestExtension(
|
|
|
585
586
|
reconcileWidget(ctx);
|
|
586
587
|
return;
|
|
587
588
|
}
|
|
588
|
-
const display = projectPrDisplay(discovery
|
|
589
|
+
const display = projectPrDisplay(discovery);
|
|
589
590
|
const footer = formatPrFooter(display, ctx.ui.theme);
|
|
590
591
|
if ((discovery.kind === "current" || discovery.kind === "blocked") && footer === undefined) {
|
|
591
592
|
throw new Error("Pull request display is missing a footer");
|
|
@@ -621,7 +622,6 @@ export default function pullRequestExtension(
|
|
|
621
622
|
lastBlockedIssueKey = undefined;
|
|
622
623
|
delegatedWorkPending = false;
|
|
623
624
|
pendingWorkspaceRename = false;
|
|
624
|
-
mergeCompleted = false;
|
|
625
625
|
displayedWidget = undefined;
|
|
626
626
|
commandGeneration = 0;
|
|
627
627
|
clearWorkflow(workflowContext);
|
|
@@ -668,21 +668,17 @@ export default function pullRequestExtension(
|
|
|
668
668
|
active = controller;
|
|
669
669
|
try {
|
|
670
670
|
let discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>;
|
|
671
|
-
let localCommit = false;
|
|
672
671
|
try {
|
|
673
672
|
discovery = await load(pi, loadContext);
|
|
674
673
|
if (controller.signal.aborted || sessionGeneration !== generation) return;
|
|
675
|
-
if (discovery.kind === "none") {
|
|
676
|
-
localCommit = !mergeCompleted && await detectLocalCommit(pi, loadContext);
|
|
677
|
-
}
|
|
678
674
|
} catch {
|
|
679
|
-
// Keep an established
|
|
675
|
+
// Keep an established footer. A refresh failure must not leave a stale action hint.
|
|
680
676
|
if (!controller.signal.aborted && sessionGeneration === generation) {
|
|
677
|
+
displayedWidget = undefined;
|
|
678
|
+
reconcileWidget(ctx);
|
|
681
679
|
if (!displayEstablished) {
|
|
682
680
|
const unavailable = unavailablePrDisplay();
|
|
683
|
-
displayedWidget = undefined;
|
|
684
681
|
ctx.ui.setStatus(UI_KEY, formatPrFooter(unavailable, ctx.ui.theme));
|
|
685
|
-
reconcileWidget(ctx);
|
|
686
682
|
displayEstablished = true;
|
|
687
683
|
}
|
|
688
684
|
reportRefreshFailure();
|
|
@@ -690,7 +686,7 @@ export default function pullRequestExtension(
|
|
|
690
686
|
return;
|
|
691
687
|
}
|
|
692
688
|
if (controller.signal.aborted || sessionGeneration !== generation) return;
|
|
693
|
-
render(ctx, discovery
|
|
689
|
+
render(ctx, discovery);
|
|
694
690
|
refreshFailureReported = false;
|
|
695
691
|
|
|
696
692
|
const pullRequest = discovery.kind === "current" ? discovery.pullRequest : undefined;
|
|
@@ -786,7 +782,6 @@ export default function pullRequestExtension(
|
|
|
786
782
|
if (!isBashToolResult(event)) return;
|
|
787
783
|
const command = event.input.command;
|
|
788
784
|
if (typeof command === "string" && (GH_PR_CREATE.test(command) || GIT_COMMIT.test(command) || GIT_PUSH.test(command))) {
|
|
789
|
-
if (GIT_COMMIT.test(command)) mergeCompleted = false;
|
|
790
785
|
await refresh().catch(reportRefreshFailure);
|
|
791
786
|
}
|
|
792
787
|
});
|
|
@@ -798,7 +793,7 @@ export default function pullRequestExtension(
|
|
|
798
793
|
releaseWorkflow,
|
|
799
794
|
});
|
|
800
795
|
pi.registerCommand("pr", {
|
|
801
|
-
description: "[--base
|
|
796
|
+
description: "[--base <branch>] [instructions] — Run the current branch pull request next step",
|
|
802
797
|
handler: async (args, ctx) => {
|
|
803
798
|
if (!ctx.hasUI || !context) return;
|
|
804
799
|
const generation = sessionGeneration;
|
|
@@ -838,7 +833,6 @@ export default function pullRequestExtension(
|
|
|
838
833
|
if (nextStep === "create") ctx.ui.setStatus(UI_KEY, undefined);
|
|
839
834
|
reconcileWidget(ctx);
|
|
840
835
|
} else {
|
|
841
|
-
if (nextStep === "merge") mergeCompleted = true;
|
|
842
836
|
activeInvocations.delete(invocation);
|
|
843
837
|
refreshInBackground();
|
|
844
838
|
}
|
package/package.json
CHANGED
|
@@ -5,12 +5,12 @@ description: Prepare and publish the current branch pull request with determinis
|
|
|
5
5
|
|
|
6
6
|
# Pi PR Create
|
|
7
7
|
|
|
8
|
-
Use
|
|
8
|
+
Use only `pi_pr_create` for base selection, merge, push, upstream, and GitHub work. Do not repeat its Git or GitHub checks with shell tools.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Start with the prompted `prepare` action. It uses the saved branch base: explicit `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then validated `origin` default. It requires a committed change ahead. Dirty work alone cannot start this route. The base is always `origin`; fork heads must share its GitHub source and host. Apply trailing prompt guidance only to the PR content.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
After `prepare`, inspect the returned base and merge-base. Separate and commit coherent pending work. Preserve coherent staging. Exclude `.context/` and unrelated changes. Stop when separation is unsafe.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Use this action order: `prepare`, `merge`, optional `continue`, `push`, then `publish`. On conflict, inspect only returned paths and bounded hunks. Give `continue` every resolved path. Run the smallest relevant validation after the helper verifies the merge.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Give `publish` a concise Conventional Commit title and a body with `Summary` and `Testing`. It validates the exact PR before no-target upstream setup. If that setup fails after publication, retry `publish` with the same title and body. Do not push again or create another PR. Reply only with the validated PR URL.
|