import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; export interface ChangeRequestPresentation { readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; readonly providerName: string; readonly shortName: string; readonly longName: string; readonly pluralLongName: string; readonly providerLongName: string; readonly checkoutCommandExample?: string; readonly urlExample: string; } export interface ChangeRequestTerminology { readonly shortLabel: string; readonly singular: string; } export const DEFAULT_CHANGE_REQUEST_TERMINOLOGY: ChangeRequestTerminology = { shortLabel: "PR", singular: "pull request", }; const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "github", providerName: "GitHub", shortName: "PR", longName: "pull request", pluralLongName: "pull requests", providerLongName: "GitHub pull request", checkoutCommandExample: "gh pr checkout 123", urlExample: "https://github.com/owner/repo/pull/42", }; const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "gitlab", providerName: "GitLab", shortName: "MR", longName: "merge request", pluralLongName: "merge requests", providerLongName: "GitLab merge request", checkoutCommandExample: "glab mr checkout 123", urlExample: "https://gitlab.com/group/project/-/merge_requests/42", }; const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "azure-devops", providerName: "Azure DevOps", shortName: "PR", longName: "pull request", pluralLongName: "pull requests", providerLongName: "Azure DevOps pull request", checkoutCommandExample: "az repos pr checkout --id 123", urlExample: "https://dev.azure.com/org/project/_git/repo/pullrequest/42", }; const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "bitbucket", providerName: "Bitbucket", shortName: "PR", longName: "pull request", pluralLongName: "pull requests", providerLongName: "Bitbucket pull request", urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", }; const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "change-request", providerName: "source control", shortName: "change request", longName: "change request", pluralLongName: "change requests", providerLongName: "change request", urlExample: "#42", }; export function resolveChangeRequestPresentation( provider: SourceControlProviderInfo | null | undefined, ): ChangeRequestPresentation { switch (provider?.kind) { case "github": case undefined: return GITHUB_CHANGE_REQUEST_PRESENTATION; case "gitlab": return GITLAB_CHANGE_REQUEST_PRESENTATION; case "azure-devops": return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; case "bitbucket": return BITBUCKET_CHANGE_REQUEST_PRESENTATION; case "unknown": return GENERIC_CHANGE_REQUEST_PRESENTATION; } } export function resolveChangeRequestPresentationForKind( kind: SourceControlProviderKind, ): ChangeRequestPresentation { return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" }); } export function formatChangeRequestAction( verb: "View" | "Create", presentation: ChangeRequestPresentation, ): string { return `${verb} ${presentation.shortName}`; } export function formatCreateChangeRequestPhrase(presentation: ChangeRequestPresentation): string { return `create ${presentation.shortName}`; } export function getChangeRequestTerminology( provider: SourceControlProviderInfo | null | undefined, ): ChangeRequestTerminology { if (!provider) { return DEFAULT_CHANGE_REQUEST_TERMINOLOGY; } const presentation = resolveChangeRequestPresentation(provider); return { shortLabel: presentation.shortName, singular: presentation.longName, }; } export function getChangeRequestTerminologyForKind( kind: SourceControlProviderKind, ): ChangeRequestTerminology { const presentation = resolveChangeRequestPresentationForKind(kind); return { shortLabel: presentation.shortName, singular: presentation.longName, }; } const SCP_SSH_REMOTE_PATTERN = /^[a-zA-Z0-9._-]+@([^:/]+):/; export function isSshRemoteUrl(remoteUrl: string): boolean { const trimmed = remoteUrl.trim(); return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://"); } function parseRemoteHost(remoteUrl: string): string | null { const trimmed = remoteUrl.trim(); if (trimmed.length === 0) { return null; } const scpMatch = SCP_SSH_REMOTE_PATTERN.exec(trimmed); if (scpMatch?.[1]) { return scpMatch[1].toLowerCase(); } try { return new URL(trimmed).host.toLowerCase(); } catch { return null; } } function parseHostName(host: string): string { try { return new URL(`https://${host}`).hostname.toLowerCase(); } catch { return host.replace(/:\d+$/u, "").toLowerCase(); } } function toBaseUrl(host: string): string { return `https://${host}`; } function hasDnsLabel(host: string, label: string): boolean { return host.split(".").includes(label); } function isGitHubHost(host: string): boolean { return host === "github.com" || hasDnsLabel(host, "github"); } function isGitLabHost(host: string): boolean { return host === "gitlab.com" || hasDnsLabel(host, "gitlab"); } function isAzureDevOpsHost(host: string): boolean { // `ssh.dev.azure.com` is the default Azure DevOps SSH clone host // (git@ssh.dev.azure.com:v3/org/project/repo), so match any `*.dev.azure.com` // subdomain, not just the bare `dev.azure.com`. Legacy hosts stay under // `.visualstudio.com` (including `vs-ssh.visualstudio.com`). return ( host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com") ); } function isBitbucketHost(host: string): boolean { return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); } export function detectSourceControlProviderFromRemoteUrl( remoteUrl: string, ): SourceControlProviderInfo | null { const host = parseRemoteHost(remoteUrl); if (!host) { return null; } const hostname = parseHostName(host); if (isGitHubHost(hostname)) { return { kind: "github", name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted", baseUrl: toBaseUrl(host), }; } if (isGitLabHost(hostname)) { return { kind: "gitlab", name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", baseUrl: toBaseUrl(host), }; } if (isAzureDevOpsHost(hostname)) { return { kind: "azure-devops", name: "Azure DevOps", baseUrl: toBaseUrl(host), }; } if (isBitbucketHost(hostname)) { return { kind: "bitbucket", name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", baseUrl: toBaseUrl(host), }; } return { kind: "unknown", name: host, baseUrl: toBaseUrl(host), }; }