@henryqw/pi-pr 3.0.1 → 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.
@@ -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 PR_LIST_LIMIT = 100;
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 parsePushUrl(value: string): PushUrl {
275
- if (/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(value)) return fail("Read push URL", "invalid push URL");
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("Read push URL", "invalid push URL");
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("Read push URL", "invalid push URL");
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("Read push URL", "invalid push URL");
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("Read push URL", "invalid push URL");
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 parsePushRepository(output: string, pushUrl: PushUrl): PushRepository {
318
- const value = parseJson(output, "Read push repository");
319
- if (!isRecord(value)) fail("Read push repository", "invalid GitHub CLI output");
320
- const nameWithOwner = repositoryName(value.nameWithOwner, "Read push repository", "nameWithOwner");
321
- const url = parseHttpUrl(value.url, "Read push repository", "url");
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 !== pushUrl.host || normalizedName !== pushUrl.normalizedName
328
- ) fail("Read push repository", "response does not match push URL");
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 parseCandidateUrls(output: string, host: string): URL[] {
460
- const pages = parseJson(output, "Find pull requests");
461
- if (!Array.isArray(pages) || !pages.length) fail("Find pull requests", "invalid GitHub CLI output");
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
- return urls;
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 readPushTarget(
884
+ async function readRemoteAuthority(
752
885
  pi: Pick<ExtensionAPI, "exec">,
753
886
  context: PullRequestLoadContext,
754
- ): Promise<PushTarget | null> {
755
- const branch = singleLine(
756
- (await execute(pi, context, "Read current branch", "git", ["branch", "--show-current"])).stdout,
757
- "Read current branch",
758
- "branch",
759
- );
760
- const pushReference = optionalPushReference(
761
- (await execute(pi, context, "Read push target", "git", [
762
- "for-each-ref",
763
- "--format=%(push:short)",
764
- `refs/heads/${branch}`,
765
- ])).stdout,
766
- );
767
- if (pushReference === null) return null;
768
- const remoteNames = lines(
769
- (await execute(pi, context, "Read push remotes", "git", ["remote"])).stdout,
770
- "Read push remotes",
771
- "remote",
772
- );
773
- const push = parsePushReference(pushReference, remoteNames);
774
- const checkedRef = singleLine(
775
- (await execute(pi, context, "Read push target", "git", ["check-ref-format", "--branch", push.ref])).stdout,
776
- "Read push target",
777
- "push ref",
778
- );
779
- if (checkedRef !== push.ref) fail("Read push target", "invalid push ref");
780
- const pushUrls = lines(
781
- (await execute(pi, context, "Read push URL", "git", ["remote", "get-url", "--push", "--all", push.remote])).stdout,
782
- "Read push URL",
783
- "push URL",
784
- );
785
- if (pushUrls.length !== 1) fail("Read push URL", "multiple push URLs are configured");
786
- const pushUrl = parsePushUrl(pushUrls[0]);
787
- const repository = parsePushRepository((await execute(
788
- pi,
789
- context,
790
- "Read push repository",
791
- "gh",
792
- ["repo", "view", pushUrl.locator, "--json", "nameWithOwner,url"],
793
- )).stdout, pushUrl);
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
- pushUrl.fetchSource,
799
- `refs/heads/${push.ref}`,
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
- remoteHeadOid = null;
806
- } else {
807
- if (remoteHead.code !== 0) commandFailure("Read remote push ref", remoteHead);
808
- remoteHeadOid = parseRemotePushRef(remoteHead.stdout, push.ref);
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
- return { fetchSource: pushUrl.fetchSource, remoteHeadOid, repository, ref: push.ref };
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
- export async function loadCurrentPullRequest(
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 | null> {
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
+ }