authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-12 20:54:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 20:54:11-07:00
log5070a83a3248c537d3baaf063e719f179c49bb52
treebef860d3bc0fec23e4eec6a15fd885b618bb9028
parent80664d5266a1be8c5989bac352ee5e060bbbdf4a
signature Signed by PGP key B5690EEEBB952194

fix(mobile): pull requests load from another environment when theirs is offline (#18698)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

7 files changed, 130 insertions(+), 3 deletions(-)

apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx+2
......@@ -109,6 +109,7 @@ import {
109109import { PullRequestMergeControls } from "./PullRequestMergeControls";
110110import { PullRequestStackCard } from "./PullRequestStackCard";
111111import { usePullRequestStack, type PullRequestStackState } from "./usePullRequestStack";
112import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFallback";
112113
113114const EMPTY_IDS: readonly string[] = [];
114115
......@@ -286,6 +287,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
286287 const insets = useSafeAreaInsets();
287288 const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
288289 const params = route.params;
290 usePullRequestEnvironmentFallback(params);
289291 const connected = usePullRequestConnected(params.environmentId);
290292 const [section, setSection] = useState<"overview" | "activity" | "guide">("overview");
291293 // Keep each tab's native list and measured rows alive when it is hidden.
apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx+3-1
......@@ -142,6 +142,7 @@ export function PullRequestsScreen(
142142 );
143143 const sidebar = props.sidebar === true || primaryColumn !== null;
144144 const selectedKey = props.selectedPullRequestKey ?? primaryColumn?.selectedPullRequestKey;
145 const connectedKey = connectedTargets.map((target) => target.environmentId).join("\n");
145146 const entries = useMemo(
146147 () =>
147148 filterPullRequestList(
......@@ -150,8 +151,9 @@ export function PullRequestsScreen(
150151 result.data?.viewers ?? {},
151152 query,
152153 result.data.localSearchHosts,
154 new Set(connectedKey.split("\n")),
153155 ),
154 [result.entries, controls, query, result.data],
156 [result.entries, controls, query, result.data, connectedKey],
155157 );
156158 const facets = useMemo(() => pullRequestListFacets(result.entries), [result.entries]);
157159 const [knownFacets, setKnownFacets] = useState({ environmentId, source: facets, ...facets });
apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts created+45
......@@ -0,0 +1,45 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { EnvironmentProject } from "@t3tools/client-runtime/state/models";
3import { pullRequestFallbackEnvironment } from "./pullRequestEnvironmentFallback";
4
5const project = (environmentId: string, id: string, canonicalKey: string) =>
6 ({
7 environmentId,
8 id,
9 repositoryIdentity: { canonicalKey },
10 }) as unknown as EnvironmentProject;
11
12const projects = [
13 project("laptop", "t3", "github.com/pingdotgg/t3code"),
14 project("desktop", "other", "github.com/pingdotgg/other"),
15 project("server", "t3-copy", "github.com/pingdotgg/t3code"),
16];
17const target = {
18 environmentId: "laptop",
19 projectId: "t3",
20 repository: "pingdotgg/t3code",
21 host: "github.com",
22};
23
24describe("pullRequestFallbackEnvironment", () => {
25 it("moves to a connected copy of the repository when the original is down", () => {
26 expect(
27 pullRequestFallbackEnvironment(target, projects, new Set(["desktop", "server"])),
28 ).toEqual({ environmentId: "server", projectId: "t3-copy" });
29 });
30
31 it("stays put while the original is connected or nothing else holds the repository", () => {
32 expect(pullRequestFallbackEnvironment(target, projects, new Set(["laptop", "server"]))).toBe(
33 null,
34 );
35 expect(pullRequestFallbackEnvironment(target, projects, new Set(["desktop"]))).toBe(null);
36 });
37
38 it("derives the host from the original project when the route has none", () => {
39 const { host: _host, ...hostless } = target;
40 expect(pullRequestFallbackEnvironment(hostless, projects, new Set(["server"]))).toEqual({
41 environmentId: "server",
42 projectId: "t3-copy",
43 });
44 });
45});
apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts created+33
......@@ -0,0 +1,33 @@
1import type { EnvironmentProject } from "@t3tools/client-runtime/state/models";
2
3export type PullRequestEnvironmentTarget = {
4 readonly environmentId: string;
5 readonly projectId: string;
6 readonly repository: string;
7 readonly host?: string;
8};
9
10/**
11 * Another connected environment holding the pull request's repository, for when the one it was
12 * opened in is unreachable. Null while the original is connected or no other copy exists.
13 */
14export function pullRequestFallbackEnvironment(
15 target: PullRequestEnvironmentTarget,
16 projects: ReadonlyArray<EnvironmentProject>,
17 connectedEnvironmentIds: ReadonlySet<string>,
18) {
19 if (connectedEnvironmentIds.has(target.environmentId)) return null;
20 const own = projects.find(
21 (project) => project.environmentId === target.environmentId && project.id === target.projectId,
22 );
23 const host = target.host ?? own?.repositoryIdentity?.canonicalKey.split("/")[0];
24 if (!host) return null;
25 const key = `${host}/${target.repository}`.toLowerCase();
26 const copy = projects.find(
27 (project) =>
28 project.environmentId !== target.environmentId &&
29 connectedEnvironmentIds.has(project.environmentId) &&
30 project.repositoryIdentity?.canonicalKey.toLowerCase() === key,
31 );
32 return copy ? { environmentId: String(copy.environmentId), projectId: String(copy.id) } : null;
33}
apps/mobile/src/features/pull-requests/pullRequestList.test.ts+13
......@@ -333,6 +333,19 @@ describe("mobile PR scan and filters", () => {
333333 ),
334334 ).toHaveLength(0);
335335 });
336 it("lists a shared repository's row from a connected environment over an offline one", () => {
337 const rows = [
338 { ...entry(1), environmentId: "laptop" },
339 { ...entry(1), environmentId: "server" },
340 ];
341 const pick = (connected: ReadonlySet<string>) =>
342 filterPullRequestList(rows, DEFAULT_PR_LIST_CONTROLS, {}, "", new Set(), connected).map(
343 (row) => row.environmentId,
344 );
345 expect(pick(new Set(["server"]))).toEqual(["server"]);
346 expect(pick(new Set(["laptop", "server"]))).toEqual(["laptop"]);
347 expect(pick(new Set())).toEqual(["laptop"]);
348 });
336349 it("sorts loaded rows and builds unique author and label options", () => {
337350 const rows = [
338351 entry(1, { labels: [{ name: "bug", color: null }] }),
apps/mobile/src/features/pull-requests/pullRequestList.ts+8-2
......@@ -171,6 +171,7 @@ export function filterPullRequestList<
171171 viewers: PullRequestListResult["viewers"],
172172 query: string,
173173 localSearchHosts: ReadonlySet<string>,
174 connectedEnvironmentIds?: ReadonlySet<string>,
174175) {
175176 const normalize = (value: string) => value.toLowerCase();
176177 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
......@@ -210,11 +211,16 @@ export function filterPullRequestList<
210211 }
211212 return true;
212213 });
213 // The same repository can be available on multiple machines. Pick its first matching source.
214 // The same repository can be available on multiple machines. Pick its first matching source,
215 // unless that machine is unreachable and another one can answer: its rows may only be cached.
216 const reachable = (entry: Entry) =>
217 !connectedEnvironmentIds ||
218 (entry.environmentId !== undefined && connectedEnvironmentIds.has(entry.environmentId));
214219 const unique = new Map<string, Entry>();
215220 for (const entry of filtered) {
216221 const key = pullRequestListKey(entry);
217 if (!unique.has(key)) unique.set(key, entry);
222 const held = unique.get(key);
223 if (!held || (!reachable(held) && reachable(entry))) unique.set(key, entry);
218224 }
219225 return [...unique.values()].sort((a, b) =>
220226 controls.sort === "created"
apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts created+26
......@@ -0,0 +1,26 @@
1import { useNavigation } from "@react-navigation/native";
2import { useEffect, useMemo } from "react";
3
4import { useProjects } from "../../state/entities";
5import { useWorkspaceEnvironments } from "../../state/workspace";
6import {
7 pullRequestFallbackEnvironment,
8 type PullRequestEnvironmentTarget,
9} from "./pullRequestEnvironmentFallback";
10
11/** Move a pull request route to a connected copy of its repository when its own is down. */
12export function usePullRequestEnvironmentFallback(params: PullRequestEnvironmentTarget) {
13 const navigation = useNavigation();
14 const projects = useProjects();
15 const connected = useWorkspaceEnvironments()
16 .filter((environment) => environment.connectionState === "connected")
17 .map((environment) => String(environment.environmentId))
18 .join("\n");
19 const fallback = useMemo(
20 () => pullRequestFallbackEnvironment(params, projects, new Set(connected.split("\n"))),
21 [params, projects, connected],
22 );
23 useEffect(() => {
24 if (fallback) navigation.setParams(fallback);
25 }, [fallback, navigation]);
26}