@henryqw/pi-pr 3.1.9 → 4.0.2
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 +28 -11
- package/docs/pr-routing.svg +1 -1
- package/extensions/pr-ci.ts +908 -0
- package/extensions/pr-command.ts +107 -21
- package/extensions/pr-comment-sweep.ts +1102 -0
- package/extensions/pr-create.ts +684 -0
- package/extensions/pr-execution.ts +558 -0
- package/extensions/pr-feedback.ts +519 -0
- package/extensions/pr-github.ts +363 -145
- package/extensions/pr-merge.ts +4 -44
- package/extensions/pr-routing.ts +2 -1
- package/extensions/pr-ui.ts +1 -1
- package/extensions/pr-update-branch.ts +327 -0
- package/extensions/pr.ts +480 -16
- package/package.json +4 -2
- package/skills/pi-pr-comment-sweep/SKILL.md +45 -89
- package/skills/pi-pr-comment-sweep/references/recovery.md +24 -46
- package/skills/pi-pr-comment-sweep/references/thread-triage.md +2 -2
- package/skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs +223 -1799
- package/skills/pi-pr-create/SKILL.md +6 -77
- package/skills/pi-pr-fix-ci/SKILL.md +39 -39
- package/skills/pi-pr-update-branch/SKILL.md +5 -108
package/extensions/pr.ts
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
1
3
|
import {
|
|
2
4
|
isBashToolResult,
|
|
3
5
|
type ExtensionAPI,
|
|
4
6
|
type ExtensionContext,
|
|
5
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
6
9
|
import { createHerdrClient } from "@henryqw/pi-herdr";
|
|
7
|
-
import {
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import { PullRequestCiFixer, type PullRequestCiFixOptions } from "./pr-ci.ts";
|
|
12
|
+
import { PullRequestCommentSweep, type PullRequestCommentSweepOptions } from "./pr-comment-sweep.ts";
|
|
13
|
+
import {
|
|
14
|
+
createPrCommandHandler,
|
|
15
|
+
type PrCommandDependencies,
|
|
16
|
+
type PrCommandInvocation,
|
|
17
|
+
type WorkflowPromptIdentity,
|
|
18
|
+
type WorkflowReservation,
|
|
19
|
+
} from "./pr-command.ts";
|
|
20
|
+
import { PullRequestCreator, type CreatePullRequestOptions } from "./pr-create.ts";
|
|
8
21
|
import {
|
|
9
22
|
hasLocalCommit,
|
|
10
23
|
loadCurrentPullRequest,
|
|
@@ -13,6 +26,7 @@ import {
|
|
|
13
26
|
samePullRequestObservation,
|
|
14
27
|
type PullRequestObservation,
|
|
15
28
|
} from "./pr-github.ts";
|
|
29
|
+
import { runChecked, spawnBounded } from "./pr-execution.ts";
|
|
16
30
|
import {
|
|
17
31
|
discoveryIssueKey,
|
|
18
32
|
discoveryIssueMessage,
|
|
@@ -22,8 +36,12 @@ import {
|
|
|
22
36
|
unavailablePrDisplay,
|
|
23
37
|
type PrDisplay,
|
|
24
38
|
} from "./pr-ui.ts";
|
|
39
|
+
import { PullRequestBranchUpdater, type UpdateBranchOptions } from "./pr-update-branch.ts";
|
|
25
40
|
|
|
26
41
|
const POLL_INTERVAL_MS = 30_000;
|
|
42
|
+
const ROUTING_SPINNER_INTERVAL_MS = 80;
|
|
43
|
+
const ROUTING_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
44
|
+
const ROUTING_WIDGET_TEXT = "Checking pull request…";
|
|
27
45
|
const HERDR_TIMEOUT_MS = 10_000;
|
|
28
46
|
const UI_KEY = "pi-pr";
|
|
29
47
|
const OBSERVATION_ENTRY = "pi-pr-observation";
|
|
@@ -32,13 +50,150 @@ const GIT_COMMIT = /(?:^|[;&|]\s*|\n\s*)git\s+commit(?=\s|$|[;&|])/;
|
|
|
32
50
|
const GIT_PUSH = /(?:^|[;&|]\s*|\n\s*)git\s+push(?=\s|$|[;&|])/;
|
|
33
51
|
const WORKFLOW_ROUTES = new Set(["create", "update-branch", "sweep", "fix-ci"]);
|
|
34
52
|
const DELEGATED_TOOLS = new Set(["delegate_task", "delegate_flow", "delegate_flow_continue"]);
|
|
53
|
+
const CLOSED = { additionalProperties: false } as const;
|
|
54
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
55
|
+
|
|
56
|
+
const RouteRunId = Type.String({ minLength: 36, maxLength: 36, pattern: UUID.source });
|
|
57
|
+
const ResolvedPaths = Type.Array(Type.String({ minLength: 1, maxLength: 4_096 }), { minItems: 1, maxItems: 128 });
|
|
58
|
+
const OwnedPaths = Type.Array(Type.String({ minLength: 1, maxLength: 4_096 }), { maxItems: 128 });
|
|
59
|
+
const SweepGuard = Type.Object({
|
|
60
|
+
epoch: Type.Integer({ minimum: 1 }),
|
|
61
|
+
runId: Type.String({ minLength: 1, maxLength: 128 }),
|
|
62
|
+
generation: Type.Integer({ minimum: 1 }),
|
|
63
|
+
fingerprint: Type.String({ minLength: 64, maxLength: 64 }),
|
|
64
|
+
}, CLOSED);
|
|
65
|
+
const SweepLedgerEntry = Type.Object({
|
|
66
|
+
id: Type.String({ minLength: 1, maxLength: 1_024 }),
|
|
67
|
+
kind: Type.Union([
|
|
68
|
+
Type.Literal("conversation_comment"), Type.Literal("review"), Type.Literal("thread"), Type.Literal("thread_comment"),
|
|
69
|
+
]),
|
|
70
|
+
disposition: Type.Union([Type.Literal("addressed"), Type.Literal("non-actionable"), Type.Literal("blocked")]),
|
|
71
|
+
note: Type.String({ maxLength: 2_048 }),
|
|
72
|
+
}, CLOSED);
|
|
73
|
+
const SweepLedger = Type.Array(SweepLedgerEntry, { maxItems: 1_000 });
|
|
74
|
+
const SweepProjection = Type.Object({
|
|
75
|
+
generation: Type.Integer({ minimum: 1 }),
|
|
76
|
+
contentFingerprint: Type.String({ minLength: 64, maxLength: 64 }),
|
|
77
|
+
items: Type.Array(Type.Object({
|
|
78
|
+
id: Type.String({ minLength: 1, maxLength: 1_024 }),
|
|
79
|
+
kind: Type.Union([
|
|
80
|
+
Type.Literal("conversation_comment"), Type.Literal("review"), Type.Literal("thread"), Type.Literal("thread_comment"),
|
|
81
|
+
]),
|
|
82
|
+
}, CLOSED), { maxItems: 1_000 }),
|
|
83
|
+
threads: Type.Array(Type.Object({
|
|
84
|
+
id: Type.String({ minLength: 1, maxLength: 1_024 }),
|
|
85
|
+
isResolved: Type.Boolean(),
|
|
86
|
+
}, CLOSED), { maxItems: 1_000 }),
|
|
87
|
+
}, CLOSED);
|
|
88
|
+
const SweepChecks = Type.Array(Type.Object({
|
|
89
|
+
command: Type.String({ minLength: 1, maxLength: 1_024 }),
|
|
90
|
+
args: Type.Array(Type.String({ maxLength: 4_096 }), { maxItems: 256 }),
|
|
91
|
+
}, CLOSED), { maxItems: 32 });
|
|
92
|
+
|
|
93
|
+
const UpdateBranchParameters = Type.Union([
|
|
94
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("merge") }, CLOSED),
|
|
95
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("continue"), resolvedPaths: ResolvedPaths }, CLOSED),
|
|
96
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("publish") }, CLOSED),
|
|
97
|
+
]);
|
|
98
|
+
const CreateParameters = Type.Union([
|
|
99
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("prepare") }, CLOSED),
|
|
100
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("merge") }, CLOSED),
|
|
101
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("continue"), resolvedPaths: ResolvedPaths }, CLOSED),
|
|
102
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("push") }, CLOSED),
|
|
103
|
+
Type.Object({
|
|
104
|
+
runId: RouteRunId,
|
|
105
|
+
action: Type.Literal("publish"),
|
|
106
|
+
title: Type.String({ minLength: 1, maxLength: 256 }),
|
|
107
|
+
body: Type.String({ maxLength: 65_536 }),
|
|
108
|
+
}, CLOSED),
|
|
109
|
+
]);
|
|
110
|
+
const SweepParameters = Type.Union([
|
|
111
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("start") }, CLOSED),
|
|
112
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("resume") }, CLOSED),
|
|
113
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("show"), guard: SweepGuard, id: Type.String({ minLength: 1, maxLength: 1_024 }) }, CLOSED),
|
|
114
|
+
Type.Object({
|
|
115
|
+
runId: RouteRunId,
|
|
116
|
+
action: Type.Literal("record"),
|
|
117
|
+
guard: SweepGuard,
|
|
118
|
+
ledger: SweepLedger,
|
|
119
|
+
ownedPaths: Type.Optional(OwnedPaths),
|
|
120
|
+
}, CLOSED),
|
|
121
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("publish"), guard: SweepGuard }, CLOSED),
|
|
122
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("refresh"), guard: SweepGuard }, CLOSED),
|
|
123
|
+
Type.Object({
|
|
124
|
+
runId: RouteRunId,
|
|
125
|
+
action: Type.Literal("resolve"),
|
|
126
|
+
guard: SweepGuard,
|
|
127
|
+
threadIds: Type.Array(Type.String({ minLength: 1, maxLength: 1_024 }), { maxItems: 1_000 }),
|
|
128
|
+
}, CLOSED),
|
|
129
|
+
Type.Object({
|
|
130
|
+
runId: RouteRunId,
|
|
131
|
+
action: Type.Literal("finalize"),
|
|
132
|
+
guard: SweepGuard,
|
|
133
|
+
projection: SweepProjection,
|
|
134
|
+
checks: SweepChecks,
|
|
135
|
+
}, CLOSED),
|
|
136
|
+
]);
|
|
137
|
+
const FixCiParameters = Type.Union([
|
|
138
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("collect") }, CLOSED),
|
|
139
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("publish") }, CLOSED),
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
type UpdateBranchWorkflow = Pick<PullRequestBranchUpdater, "state" | "merge" | "continue" | "publish">;
|
|
143
|
+
type CreateWorkflow = Pick<PullRequestCreator, "state" | "prepare" | "merge" | "continue" | "push" | "publish">;
|
|
144
|
+
type SweepWorkflow = Pick<PullRequestCommentSweep, "start" | "resume" | "show" | "record" | "publish" | "refresh" | "resolve" | "finalize">;
|
|
145
|
+
type FixCiWorkflow = Pick<PullRequestCiFixer, "collect" | "publish">;
|
|
146
|
+
|
|
147
|
+
type WorkflowContextBase = {
|
|
148
|
+
runId: string;
|
|
149
|
+
sessionGeneration: number;
|
|
150
|
+
worktree: string;
|
|
151
|
+
controller: AbortController;
|
|
152
|
+
usedSinceSettlement: boolean;
|
|
153
|
+
queuedPrompt?: WorkflowPromptIdentity;
|
|
154
|
+
conflictRetained: boolean;
|
|
155
|
+
};
|
|
156
|
+
type WorkflowContext =
|
|
157
|
+
| (WorkflowContextBase & { route: "update-branch"; workflow: UpdateBranchWorkflow })
|
|
158
|
+
| (WorkflowContextBase & { route: "create"; base?: string; workflow: CreateWorkflow })
|
|
159
|
+
| (WorkflowContextBase & { route: "sweep"; workflow: SweepWorkflow })
|
|
160
|
+
| (WorkflowContextBase & { route: "fix-ci"; workflow: FixCiWorkflow });
|
|
35
161
|
|
|
36
162
|
type PullRequestExtensionDependencies = {
|
|
37
163
|
loadCurrentPullRequest?: typeof loadCurrentPullRequest;
|
|
38
164
|
hasLocalCommit?: typeof hasLocalCommit;
|
|
39
165
|
createPrCommandHandler?: typeof createPrCommandHandler;
|
|
166
|
+
createBranchUpdater?: (options: UpdateBranchOptions) => UpdateBranchWorkflow;
|
|
167
|
+
createPullRequestCreator?: (options: CreatePullRequestOptions) => CreateWorkflow;
|
|
168
|
+
createCommentSweep?: (options: PullRequestCommentSweepOptions) => SweepWorkflow;
|
|
169
|
+
createCiFixer?: (options: PullRequestCiFixOptions) => FixCiWorkflow;
|
|
170
|
+
canonicalWorktree?: (cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
171
|
+
newRunId?: () => string;
|
|
40
172
|
};
|
|
41
173
|
|
|
174
|
+
async function canonicalWorktree(cwd: string, signal?: AbortSignal): Promise<string> {
|
|
175
|
+
const result = await runChecked(spawnBounded, "git", ["rev-parse", "--show-toplevel"], { cwd, signal });
|
|
176
|
+
const normalized = result.stdout.replace(/\r\n/g, "\n");
|
|
177
|
+
const lines = (normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized).split("\n");
|
|
178
|
+
if (lines.length !== 1 || !lines[0]) throw new Error("Git worktree root resolution returned invalid output");
|
|
179
|
+
return await realpath(lines[0]);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function toolResult(value: unknown) {
|
|
183
|
+
return {
|
|
184
|
+
content: [{ type: "text" as const, text: JSON.stringify(value) }],
|
|
185
|
+
details: value,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function matchesWorkflowPrompt(prompt: string, identity: WorkflowPromptIdentity): boolean {
|
|
190
|
+
const tokens = new Set(prompt.split(/\s+/).filter(Boolean));
|
|
191
|
+
const skillName = identity.skill.startsWith("skill:") ? identity.skill.slice("skill:".length) : "";
|
|
192
|
+
const matchesSkill = tokens.has(`/${identity.skill}`) ||
|
|
193
|
+
(skillName !== "" && tokens.has("<skill") && tokens.has(`name="${skillName}"`));
|
|
194
|
+
return matchesSkill && tokens.has(`runId=${identity.runId}`) && tokens.has(`action=${identity.action}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
42
197
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
198
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
199
|
}
|
|
@@ -102,6 +257,12 @@ export default function pullRequestExtension(
|
|
|
102
257
|
const discover = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
|
|
103
258
|
const detectLocalCommit = dependencies.hasLocalCommit ?? hasLocalCommit;
|
|
104
259
|
const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
|
|
260
|
+
const createBranchUpdater = dependencies.createBranchUpdater ?? ((options) => new PullRequestBranchUpdater(options));
|
|
261
|
+
const createPullRequestCreator = dependencies.createPullRequestCreator ?? ((options) => new PullRequestCreator(options));
|
|
262
|
+
const createCommentSweep = dependencies.createCommentSweep ?? ((options) => new PullRequestCommentSweep(options));
|
|
263
|
+
const createCiFixer = dependencies.createCiFixer ?? ((options) => new PullRequestCiFixer(options));
|
|
264
|
+
const resolveCanonicalWorktree = dependencies.canonicalWorktree ?? canonicalWorktree;
|
|
265
|
+
const newRunId = dependencies.newRunId ?? randomUUID;
|
|
105
266
|
let context: ExtensionContext | undefined;
|
|
106
267
|
let observation: PullRequestObservation | undefined;
|
|
107
268
|
const load: typeof loadCurrentPullRequest = async (api, loadContext, inspectedLocal) => {
|
|
@@ -130,9 +291,220 @@ export default function pullRequestExtension(
|
|
|
130
291
|
let mergeCompleted = false;
|
|
131
292
|
let displayedWidget: PrDisplay | undefined;
|
|
132
293
|
let commandGeneration = 0;
|
|
133
|
-
|
|
294
|
+
let workflowContext: WorkflowContext | undefined;
|
|
295
|
+
const activeInvocations = new Map<number, "routing" | "resolved" | "create-workflow" | "workflow">();
|
|
296
|
+
let widgetKind: "presentation" | "routing" = "presentation";
|
|
297
|
+
let routingSpinnerFrame = 0;
|
|
298
|
+
let routingSpinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
299
|
+
let routingSpinnerTui: TUI | undefined;
|
|
300
|
+
let routingSpinnerComponent: (Component & { dispose(): void }) | undefined;
|
|
301
|
+
|
|
302
|
+
const stopRoutingSpinner = (): void => {
|
|
303
|
+
if (routingSpinnerTimer !== undefined) clearInterval(routingSpinnerTimer);
|
|
304
|
+
routingSpinnerTimer = undefined;
|
|
305
|
+
routingSpinnerTui = undefined;
|
|
306
|
+
routingSpinnerComponent = undefined;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const clearWorkflow = (selected: WorkflowContext | undefined): void => {
|
|
310
|
+
if (!selected || workflowContext !== selected) return;
|
|
311
|
+
workflowContext = undefined;
|
|
312
|
+
selected.controller.abort();
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const reserveWorkflow: NonNullable<PrCommandDependencies["reserveWorkflow"]> = async (reservation, ctx, invocation) => {
|
|
316
|
+
if (!invocation) throw new Error("PR workflow command generation is unavailable");
|
|
317
|
+
invocation.assertCurrent();
|
|
318
|
+
const worktree = await resolveCanonicalWorktree(ctx.cwd, ctx.signal);
|
|
319
|
+
invocation.assertCurrent();
|
|
320
|
+
if (workflowContext) throw new Error(`PR workflow ${workflowContext.runId} is still active`);
|
|
321
|
+
const runId = newRunId();
|
|
322
|
+
if (!UUID.test(runId)) throw new Error("PR workflow runId generator returned an invalid UUID");
|
|
323
|
+
const common: WorkflowContextBase = {
|
|
324
|
+
runId,
|
|
325
|
+
sessionGeneration: invocation.sessionGeneration,
|
|
326
|
+
worktree,
|
|
327
|
+
controller: new AbortController(),
|
|
328
|
+
usedSinceSettlement: false,
|
|
329
|
+
conflictRetained: false,
|
|
330
|
+
};
|
|
331
|
+
switch (reservation.route) {
|
|
332
|
+
case "update-branch":
|
|
333
|
+
workflowContext = {
|
|
334
|
+
...common,
|
|
335
|
+
route: "update-branch",
|
|
336
|
+
workflow: createBranchUpdater({
|
|
337
|
+
cwd: worktree,
|
|
338
|
+
authority: reservation.pullRequest,
|
|
339
|
+
signal: common.controller.signal,
|
|
340
|
+
loadCurrentPullRequest: load,
|
|
341
|
+
}),
|
|
342
|
+
};
|
|
343
|
+
break;
|
|
344
|
+
case "create":
|
|
345
|
+
workflowContext = {
|
|
346
|
+
...common,
|
|
347
|
+
route: "create",
|
|
348
|
+
...(reservation.base === undefined ? {} : { base: reservation.base }),
|
|
349
|
+
workflow: createPullRequestCreator({
|
|
350
|
+
cwd: worktree,
|
|
351
|
+
target: reservation.target,
|
|
352
|
+
signal: common.controller.signal,
|
|
353
|
+
loadCurrentPullRequest: load,
|
|
354
|
+
}),
|
|
355
|
+
};
|
|
356
|
+
break;
|
|
357
|
+
case "sweep":
|
|
358
|
+
workflowContext = {
|
|
359
|
+
...common,
|
|
360
|
+
route: "sweep",
|
|
361
|
+
workflow: createCommentSweep({
|
|
362
|
+
cwd: worktree,
|
|
363
|
+
authority: reservation.pullRequest,
|
|
364
|
+
signal: common.controller.signal,
|
|
365
|
+
loadCurrentPullRequest: load,
|
|
366
|
+
}),
|
|
367
|
+
};
|
|
368
|
+
break;
|
|
369
|
+
case "fix-ci":
|
|
370
|
+
workflowContext = {
|
|
371
|
+
...common,
|
|
372
|
+
route: "fix-ci",
|
|
373
|
+
workflow: createCiFixer({
|
|
374
|
+
cwd: worktree,
|
|
375
|
+
authority: reservation.pullRequest,
|
|
376
|
+
signal: common.controller.signal,
|
|
377
|
+
loadCurrentPullRequest: load,
|
|
378
|
+
}),
|
|
379
|
+
};
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
return common.runId;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const markWorkflowPromptQueued: NonNullable<PrCommandDependencies["markWorkflowPromptQueued"]> = (identity, queued) => {
|
|
386
|
+
if (workflowContext?.runId !== identity.runId || workflowContext.route !== identity.route) {
|
|
387
|
+
throw new Error("PR workflow reservation is wrong or stale");
|
|
388
|
+
}
|
|
389
|
+
workflowContext.queuedPrompt = queued ? identity : undefined;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const releaseWorkflow: NonNullable<PrCommandDependencies["releaseWorkflow"]> = (runId, invocation) => {
|
|
393
|
+
const selected = workflowContext;
|
|
394
|
+
if (
|
|
395
|
+
invocation && selected?.runId === runId &&
|
|
396
|
+
selected.sessionGeneration === invocation.sessionGeneration
|
|
397
|
+
) clearWorkflow(selected);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const executeWorkflowAction = async <Route extends WorkflowContext["route"]>(
|
|
401
|
+
runId: string,
|
|
402
|
+
route: Route,
|
|
403
|
+
ctx: ExtensionContext,
|
|
404
|
+
signal: AbortSignal | undefined,
|
|
405
|
+
action: (selected: Extract<WorkflowContext, { route: Route }>) => Promise<unknown>,
|
|
406
|
+
) => {
|
|
407
|
+
signal?.throwIfAborted();
|
|
408
|
+
const selected = workflowContext;
|
|
409
|
+
if (!selected) throw new Error("No PR workflow is active");
|
|
410
|
+
if (selected.runId !== runId) throw new Error("PR workflow runId is wrong or stale");
|
|
411
|
+
if (selected.sessionGeneration !== sessionGeneration) throw new Error("PR workflow session is stale");
|
|
412
|
+
if (selected.route !== route) throw new Error(`PR workflow route is ${selected.route}, not ${route}`);
|
|
413
|
+
const abortRun = () => selected.controller.abort(signal?.reason);
|
|
414
|
+
if (signal?.aborted) abortRun();
|
|
415
|
+
else signal?.addEventListener("abort", abortRun, { once: true });
|
|
416
|
+
try {
|
|
417
|
+
selected.controller.signal.throwIfAborted();
|
|
418
|
+
const worktree = await resolveCanonicalWorktree(ctx.cwd, selected.controller.signal);
|
|
419
|
+
if (workflowContext !== selected || selected.sessionGeneration !== sessionGeneration) {
|
|
420
|
+
throw new Error("PR workflow session changed during validation");
|
|
421
|
+
}
|
|
422
|
+
selected.controller.signal.throwIfAborted();
|
|
423
|
+
if (worktree !== selected.worktree) throw new Error("PR workflow worktree is wrong or stale");
|
|
424
|
+
selected.usedSinceSettlement = true;
|
|
425
|
+
return toolResult(await action(selected as Extract<WorkflowContext, { route: Route }>));
|
|
426
|
+
} finally {
|
|
427
|
+
signal?.removeEventListener("abort", abortRun);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
pi.registerTool({
|
|
432
|
+
name: "pi_pr_update_branch",
|
|
433
|
+
label: "Update PR Branch",
|
|
434
|
+
description: "Run one guarded action for the /pr branch-update route.",
|
|
435
|
+
parameters: UpdateBranchParameters,
|
|
436
|
+
executionMode: "sequential",
|
|
437
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
438
|
+
return executeWorkflowAction(params.runId, "update-branch", ctx, signal, async (selected) => {
|
|
439
|
+
switch (params.action) {
|
|
440
|
+
case "merge": return await selected.workflow.merge();
|
|
441
|
+
case "continue": return await selected.workflow.continue(params.resolvedPaths);
|
|
442
|
+
case "publish": return await selected.workflow.publish();
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
pi.registerTool({
|
|
449
|
+
name: "pi_pr_create",
|
|
450
|
+
label: "Create Pull Request",
|
|
451
|
+
description: "Run one guarded action for the /pr creation route.",
|
|
452
|
+
parameters: CreateParameters,
|
|
453
|
+
executionMode: "sequential",
|
|
454
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
455
|
+
return executeWorkflowAction(params.runId, "create", ctx, signal, async (selected) => {
|
|
456
|
+
switch (params.action) {
|
|
457
|
+
case "prepare": return await selected.workflow.prepare(selected.base);
|
|
458
|
+
case "merge": return await selected.workflow.merge();
|
|
459
|
+
case "continue": return await selected.workflow.continue(params.resolvedPaths);
|
|
460
|
+
case "push": return await selected.workflow.push();
|
|
461
|
+
case "publish": return await selected.workflow.publish(params.title, params.body);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
pi.registerTool({
|
|
468
|
+
name: "pi_pr_sweep",
|
|
469
|
+
label: "Sweep PR Feedback",
|
|
470
|
+
description: "Run one guarded action for the /pr feedback route.",
|
|
471
|
+
parameters: SweepParameters,
|
|
472
|
+
executionMode: "sequential",
|
|
473
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
474
|
+
return executeWorkflowAction(params.runId, "sweep", ctx, signal, async (selected) => {
|
|
475
|
+
switch (params.action) {
|
|
476
|
+
case "start": return await selected.workflow.start();
|
|
477
|
+
case "resume": return await selected.workflow.resume();
|
|
478
|
+
case "show": return await selected.workflow.show(params.guard, params.id);
|
|
479
|
+
case "record": return await selected.workflow.record(params.guard, params.ledger, params.ownedPaths);
|
|
480
|
+
case "publish": return await selected.workflow.publish(params.guard);
|
|
481
|
+
case "refresh": return await selected.workflow.refresh(params.guard);
|
|
482
|
+
case "resolve": return await selected.workflow.resolve(params.guard, params.threadIds);
|
|
483
|
+
case "finalize": return await selected.workflow.finalize(params.guard, params.projection, params.checks);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
pi.registerTool({
|
|
490
|
+
name: "pi_pr_fix_ci",
|
|
491
|
+
label: "Fix PR CI",
|
|
492
|
+
description: "Run one guarded action for the /pr failed-CI route.",
|
|
493
|
+
parameters: FixCiParameters,
|
|
494
|
+
executionMode: "sequential",
|
|
495
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
496
|
+
return executeWorkflowAction(params.runId, "fix-ci", ctx, signal, async (selected) => {
|
|
497
|
+
switch (params.action) {
|
|
498
|
+
case "collect": return await selected.workflow.collect();
|
|
499
|
+
case "publish": return await selected.workflow.publish();
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
},
|
|
503
|
+
});
|
|
134
504
|
|
|
135
505
|
const setWidget = (ctx: ExtensionContext, display: PrDisplay | undefined): void => {
|
|
506
|
+
stopRoutingSpinner();
|
|
507
|
+
widgetKind = "presentation";
|
|
136
508
|
if (display?.widget === undefined) {
|
|
137
509
|
ctx.ui.setWidget(UI_KEY, undefined);
|
|
138
510
|
return;
|
|
@@ -147,6 +519,57 @@ export default function pullRequestExtension(
|
|
|
147
519
|
ctx.ui.setWidget(UI_KEY, formatPrWidget(display));
|
|
148
520
|
};
|
|
149
521
|
|
|
522
|
+
const setRoutingWidget = (ctx: ExtensionContext): void => {
|
|
523
|
+
if (widgetKind === "routing") return;
|
|
524
|
+
stopRoutingSpinner();
|
|
525
|
+
widgetKind = "routing";
|
|
526
|
+
routingSpinnerFrame = 0;
|
|
527
|
+
if (ctx.mode !== "tui") {
|
|
528
|
+
ctx.ui.setWidget(UI_KEY, [`${ROUTING_SPINNER_FRAMES[0]} ${ROUTING_WIDGET_TEXT}`]);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
ctx.ui.setWidget(UI_KEY, (tui, theme) => {
|
|
533
|
+
let cachedWidth: number | undefined;
|
|
534
|
+
let cachedLines: string[] | undefined;
|
|
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(() => {
|
|
559
|
+
routingSpinnerFrame = (routingSpinnerFrame + 1) % ROUTING_SPINNER_FRAMES.length;
|
|
560
|
+
routingSpinnerComponent?.invalidate();
|
|
561
|
+
routingSpinnerTui?.requestRender();
|
|
562
|
+
}, ROUTING_SPINNER_INTERVAL_MS);
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const reconcileWidget = (ctx: ExtensionContext): void => {
|
|
566
|
+
if ([...activeInvocations.values()].includes("routing")) {
|
|
567
|
+
setRoutingWidget(ctx);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
setWidget(ctx, activeInvocations.size > 0 ? undefined : displayedWidget);
|
|
571
|
+
};
|
|
572
|
+
|
|
150
573
|
const render = (
|
|
151
574
|
ctx: ExtensionContext,
|
|
152
575
|
discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
|
|
@@ -159,7 +582,7 @@ export default function pullRequestExtension(
|
|
|
159
582
|
lastDiscovery = "inactive";
|
|
160
583
|
displayedWidget = undefined;
|
|
161
584
|
ctx.ui.setStatus(UI_KEY, undefined);
|
|
162
|
-
|
|
585
|
+
reconcileWidget(ctx);
|
|
163
586
|
return;
|
|
164
587
|
}
|
|
165
588
|
const display = projectPrDisplay(discovery, localCommit);
|
|
@@ -168,9 +591,8 @@ export default function pullRequestExtension(
|
|
|
168
591
|
throw new Error("Pull request display is missing a footer");
|
|
169
592
|
}
|
|
170
593
|
displayedWidget = display.widget === undefined ? undefined : display;
|
|
171
|
-
const widget = activeInvocations.size > 0 ? undefined : displayedWidget;
|
|
172
594
|
ctx.ui.setStatus(UI_KEY, footer);
|
|
173
|
-
|
|
595
|
+
reconcileWidget(ctx);
|
|
174
596
|
if (discovery.kind === "blocked") {
|
|
175
597
|
const key = discoveryIssueKey(discovery.issue);
|
|
176
598
|
if (lastBlockedIssueKey !== key) {
|
|
@@ -202,7 +624,10 @@ export default function pullRequestExtension(
|
|
|
202
624
|
mergeCompleted = false;
|
|
203
625
|
displayedWidget = undefined;
|
|
204
626
|
commandGeneration = 0;
|
|
627
|
+
clearWorkflow(workflowContext);
|
|
205
628
|
activeInvocations.clear();
|
|
629
|
+
stopRoutingSpinner();
|
|
630
|
+
widgetKind = "presentation";
|
|
206
631
|
if (timer !== undefined) clearInterval(timer);
|
|
207
632
|
timer = undefined;
|
|
208
633
|
active?.abort();
|
|
@@ -255,8 +680,9 @@ export default function pullRequestExtension(
|
|
|
255
680
|
if (!controller.signal.aborted && sessionGeneration === generation) {
|
|
256
681
|
if (!displayEstablished) {
|
|
257
682
|
const unavailable = unavailablePrDisplay();
|
|
683
|
+
displayedWidget = undefined;
|
|
258
684
|
ctx.ui.setStatus(UI_KEY, formatPrFooter(unavailable, ctx.ui.theme));
|
|
259
|
-
|
|
685
|
+
reconcileWidget(ctx);
|
|
260
686
|
displayEstablished = true;
|
|
261
687
|
}
|
|
262
688
|
reportRefreshFailure();
|
|
@@ -301,6 +727,13 @@ export default function pullRequestExtension(
|
|
|
301
727
|
queued = false;
|
|
302
728
|
};
|
|
303
729
|
|
|
730
|
+
pi.on("before_agent_start", (event) => {
|
|
731
|
+
const selected = workflowContext;
|
|
732
|
+
if (selected?.queuedPrompt && matchesWorkflowPrompt(event.prompt, selected.queuedPrompt)) {
|
|
733
|
+
selected.queuedPrompt = undefined;
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
|
|
304
737
|
pi.on("session_start", async (_event, ctx) => {
|
|
305
738
|
stop();
|
|
306
739
|
const generation = sessionGeneration;
|
|
@@ -317,19 +750,33 @@ export default function pullRequestExtension(
|
|
|
317
750
|
|
|
318
751
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
319
752
|
if (!ctx.hasUI || !ctx.isIdle() || !context) return;
|
|
753
|
+
const selected = workflowContext;
|
|
754
|
+
const helperSettled = selected?.usedSinceSettlement ?? false;
|
|
755
|
+
const queuedHelperPending = selected?.queuedPrompt !== undefined && !helperSettled;
|
|
320
756
|
let workflowSettled = false;
|
|
321
757
|
let createWorkflowSettled = false;
|
|
322
758
|
for (const [invocation, phase] of activeInvocations) {
|
|
323
759
|
if (phase !== "workflow" && phase !== "create-workflow") continue;
|
|
760
|
+
if (queuedHelperPending) continue;
|
|
324
761
|
activeInvocations.delete(invocation);
|
|
325
762
|
workflowSettled = true;
|
|
326
763
|
if (phase === "create-workflow") createWorkflowSettled = true;
|
|
327
764
|
}
|
|
765
|
+
if (selected) {
|
|
766
|
+
const conflictPending = (selected.route === "create" || selected.route === "update-branch") &&
|
|
767
|
+
selected.workflow.state.phase === "conflict-awaiting-user";
|
|
768
|
+
if (!queuedHelperPending) {
|
|
769
|
+
if (helperSettled && conflictPending && !selected.conflictRetained) {
|
|
770
|
+
selected.usedSinceSettlement = false;
|
|
771
|
+
selected.conflictRetained = true;
|
|
772
|
+
} else clearWorkflow(selected);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
328
775
|
const delegatedRefresh = delegatedWorkPending && lastDiscovery !== "inactive";
|
|
329
776
|
delegatedWorkPending = false;
|
|
330
|
-
if (!workflowSettled && !delegatedRefresh) return;
|
|
777
|
+
if (!workflowSettled && !helperSettled && !delegatedRefresh) return;
|
|
331
778
|
cancelRefresh();
|
|
332
|
-
if (createWorkflowSettled) pendingWorkspaceRename = true;
|
|
779
|
+
if (createWorkflowSettled || helperSettled && selected?.route === "create") pendingWorkspaceRename = true;
|
|
333
780
|
await refresh().catch(reportRefreshFailure);
|
|
334
781
|
});
|
|
335
782
|
|
|
@@ -344,25 +791,42 @@ export default function pullRequestExtension(
|
|
|
344
791
|
}
|
|
345
792
|
});
|
|
346
793
|
|
|
347
|
-
const commandHandler = createCommandHandler(pi, {
|
|
794
|
+
const commandHandler = createCommandHandler(pi, {
|
|
795
|
+
loadCurrentPullRequest: load,
|
|
796
|
+
reserveWorkflow,
|
|
797
|
+
markWorkflowPromptQueued,
|
|
798
|
+
releaseWorkflow,
|
|
799
|
+
});
|
|
348
800
|
pi.registerCommand("pr", {
|
|
349
|
-
description: "[
|
|
801
|
+
description: "[--base=<host>/<owner>/<repository>:<ref>] — Run the current branch pull request next step",
|
|
350
802
|
handler: async (args, ctx) => {
|
|
351
803
|
if (!ctx.hasUI || !context) return;
|
|
352
804
|
const generation = sessionGeneration;
|
|
353
805
|
const invocation = ++commandGeneration;
|
|
354
806
|
activeInvocations.set(invocation, "routing");
|
|
355
|
-
|
|
807
|
+
reconcileWidget(ctx);
|
|
808
|
+
const routeResolved = (_nextStep?: unknown): void => {
|
|
809
|
+
if (sessionGeneration !== generation || activeInvocations.get(invocation) !== "routing") return;
|
|
810
|
+
activeInvocations.set(invocation, "resolved");
|
|
811
|
+
reconcileWidget(ctx);
|
|
812
|
+
};
|
|
813
|
+
const commandInvocation: PrCommandInvocation = Object.assign(routeResolved, {
|
|
814
|
+
sessionGeneration: generation,
|
|
815
|
+
assertCurrent() {
|
|
816
|
+
if (sessionGeneration !== generation) {
|
|
817
|
+
throw new Error("PR command session changed during dispatch");
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
});
|
|
356
821
|
let nextStep: Awaited<ReturnType<typeof commandHandler>>;
|
|
357
822
|
try {
|
|
358
|
-
nextStep = await commandHandler(args, ctx);
|
|
823
|
+
nextStep = await commandHandler(args, ctx, commandInvocation);
|
|
824
|
+
routeResolved();
|
|
359
825
|
} catch (error) {
|
|
360
826
|
if (sessionGeneration === generation) {
|
|
361
827
|
cancelRefresh();
|
|
362
828
|
activeInvocations.delete(invocation);
|
|
363
|
-
|
|
364
|
-
setWidget(ctx, displayedWidget);
|
|
365
|
-
}
|
|
829
|
+
reconcileWidget(ctx);
|
|
366
830
|
refreshInBackground();
|
|
367
831
|
}
|
|
368
832
|
throw error;
|
|
@@ -372,7 +836,7 @@ export default function pullRequestExtension(
|
|
|
372
836
|
if (WORKFLOW_ROUTES.has(nextStep)) {
|
|
373
837
|
activeInvocations.set(invocation, nextStep === "create" ? "create-workflow" : "workflow");
|
|
374
838
|
if (nextStep === "create") ctx.ui.setStatus(UI_KEY, undefined);
|
|
375
|
-
|
|
839
|
+
reconcileWidget(ctx);
|
|
376
840
|
} else {
|
|
377
841
|
if (nextStep === "merge") mergeCompleted = true;
|
|
378
842
|
activeInvocations.delete(invocation);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-pr",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.2",
|
|
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",
|
|
@@ -28,9 +28,11 @@
|
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
31
|
-
"@earendil-works/pi-tui": "^0.85.1"
|
|
31
|
+
"@earendil-works/pi-tui": "^0.85.1",
|
|
32
|
+
"typebox": "^1.3.15"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
35
|
+
"@henryqw/pi-config-store": "^1.1.0",
|
|
34
36
|
"@henryqw/pi-herdr": "^0.4.5"
|
|
35
37
|
},
|
|
36
38
|
"repository": {
|