From 65e22604915812423e8cde7966e852a400b3b61a Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Thu, 15 Oct 2026 11:35:04 +0100 Subject: [PATCH] fix(web): pull request links from other repositories become chips A pasted pull request link only became a chip when a project was checked out from that exact repository. Servers with host-level pull request refs can read any repository on a host through any project there, so the preview resolver now falls back to a project on the link's host. This also covers pull request hover previews in messages and context chips. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatMarkdown.tsx | 2 +- .../chat/useResolveComposerObjectLink.ts | 8 ++-- apps/web/src/lib/openPullRequestLink.test.ts | 43 ++++++++++++++++++- apps/web/src/lib/openPullRequestLink.ts | 35 ++++++++++----- docs/user/composer.md | 2 +- 5 files changed, 73 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 076883ea7ddf8..b1a67466ebd8b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3250,7 +3250,7 @@ const CHAT_MARKDOWN_COMPONENTS = { ? resolvePullRequestPreviewTarget({ environmentId, projects, - pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true, + capabilities: serverConfig?.environment.capabilities, url: pullRequestCandidateUrl, }) : null; diff --git a/apps/web/src/components/chat/useResolveComposerObjectLink.ts b/apps/web/src/components/chat/useResolveComposerObjectLink.ts index f65d39fa18a8b..192eb058e9a53 100644 --- a/apps/web/src/components/chat/useResolveComposerObjectLink.ts +++ b/apps/web/src/components/chat/useResolveComposerObjectLink.ts @@ -161,7 +161,7 @@ export interface ResolvedComposerObjectLink { /** * Turns a link into the chip its attach picker would have made: the issue or pull request is * fetched and snapshotted the same way. Null when it cannot be read (Linear, Slack, Notion, or Granola - * not connected or turned off, `gh` signed out, a repository no project here is checked out from), so the caller + * not connected or turned off, `gh` signed out, a host no project here is checked out from), so the caller * keeps the link. */ export function useResolveComposerObjectLink(input: { @@ -189,7 +189,7 @@ export function useResolveComposerObjectLink(input: { }); const projects = useProjects(); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const pullRequestsEnabled = serverConfig?.environment.capabilities.pullRequests === true; + const capabilities = serverConfig?.environment.capabilities; return useCallback( async ( @@ -278,7 +278,7 @@ export function useResolveComposerObjectLink(input: { const target = resolvePullRequestPreviewTarget({ environmentId, projects, - pullRequestsEnabled, + capabilities, url: link.url, }); if (target === null) return null; @@ -359,7 +359,7 @@ export function useResolveComposerObjectLink(input: { granolaEnabled, notionEnabled, projects, - pullRequestsEnabled, + capabilities, slackEnabled, threadRef.threadId, ], diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index 7eaef910ade77..586d16cd454a8 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -8,9 +8,10 @@ import { matchesLinkedPullRequestUrl, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, + resolvePullRequestPreviewTarget, shouldOpenPullRequestExternally, } from "./openPullRequestLink"; -import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; +import { type EnvironmentId, ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; function repositoryIdentity( @@ -441,3 +442,43 @@ describe("findProjectForChangeRequest", () => { ).toBeUndefined(); }); }); + +describe("resolvePullRequestPreviewTarget", () => { + const environmentId = "env-1" as EnvironmentId; + const frontend = { + id: ProjectId.make("frontend"), + environmentId, + repositoryIdentity: { + canonicalKey: "github.com/acme/frontend", + provider: "github", + owner: "acme", + name: "frontend", + }, + } as never; + const url = "https://github.com/acme/backend/pull/105"; + + it("reads a pull request from another repository through a project on its host", () => { + expect( + resolvePullRequestPreviewTarget({ + environmentId, + projects: [frontend], + capabilities: { pullRequests: true, threadPullRequests: true } as never, + url, + }), + ).toEqual({ + environmentId, + input: { projectId: "frontend", host: "github.com", repository: "acme/backend", number: 105 }, + }); + }); + + it("keeps the link on a server that cannot route by host", () => { + expect( + resolvePullRequestPreviewTarget({ + environmentId, + projects: [frontend], + capabilities: { pullRequests: true } as never, + url, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index ea398c5979ac4..74078e0b0126f 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,4 +1,9 @@ -import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts"; +import type { + EnvironmentId, + ExecutionEnvironmentCapabilities, + PullRequestRef, + ScopedThreadRef, +} from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; import { type MouseEvent, useCallback, useMemo } from "react"; @@ -45,31 +50,41 @@ export function findProjectForChangeRequest( }); } +/** + * The reference that reads a change request link on one environment. The project only lends + * its checkout and credentials, so any project on the link's host will do, its own first. + * Servers without `threadPullRequests` cannot route by host and read only a project's own + * repository. + */ export function resolvePullRequestPreviewTarget({ environmentId, projects, - pullRequestsEnabled, + capabilities, url, }: { environmentId: EnvironmentId | null; projects: ReadonlyArray; - pullRequestsEnabled: boolean; + capabilities: ExecutionEnvironmentCapabilities | undefined; url: string; }): { environmentId: EnvironmentId; input: PullRequestRef } | null { - if (!pullRequestsEnabled || environmentId === null) return null; + if (capabilities?.pullRequests !== true || environmentId === null) return null; const parsed = parseChangeRequestUrl(url); if (parsed === null) return null; - const project = findProjectForChangeRequest( - projects.filter((candidate) => candidate.environmentId === environmentId), - parsed, - ); + const candidates = projects.filter((candidate) => candidate.environmentId === environmentId); + const own = findProjectForChangeRequest(candidates, parsed); + const project = + own ?? + (capabilities.threadPullRequests === true + ? findProjectOnChangeRequestHost(candidates, parsed) + : undefined); if (project === undefined) return null; return { environmentId, input: { projectId: project.id, host: parsed.authority ?? parsed.host, - repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository, + repository: + (own && sourceControlRepositorySelector(own.repositoryIdentity)) ?? parsed.repository, number: parsed.number, }, }; @@ -83,7 +98,7 @@ export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, resolvePullRequestPreviewTarget({ environmentId, projects, - pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true, + capabilities: serverConfig?.environment.capabilities, url, }), [environmentId, projects, serverConfig, url], diff --git a/docs/user/composer.md b/docs/user/composer.md index 4f8aabcb8a66d..506b1401e59cf 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -336,7 +336,7 @@ converts when you type a space or new line after it. The chip replaces the link loads. A link to one comment on a pull request attaches that comment, with the replies before it when it is part of a review thread. In the attach menu's pull request picker, right-click a pull request and choose **Attach a comment…** to pick one of its comments instead. A link T3 Code cannot read stays as text: a Linear issue or Slack message before you connect that service, or a -pull request from a repository none of your projects is checked out from. Paste with +pull request from a host none of your projects is checked out from. Paste with `Cmd+Shift+V` (`Ctrl+Shift+V` elsewhere) to keep a link as text. A link you turn back into text with undo, or whose chip you delete, is not converted again as you keep typing.