@henryqw/pi-pr 3.0.0 → 3.1.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/README.md +26 -13
- package/docs/pr-routing.svg +1 -1
- package/extensions/pr-command.ts +65 -16
- package/extensions/pr-github.ts +860 -134
- package/extensions/pr-routing.ts +39 -4
- package/extensions/pr-ui.ts +82 -11
- package/extensions/pr.ts +88 -23
- package/package.json +2 -2
- package/skills/pi-pr-create/SKILL.md +50 -37
- package/skills/pi-pr-fix-ci/SKILL.md +4 -4
package/extensions/pr-github.ts
CHANGED
|
@@ -2,21 +2,27 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { lstatSync } from "node:fs";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
7
|
import { inspectLocalMergeSafety } from "./pr-merge.ts";
|
|
6
8
|
import type {
|
|
7
9
|
CiStatus,
|
|
8
10
|
LocalMergeSafety,
|
|
9
11
|
PullRequest,
|
|
10
12
|
PullRequestConditions,
|
|
13
|
+
PullRequestDiscovery,
|
|
11
14
|
PullRequestLifecycle,
|
|
15
|
+
PullRequestTarget,
|
|
12
16
|
ReviewReadiness,
|
|
13
17
|
PolicyReadiness,
|
|
14
18
|
} from "./pr-routing.ts";
|
|
15
19
|
|
|
16
20
|
const EXEC_TIMEOUT_MS = 10_000;
|
|
17
|
-
const
|
|
21
|
+
const PR_SEARCH_PAGE_SIZE = 100;
|
|
18
22
|
const PR_SEARCH_CAP = 1_000;
|
|
23
|
+
const PR_SEARCH_MAX_PAGES = PR_SEARCH_CAP / PR_SEARCH_PAGE_SIZE;
|
|
19
24
|
const PR_FIELDS = "id,number,url,state,isDraft,baseRefName,baseRefOid,headRefName,headRefOid,headRepository,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup";
|
|
25
|
+
const PR_SEARCH_QUERY = "query($searchQuery:String!,$endCursor:String){search(query:$searchQuery,type:ISSUE,first:100,after:$endCursor){issueCount edges{cursor node{__typename ...on PullRequest{number url state baseRepository{nameWithOwner}headRepository{nameWithOwner}headRefName headRefOid}}}pageInfo{hasNextPage startCursor endCursor}}}";
|
|
20
26
|
const REVIEW_THREADS_QUERY = "query($id:ID!,$endCursor:String){node(id:$id){...on PullRequest{reviewThreads(first:100,after:$endCursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}";
|
|
21
27
|
const BASE_REF_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name target{oid}}}}";
|
|
22
28
|
const BASE_BRANCH_POLICY_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name branchProtectionRule{requiresStrictStatusChecks}}}}";
|
|
@@ -83,9 +89,12 @@ export type CurrentPullRequest = PullRequest & {
|
|
|
83
89
|
base: PullRequestRef;
|
|
84
90
|
head: PullRequestRef;
|
|
85
91
|
headFetchSource: string;
|
|
92
|
+
target: PullRequestTarget;
|
|
86
93
|
merge: PullRequestMerge | null;
|
|
87
94
|
};
|
|
88
95
|
|
|
96
|
+
export type CurrentPullRequestDiscovery = PullRequestDiscovery<CurrentPullRequest>;
|
|
97
|
+
|
|
89
98
|
export type PullRequestLoadContext = Pick<ExtensionContext, "cwd" | "signal">;
|
|
90
99
|
|
|
91
100
|
type CommandOutput = {
|
|
@@ -109,12 +118,56 @@ type PushUrl = {
|
|
|
109
118
|
};
|
|
110
119
|
|
|
111
120
|
type PushTarget = {
|
|
121
|
+
provenance: "configured" | "inferred";
|
|
122
|
+
branch: string;
|
|
123
|
+
remote: string;
|
|
112
124
|
fetchSource: string;
|
|
113
125
|
remoteHeadOid: string | null;
|
|
114
126
|
repository: PushRepository;
|
|
115
127
|
ref: string;
|
|
116
128
|
};
|
|
117
129
|
|
|
130
|
+
type TargetReadResult =
|
|
131
|
+
| { kind: "target"; target: PushTarget }
|
|
132
|
+
| { kind: "missing"; branch: string; remoteNames: string[] }
|
|
133
|
+
| { kind: "blocked"; issue: "detached" | "target" }
|
|
134
|
+
| { kind: "inactive" };
|
|
135
|
+
|
|
136
|
+
type LinkConfiguration = {
|
|
137
|
+
upstreamRemote: string[];
|
|
138
|
+
upstreamMerge: string[];
|
|
139
|
+
pushRemote: string[];
|
|
140
|
+
pushDefaultRemote: string[];
|
|
141
|
+
pushRefspec: string[];
|
|
142
|
+
pushDefault: string[];
|
|
143
|
+
mirror: string[];
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
type SearchPullRequest = {
|
|
147
|
+
number: number;
|
|
148
|
+
url: URL;
|
|
149
|
+
lifecycle: PullRequestLifecycle;
|
|
150
|
+
baseRepository: string;
|
|
151
|
+
headRepository: string | null;
|
|
152
|
+
headRef: string;
|
|
153
|
+
headOid: string;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
type SearchSelection =
|
|
157
|
+
| { kind: "candidate"; candidate: SearchPullRequest; pullRequest: ListedPullRequest | null }
|
|
158
|
+
| { kind: "none" }
|
|
159
|
+
| { kind: "ambiguous"; urls: URL[] }
|
|
160
|
+
| { kind: "oid-mismatch"; urls: URL[] }
|
|
161
|
+
| { kind: "target-invalid" };
|
|
162
|
+
|
|
163
|
+
type SearchPage = {
|
|
164
|
+
issueCount: number;
|
|
165
|
+
candidates: SearchPullRequest[];
|
|
166
|
+
cursors: string[];
|
|
167
|
+
hasNextPage: boolean;
|
|
168
|
+
endCursor: string | null;
|
|
169
|
+
};
|
|
170
|
+
|
|
118
171
|
type RulesetBranchPolicy = {
|
|
119
172
|
requiresStrictStatusChecks: boolean;
|
|
120
173
|
allowedMergeMethods: MergeMethod[] | null;
|
|
@@ -215,6 +268,21 @@ function lines(output: string, action: string, field: string): string[] {
|
|
|
215
268
|
return result;
|
|
216
269
|
}
|
|
217
270
|
|
|
271
|
+
function hasRepositoryMarker(cwd: string): boolean {
|
|
272
|
+
for (let directory = resolve(cwd);; directory = dirname(directory)) {
|
|
273
|
+
try {
|
|
274
|
+
lstatSync(join(directory, ".git"));
|
|
275
|
+
return true;
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (
|
|
278
|
+
!isRecord(error) || typeof error.code !== "string" ||
|
|
279
|
+
(error.code !== "ENOENT" && error.code !== "ENOTDIR")
|
|
280
|
+
) return true;
|
|
281
|
+
}
|
|
282
|
+
if (dirname(directory) === directory) return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
218
286
|
function parseCommandOutput(value: unknown, action: string): CommandOutput {
|
|
219
287
|
if (!isRecord(value)) fail(action, "invalid command result");
|
|
220
288
|
const { stdout, stderr, code, killed } = value;
|
|
@@ -271,14 +339,15 @@ function parsePushReference(value: string, remoteNames: string[]): { remote: str
|
|
|
271
339
|
return { remote, ref };
|
|
272
340
|
}
|
|
273
341
|
|
|
274
|
-
function
|
|
275
|
-
|
|
342
|
+
function parseRemoteUrl(value: string, kind: "push" | "fetch"): PushUrl {
|
|
343
|
+
const action = `Read ${kind} URL`;
|
|
344
|
+
if (/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(value)) return fail(action, `invalid ${kind} URL`);
|
|
276
345
|
const scp = /^(?:git@)?([a-z0-9.-]+):([a-z0-9_.-]+)\/([a-z0-9_.-]+)$/i.exec(value);
|
|
277
346
|
const rawUrl = scp
|
|
278
347
|
? null
|
|
279
348
|
: /^(https|ssh):\/\/(?:(git)@)?([a-z0-9.-]+)\/([a-z0-9_.-]+)\/([a-z0-9_.-]+)\/?$/i.exec(value);
|
|
280
349
|
if (!scp && (!rawUrl || (rawUrl[1]!.toLowerCase() === "https" && rawUrl[2]))) {
|
|
281
|
-
return fail(
|
|
350
|
+
return fail(action, `invalid ${kind} URL`);
|
|
282
351
|
}
|
|
283
352
|
const host = (scp?.[1] ?? rawUrl![3])!;
|
|
284
353
|
const owner = (scp?.[2] ?? rawUrl![4])!;
|
|
@@ -289,14 +358,14 @@ function parsePushUrl(value: string): PushUrl {
|
|
|
289
358
|
normalizedHost.length > 253 || normalizedHost.split(".").some((label) =>
|
|
290
359
|
!label || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label)
|
|
291
360
|
)
|
|
292
|
-
) fail(
|
|
361
|
+
) fail(action, `invalid ${kind} URL`);
|
|
293
362
|
const normalizedName = normalizeRepository(`${owner}/${name}`);
|
|
294
363
|
if (rawUrl) {
|
|
295
364
|
let url: URL;
|
|
296
365
|
try {
|
|
297
366
|
url = new URL(value);
|
|
298
367
|
} catch {
|
|
299
|
-
return fail(
|
|
368
|
+
return fail(action, `invalid ${kind} URL`);
|
|
300
369
|
}
|
|
301
370
|
const path = /^\/([a-z0-9_.-]+)\/([a-z0-9_.-]+)\/?$/i.exec(url.pathname);
|
|
302
371
|
if (
|
|
@@ -304,7 +373,7 @@ function parsePushUrl(value: string): PushUrl {
|
|
|
304
373
|
url.username !== (rawUrl[2] ?? "") || url.password || url.port || url.search || url.hash ||
|
|
305
374
|
url.hostname.toLowerCase() !== normalizedHost || !path ||
|
|
306
375
|
normalizeRepository(`${path[1]}/${path[2]!.replace(/\.git$/i, "")}`) !== normalizedName
|
|
307
|
-
) return fail(
|
|
376
|
+
) return fail(action, `invalid ${kind} URL`);
|
|
308
377
|
}
|
|
309
378
|
return {
|
|
310
379
|
fetchSource: value,
|
|
@@ -314,18 +383,19 @@ function parsePushUrl(value: string): PushUrl {
|
|
|
314
383
|
};
|
|
315
384
|
}
|
|
316
385
|
|
|
317
|
-
function
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
386
|
+
function parseRemoteRepository(output: string, remoteUrl: PushUrl, kind: "push" | "fetch"): PushRepository {
|
|
387
|
+
const action = `Read ${kind} repository`;
|
|
388
|
+
const value = parseJson(output, action);
|
|
389
|
+
if (!isRecord(value)) fail(action, "invalid GitHub CLI output");
|
|
390
|
+
const nameWithOwner = repositoryName(value.nameWithOwner, action, "nameWithOwner");
|
|
391
|
+
const url = parseHttpUrl(value.url, action, "url");
|
|
322
392
|
const path = url.pathname.split("/").filter(Boolean);
|
|
323
393
|
const normalizedName = normalizeRepository(nameWithOwner);
|
|
324
394
|
const host = url.hostname.toLowerCase();
|
|
325
395
|
if (
|
|
326
396
|
path.length !== 2 || normalizeRepository(path.join("/")) !== normalizedName ||
|
|
327
|
-
host !==
|
|
328
|
-
) fail(
|
|
397
|
+
host !== remoteUrl.host || normalizedName !== remoteUrl.normalizedName
|
|
398
|
+
) fail(action, `response does not match ${kind} URL`);
|
|
329
399
|
return { nameWithOwner, normalizedName, host };
|
|
330
400
|
}
|
|
331
401
|
|
|
@@ -456,46 +526,109 @@ function listedPullRequest(value: unknown): ListedPullRequest | null {
|
|
|
456
526
|
};
|
|
457
527
|
}
|
|
458
528
|
|
|
459
|
-
function
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
let totalCount: number | null = null;
|
|
463
|
-
let incomplete = false;
|
|
464
|
-
const pageItems: unknown[][] = [];
|
|
465
|
-
for (const page of pages) {
|
|
466
|
-
if (
|
|
467
|
-
!isRecord(page) || typeof page.total_count !== "number" ||
|
|
468
|
-
!Number.isSafeInteger(page.total_count) || page.total_count < 0 ||
|
|
469
|
-
typeof page.incomplete_results !== "boolean" || !Array.isArray(page.items)
|
|
470
|
-
) fail("Find pull requests", "invalid GitHub CLI output");
|
|
471
|
-
if (totalCount !== null && page.total_count !== totalCount) {
|
|
472
|
-
fail("Find pull requests", "inconsistent search result pages");
|
|
473
|
-
}
|
|
474
|
-
totalCount = page.total_count;
|
|
475
|
-
incomplete ||= page.incomplete_results;
|
|
476
|
-
pageItems.push(page.items);
|
|
477
|
-
}
|
|
478
|
-
if (totalCount === null) fail("Find pull requests", "invalid GitHub CLI output");
|
|
479
|
-
if (incomplete) fail("Find pull requests", "incomplete search results");
|
|
480
|
-
if (totalCount > PR_SEARCH_CAP) fail("Find pull requests", "GitHub search result cap reached");
|
|
481
|
-
const expectedPages = Math.max(1, Math.ceil(totalCount / PR_LIST_LIMIT));
|
|
482
|
-
if (pageItems.length !== expectedPages || pageItems.some((items, index) =>
|
|
483
|
-
items.length !== Math.min(PR_LIST_LIMIT, Math.max(0, totalCount - index * PR_LIST_LIMIT))
|
|
484
|
-
)) fail("Find pull requests", "incomplete search results");
|
|
485
|
-
const urls = pageItems.flat().map((candidate) => {
|
|
486
|
-
if (!isRecord(candidate)) fail("Find pull requests", "invalid GitHub CLI output");
|
|
487
|
-
const url = parseHttpUrl(candidate.html_url, "Find pull requests", "url");
|
|
488
|
-
const path = url.pathname.split("/").filter(Boolean);
|
|
489
|
-
if (
|
|
490
|
-
url.hostname.toLowerCase() !== host || path.length !== 4 || path[2] !== "pull" ||
|
|
491
|
-
!/^[1-9][0-9]*$/.test(path[3])
|
|
492
|
-
) fail("Find pull requests", "invalid url");
|
|
493
|
-
return url;
|
|
494
|
-
});
|
|
495
|
-
if (new Set(urls.map((url) => url.href.toLowerCase())).size !== urls.length) {
|
|
496
|
-
fail("Find pull requests", "duplicate candidate url");
|
|
529
|
+
function searchPullRequest(value: unknown, host: string): SearchPullRequest {
|
|
530
|
+
if (!isRecord(value) || value.__typename !== "PullRequest") {
|
|
531
|
+
fail("Find pull requests", "invalid GitHub CLI output");
|
|
497
532
|
}
|
|
498
|
-
|
|
533
|
+
const number = value.number;
|
|
534
|
+
if (typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0) {
|
|
535
|
+
fail("Find pull requests", "invalid number");
|
|
536
|
+
}
|
|
537
|
+
const parsedUrl = parsePullRequestUrl(value.url, number);
|
|
538
|
+
if (parsedUrl.url.hostname.toLowerCase() !== host) fail("Find pull requests", "invalid url");
|
|
539
|
+
if (!isRecord(value.baseRepository)) fail("Find pull requests", "invalid baseRepository");
|
|
540
|
+
const baseRepository = repositoryName(
|
|
541
|
+
value.baseRepository.nameWithOwner,
|
|
542
|
+
"Find pull requests",
|
|
543
|
+
"baseRepository.nameWithOwner",
|
|
544
|
+
);
|
|
545
|
+
if (normalizeRepository(baseRepository) !== normalizeRepository(parsedUrl.repository)) {
|
|
546
|
+
fail("Find pull requests", "base repository does not match url");
|
|
547
|
+
}
|
|
548
|
+
let headRepository: string | null;
|
|
549
|
+
if (value.headRepository === null) {
|
|
550
|
+
headRepository = null;
|
|
551
|
+
} else {
|
|
552
|
+
if (!isRecord(value.headRepository)) fail("Find pull requests", "invalid headRepository");
|
|
553
|
+
headRepository = repositoryName(
|
|
554
|
+
value.headRepository.nameWithOwner,
|
|
555
|
+
"Find pull requests",
|
|
556
|
+
"headRepository.nameWithOwner",
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
number,
|
|
561
|
+
url: parsedUrl.url,
|
|
562
|
+
lifecycle: lifecycle(value.state),
|
|
563
|
+
baseRepository,
|
|
564
|
+
headRepository,
|
|
565
|
+
headRef: text(value.headRefName, "Find pull requests", "headRefName"),
|
|
566
|
+
headOid: oid(value.headRefOid, "Find pull requests", "headRefOid"),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function parseSearchPage(output: string, host: string): SearchPage {
|
|
571
|
+
const page = parseJson(output, "Find pull requests");
|
|
572
|
+
if (!isRecord(page)) fail("Find pull requests", "invalid GitHub CLI output");
|
|
573
|
+
if (page.errors !== undefined) {
|
|
574
|
+
if (!Array.isArray(page.errors)) fail("Find pull requests", "invalid GitHub CLI output");
|
|
575
|
+
if (page.errors.length) fail("Find pull requests", "GitHub GraphQL returned errors");
|
|
576
|
+
}
|
|
577
|
+
const search = isRecord(page.data) ? page.data.search : undefined;
|
|
578
|
+
if (
|
|
579
|
+
!isRecord(search) || typeof search.issueCount !== "number" ||
|
|
580
|
+
!Number.isSafeInteger(search.issueCount) || search.issueCount < 0 ||
|
|
581
|
+
!Array.isArray(search.edges) || search.edges.length > PR_SEARCH_PAGE_SIZE ||
|
|
582
|
+
!isRecord(search.pageInfo)
|
|
583
|
+
) fail("Find pull requests", "invalid GitHub CLI output");
|
|
584
|
+
const candidates: SearchPullRequest[] = [];
|
|
585
|
+
const cursors: string[] = [];
|
|
586
|
+
for (const edge of search.edges) {
|
|
587
|
+
if (!isRecord(edge)) fail("Find pull requests", "invalid GitHub CLI output");
|
|
588
|
+
cursors.push(text(edge.cursor, "Find pull requests", "cursor"));
|
|
589
|
+
candidates.push(searchPullRequest(edge.node, host));
|
|
590
|
+
}
|
|
591
|
+
if (new Set(cursors).size !== cursors.length) fail("Find pull requests", "duplicate candidate cursor");
|
|
592
|
+
const { hasNextPage, startCursor, endCursor } = search.pageInfo;
|
|
593
|
+
if (typeof hasNextPage !== "boolean") fail("Find pull requests", "invalid search pageInfo");
|
|
594
|
+
if (cursors.length === 0) {
|
|
595
|
+
if (startCursor !== null || endCursor !== null) fail("Find pull requests", "invalid search pageInfo");
|
|
596
|
+
} else if (startCursor !== cursors[0] || endCursor !== cursors.at(-1)) {
|
|
597
|
+
fail("Find pull requests", "invalid search pageInfo");
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
issueCount: search.issueCount,
|
|
601
|
+
candidates,
|
|
602
|
+
cursors,
|
|
603
|
+
hasNextPage,
|
|
604
|
+
endCursor: endCursor === null ? null : text(endCursor, "Find pull requests", "endCursor"),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function matchingSearchPullRequests(candidates: SearchPullRequest[], pushTarget: PushTarget): SearchPullRequest[] {
|
|
609
|
+
return candidates.filter((candidate) =>
|
|
610
|
+
candidate.url.hostname.toLowerCase() === pushTarget.repository.host &&
|
|
611
|
+
candidate.headRepository !== null &&
|
|
612
|
+
normalizeRepository(candidate.headRepository) === pushTarget.repository.normalizedName &&
|
|
613
|
+
candidate.headRef === pushTarget.ref
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function selectSearchPullRequest(candidates: SearchPullRequest[], pushTarget: PushTarget): SearchSelection {
|
|
618
|
+
const matching = matchingSearchPullRequests(candidates, pushTarget);
|
|
619
|
+
const open = matching.filter((candidate) => candidate.lifecycle === "open");
|
|
620
|
+
if (open.length > 1) return { kind: "ambiguous", urls: open.map(({ url }) => url) };
|
|
621
|
+
if (open.length === 1) {
|
|
622
|
+
if (pushTarget.remoteHeadOid === null) return { kind: "target-invalid" };
|
|
623
|
+
if (open[0].headOid !== pushTarget.remoteHeadOid) return { kind: "oid-mismatch", urls: [open[0].url] };
|
|
624
|
+
return { kind: "candidate", candidate: open[0], pullRequest: null };
|
|
625
|
+
}
|
|
626
|
+
if (pushTarget.provenance === "inferred" || pushTarget.remoteHeadOid === null) return { kind: "none" };
|
|
627
|
+
const historical = matching.filter((candidate) => candidate.headOid === pushTarget.remoteHeadOid);
|
|
628
|
+
if (historical.length > 1) return { kind: "ambiguous", urls: historical.map(({ url }) => url) };
|
|
629
|
+
return historical.length === 1
|
|
630
|
+
? { kind: "candidate", candidate: historical[0], pullRequest: null }
|
|
631
|
+
: { kind: "none" };
|
|
499
632
|
}
|
|
500
633
|
|
|
501
634
|
function parseLoadedPullRequest(output: string, expectedUrl: URL): ListedPullRequest | null {
|
|
@@ -748,66 +881,295 @@ export async function hasLocalCommit(
|
|
|
748
881
|
return commits[0] !== commits.at(-1);
|
|
749
882
|
}
|
|
750
883
|
|
|
751
|
-
async function
|
|
884
|
+
async function readRemoteAuthority(
|
|
752
885
|
pi: Pick<ExtensionAPI, "exec">,
|
|
753
886
|
context: PullRequestLoadContext,
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
"
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
"push
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
887
|
+
remote: string,
|
|
888
|
+
strict = false,
|
|
889
|
+
): Promise<{ fetchSource: string; repository: PushRepository } | null> {
|
|
890
|
+
try {
|
|
891
|
+
const readUrl = async (kind: "push" | "fetch"): Promise<PushUrl> => {
|
|
892
|
+
const action = `Read ${kind} URL`;
|
|
893
|
+
const args = kind === "push"
|
|
894
|
+
? ["remote", "get-url", "--push", "--all", remote]
|
|
895
|
+
: ["remote", "get-url", "--all", remote];
|
|
896
|
+
const result = await invoke(pi, context, action, "git", args);
|
|
897
|
+
if (result.killed || result.code !== 0) commandFailure(action, result);
|
|
898
|
+
const urls = lines(result.stdout, action, `${kind} URL`);
|
|
899
|
+
if (urls.length !== 1) fail(action, `multiple ${kind} URLs are configured`);
|
|
900
|
+
return parseRemoteUrl(urls[0], kind);
|
|
901
|
+
};
|
|
902
|
+
const readRepository = async (remoteUrl: PushUrl, kind: "push" | "fetch"): Promise<PushRepository> => {
|
|
903
|
+
const action = `Read ${kind} repository`;
|
|
904
|
+
const result = await execute(pi, context, action, "gh", [
|
|
905
|
+
"repo", "view", remoteUrl.locator, "--json", "nameWithOwner,url",
|
|
906
|
+
]);
|
|
907
|
+
return parseRemoteRepository(result.stdout, remoteUrl, kind);
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
const pushUrl = await readUrl("push");
|
|
911
|
+
const pushRepository = await readRepository(pushUrl, "push");
|
|
912
|
+
const fetchUrl = await readUrl("fetch");
|
|
913
|
+
const fetchRepository = await readRepository(fetchUrl, "fetch");
|
|
914
|
+
if (
|
|
915
|
+
fetchRepository.host !== pushRepository.host ||
|
|
916
|
+
fetchRepository.normalizedName !== pushRepository.normalizedName
|
|
917
|
+
) fail("Read fetch repository", "fetch and push repositories do not match");
|
|
918
|
+
return { fetchSource: pushUrl.fetchSource, repository: pushRepository };
|
|
919
|
+
} catch (error) {
|
|
920
|
+
if (!strict && error instanceof PullRequestLoadError) return null;
|
|
921
|
+
throw error;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function readRemoteHeadOid(
|
|
926
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
927
|
+
context: PullRequestLoadContext,
|
|
928
|
+
fetchSource: string,
|
|
929
|
+
ref: string,
|
|
930
|
+
): Promise<string | null> {
|
|
794
931
|
const remoteHead = await invoke(pi, context, "Read remote push ref", "git", [
|
|
795
932
|
"ls-remote",
|
|
796
933
|
"--exit-code",
|
|
797
934
|
"--refs",
|
|
798
|
-
|
|
799
|
-
`refs/heads/${
|
|
935
|
+
fetchSource,
|
|
936
|
+
`refs/heads/${ref}`,
|
|
800
937
|
]);
|
|
801
|
-
let remoteHeadOid: string | null;
|
|
802
938
|
if (remoteHead.killed) commandFailure("Read remote push ref", remoteHead);
|
|
803
939
|
if (remoteHead.code === 2) {
|
|
804
940
|
if (remoteHead.stdout !== "") fail("Read remote push ref", "invalid absent-ref response");
|
|
805
|
-
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
if (remoteHead.code !== 0) commandFailure("Read remote push ref", remoteHead);
|
|
944
|
+
return parseRemotePushRef(remoteHead.stdout, ref);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async function readConfigValues(
|
|
948
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
949
|
+
context: PullRequestLoadContext,
|
|
950
|
+
key: string,
|
|
951
|
+
): Promise<string[] | null> {
|
|
952
|
+
const result = await invoke(pi, context, "Read Git configuration", "git", ["config", "--get-all", key]);
|
|
953
|
+
if (result.killed) commandFailure("Read Git configuration", result);
|
|
954
|
+
if (result.code === 1 && result.stdout === "") return [];
|
|
955
|
+
if (result.code !== 0) commandFailure("Read Git configuration", result);
|
|
956
|
+
try {
|
|
957
|
+
return lines(result.stdout, "Read Git configuration", "value");
|
|
958
|
+
} catch (error) {
|
|
959
|
+
if (error instanceof PullRequestLoadError) return null;
|
|
960
|
+
throw error;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
async function readBooleanConfigValues(
|
|
965
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
966
|
+
context: PullRequestLoadContext,
|
|
967
|
+
key: string,
|
|
968
|
+
): Promise<string[] | null> {
|
|
969
|
+
const result = await invoke(pi, context, "Read Git configuration", "git", [
|
|
970
|
+
"config", "--type=bool", "--get-all", key,
|
|
971
|
+
]);
|
|
972
|
+
if (result.killed) commandFailure("Read Git configuration", result);
|
|
973
|
+
if (result.code === 1 && result.stdout === "") return [];
|
|
974
|
+
if (result.code !== 0) return null;
|
|
975
|
+
try {
|
|
976
|
+
const values = lines(result.stdout, "Read Git configuration", "boolean value");
|
|
977
|
+
return values.every((value) => value === "true" || value === "false") ? values : null;
|
|
978
|
+
} catch (error) {
|
|
979
|
+
if (error instanceof PullRequestLoadError) return null;
|
|
980
|
+
throw error;
|
|
809
981
|
}
|
|
810
|
-
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function readLinkConfiguration(
|
|
985
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
986
|
+
context: PullRequestLoadContext,
|
|
987
|
+
target: PushTarget,
|
|
988
|
+
): Promise<LinkConfiguration | null> {
|
|
989
|
+
const [upstreamRemote, upstreamMerge, pushRemote, pushDefaultRemote, pushRefspec, pushDefault, mirror] =
|
|
990
|
+
await Promise.all([
|
|
991
|
+
`branch.${target.branch}.remote`,
|
|
992
|
+
`branch.${target.branch}.merge`,
|
|
993
|
+
`branch.${target.branch}.pushRemote`,
|
|
994
|
+
"remote.pushDefault",
|
|
995
|
+
`remote.${target.remote}.push`,
|
|
996
|
+
"push.default",
|
|
997
|
+
].map((key) => readConfigValues(pi, context, key)).concat([
|
|
998
|
+
readBooleanConfigValues(pi, context, `remote.${target.remote}.mirror`),
|
|
999
|
+
]));
|
|
1000
|
+
if ([upstreamRemote, upstreamMerge, pushRemote, pushDefaultRemote, pushRefspec, pushDefault, mirror]
|
|
1001
|
+
.some((value) => value === null)) return null;
|
|
1002
|
+
return {
|
|
1003
|
+
upstreamRemote: upstreamRemote!,
|
|
1004
|
+
upstreamMerge: upstreamMerge!,
|
|
1005
|
+
pushRemote: pushRemote!,
|
|
1006
|
+
pushDefaultRemote: pushDefaultRemote!,
|
|
1007
|
+
pushRefspec: pushRefspec!,
|
|
1008
|
+
pushDefault: pushDefault!,
|
|
1009
|
+
mirror: mirror!,
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function canLinkTarget(configuration: LinkConfiguration | null, target: PushTarget): boolean {
|
|
1014
|
+
if (!configuration) return false;
|
|
1015
|
+
const { upstreamRemote, upstreamMerge, pushRemote, pushDefaultRemote, pushRefspec, pushDefault, mirror } = configuration;
|
|
1016
|
+
if (upstreamRemote.length || upstreamMerge.length || pushRefspec.length) return false;
|
|
1017
|
+
if (pushRemote.length > 1 || (pushRemote[0] !== undefined && pushRemote[0] !== target.remote)) return false;
|
|
1018
|
+
if (pushDefaultRemote.length > 1 || (pushDefaultRemote[0] !== undefined && pushDefaultRemote[0] !== target.remote)) return false;
|
|
1019
|
+
if (mirror.length > 1 || mirror[0] === "true") return false;
|
|
1020
|
+
return pushDefault.length === 0 || (pushDefault.length === 1 && pushDefault[0] === "simple");
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function publicTarget(target: PushTarget): PullRequestTarget {
|
|
1024
|
+
return {
|
|
1025
|
+
provenance: target.provenance,
|
|
1026
|
+
branch: target.branch,
|
|
1027
|
+
remote: target.remote,
|
|
1028
|
+
ref: target.ref,
|
|
1029
|
+
repository: target.repository.nameWithOwner,
|
|
1030
|
+
host: target.repository.host,
|
|
1031
|
+
fetchSource: target.fetchSource,
|
|
1032
|
+
remoteOid: target.remoteHeadOid,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async function readPushTarget(
|
|
1037
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1038
|
+
context: PullRequestLoadContext,
|
|
1039
|
+
): Promise<TargetReadResult> {
|
|
1040
|
+
const worktree = await invoke(pi, context, "Check Git worktree", "git", ["rev-parse", "--is-inside-work-tree"]);
|
|
1041
|
+
if (worktree.killed) commandFailure("Check Git worktree", worktree);
|
|
1042
|
+
const worktreeOutput = worktree.stdout.replace(/\r\n/g, "\n");
|
|
1043
|
+
if (worktree.code === 128 && worktreeOutput === "") {
|
|
1044
|
+
const probe = await invoke(pi, context, "Classify Git worktree", "env", [
|
|
1045
|
+
"LC_ALL=C",
|
|
1046
|
+
"LANG=C",
|
|
1047
|
+
"GIT_DISCOVERY_ACROSS_FILESYSTEM=1",
|
|
1048
|
+
"git",
|
|
1049
|
+
"-c",
|
|
1050
|
+
"safe.directory=*",
|
|
1051
|
+
"rev-parse",
|
|
1052
|
+
"--is-inside-work-tree",
|
|
1053
|
+
]);
|
|
1054
|
+
if (probe.killed) commandFailure("Classify Git worktree", probe);
|
|
1055
|
+
if (
|
|
1056
|
+
probe.code === 128 && probe.stdout === "" &&
|
|
1057
|
+
probe.stderr.replace(/\r\n/g, "\n") ===
|
|
1058
|
+
"fatal: not a git repository (or any of the parent directories): .git\n" &&
|
|
1059
|
+
!hasRepositoryMarker(context.cwd) && !process.env.GIT_DIR && !process.env.GIT_WORK_TREE
|
|
1060
|
+
) return { kind: "inactive" };
|
|
1061
|
+
commandFailure("Check Git worktree", worktree);
|
|
1062
|
+
}
|
|
1063
|
+
if (worktree.code === 0 && worktreeOutput === "false\n") return { kind: "inactive" };
|
|
1064
|
+
if (worktree.code !== 0) commandFailure("Check Git worktree", worktree);
|
|
1065
|
+
if (worktreeOutput !== "true\n") fail("Check Git worktree", "invalid response");
|
|
1066
|
+
|
|
1067
|
+
const branchResult = await execute(pi, context, "Read current branch", "git", ["branch", "--show-current"]);
|
|
1068
|
+
if (branchResult.stdout === "") return { kind: "blocked", issue: "detached" };
|
|
1069
|
+
let branch: string;
|
|
1070
|
+
try {
|
|
1071
|
+
branch = singleLine(branchResult.stdout, "Read current branch", "branch");
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
if (error instanceof PullRequestLoadError) return { kind: "blocked", issue: "target" };
|
|
1074
|
+
throw error;
|
|
1075
|
+
}
|
|
1076
|
+
const pushResult = await execute(pi, context, "Read push target", "git", [
|
|
1077
|
+
"for-each-ref",
|
|
1078
|
+
"--format=%(push:short)",
|
|
1079
|
+
`refs/heads/${branch}`,
|
|
1080
|
+
]);
|
|
1081
|
+
const pushReference = optionalPushReference(pushResult.stdout);
|
|
1082
|
+
const remotesResult = await execute(pi, context, "Read push remotes", "git", ["remote"]);
|
|
1083
|
+
const normalizedRemotes = remotesResult.stdout.replace(/\r\n/g, "\n");
|
|
1084
|
+
const remoteNames = normalizedRemotes === "" ? [] : lines(normalizedRemotes, "Read push remotes", "remote");
|
|
1085
|
+
if (pushReference === null) {
|
|
1086
|
+
const branchCheck = await invoke(pi, context, "Read current branch", "git", ["check-ref-format", "--branch", branch]);
|
|
1087
|
+
if (branchCheck.killed) commandFailure("Read current branch", branchCheck);
|
|
1088
|
+
if (branchCheck.code !== 0 || branchCheck.stdout.replace(/\r\n/g, "\n") !== `${branch}\n`) {
|
|
1089
|
+
return { kind: "blocked", issue: "target" };
|
|
1090
|
+
}
|
|
1091
|
+
return { kind: "missing", branch, remoteNames };
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const push = parsePushReference(pushReference, remoteNames);
|
|
1095
|
+
const checkedRef = singleLine(
|
|
1096
|
+
(await execute(pi, context, "Read push target", "git", ["check-ref-format", "--branch", push.ref])).stdout,
|
|
1097
|
+
"Read push target",
|
|
1098
|
+
"push ref",
|
|
1099
|
+
);
|
|
1100
|
+
if (checkedRef !== push.ref) fail("Read push target", "invalid push ref");
|
|
1101
|
+
const authority = await readRemoteAuthority(pi, context, push.remote, true);
|
|
1102
|
+
if (!authority) fail("Read push target", "invalid remote authority");
|
|
1103
|
+
const remoteHeadOid = await readRemoteHeadOid(pi, context, authority.fetchSource, push.ref);
|
|
1104
|
+
return {
|
|
1105
|
+
kind: "target",
|
|
1106
|
+
target: {
|
|
1107
|
+
provenance: "configured",
|
|
1108
|
+
branch,
|
|
1109
|
+
remote: push.remote,
|
|
1110
|
+
fetchSource: authority.fetchSource,
|
|
1111
|
+
remoteHeadOid,
|
|
1112
|
+
repository: authority.repository,
|
|
1113
|
+
ref: push.ref,
|
|
1114
|
+
},
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
async function inferPushTarget(
|
|
1119
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1120
|
+
context: PullRequestLoadContext,
|
|
1121
|
+
branch: string,
|
|
1122
|
+
remoteNames: string[],
|
|
1123
|
+
): Promise<
|
|
1124
|
+
| { kind: "target"; target: PushTarget }
|
|
1125
|
+
| { kind: "none"; target: PushTarget }
|
|
1126
|
+
| { kind: "blocked"; issue: "target" | "origin" | "ambiguous"; remotes?: string[] }
|
|
1127
|
+
> {
|
|
1128
|
+
const candidates: PushTarget[] = [];
|
|
1129
|
+
const authorities = new Map<string, { fetchSource: string; repository: PushRepository }>();
|
|
1130
|
+
for (const remote of remoteNames) {
|
|
1131
|
+
let validatedRemote: string;
|
|
1132
|
+
try {
|
|
1133
|
+
validatedRemote = text(remote, "Read push remotes", "remote");
|
|
1134
|
+
} catch {
|
|
1135
|
+
return { kind: "blocked", issue: "target" };
|
|
1136
|
+
}
|
|
1137
|
+
const authority = await readRemoteAuthority(pi, context, validatedRemote);
|
|
1138
|
+
if (!authority) {
|
|
1139
|
+
return { kind: "blocked", issue: validatedRemote === "origin" ? "origin" : "target" };
|
|
1140
|
+
}
|
|
1141
|
+
authorities.set(validatedRemote, authority);
|
|
1142
|
+
const remoteHeadOid = await readRemoteHeadOid(pi, context, authority.fetchSource, branch);
|
|
1143
|
+
if (remoteHeadOid !== null) {
|
|
1144
|
+
candidates.push({
|
|
1145
|
+
provenance: "inferred",
|
|
1146
|
+
branch,
|
|
1147
|
+
remote: validatedRemote,
|
|
1148
|
+
ref: branch,
|
|
1149
|
+
fetchSource: authority.fetchSource,
|
|
1150
|
+
remoteHeadOid,
|
|
1151
|
+
repository: authority.repository,
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if (candidates.length > 1) {
|
|
1156
|
+
return { kind: "blocked", issue: "ambiguous", remotes: candidates.map(({ remote }) => remote).sort() };
|
|
1157
|
+
}
|
|
1158
|
+
if (candidates.length === 1) return { kind: "target", target: candidates[0] };
|
|
1159
|
+
const origin = authorities.get("origin");
|
|
1160
|
+
if (!origin) return { kind: "blocked", issue: "origin" };
|
|
1161
|
+
return {
|
|
1162
|
+
kind: "none",
|
|
1163
|
+
target: {
|
|
1164
|
+
provenance: "inferred",
|
|
1165
|
+
branch,
|
|
1166
|
+
remote: "origin",
|
|
1167
|
+
ref: branch,
|
|
1168
|
+
fetchSource: origin.fetchSource,
|
|
1169
|
+
remoteHeadOid: null,
|
|
1170
|
+
repository: origin.repository,
|
|
1171
|
+
},
|
|
1172
|
+
};
|
|
811
1173
|
}
|
|
812
1174
|
|
|
813
1175
|
async function readUnresolvedReviewThreads(
|
|
@@ -913,41 +1275,13 @@ async function readMergeMethods(
|
|
|
913
1275
|
return parseMergeMethodSettings(result.stdout, rulesetMethods);
|
|
914
1276
|
}
|
|
915
1277
|
|
|
916
|
-
|
|
1278
|
+
async function loadPullRequestDetails(
|
|
917
1279
|
pi: Pick<ExtensionAPI, "exec">,
|
|
918
1280
|
context: PullRequestLoadContext,
|
|
1281
|
+
candidate: ListedPullRequest,
|
|
1282
|
+
pushTarget: PushTarget,
|
|
919
1283
|
inspectedLocal?: LocalMergeSafety,
|
|
920
|
-
): Promise<CurrentPullRequest
|
|
921
|
-
const pushTarget = await readPushTarget(pi, context);
|
|
922
|
-
if (pushTarget === null) return null;
|
|
923
|
-
const search = await execute(pi, context, "Find pull requests", "gh", [
|
|
924
|
-
"api",
|
|
925
|
-
"search/issues",
|
|
926
|
-
"--hostname",
|
|
927
|
-
pushTarget.repository.host,
|
|
928
|
-
"--paginate",
|
|
929
|
-
"--slurp",
|
|
930
|
-
"-X",
|
|
931
|
-
"GET",
|
|
932
|
-
"-f",
|
|
933
|
-
`q=is:pr head:${pushTarget.ref}${pushTarget.remoteHeadOid === null ? "" : ` ${pushTarget.remoteHeadOid}`}`,
|
|
934
|
-
"-f",
|
|
935
|
-
`per_page=${PR_LIST_LIMIT}`,
|
|
936
|
-
]);
|
|
937
|
-
const candidates: ListedPullRequest[] = [];
|
|
938
|
-
for (const url of parseCandidateUrls(search.stdout, pushTarget.repository.host)) {
|
|
939
|
-
const loaded = await execute(pi, context, "Find pull requests", "gh", [
|
|
940
|
-
"pr",
|
|
941
|
-
"view",
|
|
942
|
-
url.href,
|
|
943
|
-
"--json",
|
|
944
|
-
PR_FIELDS,
|
|
945
|
-
]);
|
|
946
|
-
const candidate = parseLoadedPullRequest(loaded.stdout, url);
|
|
947
|
-
if (candidate !== null) candidates.push(candidate);
|
|
948
|
-
}
|
|
949
|
-
const candidate = selectPullRequest(candidates, pushTarget);
|
|
950
|
-
if (candidate === null) return null;
|
|
1284
|
+
): Promise<CurrentPullRequest> {
|
|
951
1285
|
await execute(pi, context, "Validate pull request base ref", "git", [
|
|
952
1286
|
"check-ref-format",
|
|
953
1287
|
`refs/heads/${candidate.base.ref}`,
|
|
@@ -993,6 +1327,398 @@ export async function loadCurrentPullRequest(
|
|
|
993
1327
|
base: liveBaseOid ? { ...candidate.base, oid: liveBaseOid } : candidate.base,
|
|
994
1328
|
head: candidate.head,
|
|
995
1329
|
headFetchSource: pushTarget.fetchSource,
|
|
1330
|
+
target: publicTarget(pushTarget),
|
|
996
1331
|
merge,
|
|
997
1332
|
};
|
|
998
1333
|
}
|
|
1334
|
+
|
|
1335
|
+
async function searchPullRequests(
|
|
1336
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1337
|
+
context: PullRequestLoadContext,
|
|
1338
|
+
pushTarget: PushTarget,
|
|
1339
|
+
): Promise<SearchSelection> {
|
|
1340
|
+
const owner = pushTarget.repository.nameWithOwner.split("/")[0];
|
|
1341
|
+
const searchQuery = `is:pr${pushTarget.provenance === "inferred" ? " is:open" : ""} head:${owner}:${pushTarget.ref}`;
|
|
1342
|
+
const candidates: SearchPullRequest[] = [];
|
|
1343
|
+
const cursors = new Set<string>();
|
|
1344
|
+
let issueCount: number | null = null;
|
|
1345
|
+
let endCursor: string | null = null;
|
|
1346
|
+
for (let pageIndex = 0; pageIndex < PR_SEARCH_MAX_PAGES; pageIndex += 1) {
|
|
1347
|
+
const args = [
|
|
1348
|
+
"api",
|
|
1349
|
+
"graphql",
|
|
1350
|
+
"--hostname",
|
|
1351
|
+
pushTarget.repository.host,
|
|
1352
|
+
"-f",
|
|
1353
|
+
`query=${PR_SEARCH_QUERY}`,
|
|
1354
|
+
"-F",
|
|
1355
|
+
`searchQuery=${searchQuery}`,
|
|
1356
|
+
];
|
|
1357
|
+
if (endCursor !== null) args.push("-F", `endCursor=${endCursor}`);
|
|
1358
|
+
const result = await execute(pi, context, "Find pull requests", "gh", args);
|
|
1359
|
+
const page = parseSearchPage(result.stdout, pushTarget.repository.host);
|
|
1360
|
+
if (issueCount !== null && page.issueCount !== issueCount) {
|
|
1361
|
+
fail("Find pull requests", "inconsistent search result pages");
|
|
1362
|
+
}
|
|
1363
|
+
issueCount = page.issueCount;
|
|
1364
|
+
if (issueCount > PR_SEARCH_CAP) fail("Find pull requests", "GitHub search result cap reached");
|
|
1365
|
+
const expectedPageSize = Math.min(PR_SEARCH_PAGE_SIZE, Math.max(0, issueCount - candidates.length));
|
|
1366
|
+
if (page.candidates.length !== expectedPageSize) fail("Find pull requests", "incomplete search results");
|
|
1367
|
+
for (const cursor of page.cursors) {
|
|
1368
|
+
if (cursors.has(cursor)) fail("Find pull requests", "duplicate candidate cursor");
|
|
1369
|
+
cursors.add(cursor);
|
|
1370
|
+
}
|
|
1371
|
+
candidates.push(...page.candidates);
|
|
1372
|
+
if (new Set(candidates.map(({ url }) => url.href.toLowerCase())).size !== candidates.length) {
|
|
1373
|
+
fail("Find pull requests", "duplicate candidate url");
|
|
1374
|
+
}
|
|
1375
|
+
const hasMore = candidates.length < issueCount;
|
|
1376
|
+
if (page.hasNextPage !== hasMore) fail("Find pull requests", "incomplete search results");
|
|
1377
|
+
if (!hasMore) {
|
|
1378
|
+
const selected = selectSearchPullRequest(candidates, pushTarget);
|
|
1379
|
+
if (selected.kind !== "candidate") return selected;
|
|
1380
|
+
const loaded = await execute(pi, context, "Find pull requests", "gh", [
|
|
1381
|
+
"pr",
|
|
1382
|
+
"view",
|
|
1383
|
+
selected.candidate.url.href,
|
|
1384
|
+
"--json",
|
|
1385
|
+
PR_FIELDS,
|
|
1386
|
+
]);
|
|
1387
|
+
return {
|
|
1388
|
+
...selected,
|
|
1389
|
+
pullRequest: parseLoadedPullRequest(loaded.stdout, selected.candidate.url),
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
if (page.endCursor === null) fail("Find pull requests", "invalid search pageInfo");
|
|
1393
|
+
endCursor = page.endCursor;
|
|
1394
|
+
}
|
|
1395
|
+
return fail("Find pull requests", "GitHub search result cap reached");
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
export async function loadCurrentPullRequest(
|
|
1399
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1400
|
+
context: PullRequestLoadContext,
|
|
1401
|
+
inspectedLocal?: LocalMergeSafety,
|
|
1402
|
+
): Promise<CurrentPullRequestDiscovery> {
|
|
1403
|
+
const read = await readPushTarget(pi, context);
|
|
1404
|
+
if (read.kind === "inactive") return { kind: "inactive" };
|
|
1405
|
+
if (read.kind === "blocked") {
|
|
1406
|
+
return { kind: "blocked", issue: { kind: read.issue === "detached" ? "detached-head" : "target-invalid" } };
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
let pushTarget: PushTarget;
|
|
1410
|
+
if (read.kind === "missing") {
|
|
1411
|
+
const inferred = await inferPushTarget(pi, context, read.branch, read.remoteNames);
|
|
1412
|
+
if (inferred.kind === "blocked") {
|
|
1413
|
+
if (inferred.issue === "ambiguous") {
|
|
1414
|
+
return { kind: "blocked", issue: { kind: "candidate-remotes-ambiguous", remotes: inferred.remotes! } };
|
|
1415
|
+
}
|
|
1416
|
+
return {
|
|
1417
|
+
kind: "blocked",
|
|
1418
|
+
issue: { kind: inferred.issue === "origin" ? "origin-invalid" : "target-invalid" },
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
if (inferred.kind === "none") {
|
|
1422
|
+
if (!canLinkTarget(await readLinkConfiguration(pi, context, inferred.target), inferred.target)) {
|
|
1423
|
+
return { kind: "blocked", issue: { kind: "link-configuration", remote: inferred.target.remote } };
|
|
1424
|
+
}
|
|
1425
|
+
return { kind: "none", creationTarget: publicTarget(inferred.target) };
|
|
1426
|
+
}
|
|
1427
|
+
pushTarget = inferred.target;
|
|
1428
|
+
} else {
|
|
1429
|
+
pushTarget = read.target;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const search = await searchPullRequests(pi, context, pushTarget);
|
|
1433
|
+
if (search.kind === "ambiguous") {
|
|
1434
|
+
return {
|
|
1435
|
+
kind: "blocked",
|
|
1436
|
+
issue: {
|
|
1437
|
+
kind: "candidate-prs-ambiguous",
|
|
1438
|
+
urls: search.urls.sort((a, b) => a.href.localeCompare(b.href)),
|
|
1439
|
+
},
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
if (search.kind === "oid-mismatch") {
|
|
1443
|
+
return {
|
|
1444
|
+
kind: "blocked",
|
|
1445
|
+
issue: {
|
|
1446
|
+
kind: "candidate-oid-mismatch",
|
|
1447
|
+
remote: pushTarget.remote,
|
|
1448
|
+
urls: search.urls,
|
|
1449
|
+
},
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
if (search.kind === "target-invalid") {
|
|
1453
|
+
return { kind: "blocked", issue: { kind: "target-invalid" } };
|
|
1454
|
+
}
|
|
1455
|
+
const candidates = search.kind === "candidate" && search.pullRequest !== null ? [search.pullRequest] : [];
|
|
1456
|
+
let candidate: ListedPullRequest | null;
|
|
1457
|
+
if (pushTarget.provenance === "inferred") {
|
|
1458
|
+
const matching = candidates.filter((item) =>
|
|
1459
|
+
item.lifecycle === "open" &&
|
|
1460
|
+
item.url.hostname.toLowerCase() === pushTarget.repository.host &&
|
|
1461
|
+
normalizeRepository(item.head.repository) === pushTarget.repository.normalizedName &&
|
|
1462
|
+
item.head.ref === pushTarget.ref
|
|
1463
|
+
);
|
|
1464
|
+
if (matching.length > 1) {
|
|
1465
|
+
return {
|
|
1466
|
+
kind: "blocked",
|
|
1467
|
+
issue: { kind: "candidate-prs-ambiguous", urls: matching.map(({ url }) => url).sort((a, b) => a.href.localeCompare(b.href)) },
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
if (matching.length === 0) {
|
|
1471
|
+
return { kind: "blocked", issue: { kind: "published-without-pr", remote: pushTarget.remote } };
|
|
1472
|
+
}
|
|
1473
|
+
candidate = matching[0];
|
|
1474
|
+
if (candidate.head.oid !== pushTarget.remoteHeadOid) {
|
|
1475
|
+
return {
|
|
1476
|
+
kind: "blocked",
|
|
1477
|
+
issue: { kind: "candidate-oid-mismatch", remote: pushTarget.remote, urls: [candidate.url] },
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
if (!canLinkTarget(await readLinkConfiguration(pi, context, pushTarget), pushTarget)) {
|
|
1481
|
+
return { kind: "blocked", issue: { kind: "link-configuration", remote: pushTarget.remote } };
|
|
1482
|
+
}
|
|
1483
|
+
} else {
|
|
1484
|
+
try {
|
|
1485
|
+
candidate = selectPullRequest(candidates, pushTarget);
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
if (!(error instanceof PullRequestLoadError)) throw error;
|
|
1488
|
+
const matching = candidates.filter((item) =>
|
|
1489
|
+
normalizeRepository(item.head.repository) === pushTarget.repository.normalizedName && item.head.ref === pushTarget.ref
|
|
1490
|
+
);
|
|
1491
|
+
const urls = matching.map(({ url }) => url).sort((a, b) => a.href.localeCompare(b.href));
|
|
1492
|
+
if (error.message.includes("multiple ")) {
|
|
1493
|
+
return {
|
|
1494
|
+
kind: "blocked",
|
|
1495
|
+
issue: { kind: "candidate-prs-ambiguous", urls },
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
if (error.message.includes("does not match remote push ref")) {
|
|
1499
|
+
return {
|
|
1500
|
+
kind: "blocked",
|
|
1501
|
+
issue: { kind: "candidate-oid-mismatch", remote: pushTarget.remote, urls },
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
if (error.message.includes("remote push ref is absent")) {
|
|
1505
|
+
return { kind: "blocked", issue: { kind: "target-invalid" } };
|
|
1506
|
+
}
|
|
1507
|
+
throw error;
|
|
1508
|
+
}
|
|
1509
|
+
if (candidate === null) return { kind: "none", creationTarget: publicTarget(pushTarget) };
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
return {
|
|
1513
|
+
kind: "current",
|
|
1514
|
+
pullRequest: await loadPullRequestDetails(pi, context, candidate, pushTarget, inspectedLocal),
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
export function samePullRequestSnapshot(left: CurrentPullRequest, right: CurrentPullRequest): boolean {
|
|
1519
|
+
return left.lifecycle === right.lifecycle && left.id === right.id && left.number === right.number &&
|
|
1520
|
+
left.url.href === right.url.href && left.host === right.host &&
|
|
1521
|
+
left.base.repository === right.base.repository && left.base.ref === right.base.ref &&
|
|
1522
|
+
left.head.repository === right.head.repository && left.head.ref === right.head.ref &&
|
|
1523
|
+
left.head.oid === right.head.oid &&
|
|
1524
|
+
left.target.provenance === right.target.provenance &&
|
|
1525
|
+
left.target.branch === right.target.branch && left.target.remote === right.target.remote &&
|
|
1526
|
+
left.target.ref === right.target.ref && left.target.repository === right.target.repository &&
|
|
1527
|
+
left.target.host === right.target.host && left.target.fetchSource === right.target.fetchSource &&
|
|
1528
|
+
left.target.remoteOid === right.target.remoteOid;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function sameLinkedPullRequest(inferred: CurrentPullRequest, configured: CurrentPullRequest): boolean {
|
|
1532
|
+
return inferred.lifecycle === "open" && configured.lifecycle === "open" &&
|
|
1533
|
+
configured.target.provenance === "configured" &&
|
|
1534
|
+
inferred.id === configured.id &&
|
|
1535
|
+
inferred.number === configured.number &&
|
|
1536
|
+
inferred.url.href === configured.url.href &&
|
|
1537
|
+
inferred.host === configured.host &&
|
|
1538
|
+
inferred.head.repository === configured.head.repository &&
|
|
1539
|
+
inferred.head.ref === configured.head.ref &&
|
|
1540
|
+
inferred.head.oid === configured.head.oid &&
|
|
1541
|
+
inferred.target.branch === configured.target.branch &&
|
|
1542
|
+
inferred.target.remote === configured.target.remote &&
|
|
1543
|
+
inferred.target.ref === configured.target.ref &&
|
|
1544
|
+
inferred.target.repository === configured.target.repository &&
|
|
1545
|
+
inferred.target.host === configured.target.host &&
|
|
1546
|
+
inferred.target.fetchSource === configured.target.fetchSource &&
|
|
1547
|
+
inferred.target.remoteOid === configured.target.remoteOid;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
async function readTrackingOid(
|
|
1551
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1552
|
+
context: PullRequestLoadContext,
|
|
1553
|
+
trackingRef: string,
|
|
1554
|
+
): Promise<string | null> {
|
|
1555
|
+
const result = await invoke(pi, context, "Read remote-tracking ref", "git", [
|
|
1556
|
+
"rev-parse", "--verify", "--quiet", `${trackingRef}^{commit}`,
|
|
1557
|
+
]);
|
|
1558
|
+
if (result.killed) commandFailure("Read remote-tracking ref", result);
|
|
1559
|
+
if (result.code === 1 && result.stdout === "") return null;
|
|
1560
|
+
if (result.code !== 0) commandFailure("Read remote-tracking ref", result);
|
|
1561
|
+
return oid(singleLine(result.stdout, "Read remote-tracking ref", "OID"), "Read remote-tracking ref", "OID");
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
async function restoreConfigValue(
|
|
1565
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1566
|
+
context: PullRequestLoadContext,
|
|
1567
|
+
key: string,
|
|
1568
|
+
expected: string,
|
|
1569
|
+
original: string[],
|
|
1570
|
+
): Promise<void> {
|
|
1571
|
+
const current = await readConfigValues(pi, context, key);
|
|
1572
|
+
if (current === null) throw new Error("Restore branch upstream failed: invalid Git configuration");
|
|
1573
|
+
if (current.length !== 1 || current[0] !== expected) {
|
|
1574
|
+
throw new Error("Restore branch upstream failed: branch configuration changed concurrently");
|
|
1575
|
+
}
|
|
1576
|
+
const unset = await invoke(pi, context, "Restore branch upstream", "git", [
|
|
1577
|
+
"config", "--fixed-value", "--unset-all", key, expected,
|
|
1578
|
+
]);
|
|
1579
|
+
if (unset.killed) commandFailure("Restore branch upstream", unset);
|
|
1580
|
+
if (unset.code === 5) {
|
|
1581
|
+
throw new Error("Restore branch upstream failed: branch configuration changed concurrently");
|
|
1582
|
+
}
|
|
1583
|
+
if (unset.code !== 0) commandFailure("Restore branch upstream", unset);
|
|
1584
|
+
for (const value of original) {
|
|
1585
|
+
await execute(pi, context, "Restore branch upstream", "git", ["config", "--add", key, value]);
|
|
1586
|
+
}
|
|
1587
|
+
const restored = await readConfigValues(pi, context, key);
|
|
1588
|
+
if (restored === null || restored.length !== original.length || restored.some((value, index) => value !== original[index])) {
|
|
1589
|
+
throw new Error("Restore branch upstream failed: branch configuration changed concurrently");
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
async function restoreLinkState(
|
|
1594
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1595
|
+
context: PullRequestLoadContext,
|
|
1596
|
+
target: PushTarget,
|
|
1597
|
+
configuration: LinkConfiguration,
|
|
1598
|
+
trackingRef: string,
|
|
1599
|
+
trackingOid: string | null,
|
|
1600
|
+
upstreamMutated: boolean,
|
|
1601
|
+
fetchedOid: string | undefined,
|
|
1602
|
+
): Promise<void> {
|
|
1603
|
+
let incomplete = false;
|
|
1604
|
+
if (upstreamMutated) {
|
|
1605
|
+
for (const [key, expected, original] of [
|
|
1606
|
+
[`branch.${target.branch}.remote`, target.remote, configuration.upstreamRemote],
|
|
1607
|
+
[`branch.${target.branch}.merge`, `refs/heads/${target.ref}`, configuration.upstreamMerge],
|
|
1608
|
+
] as const) {
|
|
1609
|
+
try {
|
|
1610
|
+
await restoreConfigValue(pi, context, key, expected, original);
|
|
1611
|
+
} catch {
|
|
1612
|
+
incomplete = true;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
if (fetchedOid !== undefined) {
|
|
1617
|
+
try {
|
|
1618
|
+
const currentTrackingOid = await readTrackingOid(pi, context, trackingRef);
|
|
1619
|
+
if (currentTrackingOid !== fetchedOid) {
|
|
1620
|
+
incomplete = true;
|
|
1621
|
+
} else {
|
|
1622
|
+
const args = trackingOid === null
|
|
1623
|
+
? ["update-ref", "-d", trackingRef, currentTrackingOid]
|
|
1624
|
+
: ["update-ref", trackingRef, trackingOid, currentTrackingOid];
|
|
1625
|
+
await execute(pi, context, "Restore remote-tracking ref", "git", args);
|
|
1626
|
+
}
|
|
1627
|
+
} catch {
|
|
1628
|
+
incomplete = true;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
if (incomplete) throw new Error("Link branch failed and rollback was incomplete");
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
export async function linkInferredPullRequest(
|
|
1635
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1636
|
+
context: PullRequestLoadContext,
|
|
1637
|
+
inferred: CurrentPullRequest,
|
|
1638
|
+
): Promise<CurrentPullRequest> {
|
|
1639
|
+
if (inferred.target.provenance !== "inferred" || inferred.lifecycle !== "open") {
|
|
1640
|
+
throw new Error("Link branch failed: pull request is not an open inferred target");
|
|
1641
|
+
}
|
|
1642
|
+
const freshDiscovery = await loadCurrentPullRequest(pi, context);
|
|
1643
|
+
if (
|
|
1644
|
+
freshDiscovery.kind !== "current" ||
|
|
1645
|
+
freshDiscovery.pullRequest.target.provenance !== "inferred" ||
|
|
1646
|
+
!samePullRequestSnapshot(inferred, freshDiscovery.pullRequest)
|
|
1647
|
+
) throw new Error("Link branch cancelled: inferred pull request context changed");
|
|
1648
|
+
inferred = freshDiscovery.pullRequest;
|
|
1649
|
+
const target: PushTarget = {
|
|
1650
|
+
provenance: "inferred",
|
|
1651
|
+
branch: inferred.target.branch,
|
|
1652
|
+
remote: inferred.target.remote,
|
|
1653
|
+
ref: inferred.target.ref,
|
|
1654
|
+
fetchSource: inferred.target.fetchSource,
|
|
1655
|
+
remoteHeadOid: inferred.target.remoteOid,
|
|
1656
|
+
repository: {
|
|
1657
|
+
nameWithOwner: inferred.target.repository,
|
|
1658
|
+
normalizedName: normalizeRepository(inferred.target.repository),
|
|
1659
|
+
host: inferred.target.host,
|
|
1660
|
+
},
|
|
1661
|
+
};
|
|
1662
|
+
const linkConfiguration = await readLinkConfiguration(pi, context, target);
|
|
1663
|
+
if (target.remoteHeadOid === null || !linkConfiguration || !canLinkTarget(linkConfiguration, target)) {
|
|
1664
|
+
throw new Error("Link branch cancelled: target configuration changed");
|
|
1665
|
+
}
|
|
1666
|
+
const pushReference = optionalPushReference((await execute(pi, context, "Read push target", "git", [
|
|
1667
|
+
"for-each-ref", "--format=%(push:short)", `refs/heads/${target.branch}`,
|
|
1668
|
+
])).stdout);
|
|
1669
|
+
if (pushReference !== null) throw new Error("Link branch cancelled: push target is no longer empty");
|
|
1670
|
+
const remoteHeadOid = await readRemoteHeadOid(pi, context, target.fetchSource, target.ref);
|
|
1671
|
+
if (remoteHeadOid !== target.remoteHeadOid) throw new Error("Link branch cancelled: remote ref changed");
|
|
1672
|
+
|
|
1673
|
+
const trackingRef = `refs/remotes/${target.remote}/${target.ref}`;
|
|
1674
|
+
const trackingOid = await readTrackingOid(pi, context, trackingRef);
|
|
1675
|
+
let fetchedOid: string | undefined;
|
|
1676
|
+
let upstreamMutated = false;
|
|
1677
|
+
try {
|
|
1678
|
+
await execute(pi, context, "Fetch inferred branch", "git", [
|
|
1679
|
+
"fetch",
|
|
1680
|
+
"--no-write-fetch-head",
|
|
1681
|
+
"--no-tags",
|
|
1682
|
+
"--no-recurse-submodules",
|
|
1683
|
+
target.fetchSource,
|
|
1684
|
+
`${target.remoteHeadOid}:${trackingRef}`,
|
|
1685
|
+
]);
|
|
1686
|
+
fetchedOid = target.remoteHeadOid;
|
|
1687
|
+
const verifiedFetchedOid = (await readTrackingOid(pi, context, trackingRef)) ?? undefined;
|
|
1688
|
+
if (verifiedFetchedOid !== fetchedOid) {
|
|
1689
|
+
throw new Error("Link branch cancelled: fetched remote ref changed");
|
|
1690
|
+
}
|
|
1691
|
+
await execute(pi, context, "Set branch upstream", "git", [
|
|
1692
|
+
"branch", `--set-upstream-to=${target.remote}/${target.ref}`, "--", target.branch,
|
|
1693
|
+
]);
|
|
1694
|
+
upstreamMutated = true;
|
|
1695
|
+
const configuredTarget = optionalPushReference((await execute(pi, context, "Verify push target", "git", [
|
|
1696
|
+
"for-each-ref", "--format=%(push:short)", `refs/heads/${target.branch}`,
|
|
1697
|
+
])).stdout);
|
|
1698
|
+
if (configuredTarget !== `${target.remote}/${target.ref}`) {
|
|
1699
|
+
throw new Error("Link branch failed: configured push target does not match inferred target");
|
|
1700
|
+
}
|
|
1701
|
+
const discovery = await loadCurrentPullRequest(pi, context);
|
|
1702
|
+
if (discovery.kind !== "current" || !sameLinkedPullRequest(inferred, discovery.pullRequest)) {
|
|
1703
|
+
throw new Error("Link branch failed: configured pull request does not match inferred target");
|
|
1704
|
+
}
|
|
1705
|
+
return discovery.pullRequest;
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
const rollbackContext = { cwd: context.cwd, signal: new AbortController().signal };
|
|
1708
|
+
try {
|
|
1709
|
+
await restoreLinkState(
|
|
1710
|
+
pi,
|
|
1711
|
+
rollbackContext,
|
|
1712
|
+
target,
|
|
1713
|
+
linkConfiguration,
|
|
1714
|
+
trackingRef,
|
|
1715
|
+
trackingOid,
|
|
1716
|
+
upstreamMutated,
|
|
1717
|
+
fetchedOid,
|
|
1718
|
+
);
|
|
1719
|
+
} catch {
|
|
1720
|
+
throw new Error("Link branch failed and rollback was incomplete");
|
|
1721
|
+
}
|
|
1722
|
+
throw error;
|
|
1723
|
+
}
|
|
1724
|
+
}
|