1import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
2import {
3 detectSourceControlProviderFromRemoteUrl,
4 isSshRemoteUrl,
5} from "@t3tools/shared/sourceControl";
6
7/** A repository on a GitHub host: the API it is read through, and its owner and name. */
8export interface GitHubRepositoryLocator {
9 readonly host: string;
10 readonly owner: string;
11 readonly name: string;
12}
13
14/** The fetch remotes in `git remote -v` output, by name. */
15export function parseFetchRemotes(
16 remotes: string,
17): ReadonlyArray<{ readonly name: string; readonly url: string }> {
18 return remotes
19 .split("\n")
20 .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()))
21 .filter((match): match is RegExpExecArray => match !== null)
22 .map((match) => ({ name: match[1]!, url: match[2]! }));
23}
24
25/**
26 * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a
27 * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin
28 * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of
29 * `git config --get-regexp '^remote\..*\.gh-resolved$'`.
30 *
31 * Null whenever gh might weigh the remotes differently: a remote on another host or under an
32 * SSH alias, more than one mark, or several remotes with none of those names. Callers then
33 * fall back to the provider's remote, then to the best-ranked remote on the host.
34 */
35export function selectGitHubBaseRepository(input: {
36 readonly remotes: string;
37 readonly resolved: string;
38 readonly host: string;
39}): { readonly owner: string; readonly name: string } | null {
40 const host = input.host.toLowerCase();
41 const repositories = new Map<string, { readonly owner: string; readonly name: string }>();
42 for (const remote of parseFetchRemotes(input.remotes)) {
43 const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/");
44 if (remoteHost !== host || !owner || !name || rest.length > 0) return null;
45 repositories.set(remote.name, { owner, name });
46 }
47 const marks = input.resolved
48 .split("\n")
49 .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim()))
50 .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!));
51 if (marks.length > 1) return null;
52 const [mark] = marks;
53 if (mark) {
54 if (mark[2] === "base") return repositories.get(mark[1]!) ?? null;
55 const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/");
56 return owner && name && rest.length === 0 ? { owner, name } : null;
57 }
58 // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the
59 // top place has no defined winner.
60 const score = (remoteName: string) =>
61 ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1;
62 const ranked = [...repositories.entries()].toSorted(
63 ([left], [right]) => score(right) - score(left),
64 );
65 const [top, next] = ranked;
66 return top !== undefined && (next === undefined || score(top[0]) > score(next[0]))
67 ? top[1]
68 : null;
69}
70
71/**
72 * The repository `gh` would act on in a checkout, from its remotes: the remote `gh` would pick,
73 * else the best-ranked remote on the host. `host` is the API host it settled on, which a caller
74 * names when no repository was found there. GH_REPO is the caller's to check first.
75 */
76export function resolveGitHubRepository(input: {
77 readonly remotes: string;
78 readonly resolved: string;
79 /** The checkout's GitHub API host, when the caller already knows it. */
80 readonly hostHint: string | undefined;
81 readonly defaultHost: string;
82}): { readonly host: string; readonly locator: GitHubRepositoryLocator | null } {
83 const fetchRemotes = parseFetchRemotes(input.remotes).map((remote) => ({
84 ...remote,
85 host: gitHubApiHostForRemote(remote.url),
86 }));
87 const host =
88 (input.hostHint === undefined ? undefined : apiHostForHint(input.hostHint)) ??
89 fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ??
90 fetchRemotes.find((remote) => remote.host !== null)?.host ??
91 input.defaultHost;
92 const selected = selectGitHubBaseRepository({
93 remotes: input.remotes,
94 resolved: input.resolved,
95 host,
96 });
97 if (selected !== null) return { host, locator: { host, ...selected } };
98 // gh's own order without its prompt: upstream, github, origin, then the first remote, among
99 // the ones on this host (an SSH alias counts as its API host).
100 const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase());
101 const candidates = fetchRemotes
102 .filter(
103 (remote) => remote.host === host || normalizeGitRemoteUrl(remote.url).split("/")[0] === host,
104 )
105 .toSorted((left, right) => {
106 const l = rank(left.name);
107 const r = rank(right.name);
108 return (l === -1 ? 99 : l) - (r === -1 ? 99 : r);
109 });
110 for (const remote of candidates) {
111 const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1);
112 if (owner && name && rest.length === 0) return { host, locator: { host, owner, name } };
113 }
114 return { host, locator: null };
115}
116
117/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */
118export function parseGitHubRepositorySelector(
119 selector: string,
120 defaultHost: string,
121): GitHubRepositoryLocator | null {
122 const trimmed = selector.trim().replace(/\.git$/i, "");
123 if (/^https?:\/\//i.test(trimmed)) {
124 try {
125 const url = new URL(trimmed);
126 const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean);
127 return owner && name && rest.length === 0
128 ? { host: url.host.toLowerCase(), owner, name }
129 : null;
130 } catch {
131 return null;
132 }
133 }
134 const parts = trimmed.split("/").filter(Boolean);
135 if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! };
136 if (parts.length === 3)
137 return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! };
138 return null;
139}
140
141/**
142 * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request
143 * URL, or a branch name.
144 */
145export function parsePullRequestReference(
146 reference: string,
147):
148 | { readonly kind: "number"; readonly number: number }
149 | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number }
150 | { readonly kind: "branch"; readonly headSelector: string } {
151 const trimmed = reference.trim();
152 const numbered = /^#?([1-9]\d*)$/.exec(trimmed);
153 if (numbered) return { kind: "number", number: Number(numbered[1]) };
154 if (/^https?:\/\//i.test(trimmed)) {
155 try {
156 const url = new URL(trimmed);
157 const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname);
158 if (match) {
159 return {
160 kind: "url",
161 locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! },
162 number: Number(match[3]),
163 };
164 }
165 } catch {
166 // Not a URL after all; read it as a branch.
167 }
168 }
169 return { kind: "branch", headSelector: trimmed };
170}
171
172/**
173 * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH
174 * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through
175 * `github.com`, which is what such an alias almost always stands for (issue #6198).
176 */
177export function gitHubApiHostForRemote(remoteUrl: string): string | null {
178 const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl);
179 if (provider === null) return null;
180 const host = new URL(provider.baseUrl).host.toLowerCase();
181 // A dotless SSH host is an alias from ~/.ssh/config, never a real API host.
182 if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) {
183 return host.includes("github") ? "github.com" : null;
184 }
185 return provider.kind === "github" ? host : null;
186}
187
188/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */
189function apiHostForHint(host: string): string {
190 const normalized = host.toLowerCase();
191 return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized;
192}