@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 +4 -1
- package/extensions/pr-ci.ts +82 -203
- package/extensions/pr-comment-sweep.ts +5 -16
- package/extensions/pr-create.ts +5 -16
- package/extensions/pr-execution.ts +14 -188
- package/extensions/pr-feedback.ts +2 -6
- package/extensions/pr-github.ts +20 -35
- package/extensions/pr-merge.ts +2 -5
- package/extensions/pr-update-branch.ts +6 -32
- package/extensions/pr.ts +2 -6
- package/package.json +2 -1
- package/skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs +1 -1
- package/skills/pi-pr-fix-ci/SKILL.md +4 -3
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
|
|
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.
|
package/extensions/pr-ci.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import type
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
210
|
-
const
|
|
211
|
-
|
|
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 {
|
|
317
|
-
if (
|
|
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
|
-
|
|
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(({
|
|
382
|
-
|
|
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 =
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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
|
|
720
|
-
const
|
|
721
|
-
|
|
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 (
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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.
|
|
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
|
|
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()
|
|
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 {
|
package/extensions/pr-create.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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()
|
|
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 {
|
|
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
|
|
43
|
-
|
|
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;
|
package/extensions/pr-github.ts
CHANGED
|
@@ -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
|
|
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)
|
|
617
|
+
!/\p{Cc}/u.test(workflowName);
|
|
633
618
|
}
|
|
634
619
|
|
|
635
|
-
function checkState(value: unknown
|
|
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
|
|
640
|
+
isDiagnosableActionsCheck(value),
|
|
656
641
|
};
|
|
657
642
|
}
|
|
658
643
|
|
|
659
|
-
function checkStates(value: unknown
|
|
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(
|
|
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
|
|
680
|
+
checkStates: checkStates(value.statusCheckRollup),
|
|
696
681
|
};
|
|
697
682
|
}
|
|
698
683
|
|
package/extensions/pr-merge.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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()
|
|
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 {
|
|
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": "
|
|
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
|
|
22
|
-
|
|
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
|
|
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.
|