1import { SourceControlProviderKind } from "@t3tools/contracts";
2import type { RepositoryIdentity, SourceControlProviderInfo } from "@t3tools/contracts";
3
4export interface ChangeRequestPresentation {
5 readonly icon:
6 | "github"
7 | "gitlab"
8 | "forgejo"
9 | "azure-devops"
10 | "bitbucket"
11 | "gitcafe"
12 | "change-request";
13 readonly providerName: string;
14 readonly shortName: string;
15 readonly longName: string;
16 readonly pluralLongName: string;
17 readonly providerLongName: string;
18 readonly checkoutCommandExample?: string;
19 readonly urlExample: string;
20}
21
22export interface ChangeRequestTerminology {
23 readonly shortLabel: string;
24 readonly singular: string;
25}
26
27const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
28 icon: "github",
29 providerName: "GitHub",
30 shortName: "PR",
31 longName: "pull request",
32 pluralLongName: "pull requests",
33 providerLongName: "GitHub pull request",
34 checkoutCommandExample: "gh pr checkout 123",
35 urlExample: "https://github.com/owner/repo/pull/42",
36};
37
38const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
39 icon: "gitlab",
40 providerName: "GitLab",
41 shortName: "MR",
42 longName: "merge request",
43 pluralLongName: "merge requests",
44 providerLongName: "GitLab merge request",
45 checkoutCommandExample: "glab mr checkout 123",
46 urlExample: "https://gitlab.com/group/project/-/merge_requests/42",
47};
48
49const FORGEJO_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
50 icon: "forgejo",
51 providerName: "Forgejo",
52 shortName: "PR",
53 longName: "pull request",
54 pluralLongName: "pull requests",
55 providerLongName: "Forgejo pull request",
56 checkoutCommandExample: "tea pr checkout 123",
57 urlExample: "https://codeberg.org/owner/repo/pulls/42",
58};
59
60const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
61 icon: "azure-devops",
62 providerName: "Azure DevOps",
63 shortName: "PR",
64 longName: "pull request",
65 pluralLongName: "pull requests",
66 providerLongName: "Azure DevOps pull request",
67 checkoutCommandExample: "az repos pr checkout --id 123",
68 urlExample: "https://dev.azure.com/org/project/_git/repo/pullrequest/42",
69};
70
71const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
72 icon: "bitbucket",
73 providerName: "Bitbucket",
74 shortName: "PR",
75 longName: "pull request",
76 pluralLongName: "pull requests",
77 providerLongName: "Bitbucket pull request",
78 urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42",
79};
80
81const GITCAFE_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
82 icon: "gitcafe",
83 providerName: "GitCafe",
84 shortName: "PR",
85 longName: "pull request",
86 pluralLongName: "pull requests",
87 providerLongName: "GitCafe pull request",
88 checkoutCommandExample: "cafe pr checkout 123",
89 urlExample: "https://git.cafe/owner/repo/pulls/42",
90};
91
92const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
93 icon: "change-request",
94 providerName: "source control",
95 shortName: "change request",
96 longName: "change request",
97 pluralLongName: "change requests",
98 providerLongName: "change request",
99 urlExample: "#42",
100};
101
102export function resolveChangeRequestPresentation(
103 provider: SourceControlProviderInfo | null | undefined,
104): ChangeRequestPresentation {
105 switch (provider?.kind) {
106 case "github":
107 case undefined:
108 return GITHUB_CHANGE_REQUEST_PRESENTATION;
109 case "gitlab":
110 return GITLAB_CHANGE_REQUEST_PRESENTATION;
111 case "forgejo":
112 return FORGEJO_CHANGE_REQUEST_PRESENTATION;
113 case "azure-devops":
114 return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION;
115 case "bitbucket":
116 return BITBUCKET_CHANGE_REQUEST_PRESENTATION;
117 case "gitcafe":
118 return GITCAFE_CHANGE_REQUEST_PRESENTATION;
119 default:
120 return GENERIC_CHANGE_REQUEST_PRESENTATION;
121 }
122}
123
124function resolveChangeRequestPresentationForKind(
125 kind: SourceControlProviderKind,
126): ChangeRequestPresentation {
127 return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" });
128}
129
130export function getChangeRequestTerminologyForKind(
131 kind: SourceControlProviderKind,
132): ChangeRequestTerminology {
133 const presentation = resolveChangeRequestPresentationForKind(kind);
134 return {
135 shortLabel: presentation.shortName,
136 singular: presentation.longName,
137 };
138}
139
140const SCP_SSH_REMOTE_PATTERN = /^[a-zA-Z0-9._-]+@([^:/]+):/;
141
142export function isSshRemoteUrl(remoteUrl: string): boolean {
143 const trimmed = remoteUrl.trim();
144 return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://");
145}
146
147/**
148 * Extracts the normalized host used for provider detection. SCP-style and SSH remotes return the
149 * hostname only, while other URL schemes retain explicit ports for non-default web endpoints.
150 */
151function parseRemoteHost(remoteUrl: string): string | null {
152 const trimmed = remoteUrl.trim();
153 if (trimmed.length === 0) {
154 return null;
155 }
156
157 const scpMatch = SCP_SSH_REMOTE_PATTERN.exec(trimmed);
158 if (scpMatch?.[1]) {
159 return scpMatch[1].toLowerCase();
160 }
161
162 try {
163 const url = new URL(trimmed);
164 return (url.protocol === "ssh:" ? url.hostname : url.host).toLowerCase();
165 } catch {
166 return null;
167 }
168}
169
170function parseHostName(host: string): string {
171 try {
172 return new URL(`https://${host}`).hostname.toLowerCase();
173 } catch {
174 return host.replace(/:\d+$/u, "").toLowerCase();
175 }
176}
177
178function toBaseUrl(host: string): string {
179 return `https://${host}`;
180}
181
182function hasDnsLabel(host: string, label: string): boolean {
183 return host.split(".").includes(label);
184}
185
186function isGitHubHost(host: string): boolean {
187 return host === "github.com" || hasDnsLabel(host, "github");
188}
189
190function isGitLabHost(host: string): boolean {
191 return host === "gitlab.com" || hasDnsLabel(host, "gitlab");
192}
193
194function isAzureDevOpsHost(host: string): boolean {
195 // `ssh.dev.azure.com` is the default Azure DevOps SSH clone host
196 // (git@ssh.dev.azure.com:v3/org/project/repo), so match any `*.dev.azure.com`
197 // subdomain, not just the bare `dev.azure.com`. Legacy hosts stay under
198 // `.visualstudio.com` (including `vs-ssh.visualstudio.com`).
199 return (
200 host === "dev.azure.com" ||
201 host.endsWith(".dev.azure.com") ||
202 host.endsWith(".visualstudio.com")
203 );
204}
205
206/** GitCafe has no self-hosted installs: production and staging are the only hosts. */
207function isGitCafeHost(host: string): boolean {
208 return host === "git.cafe" || host === "staging.git.cafe";
209}
210
211function isBitbucketHost(host: string): boolean {
212 return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket");
213}
214
215export function detectSourceControlProviderFromRemoteUrl(
216 remoteUrl: string,
217): SourceControlProviderInfo | null {
218 const host = parseRemoteHost(remoteUrl);
219 if (!host) {
220 return null;
221 }
222 const hostname = parseHostName(host);
223
224 if (isGitCafeHost(hostname)) {
225 return {
226 kind: SourceControlProviderKind.make("gitcafe"),
227 name: "GitCafe",
228 baseUrl: toBaseUrl(hostname),
229 };
230 }
231
232 if (
233 hostname === "codeberg.org" ||
234 hasDnsLabel(hostname, "forgejo") ||
235 hasDnsLabel(hostname, "gitea")
236 ) {
237 return {
238 kind: SourceControlProviderKind.make("forgejo"),
239 name: "Forgejo",
240 baseUrl: /^https?:/iu.test(remoteUrl.trim())
241 ? new URL(remoteUrl.trim()).origin
242 : toBaseUrl(host),
243 };
244 }
245
246 if (isGitHubHost(hostname)) {
247 return {
248 kind: SourceControlProviderKind.make("github"),
249 name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted",
250 baseUrl: toBaseUrl(host),
251 };
252 }
253
254 if (isGitLabHost(hostname)) {
255 return {
256 kind: SourceControlProviderKind.make("gitlab"),
257 name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted",
258 baseUrl: toBaseUrl(host),
259 };
260 }
261
262 if (isAzureDevOpsHost(hostname)) {
263 return {
264 kind: SourceControlProviderKind.make("azure-devops"),
265 name: "Azure DevOps",
266 baseUrl: toBaseUrl(host),
267 };
268 }
269
270 if (isBitbucketHost(hostname)) {
271 return {
272 kind: SourceControlProviderKind.make("bitbucket"),
273 name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted",
274 baseUrl: toBaseUrl(host),
275 };
276 }
277
278 return {
279 kind: SourceControlProviderKind.make("unknown"),
280 name: host,
281 baseUrl: toBaseUrl(host),
282 };
283}
284
285/**
286 * The provider-native repository selector. `displayName` is the full path below the host, which
287 * is what nested GitLab groups need; owner/name is the two-segment fallback for identities
288 * recorded before that field existed.
289 *
290 * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and
291 * takes the organisation and project from the checkout it detects — so the recorded
292 * `org/project/_git/repo` path is refused outright and the whole repository reads as
293 * unavailable. Its name is the last segment, which is what this hands over.
294 *
295 * One function because everything downstream is keyed by what it answers: the rows' own
296 * `repository`, the per-repository cursors, and the detail and diff reads a row leads to.
297 */
298export function sourceControlRepositorySelector(
299 identity:
300 | Pick<RepositoryIdentity, "provider" | "displayName" | "owner" | "name">
301 | null
302 | undefined,
303): string | null {
304 if (!identity) return null;
305 if (identity.provider === "azure-devops") {
306 const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git");
307 return identity.name || segments.at(-1) || null;
308 }
309 if (identity.displayName) return identity.displayName;
310 return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null;
311}
312
313export function canonicalRepositoryKey(key: string): string {
314 return key
315 .replace(
316 /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u,
317 "dev.azure.com/$1/$2/_git/$3",
318 )
319 .replace(
320 /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u,
321 "dev.azure.com/$1/$2/_git/$3",
322 );
323}