1import type {
2 EnvironmentId,
3 ProjectId,
4 PullRequestRef,
5 RepositoryIdentity,
6} from "@t3tools/contracts";
7import type { ChangeRequestLink } from "@t3tools/source-control-core/shared/definition";
8
9import { sourceControlClients } from "./sourceControlClients.ts";
10
11interface ChangeRequestProject {
12 readonly id: ProjectId;
13 readonly environmentId: EnvironmentId;
14 readonly repositoryIdentity?: RepositoryIdentity | null | undefined;
15}
16
17/** The project whose own repository holds the change request, not just one on its host. */
18export function findProjectForChangeRequest<Project extends ChangeRequestProject>(
19 projects: ReadonlyArray<Project>,
20 link: ChangeRequestLink,
21): Project | undefined {
22 return projects.find((project) => {
23 const identity = project.repositoryIdentity;
24 return (
25 identity?.provider !== undefined &&
26 sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link)
27 );
28 });
29}
30
31/** Prefers the project checked out from the link's repository, else lends any project on its host. */
32export function findProjectOnChangeRequestHost<Project extends ChangeRequestProject>(
33 projects: ReadonlyArray<Project>,
34 link: ChangeRequestLink,
35): Project | undefined {
36 return (
37 findProjectForChangeRequest(projects, link) ??
38 projects.find((project) => {
39 const identity = project.repositoryIdentity;
40 return (
41 identity?.provider !== undefined &&
42 sourceControlClients.get(identity.provider).canReadChangeRequestOnHost(identity, link)
43 );
44 })
45 );
46}
47
48/**
49 * Where every client opens a change request link in the app, or null for the browser. A link
50 * opened into a pull request view may borrow any project on its host, so a mirror's or a
51 * sibling repository's link still opens in the app.
52 */
53export function resolveChangeRequestLinkTarget<Project extends ChangeRequestProject>({
54 url,
55 projects,
56 configLoaded,
57 environmentId,
58 projectId,
59 primaryEnvironmentId,
60 lendHostProject,
61}: {
62 url: string;
63 projects: ReadonlyArray<Project>;
64 configLoaded: (environmentId: EnvironmentId) => boolean;
65 /** The link's own environment; without one, any environment that reads change requests. */
66 environmentId?: EnvironmentId | undefined;
67 /** The project the link came from, tried first. */
68 projectId?: ProjectId | undefined;
69 primaryEnvironmentId?: EnvironmentId | null | undefined;
70 lendHostProject: boolean;
71}): {
72 readonly project: Project;
73 readonly repository: string;
74 readonly number: number;
75 readonly host?: string;
76} | null {
77 const link = sourceControlClients.parseChangeRequestUrl(url)?.link;
78 if (link === undefined) return null;
79 // Matching remotes do not make project ids usable outside the link's environment.
80 const candidates = (
81 environmentId === undefined
82 ? projects.filter((project) => configLoaded(project.environmentId))
83 : projects.filter((project) => project.environmentId === environmentId)
84 ).sort(
85 (left, right) =>
86 Number(right.id === projectId) - Number(left.id === projectId) ||
87 Number(right.environmentId === primaryEnvironmentId) -
88 Number(left.environmentId === primaryEnvironmentId),
89 );
90 const project =
91 findProjectForChangeRequest(candidates, link) ??
92 (lendHostProject ? findProjectOnChangeRequestHost(candidates, link) : undefined);
93 if (project === undefined || !configLoaded(project.environmentId)) return null;
94 return {
95 project,
96 repository: link.repository,
97 number: link.number,
98 host: link.authority ?? link.host,
99 };
100}
101
102export function resolvePullRequestPreviewTarget({
103 environmentId,
104 projects,
105 pullRequestsEnabled,
106 url,
107}: {
108 environmentId: EnvironmentId | null;
109 projects: ReadonlyArray<ChangeRequestProject>;
110 pullRequestsEnabled: boolean;
111 url: string;
112}): { environmentId: EnvironmentId; input: PullRequestRef } | null {
113 if (!pullRequestsEnabled || environmentId === null) return null;
114 const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link;
115 if (parsed === undefined) return null;
116 const project = findProjectForChangeRequest(
117 projects.filter((candidate) => candidate.environmentId === environmentId),
118 parsed,
119 );
120 if (project === undefined) return null;
121 return {
122 environmentId,
123 input: {
124 projectId: project.id,
125 host: parsed.authority ?? parsed.host,
126 repository:
127 sourceControlClients.repositorySelector(project.repositoryIdentity) ?? parsed.repository,
128 number: parsed.number,
129 },
130 };
131}
132
133/** The project host's browser URL for one of its change requests, before its detail loads. */
134export function projectChangeRequestUrl(
135 identity: RepositoryIdentity | null | undefined,
136 repository: string,
137 number: number,
138): string | null {
139 if (identity?.provider === undefined) return null;
140 const [host, ...path] = identity.canonicalKey.split("/");
141 if (!host) return null;
142 // The RPC selector can be shorter than the browser path (Azure's `web` for
143 // `org/project/_git/web`), so the project's own repository uses its full key.
144 const ownRepository = sourceControlClients.repositorySelector(identity) === repository;
145 return sourceControlClients.get(identity.provider).changeRequestUrl({
146 host,
147 repository: ownRepository ? path.join("/") : repository,
148 number,
149 remoteUrl: identity.locator.remoteUrl.trim(),
150 ...(ownRepository && identity.webUrl !== undefined ? { webUrl: identity.webUrl } : {}),
151 });
152}
153
154export function projectCommitUrl(
155 identity: RepositoryIdentity | null | undefined,
156 sha: string,
157): string | null {
158 if (identity?.provider === undefined) return null;
159 const [host, ...path] = identity.canonicalKey.split("/");
160 if (!host) return null;
161 return (
162 sourceControlClients.get(identity.provider).commitUrl?.({
163 host,
164 repository: path.join("/"),
165 sha,
166 remoteUrl: identity.locator.remoteUrl.trim(),
167 ...(identity.webUrl !== undefined ? { webUrl: identity.webUrl } : {}),
168 }) ?? null
169 );
170}