1import type { RepositoryIdentity, ThreadLinkedPullRequest } from "@t3tools/contracts";
2import { canonicalRepositoryKey } from "./sourceControl.ts";
3
4/**
5 * A change request named the way a thread link names one: the host below which the repository
6 * is addressed, the repository path as that host writes it, and the number.
7 *
8 * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce
9 * from a git remote — lower case, no port, the full path below the host — because links are
10 * matched against those. Anything else matches nothing.
11 */
12export interface ChangeRequestLink {
13 readonly host: string;
14 readonly repository: string;
15 readonly number: number;
16 /** Forgejo's HTTP host and port, separate from the portless repository identity. */
17 readonly authority?: string;
18}
19
20/** The host itself, one of its subdomains, or an install named after the provider. */
21function isHostOf(hostname: string, apex: string, label?: string): boolean {
22 if (hostname === apex || hostname.endsWith(`.${apex}`)) return true;
23 return label !== undefined && hostname.split(".").includes(label);
24}
25
26/**
27 * The repository and number behind a change request URL on a host this can read, or null for
28 * anything else — an issue, a commit, a repository root, a host this cannot tell apart from an
29 * ordinary link. A doubtful match is worse than no match, so nothing here guesses.
30 *
31 * Each host is recognised by the path shape it alone uses, guarded by a hostname it could
32 * plausibly be served from, since self-hosted installs are named whatever their admin chose:
33 * GitLab's `/-/` marker is unique enough to trust on any hostname, while `/pull/` is generic
34 * enough that it is only believed from a GitHub-ish host.
35 *
36 * Nothing here tries to tell a lookalike hostname from a real one — `github.com.evil.test` and
37 * the rest are an open set, and blocking spellings of it costs real hosts (`gitlab.com.br` is a
38 * registrable domain). What a claim is worth is decided where it is used.
39 */
40export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null {
41 let url: URL;
42 try {
43 url = new URL(targetUrl);
44 } catch {
45 return null;
46 }
47 // `javascript:`, `mailto:` and friends have no host to speak of and nothing to open.
48 if (url.protocol !== "https:" && url.protocol !== "http:") return null;
49 const host = url.hostname.toLowerCase();
50
51 // GitCafe, only on its own two hosts: /{owner}/{repo}/pulls/{n}
52 if (host === "git.cafe" || host === "staging.git.cafe") {
53 const match = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname);
54 return claim(host, match);
55 }
56 // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n}
57 if (isHostOf(host, "github.com", "github")) {
58 const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname);
59 if (match) return claim(host, match);
60 }
61 // Forgejo and Gitea use /pulls/ on arbitrary self-hosted domains.
62 const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname);
63 if (forgejo) {
64 const link = claim(host, forgejo);
65 return link === null ? null : { ...link, authority: url.host.toLowerCase() };
66 }
67 // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/`
68 // separator is GitLab's own, so the hostname is not asked about.
69 const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname);
70 if (gitlab) return claim(host, gitlab);
71 // Bitbucket Cloud: /{workspace}/{repo}/pull-requests/{n}
72 if (isHostOf(host, "bitbucket.org", "bitbucket")) {
73 const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname);
74 return claim(host, match);
75 }
76 // Azure DevOps, both the current host and the per-organisation one it replaced. `_git` is part
77 // of the repository path there, as it is in the remote URL the identity is read from.
78 if (isHostOf(host, "dev.azure.com") || host.endsWith(".visualstudio.com")) {
79 const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname);
80 return claim(host, match);
81 }
82 return null;
83}
84
85function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {
86 const repository = match?.[1];
87 const number = Number(match?.[2]);
88 return repository && Number.isSafeInteger(number) && number > 0
89 ? { host, repository: repository.toLowerCase(), number }
90 : null;
91}
92
93/** The web URL a host writes for a change request; null when the host shape is unknown. */
94export function changeRequestUrlFor(
95 kind: string | null | undefined,
96 host: string,
97 repository: string,
98 number: number,
99 remoteUrl?: string,
100): string | null {
101 switch (kind) {
102 case "github":
103 return `https://${host}/${repository}/pull/${number}`;
104 case "gitcafe":
105 return `https://${host}/${repository}/pulls/${number}`;
106 case "forgejo": {
107 try {
108 const remote = new URL(remoteUrl ?? "");
109 if (
110 (remote.protocol === "http:" || remote.protocol === "https:") &&
111 (remote.hostname.toLowerCase() === host.toLowerCase() ||
112 remote.host.toLowerCase() === host.toLowerCase())
113 ) {
114 return `${remote.origin}/${repository}/pulls/${number}`;
115 }
116 } catch {
117 // SSH remotes do not specify the server's web origin.
118 }
119 return `https://${host}/${repository}/pulls/${number}`;
120 }
121 case "gitlab":
122 return `https://${host}/${repository}/-/merge_requests/${number}`;
123 case "bitbucket":
124 return `https://${host}/${repository}/pull-requests/${number}`;
125 case "azure-devops":
126 return `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`;
127 default:
128 return null;
129 }
130}
131
132/** Builds a GitHub URL that remains available when the pull request API cannot be read. */
133export function gitHubPullRequestBrowserUrl(
134 identity: RepositoryIdentity | null | undefined,
135 repository: string,
136 number: number,
137): string | null {
138 if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null;
139 const repositoryPath = repository.split("/");
140 if (
141 repositoryPath.length !== 2 ||
142 repositoryPath.some((segment) => segment.length === 0 || segment === "." || segment === "..")
143 ) {
144 return null;
145 }
146
147 let origin: string | null = null;
148 try {
149 const remoteUrl = new URL(identity.locator.remoteUrl.trim());
150 if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") {
151 origin = remoteUrl.origin;
152 }
153 } catch {
154 // SCP-style remotes are read from their normalized identity below.
155 }
156 const hostname = identity.canonicalKey.split("/")[0];
157 if (origin === null && !hostname) return null;
158
159 try {
160 const url = new URL(origin ?? `https://${hostname}`);
161 url.pathname = `/${repositoryPath.join("/")}/pull/${number}`;
162 return url.toString();
163 } catch {
164 return null;
165 }
166}
167
168/**
169 * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare
170 * reference through `/issues/`, including pull requests, so this only builds a candidate: the
171 * caller must successfully read it as a pull request before treating it as one.
172 */
173export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null {
174 let url: URL;
175 try {
176 url = new URL(targetUrl);
177 } catch {
178 return null;
179 }
180 if (
181 (url.protocol !== "https:" && url.protocol !== "http:") ||
182 !(
183 url.hostname.toLowerCase() === "github.com" ||
184 url.hostname.toLowerCase().endsWith(".github.com") ||
185 url.hostname.toLowerCase().split(".").includes("github")
186 )
187 ) {
188 return null;
189 }
190 const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname);
191 if (match?.[1] === undefined || match[2] === undefined) return null;
192 url.pathname = `/${match[1]}/pull/${match[2]}`;
193 return url.toString();
194}
195
196/** Match a stored PR without requiring its project to remain available. */
197export function matchesLinkedPullRequestUrl(
198 linkedPullRequest: ThreadLinkedPullRequest,
199 targetUrl: string,
200): boolean {
201 const linked = parseChangeRequestUrl(linkedPullRequest.url);
202 const target = parseChangeRequestUrl(targetUrl);
203 return (
204 linked !== null &&
205 target !== null &&
206 linked.host === target.host &&
207 linked.repository === target.repository &&
208 linked.number === target.number &&
209 linked.authority === target.authority
210 );
211}
212
213/** The repository root behind a recognised change-request URL, without PR-specific state. */
214export function changeRequestRepositoryUrl(targetUrl: string): string | null {
215 const changeRequest = parseChangeRequestUrl(targetUrl);
216 if (changeRequest === null) return null;
217 const url = new URL(targetUrl);
218 const repositoryPath =
219 /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ??
220 /^(.*?)(?:\/pulls?\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec(
221 url.pathname,
222 )?.[1];
223 if (!repositoryPath) return null;
224 url.pathname = repositoryPath;
225 url.search = "";
226 url.hash = "";
227 return url.toString();
228}
229
230export function siblingPullRequestUrl(url: string, number: number): string | null {
231 const reference = parseChangeRequestUrl(url);
232 if (reference === null || !Number.isSafeInteger(number) || number < 1) return null;
233 const sibling = new URL(url);
234 const route = /^\/(-\/merge_requests|pulls?|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec(
235 sibling.pathname.slice(reference.repository.length + 1),
236 )?.[1];
237 if (route === undefined) return null;
238 sibling.pathname = `/${reference.repository}/${route}/${number}`;
239 sibling.search = "";
240 sibling.hash = "";
241 return sibling.toString();
242}