| 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,6 +390,10 @@ |
| 390 | "./state/mcp-apps": { | 390 | "./state/mcp-apps": { |
| 391 | "types": "./src/state/mcpApps.ts", | 391 | "types": "./src/state/mcpApps.ts", |
| 392 | "default": "./src/state/mcpApps.ts" | 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 | "scripts": { | 399 | "scripts": { |
| ... | @@ -399,6 +403,13 @@ | ... | @@ -399,6 +403,13 @@ |
| 399 | "dependencies": { | 403 | "dependencies": { |
| 400 | "@t3tools/contracts": "workspace:*", | 404 | "@t3tools/contracts": "workspace:*", |
| 401 | "@t3tools/shared": "workspace:*", | 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 | "effect": "catalog:", | 413 | "effect": "catalog:", |
| 403 | "three": "0.180.0" | 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,6 +3,10 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./client/definition": { | ||
| 7 | "types": "./src/client/definition.ts", | ||
| 8 | "import": "./src/client/definition.ts" | ||
| 9 | }, | ||
| 6 | "./server/AzureDevOpsCli": { | 10 | "./server/AzureDevOpsCli": { |
| 7 | "types": "./src/server/AzureDevOpsCli.ts", | 11 | "types": "./src/server/AzureDevOpsCli.ts", |
| 8 | "import": "./src/server/AzureDevOpsCli.ts" | 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,6 +3,10 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./client/definition": { | ||
| 7 | "types": "./src/client/definition.ts", | ||
| 8 | "import": "./src/client/definition.ts" | ||
| 9 | }, | ||
| 6 | "./server/BitbucketApi": { | 10 | "./server/BitbucketApi": { |
| 7 | "types": "./src/server/BitbucketApi.ts", | 11 | "types": "./src/server/BitbucketApi.ts", |
| 8 | "import": "./src/server/BitbucketApi.ts" | 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,6 +3,10 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./client/definition": { | ||
| 7 | "types": "./src/client/definition.ts", | ||
| 8 | "import": "./src/client/definition.ts" | ||
| 9 | }, | ||
| 6 | "./server/diffFileRevisions": { | 10 | "./server/diffFileRevisions": { |
| 7 | "types": "./src/server/diffFileRevisions.ts", | 11 | "types": "./src/server/diffFileRevisions.ts", |
| 8 | "import": "./src/server/diffFileRevisions.ts" | 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,6 +3,10 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./client/definition": { | ||
| 7 | "types": "./src/client/definition.ts", | ||
| 8 | "import": "./src/client/definition.ts" | ||
| 9 | }, | ||
| 6 | "./server/driver": { | 10 | "./server/driver": { |
| 7 | "types": "./src/server/driver.ts", | 11 | "types": "./src/server/driver.ts", |
| 8 | "import": "./src/server/driver.ts" | 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,6 +3,10 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./client/definition": { | ||
| 7 | "types": "./src/client/definition.ts", | ||
| 8 | "import": "./src/client/definition.ts" | ||
| 9 | }, | ||
| 6 | "./server/driver": { | 10 | "./server/driver": { |
| 7 | "types": "./src/server/driver.ts", | 11 | "types": "./src/server/driver.ts", |
| 8 | "import": "./src/server/driver.ts" | 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,6 +3,10 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./client/definition": { | ||
| 7 | "types": "./src/client/definition.ts", | ||
| 8 | "import": "./src/client/definition.ts" | ||
| 9 | }, | ||
| 6 | "./server/driver": { | 10 | "./server/driver": { |
| 7 | "types": "./src/server/driver.ts", | 11 | "types": "./src/server/driver.ts", |
| 8 | "import": "./src/server/driver.ts" | 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,6 +3,10 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./client/definition": { | ||
| 7 | "types": "./src/client/definition.ts", | ||
| 8 | "import": "./src/client/definition.ts" | ||
| 9 | }, | ||
| 6 | "./server/driver": { | 10 | "./server/driver": { |
| 7 | "types": "./src/server/driver.ts", | 11 | "types": "./src/server/driver.ts", |
| 8 | "import": "./src/server/driver.ts" | 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,6 +1078,27 @@ importers: |
| 1078 | '@t3tools/shared': | 1078 | '@t3tools/shared': |
| 1079 | specifier: workspace:* | 1079 | specifier: workspace:* |
| 1080 | version: link:../shared | 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 | effect: | 1102 | effect: |
| 1082 | specifier: 4.0.2 | 1103 | specifier: 4.0.2 |
| 1083 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) | 1104 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) |