1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 changeRequestUrlFor,
5 parseChangeRequestUrl,
6 pullRequestCandidateUrlFromReferenceAutolink,
7 siblingPullRequestUrl,
8} from "./changeRequestUrl.ts";
9
10describe("parseChangeRequestUrl", () => {
11 it("reads a GitHub pull request, lower-casing the repository", () => {
12 expect(parseChangeRequestUrl("https://github.com/T3Tools/T3Code/pull/123")).toEqual({
13 host: "github.com",
14 repository: "t3tools/t3code",
15 number: 123,
16 });
17 });
18
19 it("reads a pull request on a GitHub Enterprise host", () => {
20 expect(parseChangeRequestUrl("https://github.acme.test/platform/api/pull/7")).toEqual({
21 host: "github.acme.test",
22 repository: "platform/api",
23 number: 7,
24 });
25 });
26
27 it("reads Forgejo URLs even when the hostname contains github", () => {
28 expect(parseChangeRequestUrl("https://github.internal/team/repo/pulls/7")).toEqual({
29 host: "github.internal",
30 authority: "github.internal",
31 repository: "team/repo",
32 number: 7,
33 });
34 });
35
36 it("reads a supported GitHub host with a middle DNS label", () => {
37 expect(parseChangeRequestUrl("https://code.github.example.com/acme/web/pull/42")).toEqual({
38 host: "code.github.example.com",
39 repository: "acme/web",
40 number: 42,
41 });
42 expect(
43 pullRequestCandidateUrlFromReferenceAutolink(
44 "https://code.github.example.com/acme/web/issues/42",
45 ),
46 ).toBe("https://code.github.example.com/acme/web/pull/42");
47 });
48
49 it("reads a GitLab merge request on any host, nested groups and all", () => {
50 expect(
51 parseChangeRequestUrl("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42"),
52 ).toEqual({ host: "gitlab.com", repository: "t3tools/platform/t3code", number: 42 });
53 expect(parseChangeRequestUrl("https://code.acme.test/team/project/-/merge_requests/9")).toEqual(
54 { host: "code.acme.test", repository: "team/project", number: 9 },
55 );
56 });
57
58 it("reads Bitbucket and both Azure DevOps URL forms", () => {
59 expect(parseChangeRequestUrl("https://bitbucket.org/workspace/repo/pull-requests/5")).toEqual({
60 host: "bitbucket.org",
61 repository: "workspace/repo",
62 number: 5,
63 });
64 expect(
65 parseChangeRequestUrl("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17"),
66 ).toEqual({ host: "dev.azure.com", repository: "acme/platform/_git/t3code", number: 17 });
67 expect(
68 parseChangeRequestUrl("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17"),
69 ).toEqual({ host: "acme.visualstudio.com", repository: "platform/_git/t3code", number: 17 });
70 });
71
72 it("survives trailing segments, a trailing slash and a query string", () => {
73 expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/files?w=1")).toEqual({
74 host: "github.com",
75 repository: "t3tools/t3code",
76 number: 123,
77 });
78 expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/")).toEqual({
79 host: "github.com",
80 repository: "t3tools/t3code",
81 number: 123,
82 });
83 });
84
85 it("claims nothing it cannot be sure of", () => {
86 for (const link of [
87 "https://github.com/t3tools/t3code/issues/123",
88 "https://github.com/t3tools/t3code/commit/0a1b2c3",
89 "https://github.com/t3tools/t3code",
90 "https://github.com/t3tools/t3code/pull/abc",
91 "https://gitlab.com/t3tools/t3code/-/issues/12",
92 "https://blog.example.test/2026/updates/pull/3",
93 "javascript:alert(1)//github.com/t3tools/t3code/pull/1",
94 "not a url",
95 ]) {
96 expect(parseChangeRequestUrl(link), link).toBeNull();
97 }
98 });
99});
100
101describe("siblingPullRequestUrl", () => {
102 it("recognizes Forgejo on custom HTTP hosts", () => {
103 expect(parseChangeRequestUrl("http://git.example.test:3000/team/repo/pulls/42/files")).toEqual({
104 host: "git.example.test",
105 authority: "git.example.test:3000",
106 repository: "team/repo",
107 number: 42,
108 });
109 });
110 it.each([
111 [
112 "http://git.example.test:3000/team/repo/pulls/42/files",
113 "http://git.example.test:3000/team/repo/pulls/43",
114 ],
115 [
116 "https://git.example.test/forgejo/team/repo/pulls/42/files",
117 "https://git.example.test/forgejo/team/repo/pulls/43",
118 ],
119 ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"],
120 [
121 "https://git.acme.test/team/merge_requests/1/repo/-/merge_requests/42/diffs",
122 "https://git.acme.test/team/merge_requests/1/repo/-/merge_requests/43",
123 ],
124
125 ["https://github.com/acme/web/pull/42#discussion_r123", "https://github.com/acme/web/pull/43"],
126 ["https://github.com/acme/web/pull/42/files?w=1", "https://github.com/acme/web/pull/43"],
127 [
128 "https://github.acme.test:8443/acme/web/pull/42/",
129 "https://github.acme.test:8443/acme/web/pull/43",
130 ],
131 [
132 "https://git.acme.test/acme/web/-/merge_requests/42/diffs",
133 "https://git.acme.test/acme/web/-/merge_requests/43",
134 ],
135 [
136 "https://bitbucket.org/acme/web/pull-requests/42",
137 "https://bitbucket.org/acme/web/pull-requests/43",
138 ],
139 [
140 "https://dev.azure.com/acme/project/_git/web/pullrequest/42?view=files",
141 "https://dev.azure.com/acme/project/_git/web/pullrequest/43",
142 ],
143 ])("builds a canonical sibling of %s", (url, expected) => {
144 expect(siblingPullRequestUrl(url, 43)).toBe(expected);
145 });
146 it("rejects non-PR URLs and invalid numbers", () => {
147 expect(siblingPullRequestUrl("https://github.com/acme/web/issues/42", 43)).toBeNull();
148 expect(siblingPullRequestUrl("https://github.com/acme/web/pull/42", 0)).toBeNull();
149 });
150});
151
152describe("changeRequestUrlFor", () => {
153 it("preserves the origin when the Forgejo host already contains its port", () => {
154 expect(
155 changeRequestUrlFor(
156 "forgejo",
157 "forge.example:3000",
158 "team/repo",
159 42,
160 "http://forge.example:3000/team/repo.git",
161 ),
162 ).toBe("http://forge.example:3000/team/repo/pulls/42");
163 });
164
165 it.each([
166 ["http://forge.example:3000/git/owner/repo.git", "http://forge.example:3000"],
167 ["https://forge.example:8443/git/owner/repo.git", "https://forge.example:8443"],
168 ["git@forge.example:git/owner/repo.git", "https://forge.example"],
169 ["http://other.example:3000/git/owner/repo.git", "https://forge.example"],
170 ])("preserves the matching Forgejo web origin from %s", (remoteUrl, origin) => {
171 expect(changeRequestUrlFor("forgejo", "forge.example", "git/owner/repo", 42, remoteUrl)).toBe(
172 `${origin}/git/owner/repo/pulls/42`,
173 );
174 });
175
176 it.each([
177 ["ssh.dev.azure.com", "v3/org/project/web"],
178 ["vs-ssh.visualstudio.com", "v3/org/project/web"],
179 ["org.visualstudio.com", "defaultcollection/project/_git/web"],
180 ["dev.azure.com", "org/project/_git/web"],
181 ])("builds a browser URL from the Azure remote %s/%s", (host, repository) => {
182 const url = changeRequestUrlFor("azure-devops", host, repository, 42);
183 expect(url).toBe("https://dev.azure.com/org/project/_git/web/pullrequest/42");
184 expect(parseChangeRequestUrl(url!)).toEqual({
185 host: "dev.azure.com",
186 repository: "org/project/_git/web",
187 number: 42,
188 });
189 });
190});