@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.
@@ -1,12 +1,14 @@
1
- import { inspectWorktree, type Exec, type ExecResult } from "./pr-execution.ts";
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 = MergeMethodSelectionInput & InspectLocalMergeSafetyInput & {
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 runCommand(input.exec, input.cwd, "git", [
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 runCommand(input.exec, input.cwd, "git", ["cat-file", "-e", `${input.expectedHead}^{commit}`]);
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 readLocalHead(input.exec, input.cwd);
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 runCommand(
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 runCommand(
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 readLocalHead(input.exec, input.cwd);
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 runCommand(input.exec, input.cwd, "gh", [
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
- `mergeMethod=${method.toUpperCase()}`,
246
- ]);
247
- parseMergeResponse(requiredOutput(merged, "GitHub merge"), input.pullRequestId);
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" || this.state.attempts.fetchBase !== "none" || this.state.attempts.merge !== "none") {
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.attempts.fetchBase = "attempting";
201
- try {
202
- await runChecked(this.exec, "git", [
203
- "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", source, this.authority.base.oid,
204
- ], this.execOptions());
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.state.attempts.merge = "attempting";
219
- let result;
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
- try {
232
- const verified = await this.verifyMerge(this.authority.head.oid);
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
- if (this.state.attempts.stage !== "none") throw new Error("Conflict staging action was already consumed");
266
- this.state.attempts.stage = "attempting";
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.state.attempts.continueMerge = "attempting";
281
- try {
282
- await runChecked(this.exec, "git", ["-c", "core.editor=true", "merge", "--continue"], this.execOptions());
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 || this.state.attempts.push !== "none") {
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.attempts.push = "attempting";
310
- try {
311
- await runChecked(this.exec, "git", [
312
- "push", "--porcelain", `--force-with-lease=refs/heads/${this.authority.target.ref}:${original}`,
313
- "--recurse-submodules=no", "--", this.authority.target.fetchSource,
314
- `${head}:refs/heads/${this.authority.target.ref}`,
315
- ], this.execOptions());
316
- const remote = await readRemoteOid(this.exec, this.execOptions(), this.authority.target.fetchSource, this.authority.target.ref);
317
- if (remote !== head) throw new Error("Published remote ref did not match verified HEAD");
318
- this.state.attempts.push = "applied";
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, type Component, type TUI } from "@earendil-works/pi-tui";
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
- if (ctx.mode !== "tui") {
530
- ctx.ui.setWidget(UI_KEY, [`${ROUTING_SPINNER_FRAMES[0]} ${ROUTING_WIDGET_TEXT}`]);
531
- return;
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
- ctx.ui.setWidget(UI_KEY, (tui, theme) => {
535
- let cachedWidth: number | undefined;
536
- let cachedLines: string[] | undefined;
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
- routingSpinnerComponent?.invalidate();
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",
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",