@henryqw/pi-pr 6.2.6 → 6.2.9
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 +2 -2
- package/extensions/pr-ci.ts +10 -25
- package/extensions/pr-command.ts +1 -0
- package/extensions/pr-comment-sweep.ts +14 -35
- package/extensions/pr-create.ts +4 -7
- package/extensions/pr-execution.ts +8 -16
- package/extensions/pr-feedback-attention.ts +1 -2
- package/extensions/pr-github.ts +45 -140
- package/extensions/pr-merge.ts +0 -23
- package/extensions/pr-publish-work.ts +5 -11
- package/extensions/pr-update-branch.ts +11 -8
- package/extensions/pr.ts +66 -52
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Run `/pr` in a GitHub checkout. It reads fresh local and GitHub state, takes the
|
|
|
40
40
|
| Footer | ui | Linked PR number and plain-language status. |
|
|
41
41
|
| Widget | ui | Action hint or transient routing status. |
|
|
42
42
|
|
|
43
|
-
`/pr` accepts no flags, prose, or base argument. Start with the command, not a helper skill or tool: direct calls cannot establish route authority. The helper run is bound to the current session and worktree.
|
|
43
|
+
`/pr` accepts no flags, prose, or base argument. Start with the command, not a helper skill or tool: direct calls cannot establish route authority. The helper run is bound to the current session and worktree. Its agent tools use plain-object parameter schemas so providers that omit root-union tools can expose them; action-specific arguments are still checked before the workflow runs.
|
|
44
44
|
|
|
45
45
|
## Flow
|
|
46
46
|
|
|
@@ -82,5 +82,5 @@ In-progress feedback sweeps keep private recovery under `<agent-dir>/config/pi-p
|
|
|
82
82
|
- `/pr` does not open a browser, run `/done` or `/sweep`, enable auto-merge, or use a merge queue. It only starts workflows when explicitly invoked.
|
|
83
83
|
- Pushes use a saved exact remote OID lease and revalidate the destination. Concurrent updates block publication instead of being overwritten. Creation does not change the base branch, and a configured push target does not change upstream settings.
|
|
84
84
|
- Direct merge requires a clean, safe local Git state and fresh matching head; it always squashes. GitHub repository policy may still reject it. No branch or worktree is deleted.
|
|
85
|
-
- A Git operation in progress blocks direct merge. An unverified conflict rebase needs manual recovery; a verified rebase can resume through `/pr`. Branches with merge commits since the fork point require manual rebase resolution.
|
|
85
|
+
- A Git operation in progress blocks direct merge. An unverified conflict rebase needs manual recovery; a verified rebase can resume through `/pr`. Rebasing leaves other local branch refs unchanged, even with `rebase.updateRefs=true`. Branches with merge commits since the fork point require manual rebase resolution.
|
|
86
86
|
- Only authenticated GitHub.com and GitHub Enterprise repositories are supported. Resolve ambiguous remotes, unrelated local changes, or GitHub blockers before retrying `/pr`.
|
package/extensions/pr-ci.ts
CHANGED
|
@@ -108,13 +108,9 @@ type CiEvidence = {
|
|
|
108
108
|
failures: CiFailureEvidence[];
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
-
type CiPushAttempt = "before-launch" | "applied" | "not-applied" | "unknown";
|
|
112
111
|
type CiFixPhase = "ready" | "collecting" | "collected" | "published" | "blocked";
|
|
113
112
|
|
|
114
|
-
type CiFixState = {
|
|
115
|
-
phase: CiFixPhase;
|
|
116
|
-
pushAttempt: CiPushAttempt;
|
|
117
|
-
};
|
|
113
|
+
type CiFixState = { phase: CiFixPhase };
|
|
118
114
|
|
|
119
115
|
export type PullRequestCiFixOptions = {
|
|
120
116
|
cwd: string;
|
|
@@ -293,7 +289,7 @@ function errorMessage(error: unknown): string {
|
|
|
293
289
|
}
|
|
294
290
|
|
|
295
291
|
export class PullRequestCiFixer {
|
|
296
|
-
readonly state: CiFixState = { phase: "ready"
|
|
292
|
+
readonly state: CiFixState = { phase: "ready" };
|
|
297
293
|
|
|
298
294
|
private readonly cwd: string;
|
|
299
295
|
private readonly authority: CurrentPullRequest;
|
|
@@ -301,20 +297,16 @@ export class PullRequestCiFixer {
|
|
|
301
297
|
private readonly agentDir?: string;
|
|
302
298
|
private readonly exec: Exec;
|
|
303
299
|
private readonly load: Load;
|
|
304
|
-
private collectConsumed = false;
|
|
305
|
-
private publishConsumed = false;
|
|
306
300
|
private collectedFingerprint?: string;
|
|
307
301
|
|
|
308
302
|
constructor(options: PullRequestCiFixOptions) {
|
|
309
|
-
if (
|
|
303
|
+
if (options.authority.target.provenance !== "configured") {
|
|
310
304
|
throw new TypeError("CI repair requires a configured failed pull request with a clean equal local HEAD");
|
|
311
305
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (remote !== head) throw new TypeError("CI repair requires the pull request head to match the configured remote OID");
|
|
317
|
-
this.cwd = requiredText(options.cwd, "cwd");
|
|
306
|
+
if (options.authority.target.remoteOid !== options.authority.head.oid) {
|
|
307
|
+
throw new TypeError("CI repair requires the pull request head to match the configured remote OID");
|
|
308
|
+
}
|
|
309
|
+
this.cwd = options.cwd;
|
|
318
310
|
this.authority = cloneCurrentPullRequest(options.authority);
|
|
319
311
|
this.signal = options.signal;
|
|
320
312
|
this.agentDir = options.agentDir;
|
|
@@ -331,7 +323,7 @@ export class PullRequestCiFixer {
|
|
|
331
323
|
}
|
|
332
324
|
|
|
333
325
|
private context(): PullRequestLoadContext {
|
|
334
|
-
return { cwd: this.cwd, signal: this.signal
|
|
326
|
+
return { cwd: this.cwd, signal: this.signal };
|
|
335
327
|
}
|
|
336
328
|
|
|
337
329
|
private async freshAuthority(requireOriginalLocal: boolean): Promise<CurrentPullRequest> {
|
|
@@ -619,8 +611,7 @@ export class PullRequestCiFixer {
|
|
|
619
611
|
}
|
|
620
612
|
|
|
621
613
|
async collect(): Promise<CiEvidence> {
|
|
622
|
-
if (this.
|
|
623
|
-
this.collectConsumed = true;
|
|
614
|
+
if (this.state.phase !== "ready") throw new Error("CI evidence collect action was already consumed");
|
|
624
615
|
this.state.phase = "collecting";
|
|
625
616
|
try {
|
|
626
617
|
const before = await this.readSnapshot(true);
|
|
@@ -699,7 +690,6 @@ export class PullRequestCiFixer {
|
|
|
699
690
|
if (repairHead === original || !(await isAncestor(this.exec, this.options(), original, repairHead))) {
|
|
700
691
|
throw new Error("CI repair HEAD must be a new descendant of the frozen pull request head");
|
|
701
692
|
}
|
|
702
|
-
if (await readHead(this.exec, this.options()) !== repairHead) throw new Error("CI repair HEAD changed before push");
|
|
703
693
|
return repairHead;
|
|
704
694
|
}
|
|
705
695
|
|
|
@@ -714,8 +704,7 @@ export class PullRequestCiFixer {
|
|
|
714
704
|
}
|
|
715
705
|
|
|
716
706
|
async publish(): Promise<CiPublishResult> {
|
|
717
|
-
if (this.
|
|
718
|
-
this.publishConsumed = true;
|
|
707
|
+
if (this.state.phase !== "collected") throw new Error("CI repair publish action is unavailable or was already consumed");
|
|
719
708
|
try {
|
|
720
709
|
return await withWorktreeLock(this.cwd, async () => {
|
|
721
710
|
const repairHead = await this.validatePublishAuthority();
|
|
@@ -741,19 +730,15 @@ export class PullRequestCiFixer {
|
|
|
741
730
|
this.authority.target.ref,
|
|
742
731
|
);
|
|
743
732
|
} catch (error) {
|
|
744
|
-
this.state.pushAttempt = "unknown";
|
|
745
733
|
throw new Error(`CI repair push outcome is unknown: ${errorMessage(error)}`);
|
|
746
734
|
}
|
|
747
735
|
if (postcondition === repairHead) {
|
|
748
|
-
this.state.pushAttempt = "applied";
|
|
749
736
|
this.state.phase = "published";
|
|
750
737
|
return { kind: "published", head: repairHead, attempt: "applied" };
|
|
751
738
|
}
|
|
752
739
|
if (postcondition === original) {
|
|
753
|
-
this.state.pushAttempt = "not-applied";
|
|
754
740
|
throw new Error(`CI repair push was not applied${pushError ? `: ${errorMessage(pushError)}` : ""}`);
|
|
755
741
|
}
|
|
756
|
-
this.state.pushAttempt = "unknown";
|
|
757
742
|
throw new Error("CI repair push outcome is unknown: remote target has an unexpected OID");
|
|
758
743
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
759
744
|
} catch (error) {
|
package/extensions/pr-command.ts
CHANGED
|
@@ -25,6 +25,7 @@ const WORKFLOWS: Record<WorkflowNextStep, string> = {
|
|
|
25
25
|
sweep: "skill:pi-pr-comment-sweep",
|
|
26
26
|
"fix-ci": "skill:pi-pr-fix-ci",
|
|
27
27
|
};
|
|
28
|
+
export const WORKFLOW_ROUTES: ReadonlySet<string> = new Set(Object.keys(WORKFLOWS));
|
|
28
29
|
type WorkflowReservation =
|
|
29
30
|
| { route: "create"; target: PullRequestTarget }
|
|
30
31
|
| { route: Exclude<WorkflowNextStep, "create">; pullRequest: CurrentPullRequest };
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
requiredOid,
|
|
44
44
|
requiredText,
|
|
45
45
|
runChecked,
|
|
46
|
+
validatePaths,
|
|
46
47
|
withWorktreeLock,
|
|
47
48
|
type AttemptState,
|
|
48
49
|
} from "./pr-execution.ts";
|
|
@@ -190,20 +191,12 @@ function authorityFromCurrent(pullRequest: CurrentPullRequest) {
|
|
|
190
191
|
if (pullRequest.lifecycle !== "open" || pullRequest.target.provenance !== "configured") {
|
|
191
192
|
throw new Error("Comment sweep requires a configured open pull request");
|
|
192
193
|
}
|
|
193
|
-
const
|
|
194
|
-
|
|
194
|
+
const { branch, remote, ref, repository, host, fetchSource, remoteOid } = pullRequest.target;
|
|
195
|
+
if (remoteOid === null) throw new Error("Comment sweep requires a published remote lease");
|
|
195
196
|
return {
|
|
196
|
-
...
|
|
197
|
-
headFetchSource:
|
|
198
|
-
target: {
|
|
199
|
-
branch: requiredText(pullRequest.target.branch, "target branch"),
|
|
200
|
-
remote: requiredText(pullRequest.target.remote, "target remote"),
|
|
201
|
-
ref: requiredText(pullRequest.target.ref, "target ref"),
|
|
202
|
-
repository: requiredText(pullRequest.target.repository, "target repository"),
|
|
203
|
-
host: requiredText(pullRequest.target.host, "target host").toLowerCase(),
|
|
204
|
-
fetchSource: requiredText(pullRequest.target.fetchSource, "target fetch source"),
|
|
205
|
-
remoteOid,
|
|
206
|
-
},
|
|
197
|
+
...feedbackAuthorityFromCurrent(pullRequest),
|
|
198
|
+
headFetchSource: pullRequest.headFetchSource,
|
|
199
|
+
target: { branch, remote, ref, repository, host, fetchSource, remoteOid },
|
|
207
200
|
};
|
|
208
201
|
}
|
|
209
202
|
|
|
@@ -270,8 +263,7 @@ function feedbackMatchesAuthority(snapshot: FeedbackSnapshot, authority: SweepAu
|
|
|
270
263
|
|
|
271
264
|
function parseOwnedPaths(value: unknown): string[] {
|
|
272
265
|
if (!Array.isArray(value) || value.some((path) => typeof path !== "string")) throw new Error("ownedPaths must be an array of paths");
|
|
273
|
-
|
|
274
|
-
return parseNulPaths(encoded, "Sweep owned paths");
|
|
266
|
+
return validatePaths(value as string[], "Sweep owned paths");
|
|
275
267
|
}
|
|
276
268
|
|
|
277
269
|
function parseLedgerEntry(value: unknown, label: string): SweepLedgerEntry {
|
|
@@ -635,7 +627,7 @@ export class PullRequestCommentSweep {
|
|
|
635
627
|
private readonly pause?: (milliseconds: number) => Promise<void>;
|
|
636
628
|
|
|
637
629
|
constructor(options: PullRequestCommentSweepOptions) {
|
|
638
|
-
this.cwd =
|
|
630
|
+
this.cwd = options.cwd;
|
|
639
631
|
this.suppliedAuthority = options.authority ? authorityFromCurrent(options.authority) : undefined;
|
|
640
632
|
this.signal = options.signal;
|
|
641
633
|
this.agentDir = options.agentDir;
|
|
@@ -654,7 +646,7 @@ export class PullRequestCommentSweep {
|
|
|
654
646
|
}
|
|
655
647
|
|
|
656
648
|
private context(): PullRequestLoadContext {
|
|
657
|
-
return { cwd: this.cwd, signal: this.signal
|
|
649
|
+
return { cwd: this.cwd, signal: this.signal };
|
|
658
650
|
}
|
|
659
651
|
|
|
660
652
|
private async location(): Promise<{ root: string; id: string; path: string }> {
|
|
@@ -1011,7 +1003,6 @@ export class PullRequestCommentSweep {
|
|
|
1011
1003
|
|
|
1012
1004
|
async commit(guard: SweepRunGuard, message: string): Promise<{ head: string }> {
|
|
1013
1005
|
requiredText(message, "commit message");
|
|
1014
|
-
if (message.length > 256) throw new Error("commit message exceeds 256 characters");
|
|
1015
1006
|
return await withWorktreeLock(this.cwd, async () => {
|
|
1016
1007
|
const location = await this.location();
|
|
1017
1008
|
const state = await this.loadState(location);
|
|
@@ -1144,7 +1135,7 @@ export class PullRequestCommentSweep {
|
|
|
1144
1135
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
1145
1136
|
}
|
|
1146
1137
|
|
|
1147
|
-
async resolve(guard: SweepRunGuard
|
|
1138
|
+
async resolve(guard: SweepRunGuard): Promise<SweepStatus> {
|
|
1148
1139
|
return await withWorktreeLock(this.cwd, async () => {
|
|
1149
1140
|
const location = await this.location();
|
|
1150
1141
|
const state = await this.loadState(location);
|
|
@@ -1155,31 +1146,19 @@ export class PullRequestCommentSweep {
|
|
|
1155
1146
|
if (state.attempts.resolutions.some(({ state: attempt }) => attempt !== "applied")) {
|
|
1156
1147
|
throw new Error("Comment sweep has an unreconciled thread mutation; use resume");
|
|
1157
1148
|
}
|
|
1158
|
-
if (threadIdsInput !== undefined && (!Array.isArray(threadIdsInput) || threadIdsInput.length > FEEDBACK_MAX_RECORDS)) {
|
|
1159
|
-
throw new Error("threadIds must be a bounded array");
|
|
1160
|
-
}
|
|
1161
1149
|
const ledger = new Map(state.ledger.map((entry) => [entry.id, entry]));
|
|
1162
|
-
const threadIds =
|
|
1150
|
+
const threadIds = state.feedback.snapshot.reviewThreads.filter((thread) =>
|
|
1163
1151
|
!thread.isResolved && ["addressed", "non-actionable"].includes(ledger.get(thread.id)?.disposition ?? "blocked") &&
|
|
1164
1152
|
!thread.comments.some((comment) => ledger.get(comment.id)?.disposition === "blocked")
|
|
1165
|
-
).map(({ id }) => id)
|
|
1166
|
-
if (new Set(threadIds).size !== threadIds.length) throw new Error("threadIds contain duplicates");
|
|
1153
|
+
).map(({ id }) => id);
|
|
1167
1154
|
for (const threadId of threadIds) {
|
|
1168
|
-
const thread = state.feedback.snapshot.reviewThreads.find(({ id }) => id === threadId);
|
|
1169
|
-
const disposition = ledger.get(threadId)?.disposition;
|
|
1170
|
-
if (!thread || thread.isResolved || ledger.get(threadId)?.kind !== "thread" ||
|
|
1171
|
-
(disposition !== "addressed" && disposition !== "non-actionable")) {
|
|
1172
|
-
throw new Error(`Only addressed or non-actionable unresolved review thread IDs may be resolved: ${threadId}`);
|
|
1173
|
-
}
|
|
1174
1155
|
if (state.attempts.resolutions.some((attempt) => attempt.generation === state.feedback.generation && attempt.threadId === threadId && attempt.step === "resolve")) {
|
|
1175
1156
|
throw new Error(`Review thread resolution was already attempted: ${threadId}`);
|
|
1176
1157
|
}
|
|
1177
|
-
|
|
1158
|
+
const entry = ledger.get(threadId)!;
|
|
1159
|
+
if (entry.disposition === "non-actionable" && !entry.note.trim()) {
|
|
1178
1160
|
throw new Error(`Non-actionable review thread needs a reason: ${threadId}`);
|
|
1179
1161
|
}
|
|
1180
|
-
if (thread.comments.some((comment) => ledger.get(comment.id)?.disposition === "blocked")) {
|
|
1181
|
-
throw new Error(`Review thread has blocked child feedback: ${threadId}`);
|
|
1182
|
-
}
|
|
1183
1162
|
}
|
|
1184
1163
|
const hasReply = (threadId: string) => state.attempts.resolutions.some((attempt) =>
|
|
1185
1164
|
attempt.threadId === threadId && attempt.step === "reply" && attempt.state === "applied" &&
|
package/extensions/pr-create.ts
CHANGED
|
@@ -25,13 +25,12 @@ import {
|
|
|
25
25
|
inspectWorktreeState,
|
|
26
26
|
parseNulPaths,
|
|
27
27
|
parseStatusSnapshot,
|
|
28
|
-
|
|
28
|
+
validatePaths,
|
|
29
29
|
isAncestor,
|
|
30
30
|
isRecord,
|
|
31
31
|
parseSingleOutputLine,
|
|
32
32
|
readHead,
|
|
33
33
|
readRemoteOid,
|
|
34
|
-
requiredOid,
|
|
35
34
|
requiredText,
|
|
36
35
|
runChecked,
|
|
37
36
|
withWorktreeLock,
|
|
@@ -173,7 +172,6 @@ export class PullRequestCreator {
|
|
|
173
172
|
private readonly load: Load;
|
|
174
173
|
|
|
175
174
|
constructor(options: CreatePullRequestOptions) {
|
|
176
|
-
if (options.target.remoteOid !== null) requiredOid(options.target.remoteOid, "remote OID");
|
|
177
175
|
this.cwd = options.cwd;
|
|
178
176
|
this.target = { ...options.target };
|
|
179
177
|
this.noTarget = options.target.provenance === "inferred" && options.target.remoteOid === null;
|
|
@@ -192,7 +190,7 @@ export class PullRequestCreator {
|
|
|
192
190
|
}
|
|
193
191
|
|
|
194
192
|
private context(): PullRequestLoadContext {
|
|
195
|
-
return { cwd: this.cwd, signal: this.signal
|
|
193
|
+
return { cwd: this.cwd, signal: this.signal };
|
|
196
194
|
}
|
|
197
195
|
|
|
198
196
|
private async freshNone() {
|
|
@@ -282,8 +280,7 @@ export class PullRequestCreator {
|
|
|
282
280
|
if (await inspectWorktreeState(this.exec, this.options()) === "operation") throw new Error("Git operation in progress");
|
|
283
281
|
const head = await readHead(this.exec, this.options());
|
|
284
282
|
const status = (await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options())).stdout;
|
|
285
|
-
const paths = [...parseStatusSnapshot(status).keys()];
|
|
286
|
-
parseNulPaths(paths.map((path) => `${path}\0`).join(""), "Pending paths");
|
|
283
|
+
const paths = validatePaths([...parseStatusSnapshot(status).keys()], "Pending paths");
|
|
287
284
|
this.state.pending = { head, status };
|
|
288
285
|
return { paths, head };
|
|
289
286
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
@@ -291,7 +288,7 @@ export class PullRequestCreator {
|
|
|
291
288
|
|
|
292
289
|
async commit(pathsInput: string[], message: string): Promise<{ head: string }> {
|
|
293
290
|
if (this.state.phase !== "prepared" || !this.state.pending) throw new Error("PR creation has no inspected pending work");
|
|
294
|
-
const paths =
|
|
291
|
+
const paths = validatePaths(pathsInput, "Commit paths");
|
|
295
292
|
if (!paths.length || paths.some((path) => !parseStatusSnapshot(this.state.pending!.status).has(path))) {
|
|
296
293
|
throw new Error("Commit paths must be reviewed pending paths");
|
|
297
294
|
}
|
|
@@ -111,12 +111,9 @@ export async function withWorktreeLock<T>(
|
|
|
111
111
|
options: { agentDir?: string; signal?: AbortSignal } = {},
|
|
112
112
|
): Promise<T> {
|
|
113
113
|
options.signal?.throwIfAborted();
|
|
114
|
-
const rootResult = await runChecked(spawnBounded, "git", ["rev-parse", "--show-toplevel"], {
|
|
115
|
-
cwd: requiredText(cwd, "cwd"),
|
|
116
|
-
signal: options.signal,
|
|
117
|
-
});
|
|
114
|
+
const rootResult = await runChecked(spawnBounded, "git", ["rev-parse", "--show-toplevel"], { cwd, signal: options.signal });
|
|
118
115
|
const root = parseSingleOutputLine(rootResult.stdout, "Git worktree root resolution");
|
|
119
|
-
const canonical =
|
|
116
|
+
const canonical = await realpath(root);
|
|
120
117
|
const lockNamespace = resolve(extensionConfigDir("pi-pr", options.agentDir));
|
|
121
118
|
const lockDirectory = join(lockNamespace, "worktree-locks");
|
|
122
119
|
const identity = createHash("sha256").update(canonical).digest("hex");
|
|
@@ -147,7 +144,11 @@ export async function withWorktreeLock<T>(
|
|
|
147
144
|
export function parseNulPaths(output: string, label: string): string[] {
|
|
148
145
|
if (output === "") return [];
|
|
149
146
|
if (!output.endsWith("\0")) throw new Error(`${label} returned malformed paths`);
|
|
150
|
-
|
|
147
|
+
return validatePaths(output.slice(0, -1).split("\0"), label);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Reject unsafe, oversized, or duplicate repository-relative paths. */
|
|
151
|
+
export function validatePaths(paths: readonly string[], label: string): string[] {
|
|
151
152
|
let bytes = 0;
|
|
152
153
|
if (paths.length > MAX_CONFLICT_PATHS) throw new Error(`${label} returned more than ${MAX_CONFLICT_PATHS} paths`);
|
|
153
154
|
for (const path of paths) {
|
|
@@ -160,16 +161,7 @@ export function parseNulPaths(output: string, label: string): string[] {
|
|
|
160
161
|
}
|
|
161
162
|
if (bytes > MAX_CONFLICT_PATHS_BYTES) throw new Error(`${label} returned too much path data`);
|
|
162
163
|
if (new Set(paths).size !== paths.length) throw new Error(`${label} returned duplicate paths`);
|
|
163
|
-
return paths;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function validateResolvedConflictPaths(paths: readonly string[], expected: readonly string[]): string[] {
|
|
167
|
-
if (!Array.isArray(paths)) throw new TypeError("resolvedPaths must be an array");
|
|
168
|
-
const parsed = parseNulPaths(`${paths.join("\0")}${paths.length ? "\0" : ""}`, "Resolved conflict paths");
|
|
169
|
-
if (expected.some((path) => !parsed.includes(path))) {
|
|
170
|
-
throw new Error("Resolved paths must include every original conflict path");
|
|
171
|
-
}
|
|
172
|
-
return parsed;
|
|
164
|
+
return [...paths];
|
|
173
165
|
}
|
|
174
166
|
|
|
175
167
|
function statusPath(record: string): { path: string; rename: boolean } {
|
|
@@ -45,8 +45,7 @@ export async function needsFeedbackAttention(
|
|
|
45
45
|
const { cwd, agentDir, signal } = options;
|
|
46
46
|
const exec = options.exec ?? spawnBounded;
|
|
47
47
|
const snapshot = await collectPullRequestFeedback(feedbackAuthorityFromCurrent(current), { exec, cwd, signal });
|
|
48
|
-
const discovery = await (options.load ?? loadCurrentPullRequest)(extensionExecApi(exec, cwd, signal),
|
|
49
|
-
{ cwd, signal: signal ?? new AbortController().signal });
|
|
48
|
+
const discovery = await (options.load ?? loadCurrentPullRequest)(extensionExecApi(exec, cwd, signal), { cwd, signal });
|
|
50
49
|
if (discovery.kind !== "current" || !samePullRequestSnapshot(current, discovery.pullRequest) ||
|
|
51
50
|
current.base.oid !== discovery.pullRequest.base.oid) {
|
|
52
51
|
throw new Error("Feedback discovery cancelled: pull request authority changed");
|
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 =
|
|
119
|
+
export type PullRequestLoadContext = { cwd: string; signal?: AbortSignal };
|
|
120
120
|
|
|
121
121
|
type PullRequestCreationPreflight = {
|
|
122
122
|
head: string;
|
|
@@ -764,17 +764,20 @@ function parseSearchPage(output: string, pushTarget: Pick<PushTarget, "repositor
|
|
|
764
764
|
};
|
|
765
765
|
}
|
|
766
766
|
|
|
767
|
-
function
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
)
|
|
767
|
+
function matchesPushTarget(
|
|
768
|
+
url: URL,
|
|
769
|
+
headRepository: string | null,
|
|
770
|
+
headRef: string,
|
|
771
|
+
pushTarget: Pick<PushTarget, "repository" | "ref">,
|
|
772
|
+
): boolean {
|
|
773
|
+
return url.hostname.toLowerCase() === pushTarget.repository.host && headRepository !== null &&
|
|
774
|
+
normalizeRepository(headRepository) === pushTarget.repository.normalizedName && headRef === pushTarget.ref;
|
|
774
775
|
}
|
|
775
776
|
|
|
776
777
|
function selectSearchPullRequest(candidates: PullRequestCandidate[], pushTarget: PushTarget): SearchSelection {
|
|
777
|
-
const matching =
|
|
778
|
+
const matching = candidates.filter((candidate) =>
|
|
779
|
+
matchesPushTarget(candidate.url, candidate.headRepository, candidate.headRef, pushTarget)
|
|
780
|
+
);
|
|
778
781
|
const open = matching.filter((candidate) => candidate.lifecycle === "open");
|
|
779
782
|
if (open.length > 1) return { kind: "ambiguous", urls: open.map(({ url }) => url) };
|
|
780
783
|
if (open.length === 1) {
|
|
@@ -830,33 +833,6 @@ function parsePullRequestPublication(output: string, expectedUrl: URL): PullRequ
|
|
|
830
833
|
};
|
|
831
834
|
}
|
|
832
835
|
|
|
833
|
-
function selectPullRequest(
|
|
834
|
-
candidates: ListedPullRequest[],
|
|
835
|
-
pushTarget: PushTarget,
|
|
836
|
-
): ListedPullRequest | null {
|
|
837
|
-
const matching = candidates.filter((candidate) =>
|
|
838
|
-
candidate.url.hostname.toLowerCase() === pushTarget.repository.host &&
|
|
839
|
-
normalizeRepository(candidate.head.repository) === pushTarget.repository.normalizedName &&
|
|
840
|
-
candidate.head.ref === pushTarget.ref,
|
|
841
|
-
);
|
|
842
|
-
const open = matching.filter((candidate) => candidate.lifecycle === "open");
|
|
843
|
-
if (open.length > 1) fail("Find pull requests", "multiple open pull requests match current push target");
|
|
844
|
-
if (open.length === 1) {
|
|
845
|
-
if (pushTarget.remoteHeadOid === null) fail("Find pull requests", "remote push ref is absent for open pull request");
|
|
846
|
-
if (open[0].head.oid !== pushTarget.remoteHeadOid) {
|
|
847
|
-
fail("Find pull requests", "open pull request head does not match remote push ref");
|
|
848
|
-
}
|
|
849
|
-
return open[0];
|
|
850
|
-
}
|
|
851
|
-
if (pushTarget.remoteHeadOid === null) return null;
|
|
852
|
-
|
|
853
|
-
const historical = matching.filter((candidate) =>
|
|
854
|
-
candidate.lifecycle !== "open" && candidate.head.oid === pushTarget.remoteHeadOid
|
|
855
|
-
);
|
|
856
|
-
if (historical.length > 1) fail("Find pull requests", "multiple historical pull requests match remote push ref");
|
|
857
|
-
return historical[0] ?? null;
|
|
858
|
-
}
|
|
859
|
-
|
|
860
836
|
function ciStatus(checks: CheckState[]): CiStatus {
|
|
861
837
|
if (!checks.length) return "none";
|
|
862
838
|
let failed = false;
|
|
@@ -958,27 +934,6 @@ function parseBaseRefAuthority(
|
|
|
958
934
|
return oid(repository.ref.target.oid, "Read base ref", "target OID");
|
|
959
935
|
}
|
|
960
936
|
|
|
961
|
-
function parseBaseRefOid(output: string, candidate: ListedPullRequest): string {
|
|
962
|
-
return parseBaseRefAuthority(output, candidate.base);
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
function validatedCreationTarget(target: PullRequestTarget): PullRequestTarget {
|
|
966
|
-
if (!isRecord(target)) fail("Read creation target", "invalid target");
|
|
967
|
-
if (target.provenance !== "configured" && target.provenance !== "inferred") {
|
|
968
|
-
fail("Read creation target", "invalid provenance");
|
|
969
|
-
}
|
|
970
|
-
return {
|
|
971
|
-
provenance: target.provenance,
|
|
972
|
-
branch: text(target.branch, "Read creation target", "branch"),
|
|
973
|
-
remote: text(target.remote, "Read creation target", "remote"),
|
|
974
|
-
ref: text(target.ref, "Read creation target", "ref"),
|
|
975
|
-
repository: repositoryName(target.repository, "Read creation target", "repository"),
|
|
976
|
-
host: text(target.host, "Read creation target", "host").toLowerCase(),
|
|
977
|
-
fetchSource: text(target.fetchSource, "Read creation target", "fetch source"),
|
|
978
|
-
remoteOid: target.remoteOid === null ? null : oid(target.remoteOid, "Read creation target", "remote OID"),
|
|
979
|
-
};
|
|
980
|
-
}
|
|
981
|
-
|
|
982
937
|
function sameCreationTarget(left: PullRequestTarget, right: PullRequestTarget): boolean {
|
|
983
938
|
return left.provenance === right.provenance && left.branch === right.branch &&
|
|
984
939
|
left.remote === right.remote && left.ref === right.ref &&
|
|
@@ -1006,20 +961,19 @@ async function captureCreationIdentity(
|
|
|
1006
961
|
context: PullRequestLoadContext,
|
|
1007
962
|
target: PullRequestTarget,
|
|
1008
963
|
): Promise<CreationIdentity> {
|
|
1009
|
-
const validatedTarget = validatedCreationTarget(target);
|
|
1010
964
|
const branch = singleLine(
|
|
1011
965
|
(await execute(pi, context, "Read creation branch", "git", ["branch", "--show-current"])).stdout,
|
|
1012
966
|
"Read creation branch",
|
|
1013
967
|
"branch",
|
|
1014
968
|
);
|
|
1015
|
-
if (branch !==
|
|
969
|
+
if (branch !== target.branch) fail("Read creation branch", "branch changed");
|
|
1016
970
|
await validateCreationRef(pi, context, branch);
|
|
1017
971
|
const head = oid(singleLine(
|
|
1018
972
|
(await execute(pi, context, "Read creation HEAD", "git", ["rev-parse", "--verify", "HEAD^{commit}"])).stdout,
|
|
1019
973
|
"Read creation HEAD",
|
|
1020
974
|
"OID",
|
|
1021
975
|
), "Read creation HEAD", "OID");
|
|
1022
|
-
return { target
|
|
976
|
+
return { target, head };
|
|
1023
977
|
}
|
|
1024
978
|
|
|
1025
979
|
async function readConfiguredCreationBaseRef(
|
|
@@ -1139,20 +1093,12 @@ async function inspectCreationWorktree(
|
|
|
1139
1093
|
async function preflightCreation(
|
|
1140
1094
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1141
1095
|
context: PullRequestLoadContext,
|
|
1142
|
-
target: PullRequestTarget,
|
|
1143
|
-
explicitBaseRef: string | undefined,
|
|
1144
1096
|
identity: CreationIdentity,
|
|
1145
1097
|
): Promise<CreationPreflightResult> {
|
|
1146
|
-
const validatedTarget = validatedCreationTarget(target);
|
|
1147
|
-
if (!sameCreationTarget(identity.target, validatedTarget)) {
|
|
1148
|
-
fail("Read creation target", "target changed");
|
|
1149
|
-
}
|
|
1150
1098
|
const origin = await readRemoteAuthority(pi, context, "origin", true);
|
|
1151
1099
|
if (!origin) fail("Read creation repository", "origin is unavailable");
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1154
|
-
: await validateCreationRef(pi, context, explicitBaseRef);
|
|
1155
|
-
const baseRef = configuredBaseRef ?? await readDefaultCreationBaseRef(pi, context, origin);
|
|
1100
|
+
const baseRef = await readConfiguredCreationBaseRef(pi, context, identity.target.branch) ??
|
|
1101
|
+
await readDefaultCreationBaseRef(pi, context, origin);
|
|
1156
1102
|
const relation = await inspectCreationRepositoryRelation(pi, context, identity.target, origin, baseRef);
|
|
1157
1103
|
const worktree = await inspectCreationWorktree(pi, context);
|
|
1158
1104
|
if (relation === "same-ref") return { kind: relation, worktree };
|
|
@@ -1196,10 +1142,9 @@ export async function preflightPullRequestCreation(
|
|
|
1196
1142
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1197
1143
|
context: PullRequestLoadContext,
|
|
1198
1144
|
target: PullRequestTarget,
|
|
1199
|
-
explicitBaseRef?: string,
|
|
1200
1145
|
): Promise<PullRequestCreationPreflight> {
|
|
1201
1146
|
const identity = await captureCreationIdentity(pi, context, target);
|
|
1202
|
-
const result = await preflightCreation(pi, context,
|
|
1147
|
+
const result = await preflightCreation(pi, context, identity);
|
|
1203
1148
|
if (result.kind === "same-ref") fail("Read creation repository", "head and base refs match");
|
|
1204
1149
|
return result.preflight;
|
|
1205
1150
|
}
|
|
@@ -1208,11 +1153,10 @@ async function creationDiscovery(
|
|
|
1208
1153
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1209
1154
|
context: PullRequestLoadContext,
|
|
1210
1155
|
target: PullRequestTarget,
|
|
1211
|
-
explicitBaseRef: string | undefined,
|
|
1212
1156
|
identity?: CreationIdentity,
|
|
1213
1157
|
): Promise<CurrentPullRequestDiscovery> {
|
|
1214
1158
|
const captured = identity ?? await captureCreationIdentity(pi, context, target);
|
|
1215
|
-
const result = await preflightCreation(pi, context,
|
|
1159
|
+
const result = await preflightCreation(pi, context, captured);
|
|
1216
1160
|
return {
|
|
1217
1161
|
kind: "none",
|
|
1218
1162
|
creationTarget: target,
|
|
@@ -1569,7 +1513,7 @@ async function readBaseRefOid(
|
|
|
1569
1513
|
"-F",
|
|
1570
1514
|
`qualifiedName=refs/heads/${candidate.base.ref}`,
|
|
1571
1515
|
]);
|
|
1572
|
-
return
|
|
1516
|
+
return parseBaseRefAuthority(result.stdout, candidate.base);
|
|
1573
1517
|
}
|
|
1574
1518
|
|
|
1575
1519
|
export async function readPullRequestBaseRefOid(
|
|
@@ -1730,14 +1674,11 @@ export async function findExactHeadPullRequests(
|
|
|
1730
1674
|
const host = text(target.host, "Find pull requests", "host").toLowerCase();
|
|
1731
1675
|
const repository = repositoryName(target.repository, "Find pull requests", "head repository");
|
|
1732
1676
|
const ref = text(target.ref, "Find pull requests", "head ref");
|
|
1733
|
-
const
|
|
1734
|
-
|
|
1735
|
-
ref,
|
|
1736
|
-
});
|
|
1677
|
+
const pushTarget = { repository: { host, nameWithOwner: repository, normalizedName: normalizeRepository(repository) }, ref };
|
|
1678
|
+
const candidates = await enumerateSearchPullRequests(pi, context, pushTarget);
|
|
1737
1679
|
if (candidates === null) return fail("Find pull requests", "published head ref is unavailable");
|
|
1738
1680
|
return candidates.filter((candidate) =>
|
|
1739
|
-
candidate.lifecycle === "open" && candidate.headRepository
|
|
1740
|
-
normalizeRepository(candidate.headRepository) === normalizeRepository(repository) && candidate.headRef === ref
|
|
1681
|
+
candidate.lifecycle === "open" && matchesPushTarget(candidate.url, candidate.headRepository, candidate.headRef, pushTarget)
|
|
1741
1682
|
);
|
|
1742
1683
|
}
|
|
1743
1684
|
|
|
@@ -1774,9 +1715,8 @@ export async function loadCurrentPullRequest(
|
|
|
1774
1715
|
context: PullRequestLoadContext,
|
|
1775
1716
|
inspectedLocal?: LocalMergeSafety,
|
|
1776
1717
|
observed?: unknown,
|
|
1777
|
-
explicitCreationBase?: string,
|
|
1778
1718
|
): Promise<CurrentPullRequestDiscovery> {
|
|
1779
|
-
return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed
|
|
1719
|
+
return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed);
|
|
1780
1720
|
}
|
|
1781
1721
|
|
|
1782
1722
|
async function loadCurrentPullRequestInternal(
|
|
@@ -1784,7 +1724,6 @@ async function loadCurrentPullRequestInternal(
|
|
|
1784
1724
|
context: PullRequestLoadContext,
|
|
1785
1725
|
inspectedLocal: LocalMergeSafety | undefined,
|
|
1786
1726
|
observed: unknown,
|
|
1787
|
-
explicitCreationBase: string | undefined,
|
|
1788
1727
|
creationIdentity?: CreationIdentity,
|
|
1789
1728
|
): Promise<CurrentPullRequestDiscovery> {
|
|
1790
1729
|
const read = await readPushTarget(pi, context);
|
|
@@ -1809,15 +1748,13 @@ async function loadCurrentPullRequestInternal(
|
|
|
1809
1748
|
const target = publicTarget(inferred.target);
|
|
1810
1749
|
if (creationIdentity === undefined) {
|
|
1811
1750
|
const captured = await captureCreationIdentity(pi, context, target);
|
|
1812
|
-
return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed,
|
|
1813
|
-
}
|
|
1814
|
-
if (!sameCreationTarget(creationIdentity.target, validatedCreationTarget(target))) {
|
|
1815
|
-
fail("Read creation target", "target changed");
|
|
1751
|
+
return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed, captured);
|
|
1816
1752
|
}
|
|
1753
|
+
if (!sameCreationTarget(creationIdentity.target, target)) fail("Read creation target", "target changed");
|
|
1817
1754
|
if (!canLinkTarget(await readLinkConfiguration(pi, context, inferred.target), inferred.target)) {
|
|
1818
1755
|
return { kind: "blocked", issue: { kind: "link-configuration", remote: inferred.target.remote } };
|
|
1819
1756
|
}
|
|
1820
|
-
return await creationDiscovery(pi, context, target,
|
|
1757
|
+
return await creationDiscovery(pi, context, target, creationIdentity);
|
|
1821
1758
|
}
|
|
1822
1759
|
pushTarget = inferred.target;
|
|
1823
1760
|
} else {
|
|
@@ -1860,64 +1797,32 @@ async function loadCurrentPullRequestInternal(
|
|
|
1860
1797
|
if (search.kind === "target-invalid") {
|
|
1861
1798
|
return { kind: "blocked", issue: { kind: "target-invalid" } };
|
|
1862
1799
|
}
|
|
1863
|
-
|
|
1864
|
-
|
|
1800
|
+
// The loaded view may differ from the search candidate; it must still match the push target exactly.
|
|
1801
|
+
const loaded = search.kind === "candidate" ? search.pullRequest : null;
|
|
1802
|
+
const matches = loaded !== null && matchesPushTarget(loaded.url, loaded.head.repository, loaded.head.ref, pushTarget);
|
|
1803
|
+
const oidMismatch = (url: URL): CurrentPullRequestDiscovery => ({
|
|
1804
|
+
kind: "blocked",
|
|
1805
|
+
issue: { kind: "candidate-oid-mismatch", remote: pushTarget.remote, urls: [url] },
|
|
1806
|
+
});
|
|
1807
|
+
let candidate: ListedPullRequest;
|
|
1865
1808
|
if (pushTarget.provenance === "inferred") {
|
|
1866
|
-
|
|
1867
|
-
item.lifecycle === "open" &&
|
|
1868
|
-
item.url.hostname.toLowerCase() === pushTarget.repository.host &&
|
|
1869
|
-
normalizeRepository(item.head.repository) === pushTarget.repository.normalizedName &&
|
|
1870
|
-
item.head.ref === pushTarget.ref
|
|
1871
|
-
);
|
|
1872
|
-
if (matching.length > 1) {
|
|
1873
|
-
return {
|
|
1874
|
-
kind: "blocked",
|
|
1875
|
-
issue: { kind: "candidate-prs-ambiguous", urls: matching.map(({ url }) => url).sort((a, b) => a.href.localeCompare(b.href)) },
|
|
1876
|
-
};
|
|
1877
|
-
}
|
|
1878
|
-
if (matching.length === 0) {
|
|
1809
|
+
if (!matches || loaded.lifecycle !== "open") {
|
|
1879
1810
|
return { kind: "blocked", issue: { kind: "published-without-pr", remote: pushTarget.remote } };
|
|
1880
1811
|
}
|
|
1881
|
-
|
|
1882
|
-
if (candidate.head.oid !== pushTarget.remoteHeadOid) {
|
|
1883
|
-
return {
|
|
1884
|
-
kind: "blocked",
|
|
1885
|
-
issue: { kind: "candidate-oid-mismatch", remote: pushTarget.remote, urls: [candidate.url] },
|
|
1886
|
-
};
|
|
1887
|
-
}
|
|
1812
|
+
if (loaded.head.oid !== pushTarget.remoteHeadOid) return oidMismatch(loaded.url);
|
|
1888
1813
|
if (!canLinkTarget(await readLinkConfiguration(pi, context, pushTarget), pushTarget)) {
|
|
1889
1814
|
return { kind: "blocked", issue: { kind: "link-configuration", remote: pushTarget.remote } };
|
|
1890
1815
|
}
|
|
1816
|
+
candidate = loaded;
|
|
1817
|
+
} else if (matches && loaded.lifecycle === "open") {
|
|
1818
|
+
if (pushTarget.remoteHeadOid === null) return { kind: "blocked", issue: { kind: "target-invalid" } };
|
|
1819
|
+
if (loaded.head.oid !== pushTarget.remoteHeadOid) return oidMismatch(loaded.url);
|
|
1820
|
+
candidate = loaded;
|
|
1821
|
+
} else if (matches && loaded.head.oid === pushTarget.remoteHeadOid) {
|
|
1822
|
+
candidate = loaded;
|
|
1891
1823
|
} else {
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
} catch (error) {
|
|
1895
|
-
if (!(error instanceof PullRequestLoadError)) throw error;
|
|
1896
|
-
const matching = candidates.filter((item) =>
|
|
1897
|
-
normalizeRepository(item.head.repository) === pushTarget.repository.normalizedName && item.head.ref === pushTarget.ref
|
|
1898
|
-
);
|
|
1899
|
-
const urls = matching.map(({ url }) => url).sort((a, b) => a.href.localeCompare(b.href));
|
|
1900
|
-
if (error.message.includes("multiple ")) {
|
|
1901
|
-
return {
|
|
1902
|
-
kind: "blocked",
|
|
1903
|
-
issue: { kind: "candidate-prs-ambiguous", urls },
|
|
1904
|
-
};
|
|
1905
|
-
}
|
|
1906
|
-
if (error.message.includes("does not match remote push ref")) {
|
|
1907
|
-
return {
|
|
1908
|
-
kind: "blocked",
|
|
1909
|
-
issue: { kind: "candidate-oid-mismatch", remote: pushTarget.remote, urls },
|
|
1910
|
-
};
|
|
1911
|
-
}
|
|
1912
|
-
if (error.message.includes("remote push ref is absent")) {
|
|
1913
|
-
return { kind: "blocked", issue: { kind: "target-invalid" } };
|
|
1914
|
-
}
|
|
1915
|
-
throw error;
|
|
1916
|
-
}
|
|
1917
|
-
if (candidate === null) {
|
|
1918
|
-
if (creationIdentity !== undefined) fail("Read creation target", "target changed");
|
|
1919
|
-
return await creationDiscovery(pi, context, publicTarget(pushTarget), explicitCreationBase);
|
|
1920
|
-
}
|
|
1824
|
+
if (creationIdentity !== undefined) fail("Read creation target", "target changed");
|
|
1825
|
+
return await creationDiscovery(pi, context, publicTarget(pushTarget));
|
|
1921
1826
|
}
|
|
1922
1827
|
|
|
1923
1828
|
return {
|
package/extensions/pr-merge.ts
CHANGED
|
@@ -3,8 +3,6 @@ import {
|
|
|
3
3
|
inspectWorktree,
|
|
4
4
|
isRecord,
|
|
5
5
|
readHead,
|
|
6
|
-
requiredOid,
|
|
7
|
-
requiredText,
|
|
8
6
|
runChecked,
|
|
9
7
|
} from "./pr-execution.ts";
|
|
10
8
|
import type { LocalMergeSafety } from "./pr-routing.ts";
|
|
@@ -33,17 +31,8 @@ type ExecuteGitHubMergeInput = InspectLocalMergeSafetyInput & {
|
|
|
33
31
|
revalidateReadiness: (local: InspectedLocalMergeSafety) => Promise<void>;
|
|
34
32
|
};
|
|
35
33
|
|
|
36
|
-
function validateInspectionInput(input: InspectLocalMergeSafetyInput): void {
|
|
37
|
-
requiredText(input.cwd, "cwd");
|
|
38
|
-
requiredOid(input.expectedHead, "expected PR head");
|
|
39
|
-
requiredText(input.headFetchSource, "PR head fetch source");
|
|
40
|
-
if (typeof input.exec !== "function") throw new TypeError("exec must be a function");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
34
|
/** Inspect local state without changing branches, the index, or the worktree. */
|
|
44
35
|
export async function inspectLocalMergeSafety(input: InspectLocalMergeSafetyInput): Promise<InspectedLocalMergeSafety> {
|
|
45
|
-
validateInspectionInput(input);
|
|
46
|
-
|
|
47
36
|
const worktree = await inspectWorktree(input.exec, { cwd: input.cwd });
|
|
48
37
|
await runChecked(input.exec, "git", [
|
|
49
38
|
"fetch",
|
|
@@ -77,17 +66,6 @@ export async function inspectLocalMergeSafety(input: InspectLocalMergeSafetyInpu
|
|
|
77
66
|
return { worktree, head: expectedAncestor.code === 0 ? "ahead" : "diverged", headOid };
|
|
78
67
|
}
|
|
79
68
|
|
|
80
|
-
function validateExecuteInput(input: ExecuteGitHubMergeInput): void {
|
|
81
|
-
validateInspectionInput(input);
|
|
82
|
-
requiredText(input.pullRequestId, "pullRequestId");
|
|
83
|
-
requiredText(input.hostname, "hostname");
|
|
84
|
-
if (!isRecord(input.expectedBase)) throw new TypeError("expectedBase must be an object");
|
|
85
|
-
requiredText(input.expectedBase.repository, "expected base repository");
|
|
86
|
-
requiredText(input.expectedBase.ref, "expected base ref");
|
|
87
|
-
requiredText(input.expectedBase.oid, "expected base OID");
|
|
88
|
-
if (typeof input.revalidateReadiness !== "function") throw new TypeError("revalidateReadiness must be a function");
|
|
89
|
-
}
|
|
90
|
-
|
|
91
69
|
function parseGraphQLResponse(output: string, action: string): Record<string, unknown> {
|
|
92
70
|
let value: unknown;
|
|
93
71
|
try {
|
|
@@ -124,7 +102,6 @@ function parseMergeResponse(output: string, expectedId: string): void {
|
|
|
124
102
|
}
|
|
125
103
|
|
|
126
104
|
export async function executeGitHubMerge(input: ExecuteGitHubMergeInput): Promise<void> {
|
|
127
|
-
validateExecuteInput(input);
|
|
128
105
|
const local = await inspectLocalMergeSafety(input);
|
|
129
106
|
if (local.worktree !== "clean" || (local.head !== "equal" && local.head !== "behind")) {
|
|
130
107
|
throw new Error(`Local merge safety check failed: worktree is ${local.worktree}, HEAD is ${local.head}`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnBounded, type Exec } from "@henryqw/pi-process";
|
|
2
2
|
import { cloneCurrentPullRequest, loadCurrentPullRequest, readValidatedRemoteAuthority, samePullRequestSnapshot, type CurrentPullRequest } from "./pr-github.ts";
|
|
3
|
-
import { extensionExecApi, inspectWorktree, inspectWorktreeState, isAncestor, parseNulPaths, parseStatusSnapshot, readHead, readRemoteOid, requiredText, runChecked,
|
|
3
|
+
import { extensionExecApi, inspectWorktree, inspectWorktreeState, isAncestor, parseNulPaths, parseStatusSnapshot, readHead, readRemoteOid, requiredText, runChecked, validatePaths, withWorktreeLock } from "./pr-execution.ts";
|
|
4
4
|
|
|
5
5
|
type Check = { command: string; args: string[] };
|
|
6
6
|
type Options = { cwd: string; authority: CurrentPullRequest; signal?: AbortSignal; agentDir?: string; exec?: Exec; loadCurrentPullRequest?: typeof loadCurrentPullRequest };
|
|
@@ -33,12 +33,11 @@ export class PullRequestWorkPublisher {
|
|
|
33
33
|
|
|
34
34
|
private async authorityCheck(): Promise<void> {
|
|
35
35
|
const { cwd, signal } = this.options;
|
|
36
|
-
const discovery = await this.load(extensionExecApi(this.exec, cwd, signal), { cwd, signal
|
|
36
|
+
const discovery = await this.load(extensionExecApi(this.exec, cwd, signal), { cwd, signal });
|
|
37
37
|
if (discovery.kind !== "current" || !samePullRequestSnapshot(this.authority, discovery.pullRequest)) {
|
|
38
38
|
throw new Error("Local publication cancelled: PR identity or remote head changed");
|
|
39
39
|
}
|
|
40
|
-
const remote = await readValidatedRemoteAuthority(extensionExecApi(this.exec, cwd, signal),
|
|
41
|
-
{ cwd, signal: signal ?? new AbortController().signal }, this.authority.target.remote);
|
|
40
|
+
const remote = await readValidatedRemoteAuthority(extensionExecApi(this.exec, cwd, signal), { cwd, signal }, this.authority.target.remote);
|
|
42
41
|
if (remote.fetchSource !== this.authority.target.fetchSource || remote.host !== this.authority.target.host ||
|
|
43
42
|
remote.repository.toLowerCase() !== this.authority.target.repository.toLowerCase()) {
|
|
44
43
|
throw new Error("Local publication cancelled: push destination changed");
|
|
@@ -60,8 +59,7 @@ export class PullRequestWorkPublisher {
|
|
|
60
59
|
throw new Error("Local HEAD is not a descendant of the published PR head");
|
|
61
60
|
}
|
|
62
61
|
const status = (await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions())).stdout;
|
|
63
|
-
const paths = [...parseStatusSnapshot(status).keys()];
|
|
64
|
-
parseNulPaths(paths.map((path) => `${path}\0`).join(""), "Pending paths");
|
|
62
|
+
const paths = validatePaths([...parseStatusSnapshot(status).keys()], "Pending paths");
|
|
65
63
|
this.initialHead = head;
|
|
66
64
|
this.status = status;
|
|
67
65
|
return { paths, head };
|
|
@@ -70,7 +68,7 @@ export class PullRequestWorkPublisher {
|
|
|
70
68
|
|
|
71
69
|
async commit(pathsInput: string[], message: string): Promise<{ head: string }> {
|
|
72
70
|
if (!this.initialHead || this.status === undefined || this.consumed) throw new Error("Pending changes must be inspected first");
|
|
73
|
-
const paths =
|
|
71
|
+
const paths = validatePaths(pathsInput, "Commit paths");
|
|
74
72
|
if (!paths.length || paths.some((path) => !parseStatusSnapshot(this.status!).has(path))) {
|
|
75
73
|
throw new Error("Commit paths must be reviewed pending paths");
|
|
76
74
|
}
|
|
@@ -94,10 +92,6 @@ export class PullRequestWorkPublisher {
|
|
|
94
92
|
|
|
95
93
|
async validate(checks: Check[]): Promise<{ head: string; checks: number }> {
|
|
96
94
|
if (!this.initialHead || this.validatedHead) throw new Error("Local publication validation is unavailable");
|
|
97
|
-
if (!Array.isArray(checks) || checks.length > 32 || checks.some(({ command, args }) =>
|
|
98
|
-
typeof command !== "string" || !command || !Array.isArray(args) || args.some((arg) => typeof arg !== "string"))) {
|
|
99
|
-
throw new Error("Invalid validation commands");
|
|
100
|
-
}
|
|
101
95
|
return await withWorktreeLock(this.options.cwd, async () => {
|
|
102
96
|
await this.authorityCheck();
|
|
103
97
|
if (await inspectWorktree(this.exec, this.execOptions()) !== "clean") throw new Error("Unrelated pending changes remain; ask about ownership before publishing");
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
requiredOid,
|
|
25
25
|
resolveRepositoryFetchSource,
|
|
26
26
|
runChecked,
|
|
27
|
-
|
|
27
|
+
validatePaths,
|
|
28
28
|
withWorktreeLock,
|
|
29
29
|
} from "./pr-execution.ts";
|
|
30
30
|
|
|
@@ -60,6 +60,8 @@ function sameAuthority(frozen: CurrentPullRequest, fresh: CurrentPullRequest): b
|
|
|
60
60
|
type RebaseRecovery = { version: 1; phase: "pending" | "verified" | "published"; identity: string;
|
|
61
61
|
original: string; remote: string; verified: string | null };
|
|
62
62
|
|
|
63
|
+
const isOid = (value: unknown): value is string => typeof value === "string" && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value);
|
|
64
|
+
|
|
63
65
|
function recoveryIdentity(pr: CurrentPullRequest): string {
|
|
64
66
|
return JSON.stringify([pr.id, pr.number, pr.url.href, pr.host, pr.base.repository, pr.base.ref, pr.base.oid,
|
|
65
67
|
pr.head.repository, pr.head.ref, pr.target.provenance, pr.target.branch, pr.target.remote,
|
|
@@ -83,9 +85,7 @@ async function readRecovery(path: string, signal?: AbortSignal): Promise<RebaseR
|
|
|
83
85
|
if (!isRecord(record) || Object.keys(record).sort().join(",") !== "identity,original,phase,remote,verified,version" ||
|
|
84
86
|
record.version !== 1 || !["pending", "verified", "published"].includes(record.phase as string) ||
|
|
85
87
|
typeof record.identity !== "string" || record.identity.length > 2048 ||
|
|
86
|
-
|
|
87
|
-
typeof record.remote !== "string" || !/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(record.remote) ||
|
|
88
|
-
(record.verified !== null && (typeof record.verified !== "string" || !/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(record.verified))) ||
|
|
88
|
+
!isOid(record.original) || !isOid(record.remote) || (record.verified !== null && !isOid(record.verified)) ||
|
|
89
89
|
(record.phase === "pending" && record.verified !== null) ||
|
|
90
90
|
(record.phase !== "pending" && record.verified === null)) {
|
|
91
91
|
throw new Error(`Invalid branch update recovery is preserved at ${path}`);
|
|
@@ -142,7 +142,7 @@ export class PullRequestBranchUpdater {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
private context(): PullRequestLoadContext {
|
|
145
|
-
return { cwd: this.cwd, signal: this.signal
|
|
145
|
+
return { cwd: this.cwd, signal: this.signal };
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
private async freshAuthority(expectedHead: string, requireClean: boolean, published = false): Promise<CurrentPullRequest> {
|
|
@@ -257,7 +257,7 @@ export class PullRequestBranchUpdater {
|
|
|
257
257
|
if (mergeCommits) throw new Error("Branch update cannot rebase a branch with merge commits; preserve its resolutions manually");
|
|
258
258
|
await this.freshAuthority(this.authority.head.oid, true);
|
|
259
259
|
await this.writeRecovery("pending", null);
|
|
260
|
-
const result = await this.exec("git", ["-c", "core.editor=true", "-c", "rebase.backend=merge", "rebase", "--no-autostash", "--onto", this.authority.base.oid, mergeBase], this.execOptions());
|
|
260
|
+
const result = await this.exec("git", ["-c", "core.editor=true", "-c", "rebase.backend=merge", "-c", "rebase.updateRefs=false", "rebase", "--no-autostash", "--onto", this.authority.base.oid, mergeBase], this.execOptions());
|
|
261
261
|
if (result.killed) throw new Error("git rebase was killed; its outcome is unknown");
|
|
262
262
|
if (result.code === 0) return await this.verifyRebase();
|
|
263
263
|
try {
|
|
@@ -272,7 +272,10 @@ export class PullRequestBranchUpdater {
|
|
|
272
272
|
if (this.state.phase !== "conflict-awaiting-user" || !this.state.conflict) {
|
|
273
273
|
throw new Error("Branch rebase has no conflict awaiting continuation");
|
|
274
274
|
}
|
|
275
|
-
const paths =
|
|
275
|
+
const paths = validatePaths(resolvedPaths, "Resolved conflict paths");
|
|
276
|
+
if (this.state.conflict.paths.some((path) => !paths.includes(path))) {
|
|
277
|
+
throw new Error("Resolved paths must include every original conflict path");
|
|
278
|
+
}
|
|
276
279
|
return await withWorktreeLock(this.cwd, async () => {
|
|
277
280
|
const discovery = await this.load(this.pi(), this.context());
|
|
278
281
|
if (discovery.kind !== "current" || !sameAuthority(this.authority, discovery.pullRequest)) {
|
|
@@ -289,7 +292,7 @@ export class PullRequestBranchUpdater {
|
|
|
289
292
|
await runChecked(this.exec, "git", ["--literal-pathspecs", "add", "--", ...paths], this.execOptions());
|
|
290
293
|
const unmerged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.execOptions())).stdout, "Unmerged paths");
|
|
291
294
|
if (unmerged.length) throw new Error(`Conflict paths remain unresolved: ${unmerged.join(", ")}`);
|
|
292
|
-
const result = await this.exec("git", ["-c", "core.editor=true", "rebase", "--continue"], this.execOptions());
|
|
295
|
+
const result = await this.exec("git", ["-c", "core.editor=true", "-c", "rebase.updateRefs=false", "rebase", "--continue"], this.execOptions());
|
|
293
296
|
if (result.killed) throw new Error("git rebase continuation was killed; its outcome is unknown");
|
|
294
297
|
if (result.code === 0) return await this.verifyRebase();
|
|
295
298
|
return await this.captureConflict();
|
package/extensions/pr.ts
CHANGED
|
@@ -8,12 +8,14 @@ import {
|
|
|
8
8
|
} from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
10
10
|
import { createHerdrClient } from "@henryqw/pi-herdr";
|
|
11
|
-
import { Type } from "typebox";
|
|
11
|
+
import { Type, type Static, type TObject, type TSchema, type TUnion } from "typebox";
|
|
12
|
+
import { Check, Errors } from "typebox/value";
|
|
12
13
|
import { PullRequestCiFixer, type PullRequestCiFixOptions } from "./pr-ci.ts";
|
|
13
14
|
import { PullRequestCommentSweep, type PullRequestCommentSweepOptions } from "./pr-comment-sweep.ts";
|
|
14
15
|
import { needsFeedbackAttention } from "./pr-feedback-attention.ts";
|
|
15
16
|
import {
|
|
16
17
|
createPrCommandHandler,
|
|
18
|
+
WORKFLOW_ROUTES,
|
|
17
19
|
type PrCommandDependencies,
|
|
18
20
|
type PrCommandInvocation,
|
|
19
21
|
type WorkflowPromptIdentity,
|
|
@@ -48,7 +50,6 @@ const OBSERVATION_ENTRY = "pi-pr-observation";
|
|
|
48
50
|
const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
|
|
49
51
|
const GIT_COMMIT = /(?:^|[;&|]\s*|\n\s*)git\s+commit(?=\s|$|[;&|])/;
|
|
50
52
|
const GIT_PUSH = /(?:^|[;&|]\s*|\n\s*)git\s+push(?=\s|$|[;&|])/;
|
|
51
|
-
const WORKFLOW_ROUTES = new Set(["create", "publish-work", "update-branch", "sweep", "fix-ci"]);
|
|
52
53
|
const DELEGATED_TOOLS = new Set(["delegate_task"]);
|
|
53
54
|
const CLOSED = { additionalProperties: false } as const;
|
|
54
55
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
@@ -76,13 +77,38 @@ const SweepChecks = Type.Array(Type.Object({
|
|
|
76
77
|
args: Type.Array(Type.String({ maxLength: 4_096 }), { maxItems: 256 }),
|
|
77
78
|
}, CLOSED), { maxItems: 32 });
|
|
78
79
|
|
|
79
|
-
// OpenAI-compatible endpoints
|
|
80
|
-
|
|
80
|
+
// Strict OpenAI-compatible endpoints reject a parameters root without type "object", and Claude Code
|
|
81
|
+
// drops tools whose root is a union. Register a flat object root derived from the per-action variants
|
|
82
|
+
// and enforce the exact variant at execution (also after any before_tool argument replacement).
|
|
83
|
+
function flatRoot(actions: TUnion<TObject[]>): TObject {
|
|
84
|
+
const properties = new Map<string, TSchema[]>();
|
|
85
|
+
const requiredCount = new Map<string, number>();
|
|
86
|
+
for (const variant of actions.anyOf) {
|
|
87
|
+
for (const [key, schema] of Object.entries(variant.properties)) {
|
|
88
|
+
const seen = properties.get(key) ?? [];
|
|
89
|
+
if (!seen.some((candidate) => JSON.stringify(candidate) === JSON.stringify(schema))) seen.push(schema);
|
|
90
|
+
properties.set(key, seen);
|
|
91
|
+
if (variant.required?.includes(key)) requiredCount.set(key, (requiredCount.get(key) ?? 0) + 1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Type.Object(Object.fromEntries([...properties].map(([key, schemas]) => {
|
|
95
|
+
const schema = schemas.length === 1 ? schemas[0]! : Type.Union(schemas);
|
|
96
|
+
return [key, requiredCount.get(key) === actions.anyOf.length ? schema : Type.Optional(schema)];
|
|
97
|
+
})), CLOSED);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function checkedAction<T extends TUnion<TObject[]>>(actions: T, args: unknown): Static<T> {
|
|
101
|
+
if (Check(actions, args)) return args as Static<T>;
|
|
102
|
+
const issue = [...Errors(actions, args)][0];
|
|
103
|
+
throw new Error(`Tool arguments do not match one action${issue ? `: ${issue.message}` : ""}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const UpdateBranchActions = Type.Union([
|
|
81
107
|
Type.Object({ runId: RouteRunId, action: Type.Literal("rebase") }, CLOSED),
|
|
82
108
|
Type.Object({ runId: RouteRunId, action: Type.Literal("continue"), resolvedPaths: ResolvedPaths }, CLOSED),
|
|
83
109
|
Type.Object({ runId: RouteRunId, action: Type.Literal("publish") }, CLOSED),
|
|
84
|
-
]
|
|
85
|
-
const
|
|
110
|
+
]);
|
|
111
|
+
const CreateActions = Type.Union([
|
|
86
112
|
Type.Object({ runId: RouteRunId, action: Type.Literal("prepare") }, CLOSED),
|
|
87
113
|
Type.Object({ runId: RouteRunId, action: Type.Literal("inspect") }, CLOSED),
|
|
88
114
|
Type.Object({ runId: RouteRunId, action: Type.Literal("commit"), ownedPaths: OwnedPaths, message: Type.String({ minLength: 1, maxLength: 256 }) }, CLOSED),
|
|
@@ -94,8 +120,8 @@ const CreateParameters = Type.Union([
|
|
|
94
120
|
title: Type.String({ minLength: 1, maxLength: 256 }),
|
|
95
121
|
body: Type.String({ maxLength: 65_536 }),
|
|
96
122
|
}, CLOSED),
|
|
97
|
-
]
|
|
98
|
-
const
|
|
123
|
+
]);
|
|
124
|
+
const SweepActions = Type.Union([
|
|
99
125
|
Type.Object({ runId: RouteRunId, action: Type.Literal("start") }, CLOSED),
|
|
100
126
|
Type.Object({ runId: RouteRunId, action: Type.Literal("resume") }, CLOSED),
|
|
101
127
|
Type.Object({ runId: RouteRunId, action: Type.Literal("show"), guard: SweepGuard, id: Type.String({ minLength: 1, maxLength: 1_024 }) }, CLOSED),
|
|
@@ -111,17 +137,17 @@ const SweepParameters = Type.Union([
|
|
|
111
137
|
Type.Object({ runId: RouteRunId, action: Type.Literal("refresh"), guard: SweepGuard }, CLOSED),
|
|
112
138
|
Type.Object({ runId: RouteRunId, action: Type.Literal("resolve"), guard: SweepGuard }, CLOSED),
|
|
113
139
|
Type.Object({ runId: RouteRunId, action: Type.Literal("finalize"), guard: SweepGuard, checks: SweepChecks }, CLOSED),
|
|
114
|
-
]
|
|
115
|
-
const
|
|
140
|
+
]);
|
|
141
|
+
const WorkActions = Type.Union([
|
|
116
142
|
Type.Object({ runId: RouteRunId, action: Type.Literal("inspect") }, CLOSED),
|
|
117
143
|
Type.Object({ runId: RouteRunId, action: Type.Literal("commit"), ownedPaths: OwnedPaths, message: Type.String({ minLength: 1, maxLength: 256 }) }, CLOSED),
|
|
118
144
|
Type.Object({ runId: RouteRunId, action: Type.Literal("validate"), checks: SweepChecks }, CLOSED),
|
|
119
145
|
Type.Object({ runId: RouteRunId, action: Type.Literal("publish") }, CLOSED),
|
|
120
|
-
]
|
|
121
|
-
const
|
|
146
|
+
]);
|
|
147
|
+
const FixCiActions = Type.Union([
|
|
122
148
|
Type.Object({ runId: RouteRunId, action: Type.Literal("collect") }, CLOSED),
|
|
123
149
|
Type.Object({ runId: RouteRunId, action: Type.Literal("publish") }, CLOSED),
|
|
124
|
-
]
|
|
150
|
+
]);
|
|
125
151
|
|
|
126
152
|
type UpdateBranchWorkflow = Pick<PullRequestBranchUpdater, "state" | "recoveryLaunchAction" | "rebase" | "continue" | "publish">;
|
|
127
153
|
type CreateWorkflow = Pick<PullRequestCreator, "state" | "prepare" | "inspect" | "commit" | "verify" | "push" | "publish">;
|
|
@@ -247,15 +273,9 @@ export default function pullRequestExtension(
|
|
|
247
273
|
const newRunId = dependencies.newRunId ?? randomUUID;
|
|
248
274
|
let context: ExtensionContext | undefined;
|
|
249
275
|
let observation: PullRequestObservation | undefined;
|
|
250
|
-
const load: typeof loadCurrentPullRequest = async (
|
|
251
|
-
api,
|
|
252
|
-
loadContext,
|
|
253
|
-
inspectedLocal,
|
|
254
|
-
_observed,
|
|
255
|
-
explicitCreationBase,
|
|
256
|
-
) => {
|
|
276
|
+
const load: typeof loadCurrentPullRequest = async (api, loadContext, inspectedLocal) => {
|
|
257
277
|
const generation = sessionGeneration;
|
|
258
|
-
const discovery = await discover(api, loadContext, inspectedLocal, observation
|
|
278
|
+
const discovery = await discover(api, loadContext, inspectedLocal, observation);
|
|
259
279
|
if (generation !== sessionGeneration) return discovery;
|
|
260
280
|
if (discovery.kind === "current") {
|
|
261
281
|
const current = pullRequestObservation(discovery.pullRequest);
|
|
@@ -302,7 +322,6 @@ export default function pullRequestExtension(
|
|
|
302
322
|
invocation.assertCurrent();
|
|
303
323
|
if (workflowContext) throw new Error(`PR workflow ${workflowContext.runId} is still active`);
|
|
304
324
|
const runId = newRunId();
|
|
305
|
-
if (!UUID.test(runId)) throw new Error("PR workflow runId generator returned an invalid UUID");
|
|
306
325
|
const common: WorkflowContextBase = {
|
|
307
326
|
runId,
|
|
308
327
|
sessionGeneration: invocation.sessionGeneration,
|
|
@@ -432,6 +451,8 @@ export default function pullRequestExtension(
|
|
|
432
451
|
selected.controller.signal.throwIfAborted();
|
|
433
452
|
if (worktree !== selected.worktree) throw new Error("PR workflow worktree is wrong or stale");
|
|
434
453
|
selected.usedSinceSettlement = true;
|
|
454
|
+
// Drained follow-ups need not emit before_agent_start; this run has been consumed.
|
|
455
|
+
selected.queuedPrompt = undefined;
|
|
435
456
|
return toolResult(await action(selected as Extract<WorkflowContext, { route: Route }>));
|
|
436
457
|
} finally {
|
|
437
458
|
signal?.removeEventListener("abort", abortRun);
|
|
@@ -442,9 +463,10 @@ export default function pullRequestExtension(
|
|
|
442
463
|
name: "pi_pr_update_branch",
|
|
443
464
|
label: "Update PR Branch",
|
|
444
465
|
description: "Run one guarded action for the /pr branch-update route.",
|
|
445
|
-
parameters:
|
|
466
|
+
parameters: flatRoot(UpdateBranchActions),
|
|
446
467
|
executionMode: "sequential",
|
|
447
|
-
async execute(_toolCallId,
|
|
468
|
+
async execute(_toolCallId, raw, signal, _onUpdate, ctx) {
|
|
469
|
+
const params = checkedAction(UpdateBranchActions, raw);
|
|
448
470
|
return executeWorkflowAction(params.runId, "update-branch", ctx, signal, async (selected) => {
|
|
449
471
|
switch (params.action) {
|
|
450
472
|
case "rebase": return await selected.workflow.rebase();
|
|
@@ -463,9 +485,10 @@ export default function pullRequestExtension(
|
|
|
463
485
|
name: "pi_pr_create",
|
|
464
486
|
label: "Create Pull Request",
|
|
465
487
|
description: "Run one guarded action for the /pr creation route.",
|
|
466
|
-
parameters:
|
|
488
|
+
parameters: flatRoot(CreateActions),
|
|
467
489
|
executionMode: "sequential",
|
|
468
|
-
async execute(_toolCallId,
|
|
490
|
+
async execute(_toolCallId, raw, signal, _onUpdate, ctx) {
|
|
491
|
+
const params = checkedAction(CreateActions, raw);
|
|
469
492
|
return executeWorkflowAction(params.runId, "create", ctx, signal, async (selected) => {
|
|
470
493
|
switch (params.action) {
|
|
471
494
|
case "prepare": return await selected.workflow.prepare();
|
|
@@ -487,9 +510,10 @@ export default function pullRequestExtension(
|
|
|
487
510
|
name: "pi_pr_publish_work",
|
|
488
511
|
label: "Publish PR Work",
|
|
489
512
|
description: "Inspect, commit, validate, and publish only reviewed local work for the /pr route.",
|
|
490
|
-
parameters:
|
|
513
|
+
parameters: flatRoot(WorkActions),
|
|
491
514
|
executionMode: "sequential",
|
|
492
|
-
async execute(_toolCallId,
|
|
515
|
+
async execute(_toolCallId, raw, signal, _onUpdate, ctx) {
|
|
516
|
+
const params = checkedAction(WorkActions, raw);
|
|
493
517
|
return executeWorkflowAction(params.runId, "publish-work", ctx, signal, async (selected) => {
|
|
494
518
|
switch (params.action) {
|
|
495
519
|
case "inspect": return await selected.workflow.inspect();
|
|
@@ -509,9 +533,10 @@ export default function pullRequestExtension(
|
|
|
509
533
|
name: "pi_pr_sweep",
|
|
510
534
|
label: "Sweep PR Feedback",
|
|
511
535
|
description: "Run one guarded action for the /pr feedback route.",
|
|
512
|
-
parameters:
|
|
536
|
+
parameters: flatRoot(SweepActions),
|
|
513
537
|
executionMode: "sequential",
|
|
514
|
-
async execute(_toolCallId,
|
|
538
|
+
async execute(_toolCallId, raw, signal, _onUpdate, ctx) {
|
|
539
|
+
const params = checkedAction(SweepActions, raw);
|
|
515
540
|
return executeWorkflowAction(params.runId, "sweep", ctx, signal, async (selected) => {
|
|
516
541
|
switch (params.action) {
|
|
517
542
|
case "start": return await selected.workflow.start();
|
|
@@ -536,9 +561,10 @@ export default function pullRequestExtension(
|
|
|
536
561
|
name: "pi_pr_fix_ci",
|
|
537
562
|
label: "Fix PR CI",
|
|
538
563
|
description: "Run one guarded action for the /pr failed-CI route.",
|
|
539
|
-
parameters:
|
|
564
|
+
parameters: flatRoot(FixCiActions),
|
|
540
565
|
executionMode: "sequential",
|
|
541
|
-
async execute(_toolCallId,
|
|
566
|
+
async execute(_toolCallId, raw, signal, _onUpdate, ctx) {
|
|
567
|
+
const params = checkedAction(FixCiActions, raw);
|
|
542
568
|
return executeWorkflowAction(params.runId, "fix-ci", ctx, signal, async (selected) => {
|
|
543
569
|
switch (params.action) {
|
|
544
570
|
case "collect": return await selected.workflow.collect();
|
|
@@ -622,9 +648,6 @@ export default function pullRequestExtension(
|
|
|
622
648
|
}
|
|
623
649
|
const display = projectPrDisplay(discovery);
|
|
624
650
|
const footer = formatPrFooter(display, ctx.ui.theme);
|
|
625
|
-
if ((discovery.kind === "current" || discovery.kind === "blocked") && footer === undefined) {
|
|
626
|
-
throw new Error("Pull request display is missing a footer");
|
|
627
|
-
}
|
|
628
651
|
displayedWidget = display.widget === undefined ? undefined : display;
|
|
629
652
|
ctx.ui.setStatus(UI_KEY, footer);
|
|
630
653
|
reconcileWidget(ctx);
|
|
@@ -672,25 +695,15 @@ export default function pullRequestExtension(
|
|
|
672
695
|
const category = error instanceof GitHubRateLimitError ? "quota" : "generic";
|
|
673
696
|
if (!ctx || reportedRefreshFailure === category || reportedRefreshFailure === "quota") return;
|
|
674
697
|
reportedRefreshFailure = category;
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
: "PR status refresh failed: status unavailable",
|
|
680
|
-
"error",
|
|
681
|
-
);
|
|
682
|
-
} catch {
|
|
683
|
-
console.error("PR status refresh failed and could not be reported");
|
|
684
|
-
}
|
|
698
|
+
ctx.ui.notify(
|
|
699
|
+
error instanceof GitHubRateLimitError ? error.message : "PR status refresh failed: status unavailable",
|
|
700
|
+
"error",
|
|
701
|
+
);
|
|
685
702
|
};
|
|
686
703
|
|
|
687
704
|
const reportHerdrRenameFailure = (ctx: ExtensionContext, error: unknown): void => {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
ctx.ui.notify(`Herdr workspace rename failed: ${message.slice(0, 500)}`, "warning");
|
|
691
|
-
} catch (reportError) {
|
|
692
|
-
console.error("Herdr workspace rename failed and could not be reported", error, reportError);
|
|
693
|
-
}
|
|
705
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
706
|
+
ctx.ui.notify(`Herdr workspace rename failed: ${message.slice(0, 500)}`, "warning");
|
|
694
707
|
};
|
|
695
708
|
|
|
696
709
|
const refresh = async (): Promise<void> => {
|
|
@@ -783,7 +796,8 @@ export default function pullRequestExtension(
|
|
|
783
796
|
|
|
784
797
|
pi.on("agent_before_settle", async (event, ctx) => {
|
|
785
798
|
const selected = workflowContext;
|
|
786
|
-
|
|
799
|
+
// Pi evaluates continuation again after this handler queues the next workflow.
|
|
800
|
+
if (event.outcome !== "completed" || !selected?.completed ||
|
|
787
801
|
selected.queuedPrompt || selected.sessionGeneration !== sessionGeneration) return;
|
|
788
802
|
const invocation = [...activeInvocations].find(([, phase]) => phase === "workflow" || phase === "create-workflow")?.[0];
|
|
789
803
|
if (invocation === undefined) return;
|
package/package.json
CHANGED