@henryqw/pi-pr 4.0.10 → 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
@@ -172,7 +172,8 @@ The GitHub response must match the observed URL, host, repository, head ref, hea
172
172
  - A merge, rebase, cherry-pick, revert, or sequencer state blocks direct merge, even when `git status` is empty.
173
173
  - A branch update resolves the base repository ref directly. It stops if that ref moves before merge or push.
174
174
  - Before a comment-sweep push, it revalidates the configured destination, full PR identity, and local HEAD. It pushes the captured OID.
175
- - 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.
176
177
  - Before push, CI repair revalidates the saved destination, open PR, failure evidence, and repair HEAD.
177
178
  - An already-published local HEAD needs no second push.
178
179
  - Direct merge requires final confirmation and a fresh readiness check.
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
2
  import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
4
3
  import {
4
+ cloneCurrentPullRequest,
5
5
  loadCurrentPullRequest,
6
6
  readValidatedRemoteAuthority,
7
7
  samePullRequestSnapshot,
@@ -9,6 +9,7 @@ import {
9
9
  type PullRequestLoadContext,
10
10
  } from "./pr-github.ts";
11
11
  import {
12
+ extensionExecApi,
12
13
  inspectWorktree,
13
14
  isAncestor,
14
15
  readHead,
@@ -50,9 +51,6 @@ type StepIdentity = {
50
51
 
51
52
  type JobIdentity = {
52
53
  id: number;
53
- url: string;
54
- htmlUrl: string;
55
- checkRunUrl: string;
56
54
  runId: number;
57
55
  attempt: number;
58
56
  headOid: string;
@@ -64,8 +62,6 @@ type JobIdentity = {
64
62
 
65
63
  type CheckIdentity = {
66
64
  id: number;
67
- url: string;
68
- detailsUrl: string | null;
69
65
  suiteId: number;
70
66
  headOid: string;
71
67
  name: string;
@@ -76,8 +72,6 @@ type CheckIdentity = {
76
72
 
77
73
  type RunIdentity = {
78
74
  id: number;
79
- url: string;
80
- htmlUrl: string;
81
75
  attempt: number;
82
76
  suiteId: number;
83
77
  headOid: string;
@@ -87,7 +81,7 @@ type RunIdentity = {
87
81
  };
88
82
 
89
83
  type FailureIdentity = {
90
- check: CheckIdentity;
84
+ checks: CheckIdentity[];
91
85
  run: RunIdentity;
92
86
  job: JobIdentity;
93
87
  failedSteps: StepIdentity[];
@@ -99,7 +93,7 @@ type CiSnapshot = {
99
93
  };
100
94
 
101
95
  export type CiFailureEvidence = {
102
- checkRun: { id: number; url: string; detailsUrl: string; name: string; conclusion: string };
96
+ checkRuns: Array<{ id: number; name: string; conclusion: string }>;
103
97
  checkSuite: { id: number };
104
98
  run: { id: number; url: string; attempt: number };
105
99
  job: { id: number; url: string; name: string; conclusion: string };
@@ -138,18 +132,6 @@ export type CiPublishResult = {
138
132
  attempt: "applied";
139
133
  };
140
134
 
141
- function cloneAuthority(value: CurrentPullRequest): CurrentPullRequest {
142
- return {
143
- ...value,
144
- url: new URL(value.url.href),
145
- conditions: { ...value.conditions },
146
- local: { ...value.local },
147
- base: { ...value.base },
148
- head: { ...value.head },
149
- target: { ...value.target },
150
- };
151
- }
152
-
153
135
  function record(value: unknown, label: string): Record<string, unknown> {
154
136
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} returned invalid JSON`);
155
137
  return value as Record<string, unknown>;
@@ -204,91 +186,12 @@ function outputLine(output: string, label: string): string {
204
186
  return requiredText(lines[0], label);
205
187
  }
206
188
 
207
- function secureUrl(value: unknown, label: string): URL {
208
- const raw = text(value, label);
209
- let url: URL;
210
- try {
211
- url = new URL(raw);
212
- } catch {
213
- throw new Error(`${label} returned an invalid URL`);
214
- }
215
- if (url.protocol !== "https:" || url.username || url.password || url.port || url.search || url.hash) {
216
- throw new Error(`${label} returned an invalid URL`);
217
- }
218
- return url;
219
- }
220
-
221
- function pathParts(url: URL): string[] {
222
- const parts = url.pathname.split("/").filter(Boolean);
223
- if (parts.some((part) => !part || part === "." || part === ".." || part.includes("%"))) {
224
- throw new Error("GitHub returned an invalid identity URL");
225
- }
226
- return parts;
227
- }
228
-
229
- function sameRepository(parts: readonly string[], repository: string): boolean {
230
- const expected = repository.split("/");
231
- return parts.length === 2 && expected.length === 2 &&
232
- parts[0]!.toLowerCase() === expected[0]!.toLowerCase() &&
233
- parts[1]!.toLowerCase() === expected[1]!.toLowerCase();
234
- }
235
-
236
- function parseApiIdentityUrl(
237
- value: unknown,
238
- label: string,
239
- host: string,
240
- repository: string,
241
- kind: "check-runs" | "runs" | "jobs",
242
- expectedId: number,
243
- ): string {
244
- const url = secureUrl(value, label);
245
- const parts = pathParts(url);
246
- const expectedHost = (host === "github.com" ? "api.github.com" : host).toLowerCase();
247
- if (url.hostname.toLowerCase() !== expectedHost) throw new Error(`${label} returned an unexpected API origin`);
248
- const tailSize = kind === "check-runs" ? 5 : 6;
249
- const tail = parts.slice(-tailSize);
250
- const valid = kind === "check-runs"
251
- ? tail[0] === "repos" && sameRepository(tail.slice(1, 3), repository) && tail[3] === kind
252
- : tail[0] === "repos" && sameRepository(tail.slice(1, 3), repository) && tail[3] === "actions" && tail[4] === kind;
253
- 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}`;
254
192
  return url.href;
255
193
  }
256
194
 
257
- function parseRunHtmlUrl(value: unknown, label: string, host: string, repository: string, expectedId: number): string {
258
- const url = secureUrl(value, label);
259
- const parts = pathParts(url);
260
- if (url.hostname.toLowerCase() !== host || parts.length !== 5 || !sameRepository(parts.slice(0, 2), repository) ||
261
- parts[2] !== "actions" || parts[3] !== "runs" || parts[4] !== String(expectedId)) {
262
- throw new Error(`${label} did not match its immutable ID`);
263
- }
264
- return url.href;
265
- }
266
-
267
- function decimalIdentity(value: string, label: string): number {
268
- const parsed = integer(Number(value), label);
269
- if (String(parsed) !== value) throw new Error(`${label} returned a noncanonical decimal ID`);
270
- return parsed;
271
- }
272
-
273
- function parseJobHtmlUrl(
274
- value: unknown,
275
- label: string,
276
- host: string,
277
- repository: string,
278
- ): { url: string; runId: number; jobId: number } {
279
- const url = secureUrl(value, label);
280
- const parts = pathParts(url);
281
- if (url.hostname.toLowerCase() !== host || parts.length !== 7 || !sameRepository(parts.slice(0, 2), repository) ||
282
- parts[2] !== "actions" || parts[3] !== "runs" || parts[5] !== "job") {
283
- throw new Error(`${label} was not an immutable GitHub Actions job URL`);
284
- }
285
- return {
286
- url: url.href,
287
- runId: decimalIdentity(parts[4]!, `${label} run ID`),
288
- jobId: decimalIdentity(parts[6]!, `${label} job ID`),
289
- };
290
- }
291
-
292
195
  function tailUtf8(value: string, limit: number): { text: string; truncated: boolean } {
293
196
  const total = Buffer.byteLength(value, "utf8");
294
197
  if (total <= limit) return { text: value, truncated: false };
@@ -308,24 +211,28 @@ function tailUtf8(value: string, limit: number): { text: string; truncated: bool
308
211
  }
309
212
 
310
213
  function failureEvidence(
214
+ authority: CurrentPullRequest,
311
215
  failure: FailureIdentity,
312
216
  log: { text: string; truncated: boolean },
313
217
  ): CiFailureEvidence {
314
- const { check, run, job, failedSteps } = failure;
315
- 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)) {
316
220
  throw new Error(`Failed job ${job.id} omitted required evidence identity`);
317
221
  }
318
222
  return {
319
- checkRun: {
223
+ checkRuns: checks.map((check) => ({
320
224
  id: check.id,
321
- url: check.url,
322
- detailsUrl: check.detailsUrl,
323
225
  name: check.name,
324
- 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,
325
235
  },
326
- checkSuite: { id: check.suiteId },
327
- run: { id: run.id, url: run.htmlUrl, attempt: run.attempt },
328
- job: { id: job.id, url: job.htmlUrl, name: job.name, conclusion: job.conclusion },
329
236
  failedSteps: failedSteps.map((step) => {
330
237
  if (step.conclusion === null) throw new Error(`Failed job ${job.id} step omitted its conclusion`);
331
238
  return { number: step.number, name: step.name, conclusion: step.conclusion };
@@ -339,17 +246,18 @@ function jsonBytes(value: unknown): number {
339
246
  }
340
247
 
341
248
  function boundedEvidenceRecord(
249
+ authority: CurrentPullRequest,
342
250
  failure: FailureIdentity,
343
251
  rawLog: { text: string; truncated: boolean },
344
252
  maxBytes: number,
345
253
  ): CiFailureEvidence {
346
- const metadata = failureEvidence(failure, { text: "", truncated: false });
254
+ const metadata = failureEvidence(authority, failure, { text: "", truncated: false });
347
255
  const metadataBytes = jsonBytes(metadata);
348
256
  if (metadataBytes > maxBytes) throw new Error(`Failed job ${failure.job.id} metadata exceeds its evidence budget`);
349
257
  let logBytes = Math.min(Buffer.byteLength(rawLog.text, "utf8"), maxBytes - metadataBytes);
350
258
  while (true) {
351
259
  const retained = tailUtf8(rawLog.text, logBytes);
352
- const evidence = failureEvidence(failure, {
260
+ const evidence = failureEvidence(authority, failure, {
353
261
  text: retained.text,
354
262
  truncated: rawLog.truncated || retained.truncated,
355
263
  });
@@ -376,21 +284,16 @@ function authorityIdentity(value: CurrentPullRequest) {
376
284
  function snapshotFingerprint(authority: CurrentPullRequest, failures: FailureIdentity[]): string {
377
285
  return createHash("sha256").update(JSON.stringify({
378
286
  authority: authorityIdentity(authority),
379
- failures: failures.map(({ check, run, job, failedSteps }) => ({
380
- check,
287
+ failures: failures.map(({ checks, run, job, failedSteps }) => ({
288
+ checks,
381
289
  run: {
382
290
  id: run.id,
383
- url: run.url,
384
- htmlUrl: run.htmlUrl,
385
291
  attempt: run.attempt,
386
292
  suiteId: run.suiteId,
387
293
  headOid: run.headOid,
388
294
  },
389
295
  job: {
390
296
  id: job.id,
391
- url: job.url,
392
- htmlUrl: job.htmlUrl,
393
- checkRunUrl: job.checkRunUrl,
394
297
  runId: job.runId,
395
298
  attempt: job.attempt,
396
299
  headOid: job.headOid,
@@ -432,7 +335,7 @@ export class PullRequestCiFixer {
432
335
  : requiredOid(options.authority.target.remoteOid, "remote OID");
433
336
  if (remote !== head) throw new TypeError("CI repair requires the pull request head to match the configured remote OID");
434
337
  this.cwd = requiredText(options.cwd, "cwd");
435
- this.authority = cloneAuthority(options.authority);
338
+ this.authority = cloneCurrentPullRequest(options.authority);
436
339
  this.signal = options.signal;
437
340
  this.agentDir = options.agentDir;
438
341
  this.exec = options.exec ?? spawnBounded;
@@ -443,14 +346,8 @@ export class PullRequestCiFixer {
443
346
  return { cwd: this.cwd, signal: this.signal, ...extra };
444
347
  }
445
348
 
446
- private pi(): Pick<ExtensionAPI, "exec"> {
447
- return {
448
- exec: (command, args, options) => this.exec(command, args, {
449
- cwd: options?.cwd ?? this.cwd,
450
- signal: options?.signal ?? this.signal,
451
- timeoutMs: options?.timeout,
452
- }),
453
- } as Pick<ExtensionAPI, "exec">;
349
+ private pi() {
350
+ return extensionExecApi(this.exec, this.cwd, this.signal);
454
351
  }
455
352
 
456
353
  private context(): PullRequestLoadContext {
@@ -506,11 +403,8 @@ export class PullRequestCiFixer {
506
403
  if (suite.head_sha !== undefined && requiredOid(suite.head_sha, `check run ${id} suite head OID`) !== headOid) {
507
404
  throw new Error(`Check run ${id} suite is stale for the frozen pull request head`);
508
405
  }
509
- const detailsUrl = item.details_url === null ? null : secureUrl(item.details_url, `check run ${id} details URL`).href;
510
406
  return {
511
407
  id,
512
- url: parseApiIdentityUrl(item.url, `check run ${id} URL`, this.authority.host, this.authority.base.repository, "check-runs", id),
513
- detailsUrl,
514
408
  suiteId: integer(suite.id, `check run ${id} suite ID`),
515
409
  headOid,
516
410
  name: text(item.name, `check run ${id} name`),
@@ -555,8 +449,7 @@ export class PullRequestCiFixer {
555
449
  }
556
450
  if (expectedTotal === undefined || checks.length !== expectedTotal) throw new Error("Check-run pagination was incomplete");
557
451
  checks.sort((left, right) => left.id - right.id);
558
- if (new Set(checks.map(({ id }) => id)).size !== checks.length ||
559
- new Set(checks.map(({ url }) => url)).size !== checks.length) {
452
+ if (new Set(checks.map(({ id }) => id)).size !== checks.length) {
560
453
  throw new Error("Check-run pagination returned duplicate immutable identities");
561
454
  }
562
455
  return checks;
@@ -586,13 +479,8 @@ export class PullRequestCiFixer {
586
479
  const steps = array(item.steps, `job ${id} steps`).map((step) => this.parseStep(step, id));
587
480
  steps.sort((left, right) => left.number - right.number);
588
481
  if (new Set(steps.map(({ number }) => number)).size !== steps.length) throw new Error(`Job ${id} returned duplicate step identities`);
589
- const html = parseJobHtmlUrl(item.html_url, `job ${id} HTML URL`, this.authority.host, this.authority.base.repository);
590
- if (html.runId !== runId || html.jobId !== id) throw new Error(`Job ${id} HTML URL did not match its immutable IDs`);
591
482
  return {
592
483
  id,
593
- url: parseApiIdentityUrl(item.url, `job ${id} URL`, this.authority.host, this.authority.base.repository, "jobs", id),
594
- htmlUrl: html.url,
595
- checkRunUrl: secureUrl(item.check_run_url, `job ${id} check-run URL`).href,
596
484
  runId,
597
485
  attempt,
598
486
  headOid,
@@ -603,7 +491,25 @@ export class PullRequestCiFixer {
603
491
  };
604
492
  }
605
493
 
606
- 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> {
607
513
  const label = `Read workflow run ${runId}`;
608
514
  const item = record(parseJson(await this.api(
609
515
  `repos/${this.authority.base.repository}/actions/runs/${runId}`,
@@ -620,12 +526,13 @@ export class PullRequestCiFixer {
620
526
  throw new Error(`${label} does not belong to the frozen pull request repositories`);
621
527
  }
622
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
+ }
623
532
  const run: RunIdentity = {
624
533
  id: runId,
625
- url: parseApiIdentityUrl(item.url, `${label} URL`, this.authority.host, this.authority.base.repository, "runs", runId),
626
- htmlUrl: parseRunHtmlUrl(item.html_url, `${label} HTML URL`, this.authority.host, this.authority.base.repository, runId),
627
534
  attempt,
628
- suiteId: integer(item.check_suite_id, `${label} suite ID`),
535
+ suiteId,
629
536
  headOid,
630
537
  status: currentStatus,
631
538
  conclusion: conclusion(item.conclusion, currentStatus, label),
@@ -660,9 +567,7 @@ export class PullRequestCiFixer {
660
567
  }
661
568
  if (expectedTotal === undefined || run.jobs.length !== expectedTotal) throw new Error(`Workflow run ${runId} job pagination was incomplete`);
662
569
  run.jobs.sort((left, right) => left.id - right.id);
663
- if (new Set(run.jobs.map(({ id }) => id)).size !== run.jobs.length ||
664
- new Set(run.jobs.map(({ url }) => url)).size !== run.jobs.length ||
665
- new Set(run.jobs.map(({ checkRunUrl }) => checkRunUrl)).size !== run.jobs.length) {
570
+ if (new Set(run.jobs.map(({ id }) => id)).size !== run.jobs.length) {
666
571
  throw new Error(`Workflow run ${runId} returned duplicate job identities`);
667
572
  }
668
573
  return run;
@@ -678,68 +583,44 @@ export class PullRequestCiFixer {
678
583
  const unsupported = failed.find(({ provider }) => provider !== "github-actions");
679
584
  if (unsupported) throw new Error(`Unsupported failed check provider for immutable evidence: ${unsupported.provider}`);
680
585
 
681
- const runIds = new Set<number>();
682
- const detailIdentities = new Map<number, { url: string; runId: number; jobId: number }>();
586
+ const checksBySuite = new Map<number, CheckIdentity[]>();
683
587
  for (const check of failed) {
684
- if (check.detailsUrl === null) throw new Error(`Failed check run ${check.id} omitted its immutable job URL`);
685
- const detail = parseJobHtmlUrl(
686
- check.detailsUrl,
687
- `check run ${check.id} details URL`,
688
- this.authority.host,
689
- this.authority.base.repository,
690
- );
691
- detailIdentities.set(check.id, detail);
692
- runIds.add(detail.runId);
588
+ const suiteChecks = checksBySuite.get(check.suiteId) ?? [];
589
+ suiteChecks.push(check);
590
+ checksBySuite.set(check.suiteId, suiteChecks);
693
591
  }
694
592
 
695
593
  const runs: RunIdentity[] = [];
696
- this.consumeRecords(pageBudget, runIds.size);
697
- for (const runId of [...runIds].sort((left, right) => left - right)) {
698
- 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");
699
601
  }
700
602
  const allJobs = runs.flatMap(({ jobs }) => jobs);
701
- if (new Set(allJobs.map(({ id }) => id)).size !== allJobs.length ||
702
- new Set(allJobs.map(({ checkRunUrl }) => checkRunUrl)).size !== allJobs.length) {
603
+ if (new Set(allJobs.map(({ id }) => id)).size !== allJobs.length) {
703
604
  throw new Error("Workflow runs returned ambiguous duplicate job identities");
704
605
  }
705
- const runById = new Map(runs.map((run) => [run.id, run]));
706
- for (const run of runs) {
707
- for (const job of run.jobs) {
708
- const linked = checks.filter((check) => check.url === job.checkRunUrl);
709
- if (linked.length !== 1 || linked[0]!.detailsUrl !== job.htmlUrl || linked[0]!.suiteId !== run.suiteId ||
710
- linked[0]!.status !== job.status || linked[0]!.conclusion !== job.conclusion) {
711
- throw new Error(`Job ${job.id} has an incomplete, replaced, or ambiguous immutable check-run mapping`);
712
- }
713
- }
714
- }
715
- const usedJobs = new Set<number>();
606
+
716
607
  const failures: FailureIdentity[] = [];
717
- for (const check of failed) {
718
- const detail = detailIdentities.get(check.id)!;
719
- const run = runById.get(detail.runId);
720
- if (!run || run.suiteId !== check.suiteId) {
721
- throw new Error(`Failed check run ${check.id} has an ambiguous or stale suite-to-run mapping`);
722
- }
723
- const matches = run.jobs.filter((job) =>
724
- 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)
725
611
  );
726
- if (matches.length !== 1 || usedJobs.has(detail.jobId)) {
727
- throw new Error(`Failed check run ${check.id} has an incomplete, duplicate, or ambiguous immutable job mapping`);
728
- }
729
- const job = matches[0]!;
730
- if (job.status !== "completed" || job.conclusion !== check.conclusion ||
731
- job.conclusion === null || !FAILED_CONCLUSIONS.has(job.conclusion)) {
732
- 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
+ });
733
621
  }
734
- usedJobs.add(job.id);
735
- failures.push({
736
- check,
737
- run,
738
- job,
739
- failedSteps: job.steps.filter(({ conclusion: value }) => value !== null && FAILED_CONCLUSIONS.has(value)),
740
- });
741
622
  }
742
- 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);
743
624
  return {
744
625
  fingerprint: snapshotFingerprint(fresh, failures),
745
626
  failures,
@@ -763,7 +644,7 @@ export class PullRequestCiFixer {
763
644
  this.state.phase = "collecting";
764
645
  try {
765
646
  const before = await this.readSnapshot(true);
766
- 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 }));
767
648
  const metadataBytes = metadata.map(jsonBytes);
768
649
  const metadataTotal = jsonBytes(metadata);
769
650
  if (metadataBytes.some((bytes) => bytes > EVIDENCE_RECORD_BYTES) || metadataTotal > EVIDENCE_TOTAL_BYTES) {
@@ -776,7 +657,7 @@ export class PullRequestCiFixer {
776
657
  const baseBytes = metadataBytes[index]!;
777
658
  const share = Math.floor(remainingBytes / (before.failures.length - index));
778
659
  const raw = await this.readJobLog(failure.job.id);
779
- 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));
780
661
  remainingBytes -= jsonBytes(evidence) - baseBytes;
781
662
  failures.push(evidence);
782
663
  }
@@ -1,7 +1,6 @@
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";
5
4
  import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
6
5
  import {
7
6
  extensionConfigDir,
@@ -28,8 +27,10 @@ import {
28
27
  type PullRequestLoadContext,
29
28
  } from "./pr-github.ts";
30
29
  import {
30
+ extensionExecApi,
31
31
  inspectGitOperation,
32
32
  isAncestor,
33
+ isRecord,
33
34
  parseNulPaths,
34
35
  parseStatusSnapshot,
35
36
  readHead,
@@ -134,10 +135,6 @@ export type PullRequestCommentSweepOptions = {
134
135
  newRunId?: () => string;
135
136
  pause?: (milliseconds: number) => Promise<void>;
136
137
  };
137
- function isRecord(value: unknown): value is Record<string, unknown> {
138
- return value !== null && typeof value === "object" && !Array.isArray(value);
139
- }
140
-
141
138
  function exactKeys(value: Record<string, unknown>, keys: readonly string[], label: string): void {
142
139
  const actual = Object.keys(value).sort();
143
140
  const expected = [...keys].sort();
@@ -558,14 +555,8 @@ export class PullRequestCommentSweep {
558
555
  return { cwd: this.cwd, signal: this.signal, ...extra };
559
556
  }
560
557
 
561
- private pi(): Pick<ExtensionAPI, "exec"> {
562
- return {
563
- exec: (command, args, options) => this.exec(command, args, {
564
- cwd: options?.cwd ?? this.cwd,
565
- signal: options?.signal ?? this.signal,
566
- timeoutMs: options?.timeout,
567
- }),
568
- } as Pick<ExtensionAPI, "exec">;
558
+ private pi() {
559
+ return extensionExecApi(this.exec, this.cwd, this.signal);
569
560
  }
570
561
 
571
562
  private context(): PullRequestLoadContext {
@@ -1,4 +1,3 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
1
  import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
3
2
  import {
4
3
  branchTrackingRef,
@@ -22,8 +21,10 @@ import {
22
21
  } from "./pr-routing.ts";
23
22
  import {
24
23
  assertOnlyDeclaredStatusChanged,
24
+ extensionExecApi,
25
25
  inspectWorktree,
26
26
  isAncestor,
27
+ isRecord,
27
28
  parseNulPaths,
28
29
  readHead,
29
30
  readRemoteOid,
@@ -95,10 +96,6 @@ function line(output: string, label: string): string {
95
96
  return values[0];
96
97
  }
97
98
 
98
- function isRecord(value: unknown): value is Record<string, unknown> {
99
- return typeof value === "object" && value !== null && !Array.isArray(value);
100
- }
101
-
102
99
  function graphQlResponse(output: string, action: string): Record<string, unknown> {
103
100
  let value: unknown;
104
101
  try {
@@ -208,14 +205,8 @@ export class PullRequestCreator {
208
205
  return { cwd: this.cwd, signal: this.signal, ...extra };
209
206
  }
210
207
 
211
- private pi(): Pick<ExtensionAPI, "exec"> {
212
- return {
213
- exec: (command, args, options) => this.exec(command, args, {
214
- cwd: options?.cwd ?? this.cwd,
215
- signal: options?.signal ?? this.signal,
216
- timeoutMs: options?.timeout,
217
- }),
218
- } as Pick<ExtensionAPI, "exec">;
208
+ private pi() {
209
+ return extensionExecApi(this.exec, this.cwd, this.signal);
219
210
  }
220
211
 
221
212
  private context(): PullRequestLoadContext {
@@ -1,6 +1,7 @@
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 type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
5
  import { extensionConfigDir } from "@henryqw/pi-config-store";
5
6
  import { spawnBounded, type Exec, type ExecOptions, type ExecResult } from "@henryqw/pi-process";
6
7
  import { lock } from "proper-lockfile";
@@ -16,6 +17,10 @@ const WORKTREE_LOCK_OPTIONS = { realpath: false, stale: 30_000, update: 5_000, r
16
17
  export type AttemptState = "none" | "attempting" | "applied" | "blocked" | "unknown";
17
18
  export type GitWorktreeState = "clean" | "dirty" | "operation";
18
19
 
20
+ export function isRecord(value: unknown): value is Record<string, unknown> {
21
+ return typeof value === "object" && value !== null && !Array.isArray(value);
22
+ }
23
+
19
24
  export function requiredText(value: unknown, label: string): string {
20
25
  if (typeof value !== "string" || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) {
21
26
  throw new TypeError(`${label} must be a non-empty string`);
@@ -33,6 +38,16 @@ export function commandText(command: string, args: readonly string[]): string {
33
38
  return [command, ...args].join(" ");
34
39
  }
35
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
+
36
51
  export async function runChecked(
37
52
  exec: Exec,
38
53
  command: string,
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { Exec, ExecOptions } from "@henryqw/pi-process";
3
3
  import {
4
+ isRecord,
4
5
  runChecked,
5
6
  requiredOid,
6
7
  requiredText,
@@ -103,10 +104,6 @@ type Connection = {
103
104
  endCursor: string | null;
104
105
  };
105
106
 
106
- function isRecord(value: unknown): value is Record<string, unknown> {
107
- return value !== null && typeof value === "object" && !Array.isArray(value);
108
- }
109
-
110
107
  function plainText(value: unknown, label: string): string {
111
108
  if (typeof value !== "string" || value.includes("\0")) throw new Error(`${label} must be a string without NUL bytes`);
112
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,6 +1,7 @@
1
1
  import type { Exec } from "@henryqw/pi-process";
2
2
  import {
3
3
  inspectWorktree,
4
+ isRecord,
4
5
  readHead,
5
6
  requiredOid,
6
7
  requiredText,
@@ -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,6 +1,6 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
1
  import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
3
2
  import {
3
+ cloneCurrentPullRequest,
4
4
  loadCurrentPullRequest,
5
5
  samePullRequestSnapshot,
6
6
  type CurrentPullRequest,
@@ -8,6 +8,7 @@ import {
8
8
  } from "./pr-github.ts";
9
9
  import {
10
10
  assertOnlyDeclaredStatusChanged,
11
+ extensionExecApi,
11
12
  inspectWorktree,
12
13
  isAncestor,
13
14
  parseNulPaths,
@@ -43,18 +44,6 @@ export type UpdateBranchOptions = {
43
44
  loadCurrentPullRequest?: Load;
44
45
  };
45
46
 
46
- function cloneAuthority(value: CurrentPullRequest): CurrentPullRequest {
47
- return {
48
- ...value,
49
- url: new URL(value.url.href),
50
- conditions: { ...value.conditions },
51
- local: { ...value.local },
52
- base: { ...value.base },
53
- head: { ...value.head },
54
- target: { ...value.target },
55
- };
56
- }
57
-
58
47
  function sameAuthority(frozen: CurrentPullRequest, fresh: CurrentPullRequest): boolean {
59
48
  return samePullRequestSnapshot(frozen, fresh) && frozen.base.oid === fresh.base.oid &&
60
49
  fresh.lifecycle === "open" && (fresh.conditions.baseUpdateRequired || fresh.conditions.conflict);
@@ -95,7 +84,7 @@ export class PullRequestBranchUpdater {
95
84
  throw new TypeError("Pull request does not require a branch update");
96
85
  }
97
86
  this.cwd = options.cwd;
98
- this.authority = cloneAuthority(options.authority);
87
+ this.authority = cloneCurrentPullRequest(options.authority);
99
88
  this.signal = options.signal;
100
89
  this.agentDir = options.agentDir;
101
90
  this.exec = options.exec ?? spawnBounded;
@@ -106,14 +95,8 @@ export class PullRequestBranchUpdater {
106
95
  return { cwd: this.cwd, signal: this.signal, ...extra };
107
96
  }
108
97
 
109
- private pi(): Pick<ExtensionAPI, "exec"> {
110
- return {
111
- exec: (command, args, options) => this.exec(command, args, {
112
- cwd: options?.cwd ?? this.cwd,
113
- signal: options?.signal ?? this.signal,
114
- timeoutMs: options?.timeout,
115
- }),
116
- } as Pick<ExtensionAPI, "exec">;
98
+ private pi() {
99
+ return extensionExecApi(this.exec, this.cwd, this.signal);
117
100
  }
118
101
 
119
102
  private context(): PullRequestLoadContext {
@@ -135,13 +118,6 @@ export class PullRequestBranchUpdater {
135
118
  return discovery.pullRequest;
136
119
  }
137
120
 
138
- private async liveBaseMatches(): Promise<void> {
139
- const discovery = await this.load(this.pi(), this.context());
140
- if (discovery.kind !== "current" || !sameAuthority(this.authority, discovery.pullRequest)) {
141
- throw new Error("Branch update cancelled: live base or pull request authority changed");
142
- }
143
- }
144
-
145
121
  private async verifyMerge(originalHead: string): Promise<{ head: string; fastForward: boolean }> {
146
122
  const head = await readHead(this.exec, this.execOptions());
147
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
@@ -16,7 +16,6 @@ import {
16
16
  type PrCommandDependencies,
17
17
  type PrCommandInvocation,
18
18
  type WorkflowPromptIdentity,
19
- type WorkflowReservation,
20
19
  } from "./pr-command.ts";
21
20
  import { PullRequestCreator, type CreatePullRequestOptions } from "./pr-create.ts";
22
21
  import {
@@ -27,7 +26,7 @@ import {
27
26
  samePullRequestObservation,
28
27
  type PullRequestObservation,
29
28
  } from "./pr-github.ts";
30
- import { runChecked } from "./pr-execution.ts";
29
+ import { isRecord, runChecked } from "./pr-execution.ts";
31
30
  import {
32
31
  discoveryIssueKey,
33
32
  discoveryIssueMessage,
@@ -193,10 +192,6 @@ function matchesWorkflowPrompt(prompt: string, identity: WorkflowPromptIdentity)
193
192
  return matchesSkill && tokens.has(`runId=${identity.runId}`) && tokens.has(`action=${identity.action}`);
194
193
  }
195
194
 
196
- function isRecord(value: unknown): value is Record<string, unknown> {
197
- return typeof value === "object" && value !== null && !Array.isArray(value);
198
- }
199
-
200
195
  function parseWorkspaceLabel(response: Record<string, unknown>, workspaceId: string): string {
201
196
  const result = response.result;
202
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.10",
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",
@@ -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.