@henryqw/pi-pr 6.0.0 → 6.0.1
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/extensions/pr-ci.ts +11 -25
- package/extensions/pr-command.ts +11 -11
- package/extensions/pr-comment-sweep.ts +5 -5
- package/extensions/pr-create.ts +12 -31
- package/extensions/pr-execution.ts +18 -6
- package/extensions/pr-feedback.ts +7 -7
- package/extensions/pr-github.ts +4 -4
- package/extensions/pr-merge.ts +3 -3
- package/extensions/pr-routing.ts +4 -4
- package/extensions/pr-ui.ts +1 -1
- package/extensions/pr-update-branch.ts +10 -25
- package/package.json +1 -1
package/extensions/pr-ci.ts
CHANGED
|
@@ -92,7 +92,7 @@ type CiSnapshot = {
|
|
|
92
92
|
failures: FailureIdentity[];
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
type CiFailureEvidence = {
|
|
96
96
|
checkRuns: Array<{ id: number; name: string; conclusion: string }>;
|
|
97
97
|
checkSuite: { id: number };
|
|
98
98
|
run: { id: number; url: string; attempt: number };
|
|
@@ -101,20 +101,18 @@ export type CiFailureEvidence = {
|
|
|
101
101
|
log: { scope: "job"; text: string; truncated: boolean };
|
|
102
102
|
};
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
type CiEvidence = {
|
|
105
105
|
fingerprint: string;
|
|
106
106
|
pullRequest: { number: number; url: string; headOid: string };
|
|
107
107
|
failures: CiFailureEvidence[];
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
type CiPushAttempt = "before-launch" | "applied" | "not-applied" | "unknown";
|
|
111
|
+
type CiFixPhase = "ready" | "collecting" | "collected" | "published" | "blocked";
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
type CiFixState = {
|
|
114
114
|
phase: CiFixPhase;
|
|
115
115
|
pushAttempt: CiPushAttempt;
|
|
116
|
-
fingerprint?: string;
|
|
117
|
-
repairHead?: string;
|
|
118
116
|
};
|
|
119
117
|
|
|
120
118
|
export type PullRequestCiFixOptions = {
|
|
@@ -126,7 +124,7 @@ export type PullRequestCiFixOptions = {
|
|
|
126
124
|
loadCurrentPullRequest?: Load;
|
|
127
125
|
};
|
|
128
126
|
|
|
129
|
-
|
|
127
|
+
type CiPublishResult = {
|
|
130
128
|
kind: "published";
|
|
131
129
|
head: string;
|
|
132
130
|
attempt: "applied";
|
|
@@ -193,21 +191,11 @@ function actionsUrl(authority: CurrentPullRequest, suffix: string): string {
|
|
|
193
191
|
}
|
|
194
192
|
|
|
195
193
|
function tailUtf8(value: string, limit: number): { text: string; truncated: boolean } {
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
198
|
-
let start =
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
let previous = start - 1;
|
|
202
|
-
if (previous > 0 && value.charCodeAt(previous) >= 0xdc00 && value.charCodeAt(previous) <= 0xdfff &&
|
|
203
|
-
value.charCodeAt(previous - 1) >= 0xd800 && value.charCodeAt(previous - 1) <= 0xdbff) previous -= 1;
|
|
204
|
-
const character = value.slice(previous, start);
|
|
205
|
-
const size = Buffer.byteLength(character, "utf8");
|
|
206
|
-
if (bytes + size > limit) break;
|
|
207
|
-
bytes += size;
|
|
208
|
-
start = previous;
|
|
209
|
-
}
|
|
210
|
-
return { text: value.slice(start), truncated: true };
|
|
194
|
+
const bytes = Buffer.from(value, "utf8");
|
|
195
|
+
if (bytes.length <= limit) return { text: value, truncated: false };
|
|
196
|
+
let start = bytes.length - limit;
|
|
197
|
+
while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1;
|
|
198
|
+
return { text: bytes.subarray(start).toString("utf8"), truncated: true };
|
|
211
199
|
}
|
|
212
200
|
|
|
213
201
|
function failureEvidence(
|
|
@@ -669,7 +657,6 @@ export class PullRequestCiFixer {
|
|
|
669
657
|
throw new Error("Failed-CI evidence was replaced or became stale during collection");
|
|
670
658
|
}
|
|
671
659
|
this.collectedFingerprint = after.fingerprint;
|
|
672
|
-
this.state.fingerprint = after.fingerprint;
|
|
673
660
|
this.state.phase = "collected";
|
|
674
661
|
return {
|
|
675
662
|
fingerprint: after.fingerprint,
|
|
@@ -721,7 +708,6 @@ export class PullRequestCiFixer {
|
|
|
721
708
|
throw new Error("CI repair HEAD must be a new descendant of the frozen pull request head");
|
|
722
709
|
}
|
|
723
710
|
if (await readHead(this.exec, this.options()) !== repairHead) throw new Error("CI repair HEAD changed before push");
|
|
724
|
-
this.state.repairHead = repairHead;
|
|
725
711
|
return repairHead;
|
|
726
712
|
}
|
|
727
713
|
|
package/extensions/pr-command.ts
CHANGED
|
@@ -18,19 +18,19 @@ import {
|
|
|
18
18
|
type RouteIntent,
|
|
19
19
|
} from "./pr-routing.ts";
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
type WorkflowNextStep = Extract<NextStep, "create" | "update-branch" | "sweep" | "fix-ci">;
|
|
22
22
|
|
|
23
|
-
const WORKFLOWS: Record<WorkflowNextStep,
|
|
24
|
-
create:
|
|
25
|
-
"update-branch":
|
|
26
|
-
sweep:
|
|
27
|
-
"fix-ci":
|
|
23
|
+
const WORKFLOWS: Record<WorkflowNextStep, string> = {
|
|
24
|
+
create: "skill:pi-pr-create",
|
|
25
|
+
"update-branch": "skill:pi-pr-update-branch",
|
|
26
|
+
sweep: "skill:pi-pr-comment-sweep",
|
|
27
|
+
"fix-ci": "skill:pi-pr-fix-ci",
|
|
28
28
|
};
|
|
29
|
-
|
|
29
|
+
type WorkflowReservation =
|
|
30
30
|
| { route: "create"; target: PullRequestTarget; base?: string }
|
|
31
31
|
| { route: Exclude<WorkflowNextStep, "create">; pullRequest: CurrentPullRequest };
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
type WorkflowLaunchAction = "prepare" | "merge" | "start" | "resume" | "collect";
|
|
33
|
+
type WorkflowReservationResult = { runId: string; action: WorkflowLaunchAction };
|
|
34
34
|
|
|
35
35
|
type PrCommandPi = Pick<ExtensionAPI, "exec" | "getCommands" | "sendUserMessage">;
|
|
36
36
|
export type PrCommandInvocation = ((nextStep: NextStep) => void) & {
|
|
@@ -120,11 +120,11 @@ function workflowReservation(
|
|
|
120
120
|
function packageWorkflowCommand(pi: PrCommandPi, route: WorkflowNextStep) {
|
|
121
121
|
const workflow = WORKFLOWS[route];
|
|
122
122
|
const command = pi.getCommands().find((candidate) =>
|
|
123
|
-
candidate.name === workflow
|
|
123
|
+
candidate.name === workflow &&
|
|
124
124
|
candidate.source === "skill" &&
|
|
125
125
|
candidate.sourceInfo.origin === "package"
|
|
126
126
|
);
|
|
127
|
-
if (!command) throw new Error(`${workflow
|
|
127
|
+
if (!command) throw new Error(`${workflow} failed: bundled workflow is unavailable`);
|
|
128
128
|
return command;
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -52,21 +52,21 @@ const CHECK_ARGUMENTS_MAX_BYTES = 32 * 1024;
|
|
|
52
52
|
const ATTEMPT_STATES = new Set<AttemptState>(["none", "attempting", "applied", "blocked", "unknown"]);
|
|
53
53
|
const DISPOSITIONS = ["addressed", "non-actionable", "blocked"] as const;
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
type SweepDisposition = (typeof DISPOSITIONS)[number];
|
|
56
56
|
export type SweepLedgerEntry = {
|
|
57
57
|
id: string;
|
|
58
58
|
kind: FeedbackKind;
|
|
59
59
|
disposition: SweepDisposition;
|
|
60
60
|
note: string;
|
|
61
61
|
};
|
|
62
|
-
|
|
62
|
+
type SweepRunGuard = {
|
|
63
63
|
epoch: number;
|
|
64
64
|
runId: string;
|
|
65
65
|
generation: number;
|
|
66
66
|
fingerprint: string;
|
|
67
67
|
};
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
type SweepCheck = { command: string; args: string[] };
|
|
69
|
+
type SweepFinalProjection = {
|
|
70
70
|
generation: number;
|
|
71
71
|
contentFingerprint: string;
|
|
72
72
|
items: Array<{ id: string; kind: FeedbackKind }>;
|
|
@@ -88,7 +88,7 @@ export type SweepStatus = {
|
|
|
88
88
|
finalize: AttemptState;
|
|
89
89
|
};
|
|
90
90
|
};
|
|
91
|
-
|
|
91
|
+
type SweepPhase = "triage" | "recorded" | "published" | "refresh-pending" | "refreshed" | "resolving" | "resolved";
|
|
92
92
|
|
|
93
93
|
type SweepAuthority = ReturnType<typeof authorityFromCurrent>;
|
|
94
94
|
type ResolutionAttempt = {
|
package/extensions/pr-create.ts
CHANGED
|
@@ -26,11 +26,13 @@ import {
|
|
|
26
26
|
isAncestor,
|
|
27
27
|
isRecord,
|
|
28
28
|
parseNulPaths,
|
|
29
|
+
parseSingleOutputLine,
|
|
29
30
|
readHead,
|
|
30
31
|
readRemoteOid,
|
|
31
32
|
requiredOid,
|
|
32
33
|
requiredText,
|
|
33
34
|
runChecked,
|
|
35
|
+
validateResolvedConflictPaths,
|
|
34
36
|
withWorktreeLock,
|
|
35
37
|
} from "./pr-execution.ts";
|
|
36
38
|
|
|
@@ -41,7 +43,7 @@ const CREATE_PULL_REQUEST_MUTATION = "mutation($repositoryId:ID!,$baseRefName:St
|
|
|
41
43
|
|
|
42
44
|
type Load = typeof loadCurrentPullRequest;
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
type CreateBaseAuthority = {
|
|
45
47
|
host: string;
|
|
46
48
|
repository: string;
|
|
47
49
|
ref: string;
|
|
@@ -49,7 +51,7 @@ export type CreateBaseAuthority = {
|
|
|
49
51
|
fetchSource: string;
|
|
50
52
|
};
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
type CreatePhase = "unprepared" | "prepared" | "conflict-awaiting-user" | "verified" | "pushed" | "published" | "blocked";
|
|
53
55
|
|
|
54
56
|
type CrossRepositoryCreateAuthority = {
|
|
55
57
|
baseRepositoryId: string;
|
|
@@ -57,17 +59,15 @@ type CrossRepositoryCreateAuthority = {
|
|
|
57
59
|
headOwnerType: "Organization" | "User";
|
|
58
60
|
};
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
type CreatePullRequestState = {
|
|
61
63
|
phase: CreatePhase;
|
|
62
64
|
base?: CreateBaseAuthority;
|
|
63
65
|
createAuthority?: CrossRepositoryCreateAuthority;
|
|
64
|
-
mergeHead?: string;
|
|
65
66
|
publicationHead?: string;
|
|
66
67
|
conflict?: { paths: string[]; statusBaseline: string; originalHead: string };
|
|
67
|
-
url?: string;
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
type CreatePullRequestResult =
|
|
71
71
|
| { kind: "prepared"; base: CreateBaseAuthority; mergeBase: string }
|
|
72
72
|
| { kind: "verified"; head: string; fastForward: boolean }
|
|
73
73
|
| { kind: "conflict"; paths: string[] }
|
|
@@ -89,13 +89,6 @@ function sameTarget(left: PullRequestTarget, right: PullRequestTarget, expectedR
|
|
|
89
89
|
left.fetchSource === right.fetchSource && right.remoteOid === expectedRemoteOid;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
function line(output: string, label: string): string {
|
|
93
|
-
const normalized = output.replace(/\r\n/g, "\n");
|
|
94
|
-
const values = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
|
|
95
|
-
if (values.length !== 1 || !values[0]) throw new Error(`${label} returned invalid output`);
|
|
96
|
-
return values[0];
|
|
97
|
-
}
|
|
98
|
-
|
|
99
92
|
function graphQlResponse(output: string, action: string): Record<string, unknown> {
|
|
100
93
|
let value: unknown;
|
|
101
94
|
try {
|
|
@@ -166,15 +159,6 @@ function parseCreatedUrl(output: string, host: string, baseRepository: string):
|
|
|
166
159
|
return url;
|
|
167
160
|
}
|
|
168
161
|
|
|
169
|
-
function resolvedPaths(paths: readonly string[], expected: readonly string[]): string[] {
|
|
170
|
-
if (!Array.isArray(paths)) throw new TypeError("resolvedPaths must be an array");
|
|
171
|
-
const parsed = parseNulPaths(`${paths.join("\0")}${paths.length ? "\0" : ""}`, "Resolved conflict paths");
|
|
172
|
-
if (expected.some((path) => !parsed.includes(path))) {
|
|
173
|
-
throw new Error("Resolved paths must include every original conflict path");
|
|
174
|
-
}
|
|
175
|
-
return parsed;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
162
|
export class PullRequestCreator {
|
|
179
163
|
readonly state: CreatePullRequestState = { phase: "unprepared" };
|
|
180
164
|
|
|
@@ -218,7 +202,7 @@ export class PullRequestCreator {
|
|
|
218
202
|
if (discovery.kind !== "none" || !sameTarget(this.target, discovery.creationTarget)) {
|
|
219
203
|
throw new Error("PR creation cancelled: fresh complete discovery is no longer none");
|
|
220
204
|
}
|
|
221
|
-
const branch =
|
|
205
|
+
const branch = parseSingleOutputLine((await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout, "current branch");
|
|
222
206
|
if (branch !== this.target.branch) throw new Error("PR creation cancelled: current branch changed");
|
|
223
207
|
return discovery;
|
|
224
208
|
}
|
|
@@ -298,7 +282,7 @@ export class PullRequestCreator {
|
|
|
298
282
|
private async verifyMerge(originalHead: string): Promise<{ head: string; fastForward: boolean }> {
|
|
299
283
|
const base = this.state.base!;
|
|
300
284
|
const head = await readHead(this.exec, this.options());
|
|
301
|
-
const commits =
|
|
285
|
+
const commits = parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", head], this.options())).stdout, "merge parents")
|
|
302
286
|
.split(" ").map((value, index) => requiredOid(value, index ? "merge parent" : "merged HEAD"));
|
|
303
287
|
if (commits[0] !== head) throw new Error("PR creation merge verification returned a different HEAD");
|
|
304
288
|
let fastForward = false;
|
|
@@ -308,14 +292,13 @@ export class PullRequestCreator {
|
|
|
308
292
|
}
|
|
309
293
|
await this.requireCleanHead();
|
|
310
294
|
this.state.phase = "verified";
|
|
311
|
-
this.state.mergeHead = head;
|
|
312
295
|
delete this.state.conflict;
|
|
313
296
|
return { head, fastForward };
|
|
314
297
|
}
|
|
315
298
|
|
|
316
299
|
private async captureConflict(originalHead: string): Promise<string[]> {
|
|
317
300
|
const base = this.state.base!;
|
|
318
|
-
const mergeHead = requiredOid(
|
|
301
|
+
const mergeHead = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.options())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
|
|
319
302
|
if (mergeHead !== base.oid) throw new Error("Failed merge did not retain the frozen base");
|
|
320
303
|
const paths = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.options())).stdout, "Unmerged paths");
|
|
321
304
|
if (!paths.length) throw new Error("git merge failed without bounded unmerged paths");
|
|
@@ -335,7 +318,6 @@ export class PullRequestCreator {
|
|
|
335
318
|
const originalHead = await this.requireCleanHead();
|
|
336
319
|
if (await isAncestor(this.exec, this.options(), this.state.base!.oid, originalHead)) {
|
|
337
320
|
this.state.phase = "verified";
|
|
338
|
-
this.state.mergeHead = originalHead;
|
|
339
321
|
return { kind: "verified", head: originalHead, fastForward: false };
|
|
340
322
|
}
|
|
341
323
|
this.state.phase = "blocked";
|
|
@@ -358,12 +340,12 @@ export class PullRequestCreator {
|
|
|
358
340
|
if (this.state.phase !== "conflict-awaiting-user" || !this.state.conflict || !this.state.base) {
|
|
359
341
|
throw new Error("PR creation has no conflict awaiting continuation");
|
|
360
342
|
}
|
|
361
|
-
const paths =
|
|
343
|
+
const paths = validateResolvedConflictPaths(pathsInput, this.state.conflict.paths);
|
|
362
344
|
return await withWorktreeLock(this.cwd, async () => {
|
|
363
345
|
await this.freshNone();
|
|
364
346
|
if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
|
|
365
347
|
if (await readHead(this.exec, this.options()) !== this.state.conflict!.originalHead) throw new Error("PR creation merge HEAD changed");
|
|
366
|
-
const mergeHead = requiredOid(
|
|
348
|
+
const mergeHead = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.options())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
|
|
367
349
|
if (mergeHead !== this.state.base!.oid) throw new Error("PR creation merge context changed");
|
|
368
350
|
const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options());
|
|
369
351
|
assertOnlyDeclaredStatusChanged(this.state.conflict!.statusBaseline, status.stdout, paths);
|
|
@@ -433,7 +415,7 @@ export class PullRequestCreator {
|
|
|
433
415
|
private async publishedAuthority(): Promise<void> {
|
|
434
416
|
const head = this.state.publicationHead!;
|
|
435
417
|
if (this.noTarget && !this.noTargetUpstreamConfigured) {
|
|
436
|
-
const branch =
|
|
418
|
+
const branch = parseSingleOutputLine((await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout, "current branch");
|
|
437
419
|
const authority = await readValidatedRemoteAuthority(this.pi(), this.context(), this.target.remote);
|
|
438
420
|
if (branch !== this.target.branch || authority.host !== this.target.host ||
|
|
439
421
|
authority.repository.toLowerCase() !== this.target.repository.toLowerCase() ||
|
|
@@ -522,7 +504,6 @@ export class PullRequestCreator {
|
|
|
522
504
|
throw new Error("Published pull request did not retain canonical identity, title, and body");
|
|
523
505
|
}
|
|
524
506
|
this.state.phase = "published";
|
|
525
|
-
this.state.url = after.url.href;
|
|
526
507
|
return { kind: "published", url: after.url.href };
|
|
527
508
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
528
509
|
}
|
|
@@ -6,9 +6,9 @@ import { extensionConfigDir } from "@henryqw/pi-config-store";
|
|
|
6
6
|
import { spawnBounded, type Exec, type ExecOptions, type ExecResult } from "@henryqw/pi-process";
|
|
7
7
|
import { lock } from "proper-lockfile";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
const MAX_CONFLICT_PATHS = 128;
|
|
10
|
+
const MAX_CONFLICT_PATH_BYTES = 1_024;
|
|
11
|
+
const MAX_CONFLICT_PATHS_BYTES = 32 * 1024;
|
|
12
12
|
|
|
13
13
|
const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
14
14
|
const GIT_OPERATION_STATES = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
|
|
@@ -34,8 +34,11 @@ export function requiredOid(value: unknown, label: string): string {
|
|
|
34
34
|
return parsed;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function
|
|
38
|
-
|
|
37
|
+
export function parseSingleOutputLine(output: string, label: string): string {
|
|
38
|
+
const normalized = output.replace(/\r\n/g, "\n");
|
|
39
|
+
const lines = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
|
|
40
|
+
if (lines.length !== 1 || !lines[0]) throw new Error(`${label} returned invalid output`);
|
|
41
|
+
return lines[0];
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
export function extensionExecApi(exec: Exec, cwd: string, signal?: AbortSignal): Pick<ExtensionAPI, "exec"> {
|
|
@@ -58,7 +61,7 @@ export async function runChecked(
|
|
|
58
61
|
const result = await exec(command, args, options);
|
|
59
62
|
if (result.killed || !allowedCodes.includes(result.code)) {
|
|
60
63
|
const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was killed" : `exit code ${result.code}`);
|
|
61
|
-
throw new Error(`${
|
|
64
|
+
throw new Error(`${[command, ...args].join(" ")} failed: ${detail}`);
|
|
62
65
|
}
|
|
63
66
|
return result;
|
|
64
67
|
}
|
|
@@ -162,6 +165,15 @@ export function parseNulPaths(output: string, label: string): string[] {
|
|
|
162
165
|
return paths;
|
|
163
166
|
}
|
|
164
167
|
|
|
168
|
+
export function validateResolvedConflictPaths(paths: readonly string[], expected: readonly string[]): string[] {
|
|
169
|
+
if (!Array.isArray(paths)) throw new TypeError("resolvedPaths must be an array");
|
|
170
|
+
const parsed = parseNulPaths(`${paths.join("\0")}${paths.length ? "\0" : ""}`, "Resolved conflict paths");
|
|
171
|
+
if (expected.some((path) => !parsed.includes(path))) {
|
|
172
|
+
throw new Error("Resolved paths must include every original conflict path");
|
|
173
|
+
}
|
|
174
|
+
return parsed;
|
|
175
|
+
}
|
|
176
|
+
|
|
165
177
|
function statusPath(record: string): { path: string; rename: boolean } {
|
|
166
178
|
if (record.startsWith("? ") || record.startsWith("! ")) return { path: record.slice(2), rename: false };
|
|
167
179
|
const fields = record[0] === "1" ? 8 : record[0] === "2" ? 9 : record[0] === "u" ? 10 : 0;
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import type { CurrentPullRequest } from "./pr-github.ts";
|
|
10
10
|
|
|
11
11
|
export const FEEDBACK_API_PAGE_MAX_BYTES = 512 * 1024;
|
|
12
|
-
|
|
12
|
+
const FEEDBACK_MAX_PAGES = 100;
|
|
13
13
|
export const FEEDBACK_MAX_RECORDS = 1_000;
|
|
14
14
|
export const FEEDBACK_SNAPSHOT_MAX_BYTES = 1024 * 1024;
|
|
15
15
|
|
|
@@ -33,15 +33,15 @@ query ThreadReplies($threadId:ID!,$cursor:String){node(id:$threadId){... on Pull
|
|
|
33
33
|
const RESOLVE_THREAD_MUTATION = `
|
|
34
34
|
mutation ResolveThread($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`;
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
type FeedbackAuthor = { login: string } | null;
|
|
37
|
+
type FeedbackComment = {
|
|
38
38
|
id: string;
|
|
39
39
|
url: string;
|
|
40
40
|
body: string;
|
|
41
41
|
createdAt: string;
|
|
42
42
|
author: FeedbackAuthor;
|
|
43
43
|
};
|
|
44
|
-
|
|
44
|
+
type FeedbackReview = {
|
|
45
45
|
id: string;
|
|
46
46
|
url: string;
|
|
47
47
|
state: string;
|
|
@@ -49,7 +49,7 @@ export type FeedbackReview = {
|
|
|
49
49
|
submittedAt: string | null;
|
|
50
50
|
author: FeedbackAuthor;
|
|
51
51
|
};
|
|
52
|
-
|
|
52
|
+
type FeedbackThread = {
|
|
53
53
|
id: string;
|
|
54
54
|
isResolved: boolean;
|
|
55
55
|
isOutdated: boolean;
|
|
@@ -77,7 +77,7 @@ export type FeedbackSnapshot = {
|
|
|
77
77
|
reviewThreads: FeedbackThread[];
|
|
78
78
|
};
|
|
79
79
|
export type FeedbackKind = "conversation_comment" | "review" | "thread" | "thread_comment";
|
|
80
|
-
|
|
80
|
+
type FeedbackEntry = {
|
|
81
81
|
id: string;
|
|
82
82
|
kind: FeedbackKind;
|
|
83
83
|
node: FeedbackComment | FeedbackReview | FeedbackThread;
|
|
@@ -91,7 +91,7 @@ export type FeedbackItem =
|
|
|
91
91
|
kind: "thread_comment";
|
|
92
92
|
parentThread: { id: string; isResolved: boolean; isOutdated: boolean; path: string | null; line: number | null };
|
|
93
93
|
});
|
|
94
|
-
|
|
94
|
+
type FeedbackClientOptions = {
|
|
95
95
|
exec: Exec;
|
|
96
96
|
cwd: string;
|
|
97
97
|
signal?: AbortSignal;
|
package/extensions/pr-github.ts
CHANGED
|
@@ -78,7 +78,7 @@ export class GitHubRateLimitError extends PullRequestLoadError {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
type PullRequestRef = {
|
|
82
82
|
repository: string;
|
|
83
83
|
ref: string;
|
|
84
84
|
oid: string;
|
|
@@ -118,7 +118,7 @@ export type PullRequestObservation = {
|
|
|
118
118
|
|
|
119
119
|
export type PullRequestLoadContext = Pick<ExtensionContext, "cwd" | "signal">;
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
type PullRequestCreationPreflight = {
|
|
122
122
|
head: string;
|
|
123
123
|
base: {
|
|
124
124
|
host: string;
|
|
@@ -187,7 +187,7 @@ type LinkConfiguration = {
|
|
|
187
187
|
mirror: string[];
|
|
188
188
|
};
|
|
189
189
|
|
|
190
|
-
|
|
190
|
+
type PullRequestCandidate = {
|
|
191
191
|
number: number;
|
|
192
192
|
url: URL;
|
|
193
193
|
lifecycle: PullRequestLifecycle;
|
|
@@ -209,7 +209,7 @@ export type PullRequestPublication = {
|
|
|
209
209
|
body: string;
|
|
210
210
|
};
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
type ValidatedRemoteAuthority = {
|
|
213
213
|
fetchSource: string;
|
|
214
214
|
host: string;
|
|
215
215
|
repository: string;
|
package/extensions/pr-merge.ts
CHANGED
|
@@ -11,18 +11,18 @@ import type { LocalMergeSafety } from "./pr-routing.ts";
|
|
|
11
11
|
|
|
12
12
|
const MERGE_PULL_REQUEST_MUTATION = "mutation($pullRequestId:ID!,$expectedHeadOid:GitObjectID!,$mergeMethod:PullRequestMergeMethod!){mergePullRequest(input:{pullRequestId:$pullRequestId,expectedHeadOid:$expectedHeadOid,mergeMethod:$mergeMethod}){pullRequest{id state}}}";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
type InspectLocalMergeSafetyInput = {
|
|
15
15
|
exec: Exec;
|
|
16
16
|
cwd: string;
|
|
17
17
|
expectedHead: string;
|
|
18
18
|
headFetchSource: string;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
type InspectedLocalMergeSafety = LocalMergeSafety & {
|
|
22
22
|
headOid: string;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
type ExecuteGitHubMergeInput = InspectLocalMergeSafetyInput & {
|
|
26
26
|
pullRequestId: string;
|
|
27
27
|
hostname: string;
|
|
28
28
|
expectedBase: {
|
package/extensions/pr-routing.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export type PullRequestLifecycle = "open" | "merged" | "closed";
|
|
2
2
|
export type CiStatus = "none" | "running" | "success" | "failure" | "failure-blocked";
|
|
3
3
|
export type ReviewReadiness = "ready" | "pending";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
type PolicyReadiness = "ready" | "pending";
|
|
5
|
+
type LocalWorktree = "clean" | "dirty";
|
|
6
|
+
type LocalHeadRelation = "equal" | "behind" | "ahead" | "diverged";
|
|
7
7
|
|
|
8
8
|
export type PullRequestConditions = {
|
|
9
9
|
draft: boolean;
|
|
@@ -69,7 +69,7 @@ export type FeedbackRouteBlocker =
|
|
|
69
69
|
| { kind: "target-not-configured" }
|
|
70
70
|
| { kind: "worktree-dirty" }
|
|
71
71
|
| { kind: "head-not-equal"; relation: Exclude<LocalHeadRelation, "equal"> };
|
|
72
|
-
|
|
72
|
+
type RouteDecision =
|
|
73
73
|
| { kind: "selected"; nextStep: NextStep }
|
|
74
74
|
| { kind: "feedback-blocked"; nextStep: "blocked"; blocker: FeedbackRouteBlocker };
|
|
75
75
|
|
package/extensions/pr-ui.ts
CHANGED
|
@@ -12,23 +12,25 @@ import {
|
|
|
12
12
|
inspectWorktree,
|
|
13
13
|
isAncestor,
|
|
14
14
|
parseNulPaths,
|
|
15
|
+
parseSingleOutputLine,
|
|
15
16
|
readHead,
|
|
16
17
|
readRemoteOid,
|
|
17
18
|
requiredOid,
|
|
18
19
|
resolveRepositoryFetchSource,
|
|
19
20
|
runChecked,
|
|
21
|
+
validateResolvedConflictPaths,
|
|
20
22
|
withWorktreeLock,
|
|
21
23
|
} from "./pr-execution.ts";
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
type UpdateBranchPhase = "ready" | "conflict-awaiting-user" | "verified" | "published" | "blocked";
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
type UpdateBranchState = {
|
|
26
28
|
phase: UpdateBranchPhase;
|
|
27
29
|
verifiedHead?: string;
|
|
28
30
|
conflict?: { paths: string[]; statusBaseline: string };
|
|
29
31
|
};
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
type UpdateBranchResult =
|
|
32
34
|
| { kind: "verified"; head: string; fastForward: boolean }
|
|
33
35
|
| { kind: "conflict"; paths: string[] }
|
|
34
36
|
| { kind: "published"; head: string };
|
|
@@ -49,23 +51,6 @@ function sameAuthority(frozen: CurrentPullRequest, fresh: CurrentPullRequest): b
|
|
|
49
51
|
fresh.lifecycle === "open" && (fresh.conditions.baseUpdateRequired || fresh.conditions.conflict);
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
function singleLine(output: string, label: string): string {
|
|
53
|
-
const normalized = output.replace(/\r\n/g, "\n");
|
|
54
|
-
const lines = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
|
|
55
|
-
if (lines.length !== 1 || !lines[0]) throw new Error(`${label} returned invalid output`);
|
|
56
|
-
return lines[0];
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function validateDeclaredPaths(paths: readonly string[], expected: readonly string[]): string[] {
|
|
60
|
-
if (!Array.isArray(paths)) throw new TypeError("resolvedPaths must be an array");
|
|
61
|
-
const encoded = `${paths.join("\0")}${paths.length ? "\0" : ""}`;
|
|
62
|
-
const parsed = parseNulPaths(encoded, "Resolved conflict paths");
|
|
63
|
-
if (expected.some((path) => !parsed.includes(path))) {
|
|
64
|
-
throw new Error("Resolved paths must include every original conflict path");
|
|
65
|
-
}
|
|
66
|
-
return parsed;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
54
|
export class PullRequestBranchUpdater {
|
|
70
55
|
readonly state: UpdateBranchState = { phase: "ready" };
|
|
71
56
|
|
|
@@ -108,7 +93,7 @@ export class PullRequestBranchUpdater {
|
|
|
108
93
|
if (discovery.kind !== "current" || !sameAuthority(this.authority, discovery.pullRequest)) {
|
|
109
94
|
throw new Error("Branch update cancelled: frozen pull request authority changed");
|
|
110
95
|
}
|
|
111
|
-
const branch =
|
|
96
|
+
const branch = parseSingleOutputLine((await runChecked(this.exec, "git", ["branch", "--show-current"], this.execOptions())).stdout, "current branch");
|
|
112
97
|
if (branch !== this.authority.target.branch) throw new Error("Branch update cancelled: current branch changed");
|
|
113
98
|
if (requireClean && await inspectWorktree(this.exec, this.execOptions()) !== "clean") {
|
|
114
99
|
throw new Error("Branch update cancelled: worktree is dirty or a Git operation is in progress");
|
|
@@ -120,7 +105,7 @@ export class PullRequestBranchUpdater {
|
|
|
120
105
|
|
|
121
106
|
private async verifyMerge(originalHead: string): Promise<{ head: string; fastForward: boolean }> {
|
|
122
107
|
const head = await readHead(this.exec, this.execOptions());
|
|
123
|
-
const parentsOutput =
|
|
108
|
+
const parentsOutput = parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", "HEAD"], this.execOptions())).stdout, "merge parents");
|
|
124
109
|
const commits = parentsOutput.split(" ").map((value, index) => requiredOid(value, index === 0 ? "merged HEAD" : "merge parent"));
|
|
125
110
|
if (commits[0] !== head) throw new Error("Branch update merge verification returned a different HEAD");
|
|
126
111
|
let fastForward = false;
|
|
@@ -139,7 +124,7 @@ export class PullRequestBranchUpdater {
|
|
|
139
124
|
}
|
|
140
125
|
|
|
141
126
|
private async captureConflict(): Promise<string[]> {
|
|
142
|
-
const mergeHead = requiredOid(
|
|
127
|
+
const mergeHead = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.execOptions())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
|
|
143
128
|
if (mergeHead !== this.authority.base.oid) throw new Error("Failed merge did not retain the frozen base");
|
|
144
129
|
const paths = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.execOptions())).stdout, "Unmerged paths");
|
|
145
130
|
if (!paths.length) throw new Error("git merge failed without bounded unmerged paths");
|
|
@@ -189,10 +174,10 @@ export class PullRequestBranchUpdater {
|
|
|
189
174
|
if (this.state.phase !== "conflict-awaiting-user" || !this.state.conflict) {
|
|
190
175
|
throw new Error("Branch update has no conflict awaiting continuation");
|
|
191
176
|
}
|
|
192
|
-
const paths =
|
|
177
|
+
const paths = validateResolvedConflictPaths(resolvedPaths, this.state.conflict.paths);
|
|
193
178
|
return await withWorktreeLock(this.cwd, async () => {
|
|
194
179
|
await this.freshAuthority(this.authority.head.oid, false);
|
|
195
|
-
const mergeHead = requiredOid(
|
|
180
|
+
const mergeHead = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.execOptions())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
|
|
196
181
|
if (mergeHead !== this.authority.base.oid) throw new Error("Branch update merge context changed");
|
|
197
182
|
const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions());
|
|
198
183
|
assertOnlyDeclaredStatusChanged(this.state.conflict!.statusBaseline, status.stdout, paths);
|
package/package.json
CHANGED