| 1 | import { 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 | */ |
| 8 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 9 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 10 | import type { |
| 11 | EnvironmentId, |
| 12 | ProjectId, |
| 13 | PullRequestAction, |
| 14 | PullRequestDetail, |
| 15 | PullRequestMergeMethod, |
| 16 | PullRequestRef, |
| 17 | } from "@t3tools/contracts"; |
| 18 | import { useCallback, useRef, useState } from "react"; |
| 19 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 20 | import { useClientSettings, useEnvironmentSettings } from "~/hooks/useSettings"; |
| 21 | import { |
| 22 | deriveLogicalProjectKeyFromSettings, |
| 23 | derivePhysicalProjectKey, |
| 24 | selectProjectGroupingSettings, |
| 25 | } from "~/logicalProject"; |
| 26 | import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; |
| 27 | import { useProjects } from "~/state/entities"; |
| 28 | import { usePrimaryEnvironmentId } from "~/state/environments"; |
| 29 | |
| 30 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 31 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| 32 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; |
| 33 | import type { ReviewCommentContext } from "~/reviewCommentContext"; |
| 34 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 35 | |
| 36 | import { toastManager } from "../ui/toast"; |
| 37 | import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic"; |
| 38 | import { pullRequestEntryKey, type EnvironmentPullRequestEntry } from "./pullRequestList.logic"; |
| 39 | |
| 40 | /** Resolve on demand so hidden quick actions do not rebuild the legacy project grouping. */ |
| 41 | export 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 | |
| 80 | const 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. */ |
| 95 | const 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. */ |
| 110 | const 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 | */ |
| 135 | export 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. */ |
| 185 | export 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 | |
| 242 | export 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. */ |
| 248 | export 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 | */ |
| 258 | const 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 | */ |
| 265 | export 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 | } |