210 lines · 8.2 KB · TypeScript
RawDownload
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();}