| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | |
| 3 | import { |
| 4 | changeRequestRepositoryUrl, |
| 5 | findProjectForChangeRequest, |
| 6 | findProjectOnChangeRequestHost, |
| 7 | gitHubPullRequestBrowserUrl, |
| 8 | matchesLinkedPullRequestUrl, |
| 9 | parseChangeRequestUrl, |
| 10 | pullRequestCandidateUrlFromReferenceAutolink, |
| 11 | shouldOpenPullRequestExternally, |
| 12 | } from "./openPullRequestLink"; |
| 13 | import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; |
| 14 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; |
| 15 | |
| 16 | function 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 | |
| 28 | describe("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 | |
| 116 | describe("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 | |
| 134 | describe("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 | |
| 157 | describe("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 | |
| 218 | describe("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 | |
| 229 | describe("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 | |
| 357 | describe("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 | }); |