@henryqw/pi-pr 6.0.8 → 6.2.0

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,35 +12,34 @@ 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,
42
+ ctx: ExtensionContext,
43
43
  onRouteResolved?: PrCommandInvocation | ((nextStep: NextStep) => void),
44
44
  ) => Promise<NextStep>;
45
45
 
@@ -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,51 @@ 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) => {
235
+ const handle = async (
236
+ args: string, ctx: ExtensionContext,
237
+ onRouteResolved?: PrCommandInvocation | ((nextStep: NextStep) => void),
238
+ linkedAuthority?: CurrentPullRequest,
239
+ ): Promise<NextStep> => {
284
240
  const commandInvocation = onRouteResolved && "assertCurrent" in onRouteResolved
285
241
  ? onRouteResolved as PrCommandInvocation
286
242
  : undefined;
287
- const { base, intent, instructions } = parsePrArguments(args);
288
- const discovery = await load(pi, ctx, undefined, undefined, base);
243
+ if (args.trim()) throw new Error("/pr does not accept arguments");
244
+ const discovery = await load(pi, ctx);
289
245
  commandInvocation?.assertCurrent();
290
- const decision = deriveRouteDecision(discovery, intent);
291
- if (decision.kind === "feedback-blocked") {
292
- onRouteResolved?.("blocked");
293
- throw new Error(feedbackBlockerMessage(decision.blocker));
246
+ if (linkedAuthority && (discovery.kind !== "current" || discovery.pullRequest.target.provenance !== "configured" ||
247
+ !isSameConfirmedMerge(linkedAuthority, discovery.pullRequest))) {
248
+ throw new Error("Link branch continuation cancelled: configured pull request context changed");
294
249
  }
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");
250
+ let nextStep = deriveNextStep(discovery);
251
+ if (discovery.kind === "current" && discovery.pullRequest.lifecycle === "open" &&
252
+ !discovery.pullRequest.conditions.draft && discovery.pullRequest.target.provenance === "configured" &&
253
+ await inspectBranchRecovery(discovery.pullRequest, ctx)) {
254
+ nextStep = "update-branch";
255
+ commandInvocation?.assertCurrent();
256
+ }
257
+ if (nextStep !== "update-branch" && discovery.kind === "current" && discovery.pullRequest.lifecycle === "open" &&
258
+ !discovery.pullRequest.conditions.draft && discovery.pullRequest.target.provenance === "configured" &&
259
+ await inspectSweepRecovery(discovery.pullRequest, ctx)) {
260
+ nextStep = "sweep";
261
+ commandInvocation?.assertCurrent();
299
262
  }
300
- if (instructions && !(nextStep in WORKFLOWS)) {
301
- throw new Error("The current /pr route does not accept instructions");
263
+ if (discovery.kind === "current" && (nextStep === "merge" || nextStep === "none") &&
264
+ discovery.pullRequest.lifecycle === "open" && !discovery.pullRequest.conditions.draft &&
265
+ discovery.pullRequest.target.provenance === "configured" &&
266
+ discovery.pullRequest.local.worktree === "clean" && discovery.pullRequest.local.head === "equal") {
267
+ if (await needsFeedback(discovery.pullRequest, { cwd: ctx.cwd, signal: ctx.signal, load })) nextStep = "sweep";
268
+ commandInvocation?.assertCurrent();
302
269
  }
270
+ onRouteResolved?.(nextStep);
303
271
  if (discovery.kind === "inactive") return nextStep;
304
272
  if (discovery.kind === "blocked") {
305
273
  return nextStep;
@@ -313,17 +281,23 @@ export function createPrCommandHandler(
313
281
  }
314
282
  if (nextStep === "link-branch") {
315
283
  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;
284
+ const linked = await linkPullRequest(pi, ctx, discovery.pullRequest, load, link);
285
+ if (linked.target.provenance !== "configured") throw new Error("Link branch failed: target was not configured");
286
+ return await handle("", ctx, onRouteResolved, linked);
318
287
  }
319
288
  if (nextStep === "merge") {
320
289
  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";
290
+ await mergePullRequest(pi, ctx, discovery.pullRequest, load, needsFeedback);
291
+ return "merge";
322
292
  }
323
293
 
324
294
  if (!(nextStep in WORKFLOWS)) throw new Error(`/pr cannot dispatch route ${nextStep}`);
325
295
  const route = nextStep as WorkflowNextStep;
326
- const reservation = workflowReservation(route, discovery, base, instructions);
296
+ if (commandInvocation?.completedRoutes?.has(route)) {
297
+ ctx.ui.notify(`PR ${route} already ran; inspect fresh state before retrying`, "warning");
298
+ return "none";
299
+ }
300
+ const reservation = workflowReservation(route, discovery);
327
301
  await dispatchWorkflow(
328
302
  pi,
329
303
  ctx,
@@ -333,8 +307,8 @@ export function createPrCommandHandler(
333
307
  reserve,
334
308
  markPromptQueued,
335
309
  release,
336
- route === "create" ? instructions : "",
337
310
  );
338
311
  return nextStep;
339
312
  };
313
+ return handle;
340
314
  }