From 0673c02a60f243e7bf32919d9d1a963f34c8e33a Mon Sep 17 00:00:00 2001 From: Leonel Rivas Date: Thu, 1 Oct 2026 09:10:46 -0700 Subject: [PATCH 1/9] fix(shared): recognise Azure DevOps Server pull request links --- .../toolkits/pullRequests/handlers.test.ts | 32 +++++++++++++++++++ .../src/mcp/toolkits/pullRequests/handlers.ts | 7 +++- packages/shared/src/changeRequestUrl.test.ts | 15 +++++++++ packages/shared/src/changeRequestUrl.ts | 16 ++++------ 4 files changed, 60 insertions(+), 10 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 70da749edeab5..6f90b3cfb8af1 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -299,6 +299,38 @@ describe("pull request toolkit handlers", () => { }), ); + it.effect("links an Azure DevOps Server pull request on a host it has no provider for", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + project: makeProject({ + canonicalKey: "scm.example.com/tfs/collection/project/_git/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://scm.example.com/tfs/Collection/Project/_git/Repo", + }, + provider: "unknown", + displayName: "tfs/collection/project/_git/repo", + }), + }); + const supplied = "https://scm.example.com/tfs/Collection/Project/_git/Repo/pullrequest/123"; + const fromUrl = yield* harness.call("link_pull_request", { url: supplied }); + expect(fromUrl).toMatchObject({ + host: "scm.example.com", + repository: "tfs/collection/project/_git/repo", + number: 123, + url: supplied, + }); + const fromNumber = yield* harness.call("link_pull_request", { + repository: "tfs/Collection/Project/_git/Repo", + number: 124, + }); + expect(fromNumber.url).toBe( + "https://scm.example.com/tfs/collection/project/_git/repo/pullrequest/124", + ); + }), + ); + it.effect("rejects a target that names neither a URL nor repository and number", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 80cd49257fbaf..179213ee58077 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -91,7 +91,12 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( repository, input.number, project?.repositoryIdentity?.locator.remoteUrl, - ) ?? `https://${host}/${repository}/pull/${input.number}`; + ) ?? + // An Azure DevOps repository path keeps its `_git` segment on any host, Server included. + (/(?:^|\/)_git\//u.test(repository) + ? changeRequestUrlFor("azure-devops", host, repository, input.number) + : null) ?? + `https://${host}/${repository}/pull/${input.number}`; return { ...normalizeThreadPullRequestKey({ host, repository, number: input.number, url }), url, diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts index 68211c0faf5c1..207574d97c5bf 100644 --- a/packages/shared/src/changeRequestUrl.test.ts +++ b/packages/shared/src/changeRequestUrl.test.ts @@ -69,6 +69,18 @@ describe("parseChangeRequestUrl", () => { ).toEqual({ host: "acme.visualstudio.com", repository: "platform/_git/t3code", number: 17 }); }); + it("reads an Azure DevOps Server pull request on its own domain, collection path included", () => { + expect( + parseChangeRequestUrl( + "https://scm.example.com/tfs/Collection/Project/_git/Repo/pullrequest/123", + ), + ).toEqual({ + host: "scm.example.com", + repository: "tfs/collection/project/_git/repo", + number: 123, + }); + }); + it("survives trailing segments, a trailing slash and a query string", () => { expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/files?w=1")).toEqual({ host: "github.com", @@ -90,6 +102,9 @@ describe("parseChangeRequestUrl", () => { "https://github.com/t3tools/t3code/pull/abc", "https://gitlab.com/t3tools/t3code/-/issues/12", "https://blog.example.test/2026/updates/pull/3", + "https://scm.example.com/tfs/Collection/Project/_git/Repo", + "https://scm.example.com/tfs/Collection/Project/_git/Repo/pullrequest/abc", + "https://scm.example.com/tfs/Collection/Project/pullrequest/123", "javascript:alert(1)//github.com/t3tools/t3code/pull/1", "not a url", ]) { diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 5be8d355a166d..a66636bcad065 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -30,8 +30,8 @@ function isHostOf(hostname: string, apex: string, label?: string): boolean { * * Each host is recognised by the path shape it alone uses, guarded by a hostname it could * plausibly be served from, since self-hosted installs are named whatever their admin chose: - * GitLab's `/-/` marker is unique enough to trust on any hostname, while `/pull/` is generic - * enough that it is only believed from a GitHub-ish host. + * GitLab's `/-/` marker and Azure DevOps' `/_git/…/pullrequest/` are unique enough to trust on + * any hostname, while `/pull/` is generic enough that it is only believed from a GitHub-ish host. * * Nothing here tries to tell a lookalike hostname from a real one — `github.com.evil.test` and * the rest are an open set, and blocking spellings of it costs real hosts (`gitlab.com.br` is a @@ -68,13 +68,11 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); return claim(host, match); } - // Azure DevOps, both the current host and the per-organisation one it replaced. `_git` is part - // of the repository path there, as it is in the remote URL the identity is read from. - if (isHostOf(host, "dev.azure.com") || host.endsWith(".visualstudio.com")) { - const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match); - } - return null; + // Azure DevOps, Azure DevOps Server included, whose collection path comes before the project. + // `_git` is part of the repository path there, as it is in the remote URL the identity is read + // from. + const azure = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); + return azure ? claim(host, azure) : null; } function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { From b3dfddc36785142460bf6106da49f02ee7524b16 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:44:46 +0000 Subject: [PATCH 2/9] test: cover self-hosted Azure pull request linking --- .../toolkits/pullRequests/handlers.test.ts | 37 +++++++++++++++++++ packages/shared/src/changeRequestUrl.test.ts | 18 +++++++++ 2 files changed, 55 insertions(+) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index e0c36d7b17338..8115993804408 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -426,6 +426,43 @@ describe("pull request toolkit handlers", () => { }), ); + it.effect("links Azure DevOps Server URLs and numeric references without a known provider", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + project: makeProject({ + canonicalKey: "scm.example.test/tfs/collection/project/_git/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://scm.example.test/tfs/Collection/Project/_git/Repo", + }, + provider: "unknown", + displayName: "tfs/collection/project/_git/repo", + }), + }); + const generated = "https://scm.example.test/tfs/collection/project/_git/repo/pullrequest/124"; + const fromNumber = yield* harness.call("link_pull_request", { + repository: "tfs/Collection/Project/_git/Repo", + number: 124, + }); + expect.soft(fromNumber.url).toBe(generated); + + const supplied = "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/123"; + const fromUrl = yield* harness.call("link_pull_request", { url: supplied }); + expect(fromUrl).toEqual({ + host: "scm.example.test", + repository: "tfs/collection/project/_git/repo", + number: 123, + url: supplied, + alreadyLinked: false, + }); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { type: "thread.pull-request.link", number: 124, url: generated }, + { type: "thread.pull-request.link", number: 123, url: supplied }, + ]); + }), + ); + it.effect("rejects a target that names neither a URL nor repository and number", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts index 68211c0faf5c1..e25b5e0607aac 100644 --- a/packages/shared/src/changeRequestUrl.test.ts +++ b/packages/shared/src/changeRequestUrl.test.ts @@ -69,6 +69,18 @@ describe("parseChangeRequestUrl", () => { ).toEqual({ host: "acme.visualstudio.com", repository: "platform/_git/t3code", number: 17 }); }); + it("reads an Azure DevOps Server pull request with its collection path and normalized identity", () => { + expect( + parseChangeRequestUrl( + "https://SCM.Example.test:8443/tfs/Collection/Project/_git/Repo/pullrequest/123", + ), + ).toEqual({ + host: "scm.example.test", + repository: "tfs/collection/project/_git/repo", + number: 123, + }); + }); + it("survives trailing segments, a trailing slash and a query string", () => { expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/files?w=1")).toEqual({ host: "github.com", @@ -90,6 +102,12 @@ describe("parseChangeRequestUrl", () => { "https://github.com/t3tools/t3code/pull/abc", "https://gitlab.com/t3tools/t3code/-/issues/12", "https://blog.example.test/2026/updates/pull/3", + "https://scm.example.test/tfs/Collection/Project/_git/Repo", + "https://scm.example.test/tfs/Collection/Project/pullrequest/123", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/abc", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/0", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/9007199254740992", + "ftp://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/123", "javascript:alert(1)//github.com/t3tools/t3code/pull/1", "not a url", ]) { From c7a1ac4161bba6e21acbbd19fd91d180443c64f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:48:42 +0000 Subject: [PATCH 3/9] fix: recognize self-hosted Azure pull request links --- .../src/mcp/toolkits/pullRequests/handlers.ts | 6 +++++- packages/shared/src/changeRequestUrl.ts | 13 ++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 570d9020715fa..2c7fccb34950a 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -97,7 +97,11 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( repository, input.number, project?.repositoryIdentity?.locator.remoteUrl, - ) ?? `https://${host}/${repository}/pull/${input.number}`; + ) ?? + (/(?:^|\/)_git\/[^/]+$/u.test(repository) + ? changeRequestUrlFor("azure-devops", host, repository, input.number) + : null) ?? + `https://${host}/${repository}/pull/${input.number}`; return { ...normalizeThreadPullRequestKey({ host, repository, number: input.number, url }), url, diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 3c24ed7501956..f7183475b7cc4 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -28,9 +28,12 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu if (url.protocol !== "https:" && url.protocol !== "http:") return null; const host = url.hostname.toLowerCase(); + // Azure DevOps Server can use any hostname; its `_git` pull request route identifies it. + const azure = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (host === "git.cafe" || host === "staging.git.cafe") { const match = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match); + return claim(host, match ?? azure); } if (isHostOf(host, "github.com", "github")) { const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); @@ -45,13 +48,9 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu if (gitlab) return claim(host, gitlab); if (isHostOf(host, "bitbucket.org", "bitbucket")) { const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match); - } - if (isHostOf(host, "dev.azure.com") || host.endsWith(".visualstudio.com")) { - const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match); + return claim(host, match ?? azure); } - return null; + return claim(host, azure); } function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { From 9ae266e4890cec5bfbc0cb6b2c5dcaad97b1e6ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 08:02:39 +0000 Subject: [PATCH 4/9] test(server): cover Azure Server numeric link origin --- apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 8115993804408..23d48eba21e89 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -434,13 +434,14 @@ describe("pull request toolkit handlers", () => { locator: { source: "git-remote", remoteName: "origin", - remoteUrl: "https://scm.example.test/tfs/Collection/Project/_git/Repo", + remoteUrl: "http://dummy:secret@scm.example.test:8080/tfs/Collection/Project/_git/Repo", }, provider: "unknown", displayName: "tfs/collection/project/_git/repo", }), }); - const generated = "https://scm.example.test/tfs/collection/project/_git/repo/pullrequest/124"; + const generated = + "http://scm.example.test:8080/tfs/collection/project/_git/repo/pullrequest/124"; const fromNumber = yield* harness.call("link_pull_request", { repository: "tfs/Collection/Project/_git/Repo", number: 124, From a53e18793b06e7e4daaaa8386aadb78e6042f0a7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 08:06:21 +0000 Subject: [PATCH 5/9] fix: preserve Azure Server pull request link origins --- .../src/mcp/toolkits/pullRequests/handlers.ts | 8 +++- packages/shared/src/changeRequestUrl.ts | 41 +++++++++++-------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 2c7fccb34950a..c777214aad5c6 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -99,7 +99,13 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( project?.repositoryIdentity?.locator.remoteUrl, ) ?? (/(?:^|\/)_git\/[^/]+$/u.test(repository) - ? changeRequestUrlFor("azure-devops", host, repository, input.number) + ? changeRequestUrlFor( + "azure-devops", + host, + repository, + input.number, + project?.repositoryIdentity?.locator.remoteUrl, + ) : null) ?? `https://${host}/${repository}/pull/${input.number}`; return { diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index f7183475b7cc4..dbd6e5ae502f8 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -61,6 +61,22 @@ function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | : null; } +function remoteWebOrigin(host: string, remoteUrl: string | undefined): string { + try { + const remote = new URL(remoteUrl ?? ""); + if ( + (remote.protocol === "http:" || remote.protocol === "https:") && + (remote.hostname.toLowerCase() === host.toLowerCase() || + remote.host.toLowerCase() === host.toLowerCase()) + ) { + return remote.origin; + } + } catch { + // SSH remotes do not specify the server's web origin. + } + return `https://${host}`; +} + export function changeRequestUrlFor( kind: string | null | undefined, host: string, @@ -73,27 +89,18 @@ export function changeRequestUrlFor( return `https://${host}/${repository}/pull/${number}`; case "gitcafe": return `https://${host}/${repository}/pulls/${number}`; - case "forgejo": { - try { - const remote = new URL(remoteUrl ?? ""); - if ( - (remote.protocol === "http:" || remote.protocol === "https:") && - (remote.hostname.toLowerCase() === host.toLowerCase() || - remote.host.toLowerCase() === host.toLowerCase()) - ) { - return `${remote.origin}/${repository}/pulls/${number}`; - } - } catch { - // SSH remotes do not specify the server's web origin. - } - return `https://${host}/${repository}/pulls/${number}`; - } + case "forgejo": + return `${remoteWebOrigin(host, remoteUrl)}/${repository}/pulls/${number}`; case "gitlab": return `https://${host}/${repository}/-/merge_requests/${number}`; case "bitbucket": return `https://${host}/${repository}/pull-requests/${number}`; - case "azure-devops": - return `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`; + case "azure-devops": { + const [canonicalHost = host, ...path] = canonicalRepositoryKey( + `${host}/${repository}`.toLowerCase(), + ).split("/"); + return `${remoteWebOrigin(canonicalHost, remoteUrl)}/${path.join("/")}/pullrequest/${number}`; + } default: return null; } From 51be288a21c928d146aa120934f7ae8524614650 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 08:33:40 +0000 Subject: [PATCH 6/9] test(shared): distinguish Azure Server pull request ports --- packages/shared/src/changeRequestUrl.test.ts | 37 ++++++++++++++------ 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts index e25b5e0607aac..183634d07b5d5 100644 --- a/packages/shared/src/changeRequestUrl.test.ts +++ b/packages/shared/src/changeRequestUrl.test.ts @@ -1,11 +1,14 @@ +import { ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { changeRequestUrlFor, + matchesLinkedPullRequestUrl, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, siblingPullRequestUrl, } from "./changeRequestUrl.ts"; +import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts"; describe("parseChangeRequestUrl", () => { it("reads a GitHub pull request, lower-casing the repository", () => { @@ -69,16 +72,30 @@ describe("parseChangeRequestUrl", () => { ).toEqual({ host: "acme.visualstudio.com", repository: "platform/_git/t3code", number: 17 }); }); - it("reads an Azure DevOps Server pull request with its collection path and normalized identity", () => { - expect( - parseChangeRequestUrl( - "https://SCM.Example.test:8443/tfs/Collection/Project/_git/Repo/pullrequest/123", - ), - ).toEqual({ - host: "scm.example.test", - repository: "tfs/collection/project/_git/repo", - number: 123, - }); + it("reads Azure DevOps Server links and keeps servers on different ports distinct", () => { + for (const host of ["scm.example.test", "git.cafe", "code.bitbucket.example.test"]) { + const repository = "tfs/collection/project/_git/repo"; + const linked = { + projectId: ProjectId.make("project-1"), + host, + repository, + number: 123, + url: `https://${host.toUpperCase()}:8443/tfs/Collection/Project/_git/Repo/pullrequest/123`, + }; + const other = { ...linked, url: linked.url.replace(":8443/", ":9443/") }; + expect.soft(parseChangeRequestUrl(linked.url), host).toEqual({ + host, + authority: `${host}:8443`, + repository, + number: 123, + }); + expect.soft(matchesLinkedPullRequestUrl(linked, `${linked.url}?view=files`), host).toBe(true); + expect.soft(matchesLinkedPullRequestUrl(linked, other.url), host).toBe(false); + expect.soft([linked, other].map(normalizeThreadPullRequestKey), host).toEqual([ + { host: `${host}:8443`, repository, number: 123 }, + { host: `${host}:9443`, repository, number: 123 }, + ]); + } }); it("survives trailing segments, a trailing slash and a query string", () => { From c59a0bdd3cb6f7b3bcbb45ec0834c9168b2a5702 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 08:36:52 +0000 Subject: [PATCH 7/9] fix(shared): preserve Azure pull request authority --- packages/shared/src/changeRequestUrl.test.ts | 15 +++++++- packages/shared/src/changeRequestUrl.ts | 39 ++++++++++---------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts index 183634d07b5d5..04f2e54af5ea0 100644 --- a/packages/shared/src/changeRequestUrl.test.ts +++ b/packages/shared/src/changeRequestUrl.test.ts @@ -66,10 +66,20 @@ describe("parseChangeRequestUrl", () => { }); expect( parseChangeRequestUrl("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17"), - ).toEqual({ host: "dev.azure.com", repository: "acme/platform/_git/t3code", number: 17 }); + ).toEqual({ + host: "dev.azure.com", + authority: "dev.azure.com", + repository: "acme/platform/_git/t3code", + number: 17, + }); expect( parseChangeRequestUrl("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17"), - ).toEqual({ host: "acme.visualstudio.com", repository: "platform/_git/t3code", number: 17 }); + ).toEqual({ + host: "acme.visualstudio.com", + authority: "acme.visualstudio.com", + repository: "platform/_git/t3code", + number: 17, + }); }); it("reads Azure DevOps Server links and keeps servers on different ports distinct", () => { @@ -218,6 +228,7 @@ describe("changeRequestUrlFor", () => { expect(url).toBe("https://dev.azure.com/org/project/_git/web/pullrequest/42"); expect(parseChangeRequestUrl(url!)).toEqual({ host: "dev.azure.com", + authority: "dev.azure.com", repository: "org/project/_git/web", number: 42, }); diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index dbd6e5ae502f8..5c60da05e5a65 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -28,29 +28,30 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu if (url.protocol !== "https:" && url.protocol !== "http:") return null; const host = url.hostname.toLowerCase(); - // Azure DevOps Server can use any hostname; its `_git` pull request route identifies it. - const azure = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (host === "git.cafe" || host === "staging.git.cafe") { const match = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match ?? azure); - } - if (isHostOf(host, "github.com", "github")) { - const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); if (match) return claim(host, match); + } else { + if (isHostOf(host, "github.com", "github")) { + const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } + const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (forgejo) { + const link = claim(host, forgejo); + return link === null ? null : { ...link, authority: url.host.toLowerCase() }; + } + const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (gitlab) return claim(host, gitlab); + if (isHostOf(host, "bitbucket.org", "bitbucket")) { + const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } } - const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (forgejo) { - const link = claim(host, forgejo); - return link === null ? null : { ...link, authority: url.host.toLowerCase() }; - } - const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (gitlab) return claim(host, gitlab); - if (isHostOf(host, "bitbucket.org", "bitbucket")) { - const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match ?? azure); - } - return claim(host, azure); + // Azure DevOps Server can use any hostname; its `_git` pull request route identifies it. + const azure = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); + const link = claim(host, azure); + return link === null ? null : { ...link, authority: url.host.toLowerCase() }; } function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { From 03975cc696dd5fd36deed4d48de4a4e01fcbe239 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 08:46:33 +0000 Subject: [PATCH 8/9] test(web): preserve Azure cloud pull request aliases --- apps/web/src/lib/openPullRequestLink.test.ts | 23 +++++++++++++------- packages/shared/src/changeRequestUrl.test.ts | 15 ++----------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index f2eadb52f0bfc..ef222b8b007aa 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -249,17 +249,24 @@ describe("findProjectOnChangeRequestHost", () => { }); it.each([ - "ssh.dev.azure.com/v3/org-a/project/web", - "vs-ssh.visualstudio.com/v3/org-a/project/web", - "org-a.visualstudio.com/defaultcollection/project/_git/web", - "dev.azure.com/org-a/project/_git/web", - ])("matches Azure browser references against %s", (canonicalKey) => { + ["ssh.dev.azure.com/v3/org-a/project/web", "git@ssh.dev.azure.com:v3/org-a/project/web"], + [ + "vs-ssh.visualstudio.com/v3/org-a/project/web", + "git@vs-ssh.visualstudio.com:v3/org-a/project/web", + ], + [ + "org-a.visualstudio.com/defaultcollection/project/_git/web", + "https://org-a.visualstudio.com/DefaultCollection/Project/_git/Web", + ], + ["dev.azure.com/org-a/project/_git/web", "https://org-a@dev.azure.com/org-a/Project/_git/Web"], + ])("matches Azure browser references against %s", (canonicalKey, remoteUrl) => { const checkout = project("azure", { - canonicalKey, - provider: "azure-devops", + ...repositoryIdentity("azure-devops", canonicalKey, remoteUrl), displayName: canonicalKey.split("/").slice(1).join("/"), }); - const reference = { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 42 }; + const reference = parseChangeRequestUrl( + "https://dev.azure.com/org-a/project/_git/web/pullrequest/42", + )!; expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout); expect(findProjectOnChangeRequestHost([checkout], reference)).toBe(checkout); expect( diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts index 04f2e54af5ea0..183634d07b5d5 100644 --- a/packages/shared/src/changeRequestUrl.test.ts +++ b/packages/shared/src/changeRequestUrl.test.ts @@ -66,20 +66,10 @@ describe("parseChangeRequestUrl", () => { }); expect( parseChangeRequestUrl("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17"), - ).toEqual({ - host: "dev.azure.com", - authority: "dev.azure.com", - repository: "acme/platform/_git/t3code", - number: 17, - }); + ).toEqual({ host: "dev.azure.com", repository: "acme/platform/_git/t3code", number: 17 }); expect( parseChangeRequestUrl("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17"), - ).toEqual({ - host: "acme.visualstudio.com", - authority: "acme.visualstudio.com", - repository: "platform/_git/t3code", - number: 17, - }); + ).toEqual({ host: "acme.visualstudio.com", repository: "platform/_git/t3code", number: 17 }); }); it("reads Azure DevOps Server links and keeps servers on different ports distinct", () => { @@ -228,7 +218,6 @@ describe("changeRequestUrlFor", () => { expect(url).toBe("https://dev.azure.com/org/project/_git/web/pullrequest/42"); expect(parseChangeRequestUrl(url!)).toEqual({ host: "dev.azure.com", - authority: "dev.azure.com", repository: "org/project/_git/web", number: 42, }); From d2a8238d5e6af28cd3be8ee823898eea631c70ce Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 09:06:15 +0000 Subject: [PATCH 9/9] fix(shared): preserve Azure cloud pull request aliases --- packages/shared/src/changeRequestUrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 5c60da05e5a65..a39593a9c09db 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -51,7 +51,7 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu // Azure DevOps Server can use any hostname; its `_git` pull request route identifies it. const azure = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); const link = claim(host, azure); - return link === null ? null : { ...link, authority: url.host.toLowerCase() }; + return link === null || url.port === "" ? link : { ...link, authority: url.host.toLowerCase() }; } function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {