From 46c857909563b2909a1b2d4c1d01fb07e8a9ff6d Mon Sep 17 00:00:00 2001 From: Jacob <589761+jvzijp@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:25:56 +0200 Subject: [PATCH 1/2] fix(web): open Azure DevOps PR links in the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking an Azure DevOps pull request link in a message, or opening `#123` from the chat header, opened the right panel with "Pull request operation resolveRepository failed: The change request does not belong to the selected project." The server names an Azure DevOps repository by its bare name, because `az repos pr` refuses the full `org/project/_git/repo` path and takes the organisation and project from the checkout instead. Both reads are hostless, so `requireProject` checks the reference against the project's own selector — and both call sites built it from the identity's `displayName`, which is that refused path. GitHub and GitLab were unaffected because their `displayName` already is the selector. Read the repository through `sourceControlRepositorySelector`, the rule the server checks against, in the markdown link preview target (ChatMarkdown) and in the chat header's project pull request open (ChatView). Matching a link against a project still compares the full URL path; only the repository handed to the panel changes. Mobile takes the repository from the server's linked pull request record and needed no change. Desktop wraps web. --- apps/web/src/components/ChatMarkdown.tsx | 5 ++++- apps/web/src/components/ChatView.tsx | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 192abaf385f7..906d8f7a8f59 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -74,6 +74,7 @@ import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import remarkGfm from "remark-gfm"; import { remarkGithubAlerts } from "../markdown-github-alerts"; @@ -2756,8 +2757,10 @@ const CHAT_MARKDOWN_COMPONENTS = { environmentId, input: { projectId: pullRequestProject.id, + // The provider-native name, not the link's path: the preview read is hostless, + // so the server checks this against the project's own selector. repository: - pullRequestProject.repositoryIdentity?.displayName ?? + sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ?? pullRequestCandidate.repository, number: pullRequestCandidate.number, }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f1b5afba94e9..d91d5457090d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -166,6 +166,7 @@ import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { @@ -4147,7 +4148,11 @@ export default function ChatView(props: ChatViewProps) { const activeThreadMetadata = activeThreadShell ?? activeThread; const linkedThreadPullRequest = activeThreadMetadata?.linkedPullRequest ?? activeThreadMetadata?.branchPullRequest ?? null; - const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; + // Hostless reads name the repository the way the server's own project check spells it, which + // on Azure DevOps is the bare name rather than the recorded `org/project/_git/repo` path. + const activeProjectRepository = sourceControlRepositorySelector( + activeProject?.repositoryIdentity, + ); const linkedThreadPullRequestKey = linkedThreadPullRequest ? JSON.stringify([ linkedThreadPullRequest.projectId, From 10f4b3a2a9b43f9471f4c938b41484688182470b Mon Sep 17 00:00:00 2001 From: Jacob <589761+jvzijp@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:26:05 +0200 Subject: [PATCH 2/2] fix(web): prefer the scoped project for repository-only PR links The pull requests page resolves a link carrying only a repository and number by finding the project whose identity answers that repository. It compared `owner/name`, which is not what the server accepts: on Azure DevOps the repository is the bare name, so no project matched and the link opened nothing. Matching the provider-native selector instead exposes a second problem. That selector is unique per host on GitHub and GitLab, but on Azure two repositories sharing a name in two Azure projects both answer it, and the first one won even when the page was already scoped to the other. The reference then failed the server's project check or opened the wrong project's pull request. Move the lookup into `pullRequestList.logic.ts` as `findProjectForRepository`. It collects every project answering the repository and host, then prefers the one the page is scoped to, resolved the same way the page resolves its own scope. A scope naming a project the repository does not belong to still does not override the match, so the fallback order is unchanged for that case. --- .../pullRequest/pullRequestList.logic.test.ts | 91 +++++++++++++++++++ .../pullRequest/pullRequestList.logic.ts | 51 +++++++++++ apps/web/src/routes/_chat.pull-requests.tsx | 36 ++++---- 3 files changed, 158 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index a90022ad72c9..00ea812edaea 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { filterPullRequestsByInvolvement, + findProjectForRepository, findScopedProject, mergePullRequestLists, pullRequestEntryKey, @@ -1496,3 +1497,93 @@ describe("the priority groups against a paginated feed", () => { ).toEqual([6123]); }); }); + +describe("findProjectForRepository", () => { + const azure = (id: string, environmentId: string, path: string) => ({ + id, + environmentId, + repositoryIdentity: { + canonicalKey: `dev.azure.com/${path}`, + locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "" }, + provider: "azure-devops", + displayName: path, + owner: path.split("/")[0]!, + name: path.split("/").at(-1)!, + }, + }); + // Two Azure DevOps repositories that merely share a name, in two Azure projects on one host. + // Both answer the selector `api`, which is all the server accepts for that provider. + const payments = azure("p1", "env-1", "contoso/payments/_git/api"); + const billing = azure("p2", "env-1", "contoso/billing/_git/api"); + + it("prefers the project the page is scoped to among several answering one repository", () => { + expect( + findProjectForRepository([payments, billing], { + repository: "api", + scopedProjectId: "p2", + scopedEnvironmentId: "env-1", + }), + ).toBe(billing); + }); + + it("does not let a scope naming another repository's project override the match", () => { + const other = azure("p3", "env-1", "contoso/payments/_git/web"); + expect( + findProjectForRepository([payments, other], { + repository: "api", + scopedProjectId: "p3", + }), + ).toBe(payments); + }); + + it("keeps a scope on another server from claiming a same-id project here", () => { + const elsewhere = azure("p1", "env-2", "contoso/billing/_git/api"); + expect( + findProjectForRepository([payments, elsewhere], { + repository: "api", + scopedProjectId: "p1", + scopedEnvironmentId: "env-2", + }), + ).toBe(elsewhere); + }); + + it("reads a nested GitLab group as the whole path below the host", () => { + const gitlab = { + id: "gl1", + environmentId: "env-1", + repositoryIdentity: { + canonicalKey: "gitlab.com/t3tools/platform/t3code", + locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "" }, + provider: "gitlab", + displayName: "T3Tools/Platform/T3Code", + owner: "t3tools", + name: "t3code", + }, + }; + expect(findProjectForRepository([gitlab], { repository: "t3tools/platform/t3code" })).toBe( + gitlab, + ); + expect(findProjectForRepository([gitlab], { repository: "t3tools/t3code" })).toBeUndefined(); + }); + + it("matches case-insensitively and keeps two hosts apart", () => { + const github = { + id: "g1", + environmentId: "env-1", + repositoryIdentity: { + canonicalKey: "github.com/t3tools/t3code", + locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "" }, + provider: "github", + owner: "t3tools", + name: "t3code", + }, + }; + expect(findProjectForRepository([github], { repository: "T3Tools/T3Code" })).toBe(github); + expect( + findProjectForRepository([github], { + repository: "t3tools/t3code", + host: "github.acme.test", + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index c2fdde3d011f..472b746dc0e9 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -5,6 +5,7 @@ import { PullRequestListEntry, PullRequestListProjectError, PullRequestListResult, + pullRequestHostOf, resolvePullRequestAuthorFilter, } from "@t3tools/contracts"; import type { @@ -16,7 +17,10 @@ import type { PullRequestListCursors, PullRequestListFilters, PullRequestListState, + RepositoryIdentity, + SourceControlProviderKind, } from "@t3tools/contracts"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { toSortableTimestamp } from "../../lib/threadSort"; import type { PullRequestListSort } from "./pullRequestListPreferences"; @@ -911,6 +915,53 @@ export function findScopedProject< return matches.find((project) => project.environmentId === environmentId); } +/** + * The project a repository-only link belongs to. The repository is the provider-native selector + * the server checks a request against, and more than one project can answer it: two checkouts of + * one repository, and on Azure DevOps two repositories that merely share a name across that + * host's projects, since the selector there is the bare name `az repos pr` accepts. + * + * Where the page is already scoped to one of the candidates that one is chosen, because the scope + * is the reader's own word about which project the link came from. A scope naming a project the + * repository does not belong to is not believed over the repository: the first candidate stands, + * as it would with no scope at all. + */ +export function findProjectForRepository< + Project extends { + readonly id: string; + readonly environmentId: string; + readonly repositoryIdentity?: RepositoryIdentity | null | undefined; + }, +>( + projects: ReadonlyArray, + input: { + readonly repository: string; + readonly host?: string | undefined; + readonly scopedProjectId?: string | undefined; + readonly scopedEnvironmentId?: string | null | undefined; + }, +): Project | undefined { + const repository = input.repository.toLowerCase(); + const host = input.host?.toLowerCase(); + const candidates = projects.filter((project) => { + const identity = project.repositoryIdentity; + if (!identity || sourceControlRepositorySelector(identity)?.toLowerCase() !== repository) { + return false; + } + // The same `owner/name` can exist on two hosts. Without this the first match wins, and a link + // that named its host opens the pull request from the other one. + return ( + host === undefined || + pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) === host + ); + }); + const scoped = + input.scopedProjectId === undefined + ? undefined + : findScopedProject(candidates, input.scopedEnvironmentId, input.scopedProjectId); + return scoped ?? candidates[0]; +} + /** * Which environments a listing should ask, once the project scope is known. Scoping to a project * scopes to the server that owns it, saving every other server a read that could only answer with diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 62bb23bb305f..e92f45cd90e8 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -44,6 +44,7 @@ import { import { filterPullRequestsByInvolvement, + findProjectForRepository, findScopedProject, collectPullRequestListFacets, groupPullRequestsByInvolvement, @@ -374,27 +375,22 @@ function PullRequestsRouteView() { ); // A link from a thread or the sidebar only knows the repository, so the owning project is - // resolved here; an explicit `projectId` in the URL still wins. + // resolved here; an explicit `projectId` in the URL still wins. The repository is spelled the + // way the server names it, and where several projects answer that name the page's own scope + // picks among them, so a repository-only link never wanders off the project being read. const selectedHost = search.selectedHost ?? search.host; - const projectIdForRepository = useMemo(() => { - const repository = search.repository?.toLowerCase(); - if (repository === undefined) return undefined; - const identity = projects.find( - (project) => - project.repositoryIdentity?.owner && - project.repositoryIdentity.name && - `${project.repositoryIdentity.owner}/${project.repositoryIdentity.name}`.toLowerCase() === - repository && - // The same `owner/name` can exist on two hosts. Without this the first match wins, and - // a link that named its host opens the pull request from the other one. - (selectedHost === undefined || - pullRequestHostOf( - project.repositoryIdentity, - project.repositoryIdentity.provider as SourceControlProviderKind, - ) === selectedHost.toLowerCase()), - ); - return identity?.id; - }, [projects, selectedHost, search.repository]); + const projectIdForRepository = useMemo( + () => + search.repository === undefined + ? undefined + : findProjectForRepository(projects, { + repository: search.repository, + host: selectedHost, + scopedProjectId, + scopedEnvironmentId, + })?.id, + [projects, scopedEnvironmentId, scopedProjectId, selectedHost, search.repository], + ); // The selection is resolved the same way the scope is: an id no connected environment has can // never be read here, and one that arrived before the projects did is not yet wrong.