@henryqw/pi-pr 0.3.6 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -23
- package/docs/pr-routing.svg +127 -0
- package/extensions/pr-command.ts +154 -0
- package/extensions/pr-github.ts +994 -0
- package/extensions/pr-merge.ts +288 -0
- package/extensions/pr-routing.ts +59 -0
- package/extensions/pr-ui.ts +93 -0
- package/extensions/pr.ts +70 -259
- package/package.json +3 -2
- package/skills/pi-pr-comment-sweep/SKILL.md +79 -0
- package/skills/pi-pr-comment-sweep/references/recovery.md +49 -0
- package/skills/pi-pr-comment-sweep/references/thread-triage.md +8 -0
- package/skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs +1672 -0
- package/skills/pi-pr-create/SKILL.md +25 -1
- package/skills/pi-pr-fix-ci/SKILL.md +59 -0
- package/skills/pi-pr-update-branch/SKILL.md +116 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type {
|
|
8
|
+
CiStatus,
|
|
9
|
+
LocalMergeSafety,
|
|
10
|
+
PullRequest,
|
|
11
|
+
PullRequestConditions,
|
|
12
|
+
PullRequestLifecycle,
|
|
13
|
+
ReviewReadiness,
|
|
14
|
+
PolicyReadiness,
|
|
15
|
+
} from "./pr-routing.ts";
|
|
16
|
+
|
|
17
|
+
const EXEC_TIMEOUT_MS = 10_000;
|
|
18
|
+
const PR_LIST_LIMIT = 100;
|
|
19
|
+
const PR_SEARCH_CAP = 1_000;
|
|
20
|
+
const PR_FIELDS = "id,number,url,state,isDraft,baseRefName,baseRefOid,headRefName,headRefOid,headRepository,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup";
|
|
21
|
+
const REVIEW_THREADS_QUERY = "query($id:ID!,$endCursor:String){node(id:$id){...on PullRequest{reviewThreads(first:100,after:$endCursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}";
|
|
22
|
+
const BASE_BRANCH_POLICY_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name branchProtectionRule{requiresStrictStatusChecks}}}}";
|
|
23
|
+
const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
24
|
+
const FAILED_CHECK_STATES = new Set([
|
|
25
|
+
"ACTION_REQUIRED",
|
|
26
|
+
"CANCELLED",
|
|
27
|
+
"ERROR",
|
|
28
|
+
"FAILURE",
|
|
29
|
+
"STALE",
|
|
30
|
+
"STARTUP_FAILURE",
|
|
31
|
+
"TIMED_OUT",
|
|
32
|
+
]);
|
|
33
|
+
const SUCCESSFUL_CHECK_STATES = new Set(["NEUTRAL", "SKIPPED", "SUCCESS"]);
|
|
34
|
+
const PENDING_CHECK_STATES = new Set([
|
|
35
|
+
"COMPLETED",
|
|
36
|
+
"EXPECTED",
|
|
37
|
+
"IN_PROGRESS",
|
|
38
|
+
"PENDING",
|
|
39
|
+
"QUEUED",
|
|
40
|
+
"REQUESTED",
|
|
41
|
+
"WAITING",
|
|
42
|
+
]);
|
|
43
|
+
const MERGEABLE_VALUES = new Set(["MERGEABLE", "CONFLICTING", "UNKNOWN"]);
|
|
44
|
+
const MERGE_STATE_VALUES = new Set([
|
|
45
|
+
"BEHIND",
|
|
46
|
+
"BLOCKED",
|
|
47
|
+
"CLEAN",
|
|
48
|
+
"DIRTY",
|
|
49
|
+
"DRAFT",
|
|
50
|
+
"HAS_HOOKS",
|
|
51
|
+
"UNKNOWN",
|
|
52
|
+
"UNSTABLE",
|
|
53
|
+
]);
|
|
54
|
+
const REVIEW_DECISION_VALUES = new Set(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]);
|
|
55
|
+
const MERGE_METHODS: MergeMethod[] = ["merge", "rebase", "squash"];
|
|
56
|
+
|
|
57
|
+
export class PullRequestLoadError extends Error {
|
|
58
|
+
constructor(message: string) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = "PullRequestLoadError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type PullRequestRef = {
|
|
65
|
+
repository: string;
|
|
66
|
+
ref: string;
|
|
67
|
+
oid: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type MergeMethod = "merge" | "rebase" | "squash";
|
|
71
|
+
|
|
72
|
+
export type PullRequestMerge = {
|
|
73
|
+
allowedMergeMethods: MergeMethod[];
|
|
74
|
+
viewerDefaultMergeMethod: MergeMethod;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type CurrentPullRequest = PullRequest & {
|
|
78
|
+
id: string;
|
|
79
|
+
number: number;
|
|
80
|
+
url: URL;
|
|
81
|
+
host: string;
|
|
82
|
+
approved: boolean;
|
|
83
|
+
base: PullRequestRef;
|
|
84
|
+
head: PullRequestRef;
|
|
85
|
+
headFetchSource: string;
|
|
86
|
+
merge: PullRequestMerge | null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type PullRequestLoadContext = Pick<ExtensionContext, "cwd" | "signal">;
|
|
90
|
+
|
|
91
|
+
type CommandOutput = {
|
|
92
|
+
stdout: string;
|
|
93
|
+
stderr: string;
|
|
94
|
+
code: number;
|
|
95
|
+
killed: boolean;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
type PushRepository = {
|
|
99
|
+
nameWithOwner: string;
|
|
100
|
+
normalizedName: string;
|
|
101
|
+
host: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
type PushUrl = {
|
|
105
|
+
fetchSource: string;
|
|
106
|
+
host: string;
|
|
107
|
+
locator: string;
|
|
108
|
+
normalizedName: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type PushTarget = {
|
|
112
|
+
fetchSource: string;
|
|
113
|
+
headOid: string;
|
|
114
|
+
remoteHeadOid: string | null;
|
|
115
|
+
repository: PushRepository;
|
|
116
|
+
ref: string;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
type RulesetBranchPolicy = {
|
|
120
|
+
requiresStrictStatusChecks: boolean;
|
|
121
|
+
allowedMergeMethods: MergeMethod[] | null;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
type ListedPullRequest = {
|
|
125
|
+
id: string;
|
|
126
|
+
number: number;
|
|
127
|
+
url: URL;
|
|
128
|
+
lifecycle: PullRequestLifecycle;
|
|
129
|
+
isDraft: boolean;
|
|
130
|
+
base: PullRequestRef;
|
|
131
|
+
head: PullRequestRef;
|
|
132
|
+
mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
133
|
+
mergeStateStatus: "BEHIND" | "BLOCKED" | "CLEAN" | "DIRTY" | "DRAFT" | "HAS_HOOKS" | "UNKNOWN" | "UNSTABLE";
|
|
134
|
+
reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
|
|
135
|
+
checkStates: string[];
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
function fail(action: string, reason: string): never {
|
|
139
|
+
throw new PullRequestLoadError(`${action} failed: ${reason}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
143
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function text(value: unknown, action: string, field: string): string {
|
|
147
|
+
if (
|
|
148
|
+
typeof value !== "string" || !value || value.trim() !== value ||
|
|
149
|
+
/[\u0000-\u001f\u007f]/.test(value)
|
|
150
|
+
) fail(action, `invalid ${field}`);
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function oid(value: unknown, action: string, field: string): string {
|
|
155
|
+
const parsed = text(value, action, field);
|
|
156
|
+
if (!OID.test(parsed)) fail(action, `invalid ${field}`);
|
|
157
|
+
return parsed.toLowerCase();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function repositoryName(value: unknown, action: string, field: string): string {
|
|
161
|
+
const parsed = text(value, action, field);
|
|
162
|
+
const parts = parsed.split("/");
|
|
163
|
+
if (parts.length !== 2 || parts.some((part) => !part || /\s|\//.test(part))) {
|
|
164
|
+
fail(action, `invalid ${field}`);
|
|
165
|
+
}
|
|
166
|
+
return parsed;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function normalizeRepository(value: string): string {
|
|
170
|
+
return value.toLowerCase();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseJson(output: string, action: string): unknown {
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(output);
|
|
176
|
+
} catch {
|
|
177
|
+
fail(action, "invalid GitHub CLI output");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseHttpUrl(value: unknown, action: string, field: string): URL {
|
|
182
|
+
const parsed = text(value, action, field);
|
|
183
|
+
let url: URL;
|
|
184
|
+
try {
|
|
185
|
+
url = new URL(parsed);
|
|
186
|
+
} catch {
|
|
187
|
+
fail(action, `invalid ${field}`);
|
|
188
|
+
}
|
|
189
|
+
if (
|
|
190
|
+
(url.protocol !== "http:" && url.protocol !== "https:") || !url.hostname ||
|
|
191
|
+
url.username || url.password || url.search || url.hash
|
|
192
|
+
) fail(action, `invalid ${field}`);
|
|
193
|
+
return url;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function singleLine(output: string, action: string, field: string): string {
|
|
197
|
+
const lines = output.replace(/\r\n/g, "\n").split("\n");
|
|
198
|
+
if (lines.at(-1) === "") lines.pop();
|
|
199
|
+
if (lines.length !== 1) fail(action, `invalid ${field}`);
|
|
200
|
+
return text(lines[0], action, field);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function optionalPushReference(output: string): string | null {
|
|
204
|
+
const normalized = output.replace(/\r\n/g, "\n");
|
|
205
|
+
if (normalized === "\n") return null;
|
|
206
|
+
if (!normalized) fail("Read push target", "invalid push target");
|
|
207
|
+
return singleLine(normalized, "Read push target", "push target");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function lines(output: string, action: string, field: string): string[] {
|
|
211
|
+
const parsed = output.replace(/\r\n/g, "\n").split("\n");
|
|
212
|
+
if (parsed.at(-1) === "") parsed.pop();
|
|
213
|
+
if (!parsed.length) fail(action, `invalid ${field}`);
|
|
214
|
+
const result = parsed.map((value) => text(value, action, field));
|
|
215
|
+
if (new Set(result).size !== result.length) fail(action, `invalid ${field}`);
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseCommandOutput(value: unknown, action: string): CommandOutput {
|
|
220
|
+
if (!isRecord(value)) fail(action, "invalid command result");
|
|
221
|
+
const { stdout, stderr, code, killed } = value;
|
|
222
|
+
if (
|
|
223
|
+
typeof stdout !== "string" || typeof stderr !== "string" || typeof code !== "number" ||
|
|
224
|
+
!Number.isSafeInteger(code) || code < 0 || typeof killed !== "boolean"
|
|
225
|
+
) fail(action, "invalid command result");
|
|
226
|
+
return { stdout, stderr, code, killed };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function invoke(
|
|
230
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
231
|
+
context: PullRequestLoadContext,
|
|
232
|
+
action: string,
|
|
233
|
+
command: string,
|
|
234
|
+
args: string[],
|
|
235
|
+
): Promise<CommandOutput> {
|
|
236
|
+
let result: unknown;
|
|
237
|
+
try {
|
|
238
|
+
result = await pi.exec(command, args, {
|
|
239
|
+
cwd: context.cwd,
|
|
240
|
+
signal: context.signal,
|
|
241
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
242
|
+
});
|
|
243
|
+
} catch {
|
|
244
|
+
fail(action, "command threw");
|
|
245
|
+
}
|
|
246
|
+
return parseCommandOutput(result, action);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function commandFailure(action: string, result: CommandOutput): never {
|
|
250
|
+
fail(action, result.killed ? "command was cancelled" : `exit code ${result.code}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function execute(
|
|
254
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
255
|
+
context: PullRequestLoadContext,
|
|
256
|
+
action: string,
|
|
257
|
+
command: string,
|
|
258
|
+
args: string[],
|
|
259
|
+
): Promise<CommandOutput> {
|
|
260
|
+
const result = await invoke(pi, context, action, command, args);
|
|
261
|
+
if (result.killed || result.code !== 0) commandFailure(action, result);
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parsePushReference(value: string, remoteNames: string[]): { remote: string; ref: string } {
|
|
266
|
+
const remote = remoteNames
|
|
267
|
+
.filter((name) => value.startsWith(`${name}/`))
|
|
268
|
+
.sort((left, right) => right.length - left.length)[0];
|
|
269
|
+
if (!remote) fail("Read push target", "target does not name a configured remote");
|
|
270
|
+
const ref = value.slice(remote.length + 1);
|
|
271
|
+
text(ref, "Read push target", "push ref");
|
|
272
|
+
return { remote, ref };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function parsePushUrl(value: string): PushUrl {
|
|
276
|
+
if (/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(value)) return fail("Read push URL", "invalid push URL");
|
|
277
|
+
const scp = /^(?:git@)?([a-z0-9.-]+):([a-z0-9_.-]+)\/([a-z0-9_.-]+)$/i.exec(value);
|
|
278
|
+
const rawUrl = scp
|
|
279
|
+
? null
|
|
280
|
+
: /^(https|ssh):\/\/(?:(git)@)?([a-z0-9.-]+)\/([a-z0-9_.-]+)\/([a-z0-9_.-]+)\/?$/i.exec(value);
|
|
281
|
+
if (!scp && (!rawUrl || (rawUrl[1]!.toLowerCase() === "https" && rawUrl[2]))) {
|
|
282
|
+
return fail("Read push URL", "invalid push URL");
|
|
283
|
+
}
|
|
284
|
+
const host = (scp?.[1] ?? rawUrl![3])!;
|
|
285
|
+
const owner = (scp?.[2] ?? rawUrl![4])!;
|
|
286
|
+
const name = (scp?.[3] ?? rawUrl![5])!.replace(/\.git$/i, "");
|
|
287
|
+
const normalizedHost = host.toLowerCase();
|
|
288
|
+
if (
|
|
289
|
+
!name || owner === "." || owner === ".." || name === "." || name === ".." ||
|
|
290
|
+
normalizedHost.length > 253 || normalizedHost.split(".").some((label) =>
|
|
291
|
+
!label || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label)
|
|
292
|
+
)
|
|
293
|
+
) fail("Read push URL", "invalid push URL");
|
|
294
|
+
const normalizedName = normalizeRepository(`${owner}/${name}`);
|
|
295
|
+
if (rawUrl) {
|
|
296
|
+
let url: URL;
|
|
297
|
+
try {
|
|
298
|
+
url = new URL(value);
|
|
299
|
+
} catch {
|
|
300
|
+
return fail("Read push URL", "invalid push URL");
|
|
301
|
+
}
|
|
302
|
+
const path = /^\/([a-z0-9_.-]+)\/([a-z0-9_.-]+)\/?$/i.exec(url.pathname);
|
|
303
|
+
if (
|
|
304
|
+
url.protocol !== `${rawUrl[1]!.toLowerCase()}:` ||
|
|
305
|
+
url.username !== (rawUrl[2] ?? "") || url.password || url.port || url.search || url.hash ||
|
|
306
|
+
url.hostname.toLowerCase() !== normalizedHost || !path ||
|
|
307
|
+
normalizeRepository(`${path[1]}/${path[2]!.replace(/\.git$/i, "")}`) !== normalizedName
|
|
308
|
+
) return fail("Read push URL", "invalid push URL");
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
fetchSource: value,
|
|
312
|
+
host: normalizedHost,
|
|
313
|
+
locator: `${normalizedHost}/${normalizedName}`,
|
|
314
|
+
normalizedName,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function parsePushRepository(output: string, pushUrl: PushUrl): PushRepository {
|
|
319
|
+
const value = parseJson(output, "Read push repository");
|
|
320
|
+
if (!isRecord(value)) fail("Read push repository", "invalid GitHub CLI output");
|
|
321
|
+
const nameWithOwner = repositoryName(value.nameWithOwner, "Read push repository", "nameWithOwner");
|
|
322
|
+
const url = parseHttpUrl(value.url, "Read push repository", "url");
|
|
323
|
+
const path = url.pathname.split("/").filter(Boolean);
|
|
324
|
+
const normalizedName = normalizeRepository(nameWithOwner);
|
|
325
|
+
const host = url.hostname.toLowerCase();
|
|
326
|
+
if (
|
|
327
|
+
path.length !== 2 || normalizeRepository(path.join("/")) !== normalizedName ||
|
|
328
|
+
host !== pushUrl.host || normalizedName !== pushUrl.normalizedName
|
|
329
|
+
) fail("Read push repository", "response does not match push URL");
|
|
330
|
+
return { nameWithOwner, normalizedName, host };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function parseRemotePushRef(output: string, ref: string): string {
|
|
334
|
+
const normalized = output.replace(/\r\n/g, "\n");
|
|
335
|
+
const line = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
|
|
336
|
+
const parts = line.split("\t");
|
|
337
|
+
if (line.includes("\n") || parts.length !== 2 || parts[1] !== `refs/heads/${ref}`) {
|
|
338
|
+
fail("Read remote push ref", "response does not match push ref");
|
|
339
|
+
}
|
|
340
|
+
return oid(parts[0], "Read remote push ref", "OID");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function parsePullRequestUrl(value: unknown, number: number): { url: URL; repository: string } {
|
|
344
|
+
const url = parseHttpUrl(value, "Find pull requests", "url");
|
|
345
|
+
const path = url.pathname.split("/").filter(Boolean);
|
|
346
|
+
if (path.length !== 4 || path[2] !== "pull" || !/^[1-9][0-9]*$/.test(path[3])) {
|
|
347
|
+
fail("Find pull requests", "invalid url");
|
|
348
|
+
}
|
|
349
|
+
const urlNumber = Number(path[3]);
|
|
350
|
+
if (!Number.isSafeInteger(urlNumber) || urlNumber !== number) fail("Find pull requests", "url does not match number");
|
|
351
|
+
return {
|
|
352
|
+
url,
|
|
353
|
+
repository: repositoryName(`${path[0]}/${path[1]}`, "Find pull requests", "base repository"),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function lifecycle(value: unknown): PullRequestLifecycle {
|
|
358
|
+
if (value === "OPEN") return "open";
|
|
359
|
+
if (value === "MERGED") return "merged";
|
|
360
|
+
if (value === "CLOSED") return "closed";
|
|
361
|
+
return fail("Find pull requests", "invalid state");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function mergeable(value: unknown): ListedPullRequest["mergeable"] {
|
|
365
|
+
if (typeof value !== "string" || !MERGEABLE_VALUES.has(value)) {
|
|
366
|
+
fail("Find pull requests", "invalid mergeable");
|
|
367
|
+
}
|
|
368
|
+
return value as ListedPullRequest["mergeable"];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function mergeStateStatus(value: unknown): ListedPullRequest["mergeStateStatus"] {
|
|
372
|
+
if (typeof value !== "string" || !MERGE_STATE_VALUES.has(value)) {
|
|
373
|
+
fail("Find pull requests", "invalid mergeStateStatus");
|
|
374
|
+
}
|
|
375
|
+
return value as ListedPullRequest["mergeStateStatus"];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function reviewDecision(value: unknown): ListedPullRequest["reviewDecision"] {
|
|
379
|
+
if (value === null || value === "") return null;
|
|
380
|
+
if (typeof value !== "string" || !REVIEW_DECISION_VALUES.has(value)) {
|
|
381
|
+
fail("Find pull requests", "invalid reviewDecision");
|
|
382
|
+
}
|
|
383
|
+
return value as ListedPullRequest["reviewDecision"];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function optionalCheckState(check: Record<string, unknown>, field: string): string | null {
|
|
387
|
+
const value = check[field];
|
|
388
|
+
if (value === undefined || value === null || value === "") return null;
|
|
389
|
+
if (
|
|
390
|
+
typeof value !== "string" ||
|
|
391
|
+
(!FAILED_CHECK_STATES.has(value) && !SUCCESSFUL_CHECK_STATES.has(value) &&
|
|
392
|
+
!PENDING_CHECK_STATES.has(value))
|
|
393
|
+
) fail("Find pull requests", "invalid statusCheckRollup");
|
|
394
|
+
return value;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function checkOutcome(state: string): "failure" | "success" | "running" {
|
|
398
|
+
if (FAILED_CHECK_STATES.has(state)) return "failure";
|
|
399
|
+
if (SUCCESSFUL_CHECK_STATES.has(state)) return "success";
|
|
400
|
+
return "running";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function checkState(value: unknown): string {
|
|
404
|
+
if (!isRecord(value)) fail("Find pull requests", "invalid statusCheckRollup");
|
|
405
|
+
const conclusion = optionalCheckState(value, "conclusion");
|
|
406
|
+
const state = optionalCheckState(value, "state");
|
|
407
|
+
const status = optionalCheckState(value, "status");
|
|
408
|
+
const states = [conclusion, state, status].filter((value): value is string => value !== null);
|
|
409
|
+
if (!states.length) fail("Find pull requests", "invalid statusCheckRollup");
|
|
410
|
+
|
|
411
|
+
// COMPLETED describes a check run's lifecycle; its conclusion gives the outcome.
|
|
412
|
+
const outcomes = states.filter((value) => value !== "COMPLETED").map(checkOutcome);
|
|
413
|
+
if (
|
|
414
|
+
new Set(outcomes).size > 1 ||
|
|
415
|
+
(states.includes("COMPLETED") && outcomes.includes("running"))
|
|
416
|
+
) fail("Find pull requests", "invalid statusCheckRollup");
|
|
417
|
+
return conclusion ?? state ?? status ?? fail("Find pull requests", "invalid statusCheckRollup");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function checkStates(value: unknown): string[] {
|
|
421
|
+
if (value === null) return [];
|
|
422
|
+
if (!Array.isArray(value)) fail("Find pull requests", "invalid statusCheckRollup");
|
|
423
|
+
return value.map(checkState);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function listedPullRequest(value: unknown): ListedPullRequest | null {
|
|
427
|
+
if (!isRecord(value)) fail("Find pull requests", "invalid GitHub CLI output");
|
|
428
|
+
if (value.headRepository === null) return null;
|
|
429
|
+
if (!isRecord(value.headRepository)) fail("Find pull requests", "invalid headRepository");
|
|
430
|
+
const number = value.number;
|
|
431
|
+
if (typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0) {
|
|
432
|
+
fail("Find pull requests", "invalid number");
|
|
433
|
+
}
|
|
434
|
+
const parsedUrl = parsePullRequestUrl(value.url, number);
|
|
435
|
+
const isDraft = value.isDraft;
|
|
436
|
+
if (typeof isDraft !== "boolean") fail("Find pull requests", "invalid isDraft");
|
|
437
|
+
return {
|
|
438
|
+
id: text(value.id, "Find pull requests", "id"),
|
|
439
|
+
number,
|
|
440
|
+
url: parsedUrl.url,
|
|
441
|
+
lifecycle: lifecycle(value.state),
|
|
442
|
+
isDraft,
|
|
443
|
+
base: {
|
|
444
|
+
repository: parsedUrl.repository,
|
|
445
|
+
ref: text(value.baseRefName, "Find pull requests", "baseRefName"),
|
|
446
|
+
oid: oid(value.baseRefOid, "Find pull requests", "baseRefOid"),
|
|
447
|
+
},
|
|
448
|
+
head: {
|
|
449
|
+
repository: repositoryName(value.headRepository.nameWithOwner, "Find pull requests", "headRepository.nameWithOwner"),
|
|
450
|
+
ref: text(value.headRefName, "Find pull requests", "headRefName"),
|
|
451
|
+
oid: oid(value.headRefOid, "Find pull requests", "headRefOid"),
|
|
452
|
+
},
|
|
453
|
+
mergeable: mergeable(value.mergeable),
|
|
454
|
+
mergeStateStatus: mergeStateStatus(value.mergeStateStatus),
|
|
455
|
+
reviewDecision: reviewDecision(value.reviewDecision),
|
|
456
|
+
checkStates: checkStates(value.statusCheckRollup),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function parseCandidateUrls(output: string, host: string): URL[] {
|
|
461
|
+
const pages = parseJson(output, "Find pull requests");
|
|
462
|
+
if (!Array.isArray(pages) || !pages.length) fail("Find pull requests", "invalid GitHub CLI output");
|
|
463
|
+
let totalCount: number | null = null;
|
|
464
|
+
let incomplete = false;
|
|
465
|
+
const pageItems: unknown[][] = [];
|
|
466
|
+
for (const page of pages) {
|
|
467
|
+
if (
|
|
468
|
+
!isRecord(page) || typeof page.total_count !== "number" ||
|
|
469
|
+
!Number.isSafeInteger(page.total_count) || page.total_count < 0 ||
|
|
470
|
+
typeof page.incomplete_results !== "boolean" || !Array.isArray(page.items)
|
|
471
|
+
) fail("Find pull requests", "invalid GitHub CLI output");
|
|
472
|
+
if (totalCount !== null && page.total_count !== totalCount) {
|
|
473
|
+
fail("Find pull requests", "inconsistent search result pages");
|
|
474
|
+
}
|
|
475
|
+
totalCount = page.total_count;
|
|
476
|
+
incomplete ||= page.incomplete_results;
|
|
477
|
+
pageItems.push(page.items);
|
|
478
|
+
}
|
|
479
|
+
if (totalCount === null) fail("Find pull requests", "invalid GitHub CLI output");
|
|
480
|
+
if (incomplete) fail("Find pull requests", "incomplete search results");
|
|
481
|
+
if (totalCount > PR_SEARCH_CAP) fail("Find pull requests", "GitHub search result cap reached");
|
|
482
|
+
const expectedPages = Math.max(1, Math.ceil(totalCount / PR_LIST_LIMIT));
|
|
483
|
+
if (pageItems.length !== expectedPages || pageItems.some((items, index) =>
|
|
484
|
+
items.length !== Math.min(PR_LIST_LIMIT, Math.max(0, totalCount - index * PR_LIST_LIMIT))
|
|
485
|
+
)) fail("Find pull requests", "incomplete search results");
|
|
486
|
+
const urls = pageItems.flat().map((candidate) => {
|
|
487
|
+
if (!isRecord(candidate)) fail("Find pull requests", "invalid GitHub CLI output");
|
|
488
|
+
const url = parseHttpUrl(candidate.html_url, "Find pull requests", "url");
|
|
489
|
+
const path = url.pathname.split("/").filter(Boolean);
|
|
490
|
+
if (
|
|
491
|
+
url.hostname.toLowerCase() !== host || path.length !== 4 || path[2] !== "pull" ||
|
|
492
|
+
!/^[1-9][0-9]*$/.test(path[3])
|
|
493
|
+
) fail("Find pull requests", "invalid url");
|
|
494
|
+
return url;
|
|
495
|
+
});
|
|
496
|
+
if (new Set(urls.map((url) => url.href.toLowerCase())).size !== urls.length) {
|
|
497
|
+
fail("Find pull requests", "duplicate candidate url");
|
|
498
|
+
}
|
|
499
|
+
return urls;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function parseLoadedPullRequest(output: string, expectedUrl: URL): ListedPullRequest | null {
|
|
503
|
+
const value = parseJson(output, "Find pull requests");
|
|
504
|
+
const candidate = listedPullRequest(value);
|
|
505
|
+
if (candidate !== null && candidate.url.href !== expectedUrl.href) {
|
|
506
|
+
fail("Find pull requests", "response does not match candidate url");
|
|
507
|
+
}
|
|
508
|
+
return candidate;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function selectPullRequest(
|
|
512
|
+
candidates: ListedPullRequest[],
|
|
513
|
+
pushTarget: PushTarget,
|
|
514
|
+
): ListedPullRequest | null {
|
|
515
|
+
const matching = candidates.filter((candidate) =>
|
|
516
|
+
candidate.url.hostname.toLowerCase() === pushTarget.repository.host &&
|
|
517
|
+
normalizeRepository(candidate.head.repository) === pushTarget.repository.normalizedName &&
|
|
518
|
+
candidate.head.ref === pushTarget.ref,
|
|
519
|
+
);
|
|
520
|
+
const open = matching.filter((candidate) => candidate.lifecycle === "open");
|
|
521
|
+
if (open.length > 1) fail("Find pull requests", "multiple open pull requests match current push target");
|
|
522
|
+
if (open.length === 1) {
|
|
523
|
+
if (pushTarget.remoteHeadOid === null) fail("Find pull requests", "remote push ref is absent for open pull request");
|
|
524
|
+
if (open[0].head.oid !== pushTarget.remoteHeadOid) {
|
|
525
|
+
fail("Find pull requests", "open pull request head does not match remote push ref");
|
|
526
|
+
}
|
|
527
|
+
return open[0];
|
|
528
|
+
}
|
|
529
|
+
if (pushTarget.remoteHeadOid === null) return null;
|
|
530
|
+
|
|
531
|
+
const historical = matching.filter((candidate) =>
|
|
532
|
+
candidate.lifecycle !== "open" && candidate.head.oid === pushTarget.remoteHeadOid
|
|
533
|
+
);
|
|
534
|
+
if (historical.length > 1) fail("Find pull requests", "multiple historical pull requests match remote push ref");
|
|
535
|
+
return historical[0] ?? null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function ciStatus(states: string[]): CiStatus {
|
|
539
|
+
if (!states.length) return "none";
|
|
540
|
+
let running = false;
|
|
541
|
+
for (const state of states) {
|
|
542
|
+
if (FAILED_CHECK_STATES.has(state)) return "failure";
|
|
543
|
+
if (!SUCCESSFUL_CHECK_STATES.has(state)) running = true;
|
|
544
|
+
}
|
|
545
|
+
return running ? "running" : "success";
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function conditions(
|
|
549
|
+
candidate: ListedPullRequest,
|
|
550
|
+
unresolvedThreads: number,
|
|
551
|
+
requiresStrictStatusChecks: boolean,
|
|
552
|
+
): PullRequestConditions {
|
|
553
|
+
if (
|
|
554
|
+
(candidate.mergeable === "MERGEABLE" && candidate.mergeStateStatus === "DIRTY") ||
|
|
555
|
+
(candidate.mergeable === "CONFLICTING" && candidate.mergeStateStatus === "CLEAN")
|
|
556
|
+
) fail("Find pull requests", "inconsistent mergeability data");
|
|
557
|
+
const review: ReviewReadiness = candidate.reviewDecision === "REVIEW_REQUIRED" || candidate.reviewDecision === "CHANGES_REQUESTED"
|
|
558
|
+
? "pending"
|
|
559
|
+
: "ready";
|
|
560
|
+
const behind = candidate.mergeStateStatus === "BEHIND";
|
|
561
|
+
const policy: PolicyReadiness = candidate.mergeable === "MERGEABLE" &&
|
|
562
|
+
(candidate.mergeStateStatus === "CLEAN" || (behind && !requiresStrictStatusChecks))
|
|
563
|
+
? "ready"
|
|
564
|
+
: "pending";
|
|
565
|
+
return {
|
|
566
|
+
draft: candidate.isDraft,
|
|
567
|
+
baseUpdateRequired: behind && requiresStrictStatusChecks,
|
|
568
|
+
conflict: candidate.mergeable === "CONFLICTING" || candidate.mergeStateStatus === "DIRTY",
|
|
569
|
+
changesRequested: candidate.reviewDecision === "CHANGES_REQUESTED",
|
|
570
|
+
unresolvedThreads,
|
|
571
|
+
ci: ciStatus(candidate.checkStates),
|
|
572
|
+
review,
|
|
573
|
+
policy,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function parseUnresolvedReviewThreads(output: string): number {
|
|
578
|
+
const pages = parseJson(output, "Read unresolved review threads");
|
|
579
|
+
if (!Array.isArray(pages) || !pages.length) {
|
|
580
|
+
fail("Read unresolved review threads", "invalid GitHub CLI output");
|
|
581
|
+
}
|
|
582
|
+
let total = 0;
|
|
583
|
+
for (const [index, page] of pages.entries()) {
|
|
584
|
+
if (!isRecord(page)) fail("Read unresolved review threads", "invalid GitHub CLI output");
|
|
585
|
+
if (page.errors !== undefined) {
|
|
586
|
+
if (!Array.isArray(page.errors)) fail("Read unresolved review threads", "invalid GitHub CLI output");
|
|
587
|
+
if (page.errors.length) fail("Read unresolved review threads", "GitHub GraphQL returned errors");
|
|
588
|
+
}
|
|
589
|
+
if (!isRecord(page.data) || !isRecord(page.data.node)) {
|
|
590
|
+
fail("Read unresolved review threads", "invalid GitHub CLI output");
|
|
591
|
+
}
|
|
592
|
+
const reviewThreads = page.data.node.reviewThreads;
|
|
593
|
+
if (!isRecord(reviewThreads) || !Array.isArray(reviewThreads.nodes) || !isRecord(reviewThreads.pageInfo)) {
|
|
594
|
+
fail("Read unresolved review threads", "invalid GitHub CLI output");
|
|
595
|
+
}
|
|
596
|
+
const { hasNextPage, endCursor } = reviewThreads.pageInfo;
|
|
597
|
+
if (
|
|
598
|
+
typeof hasNextPage !== "boolean" ||
|
|
599
|
+
(hasNextPage && typeof endCursor !== "string") ||
|
|
600
|
+
(!hasNextPage && endCursor !== null && typeof endCursor !== "string") ||
|
|
601
|
+
hasNextPage !== (index < pages.length - 1)
|
|
602
|
+
) fail("Read unresolved review threads", "invalid GitHub CLI output");
|
|
603
|
+
for (const thread of reviewThreads.nodes) {
|
|
604
|
+
if (!isRecord(thread) || typeof thread.isResolved !== "boolean") {
|
|
605
|
+
fail("Read unresolved review threads", "invalid GitHub CLI output");
|
|
606
|
+
}
|
|
607
|
+
if (!thread.isResolved) total += 1;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (!Number.isSafeInteger(total)) fail("Read unresolved review threads", "invalid GitHub CLI output");
|
|
611
|
+
return total;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function parseLegacyBaseBranchPolicy(output: string, candidate: ListedPullRequest): boolean {
|
|
615
|
+
const value = parseJson(output, "Read base branch policy");
|
|
616
|
+
if (!isRecord(value)) fail("Read base branch policy", "invalid GitHub CLI output");
|
|
617
|
+
if (value.errors !== undefined) {
|
|
618
|
+
if (!Array.isArray(value.errors)) fail("Read base branch policy", "invalid GitHub CLI output");
|
|
619
|
+
if (value.errors.length) fail("Read base branch policy", "GitHub GraphQL returned errors");
|
|
620
|
+
}
|
|
621
|
+
const repository = isRecord(value.data) ? value.data.repository : undefined;
|
|
622
|
+
if (!isRecord(repository) || !isRecord(repository.ref)) {
|
|
623
|
+
fail("Read base branch policy", "invalid GitHub CLI output");
|
|
624
|
+
}
|
|
625
|
+
if (
|
|
626
|
+
normalizeRepository(repositoryName(repository.nameWithOwner, "Read base branch policy", "repository")) !==
|
|
627
|
+
normalizeRepository(candidate.base.repository) ||
|
|
628
|
+
text(repository.ref.name, "Read base branch policy", "ref") !== candidate.base.ref
|
|
629
|
+
) fail("Read base branch policy", "response does not match pull request base");
|
|
630
|
+
const rule = repository.ref.branchProtectionRule;
|
|
631
|
+
if (rule === null) return false;
|
|
632
|
+
if (!isRecord(rule) || typeof rule.requiresStrictStatusChecks !== "boolean") {
|
|
633
|
+
fail("Read base branch policy", "invalid GitHub CLI output");
|
|
634
|
+
}
|
|
635
|
+
return rule.requiresStrictStatusChecks;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function parseRulesetBaseBranchPolicy(output: string): RulesetBranchPolicy {
|
|
639
|
+
const pages = parseJson(output, "Read base branch rulesets");
|
|
640
|
+
if (!Array.isArray(pages) || !pages.length) fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
641
|
+
let requiresStrictStatusChecks = false;
|
|
642
|
+
let allowedMergeMethods: Set<MergeMethod> | null = null;
|
|
643
|
+
for (const page of pages) {
|
|
644
|
+
if (!Array.isArray(page)) fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
645
|
+
for (const rule of page) {
|
|
646
|
+
if (!isRecord(rule)) fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
647
|
+
const type = text(rule.type, "Read base branch rulesets", "rule type");
|
|
648
|
+
if (type === "required_status_checks") {
|
|
649
|
+
if (!isRecord(rule.parameters) || typeof rule.parameters.strict_required_status_checks_policy !== "boolean") {
|
|
650
|
+
fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
651
|
+
}
|
|
652
|
+
requiresStrictStatusChecks ||= rule.parameters.strict_required_status_checks_policy;
|
|
653
|
+
}
|
|
654
|
+
if (type === "pull_request") {
|
|
655
|
+
if (!isRecord(rule.parameters) || !Array.isArray(rule.parameters.allowed_merge_methods)) {
|
|
656
|
+
fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
657
|
+
}
|
|
658
|
+
const methods = rule.parameters.allowed_merge_methods;
|
|
659
|
+
if (
|
|
660
|
+
methods.some((method) => typeof method !== "string" || !MERGE_METHODS.includes(method as MergeMethod)) ||
|
|
661
|
+
new Set(methods).size !== methods.length
|
|
662
|
+
) fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
663
|
+
const restriction = new Set<MergeMethod>(methods as MergeMethod[]);
|
|
664
|
+
allowedMergeMethods = allowedMergeMethods === null
|
|
665
|
+
? restriction
|
|
666
|
+
: new Set<MergeMethod>([...allowedMergeMethods].filter((method: MergeMethod) => restriction.has(method)));
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return {
|
|
671
|
+
requiresStrictStatusChecks,
|
|
672
|
+
allowedMergeMethods: allowedMergeMethods === null
|
|
673
|
+
? null
|
|
674
|
+
: MERGE_METHODS.filter((method) => allowedMergeMethods.has(method)),
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function parseMergeMethodSettings(output: string, rulesetMethods: MergeMethod[] | null): PullRequestMerge {
|
|
679
|
+
const value = parseJson(output, "Read merge methods");
|
|
680
|
+
if (!isRecord(value)) fail("Read merge methods", "invalid GitHub CLI output");
|
|
681
|
+
const { mergeCommitAllowed, rebaseMergeAllowed, squashMergeAllowed } = value;
|
|
682
|
+
if (
|
|
683
|
+
typeof mergeCommitAllowed !== "boolean" || typeof rebaseMergeAllowed !== "boolean" ||
|
|
684
|
+
typeof squashMergeAllowed !== "boolean"
|
|
685
|
+
) fail("Read merge methods", "invalid GitHub CLI output");
|
|
686
|
+
let allowedMergeMethods: MergeMethod[] = [];
|
|
687
|
+
if (mergeCommitAllowed) allowedMergeMethods.push("merge");
|
|
688
|
+
if (rebaseMergeAllowed) allowedMergeMethods.push("rebase");
|
|
689
|
+
if (squashMergeAllowed) allowedMergeMethods.push("squash");
|
|
690
|
+
if (!allowedMergeMethods.length) fail("Read merge methods", "repository allows no merge method");
|
|
691
|
+
const viewerDefaultMergeMethod = value.viewerDefaultMergeMethod === "MERGE"
|
|
692
|
+
? "merge"
|
|
693
|
+
: value.viewerDefaultMergeMethod === "REBASE"
|
|
694
|
+
? "rebase"
|
|
695
|
+
: value.viewerDefaultMergeMethod === "SQUASH"
|
|
696
|
+
? "squash"
|
|
697
|
+
: fail("Read merge methods", "invalid viewerDefaultMergeMethod");
|
|
698
|
+
if (!allowedMergeMethods.includes(viewerDefaultMergeMethod)) {
|
|
699
|
+
fail("Read merge methods", "viewerDefaultMergeMethod is not allowed");
|
|
700
|
+
}
|
|
701
|
+
if (rulesetMethods !== null) {
|
|
702
|
+
allowedMergeMethods = allowedMergeMethods.filter((method) => rulesetMethods.includes(method));
|
|
703
|
+
}
|
|
704
|
+
if (!allowedMergeMethods.length) {
|
|
705
|
+
fail("Read merge methods", "repository and applicable rules allow no common merge method");
|
|
706
|
+
}
|
|
707
|
+
return { allowedMergeMethods, viewerDefaultMergeMethod };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function readPushTarget(
|
|
711
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
712
|
+
context: PullRequestLoadContext,
|
|
713
|
+
): Promise<PushTarget | null> {
|
|
714
|
+
const branch = singleLine(
|
|
715
|
+
(await execute(pi, context, "Read current branch", "git", ["branch", "--show-current"])).stdout,
|
|
716
|
+
"Read current branch",
|
|
717
|
+
"branch",
|
|
718
|
+
);
|
|
719
|
+
const headOid = oid(
|
|
720
|
+
singleLine((await execute(pi, context, "Read current HEAD", "git", ["rev-parse", "--verify", "HEAD^{commit}"])).stdout, "Read current HEAD", "HEAD"),
|
|
721
|
+
"Read current HEAD",
|
|
722
|
+
"HEAD",
|
|
723
|
+
);
|
|
724
|
+
const pushReference = optionalPushReference(
|
|
725
|
+
(await execute(pi, context, "Read push target", "git", [
|
|
726
|
+
"for-each-ref",
|
|
727
|
+
"--format=%(push:short)",
|
|
728
|
+
`refs/heads/${branch}`,
|
|
729
|
+
])).stdout,
|
|
730
|
+
);
|
|
731
|
+
if (pushReference === null) return null;
|
|
732
|
+
const remoteNames = lines(
|
|
733
|
+
(await execute(pi, context, "Read push remotes", "git", ["remote"])).stdout,
|
|
734
|
+
"Read push remotes",
|
|
735
|
+
"remote",
|
|
736
|
+
);
|
|
737
|
+
const push = parsePushReference(pushReference, remoteNames);
|
|
738
|
+
const checkedRef = singleLine(
|
|
739
|
+
(await execute(pi, context, "Read push target", "git", ["check-ref-format", "--branch", push.ref])).stdout,
|
|
740
|
+
"Read push target",
|
|
741
|
+
"push ref",
|
|
742
|
+
);
|
|
743
|
+
if (checkedRef !== push.ref) fail("Read push target", "invalid push ref");
|
|
744
|
+
const pushUrls = lines(
|
|
745
|
+
(await execute(pi, context, "Read push URL", "git", ["remote", "get-url", "--push", "--all", push.remote])).stdout,
|
|
746
|
+
"Read push URL",
|
|
747
|
+
"push URL",
|
|
748
|
+
);
|
|
749
|
+
if (pushUrls.length !== 1) fail("Read push URL", "multiple push URLs are configured");
|
|
750
|
+
const pushUrl = parsePushUrl(pushUrls[0]);
|
|
751
|
+
const repository = parsePushRepository((await execute(
|
|
752
|
+
pi,
|
|
753
|
+
context,
|
|
754
|
+
"Read push repository",
|
|
755
|
+
"gh",
|
|
756
|
+
["repo", "view", pushUrl.locator, "--json", "nameWithOwner,url"],
|
|
757
|
+
)).stdout, pushUrl);
|
|
758
|
+
const remoteHead = await invoke(pi, context, "Read remote push ref", "git", [
|
|
759
|
+
"ls-remote",
|
|
760
|
+
"--exit-code",
|
|
761
|
+
"--refs",
|
|
762
|
+
pushUrl.fetchSource,
|
|
763
|
+
`refs/heads/${push.ref}`,
|
|
764
|
+
]);
|
|
765
|
+
let remoteHeadOid: string | null;
|
|
766
|
+
if (remoteHead.killed) commandFailure("Read remote push ref", remoteHead);
|
|
767
|
+
if (remoteHead.code === 2) {
|
|
768
|
+
if (remoteHead.stdout !== "") fail("Read remote push ref", "invalid absent-ref response");
|
|
769
|
+
remoteHeadOid = null;
|
|
770
|
+
} else {
|
|
771
|
+
if (remoteHead.code !== 0) commandFailure("Read remote push ref", remoteHead);
|
|
772
|
+
remoteHeadOid = parseRemotePushRef(remoteHead.stdout, push.ref);
|
|
773
|
+
}
|
|
774
|
+
return { fetchSource: pushUrl.fetchSource, headOid, remoteHeadOid, repository, ref: push.ref };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function readUnresolvedReviewThreads(
|
|
778
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
779
|
+
context: PullRequestLoadContext,
|
|
780
|
+
candidate: ListedPullRequest,
|
|
781
|
+
): Promise<number> {
|
|
782
|
+
const result = await execute(pi, context, "Read unresolved review threads", "gh", [
|
|
783
|
+
"api",
|
|
784
|
+
"graphql",
|
|
785
|
+
"--hostname",
|
|
786
|
+
candidate.url.hostname,
|
|
787
|
+
"--paginate",
|
|
788
|
+
"--slurp",
|
|
789
|
+
"-f",
|
|
790
|
+
`query=${REVIEW_THREADS_QUERY}`,
|
|
791
|
+
"-F",
|
|
792
|
+
`id=${candidate.id}`,
|
|
793
|
+
]);
|
|
794
|
+
return parseUnresolvedReviewThreads(result.stdout);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function readLegacyBaseBranchPolicy(
|
|
798
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
799
|
+
context: PullRequestLoadContext,
|
|
800
|
+
candidate: ListedPullRequest,
|
|
801
|
+
): Promise<boolean> {
|
|
802
|
+
const [owner, name] = candidate.base.repository.split("/");
|
|
803
|
+
const result = await execute(pi, context, "Read base branch policy", "gh", [
|
|
804
|
+
"api",
|
|
805
|
+
"graphql",
|
|
806
|
+
"--hostname",
|
|
807
|
+
candidate.url.hostname,
|
|
808
|
+
"-f",
|
|
809
|
+
`query=${BASE_BRANCH_POLICY_QUERY}`,
|
|
810
|
+
"-F",
|
|
811
|
+
`owner=${owner}`,
|
|
812
|
+
"-F",
|
|
813
|
+
`name=${name}`,
|
|
814
|
+
"-F",
|
|
815
|
+
`qualifiedName=refs/heads/${candidate.base.ref}`,
|
|
816
|
+
]);
|
|
817
|
+
return parseLegacyBaseBranchPolicy(result.stdout, candidate);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function readRulesetBaseBranchPolicy(
|
|
821
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
822
|
+
context: PullRequestLoadContext,
|
|
823
|
+
candidate: ListedPullRequest,
|
|
824
|
+
): Promise<RulesetBranchPolicy> {
|
|
825
|
+
const [owner, name] = candidate.base.repository.split("/");
|
|
826
|
+
const result = await execute(pi, context, "Read base branch rulesets", "gh", [
|
|
827
|
+
"api",
|
|
828
|
+
"--hostname",
|
|
829
|
+
candidate.url.hostname,
|
|
830
|
+
"--paginate",
|
|
831
|
+
"--slurp",
|
|
832
|
+
"-H",
|
|
833
|
+
"Accept: application/vnd.github+json",
|
|
834
|
+
"-H",
|
|
835
|
+
"X-GitHub-Api-Version: 2022-11-28",
|
|
836
|
+
`repos/${owner}/${name}/rules/branches/${encodeURIComponent(candidate.base.ref)}`,
|
|
837
|
+
]);
|
|
838
|
+
return parseRulesetBaseBranchPolicy(result.stdout);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function readLocalMergeSafety(
|
|
842
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
843
|
+
context: PullRequestLoadContext,
|
|
844
|
+
pushTarget: PushTarget,
|
|
845
|
+
pullRequestHead: string,
|
|
846
|
+
): Promise<LocalMergeSafety> {
|
|
847
|
+
const status = await execute(pi, context, "Read worktree status", "git", [
|
|
848
|
+
"status",
|
|
849
|
+
"--porcelain=v1",
|
|
850
|
+
"--untracked-files=all",
|
|
851
|
+
]);
|
|
852
|
+
const operationStates = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
|
|
853
|
+
const statePaths = lines(
|
|
854
|
+
(await execute(pi, context, "Read Git operation state", "git", [
|
|
855
|
+
"rev-parse",
|
|
856
|
+
...operationStates.flatMap((state) => ["--git-path", state]),
|
|
857
|
+
])).stdout,
|
|
858
|
+
"Read Git operation state",
|
|
859
|
+
"state path",
|
|
860
|
+
);
|
|
861
|
+
if (statePaths.length !== operationStates.length) fail("Read Git operation state", "invalid state paths");
|
|
862
|
+
let operationInProgress = false;
|
|
863
|
+
for (const [index, path] of statePaths.entries()) {
|
|
864
|
+
try {
|
|
865
|
+
await lstat(resolve(context.cwd, path));
|
|
866
|
+
operationInProgress = true;
|
|
867
|
+
} catch (error) {
|
|
868
|
+
if (isRecord(error) && error.code === "ENOENT") continue;
|
|
869
|
+
const code = isRecord(error) && typeof error.code === "string" ? error.code : "filesystem error";
|
|
870
|
+
fail("Read Git operation state", `cannot inspect ${operationStates[index]}: ${code}`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
const worktree = status.stdout === "" && !operationInProgress ? "clean" : "dirty";
|
|
874
|
+
|
|
875
|
+
await execute(pi, context, "Fetch pull request head", "git", [
|
|
876
|
+
"fetch",
|
|
877
|
+
"--no-write-fetch-head",
|
|
878
|
+
"--no-tags",
|
|
879
|
+
"--no-recurse-submodules",
|
|
880
|
+
pushTarget.fetchSource,
|
|
881
|
+
pullRequestHead,
|
|
882
|
+
]);
|
|
883
|
+
await execute(pi, context, "Verify pull request head", "git", ["cat-file", "-e", `${pullRequestHead}^{commit}`]);
|
|
884
|
+
if (pushTarget.headOid === pullRequestHead) return { worktree, head: "equal" };
|
|
885
|
+
|
|
886
|
+
const localIsAncestor = await invoke(pi, context, "Compare pull request head", "git", [
|
|
887
|
+
"merge-base",
|
|
888
|
+
"--is-ancestor",
|
|
889
|
+
pushTarget.headOid,
|
|
890
|
+
pullRequestHead,
|
|
891
|
+
]);
|
|
892
|
+
if (localIsAncestor.killed) commandFailure("Compare pull request head", localIsAncestor);
|
|
893
|
+
if (localIsAncestor.code === 0) return { worktree, head: "behind" };
|
|
894
|
+
if (localIsAncestor.code !== 1) commandFailure("Compare pull request head", localIsAncestor);
|
|
895
|
+
|
|
896
|
+
const pullRequestIsAncestor = await invoke(pi, context, "Compare pull request head", "git", [
|
|
897
|
+
"merge-base",
|
|
898
|
+
"--is-ancestor",
|
|
899
|
+
pullRequestHead,
|
|
900
|
+
pushTarget.headOid,
|
|
901
|
+
]);
|
|
902
|
+
if (pullRequestIsAncestor.killed) commandFailure("Compare pull request head", pullRequestIsAncestor);
|
|
903
|
+
if (pullRequestIsAncestor.code === 0) return { worktree, head: "ahead" };
|
|
904
|
+
if (pullRequestIsAncestor.code === 1) return { worktree, head: "diverged" };
|
|
905
|
+
return commandFailure("Compare pull request head", pullRequestIsAncestor);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function readMergeMethods(
|
|
909
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
910
|
+
context: PullRequestLoadContext,
|
|
911
|
+
candidate: ListedPullRequest,
|
|
912
|
+
rulesetMethods: MergeMethod[] | null,
|
|
913
|
+
): Promise<PullRequestMerge> {
|
|
914
|
+
const result = await execute(pi, context, "Read merge methods", "gh", [
|
|
915
|
+
"repo",
|
|
916
|
+
"view",
|
|
917
|
+
`${candidate.url.hostname}/${candidate.base.repository}`,
|
|
918
|
+
"--json",
|
|
919
|
+
"mergeCommitAllowed,rebaseMergeAllowed,squashMergeAllowed,viewerDefaultMergeMethod",
|
|
920
|
+
]);
|
|
921
|
+
return parseMergeMethodSettings(result.stdout, rulesetMethods);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export async function loadCurrentPullRequest(
|
|
925
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
926
|
+
context: PullRequestLoadContext,
|
|
927
|
+
inspectedLocal?: LocalMergeSafety,
|
|
928
|
+
): Promise<CurrentPullRequest | null> {
|
|
929
|
+
const pushTarget = await readPushTarget(pi, context);
|
|
930
|
+
if (pushTarget === null) return null;
|
|
931
|
+
const [headOwner] = pushTarget.repository.nameWithOwner.split("/");
|
|
932
|
+
const search = await execute(pi, context, "Find pull requests", "gh", [
|
|
933
|
+
"api",
|
|
934
|
+
"search/issues",
|
|
935
|
+
"--hostname",
|
|
936
|
+
pushTarget.repository.host,
|
|
937
|
+
"--paginate",
|
|
938
|
+
"--slurp",
|
|
939
|
+
"-X",
|
|
940
|
+
"GET",
|
|
941
|
+
"-f",
|
|
942
|
+
`q=is:pr head:${headOwner}:${pushTarget.ref}`,
|
|
943
|
+
"-f",
|
|
944
|
+
`per_page=${PR_LIST_LIMIT}`,
|
|
945
|
+
]);
|
|
946
|
+
const candidates: ListedPullRequest[] = [];
|
|
947
|
+
for (const url of parseCandidateUrls(search.stdout, pushTarget.repository.host)) {
|
|
948
|
+
const loaded = await execute(pi, context, "Find pull requests", "gh", [
|
|
949
|
+
"pr",
|
|
950
|
+
"view",
|
|
951
|
+
url.href,
|
|
952
|
+
"--json",
|
|
953
|
+
PR_FIELDS,
|
|
954
|
+
]);
|
|
955
|
+
const candidate = parseLoadedPullRequest(loaded.stdout, url);
|
|
956
|
+
if (candidate !== null) candidates.push(candidate);
|
|
957
|
+
}
|
|
958
|
+
const candidate = selectPullRequest(candidates, pushTarget);
|
|
959
|
+
if (candidate === null) return null;
|
|
960
|
+
await execute(pi, context, "Validate pull request base ref", "git", [
|
|
961
|
+
"check-ref-format",
|
|
962
|
+
`refs/heads/${candidate.base.ref}`,
|
|
963
|
+
]);
|
|
964
|
+
|
|
965
|
+
const unresolvedThreads = candidate.lifecycle === "open"
|
|
966
|
+
? await readUnresolvedReviewThreads(pi, context, candidate)
|
|
967
|
+
: 0;
|
|
968
|
+
const rulesetPolicy = candidate.lifecycle === "open"
|
|
969
|
+
? await readRulesetBaseBranchPolicy(pi, context, candidate)
|
|
970
|
+
: null;
|
|
971
|
+
const legacyStrict = candidate.lifecycle === "open" && candidate.mergeStateStatus === "BEHIND"
|
|
972
|
+
? await readLegacyBaseBranchPolicy(pi, context, candidate)
|
|
973
|
+
: false;
|
|
974
|
+
const requiresStrictStatusChecks = legacyStrict || (rulesetPolicy?.requiresStrictStatusChecks ?? false);
|
|
975
|
+
const pullRequestConditions = conditions(candidate, unresolvedThreads, requiresStrictStatusChecks);
|
|
976
|
+
const local = inspectedLocal ?? await readLocalMergeSafety(pi, context, pushTarget, candidate.head.oid);
|
|
977
|
+
const merge = candidate.lifecycle === "open"
|
|
978
|
+
? await readMergeMethods(pi, context, candidate, rulesetPolicy?.allowedMergeMethods ?? null)
|
|
979
|
+
: null;
|
|
980
|
+
return {
|
|
981
|
+
id: candidate.id,
|
|
982
|
+
number: candidate.number,
|
|
983
|
+
url: candidate.url,
|
|
984
|
+
host: candidate.url.hostname.toLowerCase(),
|
|
985
|
+
approved: candidate.reviewDecision === "APPROVED",
|
|
986
|
+
lifecycle: candidate.lifecycle,
|
|
987
|
+
conditions: pullRequestConditions,
|
|
988
|
+
local,
|
|
989
|
+
base: candidate.base,
|
|
990
|
+
head: candidate.head,
|
|
991
|
+
headFetchSource: pushTarget.fetchSource,
|
|
992
|
+
merge,
|
|
993
|
+
};
|
|
994
|
+
}
|