1/**
2 * The client half of a source control host package.
3 *
4 * Every host package exports a `SourceControlClientDefinition` from its `./client/definition`
5 * entry point, the way `./server/driver` is its server entry. Web, mobile, and client-runtime
6 * read labels, change request nouns, picker copy, and checkout commands from these definitions,
7 * so a new host needs no per-kind branches in client code.
8 *
9 * Definitions are browser- and React Native-safe: plain data and pure functions, no server or
10 * platform UI imports.
11 *
12 * @module source-control-core/client/definition
13 */
14import {
15 type PullRequestAction,
16 pullRequestHostOf,
17 type PullRequestReviewVerdict,
18 type RepositoryIdentity,
19 SourceControlProviderKind,
20} from "@t3tools/contracts";
21import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
22import * as Schema from "effect/Schema";
23
24/** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */
25export interface ChangeRequestTerminology {
26 readonly shortLabel: string;
27 readonly singular: string;
28}
29
30/** Where a project's repository lives, for building its change request URLs. */
31export interface ChangeRequestUrlInput {
32 /** The host below which the repository is addressed, as `pullRequestHostOf` reports it. */
33 readonly host: string;
34 /** The repository path below the host. */
35 readonly repository: string;
36 readonly number: number;
37 /** The checkout's remote URL, whose origin some hosts serve their web pages from. */
38 readonly remoteUrl?: string | undefined;
39 /** The repository's browser URL, when the server resolved one from a hosting account. */
40 readonly webUrl?: string | undefined;
41}
42
43/** A project's repository, as a change request link is matched against it. */
44export type ChangeRequestProjectIdentity = Pick<
45 RepositoryIdentity,
46 "canonicalKey" | "locator" | "webUrl" | "displayName" | "owner" | "name"
47>;
48
49/** The change request a checkout command is built for. */
50export interface ChangeRequestCheckoutInput {
51 readonly number: number;
52 readonly headBranch: string;
53 readonly headRepositoryNameWithOwner?: string | null | undefined;
54 /** The repository's web URL, for hosts whose checkout fetches from it. */
55 readonly repositoryUrl?: string | null | undefined;
56}
57
58export interface SourceControlClientDefinition {
59 readonly kind: SourceControlProviderKind;
60 /** The host's name, as in "Open on GitHub". */
61 readonly label: string;
62 /** How the clone and publish pickers name the host; Forgejo's covers Gitea too. */
63 readonly pickerLabel: string;
64 /** Glyph key each client maps to its own icon; a key a client lacks draws the generic glyph. */
65 readonly icon: string;
66 readonly changeRequest: ChangeRequestTerminology;
67 /** Placeholder for a repository path on this host, such as `owner/repo`. */
68 readonly repositoryPathHint: string;
69 /**
70 * The hostname a change request reference can be attributed to before any repository
71 * identity is known, or null when a hostname alone does not name this host.
72 */
73 readonly publicHost: string | null;
74 /** Subtitle beside the host in the publish picker. */
75 readonly publishDescription: string;
76 /** Where a publish lands, given the host the server is signed in to, if it reported one. */
77 readonly publishHost: (signedInHost: string | null) => string;
78 /**
79 * Who a new project's repository is published under, given the signed-in account: an owner to
80 * name, `null` owner to let the host's CLI pick the signed-in user, or null when the host needs
81 * a choice an account cannot make, such as Azure DevOps' project.
82 */
83 readonly newRepositoryOwner: (account: string | null) => { readonly owner: string | null } | null;
84 /** Which of a repository's clone URLs a new clone uses. */
85 readonly defaultCloneTransport: "https" | "ssh";
86 /** The web URL of a change request, or null when this client cannot build one for the host. */
87 readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null;
88 /**
89 * The actions this host can take on a change request, the same list its server provider
90 * declares in `capabilities.actions`. Surfaces with no server answer to hand, such as a thread's
91 * linked pull requests, offer actions from this.
92 */
93 readonly changeRequestActions: ReadonlySet<PullRequestAction>;
94 /** A shell command that checks the change request out, or null when it cannot be built. */
95 readonly checkoutCommand: (changeRequest: ChangeRequestCheckoutInput) => string | null;
96 /** A comment author's profile page, or null where the host has none clients can link. */
97 readonly authorProfileUrl: (login: string, repositoryUrl: string) => string | null;
98 /**
99 * The repository URL that `#123` and commit SHAs in change request text link under, using
100 * GitHub's `/issues/` and `/commit/` routes, or null where the host does not route them so.
101 */
102 readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null;
103 /** Whether a review with this verdict must carry a summary, beyond what every host asks. */
104 readonly reviewSummaryRequired: (verdict: PullRequestReviewVerdict) => boolean;
105 /**
106 * The change request a pasted command names, such as `gh pr checkout 42`: its argument, which
107 * is a number or a URL. Null for anything that is not this host's checkout command.
108 */
109 readonly checkoutCommandArgument: (input: string) => string | null;
110 /**
111 * Whether a pasted URL is one of this host's change request URLs. Stricter than
112 * `isChangeRequestUrl`, which only tells hosts apart, because this decides what a reference
113 * field accepts.
114 */
115 readonly isChangeRequestReference: (url: string) => boolean;
116 /**
117 * Whether a change request link names this project's own repository. The link's host is where
118 * the repository is addressed, `link.authority` the web host and port where they differ.
119 */
120 readonly isChangeRequestInRepository: (
121 identity: ChangeRequestProjectIdentity,
122 link: ChangeRequestLink,
123 ) => boolean;
124 /**
125 * Whether this project can read a change request on the link's host on the link's behalf, for a
126 * repository nobody has checked out. False where the host's reads are scoped to the checkout.
127 */
128 readonly canReadChangeRequestOnHost: (
129 identity: ChangeRequestProjectIdentity,
130 link: ChangeRequestLink,
131 ) => boolean;
132 /**
133 * The host a change request URL addresses its repository below, as a `PullRequestRef.host`
134 * names it: the hostname, or the host and port for servers addressed by both.
135 */
136 readonly changeRequestUrlHost: (url: URL) => string;
137 /**
138 * The host a reference with none names, read from the checkout alone, or null where only the
139 * server can tell, such as when an SSH remote resolves to a different web host.
140 */
141 readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null;
142 /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */
143 readonly isChangeRequestUrl: (url: string) => boolean;
144 /**
145 * What the environment saves for this host under `settings.sourceControlHosts[kind]`, rendered
146 * as a form in Source Control settings and read by the host's server package. Fields carry
147 * `providerSettingsForm` annotations, as agent provider settings do; a `secretSetting` is
148 * kept in the server's secret store. Omitted for a host with nothing to configure.
149 */
150 readonly settings?: SourceControlHostSettingsSchema;
151}
152
153/** A host's settings struct. Its fields are strings, or a secret string per server host. */
154export type SourceControlHostSettingsSchema = {
155 readonly fields: Readonly<Record<string, Schema.Top>>;
156} & Schema.Decoder<object>;
157
158/**
159 * Whether a URL's path is a change request at `route`, such as `/pull/`, followed by its number.
160 * Matching the parsed path keeps a query or fragment that mentions another host's route out.
161 */
162export function isChangeRequestPath(url: string, route: string): boolean {
163 try {
164 const path = new URL(url).pathname;
165 const at = path.indexOf(route);
166 return at > 0 && /^\d+(?:\/|$)/u.test(path.slice(at + route.length));
167 } catch {
168 return false;
169 }
170}
171
172export function defineSourceControlClient<const Definition extends SourceControlClientDefinition>(
173 definition: Definition,
174): Definition {
175 return definition;
176}
177
178/**
179 * Whether a project's HTTP remote names the link's web authority. A link carries one only where
180 * the host and port differ from where the repository is addressed, as on a Forgejo server with a
181 * port; an SSH remote says nothing about ports, so it does not rule the link out.
182 */
183export function isChangeRequestAuthorityOfProject(
184 identity: ChangeRequestProjectIdentity,
185 link: ChangeRequestLink,
186): boolean {
187 if (link.authority === undefined) return true;
188 try {
189 const remote = new URL(identity.locator.remoteUrl);
190 if (remote.protocol === "http:" || remote.protocol === "https:") {
191 return remote.host.toLowerCase() === link.authority;
192 }
193 } catch {
194 // SSH remotes do not specify the server's HTTP port.
195 }
196 return true;
197}
198
199/** Whether a project lives on the link's host, as `pullRequestHostOf` reads it from the checkout. */
200export function isChangeRequestOnProjectHost(
201 kind: SourceControlProviderKind,
202 identity: ChangeRequestProjectIdentity,
203 link: ChangeRequestLink,
204): boolean {
205 if (!isChangeRequestAuthorityOfProject(identity, link)) return false;
206 const host = pullRequestHostOf(identity, kind);
207 return host === link.host.toLowerCase() || host === link.authority;
208}
209
210/** The repository path a project's identity names below its host. */
211function projectRepositoryPath(identity: ChangeRequestProjectIdentity): string | null {
212 return (
213 identity.displayName ??
214 (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null)
215 );
216}
217
218/**
219 * `isChangeRequestInRepository` for hosts that address a repository by its full path below the
220 * host, which is what nested GitLab groups need.
221 */
222export function isChangeRequestInProjectRepository(
223 kind: SourceControlProviderKind,
224 identity: ChangeRequestProjectIdentity,
225 link: ChangeRequestLink,
226): boolean {
227 const repository = projectRepositoryPath(identity);
228 return (
229 repository !== null &&
230 repository.toLowerCase() === link.repository.toLowerCase() &&
231 isChangeRequestOnProjectHost(kind, identity, link)
232 );
233}
234
235/** What clients show for a host they ship no definition for, including `unknown`. */
236export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = {
237 kind: SourceControlProviderKind.make("unknown"),
238 label: "source control",
239 pickerLabel: "source control",
240 icon: "change-request",
241 changeRequest: { shortLabel: "change request", singular: "change request" },
242 repositoryPathHint: "URL",
243 publicHost: null,
244 publishDescription: "Your signed-in server",
245 publishHost: (signedInHost) => signedInHost ?? "your server",
246 newRepositoryOwner: () => null,
247 defaultCloneTransport: "ssh",
248 changeRequestUrl: () => null,
249 changeRequestActions: new Set(),
250 checkoutCommand: () => null,
251 authorProfileUrl: () => null,
252 referenceAutolinkRepositoryUrl: () => null,
253 reviewSummaryRequired: () => false,
254 checkoutCommandArgument: () => null,
255 isChangeRequestReference: () => false,
256 changeRequestUrlHost: (url) => url.hostname,
257 checkoutChangeRequestHost: () => null,
258 isChangeRequestInRepository: (identity, link) =>
259 isChangeRequestInProjectRepository(SourceControlProviderKind.make("unknown"), identity, link),
260 canReadChangeRequestOnHost: (identity, link) =>
261 isChangeRequestOnProjectHost(SourceControlProviderKind.make("unknown"), identity, link),
262 isChangeRequestUrl: () => false,
263};
264
265/** The host definitions a client loaded. */
266export interface SourceControlClientRegistry {
267 readonly definitions: ReadonlyArray<SourceControlClientDefinition>;
268 /**
269 * The definition for a kind. No kind at all, before a repository reports its host, reads as
270 * the first definition; a kind this client lacks, `unknown` included, reads as
271 * `UNKNOWN_SOURCE_CONTROL_CLIENT`.
272 */
273 readonly get: (kind: string | null | undefined) => SourceControlClientDefinition;
274 /** The definition for a kind, or `undefined` for one this client lacks. */
275 readonly find: (kind: string) => SourceControlClientDefinition | undefined;
276 /** The definition whose public instance is `hostname`, if any. */
277 readonly findByPublicHost: (hostname: string) => SourceControlClientDefinition | undefined;
278 /** The definition whose change request path shape `url` has, if any. */
279 readonly findByChangeRequestUrl: (url: string) => SourceControlClientDefinition | undefined;
280 /** The name of the host a change request URL is on, for copy about that change request. */
281 readonly hostLabelForChangeRequestUrl: (url: string) => string;
282}
283
284export function makeSourceControlClientRegistry(
285 definitions: ReadonlyArray<SourceControlClientDefinition>,
286): SourceControlClientRegistry {
287 const byKind = new Map<string, SourceControlClientDefinition>();
288 for (const definition of definitions) {
289 if (byKind.has(definition.kind)) {
290 throw new Error(`Source control host '${definition.kind}' is defined more than once.`);
291 }
292 byKind.set(definition.kind, definition);
293 }
294 return {
295 definitions,
296 get: (kind) =>
297 (kind == null ? definitions[0] : byKind.get(kind)) ?? UNKNOWN_SOURCE_CONTROL_CLIENT,
298 find: (kind) => byKind.get(kind),
299 findByPublicHost: (hostname) => {
300 const host = hostname.toLowerCase();
301 return definitions.find((definition) => definition.publicHost === host);
302 },
303 findByChangeRequestUrl: (url) =>
304 definitions.find((definition) => definition.isChangeRequestUrl(url)),
305 hostLabelForChangeRequestUrl: (url) =>
306 definitions.find((definition) => definition.isChangeRequestUrl(url))?.label ?? "the host",
307 };
308}
309
310/**
311 * A host's saved settings decoded with its schema. A blob that no longer decodes, such as one a
312 * newer build wrote, reads as the schema's defaults rather than failing the host.
313 */
314export function readSourceControlHostSettings<S extends SourceControlHostSettingsSchema>(
315 schema: S,
316 saved: unknown,
317): S["Type"] {
318 const decode = Schema.decodeUnknownOption(schema as Schema.Decoder<S["Type"]>);
319 const decoded = decode(saved ?? {});
320 if (decoded._tag === "Some") return decoded.value;
321 const fallback = decode({});
322 if (fallback._tag === "Some") return fallback.value;
323 throw new Error("A source control host settings schema must decode an empty object.");
324}