1import { ProjectId, SourceControlProviderKind } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { appSignInTargetForRemote, sourceControlClients } from "./sourceControlClients.ts";
5import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts";
6
7const parse = (url: string) => {
8 const parsed = sourceControlClients.parseChangeRequestUrl(url);
9 if (parsed === null) return null;
10 const { repositoryUrl: _repositoryUrl, ...link } = parsed.link;
11 return link;
12};
13const kindOf = (url: string) => sourceControlClients.parseChangeRequestUrl(url)?.definition.kind;
14
15describe("sourceControlClients.parseChangeRequestUrl", () => {
16 it("reads a GitHub pull request, lower-casing the repository", () => {
17 expect(parse("https://github.com/T3Tools/T3Code/pull/123")).toEqual({
18 host: "github.com",
19 repository: "t3tools/t3code",
20 number: 123,
21 });
22 });
23
24 it("reads a GitCafe pull request at its canonical /pull/ path and the older /pulls/ path", () => {
25 for (const path of ["pull", "pulls"]) {
26 const url = `https://git.cafe/PingDotGG/T3Code/${path}/23897`;
27 expect(parse(url), path).toEqual({
28 host: "git.cafe",
29 repository: "pingdotgg/t3code",
30 number: 23897,
31 });
32 expect(kindOf(url), path).toBe("gitcafe");
33 }
34 expect(parse("https://staging.git.cafe/team/repo/pull/7/files")).toEqual({
35 host: "staging.git.cafe",
36 repository: "team/repo",
37 number: 7,
38 });
39 });
40
41 it("reads a Cursor Origin pull request under Origin's git host, not as GitHub's /pull/", () => {
42 for (const url of [
43 "https://cursor.com/codebase/Acme.Team/Web-App/pull/42",
44 "http://www.cursor.com/codebase/Acme.Team/Web-App/pull/42/files?w=1#diff",
45 ]) {
46 expect(parse(url), url).toEqual({
47 host: "origin.cursor.com",
48 repository: "acme.team/web-app",
49 number: 42,
50 });
51 expect(kindOf(url), url).toBe("cursor-origin");
52 }
53 expect(
54 sourceControlClients.parseChangeRequestUrl("https://cursor.com/codebase/acme/web/pull/42")
55 ?.link.repositoryUrl,
56 ).toBe("https://cursor.com/codebase/acme/web");
57 expect(parse("https://cursor.com/codebase/acme/web")).toBeNull();
58 expect(kindOf("https://www.cursor.com/updates/pull/42")).toBeUndefined();
59 expect(kindOf("https://github.com/acme/web/pull/42")).toBe("github");
60 });
61
62 it("reads Forgejo URLs even when the hostname contains github", () => {
63 expect(parse("https://github.internal/team/repo/pulls/7")).toEqual({
64 host: "github.internal",
65 authority: "github.internal",
66 repository: "team/repo",
67 number: 7,
68 });
69 });
70
71 it("reads a GitHub pull request on any host, so GitHub Enterprise works on its own name", () => {
72 expect(parse("https://code.github.example.com/acme/web/pull/42")).toEqual({
73 host: "code.github.example.com",
74 repository: "acme/web",
75 number: 42,
76 });
77 expect(kindOf("https://scm.example.test/acme/web/pull/42")).toBe("github");
78 expect(
79 sourceControlClients.changeRequestUrlFromReferenceAutolink(
80 "https://code.github.example.com/acme/web/issues/42",
81 ),
82 ).toBe("https://code.github.example.com/acme/web/pull/42");
83 });
84
85 it("reads a GitLab merge request on any host, nested groups and all", () => {
86 expect(parse("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42")).toEqual({
87 host: "gitlab.com",
88 repository: "t3tools/platform/t3code",
89 number: 42,
90 });
91 expect(parse("https://code.acme.test/team/project/-/merge_requests/9")).toEqual({
92 host: "code.acme.test",
93 repository: "team/project",
94 number: 9,
95 });
96 expect(
97 sourceControlClients.changeRequestUrlFromReferenceAutolink(
98 "https://gitlab.com/team/apps/web/-/merge_requests/42",
99 ),
100 ).toBe("https://gitlab.com/team/apps/web/-/merge_requests/42");
101 });
102
103 it("reads Bitbucket and both Azure DevOps URL forms", () => {
104 expect(parse("https://bitbucket.org/workspace/repo/pull-requests/5")).toEqual({
105 host: "bitbucket.org",
106 repository: "workspace/repo",
107 number: 5,
108 });
109 expect(parse("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17")).toEqual({
110 host: "dev.azure.com",
111 repository: "acme/platform/_git/t3code",
112 number: 17,
113 });
114 expect(parse("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17")).toEqual({
115 host: "acme.visualstudio.com",
116 repository: "platform/_git/t3code",
117 number: 17,
118 });
119 });
120
121 it("reads Azure DevOps Server links and keeps servers on different ports distinct", () => {
122 for (const host of ["scm.example.test", "git.cafe", "code.bitbucket.example.test"]) {
123 const repository = "tfs/collection/project/_git/repo";
124 const linked = {
125 projectId: ProjectId.make("project-1"),
126 host,
127 repository,
128 number: 123,
129 url: `https://${host.toUpperCase()}:8443/tfs/Collection/Project/_git/Repo/pullrequest/123`,
130 };
131 const other = { ...linked, url: linked.url.replace(":8443/", ":9443/") };
132 expect.soft(parse(linked.url), host).toEqual({
133 host,
134 authority: `${host}:8443`,
135 repository,
136 number: 123,
137 });
138 expect
139 .soft(
140 sourceControlClients.isSameChangeRequest(linked.url, `${linked.url}?view=files`),
141 host,
142 )
143 .toBe(true);
144 expect
145 .soft(sourceControlClients.isSameChangeRequest(linked.url, other.url), host)
146 .toBe(false);
147 expect.soft([linked, other].map(normalizeThreadPullRequestKey), host).toEqual([
148 { host: `${host}:8443`, repository, number: 123 },
149 { host: `${host}:9443`, repository, number: 123 },
150 ]);
151 }
152 });
153
154 it("survives trailing segments, a trailing slash and a query string", () => {
155 expect(parse("https://github.com/t3tools/t3code/pull/123/files?w=1")).toEqual({
156 host: "github.com",
157 repository: "t3tools/t3code",
158 number: 123,
159 });
160 expect(parse("https://github.com/t3tools/t3code/pull/123/")).toEqual({
161 host: "github.com",
162 repository: "t3tools/t3code",
163 number: 123,
164 });
165 });
166
167 it("claims nothing it cannot be sure of", () => {
168 for (const link of [
169 "https://github.com/t3tools/t3code/issues/123",
170 "https://git.cafe/pingdotgg/t3code/issues/23897",
171 "https://git.cafe/pingdotgg/t3code/pull/abc",
172 "https://git.cafe/pingdotgg/t3code",
173 "https://github.com/t3tools/t3code/commit/0a1b2c3",
174 "https://github.com/t3tools/t3code",
175 "https://github.com/t3tools/t3code/pull/abc",
176 "https://gitlab.com/t3tools/t3code/-/issues/12",
177 "https://blog.example.test/2026/updates/archive/pull/3",
178 "https://scm.example.test/tfs/Collection/Project/_git/Repo",
179 "https://scm.example.test/tfs/Collection/Project/pullrequest/123",
180 "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/abc",
181 "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/0",
182 "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/9007199254740992",
183 "ftp://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/123",
184 "javascript:alert(1)//github.com/t3tools/t3code/pull/1",
185 "not a url",
186 ]) {
187 expect(parse(link), link).toBeNull();
188 }
189 });
190});
191
192describe("sourceControlClients.isSameChangeRequest", () => {
193 const linked = "https://github.com/pingdotgg/t3code/pull/42";
194
195 it("matches the same pull request whatever its case or trailing path", () => {
196 expect(
197 sourceControlClients.isSameChangeRequest(
198 linked,
199 "https://github.com/PingDotGG/T3Code/pull/42/files",
200 ),
201 ).toBe(true);
202 });
203
204 it.each([
205 ["http://forge.example:3000/git/team/repo/pulls/42/files", true],
206 ["http://forge.example:4000/git/team/repo/pulls/42", false],
207 ["http://forge.example/git/team/repo/pulls/42", false],
208 ])("matches Forgejo links by web authority: %s", (url, expected) => {
209 expect(
210 sourceControlClients.isSameChangeRequest(
211 "http://forge.example:3000/git/team/repo/pulls/42",
212 url,
213 ),
214 ).toBe(expected);
215 });
216
217 it("keeps Forgejo and GitHub path shapes distinct on a GitHub-named host", () => {
218 expect(
219 sourceControlClients.isSameChangeRequest(
220 "https://github.internal/team/repo/pulls/42",
221 "https://github.internal/team/repo/pull/42",
222 ),
223 ).toBe(false);
224 });
225});
226
227describe("change request URLs built from a parsed link", () => {
228 const sibling = (url: string, number: number) => {
229 const parsed = sourceControlClients.parseChangeRequestUrl(url)!;
230 return parsed.definition.changeRequestUrl({
231 host: parsed.link.host,
232 repository: parsed.link.repository,
233 number,
234 webUrl: parsed.link.repositoryUrl,
235 });
236 };
237
238 it.each([
239 [
240 "http://git.example.test:3000/team/repo/pulls/42/files",
241 "http://git.example.test:3000/team/repo/pulls/43",
242 ],
243 ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"],
244 [
245 "https://dev.azure.com/acme/project/_git/web/pullrequest/42?view=files",
246 "https://dev.azure.com/acme/project/_git/web/pullrequest/43",
247 ],
248 ])("builds a canonical sibling of %s", (url, expected) => {
249 expect(sibling(url, 43)).toBe(expected);
250 });
251
252 it.each([
253 ["ssh.dev.azure.com", "v3/org/project/web"],
254 ["org.visualstudio.com", "defaultcollection/project/_git/web"],
255 ["dev.azure.com", "org/project/_git/web"],
256 ])("reads back the Azure browser URL built from the remote %s/%s", (host, repository) => {
257 const url = sourceControlClients
258 .get("azure-devops")
259 .changeRequestUrl({ host, repository, number: 42 });
260 expect(parse(url!)).toEqual({
261 host: "dev.azure.com",
262 repository: "org/project/_git/web",
263 number: 42,
264 });
265 });
266});
267
268describe("sourceControlClients.recognizeRemote", () => {
269 const kindOfRemote = (url: string) => sourceControlClients.recognizeRemote(url)?.kind ?? null;
270
271 it("asks hosts in a fixed order when one hostname carries several hosts' labels", () => {
272 expect(kindOfRemote("https://gitea.github.example/o/r")).toBe("forgejo");
273 expect(kindOfRemote("https://forgejo.gitlab.example/o/r")).toBe("forgejo");
274 expect(kindOfRemote("https://gitlab.github.example/o/r")).toBe("github");
275 expect(kindOfRemote("https://bitbucket.gitlab.example/o/r")).toBe("gitlab");
276 expect(kindOfRemote("https://bitbucket.visualstudio.com/p/_git/r")).toBe("azure-devops");
277 expect(kindOfRemote("git@git.cafe:pingdotgg/t3code.git")).toBe("gitcafe");
278 expect(kindOfRemote("https://origin.cursor.com/acme/web.git")).toBe("cursor-origin");
279 });
280
281 it("reads a remote no host claims as an unknown host, and a local path as no host", () => {
282 expect(
283 sourceControlClients.recognizeRemote("https://self-hosted.example.test:8443/group/repo.git"),
284 ).toEqual({
285 kind: "unknown",
286 name: "self-hosted.example.test:8443",
287 baseUrl: "https://self-hosted.example.test:8443",
288 });
289 expect(sourceControlClients.recognizeRemote("git@github-work:owner/repo.git")).toEqual({
290 kind: "unknown",
291 name: "github-work",
292 baseUrl: "https://github-work",
293 });
294 for (const local of ["/srv/git/repo.git", "C:/repos/project", "file:///srv/git/repo.git", ""]) {
295 expect(sourceControlClients.recognizeRemote(local), local).toBeNull();
296 }
297 });
298});
299
300describe("sourceControlClients repository keys", () => {
301 // Persisted as `repositoryIdentity.canonicalKey` and in thread pull request keys.
302 it.each([
303 ["git@github.com:T3Tools/T3Code.git", "github.com/t3tools/t3code"],
304 ["ssh://git@gitlab.company.com:2222/team/project.git", "gitlab.company.com/team/project"],
305 [
306 "git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code",
307 "dev.azure.com/t3tools/platform/_git/t3code",
308 ],
309 [
310 "T3Tools@vs-ssh.visualstudio.com:v3/T3Tools/Platform/T3Code",
311 "t3tools.visualstudio.com/platform/_git/t3code",
312 ],
313 [
314 "https://T3Tools.visualstudio.com/DefaultCollection/Platform/_git/T3Code",
315 "t3tools.visualstudio.com/defaultcollection/platform/_git/t3code",
316 ],
317 ["git@ssh.dev.azure.com:v3/o/p", "ssh.dev.azure.com/v3/o/p"],
318 ["ssh.dev.azure.com/v3/o/p/r", "ssh.dev.azure.com/v3/o/p/r"],
319 ["C:/repos/project", "c:/repos/project"],
320 ])("keys %s as %s", (remote, key) => {
321 expect(sourceControlClients.repositoryKeyFromRemote(remote)).toBe(key);
322 });
323
324 it("compares Azure DevOps spellings on dev.azure.com and leaves other keys alone", () => {
325 expect(
326 sourceControlClients.canonicalRepositoryKey(
327 "org.visualstudio.com/defaultcollection/project/_git/repo",
328 ),
329 ).toBe("dev.azure.com/org/project/_git/repo");
330 expect(sourceControlClients.findByRepositoryKey("dev.azure.com/o/r")?.kind).toBe(
331 "azure-devops",
332 );
333 expect(sourceControlClients.canonicalRepositoryKey("tfs.corp.example/c/p/_git/r")).toBe(
334 "tfs.corp.example/c/p/_git/r",
335 );
336 expect(sourceControlClients.findByRepositoryKey("github.com/o/r")).toBeUndefined();
337 });
338});
339
340describe("sourceControlClients project hosts", () => {
341 const identity = {
342 canonicalKey: "forge.example/team/repo",
343 locator: { remoteUrl: "http://forge.example:3000/team/repo.git" },
344 };
345
346 it("keeps an HTTP remote's port for Forgejo only, and falls back to the kind without a key", () => {
347 expect(sourceControlClients.changeRequestHost(identity, "forgejo")).toBe("forge.example:3000");
348 expect(sourceControlClients.changeRequestHost(identity, "gitlab")).toBe("forge.example");
349 expect(sourceControlClients.changeRequestHost(identity, undefined)).toBe("forge.example");
350 expect(
351 sourceControlClients.changeRequestHost(
352 { locator: { remoteUrl: "https://x.example/a/b" } },
353 "github",
354 ),
355 ).toBe("github");
356 });
357
358 it("selects a repository by its path except where the host's tools take a name", () => {
359 const repository = {
360 displayName: "group/subgroup/service",
361 owner: "group",
362 name: "service",
363 };
364 expect(sourceControlClients.repositorySelector(repository)).toBe("group/subgroup/service");
365 expect(
366 sourceControlClients.repositorySelector({
367 ...repository,
368 provider: SourceControlProviderKind.make("gitlab"),
369 }),
370 ).toBe("group/subgroup/service");
371 expect(
372 sourceControlClients.repositorySelector({
373 ...repository,
374 provider: SourceControlProviderKind.make("azure-devops"),
375 }),
376 ).toBe("service");
377 expect(sourceControlClients.repositorySelector({ owner: "o", name: "r" })).toBe("o/r");
378 });
379});
380
381describe("sourceControlClients.findByPublicDomain", () => {
382 it("names the host a link is on, subdomains included", () => {
383 const kindOf = (hostname: string) => sourceControlClients.findByPublicDomain(hostname)?.kind;
384 expect(kindOf("gist.github.com")).toBe("github");
385 expect(kindOf("GitLab.com")).toBe("gitlab");
386 expect(kindOf("codeberg.org")).toBe("forgejo");
387 expect(kindOf("git.cafe")).toBe("gitcafe");
388 expect(kindOf("notgithub.com")).toBeUndefined();
389 expect(kindOf("github.com.example")).toBeUndefined();
390 });
391
392 it("leaves findByPublicHost to the public host alone", () => {
393 expect(sourceControlClients.findByPublicHost("gist.github.com")).toBeUndefined();
394 });
395});
396
397describe("appSignInTargetForRemote", () => {
398 it("names the repository a GitHub remote points at, in the remote's casing", () => {
399 for (const remote of [
400 "https://github.com/octocat/Hello-World.git",
401 "git@github.com:octocat/Hello-World.git",
402 ]) {
403 expect(appSignInTargetForRemote(remote)?.input).toEqual({
404 provider: "github",
405 host: "github.com",
406 repository: "octocat/Hello-World",
407 });
408 }
409 });
410
411 it("skips hosts the app is not registered on", () => {
412 expect(appSignInTargetForRemote("https://ghe.acme.test/acme/web.git")).toBeNull();
413 expect(appSignInTargetForRemote("https://gitlab.com/acme/web.git")).toBeNull();
414 });
415});