@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,1102 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, realpath, rm } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
extensionConfigDir,
|
|
7
|
+
readTextFileBounded,
|
|
8
|
+
writePrivateTextFileAtomically,
|
|
9
|
+
} from "@henryqw/pi-config-store";
|
|
10
|
+
import {
|
|
11
|
+
collectPullRequestFeedback,
|
|
12
|
+
feedbackAuthorityFromCurrent,
|
|
13
|
+
feedbackContentFingerprint,
|
|
14
|
+
feedbackEntries,
|
|
15
|
+
feedbackFingerprint,
|
|
16
|
+
parseFeedbackSnapshot,
|
|
17
|
+
showFeedbackItem,
|
|
18
|
+
resolvePullRequestThread,
|
|
19
|
+
FEEDBACK_MAX_RECORDS,
|
|
20
|
+
type FeedbackItem,
|
|
21
|
+
type FeedbackKind,
|
|
22
|
+
type FeedbackSnapshot,
|
|
23
|
+
} from "./pr-feedback.ts";
|
|
24
|
+
import {
|
|
25
|
+
loadCurrentPullRequest,
|
|
26
|
+
type CurrentPullRequest,
|
|
27
|
+
type PullRequestLoadContext,
|
|
28
|
+
} from "./pr-github.ts";
|
|
29
|
+
import {
|
|
30
|
+
isAncestor,
|
|
31
|
+
parseNulPaths,
|
|
32
|
+
parseStatusSnapshot,
|
|
33
|
+
readHead,
|
|
34
|
+
readRemoteOid,
|
|
35
|
+
requiredOid,
|
|
36
|
+
requiredText,
|
|
37
|
+
runChecked,
|
|
38
|
+
spawnBounded,
|
|
39
|
+
withWorktreeLock,
|
|
40
|
+
type AttemptState,
|
|
41
|
+
type Exec,
|
|
42
|
+
type ExecOptions,
|
|
43
|
+
} from "./pr-execution.ts";
|
|
44
|
+
|
|
45
|
+
export const PR_COMMENT_SWEEP_ACTIONS = [
|
|
46
|
+
"start", "resume", "show", "record", "publish", "refresh", "resolve", "finalize",
|
|
47
|
+
] as const;
|
|
48
|
+
export type PrCommentSweepAction = (typeof PR_COMMENT_SWEEP_ACTIONS)[number];
|
|
49
|
+
export const SWEEP_RECOVERY_MAX_BYTES = 1024 * 1024;
|
|
50
|
+
|
|
51
|
+
const STATE_VERSION = 1;
|
|
52
|
+
const STATE_FILE = "state.json";
|
|
53
|
+
const GIT_OPERATION_STATES = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
|
|
54
|
+
const LEDGER_NOTE_MAX_BYTES = 2 * 1024;
|
|
55
|
+
const CHECK_MAX_COUNT = 32;
|
|
56
|
+
const CHECK_ARGUMENTS_MAX_BYTES = 32 * 1024;
|
|
57
|
+
const ATTEMPT_STATES = new Set<AttemptState>(["none", "attempting", "applied", "blocked", "unknown"]);
|
|
58
|
+
const DISPOSITIONS = ["addressed", "non-actionable", "blocked"] as const;
|
|
59
|
+
|
|
60
|
+
export type SweepDisposition = (typeof DISPOSITIONS)[number];
|
|
61
|
+
export type SweepLedgerEntry = {
|
|
62
|
+
id: string;
|
|
63
|
+
kind: FeedbackKind;
|
|
64
|
+
disposition: SweepDisposition;
|
|
65
|
+
note: string;
|
|
66
|
+
};
|
|
67
|
+
export type SweepRunGuard = {
|
|
68
|
+
epoch: number;
|
|
69
|
+
runId: string;
|
|
70
|
+
generation: number;
|
|
71
|
+
fingerprint: string;
|
|
72
|
+
};
|
|
73
|
+
export type SweepCheck = { command: string; args: string[] };
|
|
74
|
+
export type SweepFinalProjection = {
|
|
75
|
+
generation: number;
|
|
76
|
+
contentFingerprint: string;
|
|
77
|
+
items: Array<{ id: string; kind: FeedbackKind }>;
|
|
78
|
+
threads: Array<{ id: string; isResolved: boolean }>;
|
|
79
|
+
};
|
|
80
|
+
export type SweepStatus = {
|
|
81
|
+
phase: SweepPhase;
|
|
82
|
+
guard: SweepRunGuard;
|
|
83
|
+
pullRequestUrl: string;
|
|
84
|
+
originalHead: string;
|
|
85
|
+
publicationHead: string | null;
|
|
86
|
+
feedbackCount: number;
|
|
87
|
+
feedback: Array<{ id: string; kind: FeedbackKind }>;
|
|
88
|
+
ledgerComplete: boolean;
|
|
89
|
+
projection: SweepFinalProjection | null;
|
|
90
|
+
attempts: {
|
|
91
|
+
push: AttemptState;
|
|
92
|
+
resolutions: Array<{ threadId: string; state: AttemptState }>;
|
|
93
|
+
finalize: AttemptState;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
export type SweepPhase = "triage" | "recorded" | "published" | "refresh-pending" | "refreshed" | "resolving" | "resolved";
|
|
97
|
+
|
|
98
|
+
type SweepAuthority = ReturnType<typeof authorityFromCurrent>;
|
|
99
|
+
type ResolutionAttempt = {
|
|
100
|
+
generation: number;
|
|
101
|
+
threadId: string;
|
|
102
|
+
state: AttemptState;
|
|
103
|
+
beforeFingerprint: string;
|
|
104
|
+
afterFingerprint: string | null;
|
|
105
|
+
};
|
|
106
|
+
type SweepState = {
|
|
107
|
+
version: 1;
|
|
108
|
+
workflow: "pi-pr-comment-sweep";
|
|
109
|
+
worktree: { id: string; root: string };
|
|
110
|
+
epoch: number;
|
|
111
|
+
runId: string;
|
|
112
|
+
phase: SweepPhase;
|
|
113
|
+
authority: SweepAuthority;
|
|
114
|
+
original: { head: string; lease: string };
|
|
115
|
+
feedback: {
|
|
116
|
+
generation: number;
|
|
117
|
+
fingerprint: string;
|
|
118
|
+
contentFingerprint: string;
|
|
119
|
+
snapshot: FeedbackSnapshot;
|
|
120
|
+
};
|
|
121
|
+
ledger: SweepLedgerEntry[] | null;
|
|
122
|
+
ownedPaths: string[];
|
|
123
|
+
publicationHead: string | null;
|
|
124
|
+
projection: SweepFinalProjection | null;
|
|
125
|
+
attempts: {
|
|
126
|
+
push: { state: AttemptState; head: string | null };
|
|
127
|
+
resolutions: ResolutionAttempt[];
|
|
128
|
+
finalize: { state: AttemptState; checks: SweepCheck[] };
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
type Load = typeof loadCurrentPullRequest;
|
|
133
|
+
export type PullRequestCommentSweepOptions = {
|
|
134
|
+
cwd: string;
|
|
135
|
+
authority?: CurrentPullRequest;
|
|
136
|
+
signal?: AbortSignal;
|
|
137
|
+
agentDir?: string;
|
|
138
|
+
exec?: Exec;
|
|
139
|
+
loadCurrentPullRequest?: Load;
|
|
140
|
+
newRunId?: () => string;
|
|
141
|
+
pause?: (milliseconds: number) => Promise<void>;
|
|
142
|
+
};
|
|
143
|
+
export type PrCommentSweepRequest =
|
|
144
|
+
| { action: "start" }
|
|
145
|
+
| { action: "resume" }
|
|
146
|
+
| { action: "show"; guard: SweepRunGuard; id: string }
|
|
147
|
+
| { action: "record"; guard: SweepRunGuard; ledger: SweepLedgerEntry[]; ownedPaths?: string[] }
|
|
148
|
+
| { action: "publish"; guard: SweepRunGuard }
|
|
149
|
+
| { action: "refresh"; guard: SweepRunGuard }
|
|
150
|
+
| { action: "resolve"; guard: SweepRunGuard; threadIds: string[] }
|
|
151
|
+
| { action: "finalize"; guard: SweepRunGuard; projection: SweepFinalProjection; checks: SweepCheck[] };
|
|
152
|
+
|
|
153
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
154
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function exactKeys(value: Record<string, unknown>, keys: readonly string[], label: string): void {
|
|
158
|
+
const actual = Object.keys(value).sort();
|
|
159
|
+
const expected = [...keys].sort();
|
|
160
|
+
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
|
161
|
+
throw new Error(`${label} has unsupported fields`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function integer(value: unknown, label: string): number {
|
|
166
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new Error(`${label} must be a positive safe integer`);
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function attemptState(value: unknown, label: string): AttemptState {
|
|
171
|
+
if (typeof value !== "string" || !ATTEMPT_STATES.has(value as AttemptState)) throw new Error(`${label} is invalid`);
|
|
172
|
+
return value as AttemptState;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sha256(value: string, label: string): string {
|
|
176
|
+
if (!/^[0-9a-f]{64}$/.test(value)) throw new Error(`${label} must be a SHA-256 fingerprint`);
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function safeRunId(value: unknown): string {
|
|
181
|
+
const id = requiredText(value, "sweep run ID");
|
|
182
|
+
if (!/^[a-zA-Z0-9-]{16,128}$/.test(id)) throw new Error("sweep run ID is invalid");
|
|
183
|
+
return id;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function authorityFromCurrent(pullRequest: CurrentPullRequest) {
|
|
187
|
+
if (pullRequest.lifecycle !== "open" || pullRequest.target.provenance !== "configured") {
|
|
188
|
+
throw new Error("Comment sweep requires a configured open pull request");
|
|
189
|
+
}
|
|
190
|
+
const feedback = feedbackAuthorityFromCurrent(pullRequest);
|
|
191
|
+
const remoteOid = requiredOid(pullRequest.target.remoteOid, "remote lease OID");
|
|
192
|
+
return {
|
|
193
|
+
...feedback,
|
|
194
|
+
headFetchSource: requiredText(pullRequest.headFetchSource, "head fetch source"),
|
|
195
|
+
target: {
|
|
196
|
+
branch: requiredText(pullRequest.target.branch, "target branch"),
|
|
197
|
+
remote: requiredText(pullRequest.target.remote, "target remote"),
|
|
198
|
+
ref: requiredText(pullRequest.target.ref, "target ref"),
|
|
199
|
+
repository: requiredText(pullRequest.target.repository, "target repository"),
|
|
200
|
+
host: requiredText(pullRequest.target.host, "target host").toLowerCase(),
|
|
201
|
+
fetchSource: requiredText(pullRequest.target.fetchSource, "target fetch source"),
|
|
202
|
+
remoteOid,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseAuthority(value: unknown): SweepAuthority {
|
|
208
|
+
if (!isRecord(value) || !isRecord(value.base) || !isRecord(value.head) || !isRecord(value.target)) {
|
|
209
|
+
throw new Error("sweep authority is invalid");
|
|
210
|
+
}
|
|
211
|
+
exactKeys(value, ["id", "number", "url", "host", "base", "head", "headFetchSource", "target"], "sweep authority");
|
|
212
|
+
exactKeys(value.target, ["branch", "remote", "ref", "repository", "host", "fetchSource", "remoteOid"], "sweep target authority");
|
|
213
|
+
const snapshot = parseFeedbackSnapshot({ pullRequest: {
|
|
214
|
+
id: value.id,
|
|
215
|
+
number: value.number,
|
|
216
|
+
url: value.url,
|
|
217
|
+
host: value.host,
|
|
218
|
+
base: value.base,
|
|
219
|
+
head: value.head,
|
|
220
|
+
}, conversationComments: [], reviews: [], reviewThreads: [] });
|
|
221
|
+
return {
|
|
222
|
+
...snapshot.pullRequest,
|
|
223
|
+
headFetchSource: requiredText(value.headFetchSource, "head fetch source"),
|
|
224
|
+
target: {
|
|
225
|
+
branch: requiredText(value.target.branch, "target branch"),
|
|
226
|
+
remote: requiredText(value.target.remote, "target remote"),
|
|
227
|
+
ref: requiredText(value.target.ref, "target ref"),
|
|
228
|
+
repository: requiredText(value.target.repository, "target repository"),
|
|
229
|
+
host: requiredText(value.target.host, "target host").toLowerCase(),
|
|
230
|
+
fetchSource: requiredText(value.target.fetchSource, "target fetch source"),
|
|
231
|
+
remoteOid: requiredOid(value.target.remoteOid, "remote lease OID"),
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function sameLinkage(expected: SweepAuthority, current: SweepAuthority, remoteHead: string): boolean {
|
|
237
|
+
return expected.id === current.id && expected.number === current.number && expected.url === current.url &&
|
|
238
|
+
expected.host === current.host && expected.base.repository === current.base.repository &&
|
|
239
|
+
expected.base.ref === current.base.ref && expected.base.oid === current.base.oid &&
|
|
240
|
+
expected.head.repository === current.head.repository && expected.head.ref === current.head.ref &&
|
|
241
|
+
expected.headFetchSource === current.headFetchSource && expected.target.branch === current.target.branch &&
|
|
242
|
+
expected.target.remote === current.target.remote && expected.target.ref === current.target.ref &&
|
|
243
|
+
expected.target.repository === current.target.repository && expected.target.host === current.target.host &&
|
|
244
|
+
expected.target.fetchSource === current.target.fetchSource && current.head.oid === remoteHead &&
|
|
245
|
+
current.target.remoteOid === remoteHead;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function feedbackMatchesAuthority(snapshot: FeedbackSnapshot, authority: SweepAuthority, head: string): boolean {
|
|
249
|
+
const current = snapshot.pullRequest;
|
|
250
|
+
return current.id === authority.id && current.number === authority.number && current.url === authority.url &&
|
|
251
|
+
current.host === authority.host && current.base.repository === authority.base.repository &&
|
|
252
|
+
current.base.ref === authority.base.ref && current.base.oid === authority.base.oid &&
|
|
253
|
+
current.head.repository === authority.head.repository && current.head.ref === authority.head.ref &&
|
|
254
|
+
current.head.oid === head;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseOwnedPaths(value: unknown): string[] {
|
|
258
|
+
if (!Array.isArray(value) || value.some((path) => typeof path !== "string")) throw new Error("ownedPaths must be an array of paths");
|
|
259
|
+
const encoded = value.length ? `${value.join("\0")}\0` : "";
|
|
260
|
+
return parseNulPaths(encoded, "Sweep owned paths");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseLedgerEntry(value: unknown, label: string): SweepLedgerEntry {
|
|
264
|
+
if (!isRecord(value)) throw new Error(`${label} is invalid`);
|
|
265
|
+
exactKeys(value, ["id", "kind", "disposition", "note"], label);
|
|
266
|
+
const id = requiredText(value.id, `${label} ID`);
|
|
267
|
+
if (!["conversation_comment", "review", "thread", "thread_comment"].includes(String(value.kind))) {
|
|
268
|
+
throw new Error(`${label} kind is invalid`);
|
|
269
|
+
}
|
|
270
|
+
if (!DISPOSITIONS.includes(value.disposition as SweepDisposition)) throw new Error(`${label} disposition is invalid`);
|
|
271
|
+
if (typeof value.note !== "string" || value.note.includes("\0") || Buffer.byteLength(value.note, "utf8") > LEDGER_NOTE_MAX_BYTES) {
|
|
272
|
+
throw new Error(`${label} note exceeds ${LEDGER_NOTE_MAX_BYTES} bytes or contains NUL`);
|
|
273
|
+
}
|
|
274
|
+
return { id, kind: value.kind as FeedbackKind, disposition: value.disposition as SweepDisposition, note: value.note };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function exactLedger(value: unknown, snapshot: FeedbackSnapshot): SweepLedgerEntry[] {
|
|
278
|
+
if (!Array.isArray(value) || value.length > FEEDBACK_MAX_RECORDS) throw new Error("sweep ledger is invalid");
|
|
279
|
+
const parsed = value.map((entry, index) => parseLedgerEntry(entry, `sweep ledger entry ${index + 1}`));
|
|
280
|
+
const supplied = new Map<string, SweepLedgerEntry>();
|
|
281
|
+
for (const entry of parsed) {
|
|
282
|
+
if (supplied.has(entry.id)) throw new Error(`sweep ledger covers feedback more than once: ${entry.id}`);
|
|
283
|
+
supplied.set(entry.id, entry);
|
|
284
|
+
}
|
|
285
|
+
const expected = feedbackEntries(snapshot);
|
|
286
|
+
if (supplied.size !== expected.length) throw new Error("sweep ledger must cover every feedback item exactly once");
|
|
287
|
+
return expected.map((entry) => {
|
|
288
|
+
const record = supplied.get(entry.id);
|
|
289
|
+
if (!record || record.kind !== entry.kind) throw new Error(`sweep ledger does not exactly cover ${entry.kind}:${entry.id}`);
|
|
290
|
+
return record;
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function parseCheck(value: unknown, label: string): SweepCheck {
|
|
295
|
+
if (!isRecord(value)) throw new Error(`${label} is invalid`);
|
|
296
|
+
exactKeys(value, ["command", "args"], label);
|
|
297
|
+
const command = requiredText(value.command, `${label} command`);
|
|
298
|
+
if (!Array.isArray(value.args) || value.args.some((argument) => typeof argument !== "string" || argument.includes("\0"))) {
|
|
299
|
+
throw new Error(`${label} args are invalid`);
|
|
300
|
+
}
|
|
301
|
+
return { command, args: [...value.args] as string[] };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseChecks(value: unknown): SweepCheck[] {
|
|
305
|
+
if (!Array.isArray(value) || value.length > CHECK_MAX_COUNT) throw new Error(`checks must contain at most ${CHECK_MAX_COUNT} commands`);
|
|
306
|
+
const checks = value.map((check, index) => parseCheck(check, `check ${index + 1}`));
|
|
307
|
+
if (Buffer.byteLength(JSON.stringify(checks), "utf8") > CHECK_ARGUMENTS_MAX_BYTES) {
|
|
308
|
+
throw new Error(`check arguments exceed ${CHECK_ARGUMENTS_MAX_BYTES} bytes`);
|
|
309
|
+
}
|
|
310
|
+
return checks;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function buildProjection(generation: number, snapshot: FeedbackSnapshot, ledger: SweepLedgerEntry[]): SweepFinalProjection {
|
|
314
|
+
const dispositions = new Map(ledger.map((entry) => [entry.id, entry]));
|
|
315
|
+
return {
|
|
316
|
+
generation,
|
|
317
|
+
contentFingerprint: feedbackContentFingerprint(snapshot),
|
|
318
|
+
items: feedbackEntries(snapshot).map(({ id, kind }) => ({ id, kind })).sort((left, right) =>
|
|
319
|
+
left.id.localeCompare(right.id) || left.kind.localeCompare(right.kind)),
|
|
320
|
+
threads: snapshot.reviewThreads.map((thread) => ({
|
|
321
|
+
id: thread.id,
|
|
322
|
+
isResolved: thread.isResolved || dispositions.get(thread.id)?.disposition === "addressed",
|
|
323
|
+
})).sort((left, right) => left.id.localeCompare(right.id)),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function parseProjection(value: unknown): SweepFinalProjection {
|
|
328
|
+
if (!isRecord(value) || !Array.isArray(value.items) || !Array.isArray(value.threads)) throw new Error("final projection is invalid");
|
|
329
|
+
exactKeys(value, ["generation", "contentFingerprint", "items", "threads"], "final projection");
|
|
330
|
+
const items = value.items.map((item, index) => {
|
|
331
|
+
if (!isRecord(item)) throw new Error(`final projection item ${index + 1} is invalid`);
|
|
332
|
+
exactKeys(item, ["id", "kind"], `final projection item ${index + 1}`);
|
|
333
|
+
if (!["conversation_comment", "review", "thread", "thread_comment"].includes(String(item.kind))) {
|
|
334
|
+
throw new Error(`final projection item ${index + 1} kind is invalid`);
|
|
335
|
+
}
|
|
336
|
+
return { id: requiredText(item.id, `final projection item ${index + 1} ID`), kind: item.kind as FeedbackKind };
|
|
337
|
+
});
|
|
338
|
+
const threads = value.threads.map((thread, index) => {
|
|
339
|
+
if (!isRecord(thread) || typeof thread.isResolved !== "boolean") throw new Error(`final projection thread ${index + 1} is invalid`);
|
|
340
|
+
exactKeys(thread, ["id", "isResolved"], `final projection thread ${index + 1}`);
|
|
341
|
+
return { id: requiredText(thread.id, `final projection thread ${index + 1} ID`), isResolved: thread.isResolved };
|
|
342
|
+
});
|
|
343
|
+
if (new Set(items.map(({ id }) => id)).size !== items.length || new Set(threads.map(({ id }) => id)).size !== threads.length) {
|
|
344
|
+
throw new Error("final projection contains duplicate IDs");
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
generation: integer(value.generation, "final projection generation"),
|
|
348
|
+
contentFingerprint: sha256(requiredText(value.contentFingerprint, "final projection content fingerprint"), "final projection content fingerprint"),
|
|
349
|
+
items,
|
|
350
|
+
threads,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function sameProjection(left: SweepFinalProjection, right: SweepFinalProjection): boolean {
|
|
355
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function projectionMatches(snapshot: FeedbackSnapshot, projection: SweepFinalProjection): boolean {
|
|
359
|
+
if (feedbackContentFingerprint(snapshot) !== projection.contentFingerprint) return false;
|
|
360
|
+
const items = feedbackEntries(snapshot).map(({ id, kind }) => ({ id, kind })).sort((left, right) =>
|
|
361
|
+
left.id.localeCompare(right.id) || left.kind.localeCompare(right.kind));
|
|
362
|
+
const threads = snapshot.reviewThreads.map(({ id, isResolved }) => ({ id, isResolved })).sort((left, right) => left.id.localeCompare(right.id));
|
|
363
|
+
return JSON.stringify(items) === JSON.stringify(projection.items) && JSON.stringify(threads) === JSON.stringify(projection.threads);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function resolutionAttempt(value: unknown, index: number): ResolutionAttempt {
|
|
367
|
+
if (!isRecord(value)) throw new Error(`resolution attempt ${index + 1} is invalid`);
|
|
368
|
+
exactKeys(value, ["generation", "threadId", "state", "beforeFingerprint", "afterFingerprint"], `resolution attempt ${index + 1}`);
|
|
369
|
+
return {
|
|
370
|
+
generation: integer(value.generation, `resolution attempt ${index + 1} generation`),
|
|
371
|
+
threadId: requiredText(value.threadId, `resolution attempt ${index + 1} thread ID`),
|
|
372
|
+
state: attemptState(value.state, `resolution attempt ${index + 1} state`),
|
|
373
|
+
beforeFingerprint: sha256(requiredText(value.beforeFingerprint, `resolution attempt ${index + 1} before fingerprint`), `resolution attempt ${index + 1} before fingerprint`),
|
|
374
|
+
afterFingerprint: value.afterFingerprint === null
|
|
375
|
+
? null
|
|
376
|
+
: sha256(requiredText(value.afterFingerprint, `resolution attempt ${index + 1} after fingerprint`), `resolution attempt ${index + 1} after fingerprint`),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function parseState(value: unknown, expectedRoot: string, expectedId: string): SweepState {
|
|
381
|
+
if (!isRecord(value) || !isRecord(value.worktree) || !isRecord(value.original) || !isRecord(value.feedback) ||
|
|
382
|
+
!isRecord(value.attempts) || !isRecord(value.attempts.push) || !isRecord(value.attempts.finalize)) {
|
|
383
|
+
throw new Error("sweep recovery state is invalid");
|
|
384
|
+
}
|
|
385
|
+
exactKeys(value, [
|
|
386
|
+
"version", "workflow", "worktree", "epoch", "runId", "phase", "authority", "original", "feedback",
|
|
387
|
+
"ledger", "ownedPaths", "publicationHead", "projection", "attempts",
|
|
388
|
+
], "sweep recovery state");
|
|
389
|
+
if (value.version !== STATE_VERSION || value.workflow !== "pi-pr-comment-sweep") throw new Error("unsupported sweep recovery state version");
|
|
390
|
+
if (!(["triage", "recorded", "published", "refresh-pending", "refreshed", "resolving", "resolved"] as unknown[]).includes(value.phase)) {
|
|
391
|
+
throw new Error("sweep recovery phase is invalid");
|
|
392
|
+
}
|
|
393
|
+
exactKeys(value.worktree, ["id", "root"], "sweep worktree");
|
|
394
|
+
const root = requiredText(value.worktree.root, "sweep worktree root");
|
|
395
|
+
const id = requiredText(value.worktree.id, "sweep worktree ID");
|
|
396
|
+
if (root !== expectedRoot || id !== expectedId) throw new Error("sweep recovery state belongs to another worktree");
|
|
397
|
+
const authority = parseAuthority(value.authority);
|
|
398
|
+
exactKeys(value.original, ["head", "lease"], "sweep original authority");
|
|
399
|
+
const original = {
|
|
400
|
+
head: requiredOid(value.original.head, "original head"),
|
|
401
|
+
lease: requiredOid(value.original.lease, "original lease"),
|
|
402
|
+
};
|
|
403
|
+
if (original.head !== authority.head.oid || original.lease !== authority.target.remoteOid || original.head !== original.lease) {
|
|
404
|
+
throw new Error("sweep original authority is inconsistent");
|
|
405
|
+
}
|
|
406
|
+
exactKeys(value.feedback, ["generation", "fingerprint", "contentFingerprint", "snapshot"], "sweep feedback");
|
|
407
|
+
const snapshot = parseFeedbackSnapshot(value.feedback.snapshot);
|
|
408
|
+
const feedback = {
|
|
409
|
+
generation: integer(value.feedback.generation, "feedback generation"),
|
|
410
|
+
fingerprint: sha256(requiredText(value.feedback.fingerprint, "feedback fingerprint"), "feedback fingerprint"),
|
|
411
|
+
contentFingerprint: sha256(requiredText(value.feedback.contentFingerprint, "feedback content fingerprint"), "feedback content fingerprint"),
|
|
412
|
+
snapshot,
|
|
413
|
+
};
|
|
414
|
+
if (feedback.fingerprint !== feedbackFingerprint(snapshot) || feedback.contentFingerprint !== feedbackContentFingerprint(snapshot)) {
|
|
415
|
+
throw new Error("sweep feedback fingerprints do not match the complete snapshot");
|
|
416
|
+
}
|
|
417
|
+
const ledger = value.ledger === null ? null : exactLedger(value.ledger, snapshot);
|
|
418
|
+
const ownedPaths = parseOwnedPaths(value.ownedPaths);
|
|
419
|
+
const publicationHead = value.publicationHead === null ? null : requiredOid(value.publicationHead, "publication head");
|
|
420
|
+
const projection = value.projection === null ? null : parseProjection(value.projection);
|
|
421
|
+
if (projection && (projection.generation !== feedback.generation || projection.contentFingerprint !== feedback.contentFingerprint)) {
|
|
422
|
+
throw new Error("final projection is not bound to the current feedback generation");
|
|
423
|
+
}
|
|
424
|
+
exactKeys(value.attempts, ["push", "resolutions", "finalize"], "sweep attempts");
|
|
425
|
+
exactKeys(value.attempts.push, ["state", "head"], "push attempt");
|
|
426
|
+
const push = {
|
|
427
|
+
state: attemptState(value.attempts.push.state, "push attempt state"),
|
|
428
|
+
head: value.attempts.push.head === null ? null : requiredOid(value.attempts.push.head, "push attempt head"),
|
|
429
|
+
};
|
|
430
|
+
if (!Array.isArray(value.attempts.resolutions) || value.attempts.resolutions.length > FEEDBACK_MAX_RECORDS) {
|
|
431
|
+
throw new Error("resolution attempts are invalid");
|
|
432
|
+
}
|
|
433
|
+
const resolutions = value.attempts.resolutions.map(resolutionAttempt);
|
|
434
|
+
if (new Set(resolutions.map(({ generation, threadId }) => `${generation}\0${threadId}`)).size !== resolutions.length) {
|
|
435
|
+
throw new Error("resolution attempts contain duplicate thread IDs");
|
|
436
|
+
}
|
|
437
|
+
exactKeys(value.attempts.finalize, ["state", "checks"], "finalize attempt");
|
|
438
|
+
const finalize = {
|
|
439
|
+
state: attemptState(value.attempts.finalize.state, "finalize attempt state"),
|
|
440
|
+
checks: parseChecks(value.attempts.finalize.checks),
|
|
441
|
+
};
|
|
442
|
+
const state: SweepState = {
|
|
443
|
+
version: 1,
|
|
444
|
+
workflow: "pi-pr-comment-sweep",
|
|
445
|
+
worktree: { id, root },
|
|
446
|
+
epoch: integer(value.epoch, "sweep epoch"),
|
|
447
|
+
runId: safeRunId(value.runId),
|
|
448
|
+
phase: value.phase as SweepPhase,
|
|
449
|
+
authority,
|
|
450
|
+
original,
|
|
451
|
+
feedback,
|
|
452
|
+
ledger,
|
|
453
|
+
ownedPaths,
|
|
454
|
+
publicationHead,
|
|
455
|
+
projection,
|
|
456
|
+
attempts: { push, resolutions, finalize },
|
|
457
|
+
};
|
|
458
|
+
const isPublished = ["published", "refresh-pending", "refreshed", "resolving", "resolved"].includes(state.phase);
|
|
459
|
+
const hasFreshSnapshot = ["refresh-pending", "refreshed", "resolving", "resolved"].includes(state.phase);
|
|
460
|
+
const hasFinalProjection = ["refreshed", "resolving", "resolved"].includes(state.phase);
|
|
461
|
+
const feedbackHead = hasFreshSnapshot ? publicationHead : original.head;
|
|
462
|
+
if (!feedbackHead || !feedbackMatchesAuthority(snapshot, authority, feedbackHead)) {
|
|
463
|
+
throw new Error("feedback snapshot authority is inconsistent");
|
|
464
|
+
}
|
|
465
|
+
if (["triage", "refresh-pending"].includes(state.phase) !== (ledger === null)) {
|
|
466
|
+
throw new Error("sweep phase and ledger coverage are inconsistent");
|
|
467
|
+
}
|
|
468
|
+
if (isPublished !== (push.state === "applied")) throw new Error("sweep phase and push attempt are inconsistent");
|
|
469
|
+
if (push.state === "none") {
|
|
470
|
+
if (push.head !== null || publicationHead !== null) throw new Error("empty push attempt has publication data");
|
|
471
|
+
} else if (push.head === null || publicationHead !== push.head) {
|
|
472
|
+
throw new Error("push attempt head does not match publication head");
|
|
473
|
+
}
|
|
474
|
+
if (hasFinalProjection !== (projection !== null)) throw new Error("sweep phase and final projection are inconsistent");
|
|
475
|
+
if (projection && (!ledger || !sameProjection(projection, buildProjection(feedback.generation, snapshot, ledger)))) {
|
|
476
|
+
throw new Error("final projection does not match feedback and ledger coverage");
|
|
477
|
+
}
|
|
478
|
+
return state;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function guardFor(state: SweepState): SweepRunGuard {
|
|
482
|
+
return {
|
|
483
|
+
epoch: state.epoch,
|
|
484
|
+
runId: state.runId,
|
|
485
|
+
generation: state.feedback.generation,
|
|
486
|
+
fingerprint: state.feedback.fingerprint,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function requireGuard(state: SweepState, guard: SweepRunGuard): void {
|
|
491
|
+
if (!guard || guard.epoch !== state.epoch || guard.runId !== state.runId ||
|
|
492
|
+
guard.generation !== state.feedback.generation || guard.fingerprint !== state.feedback.fingerprint) {
|
|
493
|
+
throw new Error("stale comment sweep run, generation, or feedback fingerprint");
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function status(state: SweepState): SweepStatus {
|
|
498
|
+
const feedback = feedbackEntries(state.feedback.snapshot).map(({ id, kind }) => ({ id, kind }));
|
|
499
|
+
return {
|
|
500
|
+
phase: state.phase,
|
|
501
|
+
guard: guardFor(state),
|
|
502
|
+
pullRequestUrl: state.authority.url,
|
|
503
|
+
originalHead: state.original.head,
|
|
504
|
+
publicationHead: state.publicationHead,
|
|
505
|
+
feedbackCount: feedback.length,
|
|
506
|
+
feedback,
|
|
507
|
+
ledgerComplete: state.ledger !== null,
|
|
508
|
+
projection: state.projection ? structuredClone(state.projection) : null,
|
|
509
|
+
attempts: {
|
|
510
|
+
push: state.attempts.push.state,
|
|
511
|
+
resolutions: state.attempts.resolutions.map(({ threadId, state }) => ({ threadId, state })),
|
|
512
|
+
finalize: state.attempts.finalize.state,
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function oneLine(output: string, label: string): string {
|
|
518
|
+
const normalized = output.replace(/\r\n/g, "\n");
|
|
519
|
+
const lines = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
|
|
520
|
+
if (lines.length !== 1 || !lines[0]) throw new Error(`${label} returned invalid output`);
|
|
521
|
+
return lines[0];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function onlyResolutionChanged(before: FeedbackSnapshot, after: FeedbackSnapshot, threadId: string): boolean {
|
|
525
|
+
if (feedbackContentFingerprint(before) !== feedbackContentFingerprint(after)) return false;
|
|
526
|
+
const beforeStates = before.reviewThreads.map(({ id, isResolved }) => ({ id, isResolved })).sort((left, right) => left.id.localeCompare(right.id));
|
|
527
|
+
const afterStates = after.reviewThreads.map(({ id, isResolved }) => ({ id, isResolved })).sort((left, right) => left.id.localeCompare(right.id));
|
|
528
|
+
if (beforeStates.length !== afterStates.length) return false;
|
|
529
|
+
let changed = 0;
|
|
530
|
+
for (let index = 0; index < beforeStates.length; index += 1) {
|
|
531
|
+
const left = beforeStates[index]!;
|
|
532
|
+
const right = afterStates[index]!;
|
|
533
|
+
if (left.id !== right.id) return false;
|
|
534
|
+
if (left.isResolved !== right.isResolved) {
|
|
535
|
+
if (left.id !== threadId || left.isResolved || !right.isResolved) return false;
|
|
536
|
+
changed += 1;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return changed === 1;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export class PullRequestCommentSweep {
|
|
543
|
+
private readonly cwd: string;
|
|
544
|
+
private readonly suppliedAuthority?: SweepAuthority;
|
|
545
|
+
private readonly signal?: AbortSignal;
|
|
546
|
+
private readonly agentDir?: string;
|
|
547
|
+
private readonly exec: Exec;
|
|
548
|
+
private readonly load: Load;
|
|
549
|
+
private readonly newRunId: () => string;
|
|
550
|
+
private readonly pause?: (milliseconds: number) => Promise<void>;
|
|
551
|
+
|
|
552
|
+
constructor(options: PullRequestCommentSweepOptions) {
|
|
553
|
+
this.cwd = requiredText(options.cwd, "cwd");
|
|
554
|
+
this.suppliedAuthority = options.authority ? authorityFromCurrent(options.authority) : undefined;
|
|
555
|
+
this.signal = options.signal;
|
|
556
|
+
this.agentDir = options.agentDir;
|
|
557
|
+
this.exec = options.exec ?? spawnBounded;
|
|
558
|
+
this.load = options.loadCurrentPullRequest ?? loadCurrentPullRequest;
|
|
559
|
+
this.newRunId = options.newRunId ?? randomUUID;
|
|
560
|
+
this.pause = options.pause;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private options(extra: Partial<ExecOptions> = {}): ExecOptions {
|
|
564
|
+
return { cwd: this.cwd, signal: this.signal, ...extra };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private pi(): Pick<ExtensionAPI, "exec"> {
|
|
568
|
+
return {
|
|
569
|
+
exec: (command, args, options) => this.exec(command, args, {
|
|
570
|
+
cwd: options?.cwd ?? this.cwd,
|
|
571
|
+
signal: options?.signal ?? this.signal,
|
|
572
|
+
timeoutMs: options?.timeout,
|
|
573
|
+
}),
|
|
574
|
+
} as Pick<ExtensionAPI, "exec">;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private context(): PullRequestLoadContext {
|
|
578
|
+
return { cwd: this.cwd, signal: this.signal ?? new AbortController().signal };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private async location(): Promise<{ root: string; id: string; path: string }> {
|
|
582
|
+
const top = oneLine((await runChecked(this.exec, "git", ["rev-parse", "--show-toplevel"], this.options())).stdout, "Git worktree root");
|
|
583
|
+
const root = await realpath(top);
|
|
584
|
+
const id = createHash("sha256").update(root).digest("hex");
|
|
585
|
+
return { root, id, path: join(extensionConfigDir("pi-pr", this.agentDir), "sweep", id, STATE_FILE) };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async recoveryPath(): Promise<string> {
|
|
589
|
+
return (await this.location()).path;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private async loadState(location: Awaited<ReturnType<PullRequestCommentSweep["location"]>>): Promise<SweepState> {
|
|
593
|
+
const raw = await readTextFileBounded(location.path, SWEEP_RECOVERY_MAX_BYTES, { signal: this.signal });
|
|
594
|
+
let value: unknown;
|
|
595
|
+
try {
|
|
596
|
+
value = JSON.parse(raw);
|
|
597
|
+
} catch {
|
|
598
|
+
throw new Error(`Malformed comment sweep recovery is preserved at ${location.path}`);
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
return parseState(value, location.root, location.id);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
throw new Error(`Invalid comment sweep recovery is preserved at ${location.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private async loadIfPresent(location: Awaited<ReturnType<PullRequestCommentSweep["location"]>>): Promise<SweepState | undefined> {
|
|
608
|
+
try {
|
|
609
|
+
return await this.loadState(location);
|
|
610
|
+
} catch (error) {
|
|
611
|
+
if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
612
|
+
throw error;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private async save(location: Awaited<ReturnType<PullRequestCommentSweep["location"]>>, state: SweepState): Promise<void> {
|
|
617
|
+
const checked = parseState(state, location.root, location.id);
|
|
618
|
+
const contents = `${JSON.stringify(checked)}\n`;
|
|
619
|
+
if (Buffer.byteLength(contents, "utf8") > SWEEP_RECOVERY_MAX_BYTES) {
|
|
620
|
+
throw new Error(`Comment sweep recovery exceeds ${SWEEP_RECOVERY_MAX_BYTES} bytes`);
|
|
621
|
+
}
|
|
622
|
+
await writePrivateTextFileAtomically(location.path, contents, { signal: this.signal });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private async currentAuthority(expected: SweepAuthority, remoteHead: string): Promise<CurrentPullRequest> {
|
|
626
|
+
const discovery = await this.load(this.pi(), this.context());
|
|
627
|
+
if (discovery.kind !== "current") throw new Error("Comment sweep cancelled: current pull request authority is unavailable");
|
|
628
|
+
const current = authorityFromCurrent(discovery.pullRequest);
|
|
629
|
+
if (!sameLinkage(expected, current, remoteHead)) throw new Error("Comment sweep cancelled: canonical pull request authority changed");
|
|
630
|
+
const remote = await readRemoteOid(this.exec, this.options(), expected.target.fetchSource, expected.target.ref);
|
|
631
|
+
if (remote !== remoteHead) throw new Error("Comment sweep cancelled: remote lease changed");
|
|
632
|
+
return discovery.pullRequest;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private async requireNoGitOperation(): Promise<void> {
|
|
636
|
+
const paths = await runChecked(this.exec, "git", [
|
|
637
|
+
"rev-parse", ...GIT_OPERATION_STATES.flatMap((state) => ["--git-path", state]),
|
|
638
|
+
], this.options());
|
|
639
|
+
const normalized = paths.stdout.replace(/\r\n/g, "\n");
|
|
640
|
+
const values = (normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized).split("\n");
|
|
641
|
+
if (values.length !== GIT_OPERATION_STATES.length || values.some((path) => !path)) {
|
|
642
|
+
throw new Error("Git operation state path resolution returned invalid output");
|
|
643
|
+
}
|
|
644
|
+
for (const [index, path] of values.entries()) {
|
|
645
|
+
try {
|
|
646
|
+
await lstat(resolve(this.cwd, path));
|
|
647
|
+
throw new Error(`${GIT_OPERATION_STATES[index]} is in progress`);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private async localPaths(): Promise<string[]> {
|
|
656
|
+
await this.requireNoGitOperation();
|
|
657
|
+
const output = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options());
|
|
658
|
+
return [...parseStatusSnapshot(output.stdout).keys()];
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private async requireOwnedLocalState(state: SweepState, expectedHead?: string): Promise<string> {
|
|
662
|
+
const head = await readHead(this.exec, this.options());
|
|
663
|
+
if (expectedHead !== undefined && head !== expectedHead) throw new Error("Comment sweep cancelled: local HEAD changed");
|
|
664
|
+
const dirty = await this.localPaths();
|
|
665
|
+
const owned = new Set(state.ownedPaths);
|
|
666
|
+
const outside = dirty.filter((path) => !owned.has(path));
|
|
667
|
+
if (outside.length) throw new Error(`Comment sweep found changes outside owned paths: ${outside.join(", ")}`);
|
|
668
|
+
if (!(await isAncestor(this.exec, this.options(), state.original.head, head))) {
|
|
669
|
+
throw new Error("Comment sweep local HEAD no longer descends from the original head");
|
|
670
|
+
}
|
|
671
|
+
if (head !== state.original.head) {
|
|
672
|
+
const changed = parseNulPaths((await runChecked(this.exec, "git", [
|
|
673
|
+
"diff", "--name-only", "--no-renames", "-z", `${state.original.head}..${head}`,
|
|
674
|
+
], this.options())).stdout, "Sweep committed paths");
|
|
675
|
+
const committedOutside = changed.filter((path) => !owned.has(path));
|
|
676
|
+
if (committedOutside.length) throw new Error(`Comment sweep commit changed outside owned paths: ${committedOutside.join(", ")}`);
|
|
677
|
+
}
|
|
678
|
+
return head;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private async requireCleanPublication(state: SweepState, expectedHead: string): Promise<void> {
|
|
682
|
+
if ((await this.localPaths()).length) throw new Error("Comment sweep requires a clean worktree");
|
|
683
|
+
await this.requireOwnedLocalState(state, expectedHead);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
private async collect(authority: SweepAuthority, head: string): Promise<FeedbackSnapshot> {
|
|
687
|
+
const snapshot = await collectPullRequestFeedback({
|
|
688
|
+
id: authority.id,
|
|
689
|
+
number: authority.number,
|
|
690
|
+
url: authority.url,
|
|
691
|
+
host: authority.host,
|
|
692
|
+
base: authority.base,
|
|
693
|
+
head: { ...authority.head, oid: head },
|
|
694
|
+
}, { exec: this.exec, cwd: this.cwd, signal: this.signal, pause: this.pause });
|
|
695
|
+
if (!feedbackMatchesAuthority(snapshot, authority, head)) throw new Error("Complete feedback snapshot authority changed");
|
|
696
|
+
return snapshot;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private setFeedback(state: SweepState, snapshot: FeedbackSnapshot, generation = state.feedback.generation): void {
|
|
700
|
+
state.feedback = {
|
|
701
|
+
generation,
|
|
702
|
+
fingerprint: feedbackFingerprint(snapshot),
|
|
703
|
+
contentFingerprint: feedbackContentFingerprint(snapshot),
|
|
704
|
+
snapshot,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async start(): Promise<SweepStatus> {
|
|
709
|
+
return await withWorktreeLock(this.cwd, async () => {
|
|
710
|
+
const location = await this.location();
|
|
711
|
+
if (await this.loadIfPresent(location)) throw new Error("A recoverable comment sweep already exists; use resume");
|
|
712
|
+
if (!this.suppliedAuthority) throw new Error("Comment sweep start requires route authority");
|
|
713
|
+
const authority = this.suppliedAuthority;
|
|
714
|
+
if (authority.head.oid !== authority.target.remoteOid) throw new Error("Comment sweep requires PR head and remote lease to match");
|
|
715
|
+
if ((await this.localPaths()).length || await readHead(this.exec, this.options()) !== authority.head.oid) {
|
|
716
|
+
throw new Error("Comment sweep start requires a clean worktree at the PR head");
|
|
717
|
+
}
|
|
718
|
+
await this.currentAuthority(authority, authority.head.oid);
|
|
719
|
+
const snapshot = await this.collect(authority, authority.head.oid);
|
|
720
|
+
await this.currentAuthority(authority, authority.head.oid);
|
|
721
|
+
if ((await this.localPaths()).length || await readHead(this.exec, this.options()) !== authority.head.oid) {
|
|
722
|
+
throw new Error("Comment sweep authority changed during feedback fetch");
|
|
723
|
+
}
|
|
724
|
+
const state: SweepState = {
|
|
725
|
+
version: 1,
|
|
726
|
+
workflow: "pi-pr-comment-sweep",
|
|
727
|
+
worktree: { id: location.id, root: location.root },
|
|
728
|
+
epoch: 1,
|
|
729
|
+
runId: safeRunId(this.newRunId()),
|
|
730
|
+
phase: "triage",
|
|
731
|
+
authority,
|
|
732
|
+
original: { head: authority.head.oid, lease: authority.target.remoteOid },
|
|
733
|
+
feedback: {
|
|
734
|
+
generation: 1,
|
|
735
|
+
fingerprint: feedbackFingerprint(snapshot),
|
|
736
|
+
contentFingerprint: feedbackContentFingerprint(snapshot),
|
|
737
|
+
snapshot,
|
|
738
|
+
},
|
|
739
|
+
ledger: null,
|
|
740
|
+
ownedPaths: [],
|
|
741
|
+
publicationHead: null,
|
|
742
|
+
projection: null,
|
|
743
|
+
attempts: {
|
|
744
|
+
push: { state: "none", head: null },
|
|
745
|
+
resolutions: [],
|
|
746
|
+
finalize: { state: "none", checks: [] },
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
await this.save(location, state);
|
|
750
|
+
return status(state);
|
|
751
|
+
}, { agentDir: this.agentDir, signal: this.signal });
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
private async reconcile(state: SweepState): Promise<void> {
|
|
755
|
+
let remote = await readRemoteOid(this.exec, this.options(), state.authority.target.fetchSource, state.authority.target.ref);
|
|
756
|
+
if (remote === null) throw new Error("Comment sweep remote ref disappeared");
|
|
757
|
+
if (state.attempts.push.state === "attempting" || state.attempts.push.state === "unknown") {
|
|
758
|
+
const attemptedHead = state.attempts.push.head;
|
|
759
|
+
if (!attemptedHead) throw new Error("Attempted push has no captured head");
|
|
760
|
+
await this.currentAuthority(state.authority, remote);
|
|
761
|
+
if (remote === attemptedHead) {
|
|
762
|
+
state.attempts.push.state = "applied";
|
|
763
|
+
state.phase = "published";
|
|
764
|
+
} else if (remote === state.original.lease) {
|
|
765
|
+
state.attempts.push.state = "blocked";
|
|
766
|
+
} else {
|
|
767
|
+
throw new Error("Attempted push cannot be reconciled to its original lease or captured head");
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const expectedRemote = state.attempts.push.state === "applied" && state.publicationHead
|
|
771
|
+
? state.publicationHead
|
|
772
|
+
: state.original.lease;
|
|
773
|
+
remote = await readRemoteOid(this.exec, this.options(), state.authority.target.fetchSource, state.authority.target.ref);
|
|
774
|
+
if (remote !== expectedRemote) throw new Error("Comment sweep remote authority cannot be reconciled");
|
|
775
|
+
await this.currentAuthority(state.authority, expectedRemote);
|
|
776
|
+
|
|
777
|
+
const pending = state.attempts.resolutions.filter(({ state: attempt }) => attempt === "attempting" || attempt === "unknown");
|
|
778
|
+
if (pending.length > 1) throw new Error("Multiple unresolved mutation attempts cannot be reconciled");
|
|
779
|
+
if (pending.length === 1) {
|
|
780
|
+
const attempt = pending[0]!;
|
|
781
|
+
if (!state.publicationHead || attempt.generation !== state.feedback.generation || attempt.beforeFingerprint !== state.feedback.fingerprint) {
|
|
782
|
+
throw new Error("Resolution attempt is not bound to the current feedback generation");
|
|
783
|
+
}
|
|
784
|
+
const current = await this.collect(state.authority, state.publicationHead);
|
|
785
|
+
const thread = current.reviewThreads.find(({ id }) => id === attempt.threadId);
|
|
786
|
+
if (!thread || feedbackContentFingerprint(current) !== state.feedback.contentFingerprint) {
|
|
787
|
+
throw new Error("Resolution attempt feedback generation changed during recovery");
|
|
788
|
+
}
|
|
789
|
+
if (thread.isResolved && onlyResolutionChanged(state.feedback.snapshot, current, attempt.threadId)) {
|
|
790
|
+
attempt.state = "applied";
|
|
791
|
+
attempt.afterFingerprint = feedbackFingerprint(current);
|
|
792
|
+
this.setFeedback(state, current);
|
|
793
|
+
} else if (!thread.isResolved && feedbackFingerprint(current) === state.feedback.fingerprint) {
|
|
794
|
+
attempt.state = "blocked";
|
|
795
|
+
} else {
|
|
796
|
+
throw new Error("Resolution attempt outcome is not exactly reconcilable");
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (state.attempts.finalize.state === "attempting") state.attempts.finalize.state = "unknown";
|
|
800
|
+
if (state.projection && projectionMatches(state.feedback.snapshot, state.projection)) state.phase = "resolved";
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async resume(): Promise<SweepStatus> {
|
|
804
|
+
return await withWorktreeLock(this.cwd, async () => {
|
|
805
|
+
if (!this.suppliedAuthority) throw new Error("Comment sweep resume requires route authority");
|
|
806
|
+
const suppliedAuthority = this.suppliedAuthority;
|
|
807
|
+
const location = await this.location();
|
|
808
|
+
const state = await this.loadState(location);
|
|
809
|
+
const permittedHeads = new Set<string>();
|
|
810
|
+
if (state.attempts.push.state !== "applied") permittedHeads.add(state.original.lease);
|
|
811
|
+
if (
|
|
812
|
+
(state.attempts.push.state === "attempting" || state.attempts.push.state === "unknown" || state.attempts.push.state === "applied") &&
|
|
813
|
+
state.publicationHead
|
|
814
|
+
) permittedHeads.add(state.publicationHead);
|
|
815
|
+
if (![...permittedHeads].some((head) => sameLinkage(state.authority, suppliedAuthority, head))) {
|
|
816
|
+
throw new Error("Comment sweep recovery does not match supplied route authority");
|
|
817
|
+
}
|
|
818
|
+
await this.reconcile(state);
|
|
819
|
+
state.attempts.resolutions = state.attempts.resolutions.filter(({ state: attempt }) => attempt !== "blocked");
|
|
820
|
+
if (state.attempts.push.state === "blocked") {
|
|
821
|
+
state.attempts.push = { state: "none", head: null };
|
|
822
|
+
state.publicationHead = null;
|
|
823
|
+
}
|
|
824
|
+
if (state.attempts.finalize.state === "blocked") state.attempts.finalize = { state: "none", checks: [] };
|
|
825
|
+
const published = state.attempts.push.state === "applied";
|
|
826
|
+
const expectedRemote = published ? state.publicationHead! : state.original.lease;
|
|
827
|
+
await this.currentAuthority(state.authority, expectedRemote);
|
|
828
|
+
await this.requireOwnedLocalState(state, published ? expectedRemote : undefined);
|
|
829
|
+
state.epoch += 1;
|
|
830
|
+
state.runId = safeRunId(this.newRunId());
|
|
831
|
+
await this.save(location, state);
|
|
832
|
+
return status(state);
|
|
833
|
+
}, { agentDir: this.agentDir, signal: this.signal });
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async show(guard: SweepRunGuard, id: string): Promise<FeedbackItem> {
|
|
837
|
+
return await withWorktreeLock(this.cwd, async () => {
|
|
838
|
+
const location = await this.location();
|
|
839
|
+
const state = await this.loadState(location);
|
|
840
|
+
requireGuard(state, guard);
|
|
841
|
+
return structuredClone(showFeedbackItem(state.feedback.snapshot, id));
|
|
842
|
+
}, { agentDir: this.agentDir, signal: this.signal });
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async record(guard: SweepRunGuard, ledgerInput: SweepLedgerEntry[], ownedPathsInput?: string[]): Promise<SweepStatus> {
|
|
846
|
+
return await withWorktreeLock(this.cwd, async () => {
|
|
847
|
+
const location = await this.location();
|
|
848
|
+
const state = await this.loadState(location);
|
|
849
|
+
requireGuard(state, guard);
|
|
850
|
+
if (state.phase === "triage" && state.ledger === null) {
|
|
851
|
+
if (ownedPathsInput === undefined) throw new Error("Initial comment sweep ledger requires ownedPaths");
|
|
852
|
+
state.ledger = exactLedger(ledgerInput, state.feedback.snapshot);
|
|
853
|
+
state.ownedPaths = parseOwnedPaths(ownedPathsInput);
|
|
854
|
+
state.phase = "recorded";
|
|
855
|
+
} else if (state.phase === "refresh-pending" && state.ledger === null) {
|
|
856
|
+
if (ownedPathsInput !== undefined) throw new Error("Refreshed comment sweep ledger cannot change ownedPaths");
|
|
857
|
+
state.ledger = exactLedger(ledgerInput, state.feedback.snapshot);
|
|
858
|
+
state.projection = buildProjection(state.feedback.generation, state.feedback.snapshot, state.ledger);
|
|
859
|
+
state.phase = "refreshed";
|
|
860
|
+
} else {
|
|
861
|
+
throw new Error("Comment sweep ledger was already recorded or is not ready");
|
|
862
|
+
}
|
|
863
|
+
await this.save(location, state);
|
|
864
|
+
return status(state);
|
|
865
|
+
}, { agentDir: this.agentDir, signal: this.signal });
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async publish(guard: SweepRunGuard): Promise<SweepStatus> {
|
|
869
|
+
return await withWorktreeLock(this.cwd, async () => {
|
|
870
|
+
const location = await this.location();
|
|
871
|
+
const state = await this.loadState(location);
|
|
872
|
+
requireGuard(state, guard);
|
|
873
|
+
if (state.phase !== "recorded" || !state.ledger || state.attempts.push.state !== "none") {
|
|
874
|
+
throw new Error("Comment sweep is not ready to publish");
|
|
875
|
+
}
|
|
876
|
+
const head = await this.requireOwnedLocalState(state);
|
|
877
|
+
if ((await this.localPaths()).length) throw new Error("Comment sweep publish requires a clean worktree");
|
|
878
|
+
await this.currentAuthority(state.authority, state.original.lease);
|
|
879
|
+
if (head === state.original.head) {
|
|
880
|
+
state.publicationHead = head;
|
|
881
|
+
state.attempts.push = { state: "applied", head };
|
|
882
|
+
state.phase = "published";
|
|
883
|
+
await this.save(location, state);
|
|
884
|
+
return status(state);
|
|
885
|
+
}
|
|
886
|
+
if (!(await isAncestor(this.exec, this.options(), state.original.lease, head))) {
|
|
887
|
+
throw new Error("Comment sweep push would not fast-forward the original lease");
|
|
888
|
+
}
|
|
889
|
+
await this.currentAuthority(state.authority, state.original.lease);
|
|
890
|
+
if (await readHead(this.exec, this.options()) !== head || (await this.localPaths()).length) {
|
|
891
|
+
throw new Error("Comment sweep local HEAD or worktree changed before push");
|
|
892
|
+
}
|
|
893
|
+
state.publicationHead = head;
|
|
894
|
+
state.attempts.push = { state: "attempting", head };
|
|
895
|
+
await this.save(location, state);
|
|
896
|
+
try {
|
|
897
|
+
await runChecked(this.exec, "git", [
|
|
898
|
+
"push", "--porcelain", `--force-with-lease=refs/heads/${state.authority.target.ref}:${state.original.lease}`,
|
|
899
|
+
"--recurse-submodules=no", "--", state.authority.target.fetchSource,
|
|
900
|
+
`${head}:refs/heads/${state.authority.target.ref}`,
|
|
901
|
+
], this.options());
|
|
902
|
+
if (await readRemoteOid(this.exec, this.options(), state.authority.target.fetchSource, state.authority.target.ref) !== head) {
|
|
903
|
+
throw new Error("Published remote ref did not match captured HEAD");
|
|
904
|
+
}
|
|
905
|
+
await this.currentAuthority(state.authority, head);
|
|
906
|
+
state.attempts.push.state = "applied";
|
|
907
|
+
state.phase = "published";
|
|
908
|
+
await this.save(location, state);
|
|
909
|
+
return status(state);
|
|
910
|
+
} catch (error) {
|
|
911
|
+
state.attempts.push.state = "unknown";
|
|
912
|
+
await this.save(location, state);
|
|
913
|
+
throw error;
|
|
914
|
+
}
|
|
915
|
+
}, { agentDir: this.agentDir, signal: this.signal });
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async refresh(guard: SweepRunGuard): Promise<SweepStatus> {
|
|
919
|
+
return await withWorktreeLock(this.cwd, async () => {
|
|
920
|
+
const location = await this.location();
|
|
921
|
+
const state = await this.loadState(location);
|
|
922
|
+
requireGuard(state, guard);
|
|
923
|
+
if (!["published", "refresh-pending", "refreshed", "resolving", "resolved"].includes(state.phase) || !state.publicationHead || state.attempts.push.state !== "applied") {
|
|
924
|
+
throw new Error("Comment sweep is not ready to refresh");
|
|
925
|
+
}
|
|
926
|
+
if (state.attempts.resolutions.some(({ state: attempt }) => attempt !== "applied")) {
|
|
927
|
+
throw new Error("Comment sweep has an unreconciled thread mutation; use resume");
|
|
928
|
+
}
|
|
929
|
+
if (state.attempts.finalize.state !== "none" && state.attempts.finalize.state !== "applied") {
|
|
930
|
+
throw new Error("Comment sweep has an unreconciled finalization attempt; use resume");
|
|
931
|
+
}
|
|
932
|
+
await this.requireCleanPublication(state, state.publicationHead);
|
|
933
|
+
await this.currentAuthority(state.authority, state.publicationHead);
|
|
934
|
+
const snapshot = await this.collect(state.authority, state.publicationHead);
|
|
935
|
+
await this.currentAuthority(state.authority, state.publicationHead);
|
|
936
|
+
await this.requireCleanPublication(state, state.publicationHead);
|
|
937
|
+
this.setFeedback(state, snapshot, state.feedback.generation + 1);
|
|
938
|
+
state.ledger = null;
|
|
939
|
+
state.projection = null;
|
|
940
|
+
state.attempts.resolutions = [];
|
|
941
|
+
state.attempts.finalize = { state: "none", checks: [] };
|
|
942
|
+
state.phase = "refresh-pending";
|
|
943
|
+
await this.save(location, state);
|
|
944
|
+
return status(state);
|
|
945
|
+
}, { agentDir: this.agentDir, signal: this.signal });
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
async resolve(guard: SweepRunGuard, threadIdsInput: string[]): Promise<SweepStatus> {
|
|
949
|
+
return await withWorktreeLock(this.cwd, async () => {
|
|
950
|
+
const location = await this.location();
|
|
951
|
+
const state = await this.loadState(location);
|
|
952
|
+
requireGuard(state, guard);
|
|
953
|
+
if (!["refreshed", "resolving"].includes(state.phase) || !state.publicationHead || !state.ledger || !state.projection) {
|
|
954
|
+
throw new Error("Comment sweep is not ready to resolve threads");
|
|
955
|
+
}
|
|
956
|
+
if (state.attempts.resolutions.some(({ state: attempt }) => attempt !== "applied")) {
|
|
957
|
+
throw new Error("Comment sweep has an unreconciled thread mutation; use resume");
|
|
958
|
+
}
|
|
959
|
+
if (!Array.isArray(threadIdsInput) || threadIdsInput.length > FEEDBACK_MAX_RECORDS) throw new Error("threadIds must be a bounded array");
|
|
960
|
+
const threadIds = threadIdsInput.map((id, index) => requiredText(id, `thread ID ${index + 1}`));
|
|
961
|
+
if (new Set(threadIds).size !== threadIds.length) throw new Error("threadIds contain duplicates");
|
|
962
|
+
const ledger = new Map(state.ledger.map((entry) => [entry.id, entry]));
|
|
963
|
+
for (const threadId of threadIds) {
|
|
964
|
+
const thread = state.feedback.snapshot.reviewThreads.find(({ id }) => id === threadId);
|
|
965
|
+
if (!thread || thread.isResolved || ledger.get(threadId)?.kind !== "thread" || ledger.get(threadId)?.disposition !== "addressed") {
|
|
966
|
+
throw new Error(`Only addressed unresolved review thread IDs may be resolved: ${threadId}`);
|
|
967
|
+
}
|
|
968
|
+
if (state.attempts.resolutions.some((attempt) => attempt.generation === state.feedback.generation && attempt.threadId === threadId)) {
|
|
969
|
+
throw new Error(`Review thread mutation was already attempted: ${threadId}`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
for (const threadId of threadIds) {
|
|
973
|
+
await this.requireCleanPublication(state, state.publicationHead);
|
|
974
|
+
await this.currentAuthority(state.authority, state.publicationHead);
|
|
975
|
+
const before = await this.collect(state.authority, state.publicationHead);
|
|
976
|
+
if (feedbackFingerprint(before) !== state.feedback.fingerprint || feedbackContentFingerprint(before) !== state.feedback.contentFingerprint) {
|
|
977
|
+
throw new Error("Complete feedback generation or fingerprint changed before thread resolution");
|
|
978
|
+
}
|
|
979
|
+
await this.currentAuthority(state.authority, state.publicationHead);
|
|
980
|
+
await this.requireCleanPublication(state, state.publicationHead);
|
|
981
|
+
const thread = before.reviewThreads.find(({ id }) => id === threadId);
|
|
982
|
+
if (!thread || thread.isResolved) throw new Error(`Review thread is no longer unresolved: ${threadId}`);
|
|
983
|
+
const attempt: ResolutionAttempt = {
|
|
984
|
+
generation: state.feedback.generation,
|
|
985
|
+
threadId,
|
|
986
|
+
state: "attempting",
|
|
987
|
+
beforeFingerprint: state.feedback.fingerprint,
|
|
988
|
+
afterFingerprint: null,
|
|
989
|
+
};
|
|
990
|
+
state.attempts.resolutions.push(attempt);
|
|
991
|
+
state.phase = "resolving";
|
|
992
|
+
await this.save(location, state);
|
|
993
|
+
let after: FeedbackSnapshot;
|
|
994
|
+
try {
|
|
995
|
+
await resolvePullRequestThread(state.feedback.snapshot.pullRequest, threadId, {
|
|
996
|
+
exec: this.exec, cwd: this.cwd, signal: this.signal, pause: this.pause,
|
|
997
|
+
});
|
|
998
|
+
after = await this.collect(state.authority, state.publicationHead);
|
|
999
|
+
await this.currentAuthority(state.authority, state.publicationHead);
|
|
1000
|
+
await this.requireCleanPublication(state, state.publicationHead);
|
|
1001
|
+
if (!onlyResolutionChanged(before, after, threadId)) {
|
|
1002
|
+
throw new Error(`Thread resolution did not produce the exact verified transition: ${threadId}`);
|
|
1003
|
+
}
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
attempt.state = "unknown";
|
|
1006
|
+
await this.save(location, state);
|
|
1007
|
+
throw error;
|
|
1008
|
+
}
|
|
1009
|
+
attempt.state = "applied";
|
|
1010
|
+
attempt.afterFingerprint = feedbackFingerprint(after);
|
|
1011
|
+
this.setFeedback(state, after);
|
|
1012
|
+
await this.save(location, state);
|
|
1013
|
+
}
|
|
1014
|
+
if (projectionMatches(state.feedback.snapshot, state.projection)) state.phase = "resolved";
|
|
1015
|
+
await this.save(location, state);
|
|
1016
|
+
return status(state);
|
|
1017
|
+
}, { agentDir: this.agentDir, signal: this.signal });
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private async freshProjection(state: SweepState): Promise<void> {
|
|
1021
|
+
if (!state.publicationHead || !state.projection) throw new Error("Final projection is unavailable");
|
|
1022
|
+
await this.requireCleanPublication(state, state.publicationHead);
|
|
1023
|
+
await this.currentAuthority(state.authority, state.publicationHead);
|
|
1024
|
+
const snapshot = await this.collect(state.authority, state.publicationHead);
|
|
1025
|
+
await this.currentAuthority(state.authority, state.publicationHead);
|
|
1026
|
+
await this.requireCleanPublication(state, state.publicationHead);
|
|
1027
|
+
if (!projectionMatches(snapshot, state.projection)) throw new Error("Fresh complete feedback does not match the declared final projection");
|
|
1028
|
+
this.setFeedback(state, snapshot);
|
|
1029
|
+
state.phase = "resolved";
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
async finalize(
|
|
1033
|
+
guard: SweepRunGuard,
|
|
1034
|
+
projectionInput: SweepFinalProjection,
|
|
1035
|
+
checksInput: SweepCheck[],
|
|
1036
|
+
): Promise<{ kind: "finalized"; pullRequestUrl: string; head: string; checks: number }> {
|
|
1037
|
+
return await withWorktreeLock(this.cwd, async () => {
|
|
1038
|
+
const location = await this.location();
|
|
1039
|
+
const state = await this.loadState(location);
|
|
1040
|
+
requireGuard(state, guard);
|
|
1041
|
+
if (!["refreshed", "resolving", "resolved"].includes(state.phase) || !state.projection || !state.publicationHead) {
|
|
1042
|
+
throw new Error("Comment sweep is not ready to finalize");
|
|
1043
|
+
}
|
|
1044
|
+
const projection = parseProjection(projectionInput);
|
|
1045
|
+
if (!sameProjection(projection, state.projection)) throw new Error("Final projection does not match the declared refreshed projection");
|
|
1046
|
+
const checks = parseChecks(checksInput);
|
|
1047
|
+
if (state.attempts.resolutions.some(({ state: attempt }) => attempt !== "applied")) {
|
|
1048
|
+
throw new Error("Comment sweep has unresolved or unknown mutation attempts");
|
|
1049
|
+
}
|
|
1050
|
+
if (state.attempts.finalize.state !== "none" && state.attempts.finalize.state !== "applied") {
|
|
1051
|
+
throw new Error("Comment sweep has an unreconciled finalization attempt; use resume");
|
|
1052
|
+
}
|
|
1053
|
+
await this.freshProjection(state);
|
|
1054
|
+
const alreadyChecked = state.attempts.finalize.state === "applied" &&
|
|
1055
|
+
JSON.stringify(state.attempts.finalize.checks) === JSON.stringify(checks);
|
|
1056
|
+
if (!alreadyChecked) {
|
|
1057
|
+
state.attempts.finalize = { state: "attempting", checks };
|
|
1058
|
+
await this.save(location, state);
|
|
1059
|
+
for (const check of checks) {
|
|
1060
|
+
let result;
|
|
1061
|
+
try {
|
|
1062
|
+
result = await this.exec(check.command, check.args, this.options());
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
state.attempts.finalize.state = "unknown";
|
|
1065
|
+
await this.save(location, state);
|
|
1066
|
+
throw error;
|
|
1067
|
+
}
|
|
1068
|
+
if (result.killed) {
|
|
1069
|
+
state.attempts.finalize.state = "unknown";
|
|
1070
|
+
await this.save(location, state);
|
|
1071
|
+
throw new Error(`Finalization check was killed: ${check.command}`);
|
|
1072
|
+
}
|
|
1073
|
+
if (result.code !== 0) {
|
|
1074
|
+
state.attempts.finalize.state = "blocked";
|
|
1075
|
+
await this.save(location, state);
|
|
1076
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
1077
|
+
throw new Error(`${check.command} failed: ${detail}`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
state.attempts.finalize.state = "applied";
|
|
1081
|
+
await this.save(location, state);
|
|
1082
|
+
}
|
|
1083
|
+
await this.freshProjection(state);
|
|
1084
|
+
await this.save(location, state);
|
|
1085
|
+
await rm(location.path);
|
|
1086
|
+
return { kind: "finalized", pullRequestUrl: state.authority.url, head: state.publicationHead, checks: checks.length };
|
|
1087
|
+
}, { agentDir: this.agentDir, signal: this.signal });
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async action(request: PrCommentSweepRequest): Promise<SweepStatus | FeedbackItem | { kind: "finalized"; pullRequestUrl: string; head: string; checks: number }> {
|
|
1091
|
+
switch (request.action) {
|
|
1092
|
+
case "start": return await this.start();
|
|
1093
|
+
case "resume": return await this.resume();
|
|
1094
|
+
case "show": return await this.show(request.guard, request.id);
|
|
1095
|
+
case "record": return await this.record(request.guard, request.ledger, request.ownedPaths);
|
|
1096
|
+
case "publish": return await this.publish(request.guard);
|
|
1097
|
+
case "refresh": return await this.refresh(request.guard);
|
|
1098
|
+
case "resolve": return await this.resolve(request.guard, request.threadIds);
|
|
1099
|
+
case "finalize": return await this.finalize(request.guard, request.projection, request.checks);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|