@henryqw/pi-pr 6.2.13 → 9.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.
@@ -1,4 +1,6 @@
1
+ import type { PrRun } from "./pr-run.ts";
1
2
  import { createHash } from "node:crypto";
3
+ import { stripVTControlCharacters } from "node:util";
2
4
  import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
3
5
  import {
4
6
  cloneCurrentPullRequest,
@@ -43,6 +45,13 @@ const STATUSES = new Set(["completed", "in_progress", "pending", "queued", "requ
43
45
 
44
46
  type Load = typeof loadCurrentPullRequest;
45
47
 
48
+ // Only initial preflight, before any CI evidence read, can authorize rediscovery.
49
+ export class StaleCiCollect extends Error {}
50
+
51
+ function sanitizeLog(text: string): string {
52
+ return stripVTControlCharacters(text).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "");
53
+ }
54
+
46
55
  type StepIdentity = {
47
56
  number: number;
48
57
  name: string;
@@ -113,6 +122,7 @@ type CiFixPhase = "ready" | "collecting" | "collected" | "published" | "blocked"
113
122
  type CiFixState = { phase: CiFixPhase };
114
123
 
115
124
  export type PullRequestCiFixOptions = {
125
+ run?: PrRun;
116
126
  cwd: string;
117
127
  authority: CurrentPullRequest;
118
128
  signal?: AbortSignal;
@@ -291,6 +301,7 @@ function errorMessage(error: unknown): string {
291
301
  export class PullRequestCiFixer {
292
302
  readonly state: CiFixState = { phase: "ready" };
293
303
 
304
+ private readonly run?: PrRun;
294
305
  private readonly cwd: string;
295
306
  private readonly authority: CurrentPullRequest;
296
307
  private readonly signal?: AbortSignal;
@@ -307,6 +318,7 @@ export class PullRequestCiFixer {
307
318
  throw new TypeError("CI repair requires the pull request head to match the configured remote OID");
308
319
  }
309
320
  this.cwd = options.cwd;
321
+ this.run = options.run;
310
322
  this.authority = cloneCurrentPullRequest(options.authority);
311
323
  this.signal = options.signal;
312
324
  this.agentDir = options.agentDir;
@@ -326,21 +338,22 @@ export class PullRequestCiFixer {
326
338
  return { cwd: this.cwd, signal: this.signal };
327
339
  }
328
340
 
329
- private async freshAuthority(requireOriginalLocal: boolean): Promise<CurrentPullRequest> {
341
+ private async freshAuthority(requireOriginalLocal: boolean, initial = false): Promise<CurrentPullRequest> {
330
342
  const discovery = await this.load(this.pi(), this.context());
331
343
  if (discovery.kind !== "current" || !samePullRequestSnapshot(this.authority, discovery.pullRequest) ||
332
- discovery.pullRequest.base.oid !== this.authority.base.oid ||
344
+ (!initial && discovery.pullRequest.base.oid !== this.authority.base.oid) ||
333
345
  discovery.pullRequest.conditions.ci !== "failure" ||
334
346
  discovery.pullRequest.target.remoteOid !== this.authority.head.oid) {
335
347
  throw new Error("CI repair cancelled: frozen pull request, base, target, head, or failed-CI authority changed");
336
348
  }
337
- if (requireOriginalLocal && (discovery.pullRequest.local.worktree !== "clean" || discovery.pullRequest.local.head !== "equal")) {
349
+ if (requireOriginalLocal && (discovery.pullRequest.local.worktree !== "clean" ||
350
+ (discovery.pullRequest.local.head !== "equal" && !(initial && discovery.pullRequest.local.head === "ahead")))) {
338
351
  throw new Error("CI repair evidence requires the original clean equal local HEAD");
339
352
  }
340
353
  return discovery.pullRequest;
341
354
  }
342
355
 
343
- private async requireOriginalLocal(): Promise<void> {
356
+ private async requireOriginalLocal(initial = false): Promise<string> {
344
357
  const branch = requiredText(parseSingleOutputLine(
345
358
  (await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout,
346
359
  "current branch",
@@ -349,9 +362,12 @@ export class PullRequestCiFixer {
349
362
  if (await inspectWorktree(this.exec, this.options()) !== "clean") {
350
363
  throw new Error("CI repair requires a clean worktree with no Git operation in progress");
351
364
  }
352
- if (await readHead(this.exec, this.options()) !== this.authority.head.oid) {
365
+ const head = await readHead(this.exec, this.options());
366
+ if (head !== this.authority.head.oid &&
367
+ !(initial && await isAncestor(this.exec, this.options(), this.authority.head.oid, head))) {
353
368
  throw new Error("CI repair evidence requires local HEAD to equal the frozen pull request head");
354
369
  }
370
+ return head;
355
371
  }
356
372
 
357
373
  private async api(endpoint: string, label: string, options: Partial<ExecOptions> = {}): Promise<string> {
@@ -545,9 +561,17 @@ export class PullRequestCiFixer {
545
561
  return run;
546
562
  }
547
563
 
548
- private async readSnapshot(requireOriginalLocal: boolean): Promise<CiSnapshot> {
549
- const fresh = await this.freshAuthority(requireOriginalLocal);
550
- if (requireOriginalLocal) await this.requireOriginalLocal();
564
+ private async readSnapshot(requireOriginalLocal: boolean, initial = false): Promise<CiSnapshot> {
565
+ const fresh = await this.freshAuthority(requireOriginalLocal, initial);
566
+ const head = requireOriginalLocal ? await this.requireOriginalLocal(initial) : undefined;
567
+ if (initial && (head !== this.authority.head.oid || fresh.base.oid !== this.authority.base.oid)) {
568
+ await this.requireSavedDestination(this.authority.head.oid);
569
+ if (await this.requireOriginalLocal(true) !== head) throw new Error("CI repair HEAD changed during initial preflight");
570
+ this.signal?.throwIfAborted();
571
+ throw new StaleCiCollect(head !== this.authority.head.oid
572
+ ? "Local HEAD is ahead of the frozen PR head; cancelled before CI evidence or publication"
573
+ : "Base tip changed on the same ref; cancelled before CI evidence or publication");
574
+ }
551
575
  const pageBudget = { pages: 0, records: 0 };
552
576
  const checks = await this.readCheckRuns(pageBudget);
553
577
  const failed = checks.filter(({ conclusion: value }) => value !== null && FAILED_CONCLUSIONS.has(value));
@@ -600,13 +624,22 @@ export class PullRequestCiFixer {
600
624
  }
601
625
 
602
626
  private async readJobLog(jobId: number): Promise<{ text: string; truncated: boolean }> {
603
- const result = await runChecked(this.exec, "gh", [
604
- "api", "--hostname", this.authority.host, ...API_HEADERS,
627
+ // Sanitize before runChecked can include output in a failure diagnostic.
628
+ const exec: Exec = async (command, args, options) => {
629
+ const result = await this.exec(command, args, options);
630
+ if (Buffer.byteLength(result.stdout, "utf8") > LOG_TAIL_BYTES) {
631
+ throw new Error(`Read failed job ${jobId} log executor exceeded its retained tail limit`);
632
+ }
633
+ return {
634
+ ...result,
635
+ stdout: sanitizeLog(result.stdout),
636
+ stderr: sanitizeLog(result.stderr),
637
+ };
638
+ };
639
+ const result = await runChecked(exec, "gh", [
640
+ "api", "--allow-escape-sequences", "--hostname", this.authority.host, ...API_HEADERS,
605
641
  `repos/${this.authority.base.repository}/actions/jobs/${jobId}/logs`,
606
642
  ], this.options({ stdoutTailBytes: LOG_TAIL_BYTES, timeoutMs: LOG_TIMEOUT_MS }));
607
- if (Buffer.byteLength(result.stdout, "utf8") > LOG_TAIL_BYTES) {
608
- throw new Error(`Read failed job ${jobId} log executor exceeded its retained tail limit`);
609
- }
610
643
  return { text: result.stdout, truncated: result.stdoutTruncated === true };
611
644
  }
612
645
 
@@ -614,7 +647,7 @@ export class PullRequestCiFixer {
614
647
  if (this.state.phase !== "ready") throw new Error("CI evidence collect action was already consumed");
615
648
  this.state.phase = "collecting";
616
649
  try {
617
- const before = await this.readSnapshot(true);
650
+ const before = await this.readSnapshot(true, true);
618
651
  const metadata = before.failures.map((failure) => failureEvidence(this.authority, failure, { text: "", truncated: false }));
619
652
  const metadataBytes = metadata.map(jsonBytes);
620
653
  const metadataTotal = jsonBytes(metadata);
@@ -710,6 +743,7 @@ export class PullRequestCiFixer {
710
743
  const repairHead = await this.validatePublishAuthority();
711
744
  const original = this.authority.head.oid;
712
745
  await this.finalPublishRevalidation(original, repairHead);
746
+ this.run?.beforePush(original, repairHead);
713
747
  let pushError: unknown;
714
748
  try {
715
749
  await runChecked(this.exec, "git", [
@@ -733,6 +767,7 @@ export class PullRequestCiFixer {
733
767
  throw new Error(`CI repair push outcome is unknown: ${errorMessage(error)}`);
734
768
  }
735
769
  if (postcondition === repairHead) {
770
+ this.run?.observeRemote(repairHead);
736
771
  this.state.phase = "published";
737
772
  return { kind: "published", head: repairHead, attempt: "applied" };
738
773
  }
@@ -2,7 +2,10 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
+ import { setTimeout as delay } from "node:timers/promises";
5
6
  import { executeGitHubMerge } from "./pr-merge.ts";
7
+ import type { MergeMethod, PrRun } from "./pr-run.ts";
8
+ import { syncLocalHead } from "./pr-sync.ts";
6
9
  import { needsFeedbackAttention } from "./pr-feedback-attention.ts";
7
10
  import {
8
11
  linkInferredPullRequest,
@@ -26,6 +29,9 @@ const WORKFLOWS: Record<WorkflowNextStep, string> = {
26
29
  "fix-ci": "skill:pi-pr-fix-ci",
27
30
  };
28
31
  export const WORKFLOW_ROUTES: ReadonlySet<string> = new Set(Object.keys(WORKFLOWS));
32
+ /** GitHub recomputes mergeability shortly after a push; re-read a few times before giving up. */
33
+ const REFRESH_ATTEMPTS = 3;
34
+ const REFRESH_DELAY_MS = 5_000;
29
35
  type WorkflowReservation =
30
36
  | { route: "create"; target: PullRequestTarget }
31
37
  | { route: Exclude<WorkflowNextStep, "create">; pullRequest: CurrentPullRequest };
@@ -36,7 +42,9 @@ type PrCommandPi = Pick<ExtensionAPI, "exec" | "getCommands" | "sendUserMessage"
36
42
  export type PrCommandInvocation = ((nextStep: NextStep) => void) & {
37
43
  sessionGeneration: number;
38
44
  assertCurrent(): void;
39
- completedRoutes?: ReadonlySet<string>;
45
+ run?: PrRun;
46
+ staleRediscoveries?: number;
47
+ replanAuthority?: CurrentPullRequest;
40
48
  };
41
49
  export type PrCommandHandler = (
42
50
  args: string,
@@ -47,6 +55,7 @@ export type PrCommandHandler = (
47
55
  export type WorkflowPromptIdentity = Readonly<{
48
56
  route: WorkflowNextStep;
49
57
  skill: string;
58
+ path: string;
50
59
  runId: string;
51
60
  action: WorkflowLaunchAction;
52
61
  }>;
@@ -57,6 +66,11 @@ export type PrCommandDependencies = {
57
66
  linkInferredPullRequest?: typeof linkInferredPullRequest;
58
67
  inspectSweepRecovery?: (pullRequest: CurrentPullRequest, ctx: ExtensionContext) => Promise<boolean>;
59
68
  inspectBranchRecovery?: (pullRequest: CurrentPullRequest, ctx: ExtensionContext) => Promise<boolean>;
69
+ syncLocalHead?: typeof syncLocalHead;
70
+ refreshAttempts?: number;
71
+ refreshDelayMs?: number;
72
+ /** Test override for the CI poll sleep; the budget still follows the run policy. */
73
+ ciPollMs?: number;
60
74
  reserveWorkflow?: (
61
75
  reservation: WorkflowReservation,
62
76
  ctx: ExtensionContext,
@@ -106,13 +120,11 @@ async function dispatchWorkflow(
106
120
  runId = reserved.runId;
107
121
  invocation?.assertCurrent();
108
122
  const queued = !ctx.isIdle();
109
- const identity = { route, skill: workflow.name, runId, action: reserved.action };
123
+ const identity = { route, skill: workflow.name, path: workflow.sourceInfo.path, runId, action: reserved.action };
110
124
  markPromptQueued(identity, queued);
111
- const options = queued
112
- ? { deliverAs: "followUp" as const, expandPromptTemplates: true }
113
- : { expandPromptTemplates: true };
114
125
  invocation?.assertCurrent();
115
- pi.sendUserMessage(`/${identity.skill} runId=${identity.runId} action=${identity.action}`, options);
126
+ // Busy-session launches are consumed by agent_before_settle, not user input queues.
127
+ if (!queued) pi.sendUserMessage(`/${identity.skill} runId=${identity.runId} action=${identity.action}`, { expandPromptTemplates: true });
116
128
  } catch (error) {
117
129
  if (runId !== undefined) release(runId, invocation);
118
130
  throw error;
@@ -129,15 +141,6 @@ function noActionNotification(pullRequest: CurrentPullRequest): { message: strin
129
141
  if (pullRequest.conditions.ci === "failure-blocked") {
130
142
  return { message: `PR #${pullRequest.number} has a failed CI check that cannot run the CI fix workflow`, type: "warning" };
131
143
  }
132
- const mutatingWorkflowSelected = pullRequest.conditions.conflict ||
133
- pullRequest.conditions.changesRequested || pullRequest.conditions.unresolvedThreads > 0 ||
134
- pullRequest.conditions.ci === "failure";
135
- if (mutatingWorkflowSelected && pullRequest.local.worktree === "dirty") {
136
- return { message: `PR #${pullRequest.number} is blocked by a dirty worktree`, type: "warning" };
137
- }
138
- if (mutatingWorkflowSelected && pullRequest.local.head !== "equal") {
139
- return { message: `PR #${pullRequest.number} is blocked by local HEAD ${pullRequest.local.head}`, type: "warning" };
140
- }
141
144
  if (pullRequest.conditions.ci === "running") {
142
145
  return { message: `PR #${pullRequest.number} is waiting for CI`, type: "warning" };
143
146
  }
@@ -147,12 +150,6 @@ function noActionNotification(pullRequest: CurrentPullRequest): { message: strin
147
150
  if (pullRequest.conditions.policy === "pending") {
148
151
  return { message: `PR #${pullRequest.number} is blocked by merge policy`, type: "warning" };
149
152
  }
150
- if (pullRequest.local.worktree === "dirty") {
151
- return { message: `PR #${pullRequest.number} is blocked by a dirty worktree`, type: "warning" };
152
- }
153
- if (pullRequest.local.head === "ahead" || pullRequest.local.head === "diverged") {
154
- return { message: `PR #${pullRequest.number} is blocked by local HEAD ${pullRequest.local.head}`, type: "warning" };
155
- }
156
153
  return { message: `PR #${pullRequest.number} has no available action`, type: "warning" };
157
154
  }
158
155
 
@@ -171,8 +168,10 @@ async function mergePullRequest(
171
168
  current: CurrentPullRequest,
172
169
  load: typeof loadCurrentPullRequest,
173
170
  needsFeedback: typeof needsFeedbackAttention,
171
+ mergeMethod: MergeMethod | undefined,
174
172
  ): Promise<void> {
175
173
  await executeGitHubMerge({
174
+ mergeMethod,
176
175
  exec: (command, args, options) => pi.exec(command, args, {
177
176
  ...options,
178
177
  signal: ctx.signal,
@@ -228,6 +227,9 @@ export function createPrCommandHandler(
228
227
  const link = dependencies.linkInferredPullRequest ?? linkInferredPullRequest;
229
228
  const inspectSweepRecovery = dependencies.inspectSweepRecovery ?? (async () => false);
230
229
  const inspectBranchRecovery = dependencies.inspectBranchRecovery ?? (async () => false);
230
+ const sync = dependencies.syncLocalHead ?? syncLocalHead;
231
+ const refreshAttempts = dependencies.refreshAttempts ?? REFRESH_ATTEMPTS;
232
+ const refreshDelayMs = dependencies.refreshDelayMs ?? REFRESH_DELAY_MS;
231
233
  const reserve = dependencies.reserveWorkflow ?? (async () => {
232
234
  throw new Error("/pr workflow tools are unavailable");
233
235
  });
@@ -237,10 +239,17 @@ export function createPrCommandHandler(
237
239
  args: string, ctx: ExtensionContext,
238
240
  onRouteResolved?: PrCommandInvocation,
239
241
  linkedAuthority?: CurrentPullRequest,
242
+ refreshes = 0,
243
+ ciWaits = 0,
240
244
  ): Promise<NextStep> => {
241
245
  if (args.trim()) throw new Error("/pr does not accept arguments");
242
246
  const discovery = await load(pi, ctx);
243
247
  onRouteResolved?.assertCurrent();
248
+ if (discovery.kind === "current") onRouteResolved?.run?.observeRemote(discovery.pullRequest.head.oid);
249
+ if (onRouteResolved?.replanAuthority && (discovery.kind !== "current" ||
250
+ !samePullRequestSnapshot(onRouteResolved.replanAuthority, discovery.pullRequest))) {
251
+ throw new Error("PR stale-route rediscovery cancelled: frozen PR identity, destination, or remote head changed");
252
+ }
244
253
  if (linkedAuthority && (discovery.kind !== "current" || discovery.pullRequest.target.provenance !== "configured" ||
245
254
  !isSameConfirmedMerge(linkedAuthority, discovery.pullRequest))) {
246
255
  throw new Error("Link branch continuation cancelled: configured pull request context changed");
@@ -258,7 +267,7 @@ export function createPrCommandHandler(
258
267
  nextStep = "sweep";
259
268
  onRouteResolved?.assertCurrent();
260
269
  }
261
- if (discovery.kind === "current" && (nextStep === "merge" || nextStep === "none") &&
270
+ if (discovery.kind === "current" && (nextStep === "merge" || nextStep === "none" || nextStep === "wait-ci") &&
262
271
  discovery.pullRequest.lifecycle === "open" && !discovery.pullRequest.conditions.draft &&
263
272
  discovery.pullRequest.target.provenance === "configured" &&
264
273
  discovery.pullRequest.local.worktree === "clean" && discovery.pullRequest.local.head === "equal") {
@@ -283,15 +292,58 @@ export function createPrCommandHandler(
283
292
  if (linked.target.provenance !== "configured") throw new Error("Link branch failed: target was not configured");
284
293
  return await handle("", ctx, onRouteResolved, linked);
285
294
  }
295
+ if (nextStep === "sync-local") {
296
+ if (discovery.kind !== "current") throw new Error("/pr sync failed: pull request is unavailable");
297
+ const current = discovery.pullRequest;
298
+ if (onRouteResolved?.run?.hasCompleted("sync-local", current.head.oid)) {
299
+ ctx.ui.notify("PR sync-local already ran; inspect fresh state before retrying", "warning");
300
+ return "none";
301
+ }
302
+ const synced = await sync({ cwd: ctx.cwd, authority: current, signal: ctx.signal, loadCurrentPullRequest: load });
303
+ onRouteResolved?.assertCurrent();
304
+ onRouteResolved?.run?.complete("sync-local", current.head.oid);
305
+ const verb = synced.kind === "fast-forwarded" ? "fast-forwarded to" : synced.kind === "rebased" ? "rebased onto" : "already at";
306
+ ctx.ui.notify(`PR #${current.number}: local branch ${verb} the PR head`, "info");
307
+ return await handle("", ctx, onRouteResolved);
308
+ }
309
+ if (nextStep === "refresh") {
310
+ if (discovery.kind !== "current") throw new Error("/pr refresh failed: pull request is unavailable");
311
+ if (refreshes >= refreshAttempts) {
312
+ ctx.ui.notify(`PR #${discovery.pullRequest.number} mergeability is still being computed by GitHub; run /pr again`, "warning");
313
+ return "none";
314
+ }
315
+ await delay(refreshDelayMs, undefined, { signal: ctx.signal });
316
+ onRouteResolved?.assertCurrent();
317
+ return await handle("", ctx, onRouteResolved, undefined, refreshes + 1);
318
+ }
319
+ if (nextStep === "wait-ci") {
320
+ if (discovery.kind !== "current") throw new Error("/pr wait failed: pull request is unavailable");
321
+ const current = discovery.pullRequest;
322
+ const run = onRouteResolved?.run;
323
+ const interval = run?.consumeCiWait() ?? null;
324
+ if (interval === null) {
325
+ const budget = run?.policy.ciWaitMinutes ?? 0;
326
+ ctx.ui.notify(budget > 0
327
+ ? `PR #${current.number} is still waiting for CI after ${budget} min; run /pr again later`
328
+ : `PR #${current.number} is waiting for CI`, "warning");
329
+ return "none";
330
+ }
331
+ if (ciWaits === 0) {
332
+ ctx.ui.notify(`PR #${current.number}: CI is running; checking every ${run!.policy.ciPollSeconds} s for up to ${run!.policy.ciWaitMinutes} min`, "info");
333
+ }
334
+ await delay(dependencies.ciPollMs ?? interval, undefined, { signal: ctx.signal });
335
+ onRouteResolved?.assertCurrent();
336
+ return await handle("", ctx, onRouteResolved, undefined, 0, ciWaits + 1);
337
+ }
286
338
  if (nextStep === "merge") {
287
339
  if (discovery.kind !== "current") throw new Error("/pr merge failed: pull request is unavailable");
288
- await mergePullRequest(pi, ctx, discovery.pullRequest, load, needsFeedback);
340
+ await mergePullRequest(pi, ctx, discovery.pullRequest, load, needsFeedback, onRouteResolved?.run?.policy.mergeMethod);
289
341
  return "merge";
290
342
  }
291
343
 
292
344
  if (!(nextStep in WORKFLOWS)) throw new Error(`/pr cannot dispatch route ${nextStep}`);
293
345
  const route = nextStep as WorkflowNextStep;
294
- if (onRouteResolved?.completedRoutes?.has(route)) {
346
+ if (onRouteResolved?.run?.hasCompleted(route, discovery.kind === "current" ? discovery.pullRequest.head.oid : null)) {
295
347
  ctx.ui.notify(`PR ${route} already ran; inspect fresh state before retrying`, "warning");
296
348
  return "none";
297
349
  }