1import {
2 parseMergebotPullRequestUrl,
3 mergebotPullRequestUrl,
4 type EnvironmentId,
5 type PullRequestRef,
6 type ScopedThreadRef,
7} from "@t3tools/contracts";
8import { useAtomValue } from "@effect/atom-react";
9import { useNavigate } from "@tanstack/react-router";
10import { type MouseEvent, useCallback, useMemo } from "react";
11
12import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
13import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
14import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
15
16import { useOpenLink } from "../browser/useOpenLink";
17import { stackedThreadToast, toastManager } from "../components/ui/toast";
18import { useRightPanelStore } from "../rightPanelStore";
19import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
20
21import { readThreadShell, useProjects, useServerConfigs } from "../state/entities";
22import { serverEnvironment } from "../state/server";
23import { usePrimaryEnvironmentId } from "../state/environments";
24
25export {
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 */
40export 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
53export 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
83export 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 */
103export 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 */
132export function shouldOpenPullRequestExternally(
133 event: Pick<MouseEvent<HTMLElement>, "metaKey" | "ctrlKey">,
134): boolean {
135 return event.metaKey || event.ctrlKey;
136}
137
138export 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
292export 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}