@henryqw/pi-pr 4.0.3 → 4.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/extensions/pr-ci.ts +0 -1
- package/extensions/pr-command.ts +2 -14
- package/extensions/pr-comment-sweep.ts +0 -27
- package/extensions/pr-create.ts +75 -219
- package/extensions/pr-execution.ts +28 -163
- package/extensions/pr-github.ts +58 -207
- package/extensions/pr-merge.ts +24 -111
- package/extensions/pr-update-branch.ts +28 -90
- package/extensions/pr.ts +22 -38
- package/package.json +6 -2
package/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
|
}
|
|
@@ -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";
|
|
@@ -298,14 +298,10 @@ export default function pullRequestExtension(
|
|
|
298
298
|
let widgetKind: "presentation" | "routing" = "presentation";
|
|
299
299
|
let routingSpinnerFrame = 0;
|
|
300
300
|
let routingSpinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
301
|
-
let routingSpinnerTui: TUI | undefined;
|
|
302
|
-
let routingSpinnerComponent: (Component & { dispose(): void }) | undefined;
|
|
303
301
|
|
|
304
302
|
const stopRoutingSpinner = (): void => {
|
|
305
303
|
if (routingSpinnerTimer !== undefined) clearInterval(routingSpinnerTimer);
|
|
306
304
|
routingSpinnerTimer = undefined;
|
|
307
|
-
routingSpinnerTui = undefined;
|
|
308
|
-
routingSpinnerComponent = undefined;
|
|
309
305
|
};
|
|
310
306
|
|
|
311
307
|
const clearWorkflow = (selected: WorkflowContext | undefined): void => {
|
|
@@ -526,42 +522,30 @@ export default function pullRequestExtension(
|
|
|
526
522
|
stopRoutingSpinner();
|
|
527
523
|
widgetKind = "routing";
|
|
528
524
|
routingSpinnerFrame = 0;
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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;
|
|
533
541
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const component: Component & { dispose(): void } = {
|
|
538
|
-
invalidate() {
|
|
539
|
-
cachedWidth = undefined;
|
|
540
|
-
cachedLines = undefined;
|
|
541
|
-
},
|
|
542
|
-
render(width) {
|
|
543
|
-
if (width <= 0) return [];
|
|
544
|
-
if (cachedLines !== undefined && cachedWidth === width) return cachedLines;
|
|
545
|
-
const line = `${theme.fg("accent", ROUTING_SPINNER_FRAMES[routingSpinnerFrame]!)} ${ROUTING_WIDGET_TEXT}`;
|
|
546
|
-
cachedWidth = width;
|
|
547
|
-
cachedLines = [truncateToWidth(line, Math.max(1, width))];
|
|
548
|
-
return cachedLines;
|
|
549
|
-
},
|
|
550
|
-
dispose() {
|
|
551
|
-
if (routingSpinnerComponent !== component) return;
|
|
552
|
-
stopRoutingSpinner();
|
|
553
|
-
widgetKind = "presentation";
|
|
554
|
-
},
|
|
555
|
-
};
|
|
556
|
-
routingSpinnerTui = tui;
|
|
557
|
-
routingSpinnerComponent = component;
|
|
558
|
-
return component;
|
|
559
|
-
});
|
|
560
|
-
routingSpinnerTimer = setInterval(() => {
|
|
542
|
+
let spinnerTimer: ReturnType<typeof setInterval>;
|
|
543
|
+
spinnerTimer = setInterval(() => {
|
|
544
|
+
if (routingSpinnerTimer !== spinnerTimer || widgetKind !== "routing") return;
|
|
561
545
|
routingSpinnerFrame = (routingSpinnerFrame + 1) % ROUTING_SPINNER_FRAMES.length;
|
|
562
|
-
|
|
563
|
-
routingSpinnerTui?.requestRender();
|
|
546
|
+
update();
|
|
564
547
|
}, ROUTING_SPINNER_INTERVAL_MS);
|
|
548
|
+
routingSpinnerTimer = spinnerTimer;
|
|
565
549
|
};
|
|
566
550
|
|
|
567
551
|
const reconcileWidget = (ctx: ExtensionContext): void => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-pr",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.5",
|
|
4
4
|
"description": "Run /pr to safely discover or link the current pull request, then create, update, address feedback, fix CI, or merge when ready.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -33,7 +33,11 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@henryqw/pi-config-store": "^1.1.0",
|
|
36
|
-
"@henryqw/pi-herdr": "^0.4.5"
|
|
36
|
+
"@henryqw/pi-herdr": "^0.4.5",
|
|
37
|
+
"proper-lockfile": "^4.1.2"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/proper-lockfile": "^4.1.4"
|
|
37
41
|
},
|
|
38
42
|
"repository": {
|
|
39
43
|
"type": "git",
|