1import { useAtomCommand } from "~/state/use-atom-command";
2/**
3 * The actions a pull request offers, extracted from the detail panel so smaller surfaces — the
4 * thread details panel's pull request row — perform them through the very same code. Two callers
5 * running two copies of "merge" or "resolve in a thread" would drift apart one fix at a time;
6 * these hooks are where that behavior lives, and the panels are only where it is rendered.
7 */
8import { scopeProjectRef } from "@t3tools/client-runtime/environment";
9import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
10import type {
11 EnvironmentId,
12 ProjectId,
13 PullRequestAction,
14 PullRequestDetail,
15 PullRequestMergeMethod,
16 PullRequestRef,
17} from "@t3tools/contracts";
18import { useCallback, useRef, useState } from "react";
19import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
20import { useClientSettings, useEnvironmentSettings } from "~/hooks/useSettings";
21import {
22 deriveLogicalProjectKeyFromSettings,
23 derivePhysicalProjectKey,
24 selectProjectGroupingSettings,
25} from "~/logicalProject";
26import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping";
27import { useProjects } from "~/state/entities";
28import { usePrimaryEnvironmentId } from "~/state/environments";
29
30import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
31import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
32import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
33import type { ReviewCommentContext } from "~/reviewCommentContext";
34import { pullRequestEnvironment } from "~/state/pullRequests";
35
36import { toastManager } from "../ui/toast";
37import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic";
38import { pullRequestEntryKey, type EnvironmentPullRequestEntry } from "./pullRequestList.logic";
39
40/** Resolve on demand so hidden quick actions do not rebuild the legacy project grouping. */
41export function usePullRequestDefaultMergeMethodResolver(
42 environmentId: EnvironmentId,
43 projectId: ProjectId,
44) {
45 const projectDefault = useEnvironmentSettings(
46 environmentId,
47 (settings) => resolveProjectSettings(settings, projectId).settings.pullRequestMergeMethod,
48 );
49 const legacyOverrides = useClientSettings((settings) => settings.pullRequestMergeMethodOverrides);
50 const grouping = useClientSettings(selectProjectGroupingSettings);
51 const projects = useProjects();
52 const primaryEnvironmentId = usePrimaryEnvironmentId();
53 return useCallback(() => {
54 if (projectDefault != null) return projectDefault;
55 if (Object.keys(legacyOverrides).length === 0) return undefined;
56 const project = projects.find(
57 (candidate) => candidate.environmentId === environmentId && candidate.id === projectId,
58 );
59 if (!project) return undefined;
60 // Duplicate sidebar rows borrow their logical group key from their siblings.
61 const key =
62 buildPhysicalToLogicalProjectKeyMap({
63 projects,
64 settings: grouping,
65 primaryEnvironmentId,
66 }).get(derivePhysicalProjectKey(project)) ??
67 deriveLogicalProjectKeyFromSettings(project, grouping);
68 return legacyOverrides[key];
69 }, [
70 projectDefault,
71 projects,
72 environmentId,
73 projectId,
74 grouping,
75 primaryEnvironmentId,
76 legacyOverrides,
77 ]);
78}
79
80const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = {
81 merge: "Merge requested",
82 ready: "Marked ready for review",
83 draft: "Converted to draft",
84 close: "Pull request closed",
85 reopen: "Pull request reopened",
86 "update-branch": "Branch updated with the base branch",
87 "enable-auto-merge": "Auto-merge enabled",
88 "disable-auto-merge": "Auto-merge disabled",
89 revert: "Revert pull request opened",
90 "delete-branch": "Branch deleted",
91 "approve-workflows": "Workflows approved",
92};
93
94/** Said as the thing that did not happen, rather than as the operation that returned an error. */
95const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = {
96 merge: "Could not merge this pull request",
97 ready: "Could not mark this ready for review",
98 draft: "Could not convert this to a draft",
99 close: "Could not close this pull request",
100 reopen: "Could not reopen this pull request",
101 "update-branch": "Could not update this branch",
102 "enable-auto-merge": "Could not enable auto-merge",
103 "disable-auto-merge": "Could not disable auto-merge",
104 revert: "Could not open a revert pull request",
105 "delete-branch": "Could not delete this branch",
106 "approve-workflows": "Could not approve workflows",
107};
108
109/** What to try, for the times the host says only that it refused. */
110const ACTION_FAILURE_HINTS: Record<PullRequestAction, string> = {
111 merge:
112 "The host refused the merge. Check that you have write access, that the checks it requires have passed, and that the branch is not conflicting.",
113 ready: "The host refused it. Check that you have write access to this repository.",
114 draft: "The host refused it. Check that you have write access to this repository.",
115 close: "The host refused it. Check that you have write access, or that you opened it.",
116 reopen:
117 "The host refused it. Check that you have write access, and that the branch still exists.",
118 "update-branch":
119 "The host refused it. Check that you have write access, and that the base branch has not diverged in a way the host cannot merge.",
120 "enable-auto-merge":
121 "The host refused it. Check that auto-merge is enabled for this repository and that you have write access.",
122 "disable-auto-merge": "The host refused it. Check that you have write access to this repository.",
123 revert:
124 "The host refused it. Check that you have write access and that this pull request was merged on the host.",
125 "delete-branch":
126 "The host refused it. Check that you have write access to the source repository and that the branch has not moved since this page loaded.",
127 "approve-workflows":
128 "The host refused it. Check that you have Actions write access and that these workflow runs are still awaiting approval.",
129};
130
131/**
132 * Runs one host action against a pull request, with the toasts every surface should say the same
133 * way. `onSuccess` is where the caller re-reads whatever it is showing.
134 */
135export function usePullRequestActionRunner({
136 environmentId,
137 reference,
138 onSuccess,
139 resolveMergeMethod,
140}: {
141 environmentId: EnvironmentId;
142 reference: PullRequestRef | null;
143 onSuccess?: (action: PullRequestAction) => void;
144 /** Small surfaces resolve repository settings on the click, not for every visible row. */
145 resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod;
146}) {
147 const runAction = useAtomCommand(pullRequestEnvironment.runAction, {
148 reportFailure: false,
149 });
150 const [actionPending, setActionPending] = useState(false);
151 const pendingRef = useRef(false);
152
153 const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => {
154 if (pendingRef.current || reference === null) return;
155 pendingRef.current = true;
156 setActionPending(true);
157 try {
158 const result = await runAction({
159 environmentId,
160 input: {
161 ...reference,
162 action,
163 ...(method ? { mergeMethod: method } : resolveMergeMethod ? { resolveMergeMethod } : {}),
164 },
165 });
166 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
167 toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });
168 onSuccess?.(action);
169 } catch (failure) {
170 toastManager.add({
171 type: "error",
172 title: ACTION_FAILURE_LABELS[action],
173 description: readableFailure(failure, ACTION_FAILURE_HINTS[action]),
174 });
175 } finally {
176 pendingRef.current = false;
177 setActionPending(false);
178 }
179 };
180
181 return { actionPending, perform };
182}
183
184/** Queue a close sweep through the same environment lanes as individual actions. */
185export function usePullRequestCloseBatch(onClosed: (entry: EnvironmentPullRequestEntry) => void) {
186 const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });
187 const pending = useRef(new Set<string>());
188 const [closingKeys, setClosingKeys] = useState<ReadonlySet<string>>(() => new Set());
189 const close = useCallback(
190 async (entries: readonly EnvironmentPullRequestEntry[]) => {
191 const batch = entries.filter((entry) => {
192 const key = pullRequestEntryKey(entry);
193 // The sweep only gathers rows whose host can close them, so state is all that is left.
194 if (entry.state !== "open" || pending.current.has(key)) return false;
195 pending.current.add(key);
196 return true;
197 });
198 if (batch.length === 0) return;
199 setClosingKeys(new Set(pending.current));
200 let closed = 0;
201 const failures: string[] = [];
202 await Promise.all(
203 batch.map(async (entry) => {
204 try {
205 const result = await runAction({
206 environmentId: entry.environmentId,
207 input: {
208 projectId: entry.projectId,
209 host: entry.host,
210 repository: entry.repository,
211 number: entry.number,
212 action: "close",
213 },
214 });
215 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
216 closed++;
217 onClosed(entry);
218 } catch (failure) {
219 failures.push(
220 `#${entry.number}: ${readableFailure(failure, ACTION_FAILURE_HINTS.close)}`,
221 );
222 } finally {
223 pending.current.delete(pullRequestEntryKey(entry));
224 setClosingKeys(new Set(pending.current));
225 }
226 }),
227 );
228 toastManager.add({
229 type: failures.length > 0 ? "error" : "success",
230 title:
231 failures.length > 0
232 ? `Closed ${closed} of ${batch.length} pull requests`
233 : `Closed ${closed} pull request${closed === 1 ? "" : "s"}`,
234 ...(failures.length > 0 ? { description: failures.slice(0, 3).join("\n") } : {}),
235 });
236 },
237 [onClosed, runAction],
238 );
239 return { close, closingKeys };
240}
241
242export interface PullRequestThreadTask {
243 prompt: string;
244 reviewComments?: ReadonlyArray<ReviewCommentContext>;
245}
246
247/** What a hand-off needs to know about the pull request it is handing over. */
248export type PullRequestHandoffDetail = Pick<
249 PullRequestDetail,
250 "projectId" | "workspaceRoot" | "url"
251>;
252
253/**
254 * What the last hand-off wrote into each draft, kept outside React because the panel that wrote it
255 * is closed by the time the next one opens. It is how a prompt the reader has since edited is told
256 * apart from the one they were handed: only the sentence still exactly as written may be replaced.
257 */
258const lastHandoffPromptByDraft = new Map<DraftId, string>();
259
260/**
261 * The hand-offs from a pull request into a thread: a question that needs nothing checked out, and
262 * a task that needs the branch under the agent's feet first. One `handoff` key holds them all to
263 * one at a time, whatever surface pressed the button.
264 */
265export function usePullRequestHandoffs({
266 environmentId,
267 detail,
268}: {
269 environmentId: EnvironmentId;
270 detail: PullRequestHandoffDetail | null;
271}) {
272 const newThread = useNewThreadHandler();
273 const prepareThread = usePreparePullRequestThreadAction({
274 environmentId,
275 cwd: detail?.workspaceRoot ?? null,
276 });
277 // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself
278 // alone. One at a time whatever the key: they all check the same pull request out.
279 const [handoff, setHandoff] = useState<string | null>(null);
280
281 /**
282 * Opens a thread on this project and leaves the task in its composer for the reader to send.
283 *
284 * Nothing is checked out: asking a question is not a reason to move somebody's working tree or
285 * to make a worktree they did not ask for. The two hand-offs that do need the code call this
286 * after preparing it, so there is one path from "a task" to "a thread holding it".
287 */
288 const openThreadWithTask = async (
289 projectRef: ReturnType<typeof scopeProjectRef>,
290 task: PullRequestThreadTask | null,
291 opened?: { draftId: DraftId },
292 ): Promise<{ draftId: DraftId } | null> => {
293 const session =
294 opened ??
295 (await newThread(projectRef).then(
296 (result) => result,
297 () => null,
298 ));
299 if (session === null) return null;
300 const store = useComposerDraftStore.getState();
301 if (task === null) return session;
302 // The latest press is the ask: it takes over what an earlier hand-off left, prompt and chips
303 // both, rather than stacking a second one under the first. What the reader typed themselves
304 // survives — the composer they are handed is not always a fresh one, and a prompt they have
305 // since edited is theirs rather than the hand-off's.
306 const draft = store.getComposerDraft(session.draftId);
307 const existingComments = draft?.reviewComments ?? [];
308 const prompt = handoffPrompt(
309 {
310 prompt: draft?.prompt ?? "",
311 lastHandoffPrompt: lastHandoffPromptByDraft.get(session.draftId),
312 },
313 task.prompt,
314 );
315 // Remember the hand-off's own contribution, not the merged prompt: only that sentence is
316 // this session's to take back next time, and the reader's text around it is not.
317 lastHandoffPromptByDraft.set(session.draftId, task.prompt);
318 store.setPrompt(session.draftId, prompt);
319 store.setReviewComments(
320 session.draftId,
321 handoffReviewComments(existingComments, task.reviewComments ?? []),
322 );
323 return session;
324 };
325
326 /** A question about the change, which needs a thread and nothing else. */
327 const startAsk = async (kind: string, task: PullRequestThreadTask) => {
328 if (!detail || handoff !== null) return;
329 setHandoff(kind);
330 const projectRef = scopeProjectRef(environmentId, detail.projectId);
331 const opened = await openThreadWithTask(projectRef, task);
332 setHandoff(null);
333 if (opened === null) {
334 toastManager.add({
335 type: "error",
336 title: "Could not open a thread",
337 description: "Try again from the project, or open a thread first.",
338 });
339 return;
340 }
341 toastManager.add({
342 type: "success",
343 title: "Asked in a thread",
344 // "Ask" leaves the composer empty on purpose, so saying the question is in it would send
345 // the reader looking for something that is not there. The chips are what landed.
346 description:
347 task.prompt.length > 0
348 ? "The question is in the composer — read it over, then send."
349 : "The pull request is in the composer — type your question, then send.",
350 });
351 };
352
353 // Every handoff works the same way: check the pull request out into its own worktree, open a
354 // thread there, and — when it carries a task — put that in the composer for the user to read
355 // before sending. Checking out is the whole point of the ones that carry nothing.
356 const startHandoff = async (
357 kind: string,
358 task: PullRequestThreadTask | null,
359 // A worktree leaves whatever is open alone, which is why it is the default. Checking out in
360 // the repository itself is what you want when the point is to run the thing where you
361 // already work — and it moves the branch under everything else that is open there.
362 mode: "worktree" | "local" = "worktree",
363 ) => {
364 if (!detail || handoff !== null) return;
365 setHandoff(kind);
366 // The menu closes on the press and takes its "Preparing..." label with it, so this is the
367 // only thing answering for the checkout. It carries no timeout of its own: a loading toast
368 // never expires, and an explicit one would survive the update and pin the result on screen.
369 const toastId = toastManager.add({
370 type: "loading",
371 title: "Preparing the pull request checkout...",
372 });
373 const projectRef = scopeProjectRef(environmentId, detail.projectId);
374 // The thread is opened before the checkout rather than after it, because the project's setup
375 // script only runs for a checkout that knows which thread it is for — and a worktree with no
376 // dependencies installed is not something anyone can test.
377 const opened = await newThread(projectRef).then(
378 (session) => session,
379 () => null,
380 );
381 if (opened === null) {
382 setHandoff(null);
383 // Without a thread there is nowhere for the checkout to belong: its setup script would not
384 // run and its task would have no composer to land in. Better to stop before touching the
385 // working tree than to prepare a worktree nobody asked for.
386 toastManager.update(toastId, {
387 type: "error",
388 title: "Could not open a thread for the checkout",
389 description: "Try again from the project, or open a thread first.",
390 });
391 return;
392 }
393 const prepared = await prepareThread.run({
394 reference: detail.url,
395 mode,
396 threadId: opened.threadId,
397 });
398 if (prepared._tag === "Failure") {
399 setHandoff(null);
400 // The server says what to do about it — that the branch is already checked out in the main
401 // repository, say — and that sentence is the only way out of the failure.
402 const detailMessage =
403 prepareThread.error instanceof Error ? prepareThread.error.message : null;
404 toastManager.update(toastId, {
405 type: "error",
406 title: "Could not prepare the pull request checkout",
407 ...(detailMessage ? { description: detailMessage } : {}),
408 });
409 return;
410 }
411 // The same thread again, now that there is somewhere to point it at. A local checkout has
412 // no worktree of its own, so the thread runs where the repository already is.
413 const pointed = await newThread(projectRef, {
414 branch: prepared.value.branch,
415 worktreePath: prepared.value.worktreePath,
416 envMode: prepared.value.worktreePath === null ? "local" : "worktree",
417 }).then(
418 (session) => session !== null,
419 () => false,
420 );
421 if (!pointed) {
422 setHandoff(null);
423 // The checkout is on disk; only the thread failed to move onto it. Writing the task now
424 // would send the agent at whatever the thread was already open on — which is the one
425 // outcome worth stopping for, since it reads as success and is not.
426 toastManager.update(toastId, {
427 type: "error",
428 title: "Checked out, but the thread stayed where it was",
429 description: `The checkout is ready on \`${prepared.value.branch}\`. Point a thread at it from the branch picker, then ask again.`,
430 });
431 return;
432 }
433 // Released here whatever happened next: a loading toast never expires on its own, so leaving
434 // this set would spin forever and lock every handoff behind it until a reload.
435 setHandoff(null);
436 // A worktree that was already there and had been worked in keeps whatever it holds, so the
437 // thread opens on older code than the pull request carries. Said once, in place of the
438 // success, because everything else about the handoff did happen.
439 const staleCheckoutToast = {
440 type: "warning",
441 title: "Checked out, but not on the latest commits",
442 description:
443 "The checkout could not be moved onto the pull request's latest commits, so the code there is older than the pull request. Uncommitted work or local commits keep it where it is.",
444 } as const;
445 if (task === null) {
446 toastManager.update(
447 toastId,
448 prepared.value.isOnPullRequestHead
449 ? {
450 type: "success",
451 title: mode === "local" ? "Checked out here" : "Checked out",
452 description:
453 mode === "local"
454 ? "This repository is on the pull request's branch, with a thread open on it."
455 : "The pull request is in its own worktree, with a thread open on it.",
456 }
457 : staleCheckoutToast,
458 );
459 return;
460 }
461 await openThreadWithTask(projectRef, task, opened);
462 toastManager.update(
463 toastId,
464 prepared.value.isOnPullRequestHead
465 ? {
466 type: "success",
467 title: "Checkout ready",
468 description: "The task is in the composer — read it over, then send.",
469 }
470 : staleCheckoutToast,
471 );
472 };
473
474 return { handoff, startAsk, startHandoff };
475}