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, 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.