Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
},
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/components/pullRequest/pullRequestList.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";

import {
filterPullRequestsByInvolvement,
findProjectForRepository,
findScopedProject,
mergePullRequestLists,
pullRequestEntryKey,
Expand Down Expand Up @@ -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();
});
});
51 changes: 51 additions & 0 deletions apps/web/src/components/pullRequest/pullRequestList.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PullRequestListEntry,
PullRequestListProjectError,
PullRequestListResult,
pullRequestHostOf,
resolvePullRequestAuthorFilter,
} from "@t3tools/contracts";
import type {
Expand All @@ -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";
Expand Down Expand Up @@ -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<Project>,
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
Expand Down
36 changes: 16 additions & 20 deletions apps/web/src/routes/_chat.pull-requests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {

import {
filterPullRequestsByInvolvement,
findProjectForRepository,
findScopedProject,
collectPullRequestListFacets,
groupPullRequestsByInvolvement,
Expand Down Expand Up @@ -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.
Expand Down
Loading