1import { useNavigation, useNavigationState, useRoute } from "@react-navigation/native";
2import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";
3import {
4 AuthOrchestrationOperateScope,
5 AuthSourceControlWriteScope,
6 ThreadId,
7 type PullRequestDetail,
8 type PullRequestLinkedThreadsResult,
9} from "@t3tools/contracts";
10import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
11import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
12import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
13import { useRef, useState } from "react";
14import { Alert, Platform } from "react-native";
15import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
16import { pullRequestComposerContext } from "../../lib/composerContext";
17import { scopedThreadKey } from "../../lib/scopedEntities";
18import { uuidv4 } from "../../lib/uuid";
19import { useEnvironmentServerConfig } from "../../state/entities";
20import { composerPullRequests } from "../../state/pull-requests";
21import { useEnvironmentQuery } from "../../state/query";
22import { useEnvironmentScope } from "../../state/session";
23import {
24 createNewTaskDraft,
25 getComposerDraftSnapshot,
26 insertComposerDraftContext,
27 setComposerDraftText,
28 updateComposerDraftSettings,
29 waitForComposerDraftsLoaded,
30} from "../../state/use-composer-drafts";
31import { usePullRequestConnected } from "./usePullRequestConnected";
32import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
33import {
34 appendPullRequestAgentPrompt,
35 pullRequestAgentPrompt,
36 type PullRequestAgentTask,
37} from "./pullRequestAgentTasks";
38
39type LinkedThread = PullRequestLinkedThreadsResult["threads"][number];
40
41/** Stay in the PR's environment when entering a thread or creating its draft. */
42export function usePullRequestThreads(
43 params: PullRequestParams,
44 pr: PullRequestDetail | null,
45 filePath?: string,
46) {
47 const target = pullRequestTarget(params);
48 const config = useEnvironmentServerConfig(target.environmentId);
49 const supported = threadPullRequestLinkMode(config?.environment.capabilities) === "multiple";
50 const parsed = pr ? parseChangeRequestUrl(pr.url) : null;
51 const reference = {
52 ...target,
53 input: { ...target.input, ...(parsed ? normalizeThreadPullRequestKey(parsed) : {}) },
54 };
55 const key = JSON.stringify(reference);
56 const route = useRoute();
57 // The iOS split host projects detail screens beneath a synthetic workspace
58 // route. Its parent's focus flag is not the visible detail's focus flag.
59 const focused = useNavigationState((state) => state.routes[state.index]?.key === route.key);
60 const relations = useEnvironmentQuery(
61 pr && supported && focused ? composerPullRequests.linkedThreads(reference) : null,
62 );
63 const [lastRelations, setLastRelations] = useState({ key, data: relations.data });
64 if (relations.data && (lastRelations.key !== key || relations.data !== lastRelations.data))
65 setLastRelations({ key, data: relations.data });
66 const threads =
67 (relations.data ?? (lastRelations.key === key ? lastRelations.data : null))?.threads ?? [];
68 const connected = usePullRequestConnected(params.environmentId);
69 const canOperate = useEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope);
70 // Composing offline only writes a local draft. The outbox checks grants on delivery.
71 const canAsk = !connected || canOperate;
72 const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope);
73 const navigation = useNavigation();
74 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
75 const pending = useRef(false);
76 const previousPrompts = useRef(new Map<string, string>());
77 const openThread = (thread: LinkedThread) => {
78 const destination = { environmentId: params.environmentId, threadId: thread.id };
79 if (Platform.OS === "android") selectAndroidWorkspace({ name: "Thread", params: destination });
80 else navigation.navigate("Thread", destination);
81 };
82 const [busy, setBusy] = useState(false);
83 const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => {
84 if (!pr || !canAsk || pending.current) return;
85 pending.current = true;
86 setBusy(true);
87 try {
88 if (connected && !thread && !canCheckout)
89 throw new Error("Starting work from this PR requires source control write access.");
90 await waitForComposerDraftsLoaded();
91 const draftKey = thread
92 ? scopedThreadKey(target.environmentId, ThreadId.make(thread.id))
93 : createNewTaskDraft({
94 environmentId: target.environmentId,
95 projectId: target.input.projectId,
96 });
97 if (!thread)
98 updateComposerDraftSettings(draftKey, {
99 workspaceSelection: {
100 mode: "worktree",
101 branch: pr.headBranch,
102 worktreePath: null,
103 startFromOrigin: false,
104 pullRequestReference: pr.url,
105 },
106 });
107 const context = pullRequestComposerContext(
108 {
109 number: pr.number,
110 title: pr.title,
111 url: pr.url,
112 headBranch: pr.headBranch,
113 baseBranch: pr.baseBranch,
114 state: pr.state,
115 isDraft: pr.isDraft,
116 },
117 uuidv4(),
118 filePath,
119 );
120 const draft = getComposerDraftSnapshot(draftKey);
121 const attached = draft.context?.records.some(
122 (record) =>
123 record.kind === "review-comment" &&
124 "pullRequest" in record &&
125 record.pullRequest?.url === pr.url &&
126 record.filePath === context.filePath,
127 );
128 if (
129 !attached &&
130 !insertComposerDraftContext(
131 draftKey,
132 {
133 text: `${formatComposerContextReference(context)} `,
134 context: { version: 1, records: [context] },
135 },
136 { text: draft.text, start: draft.text.length, end: draft.text.length },
137 )
138 )
139 throw new Error("Remove some context from this thread's draft, then try again.");
140 const withContext = getComposerDraftSnapshot(draftKey);
141 setComposerDraftText(
142 draftKey,
143 appendPullRequestAgentPrompt(withContext.text, task, previousPrompts.current.get(draftKey)),
144 );
145 previousPrompts.current.set(draftKey, pullRequestAgentPrompt(task));
146 if (thread) openThread(thread);
147 else
148 navigation.navigate("NewTaskSheet", {
149 screen: "NewTaskDraft",
150 params: { draftId: draftKey, title: `Ask about #${pr.number}` },
151 });
152 } catch (cause) {
153 Alert.alert("Couldn't open a thread", cause instanceof Error ? cause.message : "Try again.");
154 } finally {
155 pending.current = false;
156 setBusy(false);
157 }
158 };
159 return {
160 ...relations,
161 threads,
162 canAsk,
163 canWatch: config?.environment.capabilities.threadPullRequestWatch === true,
164 busy,
165 ask,
166 openThread,
167 };
168}