Repository navigation
feat(source-control): each host package ships a client definition #17746
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+516
−0
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
|
|
||
| import { sourceControlClients } from "./sourceControlClients.ts"; | ||
|
|
||
| describe("sourceControlClients", () => { | ||
| it("reads a missing kind as the first host and an unshipped kind as the generic one", () => { | ||
| expect(sourceControlClients.get(undefined).kind).toBe("github"); | ||
| expect(sourceControlClients.get("forkhost").kind).toBe("unknown"); | ||
| }); | ||
|
|
||
| it("gives a change request URL to the first host whose shape it has", () => { | ||
| // GitCafe and Forgejo share `/pulls/`; GitCafe only claims its own hostnames. | ||
| const kindOf = (url: string) => sourceControlClients.findByChangeRequestUrl(url)?.kind; | ||
| expect(kindOf("https://git.cafe/acme/web/pulls/7")).toBe("gitcafe"); | ||
| expect(kindOf("https://codeberg.org/acme/web/pulls/7")).toBe("forgejo"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| /** | ||
| * The source control host definitions web and mobile ship, and the one lookup they use. | ||
| * | ||
| * Adding a host means writing its `@t3tools/source-control-<host>` package with a | ||
| * `./client/definition` entry and adding it here; clients then label, list, and check out its | ||
| * change requests without further branches. | ||
| * | ||
| * @module client-runtime/sourceControlClients | ||
| */ | ||
| import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; | ||
| import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; | ||
| import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition"; | ||
|
|
||
| export { | ||
| type ChangeRequestTerminology, | ||
| type SourceControlClientDefinition, | ||
| } from "@t3tools/source-control-core/client/definition"; | ||
| import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; | ||
| import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; | ||
| import * as GitHub from "@t3tools/source-control-github/client/definition"; | ||
| import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; | ||
|
|
||
| /** | ||
| * GitHub comes first because a repository that has not reported its host yet reads as GitHub, | ||
| * which is what clients have always shown there. A change request URL goes to the first host | ||
| * whose shape it has, so GitCafe, which claims `/pulls/` only on its own hostnames, comes before | ||
| * Forgejo, which claims it everywhere. Pickers sort by readiness and label, not by this order. | ||
| */ | ||
| const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ | ||
| GitHub.definition, | ||
| GitLab.definition, | ||
| GitCafe.definition, | ||
| Forgejo.definition, | ||
| Bitbucket.definition, | ||
| AzureDevOps.definition, | ||
| ]; | ||
|
|
||
| /** `definitions` lists the built-in hosts in that order, for pickers that offer every host. */ | ||
| export const sourceControlClients = makeSourceControlClientRegistry( | ||
| BUILT_IN_SOURCE_CONTROL_CLIENTS, | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
33 changes: 33 additions & 0 deletions
33
packages/source-control-azure-devops/src/client/definition.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| /** | ||
| * Azure DevOps's client definition. Browser- and React Native-safe. | ||
| * | ||
| * @module source-control-azure-devops/client/definition | ||
| */ | ||
| import { SourceControlProviderKind } from "@t3tools/contracts"; | ||
| import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; | ||
| import { | ||
| defineSourceControlClient, | ||
| isChangeRequestPath, | ||
| } from "@t3tools/source-control-core/client/definition"; | ||
|
|
||
| export const definition = defineSourceControlClient({ | ||
| kind: SourceControlProviderKind.make("azure-devops"), | ||
| label: "Azure DevOps", | ||
| pickerLabel: "Azure DevOps", | ||
| icon: "azure-devops", | ||
| changeRequest: { shortLabel: "PR", singular: "pull request" }, | ||
| repositoryPathHint: "project/repository", | ||
| // `az repos` reads need the checkout's organization and project, so a bare hostname names no | ||
| // repository this client can act on. | ||
| publicHost: null, | ||
| publishDescription: "dev.azure.com", | ||
| publishHost: () => "dev.azure.com", | ||
| defaultCloneTransport: "ssh", | ||
| changeRequestUrl: ({ host, repository, number }) => | ||
| `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`, | ||
| checkoutCommand: ({ number }) => `az repos pr checkout --id ${number}`, | ||
| authorProfileUrl: () => null, | ||
| referenceAutolinkRepositoryUrl: () => null, | ||
| reviewSummaryRequired: () => false, | ||
| isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"), | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
39 changes: 39 additions & 0 deletions
39
packages/source-control-bitbucket/src/client/definition.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| /** | ||
| * Bitbucket's client definition. Browser- and React Native-safe. | ||
| * | ||
| * @module source-control-bitbucket/client/definition | ||
| */ | ||
| import { SourceControlProviderKind } from "@t3tools/contracts"; | ||
| import { | ||
| defineSourceControlClient, | ||
| isChangeRequestPath, | ||
| } from "@t3tools/source-control-core/client/definition"; | ||
|
|
||
| const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; | ||
| const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; | ||
|
|
||
| export const definition = defineSourceControlClient({ | ||
| kind: SourceControlProviderKind.make("bitbucket"), | ||
| label: "Bitbucket", | ||
| pickerLabel: "Bitbucket", | ||
| icon: "bitbucket", | ||
| changeRequest: { shortLabel: "PR", singular: "pull request" }, | ||
| repositoryPathHint: "workspace/repository", | ||
| publicHost: "bitbucket.org", | ||
| publishDescription: "bitbucket.org", | ||
| publishHost: () => "bitbucket.org", | ||
| defaultCloneTransport: "ssh", | ||
| changeRequestUrl: ({ host, repository, number }) => | ||
| `https://${host}/${repository}/pull-requests/${number}`, | ||
| // Bitbucket has no checkout CLI, so clone the head branch from its own repository. | ||
| checkoutCommand: ({ number, headBranch, headRepositoryNameWithOwner }) => | ||
| headRepositoryNameWithOwner && | ||
| repositoryName.test(headRepositoryNameWithOwner) && | ||
| safeShellArgument.test(headBranch) | ||
| ? `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}` | ||
| : null, | ||
| authorProfileUrl: () => null, | ||
| referenceAutolinkRepositoryUrl: () => null, | ||
| reviewSummaryRequired: () => false, | ||
| isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"), | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| /** | ||
| * The client half of a source control host package. | ||
| * | ||
| * Every host package exports a `SourceControlClientDefinition` from its `./client/definition` | ||
| * entry point, the way `./server/driver` is its server entry. Web, mobile, and client-runtime | ||
| * read labels, change request nouns, picker copy, and checkout commands from these definitions, | ||
| * so a new host needs no per-kind branches in client code. | ||
| * | ||
| * Definitions are browser- and React Native-safe: plain data and pure functions, no server or | ||
| * platform UI imports. | ||
| * | ||
| * @module source-control-core/client/definition | ||
| */ | ||
| import { type PullRequestReviewVerdict, SourceControlProviderKind } from "@t3tools/contracts"; | ||
|
|
||
| /** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */ | ||
| export interface ChangeRequestTerminology { | ||
| readonly shortLabel: string; | ||
| readonly singular: string; | ||
| } | ||
|
|
||
| /** Where a project's repository lives, for building its change request URLs. */ | ||
| export interface ChangeRequestUrlInput { | ||
| /** The host below which the repository is addressed, as `pullRequestHostOf` reports it. */ | ||
| readonly host: string; | ||
| /** The repository path below the host. */ | ||
| readonly repository: string; | ||
| readonly number: number; | ||
| /** The checkout's remote URL, whose origin some hosts serve their web pages from. */ | ||
| readonly remoteUrl?: string | undefined; | ||
| /** The repository's browser URL, when the server resolved one from a hosting account. */ | ||
| readonly webUrl?: string | undefined; | ||
| } | ||
|
|
||
| /** The change request a checkout command is built for. */ | ||
| export interface ChangeRequestCheckoutInput { | ||
| readonly number: number; | ||
| readonly headBranch: string; | ||
| readonly headRepositoryNameWithOwner?: string | null | undefined; | ||
| /** The repository's web URL, for hosts whose checkout fetches from it. */ | ||
| readonly repositoryUrl?: string | null | undefined; | ||
| } | ||
|
|
||
| export interface SourceControlClientDefinition { | ||
| readonly kind: SourceControlProviderKind; | ||
| /** The host's name, as in "Open on GitHub". */ | ||
| readonly label: string; | ||
| /** How the clone and publish pickers name the host; Forgejo's covers Gitea too. */ | ||
| readonly pickerLabel: string; | ||
| /** Glyph key each client maps to its own icon; a key a client lacks draws the generic glyph. */ | ||
| readonly icon: string; | ||
| readonly changeRequest: ChangeRequestTerminology; | ||
| /** Placeholder for a repository path on this host, such as `owner/repo`. */ | ||
| readonly repositoryPathHint: string; | ||
| /** | ||
| * The hostname a change request reference can be attributed to before any repository | ||
| * identity is known, or null when a hostname alone does not name this host. | ||
| */ | ||
| readonly publicHost: string | null; | ||
| /** Subtitle beside the host in the publish picker. */ | ||
| readonly publishDescription: string; | ||
| /** Where a publish lands, given the host the server is signed in to, if it reported one. */ | ||
| readonly publishHost: (signedInHost: string | null) => string; | ||
| /** Which of a repository's clone URLs a new clone uses. */ | ||
| readonly defaultCloneTransport: "https" | "ssh"; | ||
| /** The web URL of a change request, or null when this client cannot build one for the host. */ | ||
| readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null; | ||
| /** A shell command that checks the change request out, or null when it cannot be built. */ | ||
| readonly checkoutCommand: (changeRequest: ChangeRequestCheckoutInput) => string | null; | ||
| /** A comment author's profile page, or null where the host has none clients can link. */ | ||
| readonly authorProfileUrl: (login: string, repositoryUrl: string) => string | null; | ||
| /** | ||
| * The repository URL that `#123` and commit SHAs in change request text link under, using | ||
| * GitHub's `/issues/` and `/commit/` routes, or null where the host does not route them so. | ||
| */ | ||
| readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null; | ||
| /** Whether a review with this verdict must carry a summary, beyond what every host asks. */ | ||
| readonly reviewSummaryRequired: (verdict: PullRequestReviewVerdict) => boolean; | ||
| /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */ | ||
| readonly isChangeRequestUrl: (url: string) => boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Whether a URL's path is a change request at `route`, such as `/pull/`, followed by its number. | ||
| * Matching the parsed path keeps a query or fragment that mentions another host's route out. | ||
| */ | ||
| export function isChangeRequestPath(url: string, route: string): boolean { | ||
| try { | ||
| const path = new URL(url).pathname; | ||
| const at = path.indexOf(route); | ||
| return at > 0 && /^\d+(?:\/|$)/u.test(path.slice(at + route.length)); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| export function defineSourceControlClient<const Definition extends SourceControlClientDefinition>( | ||
| definition: Definition, | ||
| ): Definition { | ||
| return definition; | ||
| } | ||
|
|
||
| /** What clients show for a host they ship no definition for, including `unknown`. */ | ||
| const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { | ||
| kind: SourceControlProviderKind.make("unknown"), | ||
| label: "source control", | ||
| pickerLabel: "source control", | ||
| icon: "change-request", | ||
| changeRequest: { shortLabel: "change request", singular: "change request" }, | ||
| repositoryPathHint: "URL", | ||
| publicHost: null, | ||
| publishDescription: "Your signed-in server", | ||
| publishHost: (signedInHost) => signedInHost ?? "your server", | ||
| defaultCloneTransport: "ssh", | ||
| changeRequestUrl: () => null, | ||
| checkoutCommand: () => null, | ||
| authorProfileUrl: () => null, | ||
| referenceAutolinkRepositoryUrl: () => null, | ||
| reviewSummaryRequired: () => false, | ||
| isChangeRequestUrl: () => false, | ||
| }; | ||
|
|
||
| /** The host definitions a client loaded. */ | ||
| export interface SourceControlClientRegistry { | ||
| readonly definitions: ReadonlyArray<SourceControlClientDefinition>; | ||
| /** | ||
| * The definition for a kind. No kind at all, before a repository reports its host, reads as | ||
| * the first definition; a kind this client lacks, `unknown` included, reads as | ||
| * `UNKNOWN_SOURCE_CONTROL_CLIENT`. | ||
| */ | ||
| readonly get: (kind: string | null | undefined) => SourceControlClientDefinition; | ||
| /** The definition for a kind, or `undefined` for one this client lacks. */ | ||
| readonly find: (kind: string) => SourceControlClientDefinition | undefined; | ||
| /** The definition whose public instance is `hostname`, if any. */ | ||
| readonly findByPublicHost: (hostname: string) => SourceControlClientDefinition | undefined; | ||
| /** The definition whose change request path shape `url` has, if any. */ | ||
| readonly findByChangeRequestUrl: (url: string) => SourceControlClientDefinition | undefined; | ||
| /** The name of the host a change request URL is on, for copy about that change request. */ | ||
| readonly hostLabelForChangeRequestUrl: (url: string) => string; | ||
| } | ||
|
|
||
| export function makeSourceControlClientRegistry( | ||
| definitions: ReadonlyArray<SourceControlClientDefinition>, | ||
| ): SourceControlClientRegistry { | ||
| const byKind = new Map<string, SourceControlClientDefinition>(); | ||
| for (const definition of definitions) { | ||
| if (byKind.has(definition.kind)) { | ||
| throw new Error(`Source control host '${definition.kind}' is defined more than once.`); | ||
| } | ||
| byKind.set(definition.kind, definition); | ||
| } | ||
| return { | ||
| definitions, | ||
| get: (kind) => | ||
| (kind == null ? definitions[0] : byKind.get(kind)) ?? UNKNOWN_SOURCE_CONTROL_CLIENT, | ||
| find: (kind) => byKind.get(kind), | ||
| findByPublicHost: (hostname) => { | ||
| const host = hostname.toLowerCase(); | ||
| return definitions.find((definition) => definition.publicHost === host); | ||
| }, | ||
| findByChangeRequestUrl: (url) => | ||
| definitions.find((definition) => definition.isChangeRequestUrl(url)), | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| hostLabelForChangeRequestUrl: (url) => | ||
| definitions.find((definition) => definition.isChangeRequestUrl(url))?.label ?? "the host", | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.