1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 changeRequestRepositoryUrl,
5 findProjectForChangeRequest,
6 findProjectOnChangeRequestHost,
7 gitHubPullRequestBrowserUrl,
8 matchesLinkedPullRequestUrl,
9 parseChangeRequestUrl,
10 pullRequestCandidateUrlFromReferenceAutolink,
11 shouldOpenPullRequestExternally,
12} from "./openPullRequestLink";
13import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts";
14import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
15
16function repositoryIdentity(
17 provider: string,
18 canonicalKey: string,
19 remoteUrl: string,
20): RepositoryIdentity {
21 return {
22 canonicalKey,
23 provider,
24 locator: { source: "git-remote", remoteName: "origin", remoteUrl },
25 };
26}
27
28describe("gitHubPullRequestBrowserUrl", () => {
29 it("uses the requested GitHub repository instead of the project's default repository", () => {
30 const identity = repositoryIdentity(
31 "github",
32 "github.com/acme/default",
33 "https://github.com/acme/default.git",
34 );
35
36 expect(gitHubPullRequestBrowserUrl(identity, "acme/other", 42)).toBe(
37 "https://github.com/acme/other/pull/42",
38 );
39 });
40
41 it("preserves a custom GitHub HTTP origin without its credentials", () => {
42 const identity = repositoryIdentity(
43 "github",
44 "github.acme.test/team/default",
45 "http://token@github.acme.test:8443/team/default.git",
46 );
47
48 expect(gitHubPullRequestBrowserUrl(identity, "platform/api", 7)).toBe(
49 "http://github.acme.test:8443/platform/api/pull/7",
50 );
51 });
52
53 it.each([
54 {
55 name: "SSH",
56 remoteUrl: "git@github.acme.test:team/default.git",
57 },
58 {
59 name: "git protocol",
60 remoteUrl: "git://github.acme.test/team/default.git",
61 },
62 ])("uses the normalized host for a $name remote", ({ remoteUrl }) => {
63 const identity = repositoryIdentity("github", "github.acme.test/team/default", remoteUrl);
64
65 expect(gitHubPullRequestBrowserUrl(identity, "platform/api", 9)).toBe(
66 "https://github.acme.test/platform/api/pull/9",
67 );
68 });
69
70 it("returns null for missing or invalid GitHub data", () => {
71 expect(gitHubPullRequestBrowserUrl(null, "acme/repository", 1)).toBeNull();
72 expect(
73 gitHubPullRequestBrowserUrl(
74 repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"),
75 "acme",
76 1,
77 ),
78 ).toBeNull();
79 expect(
80 gitHubPullRequestBrowserUrl(
81 repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"),
82 "../repository",
83 1,
84 ),
85 ).toBeNull();
86 expect(
87 gitHubPullRequestBrowserUrl(
88 repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"),
89 "acme/repository",
90 0,
91 ),
92 ).toBeNull();
93 expect(
94 gitHubPullRequestBrowserUrl(
95 repositoryIdentity("github", "bad host/acme/repository", "not a remote"),
96 "acme/repository",
97 1,
98 ),
99 ).toBeNull();
100 });
101
102 it.each(["gitlab", "bitbucket", "azure-devops", "unknown"])(
103 "does not build a fallback for %s",
104 (provider) => {
105 expect(
106 gitHubPullRequestBrowserUrl(
107 repositoryIdentity(provider, "github.com/acme/repository", "https://github.com/a/b"),
108 "acme/repository",
109 1,
110 ),
111 ).toBeNull();
112 },
113 );
114});
115
116describe("changeRequestRepositoryUrl", () => {
117 it("preserves repository path casing", () => {
118 expect(
119 changeRequestRepositoryUrl(
120 "https://gitlab.example.test/Team/Platform/Repo/-/merge_requests/42/diffs#note_1",
121 ),
122 ).toBe("https://gitlab.example.test/Team/Platform/Repo");
123 });
124
125 it("keeps pull-like segments inside nested GitLab repository paths", () => {
126 expect(
127 changeRequestRepositoryUrl(
128 "https://gitlab.example.test/group/pull/123/repo/-/merge_requests/42",
129 ),
130 ).toBe("https://gitlab.example.test/group/pull/123/repo");
131 });
132});
133
134describe("pullRequestCandidateUrlFromReferenceAutolink", () => {
135 it("turns GitHub's shared issue route into a pull request candidate", () => {
136 expect(
137 pullRequestCandidateUrlFromReferenceAutolink(
138 "https://github.com/pingdotgg/t3code/issues/8600#issuecomment-1",
139 ),
140 ).toBe("https://github.com/pingdotgg/t3code/pull/8600#issuecomment-1");
141 });
142
143 it("does not reinterpret other issue hosts or malformed references", () => {
144 expect(
145 pullRequestCandidateUrlFromReferenceAutolink(
146 "https://gitlab.com/pingdotgg/t3code/-/issues/8600",
147 ),
148 ).toBeNull();
149 expect(
150 pullRequestCandidateUrlFromReferenceAutolink(
151 "https://github.com/pingdotgg/t3code/issues/not-a-number",
152 ),
153 ).toBeNull();
154 });
155});
156
157describe("matchesLinkedPullRequestUrl", () => {
158 const linkedPullRequest = {
159 projectId: ProjectId.make("project-1"),
160 repository: "pingdotgg/t3code",
161 number: 42,
162 url: "https://github.com/pingdotgg/t3code/pull/42",
163 };
164
165 it("matches the same pull request without looking up its project", () => {
166 expect(
167 matchesLinkedPullRequestUrl(
168 linkedPullRequest,
169 "https://github.com/PingDotGG/T3Code/pull/42/files",
170 ),
171 ).toBe(true);
172 });
173
174 it.each([
175 ["http://forge.example:3000/git/team/repo/pulls/42/files", true],
176 ["http://forge.example:4000/git/team/repo/pulls/42", false],
177 ["http://forge.example/git/team/repo/pulls/42", false],
178 ])("matches Forgejo links by web authority: %s", (url, expected) => {
179 expect(
180 matchesLinkedPullRequestUrl(
181 { ...linkedPullRequest, url: "http://forge.example:3000/git/team/repo/pulls/42" },
182 url,
183 ),
184 ).toBe(expected);
185 });
186
187 it("keeps other providers' existing port normalization", () => {
188 expect(
189 matchesLinkedPullRequestUrl(
190 linkedPullRequest,
191 "https://github.com:8443/pingdotgg/t3code/pull/42",
192 ),
193 ).toBe(true);
194 });
195
196 it("keeps Forgejo and GitHub path shapes distinct on a GitHub-named host", () => {
197 expect(
198 matchesLinkedPullRequestUrl(
199 { ...linkedPullRequest, url: "https://github.internal/team/repo/pulls/42" },
200 "https://github.internal/team/repo/pull/42",
201 ),
202 ).toBe(false);
203 });
204
205 it("rejects a different pull request or host", () => {
206 expect(
207 matchesLinkedPullRequestUrl(linkedPullRequest, "https://github.com/pingdotgg/t3code/pull/43"),
208 ).toBe(false);
209 expect(
210 matchesLinkedPullRequestUrl(
211 linkedPullRequest,
212 "https://github.example.com/pingdotgg/t3code/pull/42",
213 ),
214 ).toBe(false);
215 });
216});
217
218describe("shouldOpenPullRequestExternally", () => {
219 it("uses the browser for command-click and control-click", () => {
220 expect(shouldOpenPullRequestExternally({ metaKey: true, ctrlKey: false })).toBe(true);
221 expect(shouldOpenPullRequestExternally({ metaKey: false, ctrlKey: true })).toBe(true);
222 });
223
224 it("keeps an unmodified click in the pull request view", () => {
225 expect(shouldOpenPullRequestExternally({ metaKey: false, ctrlKey: false })).toBe(false);
226 });
227});
228
229describe("findProjectOnChangeRequestHost", () => {
230 const project = (id: string, identity: Record<string, unknown>) =>
231 ({ id, repositoryIdentity: identity }) as never;
232 const frontend = project("frontend", {
233 canonicalKey: "github.com/acme/frontend",
234 provider: "github",
235 owner: "acme",
236 name: "frontend",
237 });
238 const backend = project("backend", {
239 canonicalKey: "github.com/acme/backend",
240 provider: "github",
241 owner: "acme",
242 name: "backend",
243 });
244
245 it.each([
246 "ssh.dev.azure.com/v3/org-a/project/web",
247 "vs-ssh.visualstudio.com/v3/org-a/project/web",
248 "org-a.visualstudio.com/defaultcollection/project/_git/web",
249 "dev.azure.com/org-a/project/_git/web",
250 ])("matches Azure browser references against %s", (canonicalKey) => {
251 const checkout = project("azure", {
252 canonicalKey,
253 provider: "azure-devops",
254 displayName: canonicalKey.split("/").slice(1).join("/"),
255 });
256 const reference = { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 42 };
257 expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout);
258 expect(findProjectOnChangeRequestHost([checkout], reference)).toBe(checkout);
259 expect(
260 findProjectOnChangeRequestHost([checkout], {
261 ...reference,
262 repository: "org-b/project/_git/web",
263 }),
264 ).toBeUndefined();
265 expect(
266 findProjectOnChangeRequestHost([checkout], {
267 ...reference,
268 repository: "org-a/other-project/_git/web",
269 }),
270 ).toBeUndefined();
271 });
272
273 it("prefers the project checked out from the link's own repository", () => {
274 expect(
275 findProjectOnChangeRequestHost([frontend, backend], {
276 host: "github.com",
277 repository: "acme/backend",
278 number: 7,
279 }),
280 ).toBe(backend);
281 });
282
283 it("selects the Forgejo HTTP port for repository and host-level matches", () => {
284 const projects = [3000, 4000].map((port) =>
285 project(`forgejo-${port}`, {
286 canonicalKey: "forge.example/git/team/repo",
287 provider: "forgejo",
288 displayName: "git/team/repo",
289 locator: { remoteUrl: `http://forge.example:${port}/git/team/repo.git` },
290 }),
291 );
292 const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!;
293 expect(findProjectForChangeRequest(projects, reference)).toBe(projects[1]);
294 expect(findProjectOnChangeRequestHost(projects, reference)).toBe(projects[1]);
295 expect(
296 findProjectOnChangeRequestHost(projects, { ...reference, repository: "git/team/other" }),
297 ).toBe(projects[1]);
298 expect(findProjectForChangeRequest([projects[0]!], reference)).toBeUndefined();
299 expect(findProjectOnChangeRequestHost([projects[0]!], reference)).toBeUndefined();
300 });
301
302 it("lets tea resolve the web port for Forgejo SSH remotes", () => {
303 const checkout = project("forgejo-ssh", {
304 canonicalKey: "forge.example/git/team/repo",
305 provider: "forgejo",
306 displayName: "git/team/repo",
307 locator: { remoteUrl: "git@forge.example:git/team/repo.git" },
308 });
309 const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!;
310 expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout);
311 const aliased = project("forgejo-alias", {
312 canonicalKey: "ssh.forge.example/team/repo",
313 provider: "forgejo",
314 displayName: "team/repo",
315 locator: { remoteUrl: "git@ssh.forge.example:team/repo.git" },
316 webUrl: "http://forge.example:4000/git/team/repo",
317 });
318 expect(findProjectForChangeRequest([aliased], reference)).toBe(aliased);
319 expect(findProjectOnChangeRequestHost([aliased], reference)).toBe(aliased);
320 expect(
321 findProjectOnChangeRequestHost([aliased], { ...reference, repository: "git/team/other" }),
322 ).toBe(aliased);
323 for (const url of [
324 "http://other.example:4000/git/team/repo/pulls/42",
325 "http://forge.example:3000/git/team/repo/pulls/42",
326 ]) {
327 const other = parseChangeRequestUrl(url)!;
328 expect(findProjectForChangeRequest([aliased], other)).toBeUndefined();
329 expect(findProjectOnChangeRequestHost([aliased], other)).toBeUndefined();
330 }
331 const otherMount = parseChangeRequestUrl("http://forge.example:4000/other/team/repo/pulls/42")!;
332 expect(findProjectForChangeRequest([aliased], otherMount)).toBeUndefined();
333 expect(findProjectOnChangeRequestHost([aliased], otherMount)).toBeUndefined();
334 });
335
336 it("lends any project on the host to a repository nobody has checked out", () => {
337 expect(
338 findProjectOnChangeRequestHost([frontend], {
339 host: "github.com",
340 repository: "acme/backend",
341 number: 7,
342 }),
343 ).toBe(frontend);
344 });
345
346 it("finds nothing on a host nothing is checked out from", () => {
347 expect(
348 findProjectOnChangeRequestHost([frontend], {
349 host: "gitlab.com",
350 repository: "acme/backend",
351 number: 7,
352 }),
353 ).toBeUndefined();
354 });
355});
356
357describe("findProjectForChangeRequest", () => {
358 const project = (identity: Record<string, unknown>) =>
359 ({ id: "p1", repositoryIdentity: identity }) as never;
360
361 it("matches a nested GitLab group by the whole path below the host", () => {
362 // The server identifies a repository by `displayName`, which keeps every group segment; the
363 // two-segment owner/name form would look for `t3tools/t3code` and find nothing.
364 const projects = [
365 project({
366 canonicalKey: "gitlab.com/t3tools/platform/t3code",
367 provider: "gitlab",
368 displayName: "t3tools/platform/t3code",
369 owner: "t3tools",
370 name: "t3code",
371 }),
372 ];
373 expect(
374 findProjectForChangeRequest(projects, {
375 host: "gitlab.com",
376 repository: "t3tools/platform/t3code",
377 number: 42,
378 }),
379 ).toBe(projects[0]);
380 });
381
382 it("keeps two hosts apart, so an Enterprise link does not open the public one", () => {
383 const projects = [
384 project({
385 canonicalKey: "github.com/pingdotgg/t3code",
386 provider: "github",
387 owner: "pingdotgg",
388 name: "t3code",
389 }),
390 ];
391 expect(
392 findProjectForChangeRequest(projects, {
393 host: "github.acme.test",
394 repository: "pingdotgg/t3code",
395 number: 1,
396 }),
397 ).toBeUndefined();
398 });
399
400 it("matches an Azure repository cloned over SSH, whose remote shares no part with its URL", () => {
401 // Azure alone addresses one repository under two names: `ssh.dev.azure.com` and `v3/...` over
402 // SSH against `dev.azure.com` and `.../_git/...` everywhere a person sees it. The identity is
403 // recorded in the spelling a link arrives in, so both halves of this comparison line up.
404 //
405 // Derived from the SSH remote the way the server derives it rather than written out, so the
406 // day that normalization stops reaching the web spelling this fails here too.
407 const canonicalKey = normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code");
408 const projects = [
409 project({
410 canonicalKey,
411 provider: "azure-devops",
412 displayName: canonicalKey.split("/").slice(1).join("/"),
413 owner: "t3tools",
414 name: "t3code",
415 }),
416 ];
417 expect(
418 findProjectForChangeRequest(projects, {
419 host: "dev.azure.com",
420 repository: "t3tools/platform/_git/t3code",
421 number: 1,
422 }),
423 ).toBe(projects[0]);
424 });
425
426 it("claims nothing for a lookalike host, which is what keeps a link a link", () => {
427 const projects = [
428 project({
429 canonicalKey: "github.com/pingdotgg/t3code",
430 provider: "github",
431 owner: "pingdotgg",
432 name: "t3code",
433 }),
434 ];
435 expect(
436 findProjectForChangeRequest(projects, {
437 host: "github.com-evil.test",
438 repository: "pingdotgg/t3code",
439 number: 1,
440 }),
441 ).toBeUndefined();
442 });
443});