| author | |
| committer | |
| log | 7bc732804222946c0c9b23af057484e4412698fc |
| tree | d0f2837b51b2a0956247c05389a60c4fa39110db |
| parent | fd3549c8ffa5eac8230a8b5e1a87e59d28ba488e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>18 files changed, 516 insertions(+), 0 deletions(-)
packages/client-runtime/package.json+11| ... | ... | @@ -390,6 +390,10 @@ |
| 390 | 390 | "./state/mcp-apps": { |
| 391 | 391 | "types": "./src/state/mcpApps.ts", |
| 392 | 392 | "default": "./src/state/mcpApps.ts" |
| 393 | }, | |
| 394 | "./source-control-clients": { | |
| 395 | "types": "./src/sourceControlClients.ts", | |
| 396 | "default": "./src/sourceControlClients.ts" | |
| 393 | 397 | } |
| 394 | 398 | }, |
| 395 | 399 | "scripts": { |
| ... | ... | @@ -399,6 +403,13 @@ |
| 399 | 403 | "dependencies": { |
| 400 | 404 | "@t3tools/contracts": "workspace:*", |
| 401 | 405 | "@t3tools/shared": "workspace:*", |
| 406 | "@t3tools/source-control-azure-devops": "workspace:*", | |
| 407 | "@t3tools/source-control-bitbucket": "workspace:*", | |
| 408 | "@t3tools/source-control-core": "workspace:*", | |
| 409 | "@t3tools/source-control-forgejo": "workspace:*", | |
| 410 | "@t3tools/source-control-gitcafe": "workspace:*", | |
| 411 | "@t3tools/source-control-github": "workspace:*", | |
| 412 | "@t3tools/source-control-gitlab": "workspace:*", | |
| 402 | 413 | "effect": "catalog:", |
| 403 | 414 | "three": "0.180.0" |
| 404 | 415 | }, |
packages/client-runtime/src/sourceControlClients.test.ts created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { sourceControlClients } from "./sourceControlClients.ts"; | |
| 4 | ||
| 5 | describe("sourceControlClients", () => { | |
| 6 | it("reads a missing kind as the first host and an unshipped kind as the generic one", () => { | |
| 7 | expect(sourceControlClients.get(undefined).kind).toBe("github"); | |
| 8 | expect(sourceControlClients.get("forkhost").kind).toBe("unknown"); | |
| 9 | }); | |
| 10 | ||
| 11 | it("gives a change request URL to the first host whose shape it has", () => { | |
| 12 | // GitCafe and Forgejo share `/pulls/`; GitCafe only claims its own hostnames. | |
| 13 | const kindOf = (url: string) => sourceControlClients.findByChangeRequestUrl(url)?.kind; | |
| 14 | expect(kindOf("https://git.cafe/acme/web/pulls/7")).toBe("gitcafe"); | |
| 15 | expect(kindOf("https://codeberg.org/acme/web/pulls/7")).toBe("forgejo"); | |
| 16 | }); | |
| 17 | }); |
packages/client-runtime/src/sourceControlClients.ts created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | /** | |
| 2 | * The source control host definitions web and mobile ship, and the one lookup they use. | |
| 3 | * | |
| 4 | * Adding a host means writing its `@t3tools/source-control-<host>` package with a | |
| 5 | * `./client/definition` entry and adding it here; clients then label, list, and check out its | |
| 6 | * change requests without further branches. | |
| 7 | * | |
| 8 | * @module client-runtime/sourceControlClients | |
| 9 | */ | |
| 10 | import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; | |
| 11 | import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; | |
| 12 | import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition"; | |
| 13 | ||
| 14 | export { | |
| 15 | type ChangeRequestTerminology, | |
| 16 | type SourceControlClientDefinition, | |
| 17 | } from "@t3tools/source-control-core/client/definition"; | |
| 18 | import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; | |
| 19 | import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; | |
| 20 | import * as GitHub from "@t3tools/source-control-github/client/definition"; | |
| 21 | import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; | |
| 22 | ||
| 23 | /** | |
| 24 | * GitHub comes first because a repository that has not reported its host yet reads as GitHub, | |
| 25 | * which is what clients have always shown there. A change request URL goes to the first host | |
| 26 | * whose shape it has, so GitCafe, which claims `/pulls/` only on its own hostnames, comes before | |
| 27 | * Forgejo, which claims it everywhere. Pickers sort by readiness and label, not by this order. | |
| 28 | */ | |
| 29 | const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ | |
| 30 | GitHub.definition, | |
| 31 | GitLab.definition, | |
| 32 | GitCafe.definition, | |
| 33 | Forgejo.definition, | |
| 34 | Bitbucket.definition, | |
| 35 | AzureDevOps.definition, | |
| 36 | ]; | |
| 37 | ||
| 38 | /** `definitions` lists the built-in hosts in that order, for pickers that offer every host. */ | |
| 39 | export const sourceControlClients = makeSourceControlClientRegistry( | |
| 40 | BUILT_IN_SOURCE_CONTROL_CLIENTS, | |
| 41 | ); |
packages/source-control-azure-devops/package.json+4| ... | ... | @@ -3,6 +3,10 @@ |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | "./client/definition": { | |
| 7 | "types": "./src/client/definition.ts", | |
| 8 | "import": "./src/client/definition.ts" | |
| 9 | }, | |
| 6 | 10 | "./server/AzureDevOpsCli": { |
| 7 | 11 | "types": "./src/server/AzureDevOpsCli.ts", |
| 8 | 12 | "import": "./src/server/AzureDevOpsCli.ts" |
packages/source-control-azure-devops/src/client/definition.ts created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | /** | |
| 2 | * Azure DevOps's client definition. Browser- and React Native-safe. | |
| 3 | * | |
| 4 | * @module source-control-azure-devops/client/definition | |
| 5 | */ | |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; | |
| 7 | import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; | |
| 8 | import { | |
| 9 | defineSourceControlClient, | |
| 10 | isChangeRequestPath, | |
| 11 | } from "@t3tools/source-control-core/client/definition"; | |
| 12 | ||
| 13 | export const definition = defineSourceControlClient({ | |
| 14 | kind: SourceControlProviderKind.make("azure-devops"), | |
| 15 | label: "Azure DevOps", | |
| 16 | pickerLabel: "Azure DevOps", | |
| 17 | icon: "azure-devops", | |
| 18 | changeRequest: { shortLabel: "PR", singular: "pull request" }, | |
| 19 | repositoryPathHint: "project/repository", | |
| 20 | // `az repos` reads need the checkout's organization and project, so a bare hostname names no | |
| 21 | // repository this client can act on. | |
| 22 | publicHost: null, | |
| 23 | publishDescription: "dev.azure.com", | |
| 24 | publishHost: () => "dev.azure.com", | |
| 25 | defaultCloneTransport: "ssh", | |
| 26 | changeRequestUrl: ({ host, repository, number }) => | |
| 27 | `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`, | |
| 28 | checkoutCommand: ({ number }) => `az repos pr checkout --id ${number}`, | |
| 29 | authorProfileUrl: () => null, | |
| 30 | referenceAutolinkRepositoryUrl: () => null, | |
| 31 | reviewSummaryRequired: () => false, | |
| 32 | isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"), | |
| 33 | }); |
packages/source-control-bitbucket/package.json+4| ... | ... | @@ -3,6 +3,10 @@ |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | "./client/definition": { | |
| 7 | "types": "./src/client/definition.ts", | |
| 8 | "import": "./src/client/definition.ts" | |
| 9 | }, | |
| 6 | 10 | "./server/BitbucketApi": { |
| 7 | 11 | "types": "./src/server/BitbucketApi.ts", |
| 8 | 12 | "import": "./src/server/BitbucketApi.ts" |
packages/source-control-bitbucket/src/client/definition.ts created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | /** | |
| 2 | * Bitbucket's client definition. Browser- and React Native-safe. | |
| 3 | * | |
| 4 | * @module source-control-bitbucket/client/definition | |
| 5 | */ | |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | defineSourceControlClient, | |
| 9 | isChangeRequestPath, | |
| 10 | } from "@t3tools/source-control-core/client/definition"; | |
| 11 | ||
| 12 | const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; | |
| 13 | const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; | |
| 14 | ||
| 15 | export const definition = defineSourceControlClient({ | |
| 16 | kind: SourceControlProviderKind.make("bitbucket"), | |
| 17 | label: "Bitbucket", | |
| 18 | pickerLabel: "Bitbucket", | |
| 19 | icon: "bitbucket", | |
| 20 | changeRequest: { shortLabel: "PR", singular: "pull request" }, | |
| 21 | repositoryPathHint: "workspace/repository", | |
| 22 | publicHost: "bitbucket.org", | |
| 23 | publishDescription: "bitbucket.org", | |
| 24 | publishHost: () => "bitbucket.org", | |
| 25 | defaultCloneTransport: "ssh", | |
| 26 | changeRequestUrl: ({ host, repository, number }) => | |
| 27 | `https://${host}/${repository}/pull-requests/${number}`, | |
| 28 | // Bitbucket has no checkout CLI, so clone the head branch from its own repository. | |
| 29 | checkoutCommand: ({ number, headBranch, headRepositoryNameWithOwner }) => | |
| 30 | headRepositoryNameWithOwner && | |
| 31 | repositoryName.test(headRepositoryNameWithOwner) && | |
| 32 | safeShellArgument.test(headBranch) | |
| 33 | ? `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}` | |
| 34 | : null, | |
| 35 | authorProfileUrl: () => null, | |
| 36 | referenceAutolinkRepositoryUrl: () => null, | |
| 37 | reviewSummaryRequired: () => false, | |
| 38 | isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"), | |
| 39 | }); |
packages/source-control-core/package.json+4| ... | ... | @@ -3,6 +3,10 @@ |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | "./client/definition": { | |
| 7 | "types": "./src/client/definition.ts", | |
| 8 | "import": "./src/client/definition.ts" | |
| 9 | }, | |
| 6 | 10 | "./server/diffFileRevisions": { |
| 7 | 11 | "types": "./src/server/diffFileRevisions.ts", |
| 8 | 12 | "import": "./src/server/diffFileRevisions.ts" |
packages/source-control-core/src/client/definition.ts created+166| ... | ... | @@ -0,0 +1,166 @@ |
| 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 { type PullRequestReviewVerdict, SourceControlProviderKind } from "@t3tools/contracts"; | |
| 15 | ||
| 16 | /** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */ | |
| 17 | export interface ChangeRequestTerminology { | |
| 18 | readonly shortLabel: string; | |
| 19 | readonly singular: string; | |
| 20 | } | |
| 21 | ||
| 22 | /** Where a project's repository lives, for building its change request URLs. */ | |
| 23 | export interface ChangeRequestUrlInput { | |
| 24 | /** The host below which the repository is addressed, as `pullRequestHostOf` reports it. */ | |
| 25 | readonly host: string; | |
| 26 | /** The repository path below the host. */ | |
| 27 | readonly repository: string; | |
| 28 | readonly number: number; | |
| 29 | /** The checkout's remote URL, whose origin some hosts serve their web pages from. */ | |
| 30 | readonly remoteUrl?: string | undefined; | |
| 31 | /** The repository's browser URL, when the server resolved one from a hosting account. */ | |
| 32 | readonly webUrl?: string | undefined; | |
| 33 | } | |
| 34 | ||
| 35 | /** The change request a checkout command is built for. */ | |
| 36 | export interface ChangeRequestCheckoutInput { | |
| 37 | readonly number: number; | |
| 38 | readonly headBranch: string; | |
| 39 | readonly headRepositoryNameWithOwner?: string | null | undefined; | |
| 40 | /** The repository's web URL, for hosts whose checkout fetches from it. */ | |
| 41 | readonly repositoryUrl?: string | null | undefined; | |
| 42 | } | |
| 43 | ||
| 44 | export interface SourceControlClientDefinition { | |
| 45 | readonly kind: SourceControlProviderKind; | |
| 46 | /** The host's name, as in "Open on GitHub". */ | |
| 47 | readonly label: string; | |
| 48 | /** How the clone and publish pickers name the host; Forgejo's covers Gitea too. */ | |
| 49 | readonly pickerLabel: string; | |
| 50 | /** Glyph key each client maps to its own icon; a key a client lacks draws the generic glyph. */ | |
| 51 | readonly icon: string; | |
| 52 | readonly changeRequest: ChangeRequestTerminology; | |
| 53 | /** Placeholder for a repository path on this host, such as `owner/repo`. */ | |
| 54 | readonly repositoryPathHint: string; | |
| 55 | /** | |
| 56 | * The hostname a change request reference can be attributed to before any repository | |
| 57 | * identity is known, or null when a hostname alone does not name this host. | |
| 58 | */ | |
| 59 | readonly publicHost: string | null; | |
| 60 | /** Subtitle beside the host in the publish picker. */ | |
| 61 | readonly publishDescription: string; | |
| 62 | /** Where a publish lands, given the host the server is signed in to, if it reported one. */ | |
| 63 | readonly publishHost: (signedInHost: string | null) => string; | |
| 64 | /** Which of a repository's clone URLs a new clone uses. */ | |
| 65 | readonly defaultCloneTransport: "https" | "ssh"; | |
| 66 | /** The web URL of a change request, or null when this client cannot build one for the host. */ | |
| 67 | readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null; | |
| 68 | /** A shell command that checks the change request out, or null when it cannot be built. */ | |
| 69 | readonly checkoutCommand: (changeRequest: ChangeRequestCheckoutInput) => string | null; | |
| 70 | /** A comment author's profile page, or null where the host has none clients can link. */ | |
| 71 | readonly authorProfileUrl: (login: string, repositoryUrl: string) => string | null; | |
| 72 | /** | |
| 73 | * The repository URL that `#123` and commit SHAs in change request text link under, using | |
| 74 | * GitHub's `/issues/` and `/commit/` routes, or null where the host does not route them so. | |
| 75 | */ | |
| 76 | readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null; | |
| 77 | /** Whether a review with this verdict must carry a summary, beyond what every host asks. */ | |
| 78 | readonly reviewSummaryRequired: (verdict: PullRequestReviewVerdict) => boolean; | |
| 79 | /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */ | |
| 80 | readonly isChangeRequestUrl: (url: string) => boolean; | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * Whether a URL's path is a change request at `route`, such as `/pull/`, followed by its number. | |
| 85 | * Matching the parsed path keeps a query or fragment that mentions another host's route out. | |
| 86 | */ | |
| 87 | export function isChangeRequestPath(url: string, route: string): boolean { | |
| 88 | try { | |
| 89 | const path = new URL(url).pathname; | |
| 90 | const at = path.indexOf(route); | |
| 91 | return at > 0 && /^\d+(?:\/|$)/u.test(path.slice(at + route.length)); | |
| 92 | } catch { | |
| 93 | return false; | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | export function defineSourceControlClient<const Definition extends SourceControlClientDefinition>( | |
| 98 | definition: Definition, | |
| 99 | ): Definition { | |
| 100 | return definition; | |
| 101 | } | |
| 102 | ||
| 103 | /** What clients show for a host they ship no definition for, including `unknown`. */ | |
| 104 | const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { | |
| 105 | kind: SourceControlProviderKind.make("unknown"), | |
| 106 | label: "source control", | |
| 107 | pickerLabel: "source control", | |
| 108 | icon: "change-request", | |
| 109 | changeRequest: { shortLabel: "change request", singular: "change request" }, | |
| 110 | repositoryPathHint: "URL", | |
| 111 | publicHost: null, | |
| 112 | publishDescription: "Your signed-in server", | |
| 113 | publishHost: (signedInHost) => signedInHost ?? "your server", | |
| 114 | defaultCloneTransport: "ssh", | |
| 115 | changeRequestUrl: () => null, | |
| 116 | checkoutCommand: () => null, | |
| 117 | authorProfileUrl: () => null, | |
| 118 | referenceAutolinkRepositoryUrl: () => null, | |
| 119 | reviewSummaryRequired: () => false, | |
| 120 | isChangeRequestUrl: () => false, | |
| 121 | }; | |
| 122 | ||
| 123 | /** The host definitions a client loaded. */ | |
| 124 | export interface SourceControlClientRegistry { | |
| 125 | readonly definitions: ReadonlyArray<SourceControlClientDefinition>; | |
| 126 | /** | |
| 127 | * The definition for a kind. No kind at all, before a repository reports its host, reads as | |
| 128 | * the first definition; a kind this client lacks, `unknown` included, reads as | |
| 129 | * `UNKNOWN_SOURCE_CONTROL_CLIENT`. | |
| 130 | */ | |
| 131 | readonly get: (kind: string | null | undefined) => SourceControlClientDefinition; | |
| 132 | /** The definition for a kind, or `undefined` for one this client lacks. */ | |
| 133 | readonly find: (kind: string) => SourceControlClientDefinition | undefined; | |
| 134 | /** The definition whose public instance is `hostname`, if any. */ | |
| 135 | readonly findByPublicHost: (hostname: string) => SourceControlClientDefinition | undefined; | |
| 136 | /** The definition whose change request path shape `url` has, if any. */ | |
| 137 | readonly findByChangeRequestUrl: (url: string) => SourceControlClientDefinition | undefined; | |
| 138 | /** The name of the host a change request URL is on, for copy about that change request. */ | |
| 139 | readonly hostLabelForChangeRequestUrl: (url: string) => string; | |
| 140 | } | |
| 141 | ||
| 142 | export function makeSourceControlClientRegistry( | |
| 143 | definitions: ReadonlyArray<SourceControlClientDefinition>, | |
| 144 | ): SourceControlClientRegistry { | |
| 145 | const byKind = new Map<string, SourceControlClientDefinition>(); | |
| 146 | for (const definition of definitions) { | |
| 147 | if (byKind.has(definition.kind)) { | |
| 148 | throw new Error(`Source control host '${definition.kind}' is defined more than once.`); | |
| 149 | } | |
| 150 | byKind.set(definition.kind, definition); | |
| 151 | } | |
| 152 | return { | |
| 153 | definitions, | |
| 154 | get: (kind) => | |
| 155 | (kind == null ? definitions[0] : byKind.get(kind)) ?? UNKNOWN_SOURCE_CONTROL_CLIENT, | |
| 156 | find: (kind) => byKind.get(kind), | |
| 157 | findByPublicHost: (hostname) => { | |
| 158 | const host = hostname.toLowerCase(); | |
| 159 | return definitions.find((definition) => definition.publicHost === host); | |
| 160 | }, | |
| 161 | findByChangeRequestUrl: (url) => | |
| 162 | definitions.find((definition) => definition.isChangeRequestUrl(url)), | |
| 163 | hostLabelForChangeRequestUrl: (url) => | |
| 164 | definitions.find((definition) => definition.isChangeRequestUrl(url))?.label ?? "the host", | |
| 165 | }; | |
| 166 | } |
packages/source-control-forgejo/package.json+4| ... | ... | @@ -3,6 +3,10 @@ |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | "./client/definition": { | |
| 7 | "types": "./src/client/definition.ts", | |
| 8 | "import": "./src/client/definition.ts" | |
| 9 | }, | |
| 6 | 10 | "./server/driver": { |
| 7 | 11 | "types": "./src/server/driver.ts", |
| 8 | 12 | "import": "./src/server/driver.ts" |
packages/source-control-forgejo/src/client/definition.ts created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | /** | |
| 2 | * Forgejo's client definition, which covers Gitea as well. Browser- and React Native-safe. | |
| 3 | * | |
| 4 | * @module source-control-forgejo/client/definition | |
| 5 | */ | |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | defineSourceControlClient, | |
| 9 | isChangeRequestPath, | |
| 10 | } from "@t3tools/source-control-core/client/definition"; | |
| 11 | ||
| 12 | export const definition = defineSourceControlClient({ | |
| 13 | kind: SourceControlProviderKind.make("forgejo"), | |
| 14 | label: "Forgejo", | |
| 15 | pickerLabel: "Forgejo / Gitea", | |
| 16 | icon: "forgejo", | |
| 17 | changeRequest: { shortLabel: "PR", singular: "pull request" }, | |
| 18 | repositoryPathHint: "owner/repo", | |
| 19 | // Forgejo and Gitea are self-hosted, so no hostname names them. | |
| 20 | publicHost: null, | |
| 21 | publishDescription: "Your signed-in server", | |
| 22 | publishHost: (signedInHost) => signedInHost ?? "your server", | |
| 23 | defaultCloneTransport: "https", | |
| 24 | // The server's resolved web URL wins; otherwise an HTTP remote on the same host names the | |
| 25 | // origin, which may carry a port. SSH remotes say nothing about the web origin. | |
| 26 | changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { | |
| 27 | if (webUrl) return `${webUrl.replace(/\/+$/, "")}/pulls/${number}`; | |
| 28 | try { | |
| 29 | const remote = new URL(remoteUrl ?? ""); | |
| 30 | if ( | |
| 31 | (remote.protocol === "http:" || remote.protocol === "https:") && | |
| 32 | (remote.hostname.toLowerCase() === host.toLowerCase() || | |
| 33 | remote.host.toLowerCase() === host.toLowerCase()) | |
| 34 | ) { | |
| 35 | return `${remote.origin}/${repository}/pulls/${number}`; | |
| 36 | } | |
| 37 | } catch { | |
| 38 | // Not an HTTP remote. | |
| 39 | } | |
| 40 | return `https://${host}/${repository}/pulls/${number}`; | |
| 41 | }, | |
| 42 | // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself. | |
| 43 | checkoutCommand: ({ number, repositoryUrl }) => | |
| 44 | repositoryUrl | |
| 45 | ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' refs/pull/${number}/head && git checkout -B pulls/${number} FETCH_HEAD` | |
| 46 | : null, | |
| 47 | authorProfileUrl: () => null, | |
| 48 | // Forgejo refuses a change request review without a summary, even with inline comments. | |
| 49 | referenceAutolinkRepositoryUrl: () => null, | |
| 50 | reviewSummaryRequired: (verdict) => verdict === "request-changes", | |
| 51 | isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pulls/"), | |
| 52 | }); |
packages/source-control-gitcafe/package.json+4| ... | ... | @@ -3,6 +3,10 @@ |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | "./client/definition": { | |
| 7 | "types": "./src/client/definition.ts", | |
| 8 | "import": "./src/client/definition.ts" | |
| 9 | }, | |
| 6 | 10 | "./server/driver": { |
| 7 | 11 | "types": "./src/server/driver.ts", |
| 8 | 12 | "import": "./src/server/driver.ts" |
packages/source-control-gitcafe/src/client/definition.ts created+45| ... | ... | @@ -0,0 +1,45 @@ |
| 1 | /** | |
| 2 | * GitCafe's client definition. Browser- and React Native-safe. | |
| 3 | * | |
| 4 | * @module source-control-gitcafe/client/definition | |
| 5 | */ | |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | defineSourceControlClient, | |
| 9 | isChangeRequestPath, | |
| 10 | } from "@t3tools/source-control-core/client/definition"; | |
| 11 | ||
| 12 | /** GitCafe has no self-hosted installs: production and staging are its only hosts. */ | |
| 13 | const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); | |
| 14 | ||
| 15 | export const definition = defineSourceControlClient({ | |
| 16 | kind: SourceControlProviderKind.make("gitcafe"), | |
| 17 | label: "GitCafe", | |
| 18 | pickerLabel: "GitCafe", | |
| 19 | icon: "gitcafe", | |
| 20 | changeRequest: { shortLabel: "PR", singular: "pull request" }, | |
| 21 | repositoryPathHint: "owner/repo", | |
| 22 | // Left unattributed so a reference with no repository identity behind it gets no checkout | |
| 23 | // command guessed from its hostname alone. | |
| 24 | publicHost: null, | |
| 25 | publishDescription: "git.cafe", | |
| 26 | publishHost: () => "git.cafe", | |
| 27 | defaultCloneTransport: "ssh", | |
| 28 | changeRequestUrl: ({ host, repository, number }) => | |
| 29 | `https://${host}/${repository}/pulls/${number}`, | |
| 30 | checkoutCommand: ({ number }) => `cafe pr checkout ${number}`, | |
| 31 | authorProfileUrl: () => null, | |
| 32 | referenceAutolinkRepositoryUrl: () => null, | |
| 33 | reviewSummaryRequired: () => false, | |
| 34 | // `/pulls/` is Forgejo's shape too, so only GitCafe's own hosts claim it. | |
| 35 | isChangeRequestUrl: (url) => { | |
| 36 | try { | |
| 37 | return ( | |
| 38 | GITCAFE_HOSTS.has(new URL(url).hostname.toLowerCase()) && | |
| 39 | isChangeRequestPath(url, "/pulls/") | |
| 40 | ); | |
| 41 | } catch { | |
| 42 | return false; | |
| 43 | } | |
| 44 | }, | |
| 45 | }); |
packages/source-control-github/package.json+4| ... | ... | @@ -3,6 +3,10 @@ |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | "./client/definition": { | |
| 7 | "types": "./src/client/definition.ts", | |
| 8 | "import": "./src/client/definition.ts" | |
| 9 | }, | |
| 6 | 10 | "./server/driver": { |
| 7 | 11 | "types": "./src/server/driver.ts", |
| 8 | 12 | "import": "./src/server/driver.ts" |
packages/source-control-github/src/client/definition.ts created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | /** | |
| 2 | * GitHub's client definition. Browser- and React Native-safe. | |
| 3 | * | |
| 4 | * @module source-control-github/client/definition | |
| 5 | */ | |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | defineSourceControlClient, | |
| 9 | isChangeRequestPath, | |
| 10 | } from "@t3tools/source-control-core/client/definition"; | |
| 11 | ||
| 12 | export const definition = defineSourceControlClient({ | |
| 13 | kind: SourceControlProviderKind.make("github"), | |
| 14 | label: "GitHub", | |
| 15 | pickerLabel: "GitHub", | |
| 16 | icon: "github", | |
| 17 | changeRequest: { shortLabel: "PR", singular: "pull request" }, | |
| 18 | repositoryPathHint: "owner/repo", | |
| 19 | publicHost: "github.com", | |
| 20 | publishDescription: "github.com", | |
| 21 | publishHost: () => "github.com", | |
| 22 | defaultCloneTransport: "https", | |
| 23 | changeRequestUrl: ({ host, repository, number }) => | |
| 24 | `https://${host}/${repository}/pull/${number}`, | |
| 25 | checkoutCommand: ({ number }) => `gh pr checkout ${number}`, | |
| 26 | authorProfileUrl: (login, repositoryUrl) => | |
| 27 | login.endsWith("[bot]") | |
| 28 | ? null | |
| 29 | : new URL(`/${encodeURIComponent(login)}`, repositoryUrl).toString(), | |
| 30 | referenceAutolinkRepositoryUrl: (repositoryUrl) => repositoryUrl, | |
| 31 | reviewSummaryRequired: () => false, | |
| 32 | isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"), | |
| 33 | }); |
packages/source-control-gitlab/package.json+4| ... | ... | @@ -3,6 +3,10 @@ |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | "./client/definition": { | |
| 7 | "types": "./src/client/definition.ts", | |
| 8 | "import": "./src/client/definition.ts" | |
| 9 | }, | |
| 6 | 10 | "./server/driver": { |
| 7 | 11 | "types": "./src/server/driver.ts", |
| 8 | 12 | "import": "./src/server/driver.ts" |
packages/source-control-gitlab/src/client/definition.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | /** | |
| 2 | * GitLab's client definition. Browser- and React Native-safe. | |
| 3 | * | |
| 4 | * @module source-control-gitlab/client/definition | |
| 5 | */ | |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | defineSourceControlClient, | |
| 9 | isChangeRequestPath, | |
| 10 | } from "@t3tools/source-control-core/client/definition"; | |
| 11 | ||
| 12 | export const definition = defineSourceControlClient({ | |
| 13 | kind: SourceControlProviderKind.make("gitlab"), | |
| 14 | label: "GitLab", | |
| 15 | pickerLabel: "GitLab", | |
| 16 | icon: "gitlab", | |
| 17 | changeRequest: { shortLabel: "MR", singular: "merge request" }, | |
| 18 | repositoryPathHint: "group/project", | |
| 19 | publicHost: "gitlab.com", | |
| 20 | publishDescription: "gitlab.com", | |
| 21 | publishHost: () => "gitlab.com", | |
| 22 | defaultCloneTransport: "ssh", | |
| 23 | changeRequestUrl: ({ host, repository, number }) => | |
| 24 | `https://${host}/${repository}/-/merge_requests/${number}`, | |
| 25 | checkoutCommand: ({ number }) => `glab mr checkout ${number}`, | |
| 26 | authorProfileUrl: () => null, | |
| 27 | referenceAutolinkRepositoryUrl: () => null, | |
| 28 | reviewSummaryRequired: () => false, | |
| 29 | isChangeRequestUrl: (url) => isChangeRequestPath(url, "/-/merge_requests/"), | |
| 30 | }); |
pnpm-lock.yaml+21| ... | ... | @@ -1078,6 +1078,27 @@ importers: |
| 1078 | 1078 | '@t3tools/shared': |
| 1079 | 1079 | specifier: workspace:* |
| 1080 | 1080 | version: link:../shared |
| 1081 | '@t3tools/source-control-azure-devops': | |
| 1082 | specifier: workspace:* | |
| 1083 | version: link:../source-control-azure-devops | |
| 1084 | '@t3tools/source-control-bitbucket': | |
| 1085 | specifier: workspace:* | |
| 1086 | version: link:../source-control-bitbucket | |
| 1087 | '@t3tools/source-control-core': | |
| 1088 | specifier: workspace:* | |
| 1089 | version: link:../source-control-core | |
| 1090 | '@t3tools/source-control-forgejo': | |
| 1091 | specifier: workspace:* | |
| 1092 | version: link:../source-control-forgejo | |
| 1093 | '@t3tools/source-control-gitcafe': | |
| 1094 | specifier: workspace:* | |
| 1095 | version: link:../source-control-gitcafe | |
| 1096 | '@t3tools/source-control-github': | |
| 1097 | specifier: workspace:* | |
| 1098 | version: link:../source-control-github | |
| 1099 | '@t3tools/source-control-gitlab': | |
| 1100 | specifier: workspace:* | |
| 1101 | version: link:../source-control-gitlab | |
| 1081 | 1102 | effect: |
| 1082 | 1103 | specifier: 4.0.2 |
| 1083 | 1104 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) |