| 1 | import { |
| 2 | parseMergebotPullRequestUrl, |
| 3 | mergebotPullRequestUrl, |
| 4 | type EnvironmentId, |
| 5 | type PullRequestRef, |
| 6 | type ScopedThreadRef, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { useAtomValue } from "@effect/atom-react"; |
| 9 | import { useNavigate } from "@tanstack/react-router"; |
| 10 | import { type MouseEvent, useCallback, useMemo } from "react"; |
| 11 | |
| 12 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 13 | import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; |
| 14 | import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; |
| 15 | |
| 16 | import { useOpenLink } from "../browser/useOpenLink"; |
| 17 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 18 | import { useRightPanelStore } from "../rightPanelStore"; |
| 19 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 20 | |
| 21 | import { readThreadShell, useProjects, useServerConfigs } from "../state/entities"; |
| 22 | import { serverEnvironment } from "../state/server"; |
| 23 | import { usePrimaryEnvironmentId } from "../state/environments"; |
| 24 | |
| 25 | export { |
| 26 | parseChangeRequestUrl, |
| 27 | type ChangeRequestLink, |
| 28 | gitHubPullRequestBrowserUrl, |
| 29 | pullRequestCandidateUrlFromReferenceAutolink, |
| 30 | matchesLinkedPullRequestUrl, |
| 31 | changeRequestRepositoryUrl, |
| 32 | } from "@t3tools/shared/changeRequestUrl"; |
| 33 | |
| 34 | /** |
| 35 | * The project a link belongs to, or nothing. Matched the way the server matches: the repository |
| 36 | * identity is the full path below the host where one was recorded — which is what nested GitLab |
| 37 | * groups and Azure project paths need — and the host is the first segment of the canonical |
| 38 | * remote, so github.com and an Enterprise install stay apart. |
| 39 | */ |
| 40 | export function findProjectForChangeRequest( |
| 41 | projects: ReadonlyArray<EnvironmentProject>, |
| 42 | link: ChangeRequestLink, |
| 43 | ): EnvironmentProject | undefined { |
| 44 | return projects.find((project) => { |
| 45 | const identity = project.repositoryIdentity; |
| 46 | return ( |
| 47 | identity?.provider !== undefined && |
| 48 | sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link) |
| 49 | ); |
| 50 | }); |
| 51 | } |
| 52 | |
| 53 | export function resolvePullRequestPreviewTarget({ |
| 54 | environmentId, |
| 55 | projects, |
| 56 | pullRequestsEnabled, |
| 57 | url, |
| 58 | }: { |
| 59 | environmentId: EnvironmentId | null; |
| 60 | projects: ReadonlyArray<EnvironmentProject>; |
| 61 | pullRequestsEnabled: boolean; |
| 62 | url: string; |
| 63 | }): { environmentId: EnvironmentId; input: PullRequestRef } | null { |
| 64 | if (!pullRequestsEnabled || environmentId === null) return null; |
| 65 | const parsed = parseChangeRequestUrl(url); |
| 66 | if (parsed === null) return null; |
| 67 | const project = findProjectForChangeRequest( |
| 68 | projects.filter((candidate) => candidate.environmentId === environmentId), |
| 69 | parsed, |
| 70 | ); |
| 71 | if (project === undefined) return null; |
| 72 | return { |
| 73 | environmentId, |
| 74 | input: { |
| 75 | projectId: project.id, |
| 76 | host: parsed.authority ?? parsed.host, |
| 77 | repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository, |
| 78 | number: parsed.number, |
| 79 | }, |
| 80 | }; |
| 81 | } |
| 82 | |
| 83 | export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, url: string) { |
| 84 | const projects = useProjects(); |
| 85 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); |
| 86 | return useMemo( |
| 87 | () => |
| 88 | resolvePullRequestPreviewTarget({ |
| 89 | environmentId, |
| 90 | projects, |
| 91 | pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true, |
| 92 | url, |
| 93 | }), |
| 94 | [environmentId, projects, serverConfig, url], |
| 95 | ); |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Any project checked out from the link's host. Thread links are host-level, so a pull request |
| 100 | * from a repository nobody has checked out is still linkable as long as one project on that |
| 101 | * host can lend the server its credentials. The link's own project, when it exists, comes first. |
| 102 | */ |
| 103 | export function findProjectOnChangeRequestHost( |
| 104 | projects: ReadonlyArray<EnvironmentProject>, |
| 105 | link: ChangeRequestLink, |
| 106 | ): EnvironmentProject | undefined { |
| 107 | const own = findProjectForChangeRequest(projects, link); |
| 108 | if (own !== undefined) return own; |
| 109 | return projects.find((project) => { |
| 110 | const identity = project.repositoryIdentity; |
| 111 | return ( |
| 112 | identity?.provider !== undefined && |
| 113 | sourceControlClients.get(identity.provider).canReadChangeRequestOnHost(identity, link) |
| 114 | ); |
| 115 | }); |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Opens a change request link on the page, and says whether it did. Anything else — another |
| 120 | * organisation's repository, a host nothing here is checked out from, a link that merely looks |
| 121 | * like one — is left alone for the caller to handle as the ordinary link it is. |
| 122 | * |
| 123 | * Resolving the project here rather than on the page is what makes recognising a URL safe: a |
| 124 | * lookalike hostname matches no project and stays a link, and the page is handed the project |
| 125 | * rather than a host to narrow its whole list by. |
| 126 | * |
| 127 | * Given a thread, the link opens beside it in the right panel instead of taking the whole app to |
| 128 | * the pull requests page: a reader following a link the agent wrote is reading the thread, and |
| 129 | * should still be reading it afterwards. Any change request opens there, not only the thread's |
| 130 | * own, since the panel is told which one to show. |
| 131 | */ |
| 132 | export function shouldOpenPullRequestExternally( |
| 133 | event: Pick<MouseEvent<HTMLElement>, "metaKey" | "ctrlKey">, |
| 134 | ): boolean { |
| 135 | return event.metaKey || event.ctrlKey; |
| 136 | } |
| 137 | |
| 138 | export function useOpenChangeRequestLink( |
| 139 | threadRef?: ScopedThreadRef, |
| 140 | panelRef?: ScopedThreadRef, |
| 141 | ): ( |
| 142 | event: Pick< |
| 143 | MouseEvent<HTMLElement>, |
| 144 | "preventDefault" | "stopPropagation" | "metaKey" | "ctrlKey" |
| 145 | >, |
| 146 | targetUrl: string, |
| 147 | targetThreadRef?: ScopedThreadRef, |
| 148 | targetEnvironmentId?: EnvironmentId, |
| 149 | ) => boolean { |
| 150 | const navigate = useNavigate(); |
| 151 | const allProjects = useProjects(); |
| 152 | const serverConfigs = useServerConfigs(); |
| 153 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 154 | return useCallback( |
| 155 | (event, targetUrl, targetThreadRef, targetEnvironmentId) => { |
| 156 | if (shouldOpenPullRequestExternally(event)) return false; |
| 157 | const resolvedThreadRef = targetThreadRef ?? threadRef; |
| 158 | const resolvedPanelRef = panelRef ?? resolvedThreadRef; |
| 159 | const requestedEnvironmentId = targetEnvironmentId ?? resolvedThreadRef?.environmentId; |
| 160 | const hostReference = parseChangeRequestUrl(targetUrl); |
| 161 | const mergebot = [...serverConfigs].flatMap(([environmentId, configuration]) => { |
| 162 | if (requestedEnvironmentId && environmentId !== requestedEnvironmentId) return []; |
| 163 | const reference = |
| 164 | parseMergebotPullRequestUrl(configuration.mergebot, targetUrl) ?? |
| 165 | (hostReference && mergebotPullRequestUrl(configuration.mergebot, hostReference) |
| 166 | ? hostReference |
| 167 | : null); |
| 168 | return reference ? [{ environmentId, reference }] : []; |
| 169 | })[0]; |
| 170 | const parsed: ChangeRequestLink | null = mergebot?.reference ?? hostReference; |
| 171 | if (parsed === null) return false; |
| 172 | const reads = (environmentId: string) => |
| 173 | serverConfigs.get(environmentId as EnvironmentId)?.environment.capabilities.pullRequests === |
| 174 | true; |
| 175 | // Beside a thread the panel reads on that thread's environment, so a project from another |
| 176 | // one could not be read there whatever its remote says: two environments can hold the same |
| 177 | // repository, and handing the panel the wrong one's id opens a surface that never loads. |
| 178 | // |
| 179 | // The page has no such tie — it lists every server at once — so the link is resolved |
| 180 | // against all of them, the primary first where two hold the same repository. |
| 181 | const projects = resolvedThreadRef |
| 182 | ? allProjects.filter((project) => project.environmentId === resolvedThreadRef.environmentId) |
| 183 | : targetEnvironmentId |
| 184 | ? allProjects.filter((project) => project.environmentId === targetEnvironmentId) |
| 185 | : allProjects |
| 186 | .filter((project) => reads(project.environmentId)) |
| 187 | .toSorted( |
| 188 | (left, right) => |
| 189 | Number(right.environmentId === primaryEnvironmentId) - |
| 190 | Number(left.environmentId === primaryEnvironmentId), |
| 191 | ); |
| 192 | const exactProject = findProjectForChangeRequest(projects, parsed); |
| 193 | const threadProjectId = resolvedThreadRef |
| 194 | ? readThreadShell(resolvedThreadRef)?.projectId |
| 195 | : undefined; |
| 196 | const project = |
| 197 | exactProject ?? |
| 198 | (resolvedPanelRef |
| 199 | ? findProjectOnChangeRequestHost( |
| 200 | projects.filter( |
| 201 | (candidate) => |
| 202 | serverConfigs.get(candidate.environmentId)?.environment.capabilities |
| 203 | .threadPullRequests === true, |
| 204 | ), |
| 205 | parsed, |
| 206 | ) |
| 207 | : undefined) ?? |
| 208 | (mergebot && resolvedThreadRef |
| 209 | ? projects.find( |
| 210 | (candidate) => |
| 211 | candidate.environmentId === resolvedThreadRef.environmentId && |
| 212 | candidate.id === threadProjectId, |
| 213 | ) |
| 214 | : undefined); |
| 215 | if (project === undefined && mergebot) { |
| 216 | event.preventDefault(); |
| 217 | event.stopPropagation(); |
| 218 | void navigate({ |
| 219 | to: "/pull-requests", |
| 220 | search: { |
| 221 | involvement: "all", |
| 222 | state: "all", |
| 223 | mergebot: mergebot.environmentId, |
| 224 | repository: parsed.repository, |
| 225 | number: parsed.number, |
| 226 | }, |
| 227 | }); |
| 228 | return true; |
| 229 | } |
| 230 | if (project === undefined || (!mergebot && !reads(project.environmentId))) return false; |
| 231 | const repository = |
| 232 | serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests === |
| 233 | true |
| 234 | ? parsed.repository |
| 235 | : (sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository); |
| 236 | event.preventDefault(); |
| 237 | event.stopPropagation(); |
| 238 | if (resolvedPanelRef) { |
| 239 | useRightPanelStore.getState().openPullRequest(resolvedPanelRef, { |
| 240 | // The standalone PR panel has a synthetic ref; each tab keeps its real environment. |
| 241 | ...(resolvedPanelRef.environmentId === project.environmentId |
| 242 | ? {} |
| 243 | : { environmentId: project.environmentId }), |
| 244 | projectId: project.id, |
| 245 | ...(serverConfigs.get(project.environmentId)?.environment.capabilities |
| 246 | .threadPullRequests === true |
| 247 | ? { host: parsed.authority ?? parsed.host } |
| 248 | : {}), |
| 249 | repository, |
| 250 | url: targetUrl, |
| 251 | number: parsed.number, |
| 252 | }); |
| 253 | if (!resolvedThreadRef) { |
| 254 | void navigate({ |
| 255 | to: "/pull-requests", |
| 256 | search: (previous) => ({ |
| 257 | ...previous, |
| 258 | involvement: previous.involvement ?? "all", |
| 259 | state: previous.state ?? "all", |
| 260 | repository, |
| 261 | number: parsed.number, |
| 262 | selectedHost: parsed.authority ?? parsed.host, |
| 263 | selectedProjectId: project.id, |
| 264 | selectedEnvironmentId: project.environmentId, |
| 265 | }), |
| 266 | replace: true, |
| 267 | }); |
| 268 | } |
| 269 | return true; |
| 270 | } |
| 271 | void navigate({ |
| 272 | to: "/pull-requests", |
| 273 | search: { |
| 274 | involvement: "all", |
| 275 | // Every state, so the pull request being opened is also in the list behind it whether |
| 276 | // it is open, merged or closed. |
| 277 | state: "all", |
| 278 | repository, |
| 279 | number: parsed.number, |
| 280 | selectedHost: parsed.authority ?? parsed.host, |
| 281 | selectedProjectId: project.id, |
| 282 | // Named so the page opens the right one of two servers holding this project. |
| 283 | selectedEnvironmentId: project.environmentId, |
| 284 | }, |
| 285 | }); |
| 286 | return true; |
| 287 | }, |
| 288 | [allProjects, navigate, panelRef, primaryEnvironmentId, serverConfigs, threadRef], |
| 289 | ); |
| 290 | } |
| 291 | |
| 292 | export function useOpenPrLink(threadRef?: ScopedThreadRef) { |
| 293 | const openChangeRequest = useOpenChangeRequestLink(threadRef); |
| 294 | const openLink = useOpenLink(threadRef); |
| 295 | return useCallback( |
| 296 | (event: MouseEvent<HTMLElement>, prUrl: string, targetThreadRef?: ScopedThreadRef) => { |
| 297 | event.stopPropagation(); |
| 298 | const openInBrowser = shouldOpenPullRequestExternally(event); |
| 299 | const isAnchor = |
| 300 | event.currentTarget instanceof HTMLAnchorElement && event.currentTarget.href.length > 0; |
| 301 | // A real link already knows how to cmd/ctrl+click. Leave its default |
| 302 | // action alone so the browser (or Electron's window-open handler) opens |
| 303 | // the host. Buttons have no href, so they still go through openExternal. |
| 304 | if (openInBrowser && isAnchor) return false; |
| 305 | |
| 306 | event.preventDefault(); |
| 307 | if (!openInBrowser && openChangeRequest(event, prUrl, targetThreadRef)) return true; |
| 308 | |
| 309 | // No project to show it in, so it is an ordinary link and follows the |
| 310 | // "Open links in" setting; the modifier still forces the system browser. |
| 311 | void openLink(prUrl, { event, threadRef: targetThreadRef }).catch((error: unknown) => { |
| 312 | console.error(error); |
| 313 | toastManager.add( |
| 314 | stackedThreadToast({ |
| 315 | type: "error", |
| 316 | title: "Unable to open pull request link", |
| 317 | description: error instanceof Error ? error.message : "An error occurred.", |
| 318 | }), |
| 319 | ); |
| 320 | }); |
| 321 | return false; |
| 322 | }, |
| 323 | [openChangeRequest, openLink], |
| 324 | ); |
| 325 | } |