authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-16 22:22:31-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 22:22:31-03:00
logd612d12b8bb278af97b0029f8d48b2403a5f9ee6
treef576da861d48e52bff9876b7faff936136145c35
parent19ee856ec099dddb94e28a3f933ec7c2575ed5a2
signature Signed by PGP key B5690EEEBB952194

fix(web): reuse cached GitHub PR details across entry points (#12168)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

4 files changed, 176 insertions(+), 30 deletions(-)

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+8-3
......@@ -145,6 +145,7 @@ import {
145145 PULL_REQUEST_MERGE_METHOD_LABELS,
146146 readableFailure,
147147 readPullRequestDetailSnapshot,
148 resolvePullRequestReferenceHost,
148149 resolveDisplayedPullRequestDetail,
149150 resolvePullRequestPrimaryControl,
150151 allowsSinglePullRequestMerge,
......@@ -523,18 +524,23 @@ export function PullRequestDetailPanel({
523524 onBack?: (() => void) | undefined;
524525}) {
525526 const environmentConfigs = useServerConfigs();
527 const projects = useProjects();
528 const repositoryIdentity = projects.find(
529 (project) =>
530 project.id === requestedReference.projectId && project.environmentId === environmentId,
531 )?.repositoryIdentity;
526532 const supportsThreadPullRequests =
527533 environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true;
528534 const reference = useMemo(
529535 () =>
530536 supportsThreadPullRequests
531 ? requestedReference
537 ? resolvePullRequestReferenceHost(requestedReference, repositoryIdentity)
532538 : {
533539 projectId: requestedReference.projectId,
534540 repository: requestedReference.repository,
535541 number: requestedReference.number,
536542 },
537 [requestedReference, supportsThreadPullRequests],
543 [requestedReference, repositoryIdentity, supportsThreadPullRequests],
538544 );
539545 const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`;
540546 const matchingListEntry =
......@@ -881,7 +887,6 @@ export function PullRequestDetailPanel({
881887 const newThread = useNewThreadHandler();
882888 const { environments } = useEnvironments();
883889 const primaryEnvironmentId = usePrimaryEnvironmentId();
884 const projects = useProjects();
885890 const unavailableGitHubUrl = useMemo(() => {
886891 const identity = projects.find(
887892 (project) => project.id === reference.projectId && project.environmentId === environmentId,
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+108-3
......@@ -1,6 +1,7 @@
11import { resolvePlanFollowUpSubmission } from "../../proposedPlan";
22import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend";
33import {
4 ProjectId,
45 PullRequestAction,
56 type PullRequestCheck,
67 type PullRequestComment,
......@@ -40,6 +41,7 @@ import {
4041 readableFailure,
4142 readPullRequestDetailSnapshot,
4243 resolveDisplayedPullRequestDetail,
44 resolvePullRequestReferenceHost,
4345 resolvePullRequestPrimaryControl,
4446 allowsSinglePullRequestMerge,
4547 shouldRefreshPullRequestActivity,
......@@ -1440,7 +1442,7 @@ describe("which actions need the host read again after they run", () => {
14401442});
14411443
14421444describe("cached pull request detail", () => {
1443 const reference = { projectId: "project-1", repository: "acme/web", number: 7 };
1445 const reference = { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 7 };
14441446 const detail = (overrides: Partial<PullRequestDetail> = {}): PullRequestDetail =>
14451447 ({
14461448 provider: "github",
......@@ -1511,6 +1513,106 @@ describe("cached pull request detail", () => {
15111513 expect(snapshot?.deletions).toBe(3);
15121514 });
15131515
1516 it("reuses a host-qualified snapshot when reopening a thread link without a host", () => {
1517 const storage = makeStorage();
1518 writePullRequestDetailSnapshot(
1519 storage,
1520 "env-1",
1521 { ...reference, host: "github.com" },
1522 detail(),
1523 );
1524 const resolved = resolvePullRequestReferenceHost(reference, {
1525 canonicalKey: "github.com/acme/web",
1526 locator: {
1527 source: "git-remote",
1528 remoteName: "origin",
1529 remoteUrl: "https://github.com/acme/web.git",
1530 },
1531 provider: "github",
1532 });
1533 expect(readPullRequestDetailSnapshot(storage, "env-1", resolved)?.title).toBe(
1534 "Cache the title",
1535 );
1536 const explicit = { ...reference, host: "github.example.com" };
1537 expect(
1538 resolvePullRequestReferenceHost(explicit, {
1539 canonicalKey: "github.com/acme/web",
1540 locator: {
1541 source: "git-remote",
1542 remoteName: "origin",
1543 remoteUrl: "https://github.com/acme/web.git",
1544 },
1545 }),
1546 ).toBe(explicit);
1547 });
1548
1549 it("leaves server-resolved Azure SSH references unchanged", () => {
1550 expect(
1551 resolvePullRequestReferenceHost(reference, {
1552 canonicalKey: "ssh.dev.azure.com/v3/org/project/web",
1553 locator: {
1554 source: "git-remote",
1555 remoteName: "origin",
1556 remoteUrl: "git@ssh.dev.azure.com:v3/org/project/web",
1557 },
1558 provider: "azure-devops",
1559 }),
1560 ).toBe(reference);
1561 expect(resolvePullRequestReferenceHost(reference, undefined)).toBe(reference);
1562 });
1563
1564 it("hydrates legacy hostless snapshots only for the matching host", () => {
1565 const storage = makeStorage();
1566 writePullRequestDetailSnapshot(storage, "env-1", reference, detail());
1567 expect(
1568 readPullRequestDetailSnapshot(storage, "env-1", { ...reference, host: "github.com" })?.title,
1569 ).toBe("Cache the title");
1570 expect(
1571 readPullRequestDetailSnapshot(storage, "env-1", {
1572 ...reference,
1573 host: "github.example.com",
1574 }),
1575 ).toBeNull();
1576 });
1577
1578 it("keeps Forgejo ports isolated when recovering legacy snapshots", () => {
1579 const storage = makeStorage();
1580 const cached = detail({
1581 provider: "forgejo",
1582 url: "https://forge.example:8443/acme/web/pulls/7",
1583 });
1584 writePullRequestDetailSnapshot(storage, "env-1", reference, cached);
1585 const resolved = { ...reference, host: "forge.example:8443" };
1586 expect(readPullRequestDetailSnapshot(storage, "env-1", resolved)?.title).toBe(cached.title);
1587 expect(
1588 readPullRequestDetailSnapshot(storage, "env-1", {
1589 ...reference,
1590 host: "forge.example:9443",
1591 }),
1592 ).toBeNull();
1593 expect(
1594 readPullRequestDetailSnapshot(storage, "env-1", {
1595 ...reference,
1596 host: "forge.example",
1597 }),
1598 ).toBeNull();
1599 });
1600
1601 it.each(["github", "gitlab"] as const)(
1602 "retains portless %s snapshot identities for custom web ports",
1603 (provider) => {
1604 const storage = makeStorage();
1605 const host = `${provider}.example.com`;
1606 const hosted = { ...reference, host };
1607 const cached = detail({
1608 provider,
1609 url: `https://${host}:8443/acme/web/${provider === "github" ? "pull" : "-/merge_requests"}/7`,
1610 });
1611 writePullRequestDetailSnapshot(storage, "env-1", hosted, cached);
1612 expect(readPullRequestDetailSnapshot(storage, "env-1", hosted)?.title).toBe(cached.title);
1613 },
1614 );
1615
15141616 it("keeps a cached tab painted while the live read replaces the counts", () => {
15151617 const cached = detail();
15161618 const live = detail({ additions: 40, deletions: 9, title: "Cache the title" });
......@@ -1534,11 +1636,11 @@ describe("cached pull request detail", () => {
15341636 it("isolates stored and displayed details between hosts with the same repository and number", () => {
15351637 const storage = makeStorage();
15361638 const publicRef = { ...reference, host: "github.com" };
1537 const enterpriseRef = { ...reference, host: "github.example.com" };
1639 const enterpriseRef = { ...reference, host: "ghe.example.com" };
15381640 const publicDetail = detail();
15391641 const enterpriseDetail = detail({
15401642 title: "Enterprise change",
1541 url: "https://github.example.com/acme/web/pull/7",
1643 url: "https://ghe.example.com/acme/web/pull/7",
15421644 });
15431645 writePullRequestDetailSnapshot(storage, "env-1", publicRef, publicDetail);
15441646 expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull();
......@@ -1572,6 +1674,9 @@ describe("cached pull request detail", () => {
15721674 storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json");
15731675 expect(readPullRequestDetailSnapshot(storage, "env-1", reference)).toBeNull();
15741676 expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull();
1677 const hosted = { ...reference, host: "github.com" };
1678 writePullRequestDetailSnapshot(storage, "env-1", hosted, detail({ url: "invalid url" }));
1679 expect(readPullRequestDetailSnapshot(storage, "env-1", hosted)).toBeNull();
15751680 });
15761681});
15771682
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+35-10
......@@ -1,8 +1,8 @@
11import * as Schema from "effect/Schema";
2import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
32
43import {
54 PullRequestDetail,
5 pullRequestHostOf,
66 type PullRequestAction,
77 type PullRequestActor,
88 type PullRequestBaseComparison,
......@@ -15,6 +15,8 @@ import {
1515 type PullRequestMergeability,
1616 type PullRequestMergeMethod,
1717 type PullRequestReaction,
18 type PullRequestRef,
19 type RepositoryIdentity,
1820 type PullRequestReviewThread,
1921 type PullRequestState,
2022 type PullRequestUpdateMethod,
......@@ -1092,6 +1094,15 @@ export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): bo
10921094
10931095type SnapshotStorage = Pick<Storage, "getItem" | "setItem">;
10941096
1097export function resolvePullRequestReferenceHost(
1098 reference: PullRequestRef,
1099 identity: RepositoryIdentity | null | undefined,
1100): PullRequestRef {
1101 // Other providers may resolve an SSH remote to a different web authority on the server.
1102 if (reference.host !== undefined || identity?.provider !== "github") return reference;
1103 return { ...reference, host: pullRequestHostOf(identity, "github") };
1104}
1105
10951106export interface PullRequestDetailSnapshotRef {
10961107 readonly host?: string | undefined;
10971108 readonly projectId: string;
......@@ -1121,7 +1132,13 @@ export function readPullRequestDetailSnapshot(
11211132 reference: PullRequestDetailSnapshotRef,
11221133): PullRequestDetail | null {
11231134 try {
1124 const raw = storage?.getItem(pullRequestDetailSnapshotKey(environmentId, reference));
1135 const raw =
1136 storage?.getItem(pullRequestDetailSnapshotKey(environmentId, reference)) ??
1137 (reference.host === undefined
1138 ? null
1139 : storage?.getItem(
1140 pullRequestDetailSnapshotKey(environmentId, { ...reference, host: undefined }),
1141 ));
11251142 if (!raw) return null;
11261143 const decoded = decodeDetailSnapshot(JSON.parse(raw));
11271144 return decoded._tag === "Some"
......@@ -1157,14 +1174,22 @@ export function resolveDisplayedPullRequestDetail(input: {
11571174}): PullRequestDetail | null {
11581175 if (input.live !== null) return input.live;
11591176 if (
1160 input.cached !== null &&
1161 input.cached.projectId === input.reference.projectId &&
1162 input.cached.repository.toLowerCase() === input.reference.repository.toLowerCase() &&
1163 input.cached.number === input.reference.number &&
1164 (input.reference.host === undefined ||
1165 parseChangeRequestUrl(input.cached.url)?.host === input.reference.host.toLowerCase())
1177 input.cached === null ||
1178 input.cached.projectId !== input.reference.projectId ||
1179 input.cached.repository.toLowerCase() !== input.reference.repository.toLowerCase() ||
1180 input.cached.number !== input.reference.number
11661181 ) {
1167 return input.cached;
1182 return null;
1183 }
1184 if (input.reference.host === undefined) return input.cached;
1185 try {
1186 const url = new URL(input.cached.url);
1187 const host = input.cached.provider === "forgejo" ? url.host : url.hostname;
1188 return (url.protocol === "https:" || url.protocol === "http:") &&
1189 host.toLowerCase() === input.reference.host.toLowerCase()
1190 ? input.cached
1191 : null;
1192 } catch {
1193 return null;
11681194 }
1169 return null;
11701195}
apps/web/src/hooks/useOpenPanelPullRequestUrl.ts+25-14
......@@ -5,6 +5,7 @@ import { useMemo } from "react";
55import {
66 readPullRequestDetailSnapshot,
77 resolveDisplayedPullRequestDetail,
8 resolvePullRequestReferenceHost,
89} from "../components/pullRequest/pullRequestDetail.logic";
910import { gitHubPullRequestBrowserUrl } from "../lib/openPullRequestLink";
1011import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore";
......@@ -17,28 +18,38 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
1718 const surface = useRightPanelStore((state) =>
1819 selectActiveRightPanelSurface(state.byThreadKey, threadRef),
1920 );
20 const reference = surface?.kind === "pull-request" ? surface : null;
21 const environmentId = reference?.environmentId
22 ? EnvironmentId.make(reference.environmentId)
21 const requestedReference = surface?.kind === "pull-request" ? surface : null;
22 const environmentId = requestedReference?.environmentId
23 ? EnvironmentId.make(requestedReference.environmentId)
2324 : threadRef?.environmentId;
2425 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId ?? null);
2526 const project = useProject(
26 reference && environmentId
27 ? scopeProjectRef(environmentId, ProjectId.make(reference.projectId))
27 requestedReference && environmentId
28 ? scopeProjectRef(environmentId, ProjectId.make(requestedReference.projectId))
2829 : null,
2930 );
31 const reference = useMemo(() => {
32 if (requestedReference === null) return null;
33 const input = {
34 projectId: ProjectId.make(requestedReference.projectId),
35 repository: requestedReference.repository,
36 number: requestedReference.number,
37 };
38 return supportsMultiplePullRequests
39 ? resolvePullRequestReferenceHost(
40 {
41 ...input,
42 ...(requestedReference.host === undefined ? {} : { host: requestedReference.host }),
43 },
44 project?.repositoryIdentity,
45 )
46 : input;
47 }, [requestedReference, supportsMultiplePullRequests, project?.repositoryIdentity]);
3048 const detail = useEnvironmentQuery(
3149 reference && environmentId
3250 ? pullRequestEnvironment.detail({
3351 environmentId,
34 input: {
35 projectId: ProjectId.make(reference.projectId),
36 ...(supportsMultiplePullRequests && reference.host !== undefined
37 ? { host: reference.host }
38 : {}),
39 repository: reference.repository,
40 number: reference.number,
41 },
52 input: reference,
4253 })
4354 : null,
4455 ).data;
......@@ -55,7 +66,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
5566 );
5667 return reference
5768 ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ??
58 reference.url ??
69 requestedReference?.url ??
5970 gitHubPullRequestBrowserUrl(
6071 project?.repositoryIdentity,
6172 reference.repository,