@henryqw/pi-pr 4.0.9 → 5.0.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.
package/README.md CHANGED
@@ -36,6 +36,8 @@ GitHub, use the network, or write files.
36
36
 
37
37
  **Requires.** [`@henryqw/pi-herdr`](https://pi.henry.wang/extensions/pi-herdr) is the shared Herdr CLI client. It installs with this package.
38
38
 
39
+ **Uses.** [`@henryqw/pi-process`](https://pi.henry.wang/packages/pi-process) runs bounded child processes. It installs with this package.
40
+
39
41
  **Improves.** [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) shows current-branch pull-request status in the footer.
40
42
 
41
43
  ## Use
@@ -170,7 +172,8 @@ The GitHub response must match the observed URL, host, repository, head ref, hea
170
172
  - A merge, rebase, cherry-pick, revert, or sequencer state blocks direct merge, even when `git status` is empty.
171
173
  - A branch update resolves the base repository ref directly. It stops if that ref moves before merge or push.
172
174
  - Before a comment-sweep push, it revalidates the configured destination, full PR identity, and local HEAD. It pushes the captured OID.
173
- - CI repair streams a bounded failed-step log tail and runs one narrow local reproducer before editing.
175
+ - CI repair resolves workflow runs from check-suite IDs. It does not treat HTML details links as identity.
176
+ - It streams a bounded failed-step log tail and runs one narrow local reproducer before editing.
174
177
  - Before push, CI repair revalidates the saved destination, open PR, failure evidence, and repair HEAD.
175
178
  - An already-published local HEAD needs no second push.
176
179
  - Direct merge requires final confirmation and a fresh readiness check.
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
3
3
  import {
4
+ cloneCurrentPullRequest,
4
5
  loadCurrentPullRequest,
5
6
  readValidatedRemoteAuthority,
6
7
  samePullRequestSnapshot,
@@ -8,6 +9,7 @@ import {
8
9
  type PullRequestLoadContext,
9
10
  } from "./pr-github.ts";
10
11
  import {
12
+ extensionExecApi,
11
13
  inspectWorktree,
12
14
  isAncestor,
13
15
  readHead,
@@ -15,10 +17,7 @@ import {
15
17
  requiredOid,
16
18
  requiredText,
17
19
  runChecked,
18
- spawnBounded,
19
20
  withWorktreeLock,
20
- type Exec,
21
- type ExecOptions,
22
21
  } from "./pr-execution.ts";
23
22
 
24
23
  const PAGE_BYTES = 512 * 1024;
@@ -52,9 +51,6 @@ type StepIdentity = {
52
51
 
53
52
  type JobIdentity = {
54
53
  id: number;
55
- url: string;
56
- htmlUrl: string;
57
- checkRunUrl: string;
58
54
  runId: number;
59
55
  attempt: number;
60
56
  headOid: string;
@@ -66,8 +62,6 @@ type JobIdentity = {
66
62
 
67
63
  type CheckIdentity = {
68
64
  id: number;
69
- url: string;
70
- detailsUrl: string | null;
71
65
  suiteId: number;
72
66
  headOid: string;
73
67
  name: string;
@@ -78,8 +72,6 @@ type CheckIdentity = {
78
72
 
79
73
  type RunIdentity = {
80
74
  id: number;
81
- url: string;
82
- htmlUrl: string;
83
75
  attempt: number;
84
76
  suiteId: number;
85
77
  headOid: string;
@@ -89,7 +81,7 @@ type RunIdentity = {
89
81
  };
90
82
 
91
83
  type FailureIdentity = {
92
- check: CheckIdentity;
84
+ checks: CheckIdentity[];
93
85
  run: RunIdentity;
94
86
  job: JobIdentity;
95
87
  failedSteps: StepIdentity[];
@@ -101,7 +93,7 @@ type CiSnapshot = {
101
93
  };
102
94
 
103
95
  export type CiFailureEvidence = {
104
- checkRun: { id: number; url: string; detailsUrl: string; name: string; conclusion: string };
96
+ checkRuns: Array<{ id: number; name: string; conclusion: string }>;
105
97
  checkSuite: { id: number };
106
98
  run: { id: number; url: string; attempt: number };
107
99
  job: { id: number; url: string; name: string; conclusion: string };
@@ -140,18 +132,6 @@ export type CiPublishResult = {
140
132
  attempt: "applied";
141
133
  };
142
134
 
143
- function cloneAuthority(value: CurrentPullRequest): CurrentPullRequest {
144
- return {
145
- ...value,
146
- url: new URL(value.url.href),
147
- conditions: { ...value.conditions },
148
- local: { ...value.local },
149
- base: { ...value.base },
150
- head: { ...value.head },
151
- target: { ...value.target },
152
- };
153
- }
154
-
155
135
  function record(value: unknown, label: string): Record<string, unknown> {
156
136
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} returned invalid JSON`);
157
137
  return value as Record<string, unknown>;
@@ -206,91 +186,12 @@ function outputLine(output: string, label: string): string {
206
186
  return requiredText(lines[0], label);
207
187
  }
208
188
 
209
- function secureUrl(value: unknown, label: string): URL {
210
- const raw = text(value, label);
211
- let url: URL;
212
- try {
213
- url = new URL(raw);
214
- } catch {
215
- throw new Error(`${label} returned an invalid URL`);
216
- }
217
- if (url.protocol !== "https:" || url.username || url.password || url.port || url.search || url.hash) {
218
- throw new Error(`${label} returned an invalid URL`);
219
- }
220
- return url;
221
- }
222
-
223
- function pathParts(url: URL): string[] {
224
- const parts = url.pathname.split("/").filter(Boolean);
225
- if (parts.some((part) => !part || part === "." || part === ".." || part.includes("%"))) {
226
- throw new Error("GitHub returned an invalid identity URL");
227
- }
228
- return parts;
229
- }
230
-
231
- function sameRepository(parts: readonly string[], repository: string): boolean {
232
- const expected = repository.split("/");
233
- return parts.length === 2 && expected.length === 2 &&
234
- parts[0]!.toLowerCase() === expected[0]!.toLowerCase() &&
235
- parts[1]!.toLowerCase() === expected[1]!.toLowerCase();
236
- }
237
-
238
- function parseApiIdentityUrl(
239
- value: unknown,
240
- label: string,
241
- host: string,
242
- repository: string,
243
- kind: "check-runs" | "runs" | "jobs",
244
- expectedId: number,
245
- ): string {
246
- const url = secureUrl(value, label);
247
- const parts = pathParts(url);
248
- const expectedHost = (host === "github.com" ? "api.github.com" : host).toLowerCase();
249
- if (url.hostname.toLowerCase() !== expectedHost) throw new Error(`${label} returned an unexpected API origin`);
250
- const tailSize = kind === "check-runs" ? 5 : 6;
251
- const tail = parts.slice(-tailSize);
252
- const valid = kind === "check-runs"
253
- ? tail[0] === "repos" && sameRepository(tail.slice(1, 3), repository) && tail[3] === kind
254
- : tail[0] === "repos" && sameRepository(tail.slice(1, 3), repository) && tail[3] === "actions" && tail[4] === kind;
255
- if (!valid || tail[tail.length - 1] !== String(expectedId)) throw new Error(`${label} did not match its immutable ID`);
189
+ function actionsUrl(authority: CurrentPullRequest, suffix: string): string {
190
+ const url = new URL(authority.url.origin);
191
+ url.pathname = `/${authority.base.repository}/actions/${suffix}`;
256
192
  return url.href;
257
193
  }
258
194
 
259
- function parseRunHtmlUrl(value: unknown, label: string, host: string, repository: string, expectedId: number): string {
260
- const url = secureUrl(value, label);
261
- const parts = pathParts(url);
262
- if (url.hostname.toLowerCase() !== host || parts.length !== 5 || !sameRepository(parts.slice(0, 2), repository) ||
263
- parts[2] !== "actions" || parts[3] !== "runs" || parts[4] !== String(expectedId)) {
264
- throw new Error(`${label} did not match its immutable ID`);
265
- }
266
- return url.href;
267
- }
268
-
269
- function decimalIdentity(value: string, label: string): number {
270
- const parsed = integer(Number(value), label);
271
- if (String(parsed) !== value) throw new Error(`${label} returned a noncanonical decimal ID`);
272
- return parsed;
273
- }
274
-
275
- function parseJobHtmlUrl(
276
- value: unknown,
277
- label: string,
278
- host: string,
279
- repository: string,
280
- ): { url: string; runId: number; jobId: number } {
281
- const url = secureUrl(value, label);
282
- const parts = pathParts(url);
283
- if (url.hostname.toLowerCase() !== host || parts.length !== 7 || !sameRepository(parts.slice(0, 2), repository) ||
284
- parts[2] !== "actions" || parts[3] !== "runs" || parts[5] !== "job") {
285
- throw new Error(`${label} was not an immutable GitHub Actions job URL`);
286
- }
287
- return {
288
- url: url.href,
289
- runId: decimalIdentity(parts[4]!, `${label} run ID`),
290
- jobId: decimalIdentity(parts[6]!, `${label} job ID`),
291
- };
292
- }
293
-
294
195
  function tailUtf8(value: string, limit: number): { text: string; truncated: boolean } {
295
196
  const total = Buffer.byteLength(value, "utf8");
296
197
  if (total <= limit) return { text: value, truncated: false };
@@ -310,24 +211,28 @@ function tailUtf8(value: string, limit: number): { text: string; truncated: bool
310
211
  }
311
212
 
312
213
  function failureEvidence(
214
+ authority: CurrentPullRequest,
313
215
  failure: FailureIdentity,
314
216
  log: { text: string; truncated: boolean },
315
217
  ): CiFailureEvidence {
316
- const { check, run, job, failedSteps } = failure;
317
- if (check.detailsUrl === null || check.conclusion === null || job.conclusion === null) {
218
+ const { checks, run, job, failedSteps } = failure;
219
+ if (job.conclusion === null || checks.some(({ conclusion: value }) => value === null)) {
318
220
  throw new Error(`Failed job ${job.id} omitted required evidence identity`);
319
221
  }
320
222
  return {
321
- checkRun: {
223
+ checkRuns: checks.map((check) => ({
322
224
  id: check.id,
323
- url: check.url,
324
- detailsUrl: check.detailsUrl,
325
225
  name: check.name,
326
- conclusion: check.conclusion,
226
+ conclusion: check.conclusion!,
227
+ })),
228
+ checkSuite: { id: run.suiteId },
229
+ run: { id: run.id, url: actionsUrl(authority, `runs/${run.id}`), attempt: run.attempt },
230
+ job: {
231
+ id: job.id,
232
+ url: actionsUrl(authority, `runs/${run.id}/job/${job.id}`),
233
+ name: job.name,
234
+ conclusion: job.conclusion,
327
235
  },
328
- checkSuite: { id: check.suiteId },
329
- run: { id: run.id, url: run.htmlUrl, attempt: run.attempt },
330
- job: { id: job.id, url: job.htmlUrl, name: job.name, conclusion: job.conclusion },
331
236
  failedSteps: failedSteps.map((step) => {
332
237
  if (step.conclusion === null) throw new Error(`Failed job ${job.id} step omitted its conclusion`);
333
238
  return { number: step.number, name: step.name, conclusion: step.conclusion };
@@ -341,17 +246,18 @@ function jsonBytes(value: unknown): number {
341
246
  }
342
247
 
343
248
  function boundedEvidenceRecord(
249
+ authority: CurrentPullRequest,
344
250
  failure: FailureIdentity,
345
251
  rawLog: { text: string; truncated: boolean },
346
252
  maxBytes: number,
347
253
  ): CiFailureEvidence {
348
- const metadata = failureEvidence(failure, { text: "", truncated: false });
254
+ const metadata = failureEvidence(authority, failure, { text: "", truncated: false });
349
255
  const metadataBytes = jsonBytes(metadata);
350
256
  if (metadataBytes > maxBytes) throw new Error(`Failed job ${failure.job.id} metadata exceeds its evidence budget`);
351
257
  let logBytes = Math.min(Buffer.byteLength(rawLog.text, "utf8"), maxBytes - metadataBytes);
352
258
  while (true) {
353
259
  const retained = tailUtf8(rawLog.text, logBytes);
354
- const evidence = failureEvidence(failure, {
260
+ const evidence = failureEvidence(authority, failure, {
355
261
  text: retained.text,
356
262
  truncated: rawLog.truncated || retained.truncated,
357
263
  });
@@ -378,21 +284,16 @@ function authorityIdentity(value: CurrentPullRequest) {
378
284
  function snapshotFingerprint(authority: CurrentPullRequest, failures: FailureIdentity[]): string {
379
285
  return createHash("sha256").update(JSON.stringify({
380
286
  authority: authorityIdentity(authority),
381
- failures: failures.map(({ check, run, job, failedSteps }) => ({
382
- check,
287
+ failures: failures.map(({ checks, run, job, failedSteps }) => ({
288
+ checks,
383
289
  run: {
384
290
  id: run.id,
385
- url: run.url,
386
- htmlUrl: run.htmlUrl,
387
291
  attempt: run.attempt,
388
292
  suiteId: run.suiteId,
389
293
  headOid: run.headOid,
390
294
  },
391
295
  job: {
392
296
  id: job.id,
393
- url: job.url,
394
- htmlUrl: job.htmlUrl,
395
- checkRunUrl: job.checkRunUrl,
396
297
  runId: job.runId,
397
298
  attempt: job.attempt,
398
299
  headOid: job.headOid,
@@ -434,7 +335,7 @@ export class PullRequestCiFixer {
434
335
  : requiredOid(options.authority.target.remoteOid, "remote OID");
435
336
  if (remote !== head) throw new TypeError("CI repair requires the pull request head to match the configured remote OID");
436
337
  this.cwd = requiredText(options.cwd, "cwd");
437
- this.authority = cloneAuthority(options.authority);
338
+ this.authority = cloneCurrentPullRequest(options.authority);
438
339
  this.signal = options.signal;
439
340
  this.agentDir = options.agentDir;
440
341
  this.exec = options.exec ?? spawnBounded;
@@ -445,14 +346,8 @@ export class PullRequestCiFixer {
445
346
  return { cwd: this.cwd, signal: this.signal, ...extra };
446
347
  }
447
348
 
448
- private pi(): Pick<ExtensionAPI, "exec"> {
449
- return {
450
- exec: (command, args, options) => this.exec(command, args, {
451
- cwd: options?.cwd ?? this.cwd,
452
- signal: options?.signal ?? this.signal,
453
- timeoutMs: options?.timeout,
454
- }),
455
- } as Pick<ExtensionAPI, "exec">;
349
+ private pi() {
350
+ return extensionExecApi(this.exec, this.cwd, this.signal);
456
351
  }
457
352
 
458
353
  private context(): PullRequestLoadContext {
@@ -508,11 +403,8 @@ export class PullRequestCiFixer {
508
403
  if (suite.head_sha !== undefined && requiredOid(suite.head_sha, `check run ${id} suite head OID`) !== headOid) {
509
404
  throw new Error(`Check run ${id} suite is stale for the frozen pull request head`);
510
405
  }
511
- const detailsUrl = item.details_url === null ? null : secureUrl(item.details_url, `check run ${id} details URL`).href;
512
406
  return {
513
407
  id,
514
- url: parseApiIdentityUrl(item.url, `check run ${id} URL`, this.authority.host, this.authority.base.repository, "check-runs", id),
515
- detailsUrl,
516
408
  suiteId: integer(suite.id, `check run ${id} suite ID`),
517
409
  headOid,
518
410
  name: text(item.name, `check run ${id} name`),
@@ -557,8 +449,7 @@ export class PullRequestCiFixer {
557
449
  }
558
450
  if (expectedTotal === undefined || checks.length !== expectedTotal) throw new Error("Check-run pagination was incomplete");
559
451
  checks.sort((left, right) => left.id - right.id);
560
- if (new Set(checks.map(({ id }) => id)).size !== checks.length ||
561
- new Set(checks.map(({ url }) => url)).size !== checks.length) {
452
+ if (new Set(checks.map(({ id }) => id)).size !== checks.length) {
562
453
  throw new Error("Check-run pagination returned duplicate immutable identities");
563
454
  }
564
455
  return checks;
@@ -588,13 +479,8 @@ export class PullRequestCiFixer {
588
479
  const steps = array(item.steps, `job ${id} steps`).map((step) => this.parseStep(step, id));
589
480
  steps.sort((left, right) => left.number - right.number);
590
481
  if (new Set(steps.map(({ number }) => number)).size !== steps.length) throw new Error(`Job ${id} returned duplicate step identities`);
591
- const html = parseJobHtmlUrl(item.html_url, `job ${id} HTML URL`, this.authority.host, this.authority.base.repository);
592
- if (html.runId !== runId || html.jobId !== id) throw new Error(`Job ${id} HTML URL did not match its immutable IDs`);
593
482
  return {
594
483
  id,
595
- url: parseApiIdentityUrl(item.url, `job ${id} URL`, this.authority.host, this.authority.base.repository, "jobs", id),
596
- htmlUrl: html.url,
597
- checkRunUrl: secureUrl(item.check_run_url, `job ${id} check-run URL`).href,
598
484
  runId,
599
485
  attempt,
600
486
  headOid,
@@ -605,7 +491,25 @@ export class PullRequestCiFixer {
605
491
  };
606
492
  }
607
493
 
608
- private async readRun(runId: number, budget: { pages: number; records: number }): Promise<RunIdentity> {
494
+ private async readRunIdForSuite(suiteId: number, budget: { pages: number; records: number }): Promise<number> {
495
+ const label = `List workflow runs for check suite ${suiteId}`;
496
+ this.consumePage(budget);
497
+ const endpoint = `repos/${this.authority.base.repository}/actions/runs?check_suite_id=${suiteId}&head_sha=${this.authority.head.oid}&per_page=2&page=1`;
498
+ const parsed = record(parseJson(await this.api(endpoint, label), label), label);
499
+ const runs = array(parsed.workflow_runs, label);
500
+ if (integer(parsed.total_count, `${label} total`, true) !== 1 || runs.length !== 1) {
501
+ throw new Error(`Check suite ${suiteId} did not resolve to exactly one workflow run`);
502
+ }
503
+ this.consumeRecords(budget, 1);
504
+ const item = record(runs[0], label);
505
+ if (integer(item.check_suite_id, `${label} suite ID`) !== suiteId ||
506
+ requiredOid(item.head_sha, `${label} head OID`) !== this.authority.head.oid) {
507
+ throw new Error(`${label} returned a stale or unrelated run`);
508
+ }
509
+ return integer(item.id, `${label} run ID`);
510
+ }
511
+
512
+ private async readRun(runId: number, suiteId: number, budget: { pages: number; records: number }): Promise<RunIdentity> {
609
513
  const label = `Read workflow run ${runId}`;
610
514
  const item = record(parseJson(await this.api(
611
515
  `repos/${this.authority.base.repository}/actions/runs/${runId}`,
@@ -622,12 +526,13 @@ export class PullRequestCiFixer {
622
526
  throw new Error(`${label} does not belong to the frozen pull request repositories`);
623
527
  }
624
528
  const currentStatus = status(item.status, label);
529
+ if (integer(item.check_suite_id, `${label} suite ID`) !== suiteId) {
530
+ throw new Error(`${label} does not belong to check suite ${suiteId}`);
531
+ }
625
532
  const run: RunIdentity = {
626
533
  id: runId,
627
- url: parseApiIdentityUrl(item.url, `${label} URL`, this.authority.host, this.authority.base.repository, "runs", runId),
628
- htmlUrl: parseRunHtmlUrl(item.html_url, `${label} HTML URL`, this.authority.host, this.authority.base.repository, runId),
629
534
  attempt,
630
- suiteId: integer(item.check_suite_id, `${label} suite ID`),
535
+ suiteId,
631
536
  headOid,
632
537
  status: currentStatus,
633
538
  conclusion: conclusion(item.conclusion, currentStatus, label),
@@ -662,9 +567,7 @@ export class PullRequestCiFixer {
662
567
  }
663
568
  if (expectedTotal === undefined || run.jobs.length !== expectedTotal) throw new Error(`Workflow run ${runId} job pagination was incomplete`);
664
569
  run.jobs.sort((left, right) => left.id - right.id);
665
- if (new Set(run.jobs.map(({ id }) => id)).size !== run.jobs.length ||
666
- new Set(run.jobs.map(({ url }) => url)).size !== run.jobs.length ||
667
- new Set(run.jobs.map(({ checkRunUrl }) => checkRunUrl)).size !== run.jobs.length) {
570
+ if (new Set(run.jobs.map(({ id }) => id)).size !== run.jobs.length) {
668
571
  throw new Error(`Workflow run ${runId} returned duplicate job identities`);
669
572
  }
670
573
  return run;
@@ -680,68 +583,44 @@ export class PullRequestCiFixer {
680
583
  const unsupported = failed.find(({ provider }) => provider !== "github-actions");
681
584
  if (unsupported) throw new Error(`Unsupported failed check provider for immutable evidence: ${unsupported.provider}`);
682
585
 
683
- const runIds = new Set<number>();
684
- const detailIdentities = new Map<number, { url: string; runId: number; jobId: number }>();
586
+ const checksBySuite = new Map<number, CheckIdentity[]>();
685
587
  for (const check of failed) {
686
- if (check.detailsUrl === null) throw new Error(`Failed check run ${check.id} omitted its immutable job URL`);
687
- const detail = parseJobHtmlUrl(
688
- check.detailsUrl,
689
- `check run ${check.id} details URL`,
690
- this.authority.host,
691
- this.authority.base.repository,
692
- );
693
- detailIdentities.set(check.id, detail);
694
- runIds.add(detail.runId);
588
+ const suiteChecks = checksBySuite.get(check.suiteId) ?? [];
589
+ suiteChecks.push(check);
590
+ checksBySuite.set(check.suiteId, suiteChecks);
695
591
  }
696
592
 
697
593
  const runs: RunIdentity[] = [];
698
- this.consumeRecords(pageBudget, runIds.size);
699
- for (const runId of [...runIds].sort((left, right) => left - right)) {
700
- runs.push(await this.readRun(runId, pageBudget));
594
+ for (const suiteId of [...checksBySuite.keys()].sort((left, right) => left - right)) {
595
+ const runId = await this.readRunIdForSuite(suiteId, pageBudget);
596
+ this.consumeRecords(pageBudget, 1);
597
+ runs.push(await this.readRun(runId, suiteId, pageBudget));
598
+ }
599
+ if (new Set(runs.map(({ id }) => id)).size !== runs.length) {
600
+ throw new Error("Check suites returned ambiguous duplicate workflow run identities");
701
601
  }
702
602
  const allJobs = runs.flatMap(({ jobs }) => jobs);
703
- if (new Set(allJobs.map(({ id }) => id)).size !== allJobs.length ||
704
- new Set(allJobs.map(({ checkRunUrl }) => checkRunUrl)).size !== allJobs.length) {
603
+ if (new Set(allJobs.map(({ id }) => id)).size !== allJobs.length) {
705
604
  throw new Error("Workflow runs returned ambiguous duplicate job identities");
706
605
  }
707
- const runById = new Map(runs.map((run) => [run.id, run]));
708
- for (const run of runs) {
709
- for (const job of run.jobs) {
710
- const linked = checks.filter((check) => check.url === job.checkRunUrl);
711
- if (linked.length !== 1 || linked[0]!.detailsUrl !== job.htmlUrl || linked[0]!.suiteId !== run.suiteId ||
712
- linked[0]!.status !== job.status || linked[0]!.conclusion !== job.conclusion) {
713
- throw new Error(`Job ${job.id} has an incomplete, replaced, or ambiguous immutable check-run mapping`);
714
- }
715
- }
716
- }
717
- const usedJobs = new Set<number>();
606
+
718
607
  const failures: FailureIdentity[] = [];
719
- for (const check of failed) {
720
- const detail = detailIdentities.get(check.id)!;
721
- const run = runById.get(detail.runId);
722
- if (!run || run.suiteId !== check.suiteId) {
723
- throw new Error(`Failed check run ${check.id} has an ambiguous or stale suite-to-run mapping`);
724
- }
725
- const matches = run.jobs.filter((job) =>
726
- job.id === detail.jobId && job.checkRunUrl === check.url && job.htmlUrl === detail.url
608
+ for (const run of runs) {
609
+ const failedJobs = run.jobs.filter(({ status: value, conclusion: result }) =>
610
+ value === "completed" && result !== null && FAILED_CONCLUSIONS.has(result)
727
611
  );
728
- if (matches.length !== 1 || usedJobs.has(detail.jobId)) {
729
- throw new Error(`Failed check run ${check.id} has an incomplete, duplicate, or ambiguous immutable job mapping`);
730
- }
731
- const job = matches[0]!;
732
- if (job.status !== "completed" || job.conclusion !== check.conclusion ||
733
- job.conclusion === null || !FAILED_CONCLUSIONS.has(job.conclusion)) {
734
- throw new Error(`Failed check run ${check.id} was replaced or is stale relative to its job`);
612
+ if (!failedJobs.length) throw new Error(`Check suite ${run.suiteId} has no current failed workflow jobs`);
613
+ const suiteChecks = checksBySuite.get(run.suiteId)!;
614
+ for (const job of failedJobs) {
615
+ failures.push({
616
+ checks: suiteChecks,
617
+ run,
618
+ job,
619
+ failedSteps: job.steps.filter(({ conclusion: value }) => value !== null && FAILED_CONCLUSIONS.has(value)),
620
+ });
735
621
  }
736
- usedJobs.add(job.id);
737
- failures.push({
738
- check,
739
- run,
740
- job,
741
- failedSteps: job.steps.filter(({ conclusion: value }) => value !== null && FAILED_CONCLUSIONS.has(value)),
742
- });
743
622
  }
744
- failures.sort((left, right) => left.check.id - right.check.id);
623
+ failures.sort((left, right) => left.run.id - right.run.id || left.job.id - right.job.id);
745
624
  return {
746
625
  fingerprint: snapshotFingerprint(fresh, failures),
747
626
  failures,
@@ -765,7 +644,7 @@ export class PullRequestCiFixer {
765
644
  this.state.phase = "collecting";
766
645
  try {
767
646
  const before = await this.readSnapshot(true);
768
- const metadata = before.failures.map((failure) => failureEvidence(failure, { text: "", truncated: false }));
647
+ const metadata = before.failures.map((failure) => failureEvidence(this.authority, failure, { text: "", truncated: false }));
769
648
  const metadataBytes = metadata.map(jsonBytes);
770
649
  const metadataTotal = jsonBytes(metadata);
771
650
  if (metadataBytes.some((bytes) => bytes > EVIDENCE_RECORD_BYTES) || metadataTotal > EVIDENCE_TOTAL_BYTES) {
@@ -778,7 +657,7 @@ export class PullRequestCiFixer {
778
657
  const baseBytes = metadataBytes[index]!;
779
658
  const share = Math.floor(remainingBytes / (before.failures.length - index));
780
659
  const raw = await this.readJobLog(failure.job.id);
781
- const evidence = boundedEvidenceRecord(failure, raw, Math.min(EVIDENCE_RECORD_BYTES, baseBytes + share));
660
+ const evidence = boundedEvidenceRecord(this.authority, failure, raw, Math.min(EVIDENCE_RECORD_BYTES, baseBytes + share));
782
661
  remainingBytes -= jsonBytes(evidence) - baseBytes;
783
662
  failures.push(evidence);
784
663
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { realpath, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
5
5
  import {
6
6
  extensionConfigDir,
7
7
  readTextFileBounded,
@@ -27,8 +27,10 @@ import {
27
27
  type PullRequestLoadContext,
28
28
  } from "./pr-github.ts";
29
29
  import {
30
+ extensionExecApi,
30
31
  inspectGitOperation,
31
32
  isAncestor,
33
+ isRecord,
32
34
  parseNulPaths,
33
35
  parseStatusSnapshot,
34
36
  readHead,
@@ -36,11 +38,8 @@ import {
36
38
  requiredOid,
37
39
  requiredText,
38
40
  runChecked,
39
- spawnBounded,
40
41
  withWorktreeLock,
41
42
  type AttemptState,
42
- type Exec,
43
- type ExecOptions,
44
43
  } from "./pr-execution.ts";
45
44
 
46
45
  export const SWEEP_RECOVERY_MAX_BYTES = 1024 * 1024;
@@ -136,10 +135,6 @@ export type PullRequestCommentSweepOptions = {
136
135
  newRunId?: () => string;
137
136
  pause?: (milliseconds: number) => Promise<void>;
138
137
  };
139
- function isRecord(value: unknown): value is Record<string, unknown> {
140
- return value !== null && typeof value === "object" && !Array.isArray(value);
141
- }
142
-
143
138
  function exactKeys(value: Record<string, unknown>, keys: readonly string[], label: string): void {
144
139
  const actual = Object.keys(value).sort();
145
140
  const expected = [...keys].sort();
@@ -560,14 +555,8 @@ export class PullRequestCommentSweep {
560
555
  return { cwd: this.cwd, signal: this.signal, ...extra };
561
556
  }
562
557
 
563
- private pi(): Pick<ExtensionAPI, "exec"> {
564
- return {
565
- exec: (command, args, options) => this.exec(command, args, {
566
- cwd: options?.cwd ?? this.cwd,
567
- signal: options?.signal ?? this.signal,
568
- timeoutMs: options?.timeout,
569
- }),
570
- } as Pick<ExtensionAPI, "exec">;
558
+ private pi() {
559
+ return extensionExecApi(this.exec, this.cwd, this.signal);
571
560
  }
572
561
 
573
562
  private context(): PullRequestLoadContext {
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
2
2
  import {
3
3
  branchTrackingRef,
4
4
  fetchBranchTrackingRef,
@@ -21,18 +21,17 @@ import {
21
21
  } from "./pr-routing.ts";
22
22
  import {
23
23
  assertOnlyDeclaredStatusChanged,
24
+ extensionExecApi,
24
25
  inspectWorktree,
25
26
  isAncestor,
27
+ isRecord,
26
28
  parseNulPaths,
27
29
  readHead,
28
30
  readRemoteOid,
29
31
  requiredOid,
30
32
  requiredText,
31
33
  runChecked,
32
- spawnBounded,
33
34
  withWorktreeLock,
34
- type Exec,
35
- type ExecOptions,
36
35
  } from "./pr-execution.ts";
37
36
 
38
37
  const MAX_TITLE_BYTES = 256;
@@ -97,10 +96,6 @@ function line(output: string, label: string): string {
97
96
  return values[0];
98
97
  }
99
98
 
100
- function isRecord(value: unknown): value is Record<string, unknown> {
101
- return typeof value === "object" && value !== null && !Array.isArray(value);
102
- }
103
-
104
99
  function graphQlResponse(output: string, action: string): Record<string, unknown> {
105
100
  let value: unknown;
106
101
  try {
@@ -210,14 +205,8 @@ export class PullRequestCreator {
210
205
  return { cwd: this.cwd, signal: this.signal, ...extra };
211
206
  }
212
207
 
213
- private pi(): Pick<ExtensionAPI, "exec"> {
214
- return {
215
- exec: (command, args, options) => this.exec(command, args, {
216
- cwd: options?.cwd ?? this.cwd,
217
- signal: options?.signal ?? this.signal,
218
- timeoutMs: options?.timeout,
219
- }),
220
- } as Pick<ExtensionAPI, "exec">;
208
+ private pi() {
209
+ return extensionExecApi(this.exec, this.cwd, this.signal);
221
210
  }
222
211
 
223
212
  private context(): PullRequestLoadContext {
@@ -1,47 +1,24 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { lstat, mkdir, realpath } from "node:fs/promises";
3
3
  import { join, resolve } from "node:path";
4
- import { spawn } from "node:child_process";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
  import { extensionConfigDir } from "@henryqw/pi-config-store";
6
+ import { spawnBounded, type Exec, type ExecOptions, type ExecResult } from "@henryqw/pi-process";
6
7
  import { lock } from "proper-lockfile";
7
8
 
8
- export const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
9
- export const DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024;
10
9
  export const MAX_CONFLICT_PATHS = 128;
11
10
  export const MAX_CONFLICT_PATH_BYTES = 1_024;
12
11
  export const MAX_CONFLICT_PATHS_BYTES = 32 * 1024;
13
12
 
14
13
  const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
15
14
  const GIT_OPERATION_STATES = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
16
- const PROCESS_TERMINATION_GRACE_MS = 250;
17
15
  const WORKTREE_LOCK_OPTIONS = { realpath: false, stale: 30_000, update: 5_000, retries: 0 } as const;
18
16
 
19
- export type ExecResult = {
20
- stdout: string;
21
- stderr: string;
22
- code: number;
23
- killed: boolean;
24
- stdoutTruncated?: boolean;
25
- };
26
-
27
- export type ExecOptions = {
28
- cwd: string;
29
- signal?: AbortSignal;
30
- timeoutMs?: number;
31
- stdoutLimitBytes?: number;
32
- stdoutTailBytes?: number;
33
- stderrLimitBytes?: number;
34
- stdin?: string;
35
- };
36
-
37
- export type Exec = (command: string, args: string[], options: ExecOptions) => Promise<ExecResult>;
38
-
39
17
  export type AttemptState = "none" | "attempting" | "applied" | "blocked" | "unknown";
40
18
  export type GitWorktreeState = "clean" | "dirty" | "operation";
41
19
 
42
- function positiveInteger(value: number, label: string): number {
43
- if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${label} must be a positive safe integer`);
44
- return value;
20
+ export function isRecord(value: unknown): value is Record<string, unknown> {
21
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
22
  }
46
23
 
47
24
  export function requiredText(value: unknown, label: string): string {
@@ -57,171 +34,20 @@ export function requiredOid(value: unknown, label: string): string {
57
34
  return parsed;
58
35
  }
59
36
 
60
- function validatedArguments(args: string[]): string[] {
61
- if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string" || arg.includes("\0"))) {
62
- throw new TypeError("command arguments must be an array of strings without NUL bytes");
63
- }
64
- return args;
65
- }
66
-
67
- function decode(chunks: Buffer[], bytes: number, label: string): string {
68
- try {
69
- return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks, bytes));
70
- } catch {
71
- throw new Error(`${label} was not valid UTF-8`);
72
- }
73
- }
74
-
75
- /** Run one argv-only child process with bounded streaming output, cancellation, and a deadline. */
76
- export const spawnBounded: Exec = async (command, args, options) => {
77
- requiredText(command, "command");
78
- validatedArguments(args);
79
- requiredText(options.cwd, "cwd");
80
- options.signal?.throwIfAborted();
81
- const timeoutMs = positiveInteger(options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS, "timeoutMs");
82
- const stdoutLimit = positiveInteger(options.stdoutLimitBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES, "stdoutLimitBytes");
83
- const stdoutTailLimit = options.stdoutTailBytes === undefined
84
- ? undefined
85
- : positiveInteger(options.stdoutTailBytes, "stdoutTailBytes");
86
- const stderrLimit = positiveInteger(options.stderrLimitBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES, "stderrLimitBytes");
87
- if (options.stdin !== undefined && typeof options.stdin !== "string") throw new TypeError("stdin must be a string");
88
-
89
- return await new Promise<ExecResult>((resolve, reject) => {
90
- const child = spawn(command, args, {
91
- cwd: options.cwd,
92
- detached: process.platform !== "win32",
93
- env: process.env,
94
- stdio: ["pipe", "pipe", "pipe"],
95
- });
96
- const childPid = child.pid;
97
- const processGroup = process.platform !== "win32" && typeof childPid === "number" &&
98
- Number.isSafeInteger(childPid) && childPid > 0 ? childPid : null;
99
- const stdout: Buffer[] = [];
100
- const stderr: Buffer[] = [];
101
- const stdoutDecoder = stdoutTailLimit === undefined
102
- ? undefined
103
- : new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
104
- let stdoutBytes = 0;
105
- let stdoutTruncated = false;
106
- let stderrBytes = 0;
107
- let failure: Error | undefined;
108
- let killed = false;
109
- let settled = false;
110
- let killTimer: ReturnType<typeof setTimeout> | undefined;
111
-
112
- const signalChild = (signal: NodeJS.Signals): void => {
113
- if (processGroup !== null) {
114
- try {
115
- process.kill(-processGroup, signal);
116
- killed = true;
117
- return;
118
- } catch (error) {
119
- if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ESRCH") return;
120
- }
121
- }
122
- killed = child.kill(signal) || killed;
123
- };
124
- const stop = (error: Error): void => {
125
- if (failure) return;
126
- failure = error;
127
- signalChild("SIGTERM");
128
- killTimer = setTimeout(() => signalChild("SIGKILL"), PROCESS_TERMINATION_GRACE_MS);
129
- };
130
- const appendTail = (text: string): void => {
131
- if (!text || stdoutTailLimit === undefined) return;
132
- const chunk = Buffer.from(text, "utf8");
133
- stdout.push(chunk);
134
- stdoutBytes += chunk.length;
135
- if (stdoutBytes <= stdoutTailLimit) return;
136
- stdoutTruncated = true;
137
- let remove = stdoutBytes - stdoutTailLimit;
138
- while (remove > 0) {
139
- const first = stdout[0]!;
140
- if (first.length <= remove) {
141
- stdout.shift();
142
- stdoutBytes -= first.length;
143
- remove -= first.length;
144
- continue;
145
- }
146
- let start = remove;
147
- while (start < first.length && (first[start]! & 0xc0) === 0x80) start += 1;
148
- stdout[0] = Buffer.from(first.subarray(start));
149
- stdoutBytes -= start;
150
- remove = 0;
151
- }
152
- };
153
- const decodeTail = (chunk?: Buffer): void => {
154
- if (!stdoutDecoder) return;
155
- try {
156
- appendTail(chunk === undefined ? stdoutDecoder.decode() : stdoutDecoder.decode(chunk, { stream: true }));
157
- } catch {
158
- stop(new Error(`${command} stdout was not valid UTF-8`));
159
- }
160
- };
161
- const timer = setTimeout(() => stop(new Error(`${command} timed out after ${timeoutMs}ms`)), timeoutMs);
162
- const abort = (): void => stop(options.signal?.reason instanceof Error
163
- ? options.signal.reason
164
- : new DOMException("The operation was aborted", "AbortError"));
165
- options.signal?.addEventListener("abort", abort, { once: true });
166
- if (options.signal?.aborted) abort();
167
-
168
- child.stdout.on("data", (chunk: Buffer) => {
169
- if (failure) return;
170
- if (stdoutDecoder) {
171
- decodeTail(chunk);
172
- return;
173
- }
174
- stdoutBytes += chunk.length;
175
- if (stdoutBytes > stdoutLimit) {
176
- stop(new Error(`${command} stdout exceeded ${stdoutLimit} bytes`));
177
- return;
178
- }
179
- stdout.push(chunk);
180
- });
181
- child.stderr.on("data", (chunk: Buffer) => {
182
- if (failure) return;
183
- stderrBytes += chunk.length;
184
- if (stderrBytes > stderrLimit) {
185
- stop(new Error(`${command} stderr exceeded ${stderrLimit} bytes`));
186
- return;
187
- }
188
- stderr.push(chunk);
189
- });
190
- child.once("error", stop);
191
- child.once("close", (code, signal) => {
192
- if (settled) return;
193
- settled = true;
194
- clearTimeout(timer);
195
- if (killTimer !== undefined && processGroup === null) clearTimeout(killTimer);
196
- options.signal?.removeEventListener("abort", abort);
197
- if (!failure && stdoutDecoder) decodeTail();
198
- if (failure) {
199
- reject(failure);
200
- return;
201
- }
202
- try {
203
- resolve({
204
- stdout: stdoutDecoder
205
- ? Buffer.concat(stdout, stdoutBytes).toString("utf8")
206
- : decode(stdout, stdoutBytes, `${command} stdout`),
207
- stderr: decode(stderr, stderrBytes, `${command} stderr`),
208
- code: code ?? 1,
209
- killed: killed || signal !== null,
210
- ...(stdoutDecoder ? { stdoutTruncated } : {}),
211
- });
212
- } catch (error) {
213
- reject(error);
214
- }
215
- });
216
- if (options.stdin === undefined) child.stdin.end();
217
- else child.stdin.end(options.stdin, "utf8");
218
- });
219
- };
220
-
221
37
  export function commandText(command: string, args: readonly string[]): string {
222
38
  return [command, ...args].join(" ");
223
39
  }
224
40
 
41
+ export function extensionExecApi(exec: Exec, cwd: string, signal?: AbortSignal): Pick<ExtensionAPI, "exec"> {
42
+ return {
43
+ exec: (command, args, options) => exec(command, args, {
44
+ cwd: options?.cwd ?? cwd,
45
+ signal: options?.signal ?? signal,
46
+ timeoutMs: options?.timeout,
47
+ }),
48
+ } as Pick<ExtensionAPI, "exec">;
49
+ }
50
+
225
51
  export async function runChecked(
226
52
  exec: Exec,
227
53
  command: string,
@@ -1,10 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
+ import type { Exec, ExecOptions } from "@henryqw/pi-process";
2
3
  import {
4
+ isRecord,
3
5
  runChecked,
4
6
  requiredOid,
5
7
  requiredText,
6
- type Exec,
7
- type ExecOptions,
8
8
  } from "./pr-execution.ts";
9
9
  import type { CurrentPullRequest } from "./pr-github.ts";
10
10
 
@@ -104,10 +104,6 @@ type Connection = {
104
104
  endCursor: string | null;
105
105
  };
106
106
 
107
- function isRecord(value: unknown): value is Record<string, unknown> {
108
- return value !== null && typeof value === "object" && !Array.isArray(value);
109
- }
110
-
111
107
  function plainText(value: unknown, label: string): string {
112
108
  if (typeof value !== "string" || value.includes("\0")) throw new Error(`${label} must be a string without NUL bytes`);
113
109
  return value;
@@ -7,6 +7,7 @@ import { dirname, join, resolve } from "node:path";
7
7
  import { inspectLocalMergeSafety } from "./pr-merge.ts";
8
8
  import {
9
9
  inspectWorktreeState,
10
+ isRecord,
10
11
  withWorktreeLock,
11
12
  type GitWorktreeState,
12
13
  } from "./pr-execution.ts";
@@ -97,6 +98,18 @@ export type CurrentPullRequest = PullRequest & {
97
98
 
98
99
  export type CurrentPullRequestDiscovery = PullRequestDiscovery<CurrentPullRequest>;
99
100
 
101
+ export function cloneCurrentPullRequest(value: CurrentPullRequest): CurrentPullRequest {
102
+ return {
103
+ ...value,
104
+ url: new URL(value.url.href),
105
+ conditions: { ...value.conditions },
106
+ local: { ...value.local },
107
+ base: { ...value.base },
108
+ head: { ...value.head },
109
+ target: { ...value.target },
110
+ };
111
+ }
112
+
100
113
  export type PullRequestObservation = {
101
114
  pullRequest: { url: string; number: number; host: string };
102
115
  head: PullRequestRef;
@@ -253,10 +266,6 @@ function fail(action: string, reason: string): never {
253
266
  throw new PullRequestLoadError(`${action} failed: ${reason}`);
254
267
  }
255
268
 
256
- function isRecord(value: unknown): value is Record<string, unknown> {
257
- return typeof value === "object" && value !== null && !Array.isArray(value);
258
- }
259
-
260
269
  function text(value: unknown, action: string, field: string): string {
261
270
  if (
262
271
  typeof value !== "string" || !value || value.trim() !== value ||
@@ -602,37 +611,13 @@ function checkOutcome(state: string): "failure" | "success" | "running" {
602
611
  return "running";
603
612
  }
604
613
 
605
- function canonicalPositiveDecimal(value: string): boolean {
606
- const parsed = Number(value);
607
- return Number.isSafeInteger(parsed) && parsed > 0 && String(parsed) === value;
608
- }
609
-
610
- function isActionsJobUrl(value: unknown, pullRequestUrl: URL, repository: string): boolean {
611
- if (typeof value !== "string" || !value) return false;
612
- let url: URL;
613
- try {
614
- url = new URL(value);
615
- } catch {
616
- return false;
617
- }
618
- if (url.protocol !== "https:" || url.username || url.password || url.port || url.search || url.hash ||
619
- url.hostname.toLowerCase() !== pullRequestUrl.hostname.toLowerCase()) return false;
620
- const parts = url.pathname.split("/").filter(Boolean);
621
- const expected = repository.split("/");
622
- return parts.length === 7 && expected.length === 2 &&
623
- parts[0]!.toLowerCase() === expected[0]!.toLowerCase() &&
624
- parts[1]!.toLowerCase() === expected[1]!.toLowerCase() &&
625
- parts[2] === "actions" && parts[3] === "runs" && canonicalPositiveDecimal(parts[4]!) &&
626
- parts[5] === "job" && canonicalPositiveDecimal(parts[6]!);
627
- }
628
-
629
- function isDiagnosableActionsCheck(check: Record<string, unknown>, pullRequestUrl: URL, repository: string): boolean {
614
+ function isDiagnosableActionsCheck(check: Record<string, unknown>): boolean {
630
615
  const workflowName = check.workflowName;
631
616
  return typeof workflowName === "string" && !!workflowName && workflowName.trim() === workflowName &&
632
- !/\p{Cc}/u.test(workflowName) && isActionsJobUrl(check.detailsUrl, pullRequestUrl, repository);
617
+ !/\p{Cc}/u.test(workflowName);
633
618
  }
634
619
 
635
- function checkState(value: unknown, pullRequestUrl: URL, repository: string): CheckState {
620
+ function checkState(value: unknown): CheckState {
636
621
  if (!isRecord(value) || (value.__typename !== "CheckRun" && value.__typename !== "StatusContext")) {
637
622
  return fail("Find pull requests", "invalid statusCheckRollup");
638
623
  }
@@ -652,14 +637,14 @@ function checkState(value: unknown, pullRequestUrl: URL, repository: string): Ch
652
637
  return {
653
638
  state: selected,
654
639
  diagnosableFailure: FAILED_CHECK_STATES.has(selected) && value.__typename === "CheckRun" &&
655
- isDiagnosableActionsCheck(value, pullRequestUrl, repository),
640
+ isDiagnosableActionsCheck(value),
656
641
  };
657
642
  }
658
643
 
659
- function checkStates(value: unknown, pullRequestUrl: URL, repository: string): CheckState[] {
644
+ function checkStates(value: unknown): CheckState[] {
660
645
  if (value === null) return [];
661
646
  if (!Array.isArray(value)) fail("Find pull requests", "invalid statusCheckRollup");
662
- return value.map((check) => checkState(check, pullRequestUrl, repository));
647
+ return value.map(checkState);
663
648
  }
664
649
 
665
650
  function listedPullRequest(value: unknown): ListedPullRequest | null {
@@ -692,7 +677,7 @@ function listedPullRequest(value: unknown): ListedPullRequest | null {
692
677
  mergeable: mergeable(value.mergeable),
693
678
  mergeStateStatus: mergeStateStatus(value.mergeStateStatus),
694
679
  reviewDecision: reviewDecision(value.reviewDecision),
695
- checkStates: checkStates(value.statusCheckRollup, parsedUrl.url, parsedUrl.repository),
680
+ checkStates: checkStates(value.statusCheckRollup),
696
681
  };
697
682
  }
698
683
 
@@ -1,10 +1,11 @@
1
+ import type { Exec } from "@henryqw/pi-process";
1
2
  import {
2
3
  inspectWorktree,
4
+ isRecord,
3
5
  readHead,
4
6
  requiredOid,
5
7
  requiredText,
6
8
  runChecked,
7
- type Exec,
8
9
  } from "./pr-execution.ts";
9
10
  import type { LocalMergeSafety } from "./pr-routing.ts";
10
11
 
@@ -32,10 +33,6 @@ export type ExecuteGitHubMergeInput = InspectLocalMergeSafetyInput & {
32
33
  revalidateReadiness: (local: InspectedLocalMergeSafety) => Promise<void>;
33
34
  };
34
35
 
35
- function isRecord(value: unknown): value is Record<string, unknown> {
36
- return typeof value === "object" && value !== null && !Array.isArray(value);
37
- }
38
-
39
36
  function validateInspectionInput(input: InspectLocalMergeSafetyInput): void {
40
37
  requiredText(input.cwd, "cwd");
41
38
  requiredOid(input.expectedHead, "expected PR head");
@@ -1,5 +1,6 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
2
2
  import {
3
+ cloneCurrentPullRequest,
3
4
  loadCurrentPullRequest,
4
5
  samePullRequestSnapshot,
5
6
  type CurrentPullRequest,
@@ -7,6 +8,7 @@ import {
7
8
  } from "./pr-github.ts";
8
9
  import {
9
10
  assertOnlyDeclaredStatusChanged,
11
+ extensionExecApi,
10
12
  inspectWorktree,
11
13
  isAncestor,
12
14
  parseNulPaths,
@@ -15,10 +17,7 @@ import {
15
17
  requiredOid,
16
18
  resolveRepositoryFetchSource,
17
19
  runChecked,
18
- spawnBounded,
19
20
  withWorktreeLock,
20
- type Exec,
21
- type ExecOptions,
22
21
  } from "./pr-execution.ts";
23
22
 
24
23
  export type UpdateBranchPhase = "ready" | "conflict-awaiting-user" | "verified" | "published" | "blocked";
@@ -45,18 +44,6 @@ export type UpdateBranchOptions = {
45
44
  loadCurrentPullRequest?: Load;
46
45
  };
47
46
 
48
- function cloneAuthority(value: CurrentPullRequest): CurrentPullRequest {
49
- return {
50
- ...value,
51
- url: new URL(value.url.href),
52
- conditions: { ...value.conditions },
53
- local: { ...value.local },
54
- base: { ...value.base },
55
- head: { ...value.head },
56
- target: { ...value.target },
57
- };
58
- }
59
-
60
47
  function sameAuthority(frozen: CurrentPullRequest, fresh: CurrentPullRequest): boolean {
61
48
  return samePullRequestSnapshot(frozen, fresh) && frozen.base.oid === fresh.base.oid &&
62
49
  fresh.lifecycle === "open" && (fresh.conditions.baseUpdateRequired || fresh.conditions.conflict);
@@ -97,7 +84,7 @@ export class PullRequestBranchUpdater {
97
84
  throw new TypeError("Pull request does not require a branch update");
98
85
  }
99
86
  this.cwd = options.cwd;
100
- this.authority = cloneAuthority(options.authority);
87
+ this.authority = cloneCurrentPullRequest(options.authority);
101
88
  this.signal = options.signal;
102
89
  this.agentDir = options.agentDir;
103
90
  this.exec = options.exec ?? spawnBounded;
@@ -108,14 +95,8 @@ export class PullRequestBranchUpdater {
108
95
  return { cwd: this.cwd, signal: this.signal, ...extra };
109
96
  }
110
97
 
111
- private pi(): Pick<ExtensionAPI, "exec"> {
112
- return {
113
- exec: (command, args, options) => this.exec(command, args, {
114
- cwd: options?.cwd ?? this.cwd,
115
- signal: options?.signal ?? this.signal,
116
- timeoutMs: options?.timeout,
117
- }),
118
- } as Pick<ExtensionAPI, "exec">;
98
+ private pi() {
99
+ return extensionExecApi(this.exec, this.cwd, this.signal);
119
100
  }
120
101
 
121
102
  private context(): PullRequestLoadContext {
@@ -137,13 +118,6 @@ export class PullRequestBranchUpdater {
137
118
  return discovery.pullRequest;
138
119
  }
139
120
 
140
- private async liveBaseMatches(): Promise<void> {
141
- const discovery = await this.load(this.pi(), this.context());
142
- if (discovery.kind !== "current" || !sameAuthority(this.authority, discovery.pullRequest)) {
143
- throw new Error("Branch update cancelled: live base or pull request authority changed");
144
- }
145
- }
146
-
147
121
  private async verifyMerge(originalHead: string): Promise<{ head: string; fastForward: boolean }> {
148
122
  const head = await readHead(this.exec, this.execOptions());
149
123
  const parentsOutput = singleLine((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", "HEAD"], this.execOptions())).stdout, "merge parents");
package/extensions/pr.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { spawnBounded } from "@henryqw/pi-process";
2
3
  import { realpath } from "node:fs/promises";
3
4
  import {
4
5
  isBashToolResult,
@@ -15,7 +16,6 @@ import {
15
16
  type PrCommandDependencies,
16
17
  type PrCommandInvocation,
17
18
  type WorkflowPromptIdentity,
18
- type WorkflowReservation,
19
19
  } from "./pr-command.ts";
20
20
  import { PullRequestCreator, type CreatePullRequestOptions } from "./pr-create.ts";
21
21
  import {
@@ -26,7 +26,7 @@ import {
26
26
  samePullRequestObservation,
27
27
  type PullRequestObservation,
28
28
  } from "./pr-github.ts";
29
- import { runChecked, spawnBounded } from "./pr-execution.ts";
29
+ import { isRecord, runChecked } from "./pr-execution.ts";
30
30
  import {
31
31
  discoveryIssueKey,
32
32
  discoveryIssueMessage,
@@ -192,10 +192,6 @@ function matchesWorkflowPrompt(prompt: string, identity: WorkflowPromptIdentity)
192
192
  return matchesSkill && tokens.has(`runId=${identity.runId}`) && tokens.has(`action=${identity.action}`);
193
193
  }
194
194
 
195
- function isRecord(value: unknown): value is Record<string, unknown> {
196
- return typeof value === "object" && value !== null && !Array.isArray(value);
197
- }
198
-
199
195
  function parseWorkspaceLabel(response: Record<string, unknown>, workspaceId: string): string {
200
196
  const result = response.result;
201
197
  const workspace = isRecord(result) && isRecord(result.workspace) ? result.workspace : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "4.0.9",
3
+ "version": "5.0.0",
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",
@@ -34,6 +34,7 @@
34
34
  "dependencies": {
35
35
  "@henryqw/pi-config-store": "^1.1.0",
36
36
  "@henryqw/pi-herdr": "^0.4.5",
37
+ "@henryqw/pi-process": "^0.1.0",
37
38
  "proper-lockfile": "^4.1.2"
38
39
  },
39
40
  "devDependencies": {
@@ -17,10 +17,10 @@ import {
17
17
  loadCurrentPullRequest,
18
18
  samePullRequestSnapshot,
19
19
  } from "../../../extensions/pr-github.ts";
20
+ import { spawnBounded } from "@henryqw/pi-process";
20
21
  import {
21
22
  inspectWorktree,
22
23
  readHead,
23
- spawnBounded,
24
24
  } from "../../../extensions/pr-execution.ts";
25
25
 
26
26
  class UsageError extends Error {}
@@ -18,8 +18,9 @@ The invocation authorizes one scoped edit, commit, and guarded publish.
18
18
  - Never expose raw logs, credentials, or environment values in the final report.
19
19
 
20
20
  The collect action closes after one call. It returns only current failed GitHub
21
- Actions jobs bound to immutable check, suite, run, attempt, job, and step IDs.
22
- If it blocks, report the blocker and stop.
21
+ Actions jobs bound to immutable check-suite, run, attempt, job, and step IDs.
22
+ It resolves runs through GitHub API fields, not HTML details links. If it blocks,
23
+ report the blocker and stop.
23
24
 
24
25
  ## Diagnose and repair
25
26
 
@@ -49,7 +50,7 @@ If publication blocks or reports an unknown outcome, stop without another push.
49
50
 
50
51
  Report only:
51
52
 
52
- - **Checks:** failed check names and returned URLs.
53
+ - **Checks:** failed check and job names with returned run and job URLs.
53
54
  - **Fix:** root cause and scoped files changed.
54
55
  - **Validation:** commands and results, including reproduction limits.
55
56
  - **Commit:** commit ID and message, or state that none was made.