| 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 | */ |
| 14 | import { |
| 15 | type PullRequestAction, |
| 16 | pullRequestHostOf, |
| 17 | type PullRequestReviewVerdict, |
| 18 | type RepositoryIdentity, |
| 19 | SourceControlProviderKind, |
| 20 | } from "@t3tools/contracts"; |
| 21 | import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; |
| 22 | import * as Schema from "effect/Schema"; |
| 23 | |
| 24 | /** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */ |
| 25 | export 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. */ |
| 31 | export 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. */ |
| 44 | export type ChangeRequestProjectIdentity = Pick< |
| 45 | RepositoryIdentity, |
| 46 | "canonicalKey" | "locator" | "webUrl" | "displayName" | "owner" | "name" |
| 47 | >; |
| 48 | |
| 49 | /** The change request a checkout command is built for. */ |
| 50 | export 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 | |
| 58 | export 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. */ |
| 154 | export 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 | */ |
| 162 | export 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 | |
| 172 | export 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 | */ |
| 183 | export 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. */ |
| 200 | export 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. */ |
| 211 | function 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 | */ |
| 222 | export 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`. */ |
| 236 | export 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. */ |
| 266 | export 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 | |
| 284 | export 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 | */ |
| 314 | export 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 | } |