@henryqw/pi-pr 6.0.8 → 6.2.1

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,8 +1,9 @@
1
1
  import type {
2
2
  ExtensionAPI,
3
- ExtensionCommandContext,
3
+ ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import { executeGitHubMerge } from "./pr-merge.ts";
6
+ import { needsFeedbackAttention } from "./pr-feedback-attention.ts";
6
7
  import {
7
8
  linkInferredPullRequest,
8
9
  loadCurrentPullRequest,
@@ -11,36 +12,35 @@ import {
11
12
  } from "./pr-github.ts";
12
13
  import {
13
14
  deriveNextStep,
14
- deriveRouteDecision,
15
- type FeedbackRouteBlocker,
16
15
  type NextStep,
17
16
  type PullRequestTarget,
18
- type RouteIntent,
19
17
  } from "./pr-routing.ts";
20
18
 
21
- type WorkflowNextStep = Extract<NextStep, "create" | "update-branch" | "sweep" | "fix-ci">;
19
+ type WorkflowNextStep = Extract<NextStep, "create" | "publish-work" | "update-branch" | "sweep" | "fix-ci">;
22
20
 
23
21
  const WORKFLOWS: Record<WorkflowNextStep, string> = {
24
22
  create: "skill:pi-pr-create",
23
+ "publish-work": "skill:pi-pr-publish-work",
25
24
  "update-branch": "skill:pi-pr-update-branch",
26
25
  sweep: "skill:pi-pr-comment-sweep",
27
26
  "fix-ci": "skill:pi-pr-fix-ci",
28
27
  };
29
28
  type WorkflowReservation =
30
- | { route: "create"; target: PullRequestTarget; base?: string }
29
+ | { route: "create"; target: PullRequestTarget }
31
30
  | { route: Exclude<WorkflowNextStep, "create">; pullRequest: CurrentPullRequest };
32
- type WorkflowLaunchAction = "prepare" | "merge" | "start" | "resume" | "collect";
31
+ type WorkflowLaunchAction = "prepare" | "inspect" | "rebase" | "start" | "resume" | "collect";
33
32
  type WorkflowReservationResult = { runId: string; action: WorkflowLaunchAction };
34
33
 
35
34
  type PrCommandPi = Pick<ExtensionAPI, "exec" | "getCommands" | "sendUserMessage">;
36
35
  export type PrCommandInvocation = ((nextStep: NextStep) => void) & {
37
36
  sessionGeneration: number;
38
37
  assertCurrent(): void;
38
+ completedRoutes?: ReadonlySet<string>;
39
39
  };
40
40
  export type PrCommandHandler = (
41
41
  args: string,
42
- ctx: ExtensionCommandContext,
43
- onRouteResolved?: PrCommandInvocation | ((nextStep: NextStep) => void),
42
+ ctx: ExtensionContext,
43
+ onRouteResolved?: PrCommandInvocation,
44
44
  ) => Promise<NextStep>;
45
45
 
46
46
  export type WorkflowPromptIdentity = Readonly<{
@@ -52,67 +52,27 @@ export type WorkflowPromptIdentity = Readonly<{
52
52
 
53
53
  export type PrCommandDependencies = {
54
54
  loadCurrentPullRequest?: typeof loadCurrentPullRequest;
55
+ needsFeedbackAttention?: typeof needsFeedbackAttention;
55
56
  linkInferredPullRequest?: typeof linkInferredPullRequest;
57
+ inspectSweepRecovery?: (pullRequest: CurrentPullRequest, ctx: ExtensionContext) => Promise<boolean>;
58
+ inspectBranchRecovery?: (pullRequest: CurrentPullRequest, ctx: ExtensionContext) => Promise<boolean>;
56
59
  reserveWorkflow?: (
57
60
  reservation: WorkflowReservation,
58
- ctx: ExtensionCommandContext,
61
+ ctx: ExtensionContext,
59
62
  invocation?: PrCommandInvocation,
60
63
  ) => Promise<WorkflowReservationResult>;
61
64
  markWorkflowPromptQueued?: (identity: WorkflowPromptIdentity, queued: boolean) => void;
62
65
  releaseWorkflow?: (runId: string, invocation?: PrCommandInvocation) => void;
63
66
  };
64
67
 
65
- type ParsedPrArguments = {
66
- base?: string;
67
- intent: RouteIntent;
68
- instructions: string;
69
- };
70
-
71
- function parsePrArguments(args: string): ParsedPrArguments {
72
- const leading = args.trimStart();
73
- const trimmed = leading.trim();
74
- if (leading.startsWith("--feedback=")) throw new Error("/pr feedback syntax is --feedback");
75
- if (/^--feedback(?:\s|$)/.test(leading)) {
76
- if (trimmed !== "--feedback") throw new Error("/pr --feedback cannot be combined with other options or instructions");
77
- return { intent: "feedback", instructions: "" };
78
- }
79
- if (/(?:^|\s)--feedback(?=\s|=|$)/.test(leading)) {
80
- throw new Error("/pr --feedback cannot be combined with other options or instructions");
81
- }
82
- if (leading.startsWith("--base=")) throw new Error("/pr base syntax is --base <branch>");
83
- if (/^--base(?:\s|$)/.test(leading)) {
84
- const value = /^--base\s+(\S+)/.exec(leading);
85
- if (!value) throw new Error("/pr --base requires a branch");
86
- const instructions = leading.slice(value[0].length).trim();
87
- if (instructions.startsWith("--")) throw new Error(`Unknown /pr option: ${instructions.split(/\s+/, 1)[0]}`);
88
- return { base: value[1]!, intent: "automatic", instructions };
89
- }
90
- if (leading.startsWith("--")) throw new Error(`Unknown /pr option: ${leading.split(/\s+/, 1)[0]}`);
91
- return { intent: "automatic", instructions: args.trim() };
92
- }
93
-
94
- function feedbackBlockerMessage(blocker: FeedbackRouteBlocker): string {
95
- switch (blocker.kind) {
96
- case "discovery-blocked": return "/pr --feedback is blocked because pull request discovery is unsafe";
97
- case "pull-request-unavailable": return "/pr --feedback requires a current pull request";
98
- case "pull-request-not-open": return "/pr --feedback requires an open pull request";
99
- case "target-not-configured": return "/pr --feedback requires a configured pull request target; run /pr to link the branch first";
100
- case "worktree-dirty": return "/pr --feedback is blocked by a dirty worktree";
101
- case "head-not-equal": return `/pr --feedback is blocked by local HEAD ${blocker.relation}`;
102
- }
103
- }
104
-
105
68
  function workflowReservation(
106
69
  nextStep: WorkflowNextStep,
107
70
  discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
108
- base: string | undefined,
109
- instructions: string,
110
71
  ): WorkflowReservation {
111
72
  if (nextStep === "create") {
112
73
  if (discovery.kind !== "none") throw new Error("/pr create failed: creation target is unavailable");
113
- return { route: "create", target: discovery.creationTarget, ...(base === undefined ? {} : { base }) };
74
+ return { route: "create", target: discovery.creationTarget };
114
75
  }
115
- if (instructions) throw new Error("The current /pr helper route does not accept instructions");
116
76
  if (discovery.kind !== "current") throw new Error(`/pr ${nextStep} failed: pull request is unavailable`);
117
77
  return { route: nextStep, pullRequest: discovery.pullRequest };
118
78
  }
@@ -130,14 +90,13 @@ function packageWorkflowCommand(pi: PrCommandPi, route: WorkflowNextStep) {
130
90
 
131
91
  async function dispatchWorkflow(
132
92
  pi: PrCommandPi,
133
- ctx: ExtensionCommandContext,
93
+ ctx: ExtensionContext,
134
94
  route: WorkflowNextStep,
135
95
  reservation: WorkflowReservation,
136
96
  invocation: PrCommandInvocation | undefined,
137
97
  reserve: NonNullable<PrCommandDependencies["reserveWorkflow"]>,
138
98
  markPromptQueued: NonNullable<PrCommandDependencies["markWorkflowPromptQueued"]>,
139
99
  release: NonNullable<PrCommandDependencies["releaseWorkflow"]>,
140
- instructions: string,
141
100
  ): Promise<void> {
142
101
  const workflow = packageWorkflowCommand(pi, route);
143
102
  let runId: string | undefined;
@@ -152,7 +111,7 @@ async function dispatchWorkflow(
152
111
  ? { deliverAs: "followUp" as const, expandPromptTemplates: true }
153
112
  : { expandPromptTemplates: true };
154
113
  invocation?.assertCurrent();
155
- pi.sendUserMessage(`/${identity.skill} runId=${identity.runId} action=${identity.action}${instructions ? ` ${instructions}` : ""}`, options);
114
+ pi.sendUserMessage(`/${identity.skill} runId=${identity.runId} action=${identity.action}`, options);
156
115
  } catch (error) {
157
116
  if (runId !== undefined) release(runId, invocation);
158
117
  throw error;
@@ -169,7 +128,7 @@ function noActionNotification(pullRequest: CurrentPullRequest): { message: strin
169
128
  if (pullRequest.conditions.ci === "failure-blocked") {
170
129
  return { message: `PR #${pullRequest.number} has a failed CI check that cannot run the CI fix workflow`, type: "warning" };
171
130
  }
172
- const mutatingWorkflowSelected = pullRequest.conditions.baseUpdateRequired || pullRequest.conditions.conflict ||
131
+ const mutatingWorkflowSelected = pullRequest.conditions.conflict ||
173
132
  pullRequest.conditions.changesRequested || pullRequest.conditions.unresolvedThreads > 0 ||
174
133
  pullRequest.conditions.ci === "failure";
175
134
  if (mutatingWorkflowSelected && pullRequest.local.worktree === "dirty") {
@@ -207,16 +166,11 @@ function isSameConfirmedMerge(current: CurrentPullRequest, fresh: CurrentPullReq
207
166
 
208
167
  async function mergePullRequest(
209
168
  pi: PrCommandPi,
210
- ctx: ExtensionCommandContext,
169
+ ctx: ExtensionContext,
211
170
  current: CurrentPullRequest,
212
171
  load: typeof loadCurrentPullRequest,
213
- ): Promise<boolean> {
214
- const confirmed = await ctx.ui.confirm(
215
- `Merge PR #${current.number}?`,
216
- "Method: squash.",
217
- );
218
- if (!confirmed) return false;
219
-
172
+ needsFeedback: typeof needsFeedbackAttention,
173
+ ): Promise<void> {
220
174
  await executeGitHubMerge({
221
175
  exec: (command, args, options) => pi.exec(command, args, {
222
176
  ...options,
@@ -241,32 +195,27 @@ async function mergePullRequest(
241
195
  if (deriveNextStep(discovery) !== "merge") {
242
196
  throw new Error(`PR #${fresh.number} merge cancelled: pull request is no longer merge-ready`);
243
197
  }
198
+ if (await needsFeedback(fresh, { cwd: ctx.cwd, signal: ctx.signal, load })) {
199
+ throw new Error(`PR #${fresh.number} merge cancelled: new feedback needs review`);
200
+ }
244
201
  },
245
202
  });
246
- return true;
247
203
  }
248
204
 
249
205
  async function linkPullRequest(
250
206
  pi: PrCommandPi,
251
- ctx: ExtensionCommandContext,
207
+ ctx: ExtensionContext,
252
208
  current: CurrentPullRequest,
253
209
  load: typeof loadCurrentPullRequest,
254
210
  link: typeof linkInferredPullRequest,
255
- ): Promise<void> {
256
- const targetName = `${current.target.remote}/${current.target.ref}`;
257
- const confirmed = await ctx.ui.confirm(
258
- `Link pull request branch to ${targetName}?`,
259
- `Set ${targetName} as the push target for this branch.`,
260
- );
261
- if (!confirmed) return;
262
-
211
+ ): Promise<CurrentPullRequest> {
263
212
  const discovery = await load(pi, ctx);
264
213
  if (
265
214
  discovery.kind !== "current" ||
266
215
  discovery.pullRequest.target.provenance !== "inferred" ||
267
216
  !samePullRequestSnapshot(current, discovery.pullRequest)
268
217
  ) throw new Error("Link branch cancelled: inferred pull request context changed");
269
- await link(pi, ctx, discovery.pullRequest);
218
+ return await link(pi, ctx, discovery.pullRequest);
270
219
  }
271
220
 
272
221
  export function createPrCommandHandler(
@@ -274,32 +223,48 @@ export function createPrCommandHandler(
274
223
  dependencies: PrCommandDependencies = {},
275
224
  ): PrCommandHandler {
276
225
  const load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
226
+ const needsFeedback = dependencies.needsFeedbackAttention ?? needsFeedbackAttention;
277
227
  const link = dependencies.linkInferredPullRequest ?? linkInferredPullRequest;
228
+ const inspectSweepRecovery = dependencies.inspectSweepRecovery ?? (async () => false);
229
+ const inspectBranchRecovery = dependencies.inspectBranchRecovery ?? (async () => false);
278
230
  const reserve = dependencies.reserveWorkflow ?? (async () => {
279
231
  throw new Error("/pr workflow tools are unavailable");
280
232
  });
281
233
  const markPromptQueued = dependencies.markWorkflowPromptQueued ?? (() => {});
282
234
  const release = dependencies.releaseWorkflow ?? (() => {});
283
- return async (args, ctx, onRouteResolved) => {
284
- const commandInvocation = onRouteResolved && "assertCurrent" in onRouteResolved
285
- ? onRouteResolved as PrCommandInvocation
286
- : undefined;
287
- const { base, intent, instructions } = parsePrArguments(args);
288
- const discovery = await load(pi, ctx, undefined, undefined, base);
289
- commandInvocation?.assertCurrent();
290
- const decision = deriveRouteDecision(discovery, intent);
291
- if (decision.kind === "feedback-blocked") {
292
- onRouteResolved?.("blocked");
293
- throw new Error(feedbackBlockerMessage(decision.blocker));
235
+ const handle = async (
236
+ args: string, ctx: ExtensionContext,
237
+ onRouteResolved?: PrCommandInvocation,
238
+ linkedAuthority?: CurrentPullRequest,
239
+ ): Promise<NextStep> => {
240
+ if (args.trim()) throw new Error("/pr does not accept arguments");
241
+ const discovery = await load(pi, ctx);
242
+ onRouteResolved?.assertCurrent();
243
+ if (linkedAuthority && (discovery.kind !== "current" || discovery.pullRequest.target.provenance !== "configured" ||
244
+ !isSameConfirmedMerge(linkedAuthority, discovery.pullRequest))) {
245
+ throw new Error("Link branch continuation cancelled: configured pull request context changed");
294
246
  }
295
- const nextStep = decision.nextStep;
296
- onRouteResolved?.(nextStep);
297
- if (base !== undefined && nextStep !== "create") {
298
- throw new Error("/pr --base is accepted only for pull request creation");
247
+ let nextStep = deriveNextStep(discovery);
248
+ if (discovery.kind === "current" && discovery.pullRequest.lifecycle === "open" &&
249
+ !discovery.pullRequest.conditions.draft && discovery.pullRequest.target.provenance === "configured" &&
250
+ await inspectBranchRecovery(discovery.pullRequest, ctx)) {
251
+ nextStep = "update-branch";
252
+ onRouteResolved?.assertCurrent();
253
+ }
254
+ if (nextStep !== "update-branch" && discovery.kind === "current" && discovery.pullRequest.lifecycle === "open" &&
255
+ !discovery.pullRequest.conditions.draft && discovery.pullRequest.target.provenance === "configured" &&
256
+ await inspectSweepRecovery(discovery.pullRequest, ctx)) {
257
+ nextStep = "sweep";
258
+ onRouteResolved?.assertCurrent();
299
259
  }
300
- if (instructions && !(nextStep in WORKFLOWS)) {
301
- throw new Error("The current /pr route does not accept instructions");
260
+ if (discovery.kind === "current" && (nextStep === "merge" || nextStep === "none") &&
261
+ discovery.pullRequest.lifecycle === "open" && !discovery.pullRequest.conditions.draft &&
262
+ discovery.pullRequest.target.provenance === "configured" &&
263
+ discovery.pullRequest.local.worktree === "clean" && discovery.pullRequest.local.head === "equal") {
264
+ if (await needsFeedback(discovery.pullRequest, { cwd: ctx.cwd, signal: ctx.signal, load })) nextStep = "sweep";
265
+ onRouteResolved?.assertCurrent();
302
266
  }
267
+ onRouteResolved?.(nextStep);
303
268
  if (discovery.kind === "inactive") return nextStep;
304
269
  if (discovery.kind === "blocked") {
305
270
  return nextStep;
@@ -313,28 +278,34 @@ export function createPrCommandHandler(
313
278
  }
314
279
  if (nextStep === "link-branch") {
315
280
  if (discovery.kind !== "current") throw new Error("/pr link failed: pull request is unavailable");
316
- await linkPullRequest(pi, ctx, discovery.pullRequest, load, link);
317
- return nextStep;
281
+ const linked = await linkPullRequest(pi, ctx, discovery.pullRequest, load, link);
282
+ if (linked.target.provenance !== "configured") throw new Error("Link branch failed: target was not configured");
283
+ return await handle("", ctx, onRouteResolved, linked);
318
284
  }
319
285
  if (nextStep === "merge") {
320
286
  if (discovery.kind !== "current") throw new Error("/pr merge failed: pull request is unavailable");
321
- return await mergePullRequest(pi, ctx, discovery.pullRequest, load) ? "merge" : "none";
287
+ await mergePullRequest(pi, ctx, discovery.pullRequest, load, needsFeedback);
288
+ return "merge";
322
289
  }
323
290
 
324
291
  if (!(nextStep in WORKFLOWS)) throw new Error(`/pr cannot dispatch route ${nextStep}`);
325
292
  const route = nextStep as WorkflowNextStep;
326
- const reservation = workflowReservation(route, discovery, base, instructions);
293
+ if (onRouteResolved?.completedRoutes?.has(route)) {
294
+ ctx.ui.notify(`PR ${route} already ran; inspect fresh state before retrying`, "warning");
295
+ return "none";
296
+ }
297
+ const reservation = workflowReservation(route, discovery);
327
298
  await dispatchWorkflow(
328
299
  pi,
329
300
  ctx,
330
301
  route,
331
302
  reservation,
332
- commandInvocation,
303
+ onRouteResolved,
333
304
  reserve,
334
305
  markPromptQueued,
335
306
  release,
336
- route === "create" ? instructions : "",
337
307
  );
338
308
  return nextStep;
339
309
  };
310
+ return handle;
340
311
  }