| 1 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; |
| 2 | import { |
| 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. */ |
| 8 | export 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. */ |
| 15 | export 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 | */ |
| 35 | export 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 | */ |
| 76 | export 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. */ |
| 118 | export 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 | */ |
| 145 | export 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 | */ |
| 177 | export 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. */ |
| 189 | function apiHostForHint(host: string): string { |
| 190 | const normalized = host.toLowerCase(); |
| 191 | return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; |
| 192 | } |