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 {...@@ -145,6 +145,7 @@ import {
145 PULL_REQUEST_MERGE_METHOD_LABELS,145 PULL_REQUEST_MERGE_METHOD_LABELS,
146 readableFailure,146 readableFailure,
147 readPullRequestDetailSnapshot,147 readPullRequestDetailSnapshot,
148 resolvePullRequestReferenceHost,
148 resolveDisplayedPullRequestDetail,149 resolveDisplayedPullRequestDetail,
149 resolvePullRequestPrimaryControl,150 resolvePullRequestPrimaryControl,
150 allowsSinglePullRequestMerge,151 allowsSinglePullRequestMerge,
...@@ -523,18 +524,23 @@ export function PullRequestDetailPanel({...@@ -523,18 +524,23 @@ export function PullRequestDetailPanel({
523 onBack?: (() => void) | undefined;524 onBack?: (() => void) | undefined;
524}) {525}) {
525 const environmentConfigs = useServerConfigs();526 const environmentConfigs = useServerConfigs();
527 const projects = useProjects();
528 const repositoryIdentity = projects.find(
529 (project) =>
530 project.id === requestedReference.projectId && project.environmentId === environmentId,
531 )?.repositoryIdentity;
526 const supportsThreadPullRequests =532 const supportsThreadPullRequests =
527 environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true;533 environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true;
528 const reference = useMemo(534 const reference = useMemo(
529 () =>535 () =>
530 supportsThreadPullRequests536 supportsThreadPullRequests
531 ? requestedReference537 ? resolvePullRequestReferenceHost(requestedReference, repositoryIdentity)
532 : {538 : {
533 projectId: requestedReference.projectId,539 projectId: requestedReference.projectId,
534 repository: requestedReference.repository,540 repository: requestedReference.repository,
535 number: requestedReference.number,541 number: requestedReference.number,
536 },542 },
537 [requestedReference, supportsThreadPullRequests],543 [requestedReference, repositoryIdentity, supportsThreadPullRequests],
538 );544 );
539 const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`;545 const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`;
540 const matchingListEntry =546 const matchingListEntry =
...@@ -881,7 +887,6 @@ export function PullRequestDetailPanel({...@@ -881,7 +887,6 @@ export function PullRequestDetailPanel({
881 const newThread = useNewThreadHandler();887 const newThread = useNewThreadHandler();
882 const { environments } = useEnvironments();888 const { environments } = useEnvironments();
883 const primaryEnvironmentId = usePrimaryEnvironmentId();889 const primaryEnvironmentId = usePrimaryEnvironmentId();
884 const projects = useProjects();
885 const unavailableGitHubUrl = useMemo(() => {890 const unavailableGitHubUrl = useMemo(() => {
886 const identity = projects.find(891 const identity = projects.find(
887 (project) => project.id === reference.projectId && project.environmentId === environmentId,892 (project) => project.id === reference.projectId && project.environmentId === environmentId,
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+108-3
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { resolvePlanFollowUpSubmission } from "../../proposedPlan";1import { resolvePlanFollowUpSubmission } from "../../proposedPlan";
2import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend";2import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend";
3import {3import {
4 ProjectId,
4 PullRequestAction,5 PullRequestAction,
5 type PullRequestCheck,6 type PullRequestCheck,
6 type PullRequestComment,7 type PullRequestComment,
...@@ -40,6 +41,7 @@ import {...@@ -40,6 +41,7 @@ import {
40 readableFailure,41 readableFailure,
41 readPullRequestDetailSnapshot,42 readPullRequestDetailSnapshot,
42 resolveDisplayedPullRequestDetail,43 resolveDisplayedPullRequestDetail,
44 resolvePullRequestReferenceHost,
43 resolvePullRequestPrimaryControl,45 resolvePullRequestPrimaryControl,
44 allowsSinglePullRequestMerge,46 allowsSinglePullRequestMerge,
45 shouldRefreshPullRequestActivity,47 shouldRefreshPullRequestActivity,
...@@ -1440,7 +1442,7 @@ describe("which actions need the host read again after they run", () => {...@@ -1440,7 +1442,7 @@ describe("which actions need the host read again after they run", () => {
1440});1442});
14411443
1442describe("cached pull request detail", () => {1444describe("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 };
1444 const detail = (overrides: Partial<PullRequestDetail> = {}): PullRequestDetail =>1446 const detail = (overrides: Partial<PullRequestDetail> = {}): PullRequestDetail =>
1445 ({1447 ({
1446 provider: "github",1448 provider: "github",
...@@ -1511,6 +1513,106 @@ describe("cached pull request detail", () => {...@@ -1511,6 +1513,106 @@ describe("cached pull request detail", () => {
1511 expect(snapshot?.deletions).toBe(3);1513 expect(snapshot?.deletions).toBe(3);
1512 });1514 });
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
1514 it("keeps a cached tab painted while the live read replaces the counts", () => {1616 it("keeps a cached tab painted while the live read replaces the counts", () => {
1515 const cached = detail();1617 const cached = detail();
1516 const live = detail({ additions: 40, deletions: 9, title: "Cache the title" });1618 const live = detail({ additions: 40, deletions: 9, title: "Cache the title" });
...@@ -1534,11 +1636,11 @@ describe("cached pull request detail", () => {...@@ -1534,11 +1636,11 @@ describe("cached pull request detail", () => {
1534 it("isolates stored and displayed details between hosts with the same repository and number", () => {1636 it("isolates stored and displayed details between hosts with the same repository and number", () => {
1535 const storage = makeStorage();1637 const storage = makeStorage();
1536 const publicRef = { ...reference, host: "github.com" };1638 const publicRef = { ...reference, host: "github.com" };
1537 const enterpriseRef = { ...reference, host: "github.example.com" };1639 const enterpriseRef = { ...reference, host: "ghe.example.com" };
1538 const publicDetail = detail();1640 const publicDetail = detail();
1539 const enterpriseDetail = detail({1641 const enterpriseDetail = detail({
1540 title: "Enterprise change",1642 title: "Enterprise change",
1541 url: "https://github.example.com/acme/web/pull/7",1643 url: "https://ghe.example.com/acme/web/pull/7",
1542 });1644 });
1543 writePullRequestDetailSnapshot(storage, "env-1", publicRef, publicDetail);1645 writePullRequestDetailSnapshot(storage, "env-1", publicRef, publicDetail);
1544 expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull();1646 expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull();
...@@ -1572,6 +1674,9 @@ describe("cached pull request detail", () => {...@@ -1572,6 +1674,9 @@ describe("cached pull request detail", () => {
1572 storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json");1674 storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json");
1573 expect(readPullRequestDetailSnapshot(storage, "env-1", reference)).toBeNull();1675 expect(readPullRequestDetailSnapshot(storage, "env-1", reference)).toBeNull();
1574 expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull();1676 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();
1575 });1680 });
1576});1681});
15771682
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+35-10
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
32
4import {3import {
5 PullRequestDetail,4 PullRequestDetail,
5 pullRequestHostOf,
6 type PullRequestAction,6 type PullRequestAction,
7 type PullRequestActor,7 type PullRequestActor,
8 type PullRequestBaseComparison,8 type PullRequestBaseComparison,
...@@ -15,6 +15,8 @@ import {...@@ -15,6 +15,8 @@ import {
15 type PullRequestMergeability,15 type PullRequestMergeability,
16 type PullRequestMergeMethod,16 type PullRequestMergeMethod,
17 type PullRequestReaction,17 type PullRequestReaction,
18 type PullRequestRef,
19 type RepositoryIdentity,
18 type PullRequestReviewThread,20 type PullRequestReviewThread,
19 type PullRequestState,21 type PullRequestState,
20 type PullRequestUpdateMethod,22 type PullRequestUpdateMethod,
...@@ -1092,6 +1094,15 @@ export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): bo...@@ -1092,6 +1094,15 @@ export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): bo
10921094
1093type SnapshotStorage = Pick<Storage, "getItem" | "setItem">;1095type 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
1095export interface PullRequestDetailSnapshotRef {1106export interface PullRequestDetailSnapshotRef {
1096 readonly host?: string | undefined;1107 readonly host?: string | undefined;
1097 readonly projectId: string;1108 readonly projectId: string;
...@@ -1121,7 +1132,13 @@ export function readPullRequestDetailSnapshot(...@@ -1121,7 +1132,13 @@ export function readPullRequestDetailSnapshot(
1121 reference: PullRequestDetailSnapshotRef,1132 reference: PullRequestDetailSnapshotRef,
1122): PullRequestDetail | null {1133): PullRequestDetail | null {
1123 try {1134 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 ));
1125 if (!raw) return null;1142 if (!raw) return null;
1126 const decoded = decodeDetailSnapshot(JSON.parse(raw));1143 const decoded = decodeDetailSnapshot(JSON.parse(raw));
1127 return decoded._tag === "Some"1144 return decoded._tag === "Some"
...@@ -1157,14 +1174,22 @@ export function resolveDisplayedPullRequestDetail(input: {...@@ -1157,14 +1174,22 @@ export function resolveDisplayedPullRequestDetail(input: {
1157}): PullRequestDetail | null {1174}): PullRequestDetail | null {
1158 if (input.live !== null) return input.live;1175 if (input.live !== null) return input.live;
1159 if (1176 if (
1160 input.cached !== null &&1177 input.cached === null ||
1161 input.cached.projectId === input.reference.projectId &&1178 input.cached.projectId !== input.reference.projectId ||
1162 input.cached.repository.toLowerCase() === input.reference.repository.toLowerCase() &&1179 input.cached.repository.toLowerCase() !== input.reference.repository.toLowerCase() ||
1163 input.cached.number === input.reference.number &&1180 input.cached.number !== input.reference.number
1164 (input.reference.host === undefined ||
1165 parseChangeRequestUrl(input.cached.url)?.host === input.reference.host.toLowerCase())
1166 ) {1181 ) {
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;
1168 }1194 }
1169 return null;
1170}1195}
apps/web/src/hooks/useOpenPanelPullRequestUrl.ts+25-14
...@@ -5,6 +5,7 @@ import { useMemo } from "react";...@@ -5,6 +5,7 @@ import { useMemo } from "react";
5import {5import {
6 readPullRequestDetailSnapshot,6 readPullRequestDetailSnapshot,
7 resolveDisplayedPullRequestDetail,7 resolveDisplayedPullRequestDetail,
8 resolvePullRequestReferenceHost,
8} from "../components/pullRequest/pullRequestDetail.logic";9} from "../components/pullRequest/pullRequestDetail.logic";
9import { gitHubPullRequestBrowserUrl } from "../lib/openPullRequestLink";10import { gitHubPullRequestBrowserUrl } from "../lib/openPullRequestLink";
10import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore";11import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore";
...@@ -17,28 +18,38 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {...@@ -17,28 +18,38 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
17 const surface = useRightPanelStore((state) =>18 const surface = useRightPanelStore((state) =>
18 selectActiveRightPanelSurface(state.byThreadKey, threadRef),19 selectActiveRightPanelSurface(state.byThreadKey, threadRef),
19 );20 );
20 const reference = surface?.kind === "pull-request" ? surface : null;21 const requestedReference = surface?.kind === "pull-request" ? surface : null;
21 const environmentId = reference?.environmentId22 const environmentId = requestedReference?.environmentId
22 ? EnvironmentId.make(reference.environmentId)23 ? EnvironmentId.make(requestedReference.environmentId)
23 : threadRef?.environmentId;24 : threadRef?.environmentId;
24 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId ?? null);25 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId ?? null);
25 const project = useProject(26 const project = useProject(
26 reference && environmentId27 requestedReference && environmentId
27 ? scopeProjectRef(environmentId, ProjectId.make(reference.projectId))28 ? scopeProjectRef(environmentId, ProjectId.make(requestedReference.projectId))
28 : null,29 : null,
29 );30 );
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]);
30 const detail = useEnvironmentQuery(48 const detail = useEnvironmentQuery(
31 reference && environmentId49 reference && environmentId
32 ? pullRequestEnvironment.detail({50 ? pullRequestEnvironment.detail({
33 environmentId,51 environmentId,
34 input: {52 input: reference,
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 },
42 })53 })
43 : null,54 : null,
44 ).data;55 ).data;
...@@ -55,7 +66,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {...@@ -55,7 +66,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
55 );66 );
56 return reference67 return reference
57 ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ??68 ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ??
58 reference.url ??69 requestedReference?.url ??
59 gitHubPullRequestBrowserUrl(70 gitHubPullRequestBrowserUrl(
60 project?.repositoryIdentity,71 project?.repositoryIdentity,
61 reference.repository,72 reference.repository,