@henryqw/pi-pr 6.2.14 → 9.0.3
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 +43 -14
- package/docs/pr-routing.svg +137 -88
- package/extensions/pr-ci.ts +49 -14
- package/extensions/pr-command.ts +76 -24
- package/extensions/pr-comment-sweep.ts +172 -34
- package/extensions/pr-create.ts +6 -0
- package/extensions/pr-github.ts +10 -3
- package/extensions/pr-merge.ts +4 -1
- package/extensions/pr-publish-work.ts +37 -18
- package/extensions/pr-routing.ts +20 -22
- package/extensions/pr-run.ts +107 -0
- package/extensions/pr-sync.ts +92 -0
- package/extensions/pr-ui.ts +9 -1
- package/extensions/pr-update-branch.ts +84 -23
- package/extensions/pr.ts +140 -57
- package/package.json +3 -3
- package/skills/pi-pr-comment-sweep/SKILL.md +6 -4
- package/skills/pi-pr-comment-sweep/references/recovery.md +7 -1
- package/skills/pi-pr-fix-ci/SKILL.md +13 -2
- package/skills/pi-pr-publish-work/SKILL.md +4 -2
- package/skills/pi-pr-update-branch/SKILL.md +4 -4
|
@@ -4,6 +4,7 @@ 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
6
|
import { markFeedbackHandled } from "./pr-feedback-attention.ts";
|
|
7
|
+
import { PrRun } from "./pr-run.ts";
|
|
7
8
|
import {
|
|
8
9
|
extensionConfigDir,
|
|
9
10
|
readTextFileBounded,
|
|
@@ -50,7 +51,7 @@ import {
|
|
|
50
51
|
|
|
51
52
|
export const SWEEP_RECOVERY_MAX_BYTES = 1024 * 1024;
|
|
52
53
|
|
|
53
|
-
const STATE_VERSION =
|
|
54
|
+
const STATE_VERSION = 3;
|
|
54
55
|
const STATE_FILE = "state.json";
|
|
55
56
|
const LEDGER_NOTE_MAX_BYTES = 2 * 1024;
|
|
56
57
|
const REPLY_METADATA_RESERVE_BYTES = 8 * 1024;
|
|
@@ -73,12 +74,17 @@ type SweepRunGuard = {
|
|
|
73
74
|
fingerprint: string;
|
|
74
75
|
};
|
|
75
76
|
type SweepCheck = { command: string; args: string[] };
|
|
77
|
+
type PendingScope = { head: string; exactOutsidePaths: string[] };
|
|
78
|
+
type SweepValidation = { head: string; checks: SweepCheck[] };
|
|
76
79
|
type SweepFinalProjection = {
|
|
77
80
|
generation: number;
|
|
78
81
|
contentFingerprint: string;
|
|
79
82
|
items: Array<{ id: string; kind: FeedbackKind }>;
|
|
80
83
|
threads: Array<{ id: string; isResolved: boolean }>;
|
|
81
84
|
};
|
|
85
|
+
// Only a fresh start before recovery is written may cancel for safe rerouting.
|
|
86
|
+
export class StaleSweepStart extends Error {}
|
|
87
|
+
|
|
82
88
|
export type SweepStatus = {
|
|
83
89
|
phase: SweepPhase;
|
|
84
90
|
guard: SweepRunGuard;
|
|
@@ -91,6 +97,9 @@ export type SweepStatus = {
|
|
|
91
97
|
plan: { ledger: SweepLedgerEntry[]; ownedPaths: string[] } | null;
|
|
92
98
|
approved: boolean;
|
|
93
99
|
legacyRecovery: boolean;
|
|
100
|
+
pendingScope: PendingScope | null;
|
|
101
|
+
validation: SweepValidation | null;
|
|
102
|
+
legacyChecks: SweepCheck[] | null;
|
|
94
103
|
projection: SweepFinalProjection | null;
|
|
95
104
|
attempts: {
|
|
96
105
|
commit: AttemptState;
|
|
@@ -119,7 +128,7 @@ type CommitAttempt = {
|
|
|
119
128
|
head: string | null;
|
|
120
129
|
};
|
|
121
130
|
type SweepState = {
|
|
122
|
-
version: 1 | 2;
|
|
131
|
+
version: 1 | 2 | 3;
|
|
123
132
|
workflow: "pi-pr-comment-sweep";
|
|
124
133
|
worktree: { id: string; root: string };
|
|
125
134
|
epoch: number;
|
|
@@ -137,6 +146,8 @@ type SweepState = {
|
|
|
137
146
|
approved: boolean;
|
|
138
147
|
approvalGeneration: number | null;
|
|
139
148
|
ownedPaths: string[];
|
|
149
|
+
pendingScope: PendingScope | null;
|
|
150
|
+
validation: SweepValidation | null;
|
|
140
151
|
publicationHead: string | null;
|
|
141
152
|
projection: SweepFinalProjection | null;
|
|
142
153
|
attempts: {
|
|
@@ -157,6 +168,8 @@ export type PullRequestCommentSweepOptions = {
|
|
|
157
168
|
loadCurrentPullRequest?: Load;
|
|
158
169
|
newRunId?: () => string;
|
|
159
170
|
pause?: (milliseconds: number) => Promise<void>;
|
|
171
|
+
run?: PrRun;
|
|
172
|
+
confirmLegacyChecks?: (head: string, checks: SweepCheck[]) => Promise<boolean>;
|
|
160
173
|
};
|
|
161
174
|
function exactKeys(value: Record<string, unknown>, keys: readonly string[], label: string): void {
|
|
162
175
|
const actual = Object.keys(value).sort();
|
|
@@ -248,8 +261,8 @@ function recoveryMatchesRouteAuthority(state: SweepState, suppliedAuthority: Swe
|
|
|
248
261
|
(state.attempts.push.state === "attempting" || state.attempts.push.state === "unknown" || state.attempts.push.state === "applied") &&
|
|
249
262
|
state.publicationHead
|
|
250
263
|
) permittedHeads.add(state.publicationHead);
|
|
251
|
-
|
|
252
|
-
|
|
264
|
+
// The base tip belongs to frozen feedback, not recovery or publication identity.
|
|
265
|
+
return [...permittedHeads].some((head) => sameLinkage(state.authority, suppliedAuthority, head, true));
|
|
253
266
|
}
|
|
254
267
|
|
|
255
268
|
function feedbackMatchesAuthority(snapshot: FeedbackSnapshot, authority: SweepAuthority, head: string): boolean {
|
|
@@ -414,12 +427,13 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
|
|
|
414
427
|
!isRecord(value.attempts) || !isRecord(value.attempts.push) || !isRecord(value.attempts.finalize)) {
|
|
415
428
|
throw new Error("sweep recovery state is invalid");
|
|
416
429
|
}
|
|
430
|
+
if ((value.version !== 1 && value.version !== 2 && value.version !== STATE_VERSION) || value.workflow !== "pi-pr-comment-sweep") throw new Error("unsupported sweep recovery state version");
|
|
417
431
|
exactKeys(value, [
|
|
418
432
|
"version", "workflow", "worktree", "epoch", "runId", "phase", "authority", "original", "feedback",
|
|
419
433
|
"ledger", ...(value.version === 1 && !("approved" in value) ? [] : ["approved"]),
|
|
420
434
|
...("approvalGeneration" in value ? ["approvalGeneration"] : []), "ownedPaths", "publicationHead", "projection", "attempts",
|
|
435
|
+
...(value.version === STATE_VERSION ? ["pendingScope", "validation"] : []),
|
|
421
436
|
], "sweep recovery state");
|
|
422
|
-
if ((value.version !== 1 && value.version !== STATE_VERSION) || value.workflow !== "pi-pr-comment-sweep") throw new Error("unsupported sweep recovery state version");
|
|
423
437
|
if (!(["triage", "recorded", "published", "refresh-pending", "refreshed", "resolving", "resolved"] as unknown[]).includes(value.phase)) {
|
|
424
438
|
throw new Error("sweep recovery phase is invalid");
|
|
425
439
|
}
|
|
@@ -480,8 +494,26 @@ function parseState(value: unknown, expectedRoot: string, expectedId: string): S
|
|
|
480
494
|
state: attemptState(value.attempts.finalize.state, "finalize attempt state"),
|
|
481
495
|
checks: parseChecks(value.attempts.finalize.checks),
|
|
482
496
|
};
|
|
497
|
+
let pendingScope: PendingScope | null = null;
|
|
498
|
+
let validation: SweepValidation | null = null;
|
|
499
|
+
if (value.version === STATE_VERSION) {
|
|
500
|
+
if (value.pendingScope !== null) {
|
|
501
|
+
if (!isRecord(value.pendingScope)) throw new Error("pending scope is invalid");
|
|
502
|
+
exactKeys(value.pendingScope, ["head", "exactOutsidePaths"], "pending scope");
|
|
503
|
+
pendingScope = { head: requiredOid(value.pendingScope.head, "pending scope HEAD"), exactOutsidePaths: parseOwnedPaths(value.pendingScope.exactOutsidePaths) };
|
|
504
|
+
if (value.phase !== "recorded" || push.state !== "none" || !pendingScope.exactOutsidePaths.length || pendingScope.exactOutsidePaths.some((path) => ownedPaths.includes(path))) throw new Error("pending scope is inconsistent");
|
|
505
|
+
}
|
|
506
|
+
if (value.validation !== null) {
|
|
507
|
+
if (!isRecord(value.validation)) throw new Error("validation is invalid");
|
|
508
|
+
exactKeys(value.validation, ["head", "checks"], "validation");
|
|
509
|
+
validation = { head: requiredOid(value.validation.head, "validated HEAD"), checks: parseChecks(value.validation.checks) };
|
|
510
|
+
if (pendingScope || !ledger) throw new Error("validation is inconsistent");
|
|
511
|
+
}
|
|
512
|
+
}
|
|
483
513
|
const state: SweepState = {
|
|
484
|
-
version: value.version as 1 | 2,
|
|
514
|
+
version: value.version as 1 | 2 | 3,
|
|
515
|
+
pendingScope,
|
|
516
|
+
validation,
|
|
485
517
|
workflow: "pi-pr-comment-sweep",
|
|
486
518
|
worktree: { id, root },
|
|
487
519
|
epoch: integer(value.epoch, "sweep epoch"),
|
|
@@ -554,7 +586,10 @@ function status(state: SweepState): SweepStatus {
|
|
|
554
586
|
ledgerComplete: state.ledger !== null,
|
|
555
587
|
plan: state.ledger ? { ledger: structuredClone(state.ledger), ownedPaths: [...state.ownedPaths] } : null,
|
|
556
588
|
approved: state.approved,
|
|
557
|
-
legacyRecovery: state.version
|
|
589
|
+
legacyRecovery: state.version < STATE_VERSION,
|
|
590
|
+
pendingScope: structuredClone(state.pendingScope),
|
|
591
|
+
validation: structuredClone(state.validation),
|
|
592
|
+
legacyChecks: state.version < STATE_VERSION ? structuredClone(state.attempts.finalize.checks) : null,
|
|
558
593
|
projection: state.projection ? structuredClone(state.projection) : null,
|
|
559
594
|
attempts: {
|
|
560
595
|
commit: state.attempts.commit?.state ?? "none",
|
|
@@ -625,6 +660,8 @@ export class PullRequestCommentSweep {
|
|
|
625
660
|
private readonly load: Load;
|
|
626
661
|
private readonly newRunId: () => string;
|
|
627
662
|
private readonly pause?: (milliseconds: number) => Promise<void>;
|
|
663
|
+
private readonly run: PrRun;
|
|
664
|
+
private readonly confirmLegacyChecks?: PullRequestCommentSweepOptions["confirmLegacyChecks"];
|
|
628
665
|
|
|
629
666
|
constructor(options: PullRequestCommentSweepOptions) {
|
|
630
667
|
this.cwd = options.cwd;
|
|
@@ -635,6 +672,8 @@ export class PullRequestCommentSweep {
|
|
|
635
672
|
this.load = options.loadCurrentPullRequest ?? loadCurrentPullRequest;
|
|
636
673
|
this.newRunId = options.newRunId ?? randomUUID;
|
|
637
674
|
this.pause = options.pause;
|
|
675
|
+
this.run = options.run ?? new PrRun();
|
|
676
|
+
this.confirmLegacyChecks = options.confirmLegacyChecks;
|
|
638
677
|
}
|
|
639
678
|
|
|
640
679
|
private options(extra: Partial<ExecOptions> = {}): ExecOptions {
|
|
@@ -711,8 +750,10 @@ export class PullRequestCommentSweep {
|
|
|
711
750
|
}
|
|
712
751
|
|
|
713
752
|
private async save(location: Awaited<ReturnType<PullRequestCommentSweep["location"]>>, state: SweepState): Promise<void> {
|
|
714
|
-
const
|
|
715
|
-
const
|
|
753
|
+
const { pendingScope: _pending, validation: _validation, ...legacy } = state;
|
|
754
|
+
const checked = parseState(state.version === STATE_VERSION ? state : legacy, location.root, location.id);
|
|
755
|
+
const { pendingScope: _checkedPending, validation: _checkedValidation, ...checkedLegacy } = checked;
|
|
756
|
+
const contents = `${JSON.stringify(checked.version === STATE_VERSION ? checked : checkedLegacy)}\n`;
|
|
716
757
|
if (Buffer.byteLength(contents, "utf8") > SWEEP_RECOVERY_MAX_BYTES) {
|
|
717
758
|
throw new Error(`Comment sweep recovery exceeds ${SWEEP_RECOVERY_MAX_BYTES} bytes`);
|
|
718
759
|
}
|
|
@@ -760,6 +801,81 @@ export class PullRequestCommentSweep {
|
|
|
760
801
|
return head;
|
|
761
802
|
}
|
|
762
803
|
|
|
804
|
+
private requireRecorded(state: SweepState): void {
|
|
805
|
+
if (state.phase !== "recorded" || !state.ledger || !state.approved || state.attempts.push.state !== "none" ||
|
|
806
|
+
state.attempts.commit && state.attempts.commit.state !== "applied" || state.attempts.resolutions.length || state.attempts.finalize.state !== "none") {
|
|
807
|
+
throw new Error("Scope repair requires recorded unpublished recovery with no unresolved mutation");
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Inspect scope only after reconciliation; never infer ownership from branch placement. */
|
|
812
|
+
private async planScope(state: SweepState): Promise<void> {
|
|
813
|
+
const head = await readHead(this.exec, this.options());
|
|
814
|
+
const dirty = await this.localPaths();
|
|
815
|
+
if (dirty.some((path) => !state.ownedPaths.includes(path))) throw new Error("Comment sweep found changes outside owned paths in the worktree");
|
|
816
|
+
const changed = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "--no-renames", "-z", `${state.original.head}..${head}`], this.options())).stdout, "Sweep committed paths");
|
|
817
|
+
const outside = changed.filter((path) => !state.ownedPaths.includes(path));
|
|
818
|
+
await this.requireOwnedLocalState({ ...state, ownedPaths: [...state.ownedPaths, ...outside] }, head);
|
|
819
|
+
if (outside.length) {
|
|
820
|
+
this.requireRecorded(state);
|
|
821
|
+
if (dirty.length) throw new Error("Scope review requires a clean worktree");
|
|
822
|
+
state.pendingScope = { head, exactOutsidePaths: validatePaths(outside, "Pending scope paths") };
|
|
823
|
+
state.validation = null;
|
|
824
|
+
} else state.pendingScope = null;
|
|
825
|
+
if (state.validation?.head !== head) state.validation = null;
|
|
826
|
+
await this.currentAuthority(state.authority, state.original.lease, true);
|
|
827
|
+
if (await readHead(this.exec, this.options()) !== head || !isDeepStrictEqual(await this.localPaths(), dirty)) throw new Error("Scope inspection local HEAD or worktree changed");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async adopt(guard: SweepRunGuard, headInput: string, outsideInput: string[]): Promise<SweepStatus> {
|
|
831
|
+
const head = requiredOid(headInput, "Reviewed scope HEAD");
|
|
832
|
+
const paths = parseOwnedPaths(outsideInput);
|
|
833
|
+
return await withWorktreeLock(this.cwd, async () => {
|
|
834
|
+
const location = await this.location();
|
|
835
|
+
const state = await this.loadState(location);
|
|
836
|
+
requireGuard(state, guard);
|
|
837
|
+
this.requireRecorded(state);
|
|
838
|
+
if (!state.pendingScope || state.pendingScope.head !== head || !isDeepStrictEqual([...state.pendingScope.exactOutsidePaths].sort(), [...paths].sort())) throw new Error("Adoption must match the exact pending scope HEAD and outside paths");
|
|
839
|
+
await this.planScope(state);
|
|
840
|
+
if (!state.pendingScope || state.pendingScope.head !== head || !isDeepStrictEqual([...state.pendingScope.exactOutsidePaths].sort(), [...paths].sort())) throw new Error("Reviewed committed scope changed before adoption");
|
|
841
|
+
state.ownedPaths = validatePaths([...state.ownedPaths, ...paths], "Sweep owned paths");
|
|
842
|
+
state.pendingScope = null;
|
|
843
|
+
state.validation = null;
|
|
844
|
+
await this.requireCleanPublication(state, head);
|
|
845
|
+
await this.currentAuthority(state.authority, state.original.lease, true);
|
|
846
|
+
await this.requireCleanPublication(state, head);
|
|
847
|
+
await this.save(location, state);
|
|
848
|
+
return status(state);
|
|
849
|
+
}, { agentDir: this.agentDir, signal: this.signal });
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async validate(guard: SweepRunGuard, checksInput: SweepCheck[]): Promise<SweepStatus> {
|
|
853
|
+
const checks = parseChecks(checksInput);
|
|
854
|
+
return await withWorktreeLock(this.cwd, async () => {
|
|
855
|
+
const location = await this.location();
|
|
856
|
+
const state = await this.loadState(location);
|
|
857
|
+
requireGuard(state, guard);
|
|
858
|
+
this.requireRecorded(state);
|
|
859
|
+
if (state.pendingScope) throw new Error("Review and adopt pending scope before validation");
|
|
860
|
+
const head = await readHead(this.exec, this.options());
|
|
861
|
+
await this.requireCleanPublication(state, head);
|
|
862
|
+
await this.currentAuthority(state.authority, state.original.lease, true);
|
|
863
|
+
this.run.beginChecks("sweep", head, checks);
|
|
864
|
+
state.version = STATE_VERSION;
|
|
865
|
+
state.validation = null;
|
|
866
|
+
await this.save(location, state);
|
|
867
|
+
try {
|
|
868
|
+
await runChecked(this.exec, "git", ["diff", "--check", state.original.head, head], this.options());
|
|
869
|
+
for (const check of checks) await runChecked(this.exec, check.command, check.args, this.options());
|
|
870
|
+
await this.currentAuthority(state.authority, state.original.lease, true);
|
|
871
|
+
await this.requireCleanPublication(state, head);
|
|
872
|
+
} catch (error) { this.run.checksFailed(); throw error; }
|
|
873
|
+
state.validation = { head, checks };
|
|
874
|
+
await this.save(location, state);
|
|
875
|
+
return status(state);
|
|
876
|
+
}, { agentDir: this.agentDir, signal: this.signal });
|
|
877
|
+
}
|
|
878
|
+
|
|
763
879
|
private async requireCleanPublication(state: SweepState, expectedHead: string): Promise<void> {
|
|
764
880
|
if ((await this.localPaths()).length) throw new Error("Comment sweep requires a clean worktree");
|
|
765
881
|
await this.requireOwnedLocalState(state, expectedHead);
|
|
@@ -795,6 +911,17 @@ export class PullRequestCommentSweep {
|
|
|
795
911
|
};
|
|
796
912
|
}
|
|
797
913
|
|
|
914
|
+
private async requireStartHead(authority: SweepAuthority): Promise<void> {
|
|
915
|
+
if ((await this.localPaths()).length) throw new Error("Comment sweep start requires a clean worktree");
|
|
916
|
+
const head = await readHead(this.exec, this.options());
|
|
917
|
+
if (head === authority.head.oid) return;
|
|
918
|
+
await this.currentAuthority(authority, authority.head.oid);
|
|
919
|
+
if (await isAncestor(this.exec, this.options(), authority.head.oid, head)) {
|
|
920
|
+
throw new StaleSweepStart("Local HEAD is ahead of the frozen PR head; cancelled before sweep recovery or publication");
|
|
921
|
+
}
|
|
922
|
+
throw new Error("Comment sweep start requires local HEAD to descend from the frozen PR head");
|
|
923
|
+
}
|
|
924
|
+
|
|
798
925
|
async start(): Promise<SweepStatus> {
|
|
799
926
|
return await withWorktreeLock(this.cwd, async () => {
|
|
800
927
|
const location = await this.location();
|
|
@@ -802,17 +929,13 @@ export class PullRequestCommentSweep {
|
|
|
802
929
|
if (!this.suppliedAuthority) throw new Error("Comment sweep start requires route authority");
|
|
803
930
|
const authority = this.suppliedAuthority;
|
|
804
931
|
if (authority.head.oid !== authority.target.remoteOid) throw new Error("Comment sweep requires PR head and remote lease to match");
|
|
805
|
-
|
|
806
|
-
throw new Error("Comment sweep start requires a clean worktree at the PR head");
|
|
807
|
-
}
|
|
932
|
+
await this.requireStartHead(authority);
|
|
808
933
|
await this.currentAuthority(authority, authority.head.oid);
|
|
809
934
|
const snapshot = await this.collect(authority, authority.head.oid);
|
|
810
935
|
await this.currentAuthority(authority, authority.head.oid);
|
|
811
|
-
|
|
812
|
-
throw new Error("Comment sweep authority changed during feedback fetch");
|
|
813
|
-
}
|
|
936
|
+
await this.requireStartHead(authority);
|
|
814
937
|
const state: SweepState = {
|
|
815
|
-
version:
|
|
938
|
+
version: STATE_VERSION,
|
|
816
939
|
workflow: "pi-pr-comment-sweep",
|
|
817
940
|
worktree: { id: location.id, root: location.root },
|
|
818
941
|
epoch: 1,
|
|
@@ -830,6 +953,8 @@ export class PullRequestCommentSweep {
|
|
|
830
953
|
approved: false,
|
|
831
954
|
approvalGeneration: null,
|
|
832
955
|
ownedPaths: [],
|
|
956
|
+
pendingScope: null,
|
|
957
|
+
validation: null,
|
|
833
958
|
publicationHead: null,
|
|
834
959
|
projection: null,
|
|
835
960
|
attempts: {
|
|
@@ -868,7 +993,7 @@ export class PullRequestCommentSweep {
|
|
|
868
993
|
if (state.attempts.push.state === "attempting" || state.attempts.push.state === "unknown") {
|
|
869
994
|
const attemptedHead = state.attempts.push.head;
|
|
870
995
|
if (!attemptedHead) throw new Error("Attempted push has no captured head");
|
|
871
|
-
await this.currentAuthority(state.authority, remote);
|
|
996
|
+
await this.currentAuthority(state.authority, remote, true);
|
|
872
997
|
if (remote === attemptedHead) {
|
|
873
998
|
state.attempts.push.state = "applied";
|
|
874
999
|
state.phase = "published";
|
|
@@ -883,7 +1008,7 @@ export class PullRequestCommentSweep {
|
|
|
883
1008
|
: state.original.lease;
|
|
884
1009
|
remote = await readRemoteOid(this.exec, this.options(), state.authority.target.fetchSource, state.authority.target.ref);
|
|
885
1010
|
if (remote !== expectedRemote) throw new Error("Comment sweep remote authority cannot be reconciled");
|
|
886
|
-
await this.currentAuthority(state.authority, expectedRemote,
|
|
1011
|
+
await this.currentAuthority(state.authority, expectedRemote, true);
|
|
887
1012
|
|
|
888
1013
|
const pending = state.attempts.resolutions.filter(({ state: attempt }) => attempt === "attempting" || attempt === "unknown");
|
|
889
1014
|
if (pending.length > 1) throw new Error("Multiple unresolved mutation attempts cannot be reconciled");
|
|
@@ -944,16 +1069,16 @@ export class PullRequestCommentSweep {
|
|
|
944
1069
|
state.attempts.push = { state: "none", head: null };
|
|
945
1070
|
state.publicationHead = null;
|
|
946
1071
|
}
|
|
947
|
-
if (state.attempts.finalize.state === "blocked") state.attempts.finalize = { state: "none", checks: [] };
|
|
948
1072
|
const published = state.attempts.push.state === "applied";
|
|
949
|
-
if (published) state.version =
|
|
1073
|
+
if (!published) state.version = STATE_VERSION;
|
|
950
1074
|
const expectedRemote = published ? state.publicationHead! : state.original.lease;
|
|
951
|
-
await this.currentAuthority(state.authority, expectedRemote,
|
|
952
|
-
await this.requireOwnedLocalState(state, published ? expectedRemote : undefined);
|
|
1075
|
+
await this.currentAuthority(state.authority, expectedRemote, true);
|
|
953
1076
|
if (state.ledger) {
|
|
954
1077
|
state.approved = true;
|
|
955
1078
|
state.approvalGeneration = state.feedback.generation;
|
|
956
1079
|
}
|
|
1080
|
+
if (published) await this.requireCleanPublication(state, expectedRemote);
|
|
1081
|
+
else await this.planScope(state);
|
|
957
1082
|
state.epoch += 1;
|
|
958
1083
|
state.runId = safeRunId(this.newRunId());
|
|
959
1084
|
await this.save(location, state);
|
|
@@ -978,7 +1103,7 @@ export class PullRequestCommentSweep {
|
|
|
978
1103
|
if (state.phase === "triage" && state.ledger === null) {
|
|
979
1104
|
if (ownedPathsInput === undefined) throw new Error("Initial comment sweep ledger requires ownedPaths");
|
|
980
1105
|
await this.requireCleanPublication(state, state.original.head);
|
|
981
|
-
await this.currentAuthority(state.authority, state.original.lease);
|
|
1106
|
+
await this.currentAuthority(state.authority, state.original.lease, true);
|
|
982
1107
|
state.ledger = exactLedger(ledgerInput, state.feedback.snapshot);
|
|
983
1108
|
state.approved = true;
|
|
984
1109
|
state.approvalGeneration = state.feedback.generation;
|
|
@@ -1010,10 +1135,11 @@ export class PullRequestCommentSweep {
|
|
|
1010
1135
|
if (state.phase !== "recorded" || !state.ledger || !state.approved || state.attempts.push.state !== "none") {
|
|
1011
1136
|
throw new Error("Comment sweep is not ready to commit");
|
|
1012
1137
|
}
|
|
1138
|
+
if (state.pendingScope) throw new Error("Review and adopt pending scope before committing");
|
|
1013
1139
|
if (state.attempts.commit && state.attempts.commit.state !== "applied") {
|
|
1014
1140
|
throw new Error("Comment sweep has an unreconciled commit; use resume");
|
|
1015
1141
|
}
|
|
1016
|
-
await this.currentAuthority(state.authority, state.original.lease);
|
|
1142
|
+
await this.currentAuthority(state.authority, state.original.lease, true);
|
|
1017
1143
|
const beforeHead = await this.requireOwnedLocalState(state);
|
|
1018
1144
|
const paths = await this.localPaths();
|
|
1019
1145
|
if (paths.some((path) => !state.ownedPaths.includes(path))) throw new Error("Comment sweep found changes outside owned paths before staging");
|
|
@@ -1023,9 +1149,10 @@ export class PullRequestCommentSweep {
|
|
|
1023
1149
|
const tree = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["write-tree"], this.options())).stdout, "Commit tree"), "commit tree");
|
|
1024
1150
|
const attempt: CommitAttempt = { state: "attempting", beforeHead, tree, head: null };
|
|
1025
1151
|
state.attempts.commit = attempt;
|
|
1152
|
+
state.validation = null;
|
|
1026
1153
|
await this.save(location, state);
|
|
1027
1154
|
try {
|
|
1028
|
-
await this.currentAuthority(state.authority, state.original.lease);
|
|
1155
|
+
await this.currentAuthority(state.authority, state.original.lease, true);
|
|
1029
1156
|
await this.requireOwnedLocalState(state, beforeHead);
|
|
1030
1157
|
if (parseSingleOutputLine((await runChecked(this.exec, "git", ["write-tree"], this.options())).stdout, "Commit tree") !== tree) {
|
|
1031
1158
|
throw new Error("Comment sweep index changed before commit");
|
|
@@ -1054,24 +1181,26 @@ export class PullRequestCommentSweep {
|
|
|
1054
1181
|
if (state.phase !== "recorded" || !state.ledger || state.attempts.push.state !== "none" || !state.approved) {
|
|
1055
1182
|
throw new Error("Comment sweep is not ready to publish");
|
|
1056
1183
|
}
|
|
1184
|
+
if (state.pendingScope) throw new Error("Review and adopt pending scope before publication");
|
|
1057
1185
|
if (state.attempts.commit && state.attempts.commit.state !== "applied") {
|
|
1058
1186
|
throw new Error("Comment sweep has an unreconciled commit; use resume");
|
|
1059
1187
|
}
|
|
1060
1188
|
const head = await this.requireOwnedLocalState(state);
|
|
1061
1189
|
if ((await this.localPaths()).length) throw new Error("Comment sweep publish requires a clean worktree; use the commit action for owned fixes first");
|
|
1062
|
-
await this.currentAuthority(state.authority, state.original.lease);
|
|
1190
|
+
await this.currentAuthority(state.authority, state.original.lease, true);
|
|
1063
1191
|
if (head === state.original.head) {
|
|
1064
1192
|
state.publicationHead = head;
|
|
1065
1193
|
state.attempts.push = { state: "applied", head };
|
|
1066
1194
|
state.phase = "published";
|
|
1067
|
-
state.version = 2;
|
|
1068
1195
|
await this.save(location, state);
|
|
1069
1196
|
return status(state);
|
|
1070
1197
|
}
|
|
1198
|
+
if (!state.validation || state.validation.head !== head) throw new Error("Comment sweep requires validation on the exact clean HEAD before publication");
|
|
1199
|
+
this.run.beforePush(state.original.lease, head);
|
|
1071
1200
|
if (!(await isAncestor(this.exec, this.options(), state.original.lease, head))) {
|
|
1072
1201
|
throw new Error("Comment sweep push would not fast-forward the original lease");
|
|
1073
1202
|
}
|
|
1074
|
-
await this.currentAuthority(state.authority, state.original.lease);
|
|
1203
|
+
await this.currentAuthority(state.authority, state.original.lease, true);
|
|
1075
1204
|
if (await readHead(this.exec, this.options()) !== head || (await this.localPaths()).length) {
|
|
1076
1205
|
throw new Error("Comment sweep local HEAD or worktree changed before push");
|
|
1077
1206
|
}
|
|
@@ -1087,10 +1216,10 @@ export class PullRequestCommentSweep {
|
|
|
1087
1216
|
if (await readRemoteOid(this.exec, this.options(), state.authority.target.fetchSource, state.authority.target.ref) !== head) {
|
|
1088
1217
|
throw new Error("Published remote ref did not match captured HEAD");
|
|
1089
1218
|
}
|
|
1090
|
-
await this.currentAuthority(state.authority, head);
|
|
1219
|
+
await this.currentAuthority(state.authority, head, true);
|
|
1091
1220
|
state.attempts.push.state = "applied";
|
|
1092
1221
|
state.phase = "published";
|
|
1093
|
-
|
|
1222
|
+
this.run.observeRemote(head);
|
|
1094
1223
|
await this.save(location, state);
|
|
1095
1224
|
return status(state);
|
|
1096
1225
|
} catch (error) {
|
|
@@ -1128,7 +1257,7 @@ export class PullRequestCommentSweep {
|
|
|
1128
1257
|
state.approved = ledger !== null;
|
|
1129
1258
|
state.approvalGeneration = ledger ? state.feedback.generation : null;
|
|
1130
1259
|
state.projection = ledger ? buildProjection(state.feedback.generation, snapshot, ledger) : null;
|
|
1131
|
-
state.attempts.finalize = { state: "none", checks: [] };
|
|
1260
|
+
if (state.version === STATE_VERSION) state.attempts.finalize = { state: "none", checks: [] };
|
|
1132
1261
|
state.phase = ledger ? "refreshed" : "refresh-pending";
|
|
1133
1262
|
await this.save(location, state);
|
|
1134
1263
|
return status(state);
|
|
@@ -1299,13 +1428,19 @@ export class PullRequestCommentSweep {
|
|
|
1299
1428
|
if (state.attempts.resolutions.some(({ state: attempt }) => attempt !== "applied")) {
|
|
1300
1429
|
throw new Error("Comment sweep has unresolved or unknown mutation attempts");
|
|
1301
1430
|
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1431
|
+
const legacy = state.version < STATE_VERSION;
|
|
1432
|
+
if (!legacy && state.attempts.finalize.state !== "none" && state.attempts.finalize.state !== "applied") throw new Error("Comment sweep has an unresolved finalization attempt");
|
|
1433
|
+
if (!legacy && checks.length) throw new Error("Run checks with validate before publication; v3 finalization runs no new checks");
|
|
1434
|
+
if (legacy && state.attempts.finalize.state !== "none" && !isDeepStrictEqual(state.attempts.finalize.checks, checks)) throw new Error("Legacy recovery requires exactly its saved checks");
|
|
1435
|
+
if (state.attempts.finalize.state === "unknown" || state.attempts.finalize.state === "attempting") {
|
|
1436
|
+
if (this.run.hasExecuted(state.publicationHead, checks) || !this.confirmLegacyChecks ||
|
|
1437
|
+
!await this.confirmLegacyChecks(state.publicationHead, checks)) throw new Error("Legacy check outcome unknown; a later /pr requires explicit confirmation of safe repeatability");
|
|
1304
1438
|
}
|
|
1305
1439
|
await this.freshProjection(state);
|
|
1306
1440
|
const alreadyChecked = state.attempts.finalize.state === "applied" &&
|
|
1307
1441
|
isDeepStrictEqual(state.attempts.finalize.checks, checks);
|
|
1308
|
-
if (!alreadyChecked) {
|
|
1442
|
+
if (!alreadyChecked && legacy) {
|
|
1443
|
+
this.run.beginChecks("sweep-legacy", state.publicationHead, checks);
|
|
1309
1444
|
state.attempts.finalize = { state: "attempting", checks };
|
|
1310
1445
|
await this.save(location, state);
|
|
1311
1446
|
for (const check of checks) {
|
|
@@ -1313,16 +1448,19 @@ export class PullRequestCommentSweep {
|
|
|
1313
1448
|
try {
|
|
1314
1449
|
result = await this.exec(check.command, check.args, this.options());
|
|
1315
1450
|
} catch (error) {
|
|
1451
|
+
this.run.checksFailed();
|
|
1316
1452
|
state.attempts.finalize.state = "unknown";
|
|
1317
1453
|
await this.save(location, state);
|
|
1318
1454
|
throw error;
|
|
1319
1455
|
}
|
|
1320
1456
|
if (result.killed) {
|
|
1457
|
+
this.run.checksFailed();
|
|
1321
1458
|
state.attempts.finalize.state = "unknown";
|
|
1322
1459
|
await this.save(location, state);
|
|
1323
1460
|
throw new Error(`Finalization check was killed: ${check.command}`);
|
|
1324
1461
|
}
|
|
1325
1462
|
if (result.code !== 0) {
|
|
1463
|
+
this.run.checksFailed();
|
|
1326
1464
|
state.attempts.finalize.state = "blocked";
|
|
1327
1465
|
await this.save(location, state);
|
|
1328
1466
|
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
package/extensions/pr-create.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PrRun } from "./pr-run.ts";
|
|
1
2
|
import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
|
|
2
3
|
import {
|
|
3
4
|
branchTrackingRef,
|
|
@@ -75,6 +76,7 @@ type CreatePullRequestResult =
|
|
|
75
76
|
| { kind: "published"; url: string };
|
|
76
77
|
|
|
77
78
|
export type CreatePullRequestOptions = {
|
|
79
|
+
run?: PrRun;
|
|
78
80
|
cwd: string;
|
|
79
81
|
target: PullRequestTarget;
|
|
80
82
|
signal?: AbortSignal;
|
|
@@ -162,6 +164,7 @@ function parseCreatedUrl(output: string, host: string, baseRepository: string):
|
|
|
162
164
|
export class PullRequestCreator {
|
|
163
165
|
readonly state: CreatePullRequestState = { phase: "unprepared" };
|
|
164
166
|
|
|
167
|
+
private readonly run?: PrRun;
|
|
165
168
|
private readonly cwd: string;
|
|
166
169
|
private readonly target: PullRequestTarget;
|
|
167
170
|
private readonly noTarget: boolean;
|
|
@@ -173,6 +176,7 @@ export class PullRequestCreator {
|
|
|
173
176
|
|
|
174
177
|
constructor(options: CreatePullRequestOptions) {
|
|
175
178
|
this.cwd = options.cwd;
|
|
179
|
+
this.run = options.run;
|
|
176
180
|
this.target = { ...options.target };
|
|
177
181
|
this.noTarget = options.target.provenance === "inferred" && options.target.remoteOid === null;
|
|
178
182
|
this.signal = options.signal;
|
|
@@ -359,6 +363,7 @@ export class PullRequestCreator {
|
|
|
359
363
|
await this.freshNone();
|
|
360
364
|
if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
|
|
361
365
|
if (await this.requireCleanHead() !== head) throw new Error("PR creation cancelled: local HEAD changed before push");
|
|
366
|
+
this.run?.beforePush(original, head);
|
|
362
367
|
this.state.publicationHead = head;
|
|
363
368
|
this.state.phase = "blocked";
|
|
364
369
|
await runChecked(this.exec, "git", [
|
|
@@ -368,6 +373,7 @@ export class PullRequestCreator {
|
|
|
368
373
|
if (await readRemoteOid(this.exec, this.options(), this.target.fetchSource, this.target.ref) !== head) {
|
|
369
374
|
throw new Error("Published remote ref did not match captured HEAD");
|
|
370
375
|
}
|
|
376
|
+
this.run?.observeRemote(head);
|
|
371
377
|
this.state.phase = "pushed";
|
|
372
378
|
if (this.noTarget) {
|
|
373
379
|
await this.configureNoTargetUpstream(head);
|
package/extensions/pr-github.ts
CHANGED
|
@@ -116,7 +116,7 @@ export type PullRequestObservation = {
|
|
|
116
116
|
target: { repository: string; branch: string; remote: string; ref: string };
|
|
117
117
|
};
|
|
118
118
|
|
|
119
|
-
export type PullRequestLoadContext = { cwd: string; signal?: AbortSignal };
|
|
119
|
+
export type PullRequestLoadContext = { cwd: string; signal?: AbortSignal; rebaseBranch?: string };
|
|
120
120
|
|
|
121
121
|
type PullRequestCreationPreflight = {
|
|
122
122
|
head: string;
|
|
@@ -861,10 +861,11 @@ function conditions(
|
|
|
861
861
|
? "pending"
|
|
862
862
|
: "ready";
|
|
863
863
|
const behind = candidate.mergeStateStatus === "BEHIND";
|
|
864
|
+
const conflict = candidate.mergeable === "CONFLICTING" || candidate.mergeStateStatus === "DIRTY";
|
|
864
865
|
return {
|
|
865
866
|
draft: candidate.isDraft,
|
|
866
867
|
baseUpdateRequired: behind,
|
|
867
|
-
conflict
|
|
868
|
+
conflict,
|
|
868
869
|
changesRequested: candidate.reviewDecision === "CHANGES_REQUESTED",
|
|
869
870
|
unresolvedThreads,
|
|
870
871
|
ci: ciStatus(candidate.checkStates),
|
|
@@ -872,6 +873,10 @@ function conditions(
|
|
|
872
873
|
policy: candidate.mergeable === "MERGEABLE" && candidate.mergeStateStatus === "CLEAN"
|
|
873
874
|
? "ready"
|
|
874
875
|
: "pending",
|
|
876
|
+
// A confirmed conflict on either field is definitive; the other field's UNKNOWN is not worth waiting for.
|
|
877
|
+
mergeability: !conflict && (candidate.mergeable === "UNKNOWN" || candidate.mergeStateStatus === "UNKNOWN")
|
|
878
|
+
? "pending"
|
|
879
|
+
: "known",
|
|
875
880
|
};
|
|
876
881
|
}
|
|
877
882
|
|
|
@@ -1365,7 +1370,9 @@ async function readPushTarget(
|
|
|
1365
1370
|
if (worktree.code !== 0) commandFailure("Check Git worktree", worktree);
|
|
1366
1371
|
if (worktreeOutput !== "true\n") fail("Check Git worktree", "invalid response");
|
|
1367
1372
|
|
|
1368
|
-
const branchResult =
|
|
1373
|
+
const branchResult = context.rebaseBranch === undefined
|
|
1374
|
+
? await execute(pi, context, "Read current branch", "git", ["branch", "--show-current"])
|
|
1375
|
+
: { stdout: `${context.rebaseBranch}\n` };
|
|
1369
1376
|
if (branchResult.stdout === "") return { kind: "blocked", issue: "detached" };
|
|
1370
1377
|
let branch: string;
|
|
1371
1378
|
try {
|
package/extensions/pr-merge.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
runChecked,
|
|
7
7
|
} from "./pr-execution.ts";
|
|
8
8
|
import type { LocalMergeSafety } from "./pr-routing.ts";
|
|
9
|
+
import type { MergeMethod } from "./pr-run.ts";
|
|
9
10
|
|
|
10
11
|
const MERGE_PULL_REQUEST_MUTATION = "mutation($pullRequestId:ID!,$expectedHeadOid:GitObjectID!,$mergeMethod:PullRequestMergeMethod!){mergePullRequest(input:{pullRequestId:$pullRequestId,expectedHeadOid:$expectedHeadOid,mergeMethod:$mergeMethod}){pullRequest{id state}}}";
|
|
11
12
|
|
|
@@ -23,6 +24,8 @@ type InspectedLocalMergeSafety = LocalMergeSafety & {
|
|
|
23
24
|
type ExecuteGitHubMergeInput = InspectLocalMergeSafetyInput & {
|
|
24
25
|
pullRequestId: string;
|
|
25
26
|
hostname: string;
|
|
27
|
+
/** Configured merge method; GitHub rejects a method the repository does not allow. */
|
|
28
|
+
mergeMethod?: MergeMethod;
|
|
26
29
|
expectedBase: {
|
|
27
30
|
repository: string;
|
|
28
31
|
ref: string;
|
|
@@ -127,7 +130,7 @@ export async function executeGitHubMerge(input: ExecuteGitHubMergeInput): Promis
|
|
|
127
130
|
"-F",
|
|
128
131
|
`expectedHeadOid=${input.expectedHead}`,
|
|
129
132
|
"-F",
|
|
130
|
-
|
|
133
|
+
`mergeMethod=${(input.mergeMethod ?? "squash").toUpperCase()}`,
|
|
131
134
|
], { cwd: input.cwd });
|
|
132
135
|
const output = merged.stdout.trim();
|
|
133
136
|
if (!output) throw new Error("GitHub merge returned no output");
|