1import { useNavigation, useRoute, StackActions } from "@react-navigation/native";
2import { nativePullRequestDestination } from "../lib/pullRequestNavigation";
3import { useCallback, useRef } from "react";
4import { useAtomValue } from "@effect/atom-react";
5import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
6import {
7 isAtomCommandInterrupted,
8 squashAtomCommandFailure,
9} from "@t3tools/client-runtime/state/runtime";
10import { Alert, Platform } from "react-native";
11import { CommonActions } from "@react-navigation/native";
12import { androidWorkspaceNavigationState } from "../lib/android-workspace-navigation";
13import {
14 AuthPreviewOperateScope,
15 EnvironmentId,
16 FILL_PREVIEW_VIEWPORT,
17 MERGEBOT_BROWSER_THREAD_ID,
18 ThreadId,
19 mergebotPullRequestUrl,
20 parseMergebotPullRequestUrl,
21} from "@t3tools/contracts";
22import { useThreadShell } from "../state/entities";
23import { previewEnvironment } from "../state/preview";
24import { environmentServerConfigsAtom } from "../state/server";
25import { readEnvironmentScope } from "../state/session";
26import { useAtomCommand } from "../state/use-atom-command";
27import { useAtomQueryRunner } from "../state/use-atom-query-runner";
28import { tryOpenExternalUrl } from "../lib/openExternalUrl";
29
30/** Use the thread's environment; the shared router resolves the repository's project there. */
31export function useOpenPullRequest(options?: {
32 readonly replace?: boolean;
33 readonly context?: { readonly environmentId: string; readonly projectId: string };
34}) {
35 const navigation = useNavigation();
36 const route = useRoute();
37 const routeContext = route.params as
38 | {
39 environmentId?: string | string[];
40 projectId?: string | string[];
41 threadId?: string | string[];
42 }
43 | undefined;
44 const first = (value: string | string[] | undefined) => {
45 const text = Array.isArray(value) ? value[0] : value;
46 return text?.trim() ? text : undefined;
47 };
48 const environmentId = options?.context?.environmentId ?? first(routeContext?.environmentId);
49 const threadId = first(routeContext?.threadId);
50 const thread = useThreadShell(
51 environmentId && threadId
52 ? { environmentId: EnvironmentId.make(environmentId), threadId: ThreadId.make(threadId) }
53 : null,
54 );
55 const projectId =
56 options?.context?.projectId ?? first(routeContext?.projectId) ?? thread?.projectId;
57 const replace = options?.replace;
58 const configurations = useAtomValue(environmentServerConfigsAtom);
59 const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false });
60 const listPreviews = useAtomQueryRunner(previewEnvironment.list, {
61 refresh: true,
62 reportFailure: false,
63 });
64 const busy = useRef(false);
65 return useCallback(
66 async (url: string) => {
67 const id = environmentId ? EnvironmentId.make(environmentId) : null;
68 const configuration = id ? configurations.get(id) : undefined;
69 const parsed: ChangeRequestLink | null =
70 parseMergebotPullRequestUrl(configuration?.mergebot, url) ?? parseChangeRequestUrl(url);
71 const mergebotUrl = parsed
72 ? mergebotPullRequestUrl(configuration?.mergebot, {
73 host: parsed.authority ?? parsed.host,
74 repository: parsed.repository,
75 number: parsed.number,
76 })
77 : null;
78 if (id && mergebotUrl) {
79 if (busy.current) return true;
80 if (
81 !configuration?.environment.capabilities.serverBrowser ||
82 !readEnvironmentScope(id, AuthPreviewOperateScope)
83 ) {
84 Alert.alert(
85 "Mergebot browser unavailable",
86 "Connect with browser access to an environment that can open Mergebot.",
87 );
88 return true;
89 }
90 busy.current = true;
91 try {
92 const owner = ThreadId.make(threadId ?? MERGEBOT_BROWSER_THREAD_ID);
93 const listed = await listPreviews({ environmentId: id, input: { threadId: owner } });
94 if (listed._tag === "Failure") {
95 if (isAtomCommandInterrupted(listed)) return true;
96 throw squashAtomCommandFailure(listed);
97 }
98 const matching = listed.value.sessions.find(
99 (session) =>
100 session.runtime === "server" &&
101 session.navStatus._tag !== "Idle" &&
102 session.navStatus.url === mergebotUrl,
103 );
104 const opened = matching
105 ? null
106 : await openPreview({
107 environmentId: id,
108 input: {
109 threadId: owner,
110 url: mergebotUrl,
111 runtime: "server",
112 viewport: FILL_PREVIEW_VIEWPORT,
113 },
114 });
115 if (opened?._tag === "Failure") {
116 if (isAtomCommandInterrupted(opened)) return true;
117 throw squashAtomCommandFailure(opened);
118 }
119 navigation.navigate("ThreadBrowserPreview", {
120 environmentId: id,
121 threadId: owner,
122 tabId: matching?.tabId ?? (opened?._tag === "Success" ? opened.value.tabId : undefined),
123 });
124 return true;
125 } catch (error) {
126 Alert.alert(
127 "Could not open Mergebot",
128 error instanceof Error ? error.message : "Try opening the pull request again.",
129 );
130 return true;
131 } finally {
132 busy.current = false;
133 }
134 }
135 const params = nativePullRequestDestination(
136 url,
137 environmentId && projectId
138 ? { environmentId: String(environmentId), projectId: String(projectId) }
139 : null,
140 );
141 if (!params) return tryOpenExternalUrl(url, "pull-request");
142 if (Platform.OS === "android")
143 navigation.dispatch((state) =>
144 CommonActions.reset(
145 androidWorkspaceNavigationState(state, { name: "PullRequest", params }),
146 ),
147 );
148 else if (replace) navigation.dispatch(StackActions.replace("PullRequest", params));
149 else navigation.navigate("PullRequest", params);
150 return true;
151 },
152 [
153 configurations,
154 environmentId,
155 listPreviews,
156 navigation,
157 openPreview,
158 projectId,
159 replace,
160 threadId,
161 ],
162 );
163}