@henryqw/pi-pr 4.0.10 → 5.0.1
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 +3 -2
- package/extensions/pr-ci.ts +81 -200
- package/extensions/pr-comment-sweep.ts +4 -13
- package/extensions/pr-create.ts +4 -13
- package/extensions/pr-execution.ts +15 -0
- package/extensions/pr-feedback.ts +1 -4
- package/extensions/pr-github.ts +20 -35
- package/extensions/pr-merge.ts +1 -4
- package/extensions/pr-routing.ts +2 -2
- package/extensions/pr-update-branch.ts +5 -29
- package/extensions/pr.ts +1 -6
- package/package.json +1 -1
- package/skills/pi-pr-fix-ci/SKILL.md +4 -3
package/README.md
CHANGED
|
@@ -116,7 +116,7 @@ If Herdr lookup, JSON validation, or rename fails, the PR and normal UI refresh
|
|
|
116
116
|
|
|
117
117
|
Current-branch discovery reads pull requests associated with the exact push repository ref. It does not run a global branch search. It finds a fork-head PR whose base is an upstream repository. A unique historical match uses the exact remote push-ref OID, not local HEAD.
|
|
118
118
|
|
|
119
|
-
A no-action state includes drafts, merged or closed pull requests,
|
|
119
|
+
A no-action state includes drafts, merged or closed pull requests, unsupported failed CI, pending review, and blocked merge policy. Running CI blocks merge but not other mutating workflows. A dirty tree or mismatched local HEAD also blocks a mutating workflow.
|
|
120
120
|
|
|
121
121
|
### Route priority
|
|
122
122
|
|
|
@@ -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
|
|
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.
|
package/extensions/pr-ci.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
208
|
-
const
|
|
209
|
-
|
|
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 {
|
|
315
|
-
if (
|
|
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
|
-
|
|
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(({
|
|
380
|
-
|
|
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 =
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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
|
|
718
|
-
const
|
|
719
|
-
|
|
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 (
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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.
|
|
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()
|
|
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 {
|
package/extensions/pr-create.ts
CHANGED
|
@@ -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()
|
|
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;
|
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,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");
|
package/extensions/pr-routing.ts
CHANGED
|
@@ -93,10 +93,10 @@ export function derivePullRequestNextStep(pullRequest: PullRequest): Exclude<Nex
|
|
|
93
93
|
return localMutationSafe(local) ? "sweep" : "none";
|
|
94
94
|
}
|
|
95
95
|
if (
|
|
96
|
-
conditions.ci === "running" ||
|
|
97
96
|
conditions.review === "pending" ||
|
|
98
97
|
conditions.policy === "pending" ||
|
|
99
|
-
!localMergeSafe(local)
|
|
98
|
+
!localMergeSafe(local) ||
|
|
99
|
+
conditions.ci === "running"
|
|
100
100
|
) return "none";
|
|
101
101
|
return "merge";
|
|
102
102
|
}
|
|
@@ -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 =
|
|
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()
|
|
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
|
@@ -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.
|