import type { RepositoryIdentity, ThreadLinkedPullRequest } from "@t3tools/contracts"; import { canonicalRepositoryKey } from "./sourceControl.ts"; /** * A change request named the way a thread link names one: the host below which the repository * is addressed, the repository path as that host writes it, and the number. * * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce * from a git remote — lower case, no port, the full path below the host — because links are * matched against those. Anything else matches nothing. */ export interface ChangeRequestLink { readonly host: string; readonly repository: string; readonly number: number; } /** The host itself, one of its subdomains, or an install named after the provider. */ function isHostOf(hostname: string, apex: string, label?: string): boolean { if (hostname === apex || hostname.endsWith(`.${apex}`)) return true; return label !== undefined && hostname.split(".").includes(label); } /** * The repository and number behind a change request URL on a host this can read, or null for * anything else — an issue, a commit, a repository root, a host this cannot tell apart from an * ordinary link. A doubtful match is worse than no match, so nothing here guesses. * * Each host is recognised by the path shape it alone uses, guarded by a hostname it could * plausibly be served from, since self-hosted installs are named whatever their admin chose: * GitLab's `/-/` marker is unique enough to trust on any hostname, while `/pull/` is generic * enough that it is only believed from a GitHub-ish host. * * Nothing here tries to tell a lookalike hostname from a real one — `github.com.evil.test` and * the rest are an open set, and blocking spellings of it costs real hosts (`gitlab.com.br` is a * registrable domain). What a claim is worth is decided where it is used. */ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null { let url: URL; try { url = new URL(targetUrl); } catch { return null; } // `javascript:`, `mailto:` and friends have no host to speak of and nothing to open. if (url.protocol !== "https:" && url.protocol !== "http:") return null; const host = url.hostname.toLowerCase(); // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} if (isHostOf(host, "github.com", "github")) { const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); return claim(host, match); } // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/` // separator is GitLab's own, so the hostname is not asked about. const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); if (gitlab) return claim(host, gitlab); // Bitbucket Cloud: /{workspace}/{repo}/pull-requests/{n} if (isHostOf(host, "bitbucket.org", "bitbucket")) { const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); return claim(host, match); } // Azure DevOps, both the current host and the per-organisation one it replaced. `_git` is part // of the repository path there, as it is in the remote URL the identity is read from. if (isHostOf(host, "dev.azure.com") || host.endsWith(".visualstudio.com")) { const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); return claim(host, match); } return null; } function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { const repository = match?.[1]; const number = Number(match?.[2]); return repository && Number.isSafeInteger(number) && number > 0 ? { host, repository: repository.toLowerCase(), number } : null; } /** The web URL a host writes for a change request; null when the host shape is unknown. */ export function changeRequestUrlFor( kind: string | null | undefined, host: string, repository: string, number: number, ): string | null { switch (kind) { case "github": return `https://${host}/${repository}/pull/${number}`; case "gitlab": return `https://${host}/${repository}/-/merge_requests/${number}`; case "bitbucket": return `https://${host}/${repository}/pull-requests/${number}`; case "azure-devops": return `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`; default: return null; } } /** Builds a GitHub URL that remains available when the pull request API cannot be read. */ export function gitHubPullRequestBrowserUrl( identity: RepositoryIdentity | null | undefined, repository: string, number: number, ): string | null { if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null; const repositoryPath = repository.split("/"); if ( repositoryPath.length !== 2 || repositoryPath.some((segment) => segment.length === 0 || segment === "." || segment === "..") ) { return null; } let origin: string | null = null; try { const remoteUrl = new URL(identity.locator.remoteUrl.trim()); if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") { origin = remoteUrl.origin; } } catch { // SCP-style remotes are read from their normalized identity below. } const hostname = identity.canonicalKey.split("/")[0]; if (origin === null && !hostname) return null; try { const url = new URL(origin ?? `https://${hostname}`); url.pathname = `/${repositoryPath.join("/")}/pull/${number}`; return url.toString(); } catch { return null; } } /** * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare * reference through `/issues/`, including pull requests, so this only builds a candidate: the * caller must successfully read it as a pull request before treating it as one. */ export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null { let url: URL; try { url = new URL(targetUrl); } catch { return null; } if ( (url.protocol !== "https:" && url.protocol !== "http:") || !( url.hostname.toLowerCase() === "github.com" || url.hostname.toLowerCase().endsWith(".github.com") || url.hostname.toLowerCase().split(".").includes("github") ) ) { return null; } const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname); if (match?.[1] === undefined || match[2] === undefined) return null; url.pathname = `/${match[1]}/pull/${match[2]}`; return url.toString(); } /** Match a stored PR without requiring its project to remain available. */ export function matchesLinkedPullRequestUrl( linkedPullRequest: ThreadLinkedPullRequest, targetUrl: string, ): boolean { const linked = parseChangeRequestUrl(linkedPullRequest.url); const target = parseChangeRequestUrl(targetUrl); return ( linked !== null && target !== null && linked.host === target.host && linked.repository === target.repository && linked.number === target.number ); } /** The repository root behind a recognised change-request URL, without PR-specific state. */ export function changeRequestRepositoryUrl(targetUrl: string): string | null { const changeRequest = parseChangeRequestUrl(targetUrl); if (changeRequest === null) return null; const url = new URL(targetUrl); const repositoryPath = /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ?? /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec( url.pathname, )?.[1]; if (!repositoryPath) return null; url.pathname = repositoryPath; url.search = ""; url.hash = ""; return url.toString(); } export function siblingPullRequestUrl(url: string, number: number): string | null { const reference = parseChangeRequestUrl(url); if (reference === null || !Number.isSafeInteger(number) || number < 1) return null; const sibling = new URL(url); const route = /^\/(-\/merge_requests|pull|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec( sibling.pathname.slice(reference.repository.length + 1), )?.[1]; if (route === undefined) return null; sibling.pathname = `/${reference.repository}/${route}/${number}`; sibling.search = ""; sibling.hash = ""; return sibling.toString(); }