@henryqw/pi-pr 4.0.2 → 4.0.4
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 +12 -10
- package/extensions/pr-ci.ts +0 -1
- package/extensions/pr-command.ts +36 -27
- package/extensions/pr-comment-sweep.ts +0 -27
- package/extensions/pr-create.ts +76 -230
- package/extensions/pr-execution.ts +28 -163
- package/extensions/pr-github.ts +339 -193
- package/extensions/pr-merge.ts +24 -111
- package/extensions/pr-routing.ts +6 -2
- package/extensions/pr-ui.ts +1 -2
- package/extensions/pr-update-branch.ts +28 -90
- package/extensions/pr.ts +36 -58
- package/package.json +6 -2
- package/skills/pi-pr-create/SKILL.md +5 -5
package/extensions/pr-merge.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
inspectWorktree,
|
|
3
|
+
readHead,
|
|
4
|
+
requiredOid,
|
|
5
|
+
requiredText,
|
|
6
|
+
runChecked,
|
|
7
|
+
type Exec,
|
|
8
|
+
} from "./pr-execution.ts";
|
|
2
9
|
import type { LocalMergeSafety } from "./pr-routing.ts";
|
|
3
10
|
|
|
4
|
-
export type { Exec, ExecResult } from "./pr-execution.ts";
|
|
5
|
-
|
|
6
|
-
export type MergeMethod = "merge" | "rebase" | "squash";
|
|
7
|
-
|
|
8
11
|
const MERGE_PULL_REQUEST_MUTATION = "mutation($pullRequestId:ID!,$expectedHeadOid:GitObjectID!,$mergeMethod:PullRequestMergeMethod!){mergePullRequest(input:{pullRequestId:$pullRequestId,expectedHeadOid:$expectedHeadOid,mergeMethod:$mergeMethod}){pullRequest{id state}}}";
|
|
9
|
-
const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
10
12
|
|
|
11
13
|
export type InspectLocalMergeSafetyInput = {
|
|
12
14
|
exec: Exec;
|
|
@@ -15,16 +17,11 @@ export type InspectLocalMergeSafetyInput = {
|
|
|
15
17
|
headFetchSource: string;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
|
-
export type MergeMethodSelectionInput = {
|
|
19
|
-
allowedMergeMethods: readonly MergeMethod[];
|
|
20
|
-
viewerDefaultMergeMethod?: MergeMethod | null;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
20
|
export type InspectedLocalMergeSafety = LocalMergeSafety & {
|
|
24
21
|
headOid: string;
|
|
25
22
|
};
|
|
26
23
|
|
|
27
|
-
export type ExecuteGitHubMergeInput =
|
|
24
|
+
export type ExecuteGitHubMergeInput = InspectLocalMergeSafetyInput & {
|
|
28
25
|
pullRequestId: string;
|
|
29
26
|
hostname: string;
|
|
30
27
|
expectedBase: {
|
|
@@ -35,62 +32,10 @@ export type ExecuteGitHubMergeInput = MergeMethodSelectionInput & InspectLocalMe
|
|
|
35
32
|
revalidateReadiness: (local: InspectedLocalMergeSafety) => Promise<void>;
|
|
36
33
|
};
|
|
37
34
|
|
|
38
|
-
function commandText(command: string, args: string[]): string {
|
|
39
|
-
return [command, ...args].join(" ");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function errorText(error: unknown): string {
|
|
43
|
-
return error instanceof Error ? error.message : String(error);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function commandFailure(command: string, args: string[], detail: string): Error {
|
|
47
|
-
return new Error(`${commandText(command, args)} failed: ${detail}`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function runCommand(
|
|
51
|
-
exec: Exec,
|
|
52
|
-
cwd: string,
|
|
53
|
-
command: string,
|
|
54
|
-
args: string[],
|
|
55
|
-
allowedCodes: readonly number[] = [0],
|
|
56
|
-
): Promise<ExecResult> {
|
|
57
|
-
let result: ExecResult;
|
|
58
|
-
try {
|
|
59
|
-
result = await exec(command, args, { cwd });
|
|
60
|
-
} catch (error) {
|
|
61
|
-
throw commandFailure(command, args, errorText(error));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (result.killed || !allowedCodes.includes(result.code)) {
|
|
65
|
-
const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was killed" : `exit code ${result.code}`);
|
|
66
|
-
throw commandFailure(command, args, detail);
|
|
67
|
-
}
|
|
68
|
-
return result;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function requiredText(value: unknown, label: string): string {
|
|
72
|
-
if (typeof value !== "string" || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
73
|
-
throw new TypeError(`${label} must be a non-empty string`);
|
|
74
|
-
}
|
|
75
|
-
return value;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function requiredOid(value: unknown, label: string): string {
|
|
79
|
-
const parsed = requiredText(value, label);
|
|
80
|
-
if (!OID.test(parsed)) throw new TypeError(`${label} must be a full Git OID`);
|
|
81
|
-
return parsed;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
35
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
85
36
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
86
37
|
}
|
|
87
38
|
|
|
88
|
-
function requiredOutput(result: ExecResult, label: string): string {
|
|
89
|
-
const output = result.stdout.trim();
|
|
90
|
-
if (!output) throw new Error(`${label} returned no output`);
|
|
91
|
-
return output;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
39
|
function validateInspectionInput(input: InspectLocalMergeSafetyInput): void {
|
|
95
40
|
requiredText(input.cwd, "cwd");
|
|
96
41
|
requiredOid(input.expectedHead, "expected PR head");
|
|
@@ -98,76 +43,43 @@ function validateInspectionInput(input: InspectLocalMergeSafetyInput): void {
|
|
|
98
43
|
if (typeof input.exec !== "function") throw new TypeError("exec must be a function");
|
|
99
44
|
}
|
|
100
45
|
|
|
101
|
-
async function readLocalHead(exec: Exec, cwd: string): Promise<string> {
|
|
102
|
-
return requiredOid(
|
|
103
|
-
requiredOutput(await runCommand(exec, cwd, "git", ["rev-parse", "--verify", "HEAD^{commit}"]), "local HEAD"),
|
|
104
|
-
"local HEAD",
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
46
|
/** Inspect local state without changing branches, the index, or the worktree. */
|
|
109
47
|
export async function inspectLocalMergeSafety(input: InspectLocalMergeSafetyInput): Promise<InspectedLocalMergeSafety> {
|
|
110
48
|
validateInspectionInput(input);
|
|
111
49
|
|
|
112
50
|
const worktree = await inspectWorktree(input.exec, { cwd: input.cwd });
|
|
113
|
-
await
|
|
51
|
+
await runChecked(input.exec, "git", [
|
|
114
52
|
"fetch",
|
|
115
53
|
"--no-write-fetch-head",
|
|
116
54
|
"--no-tags",
|
|
117
55
|
"--no-recurse-submodules",
|
|
118
56
|
input.headFetchSource,
|
|
119
57
|
input.expectedHead,
|
|
120
|
-
]);
|
|
121
|
-
await
|
|
58
|
+
], { cwd: input.cwd });
|
|
59
|
+
await runChecked(input.exec, "git", ["cat-file", "-e", `${input.expectedHead}^{commit}`], { cwd: input.cwd });
|
|
122
60
|
|
|
123
|
-
const headOid = await
|
|
61
|
+
const headOid = await readHead(input.exec, { cwd: input.cwd });
|
|
124
62
|
if (headOid === input.expectedHead) return { worktree, head: "equal", headOid };
|
|
125
63
|
|
|
126
|
-
const localAncestor = await
|
|
64
|
+
const localAncestor = await runChecked(
|
|
127
65
|
input.exec,
|
|
128
|
-
input.cwd,
|
|
129
66
|
"git",
|
|
130
67
|
["merge-base", "--is-ancestor", headOid, input.expectedHead],
|
|
68
|
+
{ cwd: input.cwd },
|
|
131
69
|
[0, 1],
|
|
132
70
|
);
|
|
133
71
|
if (localAncestor.code === 0) return { worktree, head: "behind", headOid };
|
|
134
72
|
|
|
135
|
-
const expectedAncestor = await
|
|
73
|
+
const expectedAncestor = await runChecked(
|
|
136
74
|
input.exec,
|
|
137
|
-
input.cwd,
|
|
138
75
|
"git",
|
|
139
76
|
["merge-base", "--is-ancestor", input.expectedHead, headOid],
|
|
77
|
+
{ cwd: input.cwd },
|
|
140
78
|
[0, 1],
|
|
141
79
|
);
|
|
142
80
|
return { worktree, head: expectedAncestor.code === 0 ? "ahead" : "diverged", headOid };
|
|
143
81
|
}
|
|
144
82
|
|
|
145
|
-
function validateMergeMethods(input: MergeMethodSelectionInput): void {
|
|
146
|
-
if (!Array.isArray(input.allowedMergeMethods)) throw new TypeError("allowedMergeMethods must be an array");
|
|
147
|
-
if (input.allowedMergeMethods.some((method) => method !== "merge" && method !== "rebase" && method !== "squash")) {
|
|
148
|
-
throw new TypeError("allowedMergeMethods contains an unsupported method");
|
|
149
|
-
}
|
|
150
|
-
if (
|
|
151
|
-
input.viewerDefaultMergeMethod !== undefined &&
|
|
152
|
-
input.viewerDefaultMergeMethod !== null &&
|
|
153
|
-
input.viewerDefaultMergeMethod !== "merge" &&
|
|
154
|
-
input.viewerDefaultMergeMethod !== "rebase" &&
|
|
155
|
-
input.viewerDefaultMergeMethod !== "squash"
|
|
156
|
-
) throw new TypeError("viewerDefaultMergeMethod is unsupported");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export function selectMergeMethod(input: MergeMethodSelectionInput): MergeMethod {
|
|
160
|
-
validateMergeMethods(input);
|
|
161
|
-
if (input.allowedMergeMethods.length === 1) return input.allowedMergeMethods[0]!;
|
|
162
|
-
if (input.allowedMergeMethods.includes("squash")) return "squash";
|
|
163
|
-
if (
|
|
164
|
-
input.viewerDefaultMergeMethod !== undefined &&
|
|
165
|
-
input.viewerDefaultMergeMethod !== null &&
|
|
166
|
-
input.allowedMergeMethods.includes(input.viewerDefaultMergeMethod)
|
|
167
|
-
) return input.viewerDefaultMergeMethod;
|
|
168
|
-
throw new Error("No deterministic GitHub merge method is available");
|
|
169
|
-
}
|
|
170
|
-
|
|
171
83
|
function validateExecuteInput(input: ExecuteGitHubMergeInput): void {
|
|
172
84
|
validateInspectionInput(input);
|
|
173
85
|
requiredText(input.pullRequestId, "pullRequestId");
|
|
@@ -216,7 +128,6 @@ function parseMergeResponse(output: string, expectedId: string): void {
|
|
|
216
128
|
|
|
217
129
|
export async function executeGitHubMerge(input: ExecuteGitHubMergeInput): Promise<void> {
|
|
218
130
|
validateExecuteInput(input);
|
|
219
|
-
const method = selectMergeMethod(input);
|
|
220
131
|
const local = await inspectLocalMergeSafety(input);
|
|
221
132
|
if (local.worktree !== "clean" || (local.head !== "equal" && local.head !== "behind")) {
|
|
222
133
|
throw new Error(`Local merge safety check failed: worktree is ${local.worktree}, HEAD is ${local.head}`);
|
|
@@ -226,11 +137,11 @@ export async function executeGitHubMerge(input: ExecuteGitHubMergeInput): Promis
|
|
|
226
137
|
if (finalWorktree !== "clean") {
|
|
227
138
|
throw new Error("Final local merge safety check failed: worktree is dirty");
|
|
228
139
|
}
|
|
229
|
-
const finalHead = await
|
|
140
|
+
const finalHead = await readHead(input.exec, { cwd: input.cwd });
|
|
230
141
|
if (finalHead !== local.headOid) {
|
|
231
142
|
throw new Error(`Final local merge safety check failed: HEAD changed from ${local.headOid} to ${finalHead}`);
|
|
232
143
|
}
|
|
233
|
-
const merged = await
|
|
144
|
+
const merged = await runChecked(input.exec, "gh", [
|
|
234
145
|
"api",
|
|
235
146
|
"graphql",
|
|
236
147
|
"--hostname",
|
|
@@ -242,7 +153,9 @@ export async function executeGitHubMerge(input: ExecuteGitHubMergeInput): Promis
|
|
|
242
153
|
"-F",
|
|
243
154
|
`expectedHeadOid=${input.expectedHead}`,
|
|
244
155
|
"-F",
|
|
245
|
-
|
|
246
|
-
]);
|
|
247
|
-
|
|
156
|
+
"mergeMethod=SQUASH",
|
|
157
|
+
], { cwd: input.cwd });
|
|
158
|
+
const output = merged.stdout.trim();
|
|
159
|
+
if (!output) throw new Error("GitHub merge returned no output");
|
|
160
|
+
parseMergeResponse(output, input.pullRequestId);
|
|
248
161
|
}
|
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
|
|
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
runChecked,
|
|
18
18
|
spawnBounded,
|
|
19
19
|
withWorktreeLock,
|
|
20
|
-
type AttemptState,
|
|
21
20
|
type Exec,
|
|
22
21
|
type ExecOptions,
|
|
23
22
|
} from "./pr-execution.ts";
|
|
@@ -26,13 +25,6 @@ export type UpdateBranchPhase = "ready" | "conflict-awaiting-user" | "verified"
|
|
|
26
25
|
|
|
27
26
|
export type UpdateBranchState = {
|
|
28
27
|
phase: UpdateBranchPhase;
|
|
29
|
-
attempts: {
|
|
30
|
-
fetchBase: AttemptState;
|
|
31
|
-
merge: AttemptState;
|
|
32
|
-
stage: AttemptState;
|
|
33
|
-
continueMerge: AttemptState;
|
|
34
|
-
push: AttemptState;
|
|
35
|
-
};
|
|
36
28
|
verifiedHead?: string;
|
|
37
29
|
conflict?: { paths: string[]; statusBaseline: string };
|
|
38
30
|
};
|
|
@@ -62,7 +54,6 @@ function cloneAuthority(value: CurrentPullRequest): CurrentPullRequest {
|
|
|
62
54
|
base: { ...value.base },
|
|
63
55
|
head: { ...value.head },
|
|
64
56
|
target: { ...value.target },
|
|
65
|
-
merge: value.merge ? { ...value.merge, allowedMergeMethods: [...value.merge.allowedMergeMethods] } : null,
|
|
66
57
|
};
|
|
67
58
|
}
|
|
68
59
|
|
|
@@ -89,10 +80,7 @@ function validateDeclaredPaths(paths: readonly string[], expected: readonly stri
|
|
|
89
80
|
}
|
|
90
81
|
|
|
91
82
|
export class PullRequestBranchUpdater {
|
|
92
|
-
readonly state: UpdateBranchState = {
|
|
93
|
-
phase: "ready",
|
|
94
|
-
attempts: { fetchBase: "none", merge: "none", stage: "none", continueMerge: "none", push: "none" },
|
|
95
|
-
};
|
|
83
|
+
readonly state: UpdateBranchState = { phase: "ready" };
|
|
96
84
|
|
|
97
85
|
private readonly cwd: string;
|
|
98
86
|
private readonly authority: CurrentPullRequest;
|
|
@@ -188,26 +176,18 @@ export class PullRequestBranchUpdater {
|
|
|
188
176
|
}
|
|
189
177
|
|
|
190
178
|
async merge(): Promise<UpdateBranchResult> {
|
|
191
|
-
if (this.state.phase !== "ready"
|
|
192
|
-
throw new Error("Branch update merge action was already consumed");
|
|
193
|
-
}
|
|
179
|
+
if (this.state.phase !== "ready") throw new Error("Branch update merge action was already consumed");
|
|
194
180
|
return await withWorktreeLock(this.cwd, async () => {
|
|
195
181
|
await this.freshAuthority(this.authority.head.oid, true);
|
|
196
182
|
const source = await resolveRepositoryFetchSource(this.exec, this.execOptions(), {
|
|
197
183
|
host: this.authority.host,
|
|
198
184
|
repository: this.authority.base.repository,
|
|
199
185
|
});
|
|
200
|
-
this.state.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
await runChecked(this.exec, "git", ["cat-file", "-e", `${this.authority.base.oid}^{commit}`], this.execOptions());
|
|
206
|
-
this.state.attempts.fetchBase = "applied";
|
|
207
|
-
} catch (error) {
|
|
208
|
-
this.state.attempts.fetchBase = "unknown";
|
|
209
|
-
throw error;
|
|
210
|
-
}
|
|
186
|
+
this.state.phase = "blocked";
|
|
187
|
+
await runChecked(this.exec, "git", [
|
|
188
|
+
"fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", source, this.authority.base.oid,
|
|
189
|
+
], this.execOptions());
|
|
190
|
+
await runChecked(this.exec, "git", ["cat-file", "-e", `${this.authority.base.oid}^{commit}`], this.execOptions());
|
|
211
191
|
await this.freshAuthority(this.authority.head.oid, true);
|
|
212
192
|
if (await isAncestor(this.exec, this.execOptions(), this.authority.base.oid, this.authority.head.oid)) {
|
|
213
193
|
this.state.phase = "verified";
|
|
@@ -215,35 +195,16 @@ export class PullRequestBranchUpdater {
|
|
|
215
195
|
return { kind: "verified", head: this.authority.head.oid, fastForward: false };
|
|
216
196
|
}
|
|
217
197
|
|
|
218
|
-
this.
|
|
219
|
-
|
|
220
|
-
try {
|
|
221
|
-
result = await this.exec("git", ["merge", "--no-edit", this.authority.base.oid], this.execOptions());
|
|
222
|
-
} catch (error) {
|
|
223
|
-
this.state.attempts.merge = "unknown";
|
|
224
|
-
throw error;
|
|
225
|
-
}
|
|
226
|
-
if (result.killed) {
|
|
227
|
-
this.state.attempts.merge = "unknown";
|
|
228
|
-
throw new Error("git merge was killed; its outcome is unknown");
|
|
229
|
-
}
|
|
198
|
+
const result = await this.exec("git", ["merge", "--no-edit", this.authority.base.oid], this.execOptions());
|
|
199
|
+
if (result.killed) throw new Error("git merge was killed; its outcome is unknown");
|
|
230
200
|
if (result.code === 0) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
this.state.attempts.merge = "applied";
|
|
234
|
-
return { kind: "verified", ...verified };
|
|
235
|
-
} catch (error) {
|
|
236
|
-
this.state.attempts.merge = "unknown";
|
|
237
|
-
throw error;
|
|
238
|
-
}
|
|
201
|
+
const verified = await this.verifyMerge(this.authority.head.oid);
|
|
202
|
+
return { kind: "verified", ...verified };
|
|
239
203
|
}
|
|
240
204
|
try {
|
|
241
205
|
const paths = await this.captureConflict();
|
|
242
|
-
this.state.attempts.merge = "applied";
|
|
243
206
|
return { kind: "conflict", paths: [...paths] };
|
|
244
207
|
} catch (error) {
|
|
245
|
-
this.state.attempts.merge = "blocked";
|
|
246
|
-
this.state.phase = "blocked";
|
|
247
208
|
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
248
209
|
throw new Error(`git merge failed: ${detail}; ${error instanceof Error ? error.message : String(error)}`);
|
|
249
210
|
}
|
|
@@ -262,36 +223,19 @@ export class PullRequestBranchUpdater {
|
|
|
262
223
|
const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions());
|
|
263
224
|
assertOnlyDeclaredStatusChanged(this.state.conflict!.statusBaseline, status.stdout, paths);
|
|
264
225
|
|
|
265
|
-
|
|
266
|
-
this.
|
|
267
|
-
try {
|
|
268
|
-
await runChecked(this.exec, "git", ["add", "--", ...paths], this.execOptions());
|
|
269
|
-
this.state.attempts.stage = "applied";
|
|
270
|
-
} catch (error) {
|
|
271
|
-
this.state.attempts.stage = "unknown";
|
|
272
|
-
throw error;
|
|
273
|
-
}
|
|
226
|
+
this.state.phase = "blocked";
|
|
227
|
+
await runChecked(this.exec, "git", ["add", "--", ...paths], this.execOptions());
|
|
274
228
|
const unmerged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.execOptions())).stdout, "Unmerged paths");
|
|
275
|
-
if (unmerged.length) {
|
|
276
|
-
this.state.phase = "blocked";
|
|
277
|
-
throw new Error(`Conflict paths remain unresolved: ${unmerged.join(", ")}`);
|
|
278
|
-
}
|
|
229
|
+
if (unmerged.length) throw new Error(`Conflict paths remain unresolved: ${unmerged.join(", ")}`);
|
|
279
230
|
|
|
280
|
-
this.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const verified = await this.verifyMerge(this.authority.head.oid);
|
|
284
|
-
this.state.attempts.continueMerge = "applied";
|
|
285
|
-
return { kind: "verified", ...verified };
|
|
286
|
-
} catch (error) {
|
|
287
|
-
this.state.attempts.continueMerge = "unknown";
|
|
288
|
-
throw error;
|
|
289
|
-
}
|
|
231
|
+
await runChecked(this.exec, "git", ["-c", "core.editor=true", "merge", "--continue"], this.execOptions());
|
|
232
|
+
const verified = await this.verifyMerge(this.authority.head.oid);
|
|
233
|
+
return { kind: "verified", ...verified };
|
|
290
234
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
291
235
|
}
|
|
292
236
|
|
|
293
237
|
async publish(): Promise<UpdateBranchResult> {
|
|
294
|
-
if (this.state.phase !== "verified" || !this.state.verifiedHead
|
|
238
|
+
if (this.state.phase !== "verified" || !this.state.verifiedHead) {
|
|
295
239
|
throw new Error("Branch update is not ready to publish");
|
|
296
240
|
}
|
|
297
241
|
const head = this.state.verifiedHead;
|
|
@@ -306,22 +250,16 @@ export class PullRequestBranchUpdater {
|
|
|
306
250
|
throw new Error("Published branch would not be a fast-forward of the frozen remote OID");
|
|
307
251
|
}
|
|
308
252
|
await this.freshAuthority(head, true);
|
|
309
|
-
this.state.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
this.state.phase = "published";
|
|
320
|
-
return { kind: "published", head };
|
|
321
|
-
} catch (error) {
|
|
322
|
-
this.state.attempts.push = "unknown";
|
|
323
|
-
throw error;
|
|
324
|
-
}
|
|
253
|
+
this.state.phase = "blocked";
|
|
254
|
+
await runChecked(this.exec, "git", [
|
|
255
|
+
"push", "--porcelain", `--force-with-lease=refs/heads/${this.authority.target.ref}:${original}`,
|
|
256
|
+
"--recurse-submodules=no", "--", this.authority.target.fetchSource,
|
|
257
|
+
`${head}:refs/heads/${this.authority.target.ref}`,
|
|
258
|
+
], this.execOptions());
|
|
259
|
+
const remote = await readRemoteOid(this.exec, this.execOptions(), this.authority.target.fetchSource, this.authority.target.ref);
|
|
260
|
+
if (remote !== head) throw new Error("Published remote ref did not match verified HEAD");
|
|
261
|
+
this.state.phase = "published";
|
|
262
|
+
return { kind: "published", head };
|
|
325
263
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
326
264
|
}
|
|
327
265
|
}
|
package/extensions/pr.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type ExtensionAPI,
|
|
6
6
|
type ExtensionContext,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { truncateToWidth
|
|
8
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
9
|
import { createHerdrClient } from "@henryqw/pi-herdr";
|
|
10
10
|
import { Type } from "typebox";
|
|
11
11
|
import { PullRequestCiFixer, type PullRequestCiFixOptions } from "./pr-ci.ts";
|
|
@@ -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;
|
|
@@ -296,14 +298,10 @@ export default function pullRequestExtension(
|
|
|
296
298
|
let widgetKind: "presentation" | "routing" = "presentation";
|
|
297
299
|
let routingSpinnerFrame = 0;
|
|
298
300
|
let routingSpinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
299
|
-
let routingSpinnerTui: TUI | undefined;
|
|
300
|
-
let routingSpinnerComponent: (Component & { dispose(): void }) | undefined;
|
|
301
301
|
|
|
302
302
|
const stopRoutingSpinner = (): void => {
|
|
303
303
|
if (routingSpinnerTimer !== undefined) clearInterval(routingSpinnerTimer);
|
|
304
304
|
routingSpinnerTimer = undefined;
|
|
305
|
-
routingSpinnerTui = undefined;
|
|
306
|
-
routingSpinnerComponent = undefined;
|
|
307
305
|
};
|
|
308
306
|
|
|
309
307
|
const clearWorkflow = (selected: WorkflowContext | undefined): void => {
|
|
@@ -524,42 +522,30 @@ export default function pullRequestExtension(
|
|
|
524
522
|
stopRoutingSpinner();
|
|
525
523
|
widgetKind = "routing";
|
|
526
524
|
routingSpinnerFrame = 0;
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
525
|
+
const update = (): void => {
|
|
526
|
+
const frame = ROUTING_SPINNER_FRAMES[routingSpinnerFrame]!;
|
|
527
|
+
if (ctx.mode === "tui") {
|
|
528
|
+
ctx.ui.setWidget(UI_KEY, (_tui, theme) => ({
|
|
529
|
+
invalidate() {},
|
|
530
|
+
render(width) {
|
|
531
|
+
if (width <= 0) return [];
|
|
532
|
+
return [truncateToWidth(`${theme.fg("accent", frame)} ${ROUTING_WIDGET_TEXT}`, width)];
|
|
533
|
+
},
|
|
534
|
+
}));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
ctx.ui.setWidget(UI_KEY, [`${frame} ${ROUTING_WIDGET_TEXT}`]);
|
|
538
|
+
};
|
|
539
|
+
update();
|
|
540
|
+
if (ctx.mode !== "tui") return;
|
|
531
541
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const component: Component & { dispose(): void } = {
|
|
536
|
-
invalidate() {
|
|
537
|
-
cachedWidth = undefined;
|
|
538
|
-
cachedLines = undefined;
|
|
539
|
-
},
|
|
540
|
-
render(width) {
|
|
541
|
-
if (width <= 0) return [];
|
|
542
|
-
if (cachedLines !== undefined && cachedWidth === width) return cachedLines;
|
|
543
|
-
const line = `${theme.fg("accent", ROUTING_SPINNER_FRAMES[routingSpinnerFrame]!)} ${ROUTING_WIDGET_TEXT}`;
|
|
544
|
-
cachedWidth = width;
|
|
545
|
-
cachedLines = [truncateToWidth(line, Math.max(1, width))];
|
|
546
|
-
return cachedLines;
|
|
547
|
-
},
|
|
548
|
-
dispose() {
|
|
549
|
-
if (routingSpinnerComponent !== component) return;
|
|
550
|
-
stopRoutingSpinner();
|
|
551
|
-
widgetKind = "presentation";
|
|
552
|
-
},
|
|
553
|
-
};
|
|
554
|
-
routingSpinnerTui = tui;
|
|
555
|
-
routingSpinnerComponent = component;
|
|
556
|
-
return component;
|
|
557
|
-
});
|
|
558
|
-
routingSpinnerTimer = setInterval(() => {
|
|
542
|
+
let spinnerTimer: ReturnType<typeof setInterval>;
|
|
543
|
+
spinnerTimer = setInterval(() => {
|
|
544
|
+
if (routingSpinnerTimer !== spinnerTimer || widgetKind !== "routing") return;
|
|
559
545
|
routingSpinnerFrame = (routingSpinnerFrame + 1) % ROUTING_SPINNER_FRAMES.length;
|
|
560
|
-
|
|
561
|
-
routingSpinnerTui?.requestRender();
|
|
546
|
+
update();
|
|
562
547
|
}, ROUTING_SPINNER_INTERVAL_MS);
|
|
548
|
+
routingSpinnerTimer = spinnerTimer;
|
|
563
549
|
};
|
|
564
550
|
|
|
565
551
|
const reconcileWidget = (ctx: ExtensionContext): void => {
|
|
@@ -573,7 +559,6 @@ export default function pullRequestExtension(
|
|
|
573
559
|
const render = (
|
|
574
560
|
ctx: ExtensionContext,
|
|
575
561
|
discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
|
|
576
|
-
localCommit: boolean,
|
|
577
562
|
): void => {
|
|
578
563
|
if (discovery.kind === "inactive") {
|
|
579
564
|
if (timer !== undefined) clearInterval(timer);
|
|
@@ -585,7 +570,7 @@ export default function pullRequestExtension(
|
|
|
585
570
|
reconcileWidget(ctx);
|
|
586
571
|
return;
|
|
587
572
|
}
|
|
588
|
-
const display = projectPrDisplay(discovery
|
|
573
|
+
const display = projectPrDisplay(discovery);
|
|
589
574
|
const footer = formatPrFooter(display, ctx.ui.theme);
|
|
590
575
|
if ((discovery.kind === "current" || discovery.kind === "blocked") && footer === undefined) {
|
|
591
576
|
throw new Error("Pull request display is missing a footer");
|
|
@@ -621,7 +606,6 @@ export default function pullRequestExtension(
|
|
|
621
606
|
lastBlockedIssueKey = undefined;
|
|
622
607
|
delegatedWorkPending = false;
|
|
623
608
|
pendingWorkspaceRename = false;
|
|
624
|
-
mergeCompleted = false;
|
|
625
609
|
displayedWidget = undefined;
|
|
626
610
|
commandGeneration = 0;
|
|
627
611
|
clearWorkflow(workflowContext);
|
|
@@ -668,21 +652,17 @@ export default function pullRequestExtension(
|
|
|
668
652
|
active = controller;
|
|
669
653
|
try {
|
|
670
654
|
let discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>;
|
|
671
|
-
let localCommit = false;
|
|
672
655
|
try {
|
|
673
656
|
discovery = await load(pi, loadContext);
|
|
674
657
|
if (controller.signal.aborted || sessionGeneration !== generation) return;
|
|
675
|
-
if (discovery.kind === "none") {
|
|
676
|
-
localCommit = !mergeCompleted && await detectLocalCommit(pi, loadContext);
|
|
677
|
-
}
|
|
678
658
|
} catch {
|
|
679
|
-
// Keep an established
|
|
659
|
+
// Keep an established footer. A refresh failure must not leave a stale action hint.
|
|
680
660
|
if (!controller.signal.aborted && sessionGeneration === generation) {
|
|
661
|
+
displayedWidget = undefined;
|
|
662
|
+
reconcileWidget(ctx);
|
|
681
663
|
if (!displayEstablished) {
|
|
682
664
|
const unavailable = unavailablePrDisplay();
|
|
683
|
-
displayedWidget = undefined;
|
|
684
665
|
ctx.ui.setStatus(UI_KEY, formatPrFooter(unavailable, ctx.ui.theme));
|
|
685
|
-
reconcileWidget(ctx);
|
|
686
666
|
displayEstablished = true;
|
|
687
667
|
}
|
|
688
668
|
reportRefreshFailure();
|
|
@@ -690,7 +670,7 @@ export default function pullRequestExtension(
|
|
|
690
670
|
return;
|
|
691
671
|
}
|
|
692
672
|
if (controller.signal.aborted || sessionGeneration !== generation) return;
|
|
693
|
-
render(ctx, discovery
|
|
673
|
+
render(ctx, discovery);
|
|
694
674
|
refreshFailureReported = false;
|
|
695
675
|
|
|
696
676
|
const pullRequest = discovery.kind === "current" ? discovery.pullRequest : undefined;
|
|
@@ -786,7 +766,6 @@ export default function pullRequestExtension(
|
|
|
786
766
|
if (!isBashToolResult(event)) return;
|
|
787
767
|
const command = event.input.command;
|
|
788
768
|
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
769
|
await refresh().catch(reportRefreshFailure);
|
|
791
770
|
}
|
|
792
771
|
});
|
|
@@ -798,7 +777,7 @@ export default function pullRequestExtension(
|
|
|
798
777
|
releaseWorkflow,
|
|
799
778
|
});
|
|
800
779
|
pi.registerCommand("pr", {
|
|
801
|
-
description: "[--base
|
|
780
|
+
description: "[--base <branch>] [instructions] — Run the current branch pull request next step",
|
|
802
781
|
handler: async (args, ctx) => {
|
|
803
782
|
if (!ctx.hasUI || !context) return;
|
|
804
783
|
const generation = sessionGeneration;
|
|
@@ -838,7 +817,6 @@ export default function pullRequestExtension(
|
|
|
838
817
|
if (nextStep === "create") ctx.ui.setStatus(UI_KEY, undefined);
|
|
839
818
|
reconcileWidget(ctx);
|
|
840
819
|
} else {
|
|
841
|
-
if (nextStep === "merge") mergeCompleted = true;
|
|
842
820
|
activeInvocations.delete(invocation);
|
|
843
821
|
refreshInBackground();
|
|
844
822
|
}
|