From 5070a83a3248c537d3baaf063e719f179c49bb52 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 12 Oct 2026 20:54:11 -0700 Subject: [PATCH] fix(mobile): pull requests load from another environment when theirs is offline (#18698) Co-authored-by: Claude Opus 5.5 --- .../pull-requests/PullRequestRouteScreen.tsx | 2 + .../pull-requests/PullRequestsScreen.tsx | 4 +- .../pullRequestEnvironmentFallback.test.ts | 45 +++++++++++++++++++ .../pullRequestEnvironmentFallback.ts | 33 ++++++++++++++ .../pull-requests/pullRequestList.test.ts | 13 ++++++ .../features/pull-requests/pullRequestList.ts | 10 ++++- .../usePullRequestEnvironmentFallback.ts | 26 +++++++++++ 7 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts create mode 100644 apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts create mode 100644 apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 4f47ee169d4aba19ac17f98bb65023c7155dccd6..37fa732930a03f530124b4d2f478d0b03420920e 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -109,6 +109,7 @@ import { import { PullRequestMergeControls } from "./PullRequestMergeControls"; import { PullRequestStackCard } from "./PullRequestStackCard"; import { usePullRequestStack, type PullRequestStackState } from "./usePullRequestStack"; +import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFallback"; const EMPTY_IDS: readonly string[] = []; @@ -286,6 +287,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps("overview"); // Keep each tab's native list and measured rows alive when it is hidden. diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index c90cdc98e50155a8b865314f66a451f5010a5f3a..c37e957a4d1b1c731ffb54fc1fd79dc10f0a03cb 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -142,6 +142,7 @@ export function PullRequestsScreen( ); const sidebar = props.sidebar === true || primaryColumn !== null; const selectedKey = props.selectedPullRequestKey ?? primaryColumn?.selectedPullRequestKey; + const connectedKey = connectedTargets.map((target) => target.environmentId).join("\n"); const entries = useMemo( () => filterPullRequestList( @@ -150,8 +151,9 @@ export function PullRequestsScreen( result.data?.viewers ?? {}, query, result.data.localSearchHosts, + new Set(connectedKey.split("\n")), ), - [result.entries, controls, query, result.data], + [result.entries, controls, query, result.data, connectedKey], ); const facets = useMemo(() => pullRequestListFacets(result.entries), [result.entries]); const [knownFacets, setKnownFacets] = useState({ environmentId, source: facets, ...facets }); diff --git a/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts b/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ed5fe6b1835f116640682883d48745c7e0fa1da --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; +import { pullRequestFallbackEnvironment } from "./pullRequestEnvironmentFallback"; + +const project = (environmentId: string, id: string, canonicalKey: string) => + ({ + environmentId, + id, + repositoryIdentity: { canonicalKey }, + }) as unknown as EnvironmentProject; + +const projects = [ + project("laptop", "t3", "github.com/pingdotgg/t3code"), + project("desktop", "other", "github.com/pingdotgg/other"), + project("server", "t3-copy", "github.com/pingdotgg/t3code"), +]; +const target = { + environmentId: "laptop", + projectId: "t3", + repository: "pingdotgg/t3code", + host: "github.com", +}; + +describe("pullRequestFallbackEnvironment", () => { + it("moves to a connected copy of the repository when the original is down", () => { + expect( + pullRequestFallbackEnvironment(target, projects, new Set(["desktop", "server"])), + ).toEqual({ environmentId: "server", projectId: "t3-copy" }); + }); + + it("stays put while the original is connected or nothing else holds the repository", () => { + expect(pullRequestFallbackEnvironment(target, projects, new Set(["laptop", "server"]))).toBe( + null, + ); + expect(pullRequestFallbackEnvironment(target, projects, new Set(["desktop"]))).toBe(null); + }); + + it("derives the host from the original project when the route has none", () => { + const { host: _host, ...hostless } = target; + expect(pullRequestFallbackEnvironment(hostless, projects, new Set(["server"]))).toEqual({ + environmentId: "server", + projectId: "t3-copy", + }); + }); +}); diff --git a/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts b/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts new file mode 100644 index 0000000000000000000000000000000000000000..a756acca2038183547f421a161657cc2c7b45ce4 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts @@ -0,0 +1,33 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; + +export type PullRequestEnvironmentTarget = { + readonly environmentId: string; + readonly projectId: string; + readonly repository: string; + readonly host?: string; +}; + +/** + * Another connected environment holding the pull request's repository, for when the one it was + * opened in is unreachable. Null while the original is connected or no other copy exists. + */ +export function pullRequestFallbackEnvironment( + target: PullRequestEnvironmentTarget, + projects: ReadonlyArray, + connectedEnvironmentIds: ReadonlySet, +) { + if (connectedEnvironmentIds.has(target.environmentId)) return null; + const own = projects.find( + (project) => project.environmentId === target.environmentId && project.id === target.projectId, + ); + const host = target.host ?? own?.repositoryIdentity?.canonicalKey.split("/")[0]; + if (!host) return null; + const key = `${host}/${target.repository}`.toLowerCase(); + const copy = projects.find( + (project) => + project.environmentId !== target.environmentId && + connectedEnvironmentIds.has(project.environmentId) && + project.repositoryIdentity?.canonicalKey.toLowerCase() === key, + ); + return copy ? { environmentId: String(copy.environmentId), projectId: String(copy.id) } : null; +} diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts index 69d8d782059ccfbb82a6df29ba329ad83a6be11f..f3a085e02b453874a994fb3301ea8b2379157cfa 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts @@ -333,6 +333,19 @@ describe("mobile PR scan and filters", () => { ), ).toHaveLength(0); }); + it("lists a shared repository's row from a connected environment over an offline one", () => { + const rows = [ + { ...entry(1), environmentId: "laptop" }, + { ...entry(1), environmentId: "server" }, + ]; + const pick = (connected: ReadonlySet) => + filterPullRequestList(rows, DEFAULT_PR_LIST_CONTROLS, {}, "", new Set(), connected).map( + (row) => row.environmentId, + ); + expect(pick(new Set(["server"]))).toEqual(["server"]); + expect(pick(new Set(["laptop", "server"]))).toEqual(["laptop"]); + expect(pick(new Set())).toEqual(["laptop"]); + }); it("sorts loaded rows and builds unique author and label options", () => { const rows = [ entry(1, { labels: [{ name: "bug", color: null }] }), diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.ts b/apps/mobile/src/features/pull-requests/pullRequestList.ts index 922755e00306f11b8cd3ab1c2285d9a7411f260b..4527ba1e295d08839054f2dfbd3f31c361027ee6 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.ts @@ -171,6 +171,7 @@ export function filterPullRequestList< viewers: PullRequestListResult["viewers"], query: string, localSearchHosts: ReadonlySet, + connectedEnvironmentIds?: ReadonlySet, ) { const normalize = (value: string) => value.toLowerCase(); const words = query.toLowerCase().split(/\s+/).filter(Boolean); @@ -210,11 +211,16 @@ export function filterPullRequestList< } return true; }); - // The same repository can be available on multiple machines. Pick its first matching source. + // The same repository can be available on multiple machines. Pick its first matching source, + // unless that machine is unreachable and another one can answer: its rows may only be cached. + const reachable = (entry: Entry) => + !connectedEnvironmentIds || + (entry.environmentId !== undefined && connectedEnvironmentIds.has(entry.environmentId)); const unique = new Map(); for (const entry of filtered) { const key = pullRequestListKey(entry); - if (!unique.has(key)) unique.set(key, entry); + const held = unique.get(key); + if (!held || (!reachable(held) && reachable(entry))) unique.set(key, entry); } return [...unique.values()].sort((a, b) => controls.sort === "created" diff --git a/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9ab9b81b25080e38ffce73a1d86d31a259e8343 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts @@ -0,0 +1,26 @@ +import { useNavigation } from "@react-navigation/native"; +import { useEffect, useMemo } from "react"; + +import { useProjects } from "../../state/entities"; +import { useWorkspaceEnvironments } from "../../state/workspace"; +import { + pullRequestFallbackEnvironment, + type PullRequestEnvironmentTarget, +} from "./pullRequestEnvironmentFallback"; + +/** Move a pull request route to a connected copy of its repository when its own is down. */ +export function usePullRequestEnvironmentFallback(params: PullRequestEnvironmentTarget) { + const navigation = useNavigation(); + const projects = useProjects(); + const connected = useWorkspaceEnvironments() + .filter((environment) => environment.connectionState === "connected") + .map((environment) => String(environment.environmentId)) + .join("\n"); + const fallback = useMemo( + () => pullRequestFallbackEnvironment(params, projects, new Set(connected.split("\n"))), + [params, projects, connected], + ); + useEffect(() => { + if (fallback) navigation.setParams(fallback); + }, [fallback, navigation]); +} -- 2.54.0