| 1 | import { useNavigation, useNavigationState, useRoute } from "@react-navigation/native"; |
| 2 | import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; |
| 3 | import { |
| 4 | AuthOrchestrationOperateScope, |
| 5 | AuthSourceControlWriteScope, |
| 6 | ThreadId, |
| 7 | type PullRequestDetail, |
| 8 | type PullRequestLinkedThreadsResult, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; |
| 11 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 12 | import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; |
| 13 | import { useRef, useState } from "react"; |
| 14 | import { Alert, Platform } from "react-native"; |
| 15 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; |
| 16 | import { pullRequestComposerContext } from "../../lib/composerContext"; |
| 17 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 18 | import { uuidv4 } from "../../lib/uuid"; |
| 19 | import { useEnvironmentServerConfig } from "../../state/entities"; |
| 20 | import { composerPullRequests } from "../../state/pull-requests"; |
| 21 | import { useEnvironmentQuery } from "../../state/query"; |
| 22 | import { useEnvironmentScope } from "../../state/session"; |
| 23 | import { |
| 24 | createNewTaskDraft, |
| 25 | getComposerDraftSnapshot, |
| 26 | insertComposerDraftContext, |
| 27 | setComposerDraftText, |
| 28 | updateComposerDraftSettings, |
| 29 | waitForComposerDraftsLoaded, |
| 30 | } from "../../state/use-composer-drafts"; |
| 31 | import { usePullRequestConnected } from "./usePullRequestConnected"; |
| 32 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; |
| 33 | import { |
| 34 | appendPullRequestAgentPrompt, |
| 35 | pullRequestAgentPrompt, |
| 36 | type PullRequestAgentTask, |
| 37 | } from "./pullRequestAgentTasks"; |
| 38 | |
| 39 | type LinkedThread = PullRequestLinkedThreadsResult["threads"][number]; |
| 40 | |
| 41 | /** Stay in the PR's environment when entering a thread or creating its draft. */ |
| 42 | export 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 | } |