diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index aea6ead6b9433e1791dae126e670e20dc6706573..987ba69fe02b73319572a9f84dd5c1eb533656d2 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -390,6 +390,10 @@ "./state/mcp-apps": { "types": "./src/state/mcpApps.ts", "default": "./src/state/mcpApps.ts" + }, + "./source-control-clients": { + "types": "./src/sourceControlClients.ts", + "default": "./src/sourceControlClients.ts" } }, "scripts": { @@ -399,6 +403,13 @@ "dependencies": { "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/source-control-azure-devops": "workspace:*", + "@t3tools/source-control-bitbucket": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "@t3tools/source-control-forgejo": "workspace:*", + "@t3tools/source-control-gitcafe": "workspace:*", + "@t3tools/source-control-github": "workspace:*", + "@t3tools/source-control-gitlab": "workspace:*", "effect": "catalog:", "three": "0.180.0" }, diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..11e1e1c09c9c3c0d4f3b8632445000538acbb28c --- /dev/null +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -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"); + }); +}); diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts new file mode 100644 index 0000000000000000000000000000000000000000..108644a04b4dfdc05c8379a2a86e287f492167f1 --- /dev/null +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -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-` 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, +); diff --git a/packages/source-control-azure-devops/package.json b/packages/source-control-azure-devops/package.json index 10347850bf19c9d1d7ca7f001f99049df7bab0cb..4e5f9747c379fea83ce869f07a0b2eae36fe09b0 100644 --- a/packages/source-control-azure-devops/package.json +++ b/packages/source-control-azure-devops/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/AzureDevOpsCli": { "types": "./src/server/AzureDevOpsCli.ts", "import": "./src/server/AzureDevOpsCli.ts" diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b935c8925dcce2faef217c6f1e8b9a128a438b6 --- /dev/null +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -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/"), +}); diff --git a/packages/source-control-bitbucket/package.json b/packages/source-control-bitbucket/package.json index c878093ef54e260cd824130d42bdc5862cf1178f..890c4c34cbe5cbe9583bf4ff1fb0cf40ac4e82d7 100644 --- a/packages/source-control-bitbucket/package.json +++ b/packages/source-control-bitbucket/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/BitbucketApi": { "types": "./src/server/BitbucketApi.ts", "import": "./src/server/BitbucketApi.ts" diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts new file mode 100644 index 0000000000000000000000000000000000000000..25010be99e938a31de2cffb89263956278e443da --- /dev/null +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -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/"), +}); diff --git a/packages/source-control-core/package.json b/packages/source-control-core/package.json index ee1045b278cd7abc1eb5b8473451f8a8a7de6b64..d463190918b2962f75d3b551561a0b34210327df 100644 --- a/packages/source-control-core/package.json +++ b/packages/source-control-core/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/diffFileRevisions": { "types": "./src/server/diffFileRevisions.ts", "import": "./src/server/diffFileRevisions.ts" diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts new file mode 100644 index 0000000000000000000000000000000000000000..8db86cfcaf4dd20f504f7e3ac42e2b49edcdadc3 --- /dev/null +++ b/packages/source-control-core/src/client/definition.ts @@ -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( + 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; + /** + * 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, +): SourceControlClientRegistry { + const byKind = new Map(); + 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)), + hostLabelForChangeRequestUrl: (url) => + definitions.find((definition) => definition.isChangeRequestUrl(url))?.label ?? "the host", + }; +} diff --git a/packages/source-control-forgejo/package.json b/packages/source-control-forgejo/package.json index 4c6b534e2e81b4356a284be53796c5bdf8c918cb..a33de46a6de8a6c443ad9520e3f8599a46b3703c 100644 --- a/packages/source-control-forgejo/package.json +++ b/packages/source-control-forgejo/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts new file mode 100644 index 0000000000000000000000000000000000000000..aab19fcde22d694d960146fd5ffaf3f462435215 --- /dev/null +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -0,0 +1,52 @@ +/** + * Forgejo's client definition, which covers Gitea as well. Browser- and React Native-safe. + * + * @module source-control-forgejo/client/definition + */ +import { SourceControlProviderKind } from "@t3tools/contracts"; +import { + defineSourceControlClient, + isChangeRequestPath, +} from "@t3tools/source-control-core/client/definition"; + +export const definition = defineSourceControlClient({ + kind: SourceControlProviderKind.make("forgejo"), + label: "Forgejo", + pickerLabel: "Forgejo / Gitea", + icon: "forgejo", + changeRequest: { shortLabel: "PR", singular: "pull request" }, + repositoryPathHint: "owner/repo", + // Forgejo and Gitea are self-hosted, so no hostname names them. + publicHost: null, + publishDescription: "Your signed-in server", + publishHost: (signedInHost) => signedInHost ?? "your server", + defaultCloneTransport: "https", + // The server's resolved web URL wins; otherwise an HTTP remote on the same host names the + // origin, which may carry a port. SSH remotes say nothing about the web origin. + changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { + if (webUrl) return `${webUrl.replace(/\/+$/, "")}/pulls/${number}`; + try { + const remote = new URL(remoteUrl ?? ""); + if ( + (remote.protocol === "http:" || remote.protocol === "https:") && + (remote.hostname.toLowerCase() === host.toLowerCase() || + remote.host.toLowerCase() === host.toLowerCase()) + ) { + return `${remote.origin}/${repository}/pulls/${number}`; + } + } catch { + // Not an HTTP remote. + } + return `https://${host}/${repository}/pulls/${number}`; + }, + // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself. + checkoutCommand: ({ number, repositoryUrl }) => + repositoryUrl + ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' refs/pull/${number}/head && git checkout -B pulls/${number} FETCH_HEAD` + : null, + authorProfileUrl: () => null, + // Forgejo refuses a change request review without a summary, even with inline comments. + referenceAutolinkRepositoryUrl: () => null, + reviewSummaryRequired: (verdict) => verdict === "request-changes", + isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pulls/"), +}); diff --git a/packages/source-control-gitcafe/package.json b/packages/source-control-gitcafe/package.json index eeda68c5e92de81eae5ebc791785fc47bd88cd51..7107cd1e47f0e5af98bf8740f4abbe217d99dd3a 100644 --- a/packages/source-control-gitcafe/package.json +++ b/packages/source-control-gitcafe/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts new file mode 100644 index 0000000000000000000000000000000000000000..6407d57828d10d9e46ff7d5acb014c7d095af89c --- /dev/null +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -0,0 +1,45 @@ +/** + * GitCafe's client definition. Browser- and React Native-safe. + * + * @module source-control-gitcafe/client/definition + */ +import { SourceControlProviderKind } from "@t3tools/contracts"; +import { + defineSourceControlClient, + isChangeRequestPath, +} from "@t3tools/source-control-core/client/definition"; + +/** GitCafe has no self-hosted installs: production and staging are its only hosts. */ +const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); + +export const definition = defineSourceControlClient({ + kind: SourceControlProviderKind.make("gitcafe"), + label: "GitCafe", + pickerLabel: "GitCafe", + icon: "gitcafe", + changeRequest: { shortLabel: "PR", singular: "pull request" }, + repositoryPathHint: "owner/repo", + // Left unattributed so a reference with no repository identity behind it gets no checkout + // command guessed from its hostname alone. + publicHost: null, + publishDescription: "git.cafe", + publishHost: () => "git.cafe", + defaultCloneTransport: "ssh", + changeRequestUrl: ({ host, repository, number }) => + `https://${host}/${repository}/pulls/${number}`, + checkoutCommand: ({ number }) => `cafe pr checkout ${number}`, + authorProfileUrl: () => null, + referenceAutolinkRepositoryUrl: () => null, + reviewSummaryRequired: () => false, + // `/pulls/` is Forgejo's shape too, so only GitCafe's own hosts claim it. + isChangeRequestUrl: (url) => { + try { + return ( + GITCAFE_HOSTS.has(new URL(url).hostname.toLowerCase()) && + isChangeRequestPath(url, "/pulls/") + ); + } catch { + return false; + } + }, +}); diff --git a/packages/source-control-github/package.json b/packages/source-control-github/package.json index 46a8965976fd32098b969578cb4c37ace3a48f7d..7cd6bed31b83bd60ad07bd7c8860d7b705020794 100644 --- a/packages/source-control-github/package.json +++ b/packages/source-control-github/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe79b7f91cc358ab4e20ab5394c91554ed705fbf --- /dev/null +++ b/packages/source-control-github/src/client/definition.ts @@ -0,0 +1,33 @@ +/** + * GitHub's client definition. Browser- and React Native-safe. + * + * @module source-control-github/client/definition + */ +import { SourceControlProviderKind } from "@t3tools/contracts"; +import { + defineSourceControlClient, + isChangeRequestPath, +} from "@t3tools/source-control-core/client/definition"; + +export const definition = defineSourceControlClient({ + kind: SourceControlProviderKind.make("github"), + label: "GitHub", + pickerLabel: "GitHub", + icon: "github", + changeRequest: { shortLabel: "PR", singular: "pull request" }, + repositoryPathHint: "owner/repo", + publicHost: "github.com", + publishDescription: "github.com", + publishHost: () => "github.com", + defaultCloneTransport: "https", + changeRequestUrl: ({ host, repository, number }) => + `https://${host}/${repository}/pull/${number}`, + checkoutCommand: ({ number }) => `gh pr checkout ${number}`, + authorProfileUrl: (login, repositoryUrl) => + login.endsWith("[bot]") + ? null + : new URL(`/${encodeURIComponent(login)}`, repositoryUrl).toString(), + referenceAutolinkRepositoryUrl: (repositoryUrl) => repositoryUrl, + reviewSummaryRequired: () => false, + isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"), +}); diff --git a/packages/source-control-gitlab/package.json b/packages/source-control-gitlab/package.json index 6b99c878ccb83317db3948096ccb117a308765df..def5c117c558c6484c64c9af263a179706f5c2b0 100644 --- a/packages/source-control-gitlab/package.json +++ b/packages/source-control-gitlab/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts new file mode 100644 index 0000000000000000000000000000000000000000..23c88b1b6d2fd60ffbddf8cb4f3bdc1faf9bb492 --- /dev/null +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -0,0 +1,30 @@ +/** + * GitLab's client definition. Browser- and React Native-safe. + * + * @module source-control-gitlab/client/definition + */ +import { SourceControlProviderKind } from "@t3tools/contracts"; +import { + defineSourceControlClient, + isChangeRequestPath, +} from "@t3tools/source-control-core/client/definition"; + +export const definition = defineSourceControlClient({ + kind: SourceControlProviderKind.make("gitlab"), + label: "GitLab", + pickerLabel: "GitLab", + icon: "gitlab", + changeRequest: { shortLabel: "MR", singular: "merge request" }, + repositoryPathHint: "group/project", + publicHost: "gitlab.com", + publishDescription: "gitlab.com", + publishHost: () => "gitlab.com", + defaultCloneTransport: "ssh", + changeRequestUrl: ({ host, repository, number }) => + `https://${host}/${repository}/-/merge_requests/${number}`, + checkoutCommand: ({ number }) => `glab mr checkout ${number}`, + authorProfileUrl: () => null, + referenceAutolinkRepositoryUrl: () => null, + reviewSummaryRequired: () => false, + isChangeRequestUrl: (url) => isChangeRequestPath(url, "/-/merge_requests/"), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 249f45a453cc958c2961f4476ff64d5984e9fd2b..dcd65d4ce34ee9a72cf4d73d1a9ef41516832337 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1078,6 +1078,27 @@ importers: '@t3tools/shared': specifier: workspace:* version: link:../shared + '@t3tools/source-control-azure-devops': + specifier: workspace:* + version: link:../source-control-azure-devops + '@t3tools/source-control-bitbucket': + specifier: workspace:* + version: link:../source-control-bitbucket + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + '@t3tools/source-control-forgejo': + specifier: workspace:* + version: link:../source-control-forgejo + '@t3tools/source-control-gitcafe': + specifier: workspace:* + version: link:../source-control-gitcafe + '@t3tools/source-control-github': + specifier: workspace:* + version: link:../source-control-github + '@t3tools/source-control-gitlab': + specifier: workspace:* + version: link:../source-control-gitlab effect: specifier: 4.0.2 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)