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 {...@@ -109,6 +109,7 @@ import {
109import { PullRequestMergeControls } from "./PullRequestMergeControls";109import { PullRequestMergeControls } from "./PullRequestMergeControls";
110import { PullRequestStackCard } from "./PullRequestStackCard";110import { PullRequestStackCard } from "./PullRequestStackCard";
111import { usePullRequestStack, type PullRequestStackState } from "./usePullRequestStack";111import { usePullRequestStack, type PullRequestStackState } from "./usePullRequestStack";
112import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFallback";
112113
113const EMPTY_IDS: readonly string[] = [];114const EMPTY_IDS: readonly string[] = [];
114115
...@@ -286,6 +287,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP...@@ -286,6 +287,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
286 const insets = useSafeAreaInsets();287 const insets = useSafeAreaInsets();
287 const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);288 const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
288 const params = route.params;289 const params = route.params;
290 usePullRequestEnvironmentFallback(params);
289 const connected = usePullRequestConnected(params.environmentId);291 const connected = usePullRequestConnected(params.environmentId);
290 const [section, setSection] = useState<"overview" | "activity" | "guide">("overview");292 const [section, setSection] = useState<"overview" | "activity" | "guide">("overview");
291 // Keep each tab's native list and measured rows alive when it is hidden.293 // 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(...@@ -142,6 +142,7 @@ export function PullRequestsScreen(
142 );142 );
143 const sidebar = props.sidebar === true || primaryColumn !== null;143 const sidebar = props.sidebar === true || primaryColumn !== null;
144 const selectedKey = props.selectedPullRequestKey ?? primaryColumn?.selectedPullRequestKey;144 const selectedKey = props.selectedPullRequestKey ?? primaryColumn?.selectedPullRequestKey;
145 const connectedKey = connectedTargets.map((target) => target.environmentId).join("\n");
145 const entries = useMemo(146 const entries = useMemo(
146 () =>147 () =>
147 filterPullRequestList(148 filterPullRequestList(
...@@ -150,8 +151,9 @@ export function PullRequestsScreen(...@@ -150,8 +151,9 @@ export function PullRequestsScreen(
150 result.data?.viewers ?? {},151 result.data?.viewers ?? {},
151 query,152 query,
152 result.data.localSearchHosts,153 result.data.localSearchHosts,
154 new Set(connectedKey.split("\n")),
153 ),155 ),
154 [result.entries, controls, query, result.data],156 [result.entries, controls, query, result.data, connectedKey],
155 );157 );
156 const facets = useMemo(() => pullRequestListFacets(result.entries), [result.entries]);158 const facets = useMemo(() => pullRequestListFacets(result.entries), [result.entries]);
157 const [knownFacets, setKnownFacets] = useState({ environmentId, source: facets, ...facets });159 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", () => {...@@ -333,6 +333,19 @@ describe("mobile PR scan and filters", () => {
333 ),333 ),
334 ).toHaveLength(0);334 ).toHaveLength(0);
335 });335 });
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 });
336 it("sorts loaded rows and builds unique author and label options", () => {349 it("sorts loaded rows and builds unique author and label options", () => {
337 const rows = [350 const rows = [
338 entry(1, { labels: [{ name: "bug", color: null }] }),351 entry(1, { labels: [{ name: "bug", color: null }] }),
apps/mobile/src/features/pull-requests/pullRequestList.ts+8-2
...@@ -171,6 +171,7 @@ export function filterPullRequestList<...@@ -171,6 +171,7 @@ export function filterPullRequestList<
171 viewers: PullRequestListResult["viewers"],171 viewers: PullRequestListResult["viewers"],
172 query: string,172 query: string,
173 localSearchHosts: ReadonlySet<string>,173 localSearchHosts: ReadonlySet<string>,
174 connectedEnvironmentIds?: ReadonlySet<string>,
174) {175) {
175 const normalize = (value: string) => value.toLowerCase();176 const normalize = (value: string) => value.toLowerCase();
176 const words = query.toLowerCase().split(/\s+/).filter(Boolean);177 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
...@@ -210,11 +211,16 @@ export function filterPullRequestList<...@@ -210,11 +211,16 @@ export function filterPullRequestList<
210 }211 }
211 return true;212 return true;
212 });213 });
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));
214 const unique = new Map<string, Entry>();219 const unique = new Map<string, Entry>();
215 for (const entry of filtered) {220 for (const entry of filtered) {
216 const key = pullRequestListKey(entry);221 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);
218 }224 }
219 return [...unique.values()].sort((a, b) =>225 return [...unique.values()].sort((a, b) =>
220 controls.sort === "created"226 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}