@henryqw/pi-pr 6.0.8 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -247
- package/docs/pr-routing.svg +13 -13
- package/extensions/pr-command.ts +68 -94
- package/extensions/pr-comment-sweep.ts +245 -53
- package/extensions/pr-create.ts +50 -80
- package/extensions/pr-feedback-attention.ts +69 -0
- package/extensions/pr-feedback.ts +23 -1
- package/extensions/pr-publish-work.ts +143 -0
- package/extensions/pr-routing.ts +4 -44
- package/extensions/pr-ui.ts +2 -0
- package/extensions/pr-update-branch.ts +187 -63
- package/extensions/pr.ts +154 -50
- package/package.json +1 -1
- package/skills/pi-pr-comment-sweep/SKILL.md +9 -46
- package/skills/pi-pr-comment-sweep/references/recovery.md +5 -24
- package/skills/pi-pr-comment-sweep/references/thread-triage.md +3 -5
- package/skills/pi-pr-create/SKILL.md +4 -6
- package/skills/pi-pr-publish-work/SKILL.md +12 -0
- package/skills/pi-pr-update-branch/SKILL.md +5 -7
|
@@ -3,6 +3,7 @@ import { realpath, rm } from "node:fs/promises";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { isDeepStrictEqual } from "node:util";
|
|
5
5
|
import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
|
|
6
|
+
import { markFeedbackHandled } from "./pr-feedback-attention.ts";
|
|
6
7
|
import {
|
|
7
8
|
extensionConfigDir,
|
|
8
9
|
readTextFileBounded,
|
|
@@ -17,7 +18,9 @@ import {
|
|
|
17
18
|
parseFeedbackSnapshot,
|
|
18
19
|
showFeedbackItem,
|
|
19
20
|
resolvePullRequestThread,
|
|
21
|
+
replyToPullRequestThread,
|
|
20
22
|
FEEDBACK_MAX_RECORDS,
|
|
23
|
+
FEEDBACK_SNAPSHOT_MAX_BYTES,
|
|
21
24
|
type FeedbackItem,
|
|
22
25
|
type FeedbackKind,
|
|
23
26
|
type FeedbackSnapshot,
|
|
@@ -46,9 +49,10 @@ import {
|
|
|
46
49
|
|
|
47
50
|
export const SWEEP_RECOVERY_MAX_BYTES = 1024 * 1024;
|
|
48
51
|
|
|
49
|
-
const STATE_VERSION =
|
|
52
|
+
const STATE_VERSION = 2;
|
|
50
53
|
const STATE_FILE = "state.json";
|
|
51
54
|
const LEDGER_NOTE_MAX_BYTES = 2 * 1024;
|
|
55
|
+
const REPLY_METADATA_RESERVE_BYTES = 8 * 1024;
|
|
52
56
|
const CHECK_MAX_COUNT = 32;
|
|
53
57
|
const CHECK_ARGUMENTS_MAX_BYTES = 32 * 1024;
|
|
54
58
|
const ATTEMPT_STATES = new Set<AttemptState>(["none", "attempting", "applied", "blocked", "unknown"]);
|
|
@@ -83,10 +87,13 @@ export type SweepStatus = {
|
|
|
83
87
|
feedbackCount: number;
|
|
84
88
|
feedback: Array<{ id: string; kind: FeedbackKind }>;
|
|
85
89
|
ledgerComplete: boolean;
|
|
90
|
+
plan: { ledger: SweepLedgerEntry[]; ownedPaths: string[] } | null;
|
|
91
|
+
approved: boolean;
|
|
92
|
+
legacyRecovery: boolean;
|
|
86
93
|
projection: SweepFinalProjection | null;
|
|
87
94
|
attempts: {
|
|
88
95
|
push: AttemptState;
|
|
89
|
-
resolutions: Array<{ threadId: string; state: AttemptState }>;
|
|
96
|
+
resolutions: Array<{ threadId: string; step: "reply" | "resolve"; state: AttemptState }>;
|
|
90
97
|
finalize: AttemptState;
|
|
91
98
|
};
|
|
92
99
|
};
|
|
@@ -96,12 +103,15 @@ type SweepAuthority = ReturnType<typeof authorityFromCurrent>;
|
|
|
96
103
|
type ResolutionAttempt = {
|
|
97
104
|
generation: number;
|
|
98
105
|
threadId: string;
|
|
106
|
+
step: "reply" | "resolve";
|
|
107
|
+
body: string | null;
|
|
108
|
+
replyId: string | null;
|
|
99
109
|
state: AttemptState;
|
|
100
110
|
beforeFingerprint: string;
|
|
101
111
|
afterFingerprint: string | null;
|
|
102
112
|
};
|
|
103
113
|
type SweepState = {
|
|
104
|
-
version: 1;
|
|
114
|
+
version: 1 | 2;
|
|
105
115
|
workflow: "pi-pr-comment-sweep";
|
|
106
116
|
worktree: { id: string; root: string };
|
|
107
117
|
epoch: number;
|
|
@@ -116,6 +126,8 @@ type SweepState = {
|
|
|
116
126
|
snapshot: FeedbackSnapshot;
|
|
117
127
|
};
|
|
118
128
|
ledger: SweepLedgerEntry[] | null;
|
|
129
|
+
approved: boolean;
|
|
130
|
+
approvalGeneration: number | null;
|
|
119
131
|
ownedPaths: string[];
|
|
120
132
|
publicationHead: string | null;
|
|
121
133
|
projection: SweepFinalProjection | null;
|
|
@@ -216,10 +228,10 @@ function parseAuthority(value: unknown): SweepAuthority {
|
|
|
216
228
|
};
|
|
217
229
|
}
|
|
218
230
|
|
|
219
|
-
function sameLinkage(expected: SweepAuthority, current: SweepAuthority, remoteHead: string): boolean {
|
|
231
|
+
function sameLinkage(expected: SweepAuthority, current: SweepAuthority, remoteHead: string, allowBaseDrift = false): boolean {
|
|
220
232
|
return expected.id === current.id && expected.number === current.number && expected.url === current.url &&
|
|
221
233
|
expected.host === current.host && expected.base.repository === current.base.repository &&
|
|
222
|
-
expected.base.ref === current.base.ref && expected.base.oid === current.base.oid &&
|
|
234
|
+
expected.base.ref === current.base.ref && (allowBaseDrift || expected.base.oid === current.base.oid) &&
|
|
223
235
|
expected.head.repository === current.head.repository && expected.head.ref === current.head.ref &&
|
|
224
236
|
expected.headFetchSource === current.headFetchSource && expected.target.branch === current.target.branch &&
|
|
225
237
|
expected.target.remote === current.target.remote && expected.target.ref === current.target.ref &&
|
|
@@ -235,7 +247,8 @@ function recoveryMatchesRouteAuthority(state: SweepState, suppliedAuthority: Swe
|
|
|
235
247
|
(state.attempts.push.state === "attempting" || state.attempts.push.state === "unknown" || state.attempts.push.state === "applied") &&
|
|
236
248
|
state.publicationHead
|
|
237
249
|
) permittedHeads.add(state.publicationHead);
|
|
238
|
-
return [...permittedHeads].some((head) => sameLinkage(state.authority, suppliedAuthority, head
|
|
250
|
+
return [...permittedHeads].some((head) => sameLinkage(state.authority, suppliedAuthority, head,
|
|
251
|
+
state.attempts.push.state === "applied"));
|
|
239
252
|
}
|
|
240
253
|
|
|
241
254
|
function feedbackMatchesAuthority(snapshot: FeedbackSnapshot, authority: SweepAuthority, head: string): boolean {
|
|
@@ -303,7 +316,7 @@ function parseChecks(value: unknown): SweepCheck[] {
|
|
|
303
316
|
return checks;
|
|
304
317
|
}
|
|
305
318
|
|
|
306
|
-
function buildProjection(generation: number, snapshot: FeedbackSnapshot, ledger: SweepLedgerEntry[]): SweepFinalProjection {
|
|
319
|
+
function buildProjection(generation: number, snapshot: FeedbackSnapshot, ledger: SweepLedgerEntry[], resolveNonActionable = true, legacyChildren = false): SweepFinalProjection {
|
|
307
320
|
const dispositions = new Map(ledger.map((entry) => [entry.id, entry]));
|
|
308
321
|
return {
|
|
309
322
|
generation,
|
|
@@ -312,7 +325,11 @@ function buildProjection(generation: number, snapshot: FeedbackSnapshot, ledger:
|
|
|
312
325
|
left.id.localeCompare(right.id) || left.kind.localeCompare(right.kind)),
|
|
313
326
|
threads: snapshot.reviewThreads.map((thread) => ({
|
|
314
327
|
id: thread.id,
|
|
315
|
-
isResolved: thread.isResolved ||
|
|
328
|
+
isResolved: thread.isResolved || (
|
|
329
|
+
(legacyChildren || !thread.comments.some((comment) => dispositions.get(comment.id)?.disposition === "blocked")) &&
|
|
330
|
+
(dispositions.get(thread.id)?.disposition === "addressed" ||
|
|
331
|
+
(resolveNonActionable && dispositions.get(thread.id)?.disposition === "non-actionable"))
|
|
332
|
+
),
|
|
316
333
|
})).sort((left, right) => left.id.localeCompare(right.id)),
|
|
317
334
|
};
|
|
318
335
|
}
|
|
@@ -352,12 +369,25 @@ function projectionMatches(snapshot: FeedbackSnapshot, projection: SweepFinalPro
|
|
|
352
369
|
return isDeepStrictEqual(items, projection.items) && isDeepStrictEqual(threads, projection.threads);
|
|
353
370
|
}
|
|
354
371
|
|
|
355
|
-
function resolutionAttempt(value: unknown, index: number): ResolutionAttempt {
|
|
372
|
+
function resolutionAttempt(value: unknown, index: number, version: number): ResolutionAttempt {
|
|
356
373
|
if (!isRecord(value)) throw new Error(`resolution attempt ${index + 1} is invalid`);
|
|
357
|
-
|
|
374
|
+
const legacy = version === 1 && !("step" in value);
|
|
375
|
+
exactKeys(value, legacy
|
|
376
|
+
? ["generation", "threadId", "state", "beforeFingerprint", "afterFingerprint"]
|
|
377
|
+
: ["generation", "threadId", "step", "body", ...("replyId" in value ? ["replyId"] : []), "state", "beforeFingerprint", "afterFingerprint"], `resolution attempt ${index + 1}`);
|
|
378
|
+
const step = legacy ? "resolve" : value.step;
|
|
379
|
+
const body = legacy ? null : value.body;
|
|
380
|
+
if (step !== "reply" && step !== "resolve") throw new Error(`resolution attempt ${index + 1} step is invalid`);
|
|
381
|
+
if (step === "reply" ? typeof body !== "string" || !body : body !== null) {
|
|
382
|
+
throw new Error(`resolution attempt ${index + 1} body is invalid`);
|
|
383
|
+
}
|
|
384
|
+
if (step !== "reply" && value.replyId != null) throw new Error(`resolution attempt ${index + 1} reply ID is invalid`);
|
|
358
385
|
return {
|
|
359
386
|
generation: integer(value.generation, `resolution attempt ${index + 1} generation`),
|
|
360
387
|
threadId: requiredText(value.threadId, `resolution attempt ${index + 1} thread ID`),
|
|
388
|
+
step,
|
|
389
|
+
body: body as string | null,
|
|
390
|
+
replyId: value.replyId === undefined || value.replyId === null ? null : requiredText(value.replyId, `resolution attempt ${index + 1} reply ID`),
|
|
361
391
|
state: attemptState(value.state, `resolution attempt ${index + 1} state`),
|
|
362
392
|
beforeFingerprint: sha256(requiredText(value.beforeFingerprint, `resolution attempt ${index + 1} before fingerprint`), `resolution attempt ${index + 1} before fingerprint`),
|
|
363
393
|
afterFingerprint: value.afterFingerprint === null
|
|
@@ -373,9 +403,10 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
|
|
|
373
403
|
}
|
|
374
404
|
exactKeys(value, [
|
|
375
405
|
"version", "workflow", "worktree", "epoch", "runId", "phase", "authority", "original", "feedback",
|
|
376
|
-
"ledger",
|
|
406
|
+
"ledger", ...(value.version === 1 && !("approved" in value) ? [] : ["approved"]),
|
|
407
|
+
...("approvalGeneration" in value ? ["approvalGeneration"] : []), "ownedPaths", "publicationHead", "projection", "attempts",
|
|
377
408
|
], "sweep recovery state");
|
|
378
|
-
if (value.version !== STATE_VERSION || value.workflow !== "pi-pr-comment-sweep") throw new Error("unsupported sweep recovery state version");
|
|
409
|
+
if ((value.version !== 1 && value.version !== STATE_VERSION) || value.workflow !== "pi-pr-comment-sweep") throw new Error("unsupported sweep recovery state version");
|
|
379
410
|
if (!(["triage", "recorded", "published", "refresh-pending", "refreshed", "resolving", "resolved"] as unknown[]).includes(value.phase)) {
|
|
380
411
|
throw new Error("sweep recovery phase is invalid");
|
|
381
412
|
}
|
|
@@ -404,6 +435,13 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
|
|
|
404
435
|
throw new Error("sweep feedback fingerprints do not match the complete snapshot");
|
|
405
436
|
}
|
|
406
437
|
const ledger = value.ledger === null ? null : exactLedger(value.ledger, snapshot);
|
|
438
|
+
const approved = value.version === 1 && !("approved" in value) ? false : value.approved;
|
|
439
|
+
if (typeof approved !== "boolean") throw new Error("sweep approval is invalid");
|
|
440
|
+
const approvalGeneration = value.approvalGeneration === undefined || value.approvalGeneration === null
|
|
441
|
+
? null : integer(value.approvalGeneration, "sweep approval generation");
|
|
442
|
+
// Old recovery could mark a refreshed ledger approved using only the original plan's approval.
|
|
443
|
+
const freshApproved = ["refreshed", "resolving", "resolved"].includes(value.phase as string)
|
|
444
|
+
? approved && approvalGeneration === feedback.generation : approved;
|
|
407
445
|
const ownedPaths = parseOwnedPaths(value.ownedPaths);
|
|
408
446
|
const publicationHead = value.publicationHead === null ? null : requiredOid(value.publicationHead, "publication head");
|
|
409
447
|
const projection = value.projection === null ? null : parseProjection(value.projection);
|
|
@@ -419,8 +457,8 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
|
|
|
419
457
|
if (!Array.isArray(value.attempts.resolutions) || value.attempts.resolutions.length > FEEDBACK_MAX_RECORDS) {
|
|
420
458
|
throw new Error("resolution attempts are invalid");
|
|
421
459
|
}
|
|
422
|
-
const resolutions = value.attempts.resolutions.map(resolutionAttempt);
|
|
423
|
-
if (new Set(resolutions.map(({ generation, threadId }) => `${generation}\0${threadId}`)).size !== resolutions.length) {
|
|
460
|
+
const resolutions = value.attempts.resolutions.map((attempt, index) => resolutionAttempt(attempt, index, value.version as number));
|
|
461
|
+
if (new Set(resolutions.map(({ generation, threadId, step }) => `${generation}\0${threadId}\0${step}`)).size !== resolutions.length) {
|
|
424
462
|
throw new Error("resolution attempts contain duplicate thread IDs");
|
|
425
463
|
}
|
|
426
464
|
exactKeys(value.attempts.finalize, ["state", "checks"], "finalize attempt");
|
|
@@ -429,7 +467,7 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
|
|
|
429
467
|
checks: parseChecks(value.attempts.finalize.checks),
|
|
430
468
|
};
|
|
431
469
|
const state: SweepState = {
|
|
432
|
-
version: 1,
|
|
470
|
+
version: value.version as 1 | 2,
|
|
433
471
|
workflow: "pi-pr-comment-sweep",
|
|
434
472
|
worktree: { id, root },
|
|
435
473
|
epoch: integer(value.epoch, "sweep epoch"),
|
|
@@ -439,6 +477,8 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
|
|
|
439
477
|
original,
|
|
440
478
|
feedback,
|
|
441
479
|
ledger,
|
|
480
|
+
approved: freshApproved,
|
|
481
|
+
approvalGeneration,
|
|
442
482
|
ownedPaths,
|
|
443
483
|
publicationHead,
|
|
444
484
|
projection,
|
|
@@ -461,7 +501,8 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
|
|
|
461
501
|
throw new Error("push attempt head does not match publication head");
|
|
462
502
|
}
|
|
463
503
|
if (hasFinalProjection !== (projection !== null)) throw new Error("sweep phase and final projection are inconsistent");
|
|
464
|
-
if (projection && (!ledger || !isDeepStrictEqual(projection, buildProjection(feedback.generation, snapshot, ledger))
|
|
504
|
+
if (projection && (!ledger || (!isDeepStrictEqual(projection, buildProjection(feedback.generation, snapshot, ledger)) &&
|
|
505
|
+
!(value.version === 1 && isDeepStrictEqual(projection, buildProjection(feedback.generation, snapshot, ledger, false, true)))))) {
|
|
465
506
|
throw new Error("final projection does not match feedback and ledger coverage");
|
|
466
507
|
}
|
|
467
508
|
return state;
|
|
@@ -494,15 +535,49 @@ function status(state: SweepState): SweepStatus {
|
|
|
494
535
|
feedbackCount: feedback.length,
|
|
495
536
|
feedback,
|
|
496
537
|
ledgerComplete: state.ledger !== null,
|
|
538
|
+
plan: state.ledger ? { ledger: structuredClone(state.ledger), ownedPaths: [...state.ownedPaths] } : null,
|
|
539
|
+
approved: state.approved,
|
|
540
|
+
legacyRecovery: state.version === 1,
|
|
497
541
|
projection: state.projection ? structuredClone(state.projection) : null,
|
|
498
542
|
attempts: {
|
|
499
543
|
push: state.attempts.push.state,
|
|
500
|
-
resolutions: state.attempts.resolutions.map(({ threadId, state }) => ({ threadId, state })),
|
|
544
|
+
resolutions: state.attempts.resolutions.map(({ threadId, step, state }) => ({ threadId, step, state })),
|
|
501
545
|
finalize: state.attempts.finalize.state,
|
|
502
546
|
},
|
|
503
547
|
};
|
|
504
548
|
}
|
|
505
549
|
|
|
550
|
+
function addedReply(before: FeedbackSnapshot, after: FeedbackSnapshot, threadId: string, body: string, replyId: string): boolean {
|
|
551
|
+
const previous = before.reviewThreads.find((thread) => thread.id === threadId);
|
|
552
|
+
const current = after.reviewThreads.find((thread) => thread.id === threadId);
|
|
553
|
+
const reply = current?.comments.find(({ id }) => id === replyId);
|
|
554
|
+
return !!previous && !!reply && reply.body === body && !feedbackEntries(before).some(({ id }) => id === replyId);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function carryLedger(before: FeedbackSnapshot, after: FeedbackSnapshot, ledger: SweepLedgerEntry[], replyId?: string): SweepLedgerEntry[] {
|
|
558
|
+
const prior = new Map(feedbackEntries(before).map((entry) => [entry.id, entry]));
|
|
559
|
+
const decisions = new Map(ledger.map((entry) => [entry.id, entry]));
|
|
560
|
+
return exactLedger(feedbackEntries(after).map((entry) => {
|
|
561
|
+
const old = prior.get(entry.id);
|
|
562
|
+
const decision = decisions.get(entry.id);
|
|
563
|
+
if (old && decision && old.kind === entry.kind) {
|
|
564
|
+
if (entry.kind !== "thread" && isDeepStrictEqual(old.node, entry.node)) return decision;
|
|
565
|
+
if (entry.kind === "thread") {
|
|
566
|
+
const { comments: _a, isResolved: _b, ...previous } = old.node as FeedbackSnapshot["reviewThreads"][number];
|
|
567
|
+
const { comments: _c, isResolved: _d, ...current } = entry.node as FeedbackSnapshot["reviewThreads"][number];
|
|
568
|
+
if (isDeepStrictEqual(previous, current)) return decision;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const emptyReview = !old && entry.kind === "review" && "state" in entry.node &&
|
|
572
|
+
entry.node.state !== "CHANGES_REQUESTED" && !entry.node.body;
|
|
573
|
+
return { id: entry.id, kind: entry.kind,
|
|
574
|
+
disposition: entry.id === replyId || emptyReview ? "non-actionable" as const : "blocked" as const,
|
|
575
|
+
note: entry.id === replyId ? "Sweep acknowledgement" : emptyReview ? "Empty review body" :
|
|
576
|
+
"Feedback changed after publication; revisit in the next fix cycle",
|
|
577
|
+
};
|
|
578
|
+
}), after);
|
|
579
|
+
}
|
|
580
|
+
|
|
506
581
|
function onlyResolutionChanged(before: FeedbackSnapshot, after: FeedbackSnapshot, threadId: string): boolean {
|
|
507
582
|
if (feedbackContentFingerprint(before) !== feedbackContentFingerprint(after)) return false;
|
|
508
583
|
const beforeStates = before.reviewThreads.map(({ id, isResolved }) => ({ id, isResolved })).sort((left, right) => left.id.localeCompare(right.id));
|
|
@@ -609,11 +684,11 @@ export class PullRequestCommentSweep {
|
|
|
609
684
|
await writePrivateTextFileAtomically(location.path, contents, { signal: this.signal });
|
|
610
685
|
}
|
|
611
686
|
|
|
612
|
-
private async currentAuthority(expected: SweepAuthority, remoteHead: string): Promise<CurrentPullRequest> {
|
|
687
|
+
private async currentAuthority(expected: SweepAuthority, remoteHead: string, allowBaseDrift = false): Promise<CurrentPullRequest> {
|
|
613
688
|
const discovery = await this.load(this.pi(), this.context());
|
|
614
689
|
if (discovery.kind !== "current") throw new Error("Comment sweep cancelled: current pull request authority is unavailable");
|
|
615
690
|
const current = authorityFromCurrent(discovery.pullRequest);
|
|
616
|
-
if (!sameLinkage(expected, current, remoteHead)) throw new Error("Comment sweep cancelled: canonical pull request authority changed");
|
|
691
|
+
if (!sameLinkage(expected, current, remoteHead, allowBaseDrift)) throw new Error("Comment sweep cancelled: canonical pull request authority changed");
|
|
617
692
|
const remote = await readRemoteOid(this.exec, this.options(), expected.target.fetchSource, expected.target.ref);
|
|
618
693
|
if (remote !== remoteHead) throw new Error("Comment sweep cancelled: remote lease changed");
|
|
619
694
|
return discovery.pullRequest;
|
|
@@ -668,6 +743,14 @@ export class PullRequestCommentSweep {
|
|
|
668
743
|
return snapshot;
|
|
669
744
|
}
|
|
670
745
|
|
|
746
|
+
private applyReply(state: SweepState, after: FeedbackSnapshot, threadId: string, replyId: string): void {
|
|
747
|
+
const parent = state.ledger?.find((entry) => entry.id === threadId);
|
|
748
|
+
if (!parent || !state.ledger || !state.projection) throw new Error("Reply has no recorded thread disposition");
|
|
749
|
+
state.ledger = carryLedger(state.feedback.snapshot, after, state.ledger, replyId);
|
|
750
|
+
this.setFeedback(state, after);
|
|
751
|
+
state.projection = buildProjection(state.feedback.generation, after, state.ledger);
|
|
752
|
+
}
|
|
753
|
+
|
|
671
754
|
private setFeedback(state: SweepState, snapshot: FeedbackSnapshot, generation = state.feedback.generation): void {
|
|
672
755
|
state.feedback = {
|
|
673
756
|
generation,
|
|
@@ -694,7 +777,7 @@ export class PullRequestCommentSweep {
|
|
|
694
777
|
throw new Error("Comment sweep authority changed during feedback fetch");
|
|
695
778
|
}
|
|
696
779
|
const state: SweepState = {
|
|
697
|
-
version:
|
|
780
|
+
version: 2,
|
|
698
781
|
workflow: "pi-pr-comment-sweep",
|
|
699
782
|
worktree: { id: location.id, root: location.root },
|
|
700
783
|
epoch: 1,
|
|
@@ -709,6 +792,8 @@ export class PullRequestCommentSweep {
|
|
|
709
792
|
snapshot,
|
|
710
793
|
},
|
|
711
794
|
ledger: null,
|
|
795
|
+
approved: false,
|
|
796
|
+
approvalGeneration: null,
|
|
712
797
|
ownedPaths: [],
|
|
713
798
|
publicationHead: null,
|
|
714
799
|
projection: null,
|
|
@@ -744,7 +829,7 @@ export class PullRequestCommentSweep {
|
|
|
744
829
|
: state.original.lease;
|
|
745
830
|
remote = await readRemoteOid(this.exec, this.options(), state.authority.target.fetchSource, state.authority.target.ref);
|
|
746
831
|
if (remote !== expectedRemote) throw new Error("Comment sweep remote authority cannot be reconciled");
|
|
747
|
-
await this.currentAuthority(state.authority, expectedRemote);
|
|
832
|
+
await this.currentAuthority(state.authority, expectedRemote, state.attempts.push.state === "applied");
|
|
748
833
|
|
|
749
834
|
const pending = state.attempts.resolutions.filter(({ state: attempt }) => attempt === "attempting" || attempt === "unknown");
|
|
750
835
|
if (pending.length > 1) throw new Error("Multiple unresolved mutation attempts cannot be reconciled");
|
|
@@ -755,15 +840,23 @@ export class PullRequestCommentSweep {
|
|
|
755
840
|
}
|
|
756
841
|
const current = await this.collect(state.authority, state.publicationHead);
|
|
757
842
|
const thread = current.reviewThreads.find(({ id }) => id === attempt.threadId);
|
|
758
|
-
if (!thread || feedbackContentFingerprint(current) !== state.feedback.contentFingerprint) {
|
|
843
|
+
if (!thread || (attempt.step === "resolve" && feedbackContentFingerprint(current) !== state.feedback.contentFingerprint)) {
|
|
759
844
|
throw new Error("Resolution attempt feedback generation changed during recovery");
|
|
760
845
|
}
|
|
761
|
-
if (
|
|
846
|
+
if (attempt.step === "reply" && attempt.replyId &&
|
|
847
|
+
addedReply(state.feedback.snapshot, current, attempt.threadId, attempt.body!, attempt.replyId)) {
|
|
848
|
+
this.applyReply(state, current, attempt.threadId, attempt.replyId);
|
|
849
|
+
attempt.state = "applied";
|
|
850
|
+
attempt.afterFingerprint = state.feedback.fingerprint;
|
|
851
|
+
} else if (feedbackFingerprint(current) === state.feedback.fingerprint) {
|
|
852
|
+
attempt.state = "blocked";
|
|
853
|
+
} else if (attempt.step === "reply") {
|
|
854
|
+
// Without the returned ID an identical comment cannot prove this attempt succeeded.
|
|
855
|
+
throw new Error("Reply attempt outcome is ambiguous; recovery is preserved without replaying the reply");
|
|
856
|
+
} else if (thread.isResolved && onlyResolutionChanged(state.feedback.snapshot, current, attempt.threadId)) {
|
|
762
857
|
attempt.state = "applied";
|
|
763
858
|
attempt.afterFingerprint = feedbackFingerprint(current);
|
|
764
859
|
this.setFeedback(state, current);
|
|
765
|
-
} else if (!thread.isResolved && feedbackFingerprint(current) === state.feedback.fingerprint) {
|
|
766
|
-
attempt.state = "blocked";
|
|
767
860
|
} else {
|
|
768
861
|
throw new Error("Resolution attempt outcome is not exactly reconcilable");
|
|
769
862
|
}
|
|
@@ -780,6 +873,10 @@ export class PullRequestCommentSweep {
|
|
|
780
873
|
if (!recoveryMatchesRouteAuthority(state, this.suppliedAuthority)) {
|
|
781
874
|
throw new Error(`Comment sweep recovery is preserved at ${location.path}: recovery does not match supplied route authority`);
|
|
782
875
|
}
|
|
876
|
+
if (state.version === 1 && state.projection && state.ledger) {
|
|
877
|
+
// Version-one projections could resolve a parent despite a blocked child.
|
|
878
|
+
state.projection = buildProjection(state.feedback.generation, state.feedback.snapshot, state.ledger);
|
|
879
|
+
}
|
|
783
880
|
await this.reconcile(state);
|
|
784
881
|
state.attempts.resolutions = state.attempts.resolutions.filter(({ state: attempt }) => attempt !== "blocked");
|
|
785
882
|
if (state.attempts.push.state === "blocked") {
|
|
@@ -788,9 +885,14 @@ export class PullRequestCommentSweep {
|
|
|
788
885
|
}
|
|
789
886
|
if (state.attempts.finalize.state === "blocked") state.attempts.finalize = { state: "none", checks: [] };
|
|
790
887
|
const published = state.attempts.push.state === "applied";
|
|
888
|
+
if (published) state.version = 2;
|
|
791
889
|
const expectedRemote = published ? state.publicationHead! : state.original.lease;
|
|
792
|
-
await this.currentAuthority(state.authority, expectedRemote);
|
|
890
|
+
await this.currentAuthority(state.authority, expectedRemote, published);
|
|
793
891
|
await this.requireOwnedLocalState(state, published ? expectedRemote : undefined);
|
|
892
|
+
if (state.ledger) {
|
|
893
|
+
state.approved = true;
|
|
894
|
+
state.approvalGeneration = state.feedback.generation;
|
|
895
|
+
}
|
|
794
896
|
state.epoch += 1;
|
|
795
897
|
state.runId = safeRunId(this.newRunId());
|
|
796
898
|
await this.save(location, state);
|
|
@@ -814,12 +916,20 @@ export class PullRequestCommentSweep {
|
|
|
814
916
|
requireGuard(state, guard);
|
|
815
917
|
if (state.phase === "triage" && state.ledger === null) {
|
|
816
918
|
if (ownedPathsInput === undefined) throw new Error("Initial comment sweep ledger requires ownedPaths");
|
|
919
|
+
await this.requireCleanPublication(state, state.original.head);
|
|
920
|
+
await this.currentAuthority(state.authority, state.original.lease);
|
|
817
921
|
state.ledger = exactLedger(ledgerInput, state.feedback.snapshot);
|
|
922
|
+
state.approved = true;
|
|
923
|
+
state.approvalGeneration = state.feedback.generation;
|
|
818
924
|
state.ownedPaths = parseOwnedPaths(ownedPathsInput);
|
|
819
925
|
state.phase = "recorded";
|
|
820
926
|
} else if (state.phase === "refresh-pending" && state.ledger === null) {
|
|
821
927
|
if (ownedPathsInput !== undefined) throw new Error("Refreshed comment sweep ledger cannot change ownedPaths");
|
|
928
|
+
await this.requireCleanPublication(state, state.publicationHead!);
|
|
929
|
+
await this.currentAuthority(state.authority, state.publicationHead!);
|
|
822
930
|
state.ledger = exactLedger(ledgerInput, state.feedback.snapshot);
|
|
931
|
+
state.approved = true;
|
|
932
|
+
state.approvalGeneration = state.feedback.generation;
|
|
823
933
|
state.projection = buildProjection(state.feedback.generation, state.feedback.snapshot, state.ledger);
|
|
824
934
|
state.phase = "refreshed";
|
|
825
935
|
} else {
|
|
@@ -835,7 +945,7 @@ export class PullRequestCommentSweep {
|
|
|
835
945
|
const location = await this.location();
|
|
836
946
|
const state = await this.loadState(location);
|
|
837
947
|
requireGuard(state, guard);
|
|
838
|
-
if (state.phase !== "recorded" || !state.ledger || state.attempts.push.state !== "none") {
|
|
948
|
+
if (state.phase !== "recorded" || !state.ledger || state.attempts.push.state !== "none" || !state.approved) {
|
|
839
949
|
throw new Error("Comment sweep is not ready to publish");
|
|
840
950
|
}
|
|
841
951
|
const head = await this.requireOwnedLocalState(state);
|
|
@@ -845,6 +955,7 @@ export class PullRequestCommentSweep {
|
|
|
845
955
|
state.publicationHead = head;
|
|
846
956
|
state.attempts.push = { state: "applied", head };
|
|
847
957
|
state.phase = "published";
|
|
958
|
+
state.version = 2;
|
|
848
959
|
await this.save(location, state);
|
|
849
960
|
return status(state);
|
|
850
961
|
}
|
|
@@ -870,6 +981,7 @@ export class PullRequestCommentSweep {
|
|
|
870
981
|
await this.currentAuthority(state.authority, head);
|
|
871
982
|
state.attempts.push.state = "applied";
|
|
872
983
|
state.phase = "published";
|
|
984
|
+
state.version = 2;
|
|
873
985
|
await this.save(location, state);
|
|
874
986
|
return status(state);
|
|
875
987
|
} catch (error) {
|
|
@@ -895,45 +1007,90 @@ export class PullRequestCommentSweep {
|
|
|
895
1007
|
throw new Error("Comment sweep has an unreconciled finalization attempt; use resume");
|
|
896
1008
|
}
|
|
897
1009
|
await this.requireCleanPublication(state, state.publicationHead);
|
|
898
|
-
await this.currentAuthority(state.authority, state.publicationHead);
|
|
899
|
-
const
|
|
900
|
-
await this.
|
|
1010
|
+
const current = await this.currentAuthority(state.authority, state.publicationHead, true);
|
|
1011
|
+
const authority = { ...state.authority, base: { ...state.authority.base, oid: current.base.oid } };
|
|
1012
|
+
const snapshot = await this.collect(authority, state.publicationHead);
|
|
1013
|
+
await this.currentAuthority(authority, state.publicationHead);
|
|
901
1014
|
await this.requireCleanPublication(state, state.publicationHead);
|
|
1015
|
+
const ledger = state.ledger ? carryLedger(state.feedback.snapshot, snapshot, state.ledger) : null;
|
|
1016
|
+
state.authority = authority;
|
|
902
1017
|
this.setFeedback(state, snapshot, state.feedback.generation + 1);
|
|
903
|
-
state.ledger =
|
|
904
|
-
state.
|
|
905
|
-
state.
|
|
1018
|
+
state.ledger = ledger;
|
|
1019
|
+
state.approved = ledger !== null;
|
|
1020
|
+
state.approvalGeneration = ledger ? state.feedback.generation : null;
|
|
1021
|
+
state.projection = ledger ? buildProjection(state.feedback.generation, snapshot, ledger) : null;
|
|
906
1022
|
state.attempts.finalize = { state: "none", checks: [] };
|
|
907
|
-
state.phase = "refresh-pending";
|
|
1023
|
+
state.phase = ledger ? "refreshed" : "refresh-pending";
|
|
908
1024
|
await this.save(location, state);
|
|
909
1025
|
return status(state);
|
|
910
1026
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
911
1027
|
}
|
|
912
1028
|
|
|
913
|
-
async resolve(guard: SweepRunGuard, threadIdsInput
|
|
1029
|
+
async resolve(guard: SweepRunGuard, threadIdsInput?: string[]): Promise<SweepStatus> {
|
|
914
1030
|
return await withWorktreeLock(this.cwd, async () => {
|
|
915
1031
|
const location = await this.location();
|
|
916
1032
|
const state = await this.loadState(location);
|
|
917
1033
|
requireGuard(state, guard);
|
|
918
|
-
if (!["refreshed", "resolving"].includes(state.phase) || !state.publicationHead || !state.ledger || !state.projection) {
|
|
1034
|
+
if (!["refreshed", "resolving", "resolved"].includes(state.phase) || !state.publicationHead || !state.ledger || !state.projection || !state.approved) {
|
|
919
1035
|
throw new Error("Comment sweep is not ready to resolve threads");
|
|
920
1036
|
}
|
|
921
1037
|
if (state.attempts.resolutions.some(({ state: attempt }) => attempt !== "applied")) {
|
|
922
1038
|
throw new Error("Comment sweep has an unreconciled thread mutation; use resume");
|
|
923
1039
|
}
|
|
924
|
-
if (!Array.isArray(threadIdsInput) || threadIdsInput.length > FEEDBACK_MAX_RECORDS)
|
|
925
|
-
|
|
926
|
-
|
|
1040
|
+
if (threadIdsInput !== undefined && (!Array.isArray(threadIdsInput) || threadIdsInput.length > FEEDBACK_MAX_RECORDS)) {
|
|
1041
|
+
throw new Error("threadIds must be a bounded array");
|
|
1042
|
+
}
|
|
927
1043
|
const ledger = new Map(state.ledger.map((entry) => [entry.id, entry]));
|
|
1044
|
+
const threadIds = (threadIdsInput ?? state.feedback.snapshot.reviewThreads.filter((thread) =>
|
|
1045
|
+
!thread.isResolved && ["addressed", "non-actionable"].includes(ledger.get(thread.id)?.disposition ?? "blocked") &&
|
|
1046
|
+
!thread.comments.some((comment) => ledger.get(comment.id)?.disposition === "blocked")
|
|
1047
|
+
).map(({ id }) => id)).map((id, index) => requiredText(id, `thread ID ${index + 1}`));
|
|
1048
|
+
if (new Set(threadIds).size !== threadIds.length) throw new Error("threadIds contain duplicates");
|
|
928
1049
|
for (const threadId of threadIds) {
|
|
929
1050
|
const thread = state.feedback.snapshot.reviewThreads.find(({ id }) => id === threadId);
|
|
930
|
-
|
|
931
|
-
|
|
1051
|
+
const disposition = ledger.get(threadId)?.disposition;
|
|
1052
|
+
if (!thread || thread.isResolved || ledger.get(threadId)?.kind !== "thread" ||
|
|
1053
|
+
(disposition !== "addressed" && disposition !== "non-actionable")) {
|
|
1054
|
+
throw new Error(`Only addressed or non-actionable unresolved review thread IDs may be resolved: ${threadId}`);
|
|
1055
|
+
}
|
|
1056
|
+
if (state.attempts.resolutions.some((attempt) => attempt.generation === state.feedback.generation && attempt.threadId === threadId && attempt.step === "resolve")) {
|
|
1057
|
+
throw new Error(`Review thread resolution was already attempted: ${threadId}`);
|
|
1058
|
+
}
|
|
1059
|
+
if (disposition === "non-actionable" && !ledger.get(threadId)!.note.trim()) {
|
|
1060
|
+
throw new Error(`Non-actionable review thread needs a reason: ${threadId}`);
|
|
932
1061
|
}
|
|
933
|
-
if (
|
|
934
|
-
throw new Error(`Review thread
|
|
1062
|
+
if (thread.comments.some((comment) => ledger.get(comment.id)?.disposition === "blocked")) {
|
|
1063
|
+
throw new Error(`Review thread has blocked child feedback: ${threadId}`);
|
|
935
1064
|
}
|
|
936
1065
|
}
|
|
1066
|
+
const hasReply = (threadId: string) => state.attempts.resolutions.some((attempt) =>
|
|
1067
|
+
attempt.threadId === threadId && attempt.step === "reply" && attempt.state === "applied" &&
|
|
1068
|
+
(attempt.generation === state.feedback.generation || !!state.feedback.snapshot.reviewThreads.find(({ id }) => id === threadId)
|
|
1069
|
+
?.comments.some(({ id }) => id === attempt.replyId)));
|
|
1070
|
+
for (const threadId of threadIds) {
|
|
1071
|
+
const oldReplies = state.attempts.resolutions.filter((attempt) =>
|
|
1072
|
+
attempt.threadId === threadId && attempt.step === "reply" && attempt.state === "applied");
|
|
1073
|
+
if (oldReplies.some((attempt) => !attempt.replyId && attempt.generation !== state.feedback.generation)) {
|
|
1074
|
+
throw new Error(`Prior reply has no verified ID after refresh: ${threadId}`);
|
|
1075
|
+
}
|
|
1076
|
+
if (oldReplies.some((attempt) => attempt.replyId && !state.feedback.snapshot.reviewThreads.find(({ id }) => id === threadId)
|
|
1077
|
+
?.comments.some(({ id }) => id === attempt.replyId))) {
|
|
1078
|
+
throw new Error(`Verified reply is missing from fresh feedback: ${threadId}`);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
const replyBodies = new Map(threadIds.filter((threadId) => !hasReply(threadId)).map((threadId) => {
|
|
1082
|
+
const entry = ledger.get(threadId)!;
|
|
1083
|
+
return [threadId, entry.disposition === "addressed"
|
|
1084
|
+
? `${new URL(state.authority.url).origin}/${state.authority.base.repository}/commit/${state.publicationHead}`
|
|
1085
|
+
: entry.note.trim()] as const;
|
|
1086
|
+
}));
|
|
1087
|
+
const reserveBytes = [...replyBodies.values()].reduce((total, body) =>
|
|
1088
|
+
total + REPLY_METADATA_RESERVE_BYTES + 2 * Buffer.byteLength(body, "utf8"), 0);
|
|
1089
|
+
if (feedbackEntries(state.feedback.snapshot).length + replyBodies.size > FEEDBACK_MAX_RECORDS ||
|
|
1090
|
+
Buffer.byteLength(JSON.stringify(state.feedback.snapshot), "utf8") + reserveBytes > FEEDBACK_SNAPSHOT_MAX_BYTES ||
|
|
1091
|
+
Buffer.byteLength(JSON.stringify(state), "utf8") + 2 * reserveBytes > SWEEP_RECOVERY_MAX_BYTES) {
|
|
1092
|
+
throw new Error("Comment sweep has insufficient feedback capacity for review thread replies");
|
|
1093
|
+
}
|
|
937
1094
|
for (const threadId of threadIds) {
|
|
938
1095
|
await this.requireCleanPublication(state, state.publicationHead);
|
|
939
1096
|
await this.currentAuthority(state.authority, state.publicationHead);
|
|
@@ -945,12 +1102,48 @@ export class PullRequestCommentSweep {
|
|
|
945
1102
|
await this.requireCleanPublication(state, state.publicationHead);
|
|
946
1103
|
const thread = before.reviewThreads.find(({ id }) => id === threadId);
|
|
947
1104
|
if (!thread || thread.isResolved) throw new Error(`Review thread is no longer unresolved: ${threadId}`);
|
|
1105
|
+
if (thread.comments.some((comment) => state.ledger!.find(({ id }) => id === comment.id)?.disposition === "blocked")) continue;
|
|
1106
|
+
const replied = hasReply(threadId);
|
|
1107
|
+
if (!replied) {
|
|
1108
|
+
const body = replyBodies.get(threadId)!;
|
|
1109
|
+
const replyAttempt: ResolutionAttempt = {
|
|
1110
|
+
generation: state.feedback.generation, threadId, step: "reply", body, replyId: null,
|
|
1111
|
+
state: "attempting", beforeFingerprint: state.feedback.fingerprint, afterFingerprint: null,
|
|
1112
|
+
};
|
|
1113
|
+
state.attempts.resolutions.push(replyAttempt);
|
|
1114
|
+
state.phase = "resolving";
|
|
1115
|
+
await this.save(location, state);
|
|
1116
|
+
let afterReply: FeedbackSnapshot;
|
|
1117
|
+
let replyId: string;
|
|
1118
|
+
try {
|
|
1119
|
+
replyId = await replyToPullRequestThread(state.feedback.snapshot.pullRequest, threadId, body, {
|
|
1120
|
+
exec: this.exec, cwd: this.cwd, signal: this.signal, pause: this.pause,
|
|
1121
|
+
});
|
|
1122
|
+
replyAttempt.replyId = replyId;
|
|
1123
|
+
await this.save(location, state);
|
|
1124
|
+
afterReply = await this.collect(state.authority, state.publicationHead);
|
|
1125
|
+
await this.currentAuthority(state.authority, state.publicationHead);
|
|
1126
|
+
await this.requireCleanPublication(state, state.publicationHead);
|
|
1127
|
+
if (!addedReply(before, afterReply, threadId, body, replyId)) {
|
|
1128
|
+
throw new Error(`Thread reply did not produce the verified reply ID: ${threadId}`);
|
|
1129
|
+
}
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
replyAttempt.state = "unknown";
|
|
1132
|
+
await this.save(location, state);
|
|
1133
|
+
throw error;
|
|
1134
|
+
}
|
|
1135
|
+
this.applyReply(state, afterReply, threadId, replyId);
|
|
1136
|
+
replyAttempt.state = "applied";
|
|
1137
|
+
replyAttempt.afterFingerprint = state.feedback.fingerprint;
|
|
1138
|
+
await this.save(location, state);
|
|
1139
|
+
}
|
|
1140
|
+
const currentThread = state.feedback.snapshot.reviewThreads.find(({ id }) => id === threadId)!;
|
|
1141
|
+
if (currentThread.isResolved || currentThread.comments.some((comment) =>
|
|
1142
|
+
state.ledger!.find(({ id }) => id === comment.id)?.disposition === "blocked")) continue;
|
|
1143
|
+
const beforeResolution = state.feedback.snapshot;
|
|
948
1144
|
const attempt: ResolutionAttempt = {
|
|
949
|
-
generation: state.feedback.generation,
|
|
950
|
-
|
|
951
|
-
state: "attempting",
|
|
952
|
-
beforeFingerprint: state.feedback.fingerprint,
|
|
953
|
-
afterFingerprint: null,
|
|
1145
|
+
generation: state.feedback.generation, threadId, step: "resolve", body: null, replyId: null,
|
|
1146
|
+
state: "attempting", beforeFingerprint: state.feedback.fingerprint, afterFingerprint: null,
|
|
954
1147
|
};
|
|
955
1148
|
state.attempts.resolutions.push(attempt);
|
|
956
1149
|
state.phase = "resolving";
|
|
@@ -963,7 +1156,7 @@ export class PullRequestCommentSweep {
|
|
|
963
1156
|
after = await this.collect(state.authority, state.publicationHead);
|
|
964
1157
|
await this.currentAuthority(state.authority, state.publicationHead);
|
|
965
1158
|
await this.requireCleanPublication(state, state.publicationHead);
|
|
966
|
-
if (!onlyResolutionChanged(
|
|
1159
|
+
if (!onlyResolutionChanged(beforeResolution, after, threadId)) {
|
|
967
1160
|
throw new Error(`Thread resolution did not produce the exact verified transition: ${threadId}`);
|
|
968
1161
|
}
|
|
969
1162
|
} catch (error) {
|
|
@@ -996,18 +1189,15 @@ export class PullRequestCommentSweep {
|
|
|
996
1189
|
|
|
997
1190
|
async finalize(
|
|
998
1191
|
guard: SweepRunGuard,
|
|
999
|
-
projectionInput: SweepFinalProjection,
|
|
1000
1192
|
checksInput: SweepCheck[],
|
|
1001
1193
|
): Promise<{ kind: "finalized"; pullRequestUrl: string; head: string; checks: number }> {
|
|
1002
1194
|
return await withWorktreeLock(this.cwd, async () => {
|
|
1003
1195
|
const location = await this.location();
|
|
1004
1196
|
const state = await this.loadState(location);
|
|
1005
1197
|
requireGuard(state, guard);
|
|
1006
|
-
if (!["refreshed", "resolving", "resolved"].includes(state.phase) || !state.projection || !state.publicationHead) {
|
|
1198
|
+
if (!["refreshed", "resolving", "resolved"].includes(state.phase) || !state.projection || !state.publicationHead || !state.approved) {
|
|
1007
1199
|
throw new Error("Comment sweep is not ready to finalize");
|
|
1008
1200
|
}
|
|
1009
|
-
const projection = parseProjection(projectionInput);
|
|
1010
|
-
if (!isDeepStrictEqual(projection, state.projection)) throw new Error("Final projection does not match the declared refreshed projection");
|
|
1011
1201
|
const checks = parseChecks(checksInput);
|
|
1012
1202
|
if (state.attempts.resolutions.some(({ state: attempt }) => attempt !== "applied")) {
|
|
1013
1203
|
throw new Error("Comment sweep has unresolved or unknown mutation attempts");
|
|
@@ -1047,6 +1237,8 @@ export class PullRequestCommentSweep {
|
|
|
1047
1237
|
}
|
|
1048
1238
|
await this.freshProjection(state);
|
|
1049
1239
|
await this.save(location, state);
|
|
1240
|
+
await markFeedbackHandled(state.feedback.snapshot, { cwd: this.cwd, agentDir: this.agentDir, signal: this.signal, exec: this.exec,
|
|
1241
|
+
blockedIds: state.ledger!.filter(({ disposition }) => disposition === "blocked").map(({ id }) => id) });
|
|
1050
1242
|
await rm(location.path);
|
|
1051
1243
|
return { kind: "finalized", pullRequestUrl: state.authority.url, head: state.publicationHead, checks: checks.length };
|
|
1052
1244
|
}, { agentDir: this.agentDir, signal: this.signal });
|