# Files changed · PR \#22593 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/22593/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix: canonicalize git.cafe pull request links](https://git.cafe/pingdotgg/t3code/pull/22593.md)

Version: 21

Head commit: 2a3009c658f0e1e08dfb55f51d44b8f349bfc778

Base commit: 8a9d0e0dd553c5b0bd943ddfd9741d6bab0e486f

Comparison base: 05696c636ec2a101a763505d820b83891e529ccd

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts

Status: modified; +31; −0

```diff
@@ -352,6 +352,37 @@
     }),
   );
 
+  it.effect("canonicalizes GitCafe links independently of the thread's project host", () =>
+    Effect.gen(function* () {
+      for (const host of ["git.cafe", "staging.git.cafe"]) {
+        for (const target of [
+          { host, repository: "PingDotGG/T3Code", number: 21353 },
+          { url: `https://${host}/PingDotGG/T3Code/pull/21353` },
+          { url: `https://${host}/PingDotGG/T3Code/pulls/21353` },
+        ]) {
+          const harness = yield* makeHarness();
+          const result = yield* harness.call("link_pull_request", target);
+          const url = `https://${host}/pingdotgg/t3code/pulls/21353`;
+          expect(result).toMatchObject({
+            host,
+            repository: "pingdotgg/t3code",
+            number: 21353,
+            url,
+          });
+          expect(yield* Ref.get(harness.commands)).toMatchObject([
+            {
+              type: "thread.pull-request.link",
+              host,
+              repository: "pingdotgg/t3code",
+              number: 21353,
+              url,
+            },
+          ]);
+        }
+      }
+    }),
+  );
+
   it.effect("builds the URL in the project host's own shape", () =>
     Effect.gen(function* () {
       const harness = yield* makeHarness({
```

## apps/server/src/mcp/toolkits/pullRequests/handlers.ts

Status: modified; +10; −3

```diff
@@ -11,7 +11,11 @@
   type ThreadId,
   type ThreadPullRequestLink,
 } from "@t3tools/contracts";
+import {
+  canonicalizeChangeRequestUrl,
+  changeRequestUrlFor,
+  parseChangeRequestUrl,
-import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
+} from "@t3tools/shared/changeRequestUrl";
 import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
 import {
   normalizeThreadPullRequestKey,
@@ -79,7 +83,10 @@
     if (parsed === null) {
       return yield* new PullRequestUrlInvalidError({});
     }
+    return {
+      ...normalizeThreadPullRequestKey(parsed),
+      url: canonicalizeChangeRequestUrl(input.url),
-    return { ...normalizeThreadPullRequestKey(parsed), url: input.url } satisfies ResolvedTarget;
+    } satisfies ResolvedTarget;
   }
   if (input.repository === undefined || input.number === undefined) {
     return yield* new PullRequestTargetIncompleteError({});
@@ -110,7 +117,7 @@
     `https://${host}/${repository}/pull/${input.number}`;
   return {
     ...normalizeThreadPullRequestKey({ host, repository, number: input.number, url }),
+    url: canonicalizeChangeRequestUrl(url),
-    url,
   } satisfies ResolvedTarget;
 });
 
```

## apps/server/src/orchestration-v2/Orchestrator.ts

Status: modified; +5; −4

```diff
@@ -10,6 +10,7 @@
   usageLimitBlockedRun,
 } from "@t3tools/shared/orchestrationV2ThreadError";
 import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests";
+import { canonicalizeChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
 import {
   normalizeThreadPullRequestKey,
   visibleThreadPullRequests,
@@ -579,7 +580,7 @@
   return withPullRequestWatch(
     {
       ...key,
+      url: canonicalizeChangeRequestUrl(linked.url),
-      url: linked.url,
       source: "manual",
       linkedAt: DateTime.formatIso(now),
       snapshot: null,
@@ -3227,14 +3228,14 @@
               ? {
                   ...existing,
                   source: command.source,
-                  url: command.url,
+                  url: canonicalizeChangeRequestUrl(command.url),
                   ...(command.repositoryWebUrl !== undefined && {
                     repositoryWebUrl: command.repositoryWebUrl,
                   }),
                 }
               : {
                   ...key,
-                  url: command.url,
+                  url: canonicalizeChangeRequestUrl(command.url),
                   ...(command.repositoryWebUrl !== undefined && {
                     repositoryWebUrl: command.repositoryWebUrl,
                   }),
@@ -3330,7 +3331,7 @@
                   ...linked.filter((link) => !threadPullRequestKeysEqual(link, key)),
                   {
                     ...key,
-                    url: command.link.url,
+                    url: canonicalizeChangeRequestUrl(command.link.url),
                     ...((command.link.repositoryWebUrl ?? dismissed?.repositoryWebUrl) !==
                       undefined && {
                       repositoryWebUrl:
```

## apps/server/src/orchestration-v2/runtimeLayer.test.ts

Status: modified; +19; −0

```diff
@@ -2259,6 +2259,25 @@
         (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.map((link) => link.number),
         [2, 3, 4],
       );
+      for (const [index, route] of ["pull", "pulls"].entries()) {
+        yield* orchestrator.dispatch({
+          type: "thread.pull-request.link",
+          commandId: CommandId.make(`gitcafe-pr-link-${index}`),
+          threadId,
+          host: "git.cafe",
+          repository: "pingdotgg/t3code",
+          number: 21353 + index,
+          url: `https://git.cafe/pingdotgg/t3code/${route}/${21353 + index}`,
+          source: "manual",
+        });
+      }
+      assert.isTrue((yield* maintenance.rebuild).valid);
+      assert.deepEqual(
+        (yield* orchestrator.getThreadShell(threadId))?.pullRequests
+          ?.filter((link) => link.host === "git.cafe")
+          .map((link) => link.url),
+        [21353, 21354].map((number) => `https://git.cafe/pingdotgg/t3code/pulls/${number}`),
+      );
     }),
   );
 
```

## packages/client-runtime/src/pullRequestLinkInput.test.ts

Status: modified; +25; −0

```diff
@@ -14,6 +14,31 @@
 };
 
 describe("resolveLinkPullRequestInput", () => {
+  it.each(["git.cafe", "staging.git.cafe"])("canonicalizes both GitCafe routes on %s", (host) => {
+    for (const [repository, route] of [
+      ["web", "pull"],
+      ["web", "pulls"],
+      ["pull", "pull"],
+      ["pull", "pulls"],
+      ["pulls", "pull"],
+      ["pulls", "pulls"],
+    ]) {
+      const url = `https://${host}/acme/${repository}/${route}/42`;
+      expect(sourceControlClients.get("gitcafe").isChangeRequestUrl(url)).toBe(true);
+      expect(parsePullRequestReference(url)).toBe(url);
+      expect(
+        resolveLinkPullRequestInput({ reference: url, project: null, hasProject: () => true }),
+      ).toEqual({
+        link: {
+          host,
+          repository: `acme/${repository}`,
+          number: 42,
+          url: `https://${host}/acme/${repository}/pulls/42`,
+        },
+      });
+    }
+  });
+
   it.each([
     ["https://bitbucket.org/acme/web/pull-requests/42", "bitbucket.org"],
     ["https://github.acme.test/acme/web/pull/42", "github.acme.test"],
```

## packages/client-runtime/src/pullRequestLinkInput.ts

Status: modified; +10; −2

```diff
@@ -1,5 +1,8 @@
 import { pullRequestHostOf, type RepositoryIdentity } from "@t3tools/contracts";
+import {
+  canonicalizeChangeRequestUrl,
-import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
+  parseChangeRequestUrl,
+} from "@t3tools/shared/changeRequestUrl";
 
 import { sourceControlClients } from "./sourceControlClients.ts";
 
@@ -85,7 +88,12 @@
       return { error: `No project in this environment can read ${url.host}/${url.repository}.` };
     }
     return {
-      link: { host: url.host, repository: url.repository, number: url.number, url: parsed },
+      link: {
+        host: url.host,
+        repository: url.repository,
+        number: url.number,
+        url: canonicalizeChangeRequestUrl(parsed),
+      },
     };
   }
   const number = Number(parsed);
```

## packages/shared/src/changeRequestUrl.test.ts

Status: modified; +22; −0

```diff
@@ -3,14 +3,31 @@
 
 import {
   changeRequestUrlFor,
+  canonicalizeChangeRequestUrl,
   matchesLinkedPullRequestUrl,
   parseChangeRequestUrl,
+  parseGitCafePullRequestUrl,
   pullRequestCandidateUrlFromReferenceAutolink,
   siblingPullRequestUrl,
 } from "./changeRequestUrl.ts";
 import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts";
 
 describe("parseChangeRequestUrl", () => {
+  it.each(["git.cafe", "staging.git.cafe"])("reads both GitCafe routes on %s", (host) => {
+    for (const route of ["pull", "pulls"]) {
+      const url = `https://${host}/PingDotGG/T3Code/${route}/21353/files?w=1#diff`;
+      expect(parseChangeRequestUrl(url)).toEqual({
+        host,
+        repository: "pingdotgg/t3code",
+        number: 21353,
+      });
+      expect(canonicalizeChangeRequestUrl(url)).toBe(
+        `https://${host}/pingdotgg/t3code/pulls/21353`,
+      );
+      expect(parseGitCafePullRequestUrl(url)).toEqual(parseChangeRequestUrl(url));
+    }
+  });
+
   it("reads a GitHub pull request, lower-casing the repository", () => {
     expect(parseChangeRequestUrl("https://github.com/T3Tools/T3Code/pull/123")).toEqual({
       host: "github.com",
@@ -81,6 +98,8 @@
         repository,
         number: 123,
       });
+      expect.soft(parseGitCafePullRequestUrl(linked.url), host).toBeNull();
+      expect.soft(canonicalizeChangeRequestUrl(linked.url), host).toBe(linked.url);
       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([
@@ -111,6 +130,9 @@
       "https://github.com/t3tools/t3code/pull/abc",
       "https://gitlab.com/t3tools/t3code/-/issues/12",
       "https://blog.example.test/2026/updates/pull/3",
+      "https://git.cafe/owner/repo/pull/0",
+      "https://git.cafe/owner/repo/pull/9007199254740992",
+      "https://staging.git.cafe/owner/repo/pull/7suffix",
       "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",
```

## packages/shared/src/changeRequestUrl.ts

Status: modified; +17; −1

```diff
@@ -18,6 +18,8 @@
   return label !== undefined && hostname.split(".").includes(label);
 }
 
+const gitCafePullRequestPath = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u;
+
 export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null {
   let url: URL;
   try {
@@ -29,7 +31,7 @@
   const host = url.hostname.toLowerCase();
 
   if (host === "git.cafe" || host === "staging.git.cafe") {
+    const match = gitCafePullRequestPath.exec(url.pathname);
-    const match = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname);
     if (match) return claim(host, match);
   } else {
     if (isHostOf(host, "github.com", "github")) {
@@ -54,6 +56,20 @@
   return link === null || url.port === "" ? link : { ...link, authority: url.host.toLowerCase() };
 }
 
+export function parseGitCafePullRequestUrl(targetUrl: string): ChangeRequestLink | null {
+  const link = parseChangeRequestUrl(targetUrl);
+  return link !== null &&
+    (link.host === "git.cafe" || link.host === "staging.git.cafe") &&
+    gitCafePullRequestPath.test(new URL(targetUrl).pathname)
+    ? link
+    : null;
+}
+
+export function canonicalizeChangeRequestUrl(targetUrl: string): string {
+  const link = parseGitCafePullRequestUrl(targetUrl);
+  return link === null ? targetUrl : `https://${link.host}/${link.repository}/pulls/${link.number}`;
+}
+
 function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {
   const repository = match?.[1];
   const number = Number(match?.[2]);
```

## packages/source-control-gitcafe/src/client/definition.ts

Status: modified; +6; −14

```diff
@@ -6,15 +6,16 @@
 } from "@t3tools/contracts";
 import * as Effect from "effect/Effect";
 import * as Schema from "effect/Schema";
+import { parseGitCafePullRequestUrl } from "@t3tools/shared/changeRequestUrl";
 import {
   defineSourceControlClient,
   isChangeRequestInProjectRepository,
   isChangeRequestOnProjectHost,
-  isChangeRequestPath,
 } from "@t3tools/source-control-core/client/definition";
 
+function isGitCafePullRequestUrl(url: string): boolean {
-/** GitCafe has no self-hosted installs: production and staging are its only hosts. */
-const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]);
+  return parseGitCafePullRequestUrl(url) !== null;
+}
 
 const KIND = SourceControlProviderKind.make("gitcafe");
 
@@ -60,22 +61,13 @@
   reviewSummaryRequired: () => false,
   // `/pulls/` is Forgejo's shape too, so only GitCafe's own hosts claim it.
   checkoutCommandArgument: () => null,
+  isChangeRequestReference: isGitCafePullRequestUrl,
-  isChangeRequestReference: () => false,
   changeRequestUrlHost: (url) => url.hostname,
   checkoutChangeRequestHost: () => null,
   isChangeRequestInRepository: (identity, link) =>
     isChangeRequestInProjectRepository(KIND, identity, link),
   canReadChangeRequestOnHost: (identity, link) =>
     isChangeRequestOnProjectHost(KIND, identity, link),
-  isChangeRequestUrl: (url) => {
-    try {
-      return (
-        GITCAFE_HOSTS.has(new URL(url).hostname.toLowerCase()) &&
-        isChangeRequestPath(url, "/pulls/")
-      );
-    } catch {
+  isChangeRequestUrl: isGitCafePullRequestUrl,
-      return false;
-    }
-  },
   settings,
 });
```

## packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts

Status: modified; +8; −6

```diff
@@ -244,12 +244,14 @@
           };
     return Effect.gen(function* () {
       const provider = yield* GitCafeSourceControlProvider.make;
-      yield* provider.checkoutChangeRequest({
-        cwd: "/repo",
-        reference: "https://git.cafe/team/project/pulls/7",
+      for (const route of ["pull", "pulls"]) {
-        ...(context === undefined ? {} : { context }),
-      });
-      assert.deepStrictEqual(remotes, [expected]);
+        yield* provider.checkoutChangeRequest({
+          cwd: "/repo",
+          reference: `https://git.cafe/team/project/${route}/7`,
+          ...(context === undefined ? {} : { context }),
+        });
+      }
+      assert.deepStrictEqual(remotes, [expected, expected]);
     }).pipe(
       Effect.provide(
         Layer.merge(GitCafeApi.layer, NoFiles).pipe(
```

## packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts

Status: modified; +6; −7

```diff
@@ -1,4 +1,5 @@
 import { SourceControlProviderKind } from "@t3tools/contracts";
+import { parseGitCafePullRequestUrl } from "@t3tools/shared/changeRequestUrl";
 import * as Effect from "effect/Effect";
 import * as FileSystem from "effect/FileSystem";
 import * as Option from "effect/Option";
@@ -166,8 +167,6 @@
 const isSshRemote = (remoteUrl: string | undefined) =>
   remoteUrl !== undefined && (/^ssh:\/\//iu.test(remoteUrl) || /^[^/:]+@[^/:]+:/u.test(remoteUrl));
 
-const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[?#].*)?$/u;
-
 export const make = Effect.gen(function* () {
   const api = yield* GitCafeApi.GitCafeApi;
   const host = yield* SourceControlHost.SourceControlHost;
@@ -242,15 +241,15 @@
     readonly context?: SourceControlProvider.SourceControlProviderContext;
     readonly reference: string;
   }) {
+    const match = parseGitCafePullRequestUrl(input.reference.trim());
-    const match = pullUrlPattern.exec(input.reference.trim());
+    const urlHost = match === null ? null : GitCafeHosts.gitCafeHost(match.host);
-    const urlHost = match?.[1] ? GitCafeHosts.gitCafeHost(match[1]) : null;
     const target: Target =
-      urlHost && match?.[2]
+      urlHost && match
-        ? { host: urlHost, repository: match[2] }
+        ? { host: urlHost, repository: match.repository }
         : yield* resolveTarget("getChangeRequest", input);
     const number = match
       ? urlHost
+        ? String(match.number)
-        ? match[3]
         : undefined
       : /^#?([1-9]\d*)$/u.exec(input.reference.trim())?.[1];
     if (number === undefined)
```
