@henryqw/pi-pr 3.1.9 → 4.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -11
- package/docs/pr-routing.svg +1 -1
- package/extensions/pr-ci.ts +908 -0
- package/extensions/pr-command.ts +107 -21
- package/extensions/pr-comment-sweep.ts +1102 -0
- package/extensions/pr-create.ts +684 -0
- package/extensions/pr-execution.ts +558 -0
- package/extensions/pr-feedback.ts +519 -0
- package/extensions/pr-github.ts +363 -145
- package/extensions/pr-merge.ts +4 -44
- package/extensions/pr-routing.ts +2 -1
- package/extensions/pr-ui.ts +1 -1
- package/extensions/pr-update-branch.ts +327 -0
- package/extensions/pr.ts +480 -16
- package/package.json +4 -2
- package/skills/pi-pr-comment-sweep/SKILL.md +45 -89
- package/skills/pi-pr-comment-sweep/references/recovery.md +24 -46
- package/skills/pi-pr-comment-sweep/references/thread-triage.md +2 -2
- package/skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs +223 -1799
- package/skills/pi-pr-create/SKILL.md +6 -77
- package/skills/pi-pr-fix-ci/SKILL.md +39 -39
- package/skills/pi-pr-update-branch/SKILL.md +5 -108
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
loadCurrentPullRequest,
|
|
5
|
+
readValidatedRemoteAuthority,
|
|
6
|
+
samePullRequestSnapshot,
|
|
7
|
+
type CurrentPullRequest,
|
|
8
|
+
type PullRequestLoadContext,
|
|
9
|
+
} from "./pr-github.ts";
|
|
10
|
+
import {
|
|
11
|
+
inspectWorktree,
|
|
12
|
+
isAncestor,
|
|
13
|
+
readHead,
|
|
14
|
+
readRemoteOid,
|
|
15
|
+
requiredOid,
|
|
16
|
+
requiredText,
|
|
17
|
+
runChecked,
|
|
18
|
+
spawnBounded,
|
|
19
|
+
withWorktreeLock,
|
|
20
|
+
type Exec,
|
|
21
|
+
type ExecOptions,
|
|
22
|
+
} from "./pr-execution.ts";
|
|
23
|
+
|
|
24
|
+
const PAGE_BYTES = 512 * 1024;
|
|
25
|
+
const MAX_PAGES = 100;
|
|
26
|
+
const MAX_RECORDS = 1_000;
|
|
27
|
+
const LOG_TAIL_BYTES = 20 * 1024;
|
|
28
|
+
const LOG_TIMEOUT_MS = 60_000;
|
|
29
|
+
const EVIDENCE_RECORD_BYTES = 20 * 1024;
|
|
30
|
+
const EVIDENCE_TOTAL_BYTES = 256 * 1024;
|
|
31
|
+
const PAGE_SIZE = 100;
|
|
32
|
+
const API_HEADERS = [
|
|
33
|
+
"-H", "Accept: application/vnd.github+json",
|
|
34
|
+
"-H", "X-GitHub-Api-Version: 2022-11-28",
|
|
35
|
+
];
|
|
36
|
+
const FAILED_CONCLUSIONS = new Set([
|
|
37
|
+
"action_required", "cancelled", "failure", "stale", "startup_failure", "timed_out",
|
|
38
|
+
]);
|
|
39
|
+
const CONCLUSIONS = new Set([
|
|
40
|
+
...FAILED_CONCLUSIONS, "neutral", "skipped", "success",
|
|
41
|
+
]);
|
|
42
|
+
const STATUSES = new Set(["completed", "in_progress", "pending", "queued", "requested", "waiting"]);
|
|
43
|
+
|
|
44
|
+
type Load = typeof loadCurrentPullRequest;
|
|
45
|
+
|
|
46
|
+
type StepIdentity = {
|
|
47
|
+
number: number;
|
|
48
|
+
name: string;
|
|
49
|
+
status: string;
|
|
50
|
+
conclusion: string | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type JobIdentity = {
|
|
54
|
+
id: number;
|
|
55
|
+
url: string;
|
|
56
|
+
htmlUrl: string;
|
|
57
|
+
checkRunUrl: string;
|
|
58
|
+
runId: number;
|
|
59
|
+
attempt: number;
|
|
60
|
+
headOid: string;
|
|
61
|
+
name: string;
|
|
62
|
+
status: string;
|
|
63
|
+
conclusion: string | null;
|
|
64
|
+
steps: StepIdentity[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type CheckIdentity = {
|
|
68
|
+
id: number;
|
|
69
|
+
url: string;
|
|
70
|
+
detailsUrl: string | null;
|
|
71
|
+
suiteId: number;
|
|
72
|
+
headOid: string;
|
|
73
|
+
name: string;
|
|
74
|
+
status: string;
|
|
75
|
+
conclusion: string | null;
|
|
76
|
+
provider: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type RunIdentity = {
|
|
80
|
+
id: number;
|
|
81
|
+
url: string;
|
|
82
|
+
htmlUrl: string;
|
|
83
|
+
attempt: number;
|
|
84
|
+
suiteId: number;
|
|
85
|
+
headOid: string;
|
|
86
|
+
status: string;
|
|
87
|
+
conclusion: string | null;
|
|
88
|
+
jobs: JobIdentity[];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
type FailureIdentity = {
|
|
92
|
+
check: CheckIdentity;
|
|
93
|
+
run: RunIdentity;
|
|
94
|
+
job: JobIdentity;
|
|
95
|
+
failedSteps: StepIdentity[];
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
type CiSnapshot = {
|
|
99
|
+
fingerprint: string;
|
|
100
|
+
failures: FailureIdentity[];
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type CiFailureEvidence = {
|
|
104
|
+
checkRun: { id: number; url: string; detailsUrl: string; name: string; conclusion: string };
|
|
105
|
+
checkSuite: { id: number };
|
|
106
|
+
run: { id: number; url: string; attempt: number };
|
|
107
|
+
job: { id: number; url: string; name: string; conclusion: string };
|
|
108
|
+
failedSteps: Array<{ number: number; name: string; conclusion: string }>;
|
|
109
|
+
log: { scope: "job"; text: string; truncated: boolean };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export type CiEvidence = {
|
|
113
|
+
fingerprint: string;
|
|
114
|
+
pullRequest: { number: number; url: string; headOid: string };
|
|
115
|
+
failures: CiFailureEvidence[];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type CiPushAttempt = "before-launch" | "applied" | "not-applied" | "unknown";
|
|
119
|
+
export type CiFixPhase = "ready" | "collecting" | "collected" | "published" | "blocked";
|
|
120
|
+
|
|
121
|
+
export type CiFixState = {
|
|
122
|
+
phase: CiFixPhase;
|
|
123
|
+
pushAttempt: CiPushAttempt;
|
|
124
|
+
fingerprint?: string;
|
|
125
|
+
repairHead?: string;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export type PullRequestCiFixOptions = {
|
|
129
|
+
cwd: string;
|
|
130
|
+
authority: CurrentPullRequest;
|
|
131
|
+
signal?: AbortSignal;
|
|
132
|
+
agentDir?: string;
|
|
133
|
+
exec?: Exec;
|
|
134
|
+
loadCurrentPullRequest?: Load;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export type CiPublishResult = {
|
|
138
|
+
kind: "published";
|
|
139
|
+
head: string;
|
|
140
|
+
attempt: "applied";
|
|
141
|
+
};
|
|
142
|
+
|
|
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
|
+
merge: value.merge ? { ...value.merge, allowedMergeMethods: [...value.merge.allowedMergeMethods] } : null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
157
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} returned invalid JSON`);
|
|
158
|
+
return value as Record<string, unknown>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function array(value: unknown, label: string): unknown[] {
|
|
162
|
+
if (!Array.isArray(value)) throw new Error(`${label} returned invalid JSON`);
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function integer(value: unknown, label: string, allowZero = false): number {
|
|
167
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < (allowZero ? 0 : 1)) {
|
|
168
|
+
throw new Error(`${label} returned an invalid integer`);
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function text(value: unknown, label: string): string {
|
|
174
|
+
const parsed = requiredText(value, label);
|
|
175
|
+
if (Buffer.byteLength(parsed, "utf8") > 8 * 1024) throw new Error(`${label} was too long`);
|
|
176
|
+
return parsed;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function conclusion(value: unknown, status: string, label: string): string | null {
|
|
180
|
+
if (value === null) {
|
|
181
|
+
if (status === "completed") throw new Error(`${label} omitted a completed conclusion`);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
const parsed = text(value, `${label} conclusion`);
|
|
185
|
+
if (!CONCLUSIONS.has(parsed) || status !== "completed") throw new Error(`${label} returned an invalid conclusion`);
|
|
186
|
+
return parsed;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function status(value: unknown, label: string): string {
|
|
190
|
+
const parsed = text(value, `${label} status`);
|
|
191
|
+
if (!STATUSES.has(parsed)) throw new Error(`${label} returned an invalid status`);
|
|
192
|
+
return parsed;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parseJson(output: string, label: string): unknown {
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(output);
|
|
198
|
+
} catch {
|
|
199
|
+
throw new Error(`${label} returned invalid JSON`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function outputLine(output: string, label: string): string {
|
|
204
|
+
const normalized = output.replace(/\r\n/g, "\n");
|
|
205
|
+
const lines = (normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized).split("\n");
|
|
206
|
+
if (lines.length !== 1 || !lines[0]) throw new Error(`${label} returned invalid output`);
|
|
207
|
+
return requiredText(lines[0], label);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function secureUrl(value: unknown, label: string): URL {
|
|
211
|
+
const raw = text(value, label);
|
|
212
|
+
let url: URL;
|
|
213
|
+
try {
|
|
214
|
+
url = new URL(raw);
|
|
215
|
+
} catch {
|
|
216
|
+
throw new Error(`${label} returned an invalid URL`);
|
|
217
|
+
}
|
|
218
|
+
if (url.protocol !== "https:" || url.username || url.password || url.port || url.search || url.hash) {
|
|
219
|
+
throw new Error(`${label} returned an invalid URL`);
|
|
220
|
+
}
|
|
221
|
+
return url;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function pathParts(url: URL): string[] {
|
|
225
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
226
|
+
if (parts.some((part) => !part || part === "." || part === ".." || part.includes("%"))) {
|
|
227
|
+
throw new Error("GitHub returned an invalid identity URL");
|
|
228
|
+
}
|
|
229
|
+
return parts;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function sameRepository(parts: readonly string[], repository: string): boolean {
|
|
233
|
+
const expected = repository.split("/");
|
|
234
|
+
return parts.length === 2 && expected.length === 2 &&
|
|
235
|
+
parts[0]!.toLowerCase() === expected[0]!.toLowerCase() &&
|
|
236
|
+
parts[1]!.toLowerCase() === expected[1]!.toLowerCase();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseApiIdentityUrl(
|
|
240
|
+
value: unknown,
|
|
241
|
+
label: string,
|
|
242
|
+
host: string,
|
|
243
|
+
repository: string,
|
|
244
|
+
kind: "check-runs" | "runs" | "jobs",
|
|
245
|
+
expectedId: number,
|
|
246
|
+
): string {
|
|
247
|
+
const url = secureUrl(value, label);
|
|
248
|
+
const parts = pathParts(url);
|
|
249
|
+
const expectedHost = (host === "github.com" ? "api.github.com" : host).toLowerCase();
|
|
250
|
+
if (url.hostname.toLowerCase() !== expectedHost) throw new Error(`${label} returned an unexpected API origin`);
|
|
251
|
+
const tailSize = kind === "check-runs" ? 5 : 6;
|
|
252
|
+
const tail = parts.slice(-tailSize);
|
|
253
|
+
const valid = kind === "check-runs"
|
|
254
|
+
? tail[0] === "repos" && sameRepository(tail.slice(1, 3), repository) && tail[3] === kind
|
|
255
|
+
: tail[0] === "repos" && sameRepository(tail.slice(1, 3), repository) && tail[3] === "actions" && tail[4] === kind;
|
|
256
|
+
if (!valid || tail[tail.length - 1] !== String(expectedId)) throw new Error(`${label} did not match its immutable ID`);
|
|
257
|
+
return url.href;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function parseRunHtmlUrl(value: unknown, label: string, host: string, repository: string, expectedId: number): string {
|
|
261
|
+
const url = secureUrl(value, label);
|
|
262
|
+
const parts = pathParts(url);
|
|
263
|
+
if (url.hostname.toLowerCase() !== host || parts.length !== 5 || !sameRepository(parts.slice(0, 2), repository) ||
|
|
264
|
+
parts[2] !== "actions" || parts[3] !== "runs" || parts[4] !== String(expectedId)) {
|
|
265
|
+
throw new Error(`${label} did not match its immutable ID`);
|
|
266
|
+
}
|
|
267
|
+
return url.href;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function decimalIdentity(value: string, label: string): number {
|
|
271
|
+
const parsed = integer(Number(value), label);
|
|
272
|
+
if (String(parsed) !== value) throw new Error(`${label} returned a noncanonical decimal ID`);
|
|
273
|
+
return parsed;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseJobHtmlUrl(
|
|
277
|
+
value: unknown,
|
|
278
|
+
label: string,
|
|
279
|
+
host: string,
|
|
280
|
+
repository: string,
|
|
281
|
+
): { url: string; runId: number; jobId: number } {
|
|
282
|
+
const url = secureUrl(value, label);
|
|
283
|
+
const parts = pathParts(url);
|
|
284
|
+
if (url.hostname.toLowerCase() !== host || parts.length !== 7 || !sameRepository(parts.slice(0, 2), repository) ||
|
|
285
|
+
parts[2] !== "actions" || parts[3] !== "runs" || parts[5] !== "job") {
|
|
286
|
+
throw new Error(`${label} was not an immutable GitHub Actions job URL`);
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
url: url.href,
|
|
290
|
+
runId: decimalIdentity(parts[4]!, `${label} run ID`),
|
|
291
|
+
jobId: decimalIdentity(parts[6]!, `${label} job ID`),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function tailUtf8(value: string, limit: number): { text: string; truncated: boolean } {
|
|
296
|
+
const total = Buffer.byteLength(value, "utf8");
|
|
297
|
+
if (total <= limit) return { text: value, truncated: false };
|
|
298
|
+
let start = value.length;
|
|
299
|
+
let bytes = 0;
|
|
300
|
+
while (start > 0) {
|
|
301
|
+
let previous = start - 1;
|
|
302
|
+
if (previous > 0 && value.charCodeAt(previous) >= 0xdc00 && value.charCodeAt(previous) <= 0xdfff &&
|
|
303
|
+
value.charCodeAt(previous - 1) >= 0xd800 && value.charCodeAt(previous - 1) <= 0xdbff) previous -= 1;
|
|
304
|
+
const character = value.slice(previous, start);
|
|
305
|
+
const size = Buffer.byteLength(character, "utf8");
|
|
306
|
+
if (bytes + size > limit) break;
|
|
307
|
+
bytes += size;
|
|
308
|
+
start = previous;
|
|
309
|
+
}
|
|
310
|
+
return { text: value.slice(start), truncated: true };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function failureEvidence(
|
|
314
|
+
failure: FailureIdentity,
|
|
315
|
+
log: { text: string; truncated: boolean },
|
|
316
|
+
): CiFailureEvidence {
|
|
317
|
+
const { check, run, job, failedSteps } = failure;
|
|
318
|
+
if (check.detailsUrl === null || check.conclusion === null || job.conclusion === null) {
|
|
319
|
+
throw new Error(`Failed job ${job.id} omitted required evidence identity`);
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
checkRun: {
|
|
323
|
+
id: check.id,
|
|
324
|
+
url: check.url,
|
|
325
|
+
detailsUrl: check.detailsUrl,
|
|
326
|
+
name: check.name,
|
|
327
|
+
conclusion: check.conclusion,
|
|
328
|
+
},
|
|
329
|
+
checkSuite: { id: check.suiteId },
|
|
330
|
+
run: { id: run.id, url: run.htmlUrl, attempt: run.attempt },
|
|
331
|
+
job: { id: job.id, url: job.htmlUrl, name: job.name, conclusion: job.conclusion },
|
|
332
|
+
failedSteps: failedSteps.map((step) => {
|
|
333
|
+
if (step.conclusion === null) throw new Error(`Failed job ${job.id} step omitted its conclusion`);
|
|
334
|
+
return { number: step.number, name: step.name, conclusion: step.conclusion };
|
|
335
|
+
}),
|
|
336
|
+
log: { scope: "job", ...log },
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function jsonBytes(value: unknown): number {
|
|
341
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function boundedEvidenceRecord(
|
|
345
|
+
failure: FailureIdentity,
|
|
346
|
+
rawLog: { text: string; truncated: boolean },
|
|
347
|
+
maxBytes: number,
|
|
348
|
+
): CiFailureEvidence {
|
|
349
|
+
const metadata = failureEvidence(failure, { text: "", truncated: false });
|
|
350
|
+
const metadataBytes = jsonBytes(metadata);
|
|
351
|
+
if (metadataBytes > maxBytes) throw new Error(`Failed job ${failure.job.id} metadata exceeds its evidence budget`);
|
|
352
|
+
let logBytes = Math.min(Buffer.byteLength(rawLog.text, "utf8"), maxBytes - metadataBytes);
|
|
353
|
+
while (true) {
|
|
354
|
+
const retained = tailUtf8(rawLog.text, logBytes);
|
|
355
|
+
const evidence = failureEvidence(failure, {
|
|
356
|
+
text: retained.text,
|
|
357
|
+
truncated: rawLog.truncated || retained.truncated,
|
|
358
|
+
});
|
|
359
|
+
const size = jsonBytes(evidence);
|
|
360
|
+
if (size <= maxBytes) return evidence;
|
|
361
|
+
if (logBytes === 0) throw new Error(`Failed job ${failure.job.id} metadata exceeds its evidence budget`);
|
|
362
|
+
const serializedLogBytes = Math.max(1, size - metadataBytes);
|
|
363
|
+
logBytes = Math.max(0, Math.floor(logBytes * (maxBytes - metadataBytes) / serializedLogBytes) - 1);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function authorityIdentity(value: CurrentPullRequest) {
|
|
368
|
+
return {
|
|
369
|
+
id: value.id,
|
|
370
|
+
number: value.number,
|
|
371
|
+
url: value.url.href,
|
|
372
|
+
host: value.host,
|
|
373
|
+
base: { ...value.base },
|
|
374
|
+
head: { ...value.head },
|
|
375
|
+
target: { ...value.target },
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function snapshotFingerprint(authority: CurrentPullRequest, failures: FailureIdentity[]): string {
|
|
380
|
+
return createHash("sha256").update(JSON.stringify({
|
|
381
|
+
authority: authorityIdentity(authority),
|
|
382
|
+
failures: failures.map(({ check, run, job, failedSteps }) => ({
|
|
383
|
+
check,
|
|
384
|
+
run: {
|
|
385
|
+
id: run.id,
|
|
386
|
+
url: run.url,
|
|
387
|
+
htmlUrl: run.htmlUrl,
|
|
388
|
+
attempt: run.attempt,
|
|
389
|
+
suiteId: run.suiteId,
|
|
390
|
+
headOid: run.headOid,
|
|
391
|
+
},
|
|
392
|
+
job: {
|
|
393
|
+
id: job.id,
|
|
394
|
+
url: job.url,
|
|
395
|
+
htmlUrl: job.htmlUrl,
|
|
396
|
+
checkRunUrl: job.checkRunUrl,
|
|
397
|
+
runId: job.runId,
|
|
398
|
+
attempt: job.attempt,
|
|
399
|
+
headOid: job.headOid,
|
|
400
|
+
name: job.name,
|
|
401
|
+
status: job.status,
|
|
402
|
+
conclusion: job.conclusion,
|
|
403
|
+
},
|
|
404
|
+
failedSteps,
|
|
405
|
+
})),
|
|
406
|
+
})).digest("hex");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function errorMessage(error: unknown): string {
|
|
410
|
+
return error instanceof Error ? error.message : String(error);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export class PullRequestCiFixer {
|
|
414
|
+
readonly state: CiFixState = { phase: "ready", pushAttempt: "before-launch" };
|
|
415
|
+
|
|
416
|
+
private readonly cwd: string;
|
|
417
|
+
private readonly authority: CurrentPullRequest;
|
|
418
|
+
private readonly signal?: AbortSignal;
|
|
419
|
+
private readonly agentDir?: string;
|
|
420
|
+
private readonly exec: Exec;
|
|
421
|
+
private readonly load: Load;
|
|
422
|
+
private collectConsumed = false;
|
|
423
|
+
private publishConsumed = false;
|
|
424
|
+
private collectedFingerprint?: string;
|
|
425
|
+
|
|
426
|
+
constructor(options: PullRequestCiFixOptions) {
|
|
427
|
+
if (!options.authority || options.authority.lifecycle !== "open" ||
|
|
428
|
+
options.authority.target.provenance !== "configured" || options.authority.conditions.ci !== "failure" ||
|
|
429
|
+
options.authority.local.worktree !== "clean" || options.authority.local.head !== "equal") {
|
|
430
|
+
throw new TypeError("CI repair requires a configured failed pull request with a clean equal local HEAD");
|
|
431
|
+
}
|
|
432
|
+
const head = requiredOid(options.authority.head.oid, "pull request head OID");
|
|
433
|
+
const remote = options.authority.target.remoteOid === null
|
|
434
|
+
? null
|
|
435
|
+
: requiredOid(options.authority.target.remoteOid, "remote OID");
|
|
436
|
+
if (remote !== head) throw new TypeError("CI repair requires the pull request head to match the configured remote OID");
|
|
437
|
+
this.cwd = requiredText(options.cwd, "cwd");
|
|
438
|
+
this.authority = cloneAuthority(options.authority);
|
|
439
|
+
this.signal = options.signal;
|
|
440
|
+
this.agentDir = options.agentDir;
|
|
441
|
+
this.exec = options.exec ?? spawnBounded;
|
|
442
|
+
this.load = options.loadCurrentPullRequest ?? loadCurrentPullRequest;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private options(extra: Partial<ExecOptions> = {}): ExecOptions {
|
|
446
|
+
return { cwd: this.cwd, signal: this.signal, ...extra };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private pi(): Pick<ExtensionAPI, "exec"> {
|
|
450
|
+
return {
|
|
451
|
+
exec: (command, args, options) => this.exec(command, args, {
|
|
452
|
+
cwd: options?.cwd ?? this.cwd,
|
|
453
|
+
signal: options?.signal ?? this.signal,
|
|
454
|
+
timeoutMs: options?.timeout,
|
|
455
|
+
}),
|
|
456
|
+
} as Pick<ExtensionAPI, "exec">;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private context(): PullRequestLoadContext {
|
|
460
|
+
return { cwd: this.cwd, signal: this.signal ?? new AbortController().signal };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private async freshAuthority(requireOriginalLocal: boolean): Promise<CurrentPullRequest> {
|
|
464
|
+
const discovery = await this.load(this.pi(), this.context());
|
|
465
|
+
if (discovery.kind !== "current" || !samePullRequestSnapshot(this.authority, discovery.pullRequest) ||
|
|
466
|
+
discovery.pullRequest.base.oid !== this.authority.base.oid ||
|
|
467
|
+
discovery.pullRequest.conditions.ci !== "failure" ||
|
|
468
|
+
discovery.pullRequest.target.remoteOid !== this.authority.head.oid) {
|
|
469
|
+
throw new Error("CI repair cancelled: frozen pull request, base, target, head, or failed-CI authority changed");
|
|
470
|
+
}
|
|
471
|
+
if (requireOriginalLocal && (discovery.pullRequest.local.worktree !== "clean" || discovery.pullRequest.local.head !== "equal")) {
|
|
472
|
+
throw new Error("CI repair evidence requires the original clean equal local HEAD");
|
|
473
|
+
}
|
|
474
|
+
return discovery.pullRequest;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private async requireOriginalLocal(): Promise<void> {
|
|
478
|
+
const branch = outputLine(
|
|
479
|
+
(await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout,
|
|
480
|
+
"current branch",
|
|
481
|
+
);
|
|
482
|
+
if (branch !== this.authority.target.branch) throw new Error("CI repair cancelled: current branch changed");
|
|
483
|
+
if (await inspectWorktree(this.exec, this.options()) !== "clean") {
|
|
484
|
+
throw new Error("CI repair requires a clean worktree with no Git operation in progress");
|
|
485
|
+
}
|
|
486
|
+
if (await readHead(this.exec, this.options()) !== this.authority.head.oid) {
|
|
487
|
+
throw new Error("CI repair evidence requires local HEAD to equal the frozen pull request head");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private async api(endpoint: string, label: string, options: Partial<ExecOptions> = {}): Promise<string> {
|
|
492
|
+
const result = await runChecked(this.exec, "gh", [
|
|
493
|
+
"api", "--hostname", this.authority.host, ...API_HEADERS, endpoint,
|
|
494
|
+
], this.options({ stdoutLimitBytes: PAGE_BYTES, ...options }));
|
|
495
|
+
const limit = options.stdoutLimitBytes ?? PAGE_BYTES;
|
|
496
|
+
if (Buffer.byteLength(result.stdout, "utf8") > limit) throw new Error(`${label} exceeded ${limit} bytes`);
|
|
497
|
+
return result.stdout;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private parseCheck(value: unknown): CheckIdentity {
|
|
501
|
+
const item = record(value, "List commit check runs");
|
|
502
|
+
const id = integer(item.id, "check-run ID");
|
|
503
|
+
const currentStatus = status(item.status, `check run ${id}`);
|
|
504
|
+
const currentConclusion = conclusion(item.conclusion, currentStatus, `check run ${id}`);
|
|
505
|
+
const suite = record(item.check_suite, `check run ${id} suite`);
|
|
506
|
+
const app = record(item.app, `check run ${id} provider`);
|
|
507
|
+
const headOid = requiredOid(item.head_sha, `check run ${id} head OID`);
|
|
508
|
+
if (headOid !== this.authority.head.oid) throw new Error(`Check run ${id} is stale for the frozen pull request head`);
|
|
509
|
+
if (suite.head_sha !== undefined && requiredOid(suite.head_sha, `check run ${id} suite head OID`) !== headOid) {
|
|
510
|
+
throw new Error(`Check run ${id} suite is stale for the frozen pull request head`);
|
|
511
|
+
}
|
|
512
|
+
const detailsUrl = item.details_url === null ? null : secureUrl(item.details_url, `check run ${id} details URL`).href;
|
|
513
|
+
return {
|
|
514
|
+
id,
|
|
515
|
+
url: parseApiIdentityUrl(item.url, `check run ${id} URL`, this.authority.host, this.authority.base.repository, "check-runs", id),
|
|
516
|
+
detailsUrl,
|
|
517
|
+
suiteId: integer(suite.id, `check run ${id} suite ID`),
|
|
518
|
+
headOid,
|
|
519
|
+
name: text(item.name, `check run ${id} name`),
|
|
520
|
+
status: currentStatus,
|
|
521
|
+
conclusion: currentConclusion,
|
|
522
|
+
provider: text(app.slug, `check run ${id} provider`),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private consumePage(budget: { pages: number; records: number }): void {
|
|
527
|
+
budget.pages += 1;
|
|
528
|
+
if (budget.pages > MAX_PAGES) throw new Error(`CI evidence pagination exceeds ${MAX_PAGES} pages`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private consumeRecords(budget: { pages: number; records: number }, count: number): void {
|
|
532
|
+
if (count > MAX_RECORDS - budget.records) throw new Error(`CI evidence exceeds ${MAX_RECORDS} aggregate records`);
|
|
533
|
+
budget.records += count;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private async readCheckRuns(budget: { pages: number; records: number }): Promise<CheckIdentity[]> {
|
|
537
|
+
const checks: CheckIdentity[] = [];
|
|
538
|
+
let expectedTotal: number | undefined;
|
|
539
|
+
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
540
|
+
this.consumePage(budget);
|
|
541
|
+
const endpoint = `repos/${this.authority.base.repository}/commits/${this.authority.head.oid}/check-runs?filter=latest&per_page=${PAGE_SIZE}&page=${page}`;
|
|
542
|
+
const parsed = record(parseJson(await this.api(endpoint, "List commit check runs"), "List commit check runs"), "List commit check runs");
|
|
543
|
+
const total = integer(parsed.total_count, "check-run total", true);
|
|
544
|
+
const pageChecks = array(parsed.check_runs, "List commit check runs");
|
|
545
|
+
if (pageChecks.length > PAGE_SIZE) throw new Error("Check-run page exceeded its record limit");
|
|
546
|
+
if (expectedTotal === undefined) {
|
|
547
|
+
expectedTotal = total;
|
|
548
|
+
if (total > MAX_RECORDS - budget.records) throw new Error(`CI evidence exceeds ${MAX_RECORDS} aggregate records`);
|
|
549
|
+
} else if (total !== expectedTotal) {
|
|
550
|
+
throw new Error("Check-run pagination total changed during collection");
|
|
551
|
+
}
|
|
552
|
+
this.consumeRecords(budget, pageChecks.length);
|
|
553
|
+
checks.push(...pageChecks.map((item) => this.parseCheck(item)));
|
|
554
|
+
if (checks.length > expectedTotal) throw new Error("Check-run pagination exceeded its declared total");
|
|
555
|
+
if (checks.length === expectedTotal) break;
|
|
556
|
+
if (!pageChecks.length) throw new Error("Check-run pagination ended before its declared total");
|
|
557
|
+
if (page === MAX_PAGES) throw new Error(`Check-run pagination exceeds ${MAX_PAGES} pages`);
|
|
558
|
+
}
|
|
559
|
+
if (expectedTotal === undefined || checks.length !== expectedTotal) throw new Error("Check-run pagination was incomplete");
|
|
560
|
+
checks.sort((left, right) => left.id - right.id);
|
|
561
|
+
if (new Set(checks.map(({ id }) => id)).size !== checks.length ||
|
|
562
|
+
new Set(checks.map(({ url }) => url)).size !== checks.length) {
|
|
563
|
+
throw new Error("Check-run pagination returned duplicate immutable identities");
|
|
564
|
+
}
|
|
565
|
+
return checks;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private parseStep(value: unknown, jobId: number): StepIdentity {
|
|
569
|
+
const item = record(value, `job ${jobId} step`);
|
|
570
|
+
const number = integer(item.number, `job ${jobId} step number`);
|
|
571
|
+
const currentStatus = status(item.status, `job ${jobId} step ${number}`);
|
|
572
|
+
return {
|
|
573
|
+
number,
|
|
574
|
+
name: text(item.name, `job ${jobId} step ${number} name`),
|
|
575
|
+
status: currentStatus,
|
|
576
|
+
conclusion: conclusion(item.conclusion, currentStatus, `job ${jobId} step ${number}`),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private parseJob(value: unknown, runId: number, attempt: number): JobIdentity {
|
|
581
|
+
const item = record(value, `run ${runId} job`);
|
|
582
|
+
const id = integer(item.id, `run ${runId} job ID`);
|
|
583
|
+
if (integer(item.run_id, `job ${id} run ID`) !== runId || integer(item.run_attempt, `job ${id} run attempt`) !== attempt) {
|
|
584
|
+
throw new Error(`Job ${id} does not belong to the exact current run attempt`);
|
|
585
|
+
}
|
|
586
|
+
const headOid = requiredOid(item.head_sha, `job ${id} head OID`);
|
|
587
|
+
if (headOid !== this.authority.head.oid) throw new Error(`Job ${id} is stale for the frozen pull request head`);
|
|
588
|
+
const currentStatus = status(item.status, `job ${id}`);
|
|
589
|
+
const steps = array(item.steps, `job ${id} steps`).map((step) => this.parseStep(step, id));
|
|
590
|
+
steps.sort((left, right) => left.number - right.number);
|
|
591
|
+
if (new Set(steps.map(({ number }) => number)).size !== steps.length) throw new Error(`Job ${id} returned duplicate step identities`);
|
|
592
|
+
const html = parseJobHtmlUrl(item.html_url, `job ${id} HTML URL`, this.authority.host, this.authority.base.repository);
|
|
593
|
+
if (html.runId !== runId || html.jobId !== id) throw new Error(`Job ${id} HTML URL did not match its immutable IDs`);
|
|
594
|
+
return {
|
|
595
|
+
id,
|
|
596
|
+
url: parseApiIdentityUrl(item.url, `job ${id} URL`, this.authority.host, this.authority.base.repository, "jobs", id),
|
|
597
|
+
htmlUrl: html.url,
|
|
598
|
+
checkRunUrl: secureUrl(item.check_run_url, `job ${id} check-run URL`).href,
|
|
599
|
+
runId,
|
|
600
|
+
attempt,
|
|
601
|
+
headOid,
|
|
602
|
+
name: text(item.name, `job ${id} name`),
|
|
603
|
+
status: currentStatus,
|
|
604
|
+
conclusion: conclusion(item.conclusion, currentStatus, `job ${id}`),
|
|
605
|
+
steps,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private async readRun(runId: number, budget: { pages: number; records: number }): Promise<RunIdentity> {
|
|
610
|
+
const label = `Read workflow run ${runId}`;
|
|
611
|
+
const item = record(parseJson(await this.api(
|
|
612
|
+
`repos/${this.authority.base.repository}/actions/runs/${runId}`,
|
|
613
|
+
label,
|
|
614
|
+
), label), label);
|
|
615
|
+
if (integer(item.id, `${label} ID`) !== runId) throw new Error(`${label} returned a replaced run`);
|
|
616
|
+
const attempt = integer(item.run_attempt, `${label} attempt`);
|
|
617
|
+
const headOid = requiredOid(item.head_sha, `${label} head OID`);
|
|
618
|
+
if (headOid !== this.authority.head.oid) throw new Error(`${label} is stale for the frozen pull request head`);
|
|
619
|
+
const repository = record(item.repository, `${label} repository`);
|
|
620
|
+
const headRepository = record(item.head_repository, `${label} head repository`);
|
|
621
|
+
if (text(repository.full_name, `${label} repository name`).toLowerCase() !== this.authority.base.repository.toLowerCase() ||
|
|
622
|
+
text(headRepository.full_name, `${label} head repository name`).toLowerCase() !== this.authority.head.repository.toLowerCase()) {
|
|
623
|
+
throw new Error(`${label} does not belong to the frozen pull request repositories`);
|
|
624
|
+
}
|
|
625
|
+
const currentStatus = status(item.status, label);
|
|
626
|
+
const run: RunIdentity = {
|
|
627
|
+
id: runId,
|
|
628
|
+
url: parseApiIdentityUrl(item.url, `${label} URL`, this.authority.host, this.authority.base.repository, "runs", runId),
|
|
629
|
+
htmlUrl: parseRunHtmlUrl(item.html_url, `${label} HTML URL`, this.authority.host, this.authority.base.repository, runId),
|
|
630
|
+
attempt,
|
|
631
|
+
suiteId: integer(item.check_suite_id, `${label} suite ID`),
|
|
632
|
+
headOid,
|
|
633
|
+
status: currentStatus,
|
|
634
|
+
conclusion: conclusion(item.conclusion, currentStatus, label),
|
|
635
|
+
jobs: [],
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
let expectedTotal: number | undefined;
|
|
639
|
+
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
640
|
+
this.consumePage(budget);
|
|
641
|
+
const jobsLabel = `List workflow run ${runId} attempt ${attempt} jobs`;
|
|
642
|
+
const output = await this.api(
|
|
643
|
+
`repos/${this.authority.base.repository}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=${PAGE_SIZE}&page=${page}`,
|
|
644
|
+
jobsLabel,
|
|
645
|
+
);
|
|
646
|
+
const parsed = record(parseJson(output, jobsLabel), jobsLabel);
|
|
647
|
+
const total = integer(parsed.total_count, `${jobsLabel} total`, true);
|
|
648
|
+
const pageJobs = array(parsed.jobs, jobsLabel);
|
|
649
|
+
if (pageJobs.length > PAGE_SIZE) throw new Error(`${jobsLabel} page exceeded its record limit`);
|
|
650
|
+
if (expectedTotal === undefined) {
|
|
651
|
+
expectedTotal = total;
|
|
652
|
+
if (total > MAX_RECORDS - budget.records) throw new Error(`CI evidence exceeds ${MAX_RECORDS} aggregate records`);
|
|
653
|
+
} else if (total !== expectedTotal) {
|
|
654
|
+
throw new Error(`${jobsLabel} total changed during collection`);
|
|
655
|
+
}
|
|
656
|
+
const stepCount = pageJobs.reduce<number>((count, job) => count + array(record(job, jobsLabel).steps, `${jobsLabel} steps`).length, 0);
|
|
657
|
+
this.consumeRecords(budget, pageJobs.length + stepCount);
|
|
658
|
+
run.jobs.push(...pageJobs.map((job) => this.parseJob(job, runId, attempt)));
|
|
659
|
+
if (run.jobs.length > expectedTotal) throw new Error(`${jobsLabel} exceeded its declared total`);
|
|
660
|
+
if (run.jobs.length === expectedTotal) break;
|
|
661
|
+
if (!pageJobs.length) throw new Error(`${jobsLabel} ended before its declared total`);
|
|
662
|
+
if (page === MAX_PAGES) throw new Error(`${jobsLabel} exceeds ${MAX_PAGES} pages`);
|
|
663
|
+
}
|
|
664
|
+
if (expectedTotal === undefined || run.jobs.length !== expectedTotal) throw new Error(`Workflow run ${runId} job pagination was incomplete`);
|
|
665
|
+
run.jobs.sort((left, right) => left.id - right.id);
|
|
666
|
+
if (new Set(run.jobs.map(({ id }) => id)).size !== run.jobs.length ||
|
|
667
|
+
new Set(run.jobs.map(({ url }) => url)).size !== run.jobs.length ||
|
|
668
|
+
new Set(run.jobs.map(({ checkRunUrl }) => checkRunUrl)).size !== run.jobs.length) {
|
|
669
|
+
throw new Error(`Workflow run ${runId} returned duplicate job identities`);
|
|
670
|
+
}
|
|
671
|
+
return run;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private async readSnapshot(requireOriginalLocal: boolean): Promise<CiSnapshot> {
|
|
675
|
+
const fresh = await this.freshAuthority(requireOriginalLocal);
|
|
676
|
+
if (requireOriginalLocal) await this.requireOriginalLocal();
|
|
677
|
+
const pageBudget = { pages: 0, records: 0 };
|
|
678
|
+
const checks = await this.readCheckRuns(pageBudget);
|
|
679
|
+
const failed = checks.filter(({ conclusion: value }) => value !== null && FAILED_CONCLUSIONS.has(value));
|
|
680
|
+
if (!failed.length) throw new Error("Failed-CI evidence is stale: no current failed check runs remain");
|
|
681
|
+
const unsupported = failed.find(({ provider }) => provider !== "github-actions");
|
|
682
|
+
if (unsupported) throw new Error(`Unsupported failed check provider for immutable evidence: ${unsupported.provider}`);
|
|
683
|
+
|
|
684
|
+
const runIds = new Set<number>();
|
|
685
|
+
const detailIdentities = new Map<number, { url: string; runId: number; jobId: number }>();
|
|
686
|
+
for (const check of failed) {
|
|
687
|
+
if (check.detailsUrl === null) throw new Error(`Failed check run ${check.id} omitted its immutable job URL`);
|
|
688
|
+
const detail = parseJobHtmlUrl(
|
|
689
|
+
check.detailsUrl,
|
|
690
|
+
`check run ${check.id} details URL`,
|
|
691
|
+
this.authority.host,
|
|
692
|
+
this.authority.base.repository,
|
|
693
|
+
);
|
|
694
|
+
detailIdentities.set(check.id, detail);
|
|
695
|
+
runIds.add(detail.runId);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const runs: RunIdentity[] = [];
|
|
699
|
+
this.consumeRecords(pageBudget, runIds.size);
|
|
700
|
+
for (const runId of [...runIds].sort((left, right) => left - right)) {
|
|
701
|
+
runs.push(await this.readRun(runId, pageBudget));
|
|
702
|
+
}
|
|
703
|
+
const allJobs = runs.flatMap(({ jobs }) => jobs);
|
|
704
|
+
if (new Set(allJobs.map(({ id }) => id)).size !== allJobs.length ||
|
|
705
|
+
new Set(allJobs.map(({ checkRunUrl }) => checkRunUrl)).size !== allJobs.length) {
|
|
706
|
+
throw new Error("Workflow runs returned ambiguous duplicate job identities");
|
|
707
|
+
}
|
|
708
|
+
const runById = new Map(runs.map((run) => [run.id, run]));
|
|
709
|
+
for (const run of runs) {
|
|
710
|
+
for (const job of run.jobs) {
|
|
711
|
+
const linked = checks.filter((check) => check.url === job.checkRunUrl);
|
|
712
|
+
if (linked.length !== 1 || linked[0]!.detailsUrl !== job.htmlUrl || linked[0]!.suiteId !== run.suiteId ||
|
|
713
|
+
linked[0]!.status !== job.status || linked[0]!.conclusion !== job.conclusion) {
|
|
714
|
+
throw new Error(`Job ${job.id} has an incomplete, replaced, or ambiguous immutable check-run mapping`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const usedJobs = new Set<number>();
|
|
719
|
+
const failures: FailureIdentity[] = [];
|
|
720
|
+
for (const check of failed) {
|
|
721
|
+
const detail = detailIdentities.get(check.id)!;
|
|
722
|
+
const run = runById.get(detail.runId);
|
|
723
|
+
if (!run || run.suiteId !== check.suiteId) {
|
|
724
|
+
throw new Error(`Failed check run ${check.id} has an ambiguous or stale suite-to-run mapping`);
|
|
725
|
+
}
|
|
726
|
+
const matches = run.jobs.filter((job) =>
|
|
727
|
+
job.id === detail.jobId && job.checkRunUrl === check.url && job.htmlUrl === detail.url
|
|
728
|
+
);
|
|
729
|
+
if (matches.length !== 1 || usedJobs.has(detail.jobId)) {
|
|
730
|
+
throw new Error(`Failed check run ${check.id} has an incomplete, duplicate, or ambiguous immutable job mapping`);
|
|
731
|
+
}
|
|
732
|
+
const job = matches[0]!;
|
|
733
|
+
if (job.status !== "completed" || job.conclusion !== check.conclusion ||
|
|
734
|
+
job.conclusion === null || !FAILED_CONCLUSIONS.has(job.conclusion)) {
|
|
735
|
+
throw new Error(`Failed check run ${check.id} was replaced or is stale relative to its job`);
|
|
736
|
+
}
|
|
737
|
+
usedJobs.add(job.id);
|
|
738
|
+
failures.push({
|
|
739
|
+
check,
|
|
740
|
+
run,
|
|
741
|
+
job,
|
|
742
|
+
failedSteps: job.steps.filter(({ conclusion: value }) => value !== null && FAILED_CONCLUSIONS.has(value)),
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
failures.sort((left, right) => left.check.id - right.check.id);
|
|
746
|
+
return {
|
|
747
|
+
fingerprint: snapshotFingerprint(fresh, failures),
|
|
748
|
+
failures,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private async readJobLog(jobId: number): Promise<{ text: string; truncated: boolean }> {
|
|
753
|
+
const result = await runChecked(this.exec, "gh", [
|
|
754
|
+
"api", "--hostname", this.authority.host, ...API_HEADERS,
|
|
755
|
+
`repos/${this.authority.base.repository}/actions/jobs/${jobId}/logs`,
|
|
756
|
+
], this.options({ stdoutTailBytes: LOG_TAIL_BYTES, timeoutMs: LOG_TIMEOUT_MS }));
|
|
757
|
+
if (Buffer.byteLength(result.stdout, "utf8") > LOG_TAIL_BYTES) {
|
|
758
|
+
throw new Error(`Read failed job ${jobId} log executor exceeded its retained tail limit`);
|
|
759
|
+
}
|
|
760
|
+
return { text: result.stdout, truncated: result.stdoutTruncated === true };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async collect(): Promise<CiEvidence> {
|
|
764
|
+
if (this.collectConsumed || this.state.phase !== "ready") throw new Error("CI evidence collect action was already consumed");
|
|
765
|
+
this.collectConsumed = true;
|
|
766
|
+
this.state.phase = "collecting";
|
|
767
|
+
try {
|
|
768
|
+
const before = await this.readSnapshot(true);
|
|
769
|
+
const metadata = before.failures.map((failure) => failureEvidence(failure, { text: "", truncated: false }));
|
|
770
|
+
const metadataBytes = metadata.map(jsonBytes);
|
|
771
|
+
const metadataTotal = jsonBytes(metadata);
|
|
772
|
+
if (metadataBytes.some((bytes) => bytes > EVIDENCE_RECORD_BYTES) || metadataTotal > EVIDENCE_TOTAL_BYTES) {
|
|
773
|
+
throw new Error("Failed-CI metadata exceeds the retained evidence budget");
|
|
774
|
+
}
|
|
775
|
+
let remainingBytes = EVIDENCE_TOTAL_BYTES - metadataTotal;
|
|
776
|
+
const failures: CiFailureEvidence[] = [];
|
|
777
|
+
for (let index = 0; index < before.failures.length; index += 1) {
|
|
778
|
+
const failure = before.failures[index]!;
|
|
779
|
+
const baseBytes = metadataBytes[index]!;
|
|
780
|
+
const share = Math.floor(remainingBytes / (before.failures.length - index));
|
|
781
|
+
const raw = await this.readJobLog(failure.job.id);
|
|
782
|
+
const evidence = boundedEvidenceRecord(failure, raw, Math.min(EVIDENCE_RECORD_BYTES, baseBytes + share));
|
|
783
|
+
remainingBytes -= jsonBytes(evidence) - baseBytes;
|
|
784
|
+
failures.push(evidence);
|
|
785
|
+
}
|
|
786
|
+
if (failures.some((evidence) => jsonBytes(evidence) > EVIDENCE_RECORD_BYTES) || jsonBytes(failures) > EVIDENCE_TOTAL_BYTES) {
|
|
787
|
+
throw new Error("Failed-CI evidence exceeds its retained byte budget");
|
|
788
|
+
}
|
|
789
|
+
const after = await this.readSnapshot(true);
|
|
790
|
+
if (after.fingerprint !== before.fingerprint) {
|
|
791
|
+
throw new Error("Failed-CI evidence was replaced or became stale during collection");
|
|
792
|
+
}
|
|
793
|
+
this.collectedFingerprint = after.fingerprint;
|
|
794
|
+
this.state.fingerprint = after.fingerprint;
|
|
795
|
+
this.state.phase = "collected";
|
|
796
|
+
return {
|
|
797
|
+
fingerprint: after.fingerprint,
|
|
798
|
+
pullRequest: {
|
|
799
|
+
number: this.authority.number,
|
|
800
|
+
url: this.authority.url.href,
|
|
801
|
+
headOid: this.authority.head.oid,
|
|
802
|
+
},
|
|
803
|
+
failures,
|
|
804
|
+
};
|
|
805
|
+
} catch (error) {
|
|
806
|
+
this.state.phase = "blocked";
|
|
807
|
+
throw error;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private async requireCollectedEvidence(): Promise<void> {
|
|
812
|
+
const current = await this.readSnapshot(false);
|
|
813
|
+
if (!this.collectedFingerprint || current.fingerprint !== this.collectedFingerprint) {
|
|
814
|
+
throw new Error("CI repair publish cancelled: stored evidence fingerprint is stale or replaced");
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
private async requireSavedDestination(original: string): Promise<void> {
|
|
819
|
+
const remote = await readValidatedRemoteAuthority(this.pi(), this.context(), this.authority.target.remote);
|
|
820
|
+
if (remote.fetchSource !== this.authority.target.fetchSource || remote.host !== this.authority.target.host ||
|
|
821
|
+
remote.repository.toLowerCase() !== this.authority.target.repository.toLowerCase()) {
|
|
822
|
+
throw new Error("CI repair publish cancelled: configured remote authority changed");
|
|
823
|
+
}
|
|
824
|
+
if (await readRemoteOid(this.exec, this.options(), this.authority.target.fetchSource, this.authority.target.ref) !== original) {
|
|
825
|
+
throw new Error("CI repair publish cancelled: remote target no longer matches the frozen pull request head");
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private async validatePublishAuthority(): Promise<string> {
|
|
830
|
+
await this.requireCollectedEvidence();
|
|
831
|
+
const original = this.authority.head.oid;
|
|
832
|
+
await this.requireSavedDestination(original);
|
|
833
|
+
const branch = outputLine(
|
|
834
|
+
(await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout,
|
|
835
|
+
"current branch",
|
|
836
|
+
);
|
|
837
|
+
if (branch !== this.authority.target.branch) throw new Error("CI repair publish cancelled: current branch changed");
|
|
838
|
+
if (await inspectWorktree(this.exec, this.options()) !== "clean") {
|
|
839
|
+
throw new Error("CI repair publish requires a clean worktree with no Git operation in progress");
|
|
840
|
+
}
|
|
841
|
+
const repairHead = await readHead(this.exec, this.options());
|
|
842
|
+
if (repairHead === original || !(await isAncestor(this.exec, this.options(), original, repairHead))) {
|
|
843
|
+
throw new Error("CI repair HEAD must be a new descendant of the frozen pull request head");
|
|
844
|
+
}
|
|
845
|
+
if (await readHead(this.exec, this.options()) !== repairHead) throw new Error("CI repair HEAD changed before push");
|
|
846
|
+
this.state.repairHead = repairHead;
|
|
847
|
+
return repairHead;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
private async finalPublishRevalidation(original: string, repairHead: string): Promise<void> {
|
|
851
|
+
await this.requireSavedDestination(original);
|
|
852
|
+
await this.requireCollectedEvidence();
|
|
853
|
+
if (await readHead(this.exec, this.options()) !== repairHead) throw new Error("CI repair HEAD changed before push");
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
private blockUnpublishedAttempt(): void {
|
|
857
|
+
if (this.state.phase !== "published") this.state.phase = "blocked";
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async publish(): Promise<CiPublishResult> {
|
|
861
|
+
if (this.publishConsumed || this.state.phase !== "collected") throw new Error("CI repair publish action is unavailable or was already consumed");
|
|
862
|
+
this.publishConsumed = true;
|
|
863
|
+
try {
|
|
864
|
+
return await withWorktreeLock(this.cwd, async () => {
|
|
865
|
+
const repairHead = await this.validatePublishAuthority();
|
|
866
|
+
const original = this.authority.head.oid;
|
|
867
|
+
await this.finalPublishRevalidation(original, repairHead);
|
|
868
|
+
let pushError: unknown;
|
|
869
|
+
try {
|
|
870
|
+
await runChecked(this.exec, "git", [
|
|
871
|
+
"push", "--porcelain", `--force-with-lease=refs/heads/${this.authority.target.ref}:${original}`,
|
|
872
|
+
"--recurse-submodules=no", "--", this.authority.target.fetchSource,
|
|
873
|
+
`${repairHead}:refs/heads/${this.authority.target.ref}`,
|
|
874
|
+
], this.options());
|
|
875
|
+
} catch (error) {
|
|
876
|
+
pushError = error;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
let postcondition: string | null;
|
|
880
|
+
try {
|
|
881
|
+
postcondition = await readRemoteOid(
|
|
882
|
+
this.exec,
|
|
883
|
+
this.options(),
|
|
884
|
+
this.authority.target.fetchSource,
|
|
885
|
+
this.authority.target.ref,
|
|
886
|
+
);
|
|
887
|
+
} catch (error) {
|
|
888
|
+
this.state.pushAttempt = "unknown";
|
|
889
|
+
throw new Error(`CI repair push outcome is unknown: ${errorMessage(error)}`);
|
|
890
|
+
}
|
|
891
|
+
if (postcondition === repairHead) {
|
|
892
|
+
this.state.pushAttempt = "applied";
|
|
893
|
+
this.state.phase = "published";
|
|
894
|
+
return { kind: "published", head: repairHead, attempt: "applied" };
|
|
895
|
+
}
|
|
896
|
+
if (postcondition === original) {
|
|
897
|
+
this.state.pushAttempt = "not-applied";
|
|
898
|
+
throw new Error(`CI repair push was not applied${pushError ? `: ${errorMessage(pushError)}` : ""}`);
|
|
899
|
+
}
|
|
900
|
+
this.state.pushAttempt = "unknown";
|
|
901
|
+
throw new Error("CI repair push outcome is unknown: remote target has an unexpected OID");
|
|
902
|
+
}, { agentDir: this.agentDir, signal: this.signal });
|
|
903
|
+
} catch (error) {
|
|
904
|
+
this.blockUnpublishedAttempt();
|
|
905
|
+
throw error;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|