@henryqw/pi-pr 3.1.10 → 4.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -12
- package/docs/pr-routing.svg +1 -1
- package/extensions/pr-ci.ts +908 -0
- package/extensions/pr-command.ts +124 -22
- package/extensions/pr-comment-sweep.ts +1102 -0
- package/extensions/pr-create.ts +674 -0
- package/extensions/pr-execution.ts +558 -0
- package/extensions/pr-feedback.ts +519 -0
- package/extensions/pr-github.ts +690 -163
- package/extensions/pr-merge.ts +4 -44
- package/extensions/pr-routing.ts +8 -3
- package/extensions/pr-ui.ts +2 -3
- package/extensions/pr-update-branch.ts +327 -0
- package/extensions/pr.ts +409 -26
- 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,3 +1,5 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
1
3
|
import {
|
|
2
4
|
isBashToolResult,
|
|
3
5
|
type ExtensionAPI,
|
|
@@ -5,15 +7,25 @@ import {
|
|
|
5
7
|
} from "@earendil-works/pi-coding-agent";
|
|
6
8
|
import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
7
9
|
import { createHerdrClient } from "@henryqw/pi-herdr";
|
|
8
|
-
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";
|
|
9
21
|
import {
|
|
10
|
-
hasLocalCommit,
|
|
11
22
|
loadCurrentPullRequest,
|
|
12
23
|
parsePullRequestObservation,
|
|
13
24
|
pullRequestObservation,
|
|
14
25
|
samePullRequestObservation,
|
|
15
26
|
type PullRequestObservation,
|
|
16
27
|
} from "./pr-github.ts";
|
|
28
|
+
import { runChecked, spawnBounded } from "./pr-execution.ts";
|
|
17
29
|
import {
|
|
18
30
|
discoveryIssueKey,
|
|
19
31
|
discoveryIssueMessage,
|
|
@@ -23,6 +35,7 @@ import {
|
|
|
23
35
|
unavailablePrDisplay,
|
|
24
36
|
type PrDisplay,
|
|
25
37
|
} from "./pr-ui.ts";
|
|
38
|
+
import { PullRequestBranchUpdater, type UpdateBranchOptions } from "./pr-update-branch.ts";
|
|
26
39
|
|
|
27
40
|
const POLL_INTERVAL_MS = 30_000;
|
|
28
41
|
const ROUTING_SPINNER_INTERVAL_MS = 80;
|
|
@@ -36,13 +49,149 @@ const GIT_COMMIT = /(?:^|[;&|]\s*|\n\s*)git\s+commit(?=\s|$|[;&|])/;
|
|
|
36
49
|
const GIT_PUSH = /(?:^|[;&|]\s*|\n\s*)git\s+push(?=\s|$|[;&|])/;
|
|
37
50
|
const WORKFLOW_ROUTES = new Set(["create", "update-branch", "sweep", "fix-ci"]);
|
|
38
51
|
const DELEGATED_TOOLS = new Set(["delegate_task", "delegate_flow", "delegate_flow_continue"]);
|
|
52
|
+
const CLOSED = { additionalProperties: false } as const;
|
|
53
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
54
|
+
|
|
55
|
+
const RouteRunId = Type.String({ minLength: 36, maxLength: 36, pattern: UUID.source });
|
|
56
|
+
const ResolvedPaths = Type.Array(Type.String({ minLength: 1, maxLength: 4_096 }), { minItems: 1, maxItems: 128 });
|
|
57
|
+
const OwnedPaths = Type.Array(Type.String({ minLength: 1, maxLength: 4_096 }), { maxItems: 128 });
|
|
58
|
+
const SweepGuard = Type.Object({
|
|
59
|
+
epoch: Type.Integer({ minimum: 1 }),
|
|
60
|
+
runId: Type.String({ minLength: 1, maxLength: 128 }),
|
|
61
|
+
generation: Type.Integer({ minimum: 1 }),
|
|
62
|
+
fingerprint: Type.String({ minLength: 64, maxLength: 64 }),
|
|
63
|
+
}, CLOSED);
|
|
64
|
+
const SweepLedgerEntry = Type.Object({
|
|
65
|
+
id: Type.String({ minLength: 1, maxLength: 1_024 }),
|
|
66
|
+
kind: Type.Union([
|
|
67
|
+
Type.Literal("conversation_comment"), Type.Literal("review"), Type.Literal("thread"), Type.Literal("thread_comment"),
|
|
68
|
+
]),
|
|
69
|
+
disposition: Type.Union([Type.Literal("addressed"), Type.Literal("non-actionable"), Type.Literal("blocked")]),
|
|
70
|
+
note: Type.String({ maxLength: 2_048 }),
|
|
71
|
+
}, CLOSED);
|
|
72
|
+
const SweepLedger = Type.Array(SweepLedgerEntry, { maxItems: 1_000 });
|
|
73
|
+
const SweepProjection = Type.Object({
|
|
74
|
+
generation: Type.Integer({ minimum: 1 }),
|
|
75
|
+
contentFingerprint: Type.String({ minLength: 64, maxLength: 64 }),
|
|
76
|
+
items: Type.Array(Type.Object({
|
|
77
|
+
id: Type.String({ minLength: 1, maxLength: 1_024 }),
|
|
78
|
+
kind: Type.Union([
|
|
79
|
+
Type.Literal("conversation_comment"), Type.Literal("review"), Type.Literal("thread"), Type.Literal("thread_comment"),
|
|
80
|
+
]),
|
|
81
|
+
}, CLOSED), { maxItems: 1_000 }),
|
|
82
|
+
threads: Type.Array(Type.Object({
|
|
83
|
+
id: Type.String({ minLength: 1, maxLength: 1_024 }),
|
|
84
|
+
isResolved: Type.Boolean(),
|
|
85
|
+
}, CLOSED), { maxItems: 1_000 }),
|
|
86
|
+
}, CLOSED);
|
|
87
|
+
const SweepChecks = Type.Array(Type.Object({
|
|
88
|
+
command: Type.String({ minLength: 1, maxLength: 1_024 }),
|
|
89
|
+
args: Type.Array(Type.String({ maxLength: 4_096 }), { maxItems: 256 }),
|
|
90
|
+
}, CLOSED), { maxItems: 32 });
|
|
91
|
+
|
|
92
|
+
const UpdateBranchParameters = Type.Union([
|
|
93
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("merge") }, CLOSED),
|
|
94
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("continue"), resolvedPaths: ResolvedPaths }, CLOSED),
|
|
95
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("publish") }, CLOSED),
|
|
96
|
+
]);
|
|
97
|
+
const CreateParameters = Type.Union([
|
|
98
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("prepare") }, CLOSED),
|
|
99
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("merge") }, CLOSED),
|
|
100
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("continue"), resolvedPaths: ResolvedPaths }, CLOSED),
|
|
101
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("push") }, CLOSED),
|
|
102
|
+
Type.Object({
|
|
103
|
+
runId: RouteRunId,
|
|
104
|
+
action: Type.Literal("publish"),
|
|
105
|
+
title: Type.String({ minLength: 1, maxLength: 256 }),
|
|
106
|
+
body: Type.String({ maxLength: 65_536 }),
|
|
107
|
+
}, CLOSED),
|
|
108
|
+
]);
|
|
109
|
+
const SweepParameters = Type.Union([
|
|
110
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("start") }, CLOSED),
|
|
111
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("resume") }, CLOSED),
|
|
112
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("show"), guard: SweepGuard, id: Type.String({ minLength: 1, maxLength: 1_024 }) }, CLOSED),
|
|
113
|
+
Type.Object({
|
|
114
|
+
runId: RouteRunId,
|
|
115
|
+
action: Type.Literal("record"),
|
|
116
|
+
guard: SweepGuard,
|
|
117
|
+
ledger: SweepLedger,
|
|
118
|
+
ownedPaths: Type.Optional(OwnedPaths),
|
|
119
|
+
}, CLOSED),
|
|
120
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("publish"), guard: SweepGuard }, CLOSED),
|
|
121
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("refresh"), guard: SweepGuard }, CLOSED),
|
|
122
|
+
Type.Object({
|
|
123
|
+
runId: RouteRunId,
|
|
124
|
+
action: Type.Literal("resolve"),
|
|
125
|
+
guard: SweepGuard,
|
|
126
|
+
threadIds: Type.Array(Type.String({ minLength: 1, maxLength: 1_024 }), { maxItems: 1_000 }),
|
|
127
|
+
}, CLOSED),
|
|
128
|
+
Type.Object({
|
|
129
|
+
runId: RouteRunId,
|
|
130
|
+
action: Type.Literal("finalize"),
|
|
131
|
+
guard: SweepGuard,
|
|
132
|
+
projection: SweepProjection,
|
|
133
|
+
checks: SweepChecks,
|
|
134
|
+
}, CLOSED),
|
|
135
|
+
]);
|
|
136
|
+
const FixCiParameters = Type.Union([
|
|
137
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("collect") }, CLOSED),
|
|
138
|
+
Type.Object({ runId: RouteRunId, action: Type.Literal("publish") }, CLOSED),
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
type UpdateBranchWorkflow = Pick<PullRequestBranchUpdater, "state" | "merge" | "continue" | "publish">;
|
|
142
|
+
type CreateWorkflow = Pick<PullRequestCreator, "state" | "prepare" | "merge" | "continue" | "push" | "publish">;
|
|
143
|
+
type SweepWorkflow = Pick<PullRequestCommentSweep, "start" | "resume" | "show" | "record" | "publish" | "refresh" | "resolve" | "finalize">;
|
|
144
|
+
type FixCiWorkflow = Pick<PullRequestCiFixer, "collect" | "publish">;
|
|
145
|
+
|
|
146
|
+
type WorkflowContextBase = {
|
|
147
|
+
runId: string;
|
|
148
|
+
sessionGeneration: number;
|
|
149
|
+
worktree: string;
|
|
150
|
+
controller: AbortController;
|
|
151
|
+
usedSinceSettlement: boolean;
|
|
152
|
+
queuedPrompt?: WorkflowPromptIdentity;
|
|
153
|
+
conflictRetained: boolean;
|
|
154
|
+
};
|
|
155
|
+
type WorkflowContext =
|
|
156
|
+
| (WorkflowContextBase & { route: "update-branch"; workflow: UpdateBranchWorkflow })
|
|
157
|
+
| (WorkflowContextBase & { route: "create"; base?: string; workflow: CreateWorkflow })
|
|
158
|
+
| (WorkflowContextBase & { route: "sweep"; workflow: SweepWorkflow })
|
|
159
|
+
| (WorkflowContextBase & { route: "fix-ci"; workflow: FixCiWorkflow });
|
|
39
160
|
|
|
40
161
|
type PullRequestExtensionDependencies = {
|
|
41
162
|
loadCurrentPullRequest?: typeof loadCurrentPullRequest;
|
|
42
|
-
hasLocalCommit?: typeof hasLocalCommit;
|
|
43
163
|
createPrCommandHandler?: typeof createPrCommandHandler;
|
|
164
|
+
createBranchUpdater?: (options: UpdateBranchOptions) => UpdateBranchWorkflow;
|
|
165
|
+
createPullRequestCreator?: (options: CreatePullRequestOptions) => CreateWorkflow;
|
|
166
|
+
createCommentSweep?: (options: PullRequestCommentSweepOptions) => SweepWorkflow;
|
|
167
|
+
createCiFixer?: (options: PullRequestCiFixOptions) => FixCiWorkflow;
|
|
168
|
+
canonicalWorktree?: (cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
169
|
+
newRunId?: () => string;
|
|
44
170
|
};
|
|
45
171
|
|
|
172
|
+
async function canonicalWorktree(cwd: string, signal?: AbortSignal): Promise<string> {
|
|
173
|
+
const result = await runChecked(spawnBounded, "git", ["rev-parse", "--show-toplevel"], { cwd, signal });
|
|
174
|
+
const normalized = result.stdout.replace(/\r\n/g, "\n");
|
|
175
|
+
const lines = (normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized).split("\n");
|
|
176
|
+
if (lines.length !== 1 || !lines[0]) throw new Error("Git worktree root resolution returned invalid output");
|
|
177
|
+
return await realpath(lines[0]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function toolResult(value: unknown) {
|
|
181
|
+
return {
|
|
182
|
+
content: [{ type: "text" as const, text: JSON.stringify(value) }],
|
|
183
|
+
details: value,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function matchesWorkflowPrompt(prompt: string, identity: WorkflowPromptIdentity): boolean {
|
|
188
|
+
const tokens = new Set(prompt.split(/\s+/).filter(Boolean));
|
|
189
|
+
const skillName = identity.skill.startsWith("skill:") ? identity.skill.slice("skill:".length) : "";
|
|
190
|
+
const matchesSkill = tokens.has(`/${identity.skill}`) ||
|
|
191
|
+
(skillName !== "" && tokens.has("<skill") && tokens.has(`name="${skillName}"`));
|
|
192
|
+
return matchesSkill && tokens.has(`runId=${identity.runId}`) && tokens.has(`action=${identity.action}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
46
195
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
47
196
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
48
197
|
}
|
|
@@ -104,13 +253,24 @@ export default function pullRequestExtension(
|
|
|
104
253
|
};
|
|
105
254
|
|
|
106
255
|
const discover = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
|
|
107
|
-
const detectLocalCommit = dependencies.hasLocalCommit ?? hasLocalCommit;
|
|
108
256
|
const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
|
|
257
|
+
const createBranchUpdater = dependencies.createBranchUpdater ?? ((options) => new PullRequestBranchUpdater(options));
|
|
258
|
+
const createPullRequestCreator = dependencies.createPullRequestCreator ?? ((options) => new PullRequestCreator(options));
|
|
259
|
+
const createCommentSweep = dependencies.createCommentSweep ?? ((options) => new PullRequestCommentSweep(options));
|
|
260
|
+
const createCiFixer = dependencies.createCiFixer ?? ((options) => new PullRequestCiFixer(options));
|
|
261
|
+
const resolveCanonicalWorktree = dependencies.canonicalWorktree ?? canonicalWorktree;
|
|
262
|
+
const newRunId = dependencies.newRunId ?? randomUUID;
|
|
109
263
|
let context: ExtensionContext | undefined;
|
|
110
264
|
let observation: PullRequestObservation | undefined;
|
|
111
|
-
const load: typeof loadCurrentPullRequest = async (
|
|
265
|
+
const load: typeof loadCurrentPullRequest = async (
|
|
266
|
+
api,
|
|
267
|
+
loadContext,
|
|
268
|
+
inspectedLocal,
|
|
269
|
+
_observed,
|
|
270
|
+
explicitCreationBase,
|
|
271
|
+
) => {
|
|
112
272
|
const generation = sessionGeneration;
|
|
113
|
-
const discovery = await discover(api, loadContext, inspectedLocal, observation);
|
|
273
|
+
const discovery = await discover(api, loadContext, inspectedLocal, observation, explicitCreationBase);
|
|
114
274
|
if (generation !== sessionGeneration) return discovery;
|
|
115
275
|
if (discovery.kind === "current") {
|
|
116
276
|
const current = pullRequestObservation(discovery.pullRequest);
|
|
@@ -131,9 +291,9 @@ export default function pullRequestExtension(
|
|
|
131
291
|
let lastBlockedIssueKey: string | undefined;
|
|
132
292
|
let delegatedWorkPending = false;
|
|
133
293
|
let pendingWorkspaceRename = false;
|
|
134
|
-
let mergeCompleted = false;
|
|
135
294
|
let displayedWidget: PrDisplay | undefined;
|
|
136
295
|
let commandGeneration = 0;
|
|
296
|
+
let workflowContext: WorkflowContext | undefined;
|
|
137
297
|
const activeInvocations = new Map<number, "routing" | "resolved" | "create-workflow" | "workflow">();
|
|
138
298
|
let widgetKind: "presentation" | "routing" = "presentation";
|
|
139
299
|
let routingSpinnerFrame = 0;
|
|
@@ -148,6 +308,202 @@ export default function pullRequestExtension(
|
|
|
148
308
|
routingSpinnerComponent = undefined;
|
|
149
309
|
};
|
|
150
310
|
|
|
311
|
+
const clearWorkflow = (selected: WorkflowContext | undefined): void => {
|
|
312
|
+
if (!selected || workflowContext !== selected) return;
|
|
313
|
+
workflowContext = undefined;
|
|
314
|
+
selected.controller.abort();
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const reserveWorkflow: NonNullable<PrCommandDependencies["reserveWorkflow"]> = async (reservation, ctx, invocation) => {
|
|
318
|
+
if (!invocation) throw new Error("PR workflow command generation is unavailable");
|
|
319
|
+
invocation.assertCurrent();
|
|
320
|
+
const worktree = await resolveCanonicalWorktree(ctx.cwd, ctx.signal);
|
|
321
|
+
invocation.assertCurrent();
|
|
322
|
+
if (workflowContext) throw new Error(`PR workflow ${workflowContext.runId} is still active`);
|
|
323
|
+
const runId = newRunId();
|
|
324
|
+
if (!UUID.test(runId)) throw new Error("PR workflow runId generator returned an invalid UUID");
|
|
325
|
+
const common: WorkflowContextBase = {
|
|
326
|
+
runId,
|
|
327
|
+
sessionGeneration: invocation.sessionGeneration,
|
|
328
|
+
worktree,
|
|
329
|
+
controller: new AbortController(),
|
|
330
|
+
usedSinceSettlement: false,
|
|
331
|
+
conflictRetained: false,
|
|
332
|
+
};
|
|
333
|
+
switch (reservation.route) {
|
|
334
|
+
case "update-branch":
|
|
335
|
+
workflowContext = {
|
|
336
|
+
...common,
|
|
337
|
+
route: "update-branch",
|
|
338
|
+
workflow: createBranchUpdater({
|
|
339
|
+
cwd: worktree,
|
|
340
|
+
authority: reservation.pullRequest,
|
|
341
|
+
signal: common.controller.signal,
|
|
342
|
+
loadCurrentPullRequest: load,
|
|
343
|
+
}),
|
|
344
|
+
};
|
|
345
|
+
break;
|
|
346
|
+
case "create":
|
|
347
|
+
workflowContext = {
|
|
348
|
+
...common,
|
|
349
|
+
route: "create",
|
|
350
|
+
...(reservation.base === undefined ? {} : { base: reservation.base }),
|
|
351
|
+
workflow: createPullRequestCreator({
|
|
352
|
+
cwd: worktree,
|
|
353
|
+
target: reservation.target,
|
|
354
|
+
signal: common.controller.signal,
|
|
355
|
+
loadCurrentPullRequest: load,
|
|
356
|
+
}),
|
|
357
|
+
};
|
|
358
|
+
break;
|
|
359
|
+
case "sweep":
|
|
360
|
+
workflowContext = {
|
|
361
|
+
...common,
|
|
362
|
+
route: "sweep",
|
|
363
|
+
workflow: createCommentSweep({
|
|
364
|
+
cwd: worktree,
|
|
365
|
+
authority: reservation.pullRequest,
|
|
366
|
+
signal: common.controller.signal,
|
|
367
|
+
loadCurrentPullRequest: load,
|
|
368
|
+
}),
|
|
369
|
+
};
|
|
370
|
+
break;
|
|
371
|
+
case "fix-ci":
|
|
372
|
+
workflowContext = {
|
|
373
|
+
...common,
|
|
374
|
+
route: "fix-ci",
|
|
375
|
+
workflow: createCiFixer({
|
|
376
|
+
cwd: worktree,
|
|
377
|
+
authority: reservation.pullRequest,
|
|
378
|
+
signal: common.controller.signal,
|
|
379
|
+
loadCurrentPullRequest: load,
|
|
380
|
+
}),
|
|
381
|
+
};
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
return common.runId;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const markWorkflowPromptQueued: NonNullable<PrCommandDependencies["markWorkflowPromptQueued"]> = (identity, queued) => {
|
|
388
|
+
if (workflowContext?.runId !== identity.runId || workflowContext.route !== identity.route) {
|
|
389
|
+
throw new Error("PR workflow reservation is wrong or stale");
|
|
390
|
+
}
|
|
391
|
+
workflowContext.queuedPrompt = queued ? identity : undefined;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const releaseWorkflow: NonNullable<PrCommandDependencies["releaseWorkflow"]> = (runId, invocation) => {
|
|
395
|
+
const selected = workflowContext;
|
|
396
|
+
if (
|
|
397
|
+
invocation && selected?.runId === runId &&
|
|
398
|
+
selected.sessionGeneration === invocation.sessionGeneration
|
|
399
|
+
) clearWorkflow(selected);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const executeWorkflowAction = async <Route extends WorkflowContext["route"]>(
|
|
403
|
+
runId: string,
|
|
404
|
+
route: Route,
|
|
405
|
+
ctx: ExtensionContext,
|
|
406
|
+
signal: AbortSignal | undefined,
|
|
407
|
+
action: (selected: Extract<WorkflowContext, { route: Route }>) => Promise<unknown>,
|
|
408
|
+
) => {
|
|
409
|
+
signal?.throwIfAborted();
|
|
410
|
+
const selected = workflowContext;
|
|
411
|
+
if (!selected) throw new Error("No PR workflow is active");
|
|
412
|
+
if (selected.runId !== runId) throw new Error("PR workflow runId is wrong or stale");
|
|
413
|
+
if (selected.sessionGeneration !== sessionGeneration) throw new Error("PR workflow session is stale");
|
|
414
|
+
if (selected.route !== route) throw new Error(`PR workflow route is ${selected.route}, not ${route}`);
|
|
415
|
+
const abortRun = () => selected.controller.abort(signal?.reason);
|
|
416
|
+
if (signal?.aborted) abortRun();
|
|
417
|
+
else signal?.addEventListener("abort", abortRun, { once: true });
|
|
418
|
+
try {
|
|
419
|
+
selected.controller.signal.throwIfAborted();
|
|
420
|
+
const worktree = await resolveCanonicalWorktree(ctx.cwd, selected.controller.signal);
|
|
421
|
+
if (workflowContext !== selected || selected.sessionGeneration !== sessionGeneration) {
|
|
422
|
+
throw new Error("PR workflow session changed during validation");
|
|
423
|
+
}
|
|
424
|
+
selected.controller.signal.throwIfAborted();
|
|
425
|
+
if (worktree !== selected.worktree) throw new Error("PR workflow worktree is wrong or stale");
|
|
426
|
+
selected.usedSinceSettlement = true;
|
|
427
|
+
return toolResult(await action(selected as Extract<WorkflowContext, { route: Route }>));
|
|
428
|
+
} finally {
|
|
429
|
+
signal?.removeEventListener("abort", abortRun);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
pi.registerTool({
|
|
434
|
+
name: "pi_pr_update_branch",
|
|
435
|
+
label: "Update PR Branch",
|
|
436
|
+
description: "Run one guarded action for the /pr branch-update route.",
|
|
437
|
+
parameters: UpdateBranchParameters,
|
|
438
|
+
executionMode: "sequential",
|
|
439
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
440
|
+
return executeWorkflowAction(params.runId, "update-branch", ctx, signal, async (selected) => {
|
|
441
|
+
switch (params.action) {
|
|
442
|
+
case "merge": return await selected.workflow.merge();
|
|
443
|
+
case "continue": return await selected.workflow.continue(params.resolvedPaths);
|
|
444
|
+
case "publish": return await selected.workflow.publish();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
pi.registerTool({
|
|
451
|
+
name: "pi_pr_create",
|
|
452
|
+
label: "Create Pull Request",
|
|
453
|
+
description: "Run one guarded action for the /pr creation route.",
|
|
454
|
+
parameters: CreateParameters,
|
|
455
|
+
executionMode: "sequential",
|
|
456
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
457
|
+
return executeWorkflowAction(params.runId, "create", ctx, signal, async (selected) => {
|
|
458
|
+
switch (params.action) {
|
|
459
|
+
case "prepare": return await selected.workflow.prepare(selected.base);
|
|
460
|
+
case "merge": return await selected.workflow.merge();
|
|
461
|
+
case "continue": return await selected.workflow.continue(params.resolvedPaths);
|
|
462
|
+
case "push": return await selected.workflow.push();
|
|
463
|
+
case "publish": return await selected.workflow.publish(params.title, params.body);
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
pi.registerTool({
|
|
470
|
+
name: "pi_pr_sweep",
|
|
471
|
+
label: "Sweep PR Feedback",
|
|
472
|
+
description: "Run one guarded action for the /pr feedback route.",
|
|
473
|
+
parameters: SweepParameters,
|
|
474
|
+
executionMode: "sequential",
|
|
475
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
476
|
+
return executeWorkflowAction(params.runId, "sweep", ctx, signal, async (selected) => {
|
|
477
|
+
switch (params.action) {
|
|
478
|
+
case "start": return await selected.workflow.start();
|
|
479
|
+
case "resume": return await selected.workflow.resume();
|
|
480
|
+
case "show": return await selected.workflow.show(params.guard, params.id);
|
|
481
|
+
case "record": return await selected.workflow.record(params.guard, params.ledger, params.ownedPaths);
|
|
482
|
+
case "publish": return await selected.workflow.publish(params.guard);
|
|
483
|
+
case "refresh": return await selected.workflow.refresh(params.guard);
|
|
484
|
+
case "resolve": return await selected.workflow.resolve(params.guard, params.threadIds);
|
|
485
|
+
case "finalize": return await selected.workflow.finalize(params.guard, params.projection, params.checks);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
pi.registerTool({
|
|
492
|
+
name: "pi_pr_fix_ci",
|
|
493
|
+
label: "Fix PR CI",
|
|
494
|
+
description: "Run one guarded action for the /pr failed-CI route.",
|
|
495
|
+
parameters: FixCiParameters,
|
|
496
|
+
executionMode: "sequential",
|
|
497
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
498
|
+
return executeWorkflowAction(params.runId, "fix-ci", ctx, signal, async (selected) => {
|
|
499
|
+
switch (params.action) {
|
|
500
|
+
case "collect": return await selected.workflow.collect();
|
|
501
|
+
case "publish": return await selected.workflow.publish();
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
|
|
151
507
|
const setWidget = (ctx: ExtensionContext, display: PrDisplay | undefined): void => {
|
|
152
508
|
stopRoutingSpinner();
|
|
153
509
|
widgetKind = "presentation";
|
|
@@ -219,7 +575,6 @@ export default function pullRequestExtension(
|
|
|
219
575
|
const render = (
|
|
220
576
|
ctx: ExtensionContext,
|
|
221
577
|
discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
|
|
222
|
-
localCommit: boolean,
|
|
223
578
|
): void => {
|
|
224
579
|
if (discovery.kind === "inactive") {
|
|
225
580
|
if (timer !== undefined) clearInterval(timer);
|
|
@@ -231,7 +586,7 @@ export default function pullRequestExtension(
|
|
|
231
586
|
reconcileWidget(ctx);
|
|
232
587
|
return;
|
|
233
588
|
}
|
|
234
|
-
const display = projectPrDisplay(discovery
|
|
589
|
+
const display = projectPrDisplay(discovery);
|
|
235
590
|
const footer = formatPrFooter(display, ctx.ui.theme);
|
|
236
591
|
if ((discovery.kind === "current" || discovery.kind === "blocked") && footer === undefined) {
|
|
237
592
|
throw new Error("Pull request display is missing a footer");
|
|
@@ -267,9 +622,9 @@ export default function pullRequestExtension(
|
|
|
267
622
|
lastBlockedIssueKey = undefined;
|
|
268
623
|
delegatedWorkPending = false;
|
|
269
624
|
pendingWorkspaceRename = false;
|
|
270
|
-
mergeCompleted = false;
|
|
271
625
|
displayedWidget = undefined;
|
|
272
626
|
commandGeneration = 0;
|
|
627
|
+
clearWorkflow(workflowContext);
|
|
273
628
|
activeInvocations.clear();
|
|
274
629
|
stopRoutingSpinner();
|
|
275
630
|
widgetKind = "presentation";
|
|
@@ -313,21 +668,17 @@ export default function pullRequestExtension(
|
|
|
313
668
|
active = controller;
|
|
314
669
|
try {
|
|
315
670
|
let discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>;
|
|
316
|
-
let localCommit = false;
|
|
317
671
|
try {
|
|
318
672
|
discovery = await load(pi, loadContext);
|
|
319
673
|
if (controller.signal.aborted || sessionGeneration !== generation) return;
|
|
320
|
-
if (discovery.kind === "none") {
|
|
321
|
-
localCommit = !mergeCompleted && await detectLocalCommit(pi, loadContext);
|
|
322
|
-
}
|
|
323
674
|
} catch {
|
|
324
|
-
// Keep an established
|
|
675
|
+
// Keep an established footer. A refresh failure must not leave a stale action hint.
|
|
325
676
|
if (!controller.signal.aborted && sessionGeneration === generation) {
|
|
677
|
+
displayedWidget = undefined;
|
|
678
|
+
reconcileWidget(ctx);
|
|
326
679
|
if (!displayEstablished) {
|
|
327
680
|
const unavailable = unavailablePrDisplay();
|
|
328
|
-
displayedWidget = undefined;
|
|
329
681
|
ctx.ui.setStatus(UI_KEY, formatPrFooter(unavailable, ctx.ui.theme));
|
|
330
|
-
reconcileWidget(ctx);
|
|
331
682
|
displayEstablished = true;
|
|
332
683
|
}
|
|
333
684
|
reportRefreshFailure();
|
|
@@ -335,7 +686,7 @@ export default function pullRequestExtension(
|
|
|
335
686
|
return;
|
|
336
687
|
}
|
|
337
688
|
if (controller.signal.aborted || sessionGeneration !== generation) return;
|
|
338
|
-
render(ctx, discovery
|
|
689
|
+
render(ctx, discovery);
|
|
339
690
|
refreshFailureReported = false;
|
|
340
691
|
|
|
341
692
|
const pullRequest = discovery.kind === "current" ? discovery.pullRequest : undefined;
|
|
@@ -372,6 +723,13 @@ export default function pullRequestExtension(
|
|
|
372
723
|
queued = false;
|
|
373
724
|
};
|
|
374
725
|
|
|
726
|
+
pi.on("before_agent_start", (event) => {
|
|
727
|
+
const selected = workflowContext;
|
|
728
|
+
if (selected?.queuedPrompt && matchesWorkflowPrompt(event.prompt, selected.queuedPrompt)) {
|
|
729
|
+
selected.queuedPrompt = undefined;
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
|
|
375
733
|
pi.on("session_start", async (_event, ctx) => {
|
|
376
734
|
stop();
|
|
377
735
|
const generation = sessionGeneration;
|
|
@@ -388,19 +746,33 @@ export default function pullRequestExtension(
|
|
|
388
746
|
|
|
389
747
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
390
748
|
if (!ctx.hasUI || !ctx.isIdle() || !context) return;
|
|
749
|
+
const selected = workflowContext;
|
|
750
|
+
const helperSettled = selected?.usedSinceSettlement ?? false;
|
|
751
|
+
const queuedHelperPending = selected?.queuedPrompt !== undefined && !helperSettled;
|
|
391
752
|
let workflowSettled = false;
|
|
392
753
|
let createWorkflowSettled = false;
|
|
393
754
|
for (const [invocation, phase] of activeInvocations) {
|
|
394
755
|
if (phase !== "workflow" && phase !== "create-workflow") continue;
|
|
756
|
+
if (queuedHelperPending) continue;
|
|
395
757
|
activeInvocations.delete(invocation);
|
|
396
758
|
workflowSettled = true;
|
|
397
759
|
if (phase === "create-workflow") createWorkflowSettled = true;
|
|
398
760
|
}
|
|
761
|
+
if (selected) {
|
|
762
|
+
const conflictPending = (selected.route === "create" || selected.route === "update-branch") &&
|
|
763
|
+
selected.workflow.state.phase === "conflict-awaiting-user";
|
|
764
|
+
if (!queuedHelperPending) {
|
|
765
|
+
if (helperSettled && conflictPending && !selected.conflictRetained) {
|
|
766
|
+
selected.usedSinceSettlement = false;
|
|
767
|
+
selected.conflictRetained = true;
|
|
768
|
+
} else clearWorkflow(selected);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
399
771
|
const delegatedRefresh = delegatedWorkPending && lastDiscovery !== "inactive";
|
|
400
772
|
delegatedWorkPending = false;
|
|
401
|
-
if (!workflowSettled && !delegatedRefresh) return;
|
|
773
|
+
if (!workflowSettled && !helperSettled && !delegatedRefresh) return;
|
|
402
774
|
cancelRefresh();
|
|
403
|
-
if (createWorkflowSettled) pendingWorkspaceRename = true;
|
|
775
|
+
if (createWorkflowSettled || helperSettled && selected?.route === "create") pendingWorkspaceRename = true;
|
|
404
776
|
await refresh().catch(reportRefreshFailure);
|
|
405
777
|
});
|
|
406
778
|
|
|
@@ -410,28 +782,40 @@ export default function pullRequestExtension(
|
|
|
410
782
|
if (!isBashToolResult(event)) return;
|
|
411
783
|
const command = event.input.command;
|
|
412
784
|
if (typeof command === "string" && (GH_PR_CREATE.test(command) || GIT_COMMIT.test(command) || GIT_PUSH.test(command))) {
|
|
413
|
-
if (GIT_COMMIT.test(command)) mergeCompleted = false;
|
|
414
785
|
await refresh().catch(reportRefreshFailure);
|
|
415
786
|
}
|
|
416
787
|
});
|
|
417
788
|
|
|
418
|
-
const commandHandler = createCommandHandler(pi, {
|
|
789
|
+
const commandHandler = createCommandHandler(pi, {
|
|
790
|
+
loadCurrentPullRequest: load,
|
|
791
|
+
reserveWorkflow,
|
|
792
|
+
markWorkflowPromptQueued,
|
|
793
|
+
releaseWorkflow,
|
|
794
|
+
});
|
|
419
795
|
pi.registerCommand("pr", {
|
|
420
|
-
description: "[instructions] — Run the current branch pull request next step",
|
|
796
|
+
description: "[--base <branch>] [instructions] — Run the current branch pull request next step",
|
|
421
797
|
handler: async (args, ctx) => {
|
|
422
798
|
if (!ctx.hasUI || !context) return;
|
|
423
799
|
const generation = sessionGeneration;
|
|
424
800
|
const invocation = ++commandGeneration;
|
|
425
801
|
activeInvocations.set(invocation, "routing");
|
|
426
802
|
reconcileWidget(ctx);
|
|
427
|
-
const routeResolved = (): void => {
|
|
803
|
+
const routeResolved = (_nextStep?: unknown): void => {
|
|
428
804
|
if (sessionGeneration !== generation || activeInvocations.get(invocation) !== "routing") return;
|
|
429
805
|
activeInvocations.set(invocation, "resolved");
|
|
430
806
|
reconcileWidget(ctx);
|
|
431
807
|
};
|
|
808
|
+
const commandInvocation: PrCommandInvocation = Object.assign(routeResolved, {
|
|
809
|
+
sessionGeneration: generation,
|
|
810
|
+
assertCurrent() {
|
|
811
|
+
if (sessionGeneration !== generation) {
|
|
812
|
+
throw new Error("PR command session changed during dispatch");
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
});
|
|
432
816
|
let nextStep: Awaited<ReturnType<typeof commandHandler>>;
|
|
433
817
|
try {
|
|
434
|
-
nextStep = await commandHandler(args, ctx,
|
|
818
|
+
nextStep = await commandHandler(args, ctx, commandInvocation);
|
|
435
819
|
routeResolved();
|
|
436
820
|
} catch (error) {
|
|
437
821
|
if (sessionGeneration === generation) {
|
|
@@ -449,7 +833,6 @@ export default function pullRequestExtension(
|
|
|
449
833
|
if (nextStep === "create") ctx.ui.setStatus(UI_KEY, undefined);
|
|
450
834
|
reconcileWidget(ctx);
|
|
451
835
|
} else {
|
|
452
|
-
if (nextStep === "merge") mergeCompleted = true;
|
|
453
836
|
activeInvocations.delete(invocation);
|
|
454
837
|
refreshInBackground();
|
|
455
838
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-pr",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.3",
|
|
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": {
|