diff --git a/.github/workflows/build-gitea.yml b/.github/workflows/build-gitea.yml new file mode 100644 index 000000000000..5c10a49e2748 --- /dev/null +++ b/.github/workflows/build-gitea.yml @@ -0,0 +1,116 @@ +name: Build Gitea fork artifacts + +# Personal fork build. Upstream runs on Blacksmith runners that do not exist in +# this fork, so everything here uses GitHub-hosted runners instead. +on: + workflow_dispatch: + push: + branches: + - main + - gitea-provider-merge + +permissions: + contents: read + +jobs: + linux: + name: Linux (server bundle + AppImage) + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Install Linux packaging prerequisites + run: | + sudo apt-get update + sudo apt-get install -y build-essential libsecret-1-dev pkg-config imagemagick + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build Linux AppImage + run: vp run dist:desktop:linux + + - name: Upload Linux server bundle + uses: actions/upload-artifact@v7 + with: + name: t3-server-linux-x64 + path: apps/server/dist + if-no-files-found: error + retention-days: 30 + + - name: Upload Linux AppImage + uses: actions/upload-artifact@v7 + with: + name: t3-desktop-linux-x64 + path: release/*.AppImage + if-no-files-found: error + retention-days: 30 + + windows: + name: Windows (NSIS installer) + runs-on: windows-2022 + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: false + run-install: true + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + # Mirrors upstream: node-pty and friends are rebuilt with MSVC, which + # wants the Spectre-mitigated libraries that are optional in the base + # runner image. + - name: Install Spectre-mitigated MSVC libs + shell: pwsh + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $installPath = & $vswhere -products * -latest -property installationPath + $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" + $proc = Start-Process -FilePath $setupExe ` + -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", "--quiet", "--norestart" ` + -Wait -PassThru -NoNewWindow + if ($null -eq $proc -or $proc.ExitCode -ne 0) { + $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } + Write-Error "Visual Studio Installer failed with exit code $code" + exit $code + } + + - name: Build Windows installer + run: vp run dist:desktop:win + + - name: Upload Windows installer + uses: actions/upload-artifact@v7 + with: + name: t3-desktop-windows-x64 + path: release/*.exe + if-no-files-found: error + retention-days: 30 diff --git a/apps/mobile/src/components/SourceControlIcon.tsx b/apps/mobile/src/components/SourceControlIcon.tsx index 873f3729a8a1..846ff7b025b2 100644 --- a/apps/mobile/src/components/SourceControlIcon.tsx +++ b/apps/mobile/src/components/SourceControlIcon.tsx @@ -1,9 +1,15 @@ -import Svg, { Circle, Defs, G, LinearGradient, Path, Stop } from "react-native-svg"; +import Svg, { Circle, Defs, G, LinearGradient, Line, Path, Stop } from "react-native-svg"; import { withUniwind } from "uniwind"; const ThemedSvg = withUniwind(Svg); -export type SourceControlIconKind = "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; +export type SourceControlIconKind = + | "github" + | "gitlab" + | "forgejo" + | "bitbucket" + | "azure-devops" + | "gitea"; export function SourceControlIcon(props: { readonly kind: SourceControlIconKind; @@ -14,6 +20,29 @@ export function SourceControlIcon(props: { const size = props.size ?? 18; switch (props.kind) { + case "gitea": + return ( + + + + + + + ); case "forgejo": // Official two-color mark from https://forgejo.org/favicon.svg. return ( diff --git a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx index 17700e6599c9..496669d7de28 100644 --- a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx +++ b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx @@ -19,7 +19,8 @@ export function AddProjectRepositoryRoute({ source === "gitlab" || source === "forgejo" || source === "bitbucket" || - source === "azure-devops" + source === "azure-devops" || + source === "gitea" ? addProjectRemoteSourceLabel(source) : "Git URL"; diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 5231228829d3..b78fb2d308e1 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -112,7 +112,8 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote source === "gitlab" || source === "forgejo" || source === "bitbucket" || - source === "azure-devops" + source === "azure-devops" || + source === "gitea" ) { return source; } diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index cab5020d7423..3b72bf128414 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -31,6 +31,8 @@ import { TextGenerationError, } from "@t3tools/contracts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GiteaCli from "../sourceControl/GiteaCli.ts"; +import * as GiteaSourceControlProvider from "../sourceControl/GiteaSourceControlProvider.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b8c3ca6096e9..83dd01091682 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -53,6 +53,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; +import * as GiteaCli from "./sourceControl/GiteaCli.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; @@ -321,6 +322,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, + GiteaCli.layer, GitHubCli.layer, GitLabCli.layer, ForgejoCli.layer, diff --git a/apps/server/src/sourceControl/GiteaCli.test.ts b/apps/server/src/sourceControl/GiteaCli.test.ts new file mode 100644 index 000000000000..f25ce1469aa6 --- /dev/null +++ b/apps/server/src/sourceControl/GiteaCli.test.ts @@ -0,0 +1,1095 @@ +import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GiteaCli from "./GiteaCli.ts"; + +const mockedRun = vi.fn(); +const layer = it.layer( + GiteaCli.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: mockedRun, + }), + ), + ), +); + +/** + * `tea api -i` prints the HTTP status line to stderr and the body to stdout, and exits 0 whatever + * the status is. These doubles reproduce that exactly; it is the behavior the error mapping rests + * on, verified against tea 0.15.1. + */ +function apiOutput(stdout: string, status = 200): VcsProcess.VcsProcessOutput { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: `HTTP/1.1 ${status} ${status === 200 ? "OK" : "Error"}\r\nContent-Type: application/json\r\n`, + stdoutTruncated: false, + stderrTruncated: false, + }; +} + +/** Serializes a fixture into the stdout tea would produce. */ +function apiJson(value: unknown, status = 200): VcsProcess.VcsProcessOutput { + return apiOutput(JSON.stringify(value), status); +} + +function pullRequestJson(overrides: Record = {}) { + return { + number: 42, + title: "Add widget", + html_url: "https://git.example.com/owner/repo/pulls/42", + state: "open", + merged: false, + updated_at: "2026-01-02T03:04:05Z", + base: { ref: "main", label: "main", repo: { full_name: "owner/repo" } }, + head: { + ref: "t3code/abcd1234", + label: "t3code/abcd1234", + repo: { full_name: "owner/repo", owner: { login: "owner" } }, + }, + ...overrides, + }; +} + +function lastArgs(): ReadonlyArray { + const call = mockedRun.mock.calls.at(-1); + return call?.[0].args ?? []; +} + +afterEach(() => { + mockedRun.mockReset(); +}); + +describe("parseHttpStatusCode", () => { + it("reads the status line tea writes under -i", () => { + expect(GiteaCli.parseHttpStatusCode("HTTP/1.1 404 Not Found\r\nDate: x\r\n")).toBe(404); + expect(GiteaCli.parseHttpStatusCode("HTTP/2 200 OK\n")).toBe(200); + }); + + it("uses the final status when a redirect chain is reported", () => { + expect(GiteaCli.parseHttpStatusCode("HTTP/1.1 301 Moved\nHTTP/1.1 200 OK\n")).toBe(200); + }); + + it("returns null when no status line is present", () => { + expect(GiteaCli.parseHttpStatusCode("")).toBeNull(); + expect(GiteaCli.parseHttpStatusCode("some other output")).toBeNull(); + }); +}); + +describe("parseGiteaPullRequestReference", () => { + it("accepts bare and hash-prefixed indexes", () => { + expect(GiteaCli.parseGiteaPullRequestReference("42")).toEqual({ index: "42" }); + expect(GiteaCli.parseGiteaPullRequestReference("#42")).toEqual({ index: "42" }); + expect(GiteaCli.parseGiteaPullRequestReference(" 42 ")).toEqual({ index: "42" }); + }); + + it("accepts Gitea PR URLs on arbitrary self-hosted hosts", () => { + expect(GiteaCli.parseGiteaPullRequestReference("https://gitea.com/foo/bar/pulls/1")).toEqual({ + index: "1", + repository: "foo/bar", + }); + expect( + GiteaCli.parseGiteaPullRequestReference("https://git.example.com/foo/bar/pulls/42"), + ).toEqual({ index: "42", repository: "foo/bar" }); + expect( + GiteaCli.parseGiteaPullRequestReference("https://code.home.internal/team/project/pulls/999"), + ).toEqual({ index: "999", repository: "team/project" }); + }); + + it("accepts the singular /pull/ spelling and a trailing slash", () => { + expect(GiteaCli.parseGiteaPullRequestReference("https://git.example.com/o/r/pull/7")).toEqual({ + index: "7", + repository: "o/r", + }); + expect(GiteaCli.parseGiteaPullRequestReference("https://git.example.com/o/r/pulls/7/")).toEqual( + { + index: "7", + repository: "o/r", + }, + ); + }); + + it("rejects references that are neither an index nor a PR URL", () => { + expect(GiteaCli.parseGiteaPullRequestReference("")).toBeNull(); + expect(GiteaCli.parseGiteaPullRequestReference("not-a-ref")).toBeNull(); + expect( + GiteaCli.parseGiteaPullRequestReference("https://git.example.com/o/r/issues/1"), + ).toBeNull(); + expect( + GiteaCli.parseGiteaPullRequestReference("https://git.example.com/o/r/pulls/abc"), + ).toBeNull(); + }); +}); + +layer("GiteaCli.layer", (it) => { + it.effect("gets a pull request by index", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiJson(pullRequestJson()))); + + const tea = yield* GiteaCli.GiteaCli; + const { updatedAt, ...result } = yield* tea.getPullRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add widget", + url: "https://git.example.com/owner/repo/pulls/42", + baseRefName: "main", + headRefName: "t3code/abcd1234", + state: "open", + isCrossRepository: false, + headRepositoryNameWithOwner: "owner/repo", + headRepositoryOwnerLogin: "owner", + }); + expect(Option.isSome(updatedAt ?? Option.none())).toBe(true); + expect(lastArgs()).toEqual(["api", "-i", "repos/{owner}/{repo}/pulls/42"]); + }), + ); + + it.effect("targets the repository named in a PR URL rather than the repo in cwd", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiJson(pullRequestJson()))); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.getPullRequest({ + cwd: "/repo", + reference: "https://git.example.com/other/project/pulls/7", + }); + + expect(lastArgs()).toEqual(["api", "-i", "repos/other/project/pulls/7"]); + }), + ); + + it.effect("reports a merged pull request as merged, not closed", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed(apiJson(pullRequestJson({ state: "closed", merged: true }))), + ); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.getPullRequest({ cwd: "/repo", reference: "42" }); + expect(result.state).toBe("merged"); + }), + ); + + it.effect("reports a closed, unmerged pull request as closed", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed(apiJson(pullRequestJson({ state: "closed", merged: false }))), + ); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.getPullRequest({ cwd: "/repo", reference: "42" }); + expect(result.state).toBe("closed"); + }), + ); + + it.effect("marks a fork pull request as cross-repository", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson( + pullRequestJson({ + head: { + ref: "feature", + label: "contributor:feature", + repo: { full_name: "contributor/repo", owner: { login: "contributor" } }, + }, + }), + ), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.getPullRequest({ cwd: "/repo", reference: "42" }); + expect(result.isCrossRepository).toBe(true); + expect(result.headRepositoryNameWithOwner).toBe("contributor/repo"); + expect(result.headRepositoryOwnerLogin).toBe("contributor"); + expect(result.headRefName).toBe("feature"); + }), + ); + + it.effect("derives the head branch from label when ref is absent", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson(pullRequestJson({ head: { label: "contributor:feature", repo: null } })), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.getPullRequest({ cwd: "/repo", reference: "42" }); + expect(result.headRefName).toBe("feature"); + }), + ); + + it.effect("filters the list by head branch, which Gitea cannot do server side", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson([ + pullRequestJson({ number: 1, head: { ref: "other-branch", repo: null } }), + pullRequestJson({ number: 2 }), + ]), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.listPullRequests({ + cwd: "/repo", + headSelector: "t3code/abcd1234", + state: "open", + }); + + expect(result.map((entry) => entry.number)).toEqual([2]); + expect(lastArgs()[2]).toBe( + "repos/{owner}/{repo}/pulls?state=open&sort=recentupdate&limit=50&page=1", + ); + }), + ); + + it.effect("returns an empty list when the repository has no pull requests", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput("[]"))); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.listPullRequests({ + cwd: "/repo", + headSelector: "feature", + state: "open", + }); + + expect(result).toEqual([]); + expect(mockedRun).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("stops after one request when the first page is short", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiJson([pullRequestJson({ number: 9 })]))); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.listPullRequests({ + cwd: "/repo", + headSelector: "nothing-matches", + state: "open", + }); + + expect(mockedRun).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("walks to the next page when a full page holds no match", () => + Effect.gen(function* () { + const fullPage = Array.from({ length: 50 }, (_unused, index) => + pullRequestJson({ number: index + 1, head: { ref: "unrelated", repo: null } }), + ); + mockedRun.mockReturnValueOnce(Effect.succeed(apiJson(fullPage))); + mockedRun.mockReturnValueOnce(Effect.succeed(apiJson([pullRequestJson({ number: 77 })]))); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.listPullRequests({ + cwd: "/repo", + headSelector: "t3code/abcd1234", + state: "open", + }); + + expect(result.map((entry) => entry.number)).toEqual([77]); + expect(mockedRun).toHaveBeenCalledTimes(2); + expect(lastArgs()[2]).toBe( + "repos/{owner}/{repo}/pulls?state=open&sort=recentupdate&limit=50&page=2", + ); + }), + ); + + it.effect("asks Gitea for closed PRs when merged ones are wanted, then keeps only merged", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson([ + pullRequestJson({ number: 3, state: "closed", merged: false }), + pullRequestJson({ number: 4, state: "closed", merged: true }), + ]), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.listPullRequests({ + cwd: "/repo", + headSelector: "t3code/abcd1234", + state: "merged", + }); + + expect(result.map((entry) => entry.number)).toEqual([4]); + expect(lastArgs()[2]).toContain("state=closed"); + }), + ); + + it.effect("excludes merged PRs from a closed-state query", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson([ + pullRequestJson({ number: 3, state: "closed", merged: false }), + pullRequestJson({ number: 4, state: "closed", merged: true }), + ]), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.listPullRequests({ + cwd: "/repo", + headSelector: "t3code/abcd1234", + state: "closed", + }); + + expect(result.map((entry) => entry.number)).toEqual([3]); + }), + ); + + it.effect("creates a pull request with the body passed as a file, never as argv", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput("{}"))); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "t3code/abcd1234", + title: "Add widget", + bodyFile: "/tmp/body.md", + }); + + expect(lastArgs()).toEqual([ + "api", + "-i", + "-X", + "POST", + "repos/{owner}/{repo}/pulls", + "-f", + "head=t3code/abcd1234", + "-f", + "base=main", + "-f", + "title=Add widget", + "-F", + "body=@/tmp/body.md", + ]); + }), + ); + + it.effect("creates a cross-repository pull request using Gitea's owner:branch head", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput("{}"))); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "contributor:feature", + source: { owner: "contributor", refName: "feature" }, + title: "Add widget", + bodyFile: "/tmp/body.md", + }); + + expect(lastArgs()).toContain("head=contributor:feature"); + }), + ); + + it.effect("reads the repository default branch", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiJson({ default_branch: "trunk" }))); + + const tea = yield* GiteaCli.GiteaCli; + expect(yield* tea.getDefaultBranch({ cwd: "/repo" })).toBe("trunk"); + expect(lastArgs()).toEqual(["api", "-i", "repos/{owner}/{repo}"]); + }), + ); + + it.effect("returns null when the repository reports no default branch", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput("{}"))); + + const tea = yield* GiteaCli.GiteaCli; + expect(yield* tea.getDefaultBranch({ cwd: "/repo" })).toBeNull(); + }), + ); + + it.effect("maps clone URLs from clone_url and ssh_url", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson({ + full_name: "owner/repo", + clone_url: "https://git.example.com/owner/repo.git", + ssh_url: "git@git.example.com:owner/repo.git", + html_url: "https://git.example.com/owner/repo", + }), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }); + + assert.deepStrictEqual(result, { + nameWithOwner: "owner/repo", + // The browser URL would not work as a git remote, so clone_url is the one that matters. + url: "https://git.example.com/owner/repo.git", + sshUrl: "git@git.example.com:owner/repo.git", + }); + expect(lastArgs()).toEqual(["api", "-i", "repos/owner/repo"]); + }), + ); + + it.effect("creates a repository for the authenticated user when no owner is given", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson({ + full_name: "mario/widget", + clone_url: "https://git.example.com/mario/widget.git", + ssh_url: "git@git.example.com:mario/widget.git", + }), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.createRepository({ cwd: "/repo", repository: "widget", visibility: "private" }); + + expect(lastArgs()).toEqual([ + "api", + "-i", + "-X", + "POST", + "user/repos", + "-f", + "name=widget", + "-F", + "private=true", + ]); + }), + ); + + it.effect("creates under the user when the owner is the authenticated account", () => + Effect.gen(function* () { + // The publish dialog prefills the signed-in account as the owner, so `/name` is the + // ordinary input. Gitea's orgs endpoint 404s for a plain user, so this must not use it. + mockedRun.mockReturnValueOnce(Effect.succeed(apiJson({ login: "mario" }))); + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson({ + full_name: "mario/widget", + clone_url: "https://git.example.com/mario/widget.git", + ssh_url: "git@git.example.com:mario/widget.git", + }), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.createRepository({ + cwd: "/repo", + repository: "mario/widget", + visibility: "private", + }); + + expect(mockedRun.mock.calls[0]?.[0].args).toEqual(["api", "-i", "user"]); + expect(lastArgs()).toEqual([ + "api", + "-i", + "-X", + "POST", + "user/repos", + "-f", + "name=widget", + "-F", + "private=true", + ]); + expect(result.nameWithOwner).toBe("mario/widget"); + }), + ); + + it.effect("matches the authenticated account case-insensitively", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiJson({ login: "Mario" }))); + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson({ + full_name: "Mario/widget", + clone_url: "https://git.example.com/Mario/widget.git", + ssh_url: "git@git.example.com:Mario/widget.git", + }), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.createRepository({ + cwd: "/repo", + repository: "mario/widget", + visibility: "public", + }); + + expect(lastArgs()[4]).toBe("user/repos"); + }), + ); + + it.effect("falls back to username when Gitea omits login", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiJson({ username: "mario" }))); + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson({ + full_name: "mario/widget", + clone_url: "https://git.example.com/mario/widget.git", + ssh_url: "git@git.example.com:mario/widget.git", + }), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.createRepository({ + cwd: "/repo", + repository: "mario/widget", + visibility: "public", + }); + + expect(lastArgs()[4]).toBe("user/repos"); + }), + ); + + it.effect("creates a repository under an organization when an owner is given", () => + Effect.gen(function* () { + // acme is not the authenticated account, so this one really does belong on the orgs endpoint. + mockedRun.mockReturnValueOnce(Effect.succeed(apiJson({ login: "mario" }))); + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson({ + full_name: "acme/widget", + clone_url: "https://git.example.com/acme/widget.git", + ssh_url: "git@git.example.com:acme/widget.git", + }), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.createRepository({ + cwd: "/repo", + repository: "acme/widget", + visibility: "public", + }); + + expect(mockedRun.mock.calls[0]?.[0].args).toEqual(["api", "-i", "user"]); + + expect(lastArgs()).toEqual([ + "api", + "-i", + "-X", + "POST", + "orgs/acme/repos", + "-f", + "name=widget", + "-F", + "private=false", + ]); + }), + ); + + it.effect("checks out a pull request through tea, creating the local branch", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput(""))); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.checkoutPullRequest({ cwd: "/repo", reference: "42" }); + + expect(lastArgs()).toEqual(["pulls", "checkout", "42", "--branch"]); + }), + ); + + it.effect("checks out by index when handed a full PR URL", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson({ + full_name: "owner/repo", + clone_url: "https://git.example.com/owner/repo.git", + ssh_url: "ssh://git.example.com/owner/repo.git", + }), + ), + ); + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput(""))); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.checkoutPullRequest({ + cwd: "/repo", + reference: "https://git.example.com/owner/repo/pulls/42", + }); + + expect(lastArgs()).toEqual(["pulls", "checkout", "42", "--branch"]); + }), + ); +}); + +layer("GiteaCli failures", (it) => { + // These are the cases that matter most: tea exits 0 on HTTP errors, so without status parsing a + // 404 would decode as "no pull request" and T3 would open a duplicate PR. + it.effect("turns HTTP 404 into a not-found error rather than an empty result", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed(apiOutput('{"message":"The target couldn\'t be found."}', 404)), + ); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip(tea.getPullRequest({ cwd: "/repo", reference: "42" })); + + expect(error._tag).toBe("GiteaPullRequestNotFoundError"); + }), + ); + + it.effect("turns HTTP 401 and 403 into authentication errors", () => + Effect.gen(function* () { + const tea = yield* GiteaCli.GiteaCli; + + for (const status of [401, 403]) { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput('{"message":"no"}', status))); + const error = yield* Effect.flip(tea.getDefaultBranch({ cwd: "/repo" })); + expect(error._tag).toBe("GiteaCliAuthenticationError"); + } + }), + ); + + it.effect("turns HTTP 429 into a rate limit error", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput('{"message":"slow down"}', 429))); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip(tea.getDefaultBranch({ cwd: "/repo" })); + + expect(error._tag).toBe("GiteaCliRateLimitError"); + }), + ); + + it.effect("turns other HTTP failures into command errors", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput('{"message":"boom"}', 500))); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip(tea.getDefaultBranch({ cwd: "/repo" })); + + expect(error._tag).toBe("GiteaCliCommandError"); + }), + ); + + it.effect("fails a create when the API rejects it, so no duplicate PR is silently assumed", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput('{"message":"conflict"}', 409))); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip( + tea.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature", + title: "t", + bodyFile: "/tmp/b.md", + }), + ); + + expect(error._tag).toBe("GiteaCliCommandError"); + }), + ); + + it.effect("reports a missing tea executable as unavailable", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessSpawnError({ + operation: "GiteaCli.execute", + command: "tea", + cwd: "/repo", + argumentCount: 3, + cause: new Error("spawn tea ENOENT"), + }), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip(tea.getDefaultBranch({ cwd: "/repo" })); + + expect(error._tag).toBe("GiteaCliUnavailableError"); + }), + ); + + it.effect("maps a non-zero tea exit during checkout to a not-found error", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessExitError({ + operation: "GiteaCli.execute", + command: "tea", + cwd: "/repo", + argumentCount: 4, + exitCode: ChildProcessSpawner.ExitCode(1), + detail: "pull request not found", + failureKind: "not-found", + }), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip( + tea.checkoutPullRequest({ cwd: "/repo", reference: "9999" }), + ); + + expect(error._tag).toBe("GiteaPullRequestNotFoundError"); + }), + ); + + it.effect("rejects a reference that is neither an index nor a PR URL", () => + Effect.gen(function* () { + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip( + tea.getPullRequest({ cwd: "/repo", reference: "definitely-not-a-pr" }), + ); + + expect(error._tag).toBe("GiteaPullRequestNotFoundError"); + expect(mockedRun).not.toHaveBeenCalled(); + }), + ); + + it("returns null for a PR URL with invalid percent-encoding", () => { + const result = GiteaCli.parseGiteaPullRequestReference( + "https://git.example.com/o/r/pulls/%ZZ42", + ); + expect(result).toBeNull(); + }); + + it.effect("reports HTTP status on errors instead of a placeholder cause", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput('{"message":"forbidden"}', 403))); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip(tea.getDefaultBranch({ cwd: "/repo" })); + + expect(error._tag).toBe("GiteaCliAuthenticationError"); + if ("status" in error) { + expect(error.status).toBe(403); + } + }), + ); + + it.effect("omits cause on HTTP status errors when there is no upstream error", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput('{"message":"rate limited"}', 429))); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip(tea.getDefaultBranch({ cwd: "/repo" })); + + expect(error._tag).toBe("GiteaCliRateLimitError"); + if ("cause" in error) { + expect(error.cause).toBeUndefined(); + } + }), + ); + + it.effect("does not leak the operation literal in error messages", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput('{"message":"forbidden"}', 403))); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip(tea.getDefaultBranch({ cwd: "/repo" })); + + expect(error.message).not.toContain("execute"); + }), + ); + + it.effect("filters by normalized head repository owner when source identifies a fork", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson([ + pullRequestJson({ + number: 1, + head: { + ref: "feature", + label: "fork:feature", + repo: { full_name: "fork/repo", owner: { login: "fork" } }, + }, + }), + pullRequestJson({ + number: 2, + head: { + ref: "feature", + label: "other:feature", + repo: { full_name: "other/repo", owner: { login: "other" } }, + }, + }), + pullRequestJson({ + number: 3, + head: { + ref: "feature", + label: "fork:feature", + repo: { full_name: "Fork/repo", owner: { login: "Fork" } }, + }, + }), + ]), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.listPullRequests({ + cwd: "/repo", + headSelector: "feature", + source: { owner: "fork", refName: "feature" }, + state: "open", + }); + + expect(result.map((entry) => entry.number)).toEqual([1, 3]); + }), + ); + + it.effect( + "continues pagination when malformed entries reduce decoded count below page size", + () => + Effect.gen(function* () { + const fullPageWithMalformed = Array.from({ length: 50 }, (_unused, index) => + index === 0 + ? { number: "not a number" } + : pullRequestJson({ number: index + 1, head: { ref: "unrelated", repo: null } }), + ); + mockedRun.mockReturnValueOnce(Effect.succeed(apiJson(fullPageWithMalformed))); + mockedRun.mockReturnValueOnce(Effect.succeed(apiJson([pullRequestJson({ number: 77 })]))); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.listPullRequests({ + cwd: "/repo", + headSelector: "t3code/abcd1234", + state: "open", + }); + + expect(result.map((entry) => entry.number)).toEqual([77]); + expect(mockedRun).toHaveBeenCalledTimes(2); + }), + ); + + it.effect("passes --force to checkout when input.force is true", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput(""))); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.checkoutPullRequest({ cwd: "/repo", reference: "42", force: true }); + + expect(lastArgs()).toEqual(["pulls", "checkout", "42", "--branch", "--force"]); + }), + ); + + it.effect("does not pass --force to checkout when input.force is absent", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput(""))); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.checkoutPullRequest({ cwd: "/repo", reference: "42" }); + + expect(lastArgs()).toEqual(["pulls", "checkout", "42", "--branch"]); + }), + ); + + it.effect( + "rejects a full-URL reference whose repository differs from the current repository", + () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson({ + full_name: "owner/current", + clone_url: "https://git.example.com/owner/current.git", + ssh_url: "ssh://git.example.com/owner/current.git", + }), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip( + tea.checkoutPullRequest({ + cwd: "/repo", + reference: "https://git.example.com/other/repo/pulls/42", + }), + ); + + expect(error._tag).toBe("GiteaPullRequestNotFoundError"); + expect(mockedRun).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("checks out a same-repository full URL when the current repository matches", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + apiJson({ + full_name: "owner/repo", + clone_url: "https://git.example.com/owner/repo.git", + ssh_url: "ssh://git.example.com/owner/repo.git", + }), + ), + ); + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput(""))); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.checkoutPullRequest({ + cwd: "/repo", + reference: "https://git.example.com/owner/repo/pulls/42", + }); + + const checkoutArgs = mockedRun.mock.calls.at(-1)?.[0].args; + expect(checkoutArgs).toEqual(["pulls", "checkout", "42", "--branch"]); + }), + ); + + it.effect("classifies a non-ENOENT spawn failure as GiteaCliCommandError", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessSpawnError({ + operation: "GiteaCli.execute", + command: "tea", + cwd: "/repo", + argumentCount: 3, + cause: new Error("spawn EACCES permission denied"), + }), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip(tea.getDefaultBranch({ cwd: "/repo" })); + + expect(error._tag).toBe("GiteaCliCommandError"); + }), + ); + + it.effect("classifies an ENOENT spawn failure as GiteaCliUnavailableError", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessSpawnError({ + operation: "GiteaCli.execute", + command: "tea", + cwd: "/repo", + argumentCount: 3, + cause: new Error("spawn tea ENOENT"), + }), + ), + ); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip(tea.getDefaultBranch({ cwd: "/repo" })); + + expect(error._tag).toBe("GiteaCliUnavailableError"); + }), + ); + + it.effect("fails on invalid JSON instead of returning a half-decoded pull request", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput("not json"))); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip(tea.getPullRequest({ cwd: "/repo", reference: "42" })); + + expect(error._tag).toBe("GiteaPullRequestDecodeError"); + }), + ); + + it.effect("fails when required pull request fields are missing", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiJson({ number: 42 }))); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip(tea.getPullRequest({ cwd: "/repo", reference: "42" })); + + expect(error._tag).toBe("GiteaPullRequestDecodeError"); + }), + ); + + it.effect("skips malformed entries in a list rather than failing the whole refresh", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed(apiJson([{ number: "not a number" }, pullRequestJson({ number: 5 })])), + ); + + const tea = yield* GiteaCli.GiteaCli; + const result = yield* tea.listPullRequests({ + cwd: "/repo", + headSelector: "t3code/abcd1234", + state: "open", + }); + + expect(result.map((entry) => entry.number)).toEqual([5]); + }), + ); + + it.effect("fails when the list is not JSON at all", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput("error"))); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip( + tea.listPullRequests({ cwd: "/repo", headSelector: "x", state: "open" }), + ); + + expect(error._tag).toBe("GiteaPullRequestListDecodeError"); + }), + ); + + it.effect("maps a createPullRequest HTTP 404 to GiteaCliCommandError, not not-found", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed(apiOutput('{"message":"repository not found"}', 404)), + ); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip( + tea.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature", + title: "t", + bodyFile: "/tmp/b.md", + }), + ); + + expect(error._tag).toBe("GiteaCliCommandError"); + }), + ); + + it.effect("still maps a getPullRequest HTTP 404 to GiteaPullRequestNotFoundError", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput('{"message":"not found"}', 404))); + + const tea = yield* GiteaCli.GiteaCli; + const error = yield* Effect.flip(tea.getPullRequest({ cwd: "/repo", reference: "42" })); + + expect(error._tag).toBe("GiteaPullRequestNotFoundError"); + }), + ); + + it.effect("sends sort=recentupdate in listPullRequests query", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(apiOutput("[]"))); + + const tea = yield* GiteaCli.GiteaCli; + yield* tea.listPullRequests({ + cwd: "/repo", + headSelector: "feature", + state: "open", + }); + + expect(lastArgs()[2]).toContain("sort=recentupdate"); + }), + ); +}); diff --git a/apps/server/src/sourceControl/GiteaCli.ts b/apps/server/src/sourceControl/GiteaCli.ts new file mode 100644 index 000000000000..c5d9fcd32207 --- /dev/null +++ b/apps/server/src/sourceControl/GiteaCli.ts @@ -0,0 +1,892 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Match from "effect/Match"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type * as DateTime from "effect/DateTime"; + +import { + TrimmedNonEmptyString, + type SourceControlRepositoryVisibility, + type VcsError, +} from "@t3tools/contracts"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { decodeGiteaPullRequestJson, decodeGiteaPullRequestListJson } from "./giteaPullRequests.ts"; +import type * as SourceControlProvider from "./SourceControlProvider.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Gitea's list endpoint cannot filter by head branch, so T3 filters client side. Pages are capped + * so a repository with a long PR history cannot turn one status refresh into unbounded requests. + */ +const LIST_PAGE_SIZE = 50; +const MAX_LIST_PAGES = 5; + +const giteaCliExecutionErrorContext = { + command: Schema.Literal("tea"), + cwd: Schema.String, + status: Schema.optional(Schema.Int), + cause: Schema.optional(Schema.Defect()), +}; + +const giteaCliDecodeErrorContext = { + command: Schema.Literal("tea"), + cwd: Schema.String, + cause: Schema.Defect(), +}; + +const giteaPullRequestDecodeErrorContext = { + command: Schema.Literal("tea"), + cwd: Schema.String, + cause: Schema.Defect(), + reference: Schema.String, +}; + +export class GiteaCliUnavailableError extends Schema.TaggedError()( + "GiteaCliUnavailableError", + giteaCliExecutionErrorContext, +) { + get detail(): string { + return "Gitea CLI (`tea`) is required but not available on PATH."; + } + + override get message(): string { + return `Gitea CLI failed: ${this.detail}`; + } +} + +export class GiteaCliAuthenticationError extends Schema.TaggedError()( + "GiteaCliAuthenticationError", + giteaCliExecutionErrorContext, +) { + get detail(): string { + return "Gitea CLI is not authenticated for this instance. Run `tea login add` and retry."; + } + + override get message(): string { + return `Gitea CLI failed: ${this.detail}`; + } +} + +export class GiteaCliRateLimitError extends Schema.TaggedError()( + "GiteaCliRateLimitError", + giteaCliExecutionErrorContext, +) { + get detail(): string { + return "Gitea API rate limit exceeded."; + } + + override get message(): string { + return `Gitea CLI failed: ${this.detail}`; + } +} + +export class GiteaPullRequestNotFoundError extends Schema.TaggedError()( + "GiteaPullRequestNotFoundError", + { + ...giteaCliExecutionErrorContext, + reference: Schema.String, + }, +) { + get detail(): string { + return `Pull request ${this.reference} was not found. Check the PR number or URL and try again.`; + } + + override get message(): string { + return `Gitea CLI failed: ${this.detail}`; + } + + static fromVcsError( + context: { + readonly command: "tea"; + readonly cwd: string; + readonly reference: string; + }, + error: VcsError, + ): GiteaCliError { + if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") { + return new GiteaPullRequestNotFoundError({ ...context, cause: error }); + } + + return GiteaCliCommandError.fromVcsError({ command: context.command, cwd: context.cwd }, error); + } +} + +export class GiteaCliCommandError extends Schema.TaggedError()( + "GiteaCliCommandError", + giteaCliExecutionErrorContext, +) { + get detail(): string { + return "Gitea CLI command failed."; + } + + override get message(): string { + return `Gitea CLI failed: ${this.detail}`; + } + + static fromVcsError( + context: { + readonly command: "tea"; + readonly cwd: string; + }, + error: VcsError, + ): GiteaCliError { + return Match.valueTags(error, { + VcsProcessSpawnError: (cause) => { + if (isSpawnNotFound(cause)) { + return new GiteaCliUnavailableError({ ...context, cause }); + } + return new GiteaCliCommandError({ ...context, cause }); + }, + VcsProcessExitError: (cause) => { + switch (cause.failureKind) { + case "authentication": + return new GiteaCliAuthenticationError({ ...context, cause }); + case "rate-limited": + return new GiteaCliRateLimitError({ ...context, cause }); + case "not-found": + case "command-failed": + case undefined: + return new GiteaCliCommandError({ ...context, cause }); + } + }, + VcsProcessTimeoutError: (cause) => new GiteaCliCommandError({ ...context, cause }), + VcsProcessStdinWriteError: (cause) => new GiteaCliCommandError({ ...context, cause }), + VcsProcessOutputReadError: (cause) => new GiteaCliCommandError({ ...context, cause }), + VcsProcessOutputLimitError: (cause) => new GiteaCliCommandError({ ...context, cause }), + VcsProcessMissingExitCodeError: (cause) => new GiteaCliCommandError({ ...context, cause }), + VcsRepositoryDetectionError: (cause) => new GiteaCliCommandError({ ...context, cause }), + VcsUnsupportedOperationError: (cause) => new GiteaCliCommandError({ ...context, cause }), + }); + } +} + +export class GiteaPullRequestListDecodeError extends Schema.TaggedError()( + "GiteaPullRequestListDecodeError", + giteaCliDecodeErrorContext, +) { + get detail(): string { + return "Gitea CLI returned invalid pull request list JSON."; + } + + override get message(): string { + return `Gitea CLI failed: ${this.detail}`; + } +} + +export class GiteaPullRequestDecodeError extends Schema.TaggedError()( + "GiteaPullRequestDecodeError", + giteaPullRequestDecodeErrorContext, +) { + get detail(): string { + return "Gitea CLI returned invalid pull request JSON."; + } + + override get message(): string { + return `Gitea CLI failed: ${this.detail}`; + } +} + +export class GiteaRepositoryDecodeError extends Schema.TaggedError()( + "GiteaRepositoryDecodeError", + { + ...giteaCliDecodeErrorContext, + operation: Schema.Literals([ + "getRepositoryCloneUrls", + "createRepository", + "getDefaultBranch", + "checkoutPullRequest", + ]), + repository: Schema.optional(Schema.String), + }, +) { + get detail(): string { + return "Gitea CLI returned invalid repository JSON."; + } + + override get message(): string { + return `Gitea CLI failed: ${this.detail}`; + } +} + +export const GiteaCliError = Schema.Union([ + GiteaCliUnavailableError, + GiteaCliAuthenticationError, + GiteaCliRateLimitError, + GiteaPullRequestNotFoundError, + GiteaCliCommandError, + GiteaPullRequestListDecodeError, + GiteaPullRequestDecodeError, + GiteaRepositoryDecodeError, +]); +export type GiteaCliError = typeof GiteaCliError.Type; +export const isGiteaCliError = Schema.is(GiteaCliError); + +export interface GiteaPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state?: "open" | "closed" | "merged"; + readonly updatedAt?: Option.Option; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export interface GiteaRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export class GiteaCli extends Context.Service< + GiteaCli, + { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + /** Piped to the child's stdin, for payloads that must never appear in argv. */ + readonly stdin?: string; + readonly maxOutputBytes?: number; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, GiteaCliError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } +>()("t3/sourceControl/GiteaCli") {} + +const RawGiteaRepositorySchema = Schema.Struct({ + full_name: TrimmedNonEmptyString, + clone_url: TrimmedNonEmptyString, + ssh_url: TrimmedNonEmptyString, +}); + +/** `GET /user`. Gitea reports the account name as `login`; older builds also send `username`. */ +const RawGiteaUserSchema = Schema.Struct({ + login: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + username: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), +}); + +const RawGiteaDefaultBranchSchema = Schema.Struct({ + default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), +}); + +const decodeGiteaRepository = Schema.decodeEffect(Schema.fromJsonString(RawGiteaRepositorySchema)); +const decodeGiteaDefaultBranch = Schema.decodeEffect( + Schema.fromJsonString(RawGiteaDefaultBranchSchema), +); +const decodeGiteaUser = Schema.decodeEffect(Schema.fromJsonString(RawGiteaUserSchema)); + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): GiteaRepositoryCloneUrls { + return { + nameWithOwner: raw.full_name, + // clone_url, not html_url: this value is handed to git as the remote for HTTPS clones. + url: raw.clone_url, + sshUrl: raw.ssh_url, + }; +} + +/** + * `tea api` exits 0 even for HTTP 4xx and prints the status line to stderr under `-i`, so failures + * have to be read off the response rather than the exit code. + */ +const HTTP_STATUS_LINE_PATTERN = /^HTTP\/[\d.]+\s+(\d{3})\b/gmu; + +export function parseHttpStatusCode(stderr: string): number | null { + let status: number | null = null; + // Redirects emit several status lines; the last one describes the response actually returned. + for (const match of stderr.matchAll(HTTP_STATUS_LINE_PATTERN)) { + const parsed = Number(match[1]); + if (Number.isFinite(parsed)) status = parsed; + } + return status; +} + +/** Detects a spawn failure caused by a missing executable (ENOENT). */ +function isSpawnNotFound(cause: unknown): boolean { + if (!isNonErrorDefect(cause)) { + return false; + } + + return hasEnoentCode(cause) || ENOENT_MESSAGE.test(cause.message) || isNestedEnoent(cause); +} + +const ENOENT_MESSAGE = /ENOENT|no such file or directory/iu; + +function isNonErrorDefect(cause: unknown): cause is Error { + return cause instanceof Error; +} + +function hasEnoentCode(error: Error): boolean { + return (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +function isNestedEnoent(error: Error): boolean { + const inner = (error as { readonly cause?: unknown }).cause; + if (!isNonErrorDefect(inner)) { + return false; + } + return hasEnoentCode(inner) || ENOENT_MESSAGE.test(inner.message) || isNestedEnoent(inner); +} + +function httpStatusFailure( + status: number, + context: { readonly cwd: string; readonly reference?: string }, +): GiteaCliError { + const base = { command: "tea", cwd: context.cwd, status } as const; + + if (status === 401 || status === 403) { + return new GiteaCliAuthenticationError(base); + } + if (status === 429) { + return new GiteaCliRateLimitError(base); + } + if (status === 404 && context.reference !== undefined) { + return new GiteaPullRequestNotFoundError({ ...base, reference: context.reference }); + } + return new GiteaCliCommandError(base); +} + +function repositoryEndpoint(repository: string): string { + const segments = repository + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) + .map((segment) => encodeURIComponent(segment)); + return `repos/${segments.join("/")}`; +} + +export interface GiteaPullRequestReference { + /** The PR index within its repository. */ + readonly index: string; + /** Present when the reference was a full URL pointing at a specific repository. */ + readonly repository?: string; +} + +/** + * Accepts a bare index (`42`, `#42`) or a Gitea PR URL on any host, since self-hosted instances + * live on arbitrary hostnames: https://HOST/OWNER/REPO/pulls/42. + */ +export function parseGiteaPullRequestReference( + reference: string, +): GiteaPullRequestReference | null { + const trimmed = reference.trim(); + if (trimmed.length === 0) return null; + + const bare = /^#?(\d+)$/u.exec(trimmed); + if (bare?.[1]) return { index: bare[1] }; + + let path: string; + try { + path = new URL(trimmed).pathname; + } catch { + return null; + } + + const url = /^\/([^/]+)\/([^/]+)\/pulls?\/(\d+)\/?$/u.exec(path); + const owner = url?.[1]; + const repo = url?.[2]; + const index = url?.[3]; + if (!owner || !repo || !index) return null; + + try { + return { index, repository: `${decodeURIComponent(owner)}/${decodeURIComponent(repo)}` }; + } catch { + return null; + } +} + +/** The endpoint prefix for a reference: an explicit repo from a URL, or the repo in cwd. */ +function referenceRepositoryEndpoint(reference: GiteaPullRequestReference): string { + return reference.repository === undefined + ? "repos/{owner}/{repo}" + : repositoryEndpoint(reference.repository); +} + +/** Gitea exposes only open/closed/all; merged is a closed PR carrying `merged: true`. */ +function listStateParameter(state: "open" | "closed" | "merged" | "all"): string { + switch (state) { + case "open": + return "open"; + case "closed": + case "merged": + return "closed"; + case "all": + return "all"; + } +} + +function matchesRequestedState( + summary: GiteaPullRequestSummary, + state: "open" | "closed" | "merged" | "all", +): boolean { + switch (state) { + case "all": + return true; + case "open": + return summary.state === "open"; + case "closed": + // T3 treats merged as its own state, so a merged PR is not a "closed" result. + return summary.state === "closed"; + case "merged": + return summary.state === "merged"; + } +} + +function normalizeHeadSelector(headSelector: string): string { + const trimmed = headSelector.trim(); + const ownerBranch = /^[^:]+:(.+)$/u.exec(trimmed); + return ownerBranch?.[1]?.trim() || trimmed; +} + +function sourceRefName(input: { + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; +}): string { + return input.source?.refName ?? normalizeHeadSelector(input.headSelector); +} + +/** Gitea expresses a fork head as `owner:branch`, matching T3's own head selector syntax. */ +function headParameter(input: { + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; +}): string { + const refName = sourceRefName(input); + const owner = input.source?.owner; + return owner ? `${owner}:${refName}` : refName; +} + +function toSummaryWithOptionalUpdatedAt( + record: GiteaPullRequestSummary & { readonly updatedAt: Option.Option }, +): GiteaPullRequestSummary { + const { updatedAt, ...summary } = record; + return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; +} + +function parseRepositoryPath(repository: string): { + readonly owner: string | null; + readonly name: string; +} { + const parts: Array = []; + for (const part of repository.split("/")) { + const trimmed = part.trim(); + if (trimmed.length > 0) parts.push(trimmed); + } + const name = parts.at(-1) ?? repository.trim(); + const owner = parts.length > 1 ? parts.slice(0, -1).join("/") : null; + return { owner, name }; +} + +export const make = Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + + const run = ( + input: Parameters[0], + mapError: (error: VcsError) => GiteaCliError, + ) => + process + .run({ + operation: "GiteaCli.execute", + command: "tea", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + }) + .pipe(Effect.mapError(mapError)); + + const execute: GiteaCli["Service"]["execute"] = (input) => + run(input, (error) => + GiteaCliCommandError.fromVcsError({ command: "tea", cwd: input.cwd }, error), + ); + + /** + * Runs a `tea api` call and converts an HTTP error status into a typed failure. Every API call + * goes through here so a 401 or 404 can never be mistaken for an empty result. + */ + const api = (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly reference?: string; + readonly maxOutputBytes?: number; + }) => + execute({ + cwd: input.cwd, + args: ["api", "-i", ...input.args], + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + }).pipe( + Effect.flatMap((result) => { + const status = parseHttpStatusCode(result.stderr); + if (status !== null && status >= 400) { + return Effect.fail( + httpStatusFailure(status, { + cwd: input.cwd, + ...(input.reference === undefined ? {} : { reference: input.reference }), + }), + ); + } + return Effect.succeed(result.stdout.trim()); + }), + ); + + const listPage = (input: { + readonly cwd: string; + readonly state: "open" | "closed" | "merged" | "all"; + readonly page: number; + }) => + api({ + cwd: input.cwd, + args: [ + `repos/{owner}/{repo}/pulls?state=${listStateParameter(input.state)}&sort=recentupdate&limit=${LIST_PAGE_SIZE}&page=${input.page}`, + ], + }).pipe( + Effect.flatMap((raw) => { + if (raw.length === 0) { + return Effect.succeed({ + entries: [] as ReadonlyArray, + rawCount: 0, + }); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + return Effect.fail( + new GiteaPullRequestListDecodeError({ + command: "tea", + cwd: input.cwd, + cause: error, + }), + ); + } + const rawCount = Array.isArray(parsed) ? parsed.length : 0; + return Effect.sync(() => decodeGiteaPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => + Result.isSuccess(decoded) + ? Effect.succeed({ + entries: decoded.success.map(toSummaryWithOptionalUpdatedAt), + rawCount, + }) + : Effect.fail( + new GiteaPullRequestListDecodeError({ + command: "tea", + cwd: input.cwd, + cause: decoded.failure, + }), + ), + ), + ); + }), + ); + + return GiteaCli.of({ + execute, + /** + * Gitea's list endpoint has no head-branch filter, so pages are walked and matched locally. + * The common case costs one request: page one is usually short, and the walk stops as soon as + * enough matches are found or a partial page proves the list is exhausted. + */ + listPullRequests: (input) => + Effect.gen(function* () { + const wanted = input.limit ?? 20; + const headRefName = sourceRefName(input); + const sourceOwner = input.source?.owner?.toLowerCase() ?? null; + const matches: Array = []; + + for (let page = 1; page <= MAX_LIST_PAGES; page += 1) { + const { entries, rawCount } = yield* listPage({ + cwd: input.cwd, + state: input.state, + page, + }); + + for (const entry of entries) { + if ( + entry.headRefName === headRefName && + matchesRequestedState(entry, input.state) && + (sourceOwner === null || + entry.headRepositoryOwnerLogin?.toLowerCase() === sourceOwner) + ) { + matches.push(entry); + } + } + + if (matches.length >= wanted || rawCount < LIST_PAGE_SIZE) break; + } + + return matches.slice(0, wanted); + }), + getPullRequest: (input) => + Effect.gen(function* () { + const reference = parseGiteaPullRequestReference(input.reference); + if (reference === null) { + return yield* Effect.fail( + new GiteaPullRequestNotFoundError({ + command: "tea", + cwd: input.cwd, + reference: input.reference, + }), + ); + } + + const raw = yield* api({ + cwd: input.cwd, + reference: input.reference, + args: [`${referenceRepositoryEndpoint(reference)}/pulls/${reference.index}`], + }); + + const decoded = decodeGiteaPullRequestJson(raw); + if (!Result.isSuccess(decoded)) { + return yield* Effect.fail( + new GiteaPullRequestDecodeError({ + command: "tea", + cwd: input.cwd, + reference: input.reference, + cause: decoded.failure, + }), + ); + } + return toSummaryWithOptionalUpdatedAt(decoded.success); + }), + getRepositoryCloneUrls: (input) => + api({ cwd: input.cwd, args: [repositoryEndpoint(input.repository)] }).pipe( + Effect.flatMap((raw) => + decodeGiteaRepository(raw).pipe( + Effect.mapError( + (cause) => + new GiteaRepositoryDecodeError({ + operation: "getRepositoryCloneUrls", + command: "tea", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createRepository: (input) => { + const { owner, name } = parseRepositoryPath(input.repository); + + /** + * Gitea splits repository creation in two: `POST /user/repos` creates under the authenticated + * user, while `POST /orgs/{org}/repos` requires a real organization and 404s for a plain + * user. T3's publish dialog prefills the signed-in account as the owner, so the common input + * is `/name` — sending that to the orgs endpoint would fail every default publish. + * Resolve who we are and pick accordingly. + */ + const endpoint: Effect.Effect = + owner === null + ? Effect.succeed("user/repos") + : api({ cwd: input.cwd, args: ["user"] }).pipe( + Effect.flatMap((raw) => + decodeGiteaUser(raw).pipe( + Effect.mapError( + (cause) => + new GiteaRepositoryDecodeError({ + operation: "createRepository", + command: "tea", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), + ), + ), + Effect.map((user) => { + const login = user.login ?? user.username ?? null; + return login !== null && login.toLowerCase() === owner.toLowerCase() + ? "user/repos" + : `orgs/${encodeURIComponent(owner)}/repos`; + }), + ); + + return endpoint.pipe( + Effect.flatMap((resolvedEndpoint) => + api({ + cwd: input.cwd, + args: [ + "-X", + "POST", + resolvedEndpoint, + "-f", + `name=${name}`, + "-F", + `private=${input.visibility === "private"}`, + ], + }), + ), + Effect.flatMap((raw) => + decodeGiteaRepository(raw).pipe( + Effect.mapError( + (cause) => + new GiteaRepositoryDecodeError({ + operation: "createRepository", + command: "tea", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ); + }, + createPullRequest: (input) => + api({ + cwd: input.cwd, + args: [ + "-X", + "POST", + "repos/{owner}/{repo}/pulls", + "-f", + `head=${headParameter(input)}`, + "-f", + `base=${input.target?.refName ?? input.baseBranch}`, + "-f", + `title=${input.title}`, + // `-F key=@file` reads the file and always encodes it as a JSON string, so a body that + // happens to start with `{` stays a body and never becomes argv. + "-F", + `body=@${input.bodyFile}`, + ], + }).pipe(Effect.asVoid), + getDefaultBranch: (input) => + api({ cwd: input.cwd, args: ["repos/{owner}/{repo}"] }).pipe( + Effect.flatMap((raw) => + decodeGiteaDefaultBranch(raw).pipe( + Effect.mapError( + (cause) => + new GiteaRepositoryDecodeError({ + operation: "getDefaultBranch", + command: "tea", + cwd: input.cwd, + cause, + }), + ), + ), + ), + Effect.map((value) => value.default_branch ?? null), + ), + // `tea pulls checkout` is a real subcommand that exits non-zero on failure, so it keeps the + // ordinary exit-code error mapping instead of the `tea api` status handling. + checkoutPullRequest: (input) => + Effect.gen(function* () { + const reference = parseGiteaPullRequestReference(input.reference); + if (reference === null) { + return yield* Effect.fail( + new GiteaPullRequestNotFoundError({ + command: "tea", + cwd: input.cwd, + reference: input.reference, + }), + ); + } + + if (reference.repository !== undefined) { + const raw = yield* api({ + cwd: input.cwd, + args: ["repos/{owner}/{repo}"], + }); + + const decoded = yield* decodeGiteaRepository(raw).pipe( + Effect.mapError( + (cause) => + new GiteaRepositoryDecodeError({ + operation: "checkoutPullRequest", + command: "tea", + cwd: input.cwd, + cause, + }), + ), + ); + if (decoded.full_name.toLowerCase() !== reference.repository.toLowerCase()) { + return yield* Effect.fail( + new GiteaPullRequestNotFoundError({ + command: "tea", + cwd: input.cwd, + reference: input.reference, + }), + ); + } + } + + return yield* run( + { + cwd: input.cwd, + args: [ + "pulls", + "checkout", + reference.index, + "--branch", + ...(input.force ? ["--force"] : []), + ], + }, + (error) => + GiteaPullRequestNotFoundError.fromVcsError( + { + command: "tea", + cwd: input.cwd, + reference: input.reference, + }, + error, + ), + ); + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(GiteaCli, make); diff --git a/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts b/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts new file mode 100644 index 000000000000..edcc2f40d52a --- /dev/null +++ b/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts @@ -0,0 +1,375 @@ +import { assert, it } from "@effect/vitest"; +import type { SourceControlProviderError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as GiteaCli from "./GiteaCli.ts"; +import * as GiteaSourceControlProvider from "./GiteaSourceControlProvider.ts"; + +function makeProvider(gitea: Partial) { + return GiteaSourceControlProvider.make.pipe(Effect.provide(Layer.mock(GiteaCli.GiteaCli)(gitea))); +} + +/** Serializes tea's login list for discovery inputs. */ +function loginsJson(logins: ReadonlyArray>): string { + return JSON.stringify(logins); +} + +const SELF_HOSTED_LOGIN = { + name: "self-hosted", + url: "https://git.example.com", + ssh_host: "git.example.com", + user: "mario", + default: "true", +}; + +it.effect("maps Gitea PR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getPullRequest: () => + Effect.succeed({ + number: 42, + title: "Add Gitea provider", + url: "https://git.example.com/owner/repo/pulls/42", + baseRefName: "main", + headRefName: "t3code/abcd1234", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/repo", + headRepositoryOwnerLogin: "fork", + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ cwd: "/repo", reference: "42" }); + + assert.deepStrictEqual(changeRequest, { + provider: "gitea", + number: 42, + title: "Add Gitea provider", + url: "https://git.example.com/owner/repo/pulls/42", + baseRefName: "main", + headRefName: "t3code/abcd1234", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/repo", + headRepositoryOwnerLogin: "fork", + }); + }), +); + +it.effect("adds repository context while retaining Gitea CLI causes", () => + Effect.gen(function* () { + const cause = new GiteaCli.GiteaCliCommandError({ + command: "tea", + cwd: "/repo", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ createRepository: () => Effect.fail(cause) }); + + const error = yield* provider + .createRepository({ cwd: "/repo", repository: "owner/repo", visibility: "private" }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + repository: error.repository, + detail: error.detail, + }, + { + provider: "gitea", + operation: "createRepository", + command: "tea", + cwd: "/repo", + repository: "owner/repo", + detail: "Gitea CLI command failed.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + +it.effect("reports the right operation for each failing Gitea call", () => + Effect.gen(function* () { + const cause = new GiteaCli.GiteaCliAuthenticationError({ + command: "tea", + cwd: "/repo", + cause: new Error("http 401"), + }); + const provider = yield* makeProvider({ + listPullRequests: () => Effect.fail(cause), + getPullRequest: () => Effect.fail(cause), + createPullRequest: () => Effect.fail(cause), + getDefaultBranch: () => Effect.fail(cause), + checkoutPullRequest: () => Effect.fail(cause), + getRepositoryCloneUrls: () => Effect.fail(cause), + }); + + const operations: ReadonlyArray< + readonly [string, Effect.Effect] + > = [ + [ + "listChangeRequests", + provider + .listChangeRequests({ cwd: "/repo", headSelector: "x", state: "open" }) + .pipe(Effect.asVoid), + ], + [ + "getChangeRequest", + provider.getChangeRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.asVoid), + ], + [ + "createChangeRequest", + provider + .createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "x", + title: "t", + bodyFile: "/tmp/b.md", + }) + .pipe(Effect.asVoid), + ], + ["getDefaultBranch", provider.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.asVoid)], + ["checkoutChangeRequest", provider.checkoutChangeRequest({ cwd: "/repo", reference: "42" })], + [ + "getRepositoryCloneUrls", + provider + .getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }) + .pipe(Effect.asVoid), + ], + ]; + + for (const [operation, effect] of operations) { + const error = yield* Effect.flip(effect); + assert.strictEqual(error.provider, "gitea"); + assert.strictEqual(error.operation, operation); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + } + }), +); + +it.effect("passes provider-neutral list input straight through to tea", () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + listPullRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "t3code/abcd1234", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "t3code/abcd1234", + state: "all", + limit: 10, + }); + }), +); + +it.effect("splits an owner:branch head selector into a cross-repository source", () => + Effect.gen(function* () { + let createInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + createPullRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "contributor:feature", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "contributor:feature", + source: { owner: "contributor", refName: "feature" }, + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + }), +); + +it("reports the default tea login as the authenticated account", () => { + const auth = GiteaSourceControlProvider.discovery.parseAuth({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: loginsJson([SELF_HOSTED_LOGIN]), + stderr: "", + }); + + assert.deepStrictEqual( + { status: auth.status, account: auth.account, host: auth.host }, + { + status: "authenticated", + account: Option.some("mario"), + host: Option.some("git.example.com"), + }, + ); +}); + +it("mentions the other instances when several Gitea logins are configured", () => { + const auth = GiteaSourceControlProvider.discovery.parseAuth({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: loginsJson([ + { ...SELF_HOSTED_LOGIN, default: "false" }, + { + name: "work", + url: "https://code.work.internal:3000", + ssh_host: "code.work.internal", + user: "worker", + default: "true", + }, + ]), + stderr: "", + }); + + assert.strictEqual(auth.status, "authenticated"); + assert.deepStrictEqual(auth.account, Option.some("worker")); + assert.equal(Option.getOrElse(auth.detail, () => "").includes("2 Gitea instances"), true); +}); + +it("reports unauthenticated when tea has no logins", () => { + const auth = GiteaSourceControlProvider.discovery.parseAuth({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "[]", + stderr: "", + }); + + assert.strictEqual(auth.status, "unauthenticated"); + assert.deepStrictEqual(auth.account, Option.none()); +}); + +it("reports unauthenticated when tea exits non-zero", () => { + const auth = GiteaSourceControlProvider.discovery.parseAuth({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "Error: no logins configured", + }); + + assert.strictEqual(auth.status, "unauthenticated"); +}); + +it("survives malformed tea output instead of throwing", () => { + const auth = GiteaSourceControlProvider.discovery.parseAuth({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "not json at all", + stderr: "", + }); + + assert.strictEqual(auth.status, "unauthenticated"); +}); + +it("refines an unknown remote whose host tea is authenticated against", () => { + const provider = GiteaSourceControlProvider.discovery.refineUnknownRemote?.({ + cwd: "/repo", + context: { + provider: { + kind: "unknown", + name: "git.example.com", + baseUrl: "https://git.example.com", + }, + remoteName: "origin", + remoteUrl: "git@git.example.com:owner/repo.git", + }, + auth: { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: loginsJson([SELF_HOSTED_LOGIN]), + stderr: "", + }, + }); + + assert.deepStrictEqual(provider, { + kind: "gitea", + name: "Gitea Self-Hosted", + baseUrl: "https://git.example.com", + }); +}); + +it("refines a remote host carrying a port that the login does not", () => { + const provider = GiteaSourceControlProvider.discovery.refineUnknownRemote?.({ + cwd: "/repo", + context: { + provider: { + kind: "unknown", + name: "git.example.com:3000", + baseUrl: "https://git.example.com:3000", + }, + remoteName: "origin", + remoteUrl: "https://git.example.com:3000/owner/repo.git", + }, + auth: { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: loginsJson([SELF_HOSTED_LOGIN]), + stderr: "", + }, + }); + + assert.strictEqual(provider?.kind, "gitea"); + assert.strictEqual(provider?.baseUrl, "https://git.example.com:3000"); +}); + +it("does not refine a host tea knows nothing about", () => { + const provider = GiteaSourceControlProvider.discovery.refineUnknownRemote?.({ + cwd: "/repo", + context: { + provider: { + kind: "unknown", + name: "git.unrelated.example", + baseUrl: "https://git.unrelated.example", + }, + remoteName: "origin", + remoteUrl: "git@git.unrelated.example:owner/repo.git", + }, + auth: { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: loginsJson([SELF_HOSTED_LOGIN]), + stderr: "", + }, + }); + + assert.strictEqual(provider, null); +}); + +it("does not refine a login with null user", () => { + const provider = GiteaSourceControlProvider.discovery.refineUnknownRemote?.({ + cwd: "/repo", + context: { + provider: { + kind: "unknown", + name: "git.example.com", + baseUrl: "https://git.example.com", + }, + remoteName: "origin", + remoteUrl: "git@git.example.com:owner/repo.git", + }, + auth: { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: loginsJson([{ ...SELF_HOSTED_LOGIN, user: "" }]), + stderr: "", + }, + }); + + assert.strictEqual(provider, null); +}); diff --git a/apps/server/src/sourceControl/GiteaSourceControlProvider.ts b/apps/server/src/sourceControl/GiteaSourceControlProvider.ts new file mode 100644 index 000000000000..0edd62558da0 --- /dev/null +++ b/apps/server/src/sourceControl/GiteaSourceControlProvider.ts @@ -0,0 +1,265 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; + +import * as GiteaCli from "./GiteaCli.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { + firstSafeAuthLine, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, + type SourceControlUnknownRemoteRefinementInput, +} from "./SourceControlProviderDiscovery.ts"; +import { findGiteaLoginForHost, findPrimaryGiteaLogin, parseGiteaLogins } from "./giteaLogins.ts"; + +function toChangeRequest(summary: GiteaCli.GiteaPullRequestSummary): ChangeRequest { + return { + provider: "gitea", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state ?? "open", + updatedAt: summary.updatedAt ?? Option.none(), + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + +const LOGIN_HINT = "Run `tea login add` to authenticate against a Gitea instance."; + +/** + * Reads `tea logins list --output json`. Only stdout is parsed: stderr may carry warnings that + * would invalidate the JSON, and it is used for diagnostics only. + */ +function parseGiteaAuth(input: SourceControlAuthProbeInput) { + const logins = parseGiteaLogins(input.stdout); + const primary = findPrimaryGiteaLogin(logins); + const host = primary?.hostname; + + if (primary?.user) { + // The discovery contract holds a single account, so extra instances are named in the detail + // rather than dropped silently — `tea` still refines remotes against all of them. + const others = logins.length - 1; + return providerAuth({ + status: "authenticated", + account: primary.user, + host, + ...(others > 0 + ? { detail: `${logins.length} Gitea instances configured; showing the default.` } + : {}), + }); + } + + if (logins.length > 0) { + return providerAuth({ + status: "unknown", + host, + detail: `Gitea logins are configured but report no user. ${LOGIN_HINT}`, + }); + } + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + detail: firstSafeAuthLine(input.stderr) ?? LOGIN_HINT, + }); + } + + return providerAuth({ status: "unauthenticated", detail: LOGIN_HINT }); +} + +/** + * Gitea is nearly always self-hosted on a hostname that carries no hint of it, so the static + * detector leaves those remotes `unknown`. This promotes one to `gitea` only when `tea` is already + * authenticated against that exact host, which keeps unrelated Git hosts untouched and avoids any + * network probing of arbitrary remotes. + */ +function refineUnknownGiteaRemote(input: SourceControlUnknownRemoteRefinementInput) { + const login = findGiteaLoginForHost( + parseGiteaLogins(input.auth.stdout), + input.context.provider.name, + ); + if (!login || login.user === null) { + return null; + } + + return { + kind: "gitea", + name: "Gitea Self-Hosted", + baseUrl: input.context.provider.baseUrl, + } as const; +} + +export const discovery = { + type: "cli", + kind: "gitea", + label: "Gitea", + executable: "tea", + versionArgs: ["--version"], + authArgs: ["logins", "list", "--output", "json"], + parseAuth: parseGiteaAuth, + refineUnknownRemote: refineUnknownGiteaRemote, + installHint: + "Install the Gitea command-line tool (`tea`) from https://gitea.com/gitea/tea or your package manager (for example `brew install tea`), then run `tea login add`.", +} satisfies SourceControlCliDiscoverySpec; + +export const make = Effect.gen(function* () { + const gitea = yield* GiteaCli.GiteaCli; + + return SourceControlProvider.SourceControlProvider.of({ + kind: "gitea", + listChangeRequests: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return gitea + .listPullRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(source ? { source } : {}), + state: input.state, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitea", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); + }, + getChangeRequest: (input) => + gitea.getPullRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitea", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), + createChangeRequest: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return gitea + .createPullRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + ...(source ? { source } : {}), + ...(input.target ? { target: input.target } : {}), + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitea", + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); + }, + getRepositoryCloneUrls: (input) => + gitea.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitea", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), + createRepository: (input) => + gitea.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitea", + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), + getDefaultBranch: (input) => + gitea.getDefaultBranch(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitea", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), + checkoutChangeRequest: (input) => + gitea.checkoutPullRequest(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitea", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), + }); +}); + +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 30aa22995b95..8788cf25a42c 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -17,6 +17,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; +import * as GiteaCli from "./GiteaCli.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; @@ -38,6 +39,7 @@ const sourceControlProviderRegistryTestLayer = (input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GiteaCli.GiteaCli)({}), Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), @@ -479,6 +481,12 @@ it.effect("reports implemented tools separately from locally available executabl auth: "unauthenticated", account: Option.none(), }, + { + kind: "gitea", + status: "missing", + auth: "unknown", + account: Option.none(), + }, { kind: "forgejo", status: "missing", @@ -526,6 +534,21 @@ Logged in to gitlab.com as gitlab-user `), ); } + if (input.command === "tea" && input.args.join(" ") === "logins list --output json") { + return Effect.succeed( + processOutput( + JSON.stringify([ + { + name: "gitea", + url: "https://gitea.example.com", + ssh_host: "gitea.example.com", + user: "gitea-user", + default: "true", + }, + ]), + ), + ); + } if (input.command === "tea" && input.args[0] === "login") { return Effect.succeed( processOutput( @@ -617,6 +640,12 @@ Logged in to gitlab.com as gitlab-user account: Option.some("bitbucket-user"), detail: Option.none(), }, + { + kind: "gitea", + auth: "authenticated", + account: Option.some("gitea-user"), + detail: Option.none(), + }, { kind: "forgejo", auth: "authenticated", diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 02e9b03e1f29..a759b18cd93e 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -14,6 +14,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; +import * as GiteaCli from "./GiteaCli.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -93,6 +94,7 @@ function makeRegistry(input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GiteaCli.GiteaCli)({}), Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), ServerConfig.layerTest(process.cwd(), { @@ -296,3 +298,58 @@ it.effect("falls back to a non-origin remote when origin is not configured", () assert.strictEqual(provider.kind, "azure-devops"); }), ); + +const teaLoginsProcess = (logins: ReadonlyArray>) => ({ + run: (input: VcsProcess.VcsProcessInput) => + input.command === "tea" + ? Effect.succeed(processOutput(JSON.stringify(logins))) + : Effect.succeed(processOutput("")), +}); + +it.effect("routes gitea.com remotes to the Gitea provider", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@gitea.com:owner/repo.git" }], + }); + const provider = yield* registry.resolve({ cwd: "/repo" }); + assert.strictEqual(provider.kind, "gitea"); + }), +); + +it.effect("refines an unmarked self-hosted remote to Gitea from tea authentication", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@git.example.com:owner/repo.git" }], + process: teaLoginsProcess([ + { + name: "self-hosted", + url: "https://git.example.com", + ssh_host: "git.example.com", + user: "mario", + default: "true", + }, + ]), + }); + const provider = yield* registry.resolve({ cwd: "/repo" }); + assert.strictEqual(provider.kind, "gitea"); + }), +); + +it.effect("leaves an unrelated remote unknown when tea has no matching login", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@git.unrelated.example:owner/repo.git" }], + process: teaLoginsProcess([ + { + name: "self-hosted", + url: "https://git.example.com", + ssh_host: "git.example.com", + user: "mario", + default: "true", + }, + ]), + }); + const provider = yield* registry.resolve({ cwd: "/repo" }); + assert.strictEqual(provider.kind, "unknown"); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 57dfc78b6672..98f884ae33d5 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -13,6 +13,7 @@ import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/source import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; +import * as GiteaSourceControlProvider from "./GiteaSourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; @@ -302,6 +303,7 @@ export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketSourceControlProvider.make; const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; const azureDevOps = yield* AzureDevOpsSourceControlProvider.make; + const gitea = yield* GiteaSourceControlProvider.make; return yield* makeWithProviders([ { kind: "github", @@ -323,6 +325,11 @@ export const make = Effect.gen(function* () { provider: bitbucket, discovery: bitbucketDiscovery, }, + { + kind: "gitea", + provider: gitea, + discovery: GiteaSourceControlProvider.discovery, + }, { kind: "forgejo", provider: forgejo, discovery: forgejoDiscovery }, ]); }); diff --git a/apps/server/src/sourceControl/giteaLogins.test.ts b/apps/server/src/sourceControl/giteaLogins.test.ts new file mode 100644 index 000000000000..4bd07cf8c23e --- /dev/null +++ b/apps/server/src/sourceControl/giteaLogins.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + findGiteaLoginForHost, + findPrimaryGiteaLogin, + normalizeGiteaHostname, + parseGiteaLogins, +} from "./giteaLogins.ts"; + +// Captured from a real `tea logins list --output json` (tea 0.15.1). Note that `default` is the +// string "true", not a boolean, and that no token is ever included in the output. +const TWO_LOGINS = JSON.stringify([ + { + name: "local", + url: "https://git.example.internal", + ssh_host: "git.example.internal", + user: "mario", + default: "true", + }, + { + name: "second", + url: "https://code.home.arpa:3000", + ssh_host: "code.home.arpa", + user: "otheruser", + default: "false", + }, +]); + +describe("parseGiteaLogins", () => { + it("parses multiple logins and reads tea's string `default` flag", () => { + const logins = parseGiteaLogins(TWO_LOGINS); + expect(logins).toHaveLength(2); + expect(logins[0]).toEqual({ + name: "local", + url: "https://git.example.internal", + hostname: "git.example.internal", + sshHostname: "git.example.internal", + user: "mario", + isDefault: true, + }); + expect(logins[1]?.isDefault).toBe(false); + expect(logins[1]?.hostname).toBe("code.home.arpa"); + }); + + it("parses a single login", () => { + const logins = parseGiteaLogins( + JSON.stringify([ + { + name: "only", + url: "https://git.example.com", + ssh_host: "", + user: "sam", + default: "true", + }, + ]), + ); + expect(logins).toHaveLength(1); + expect(logins[0]?.user).toBe("sam"); + expect(logins[0]?.sshHostname).toBe(""); + }); + + it("returns no logins when tea has none configured", () => { + expect(parseGiteaLogins("[]")).toEqual([]); + expect(parseGiteaLogins("")).toEqual([]); + expect(parseGiteaLogins(" \n ")).toEqual([]); + }); + + it("returns no logins for malformed or unexpected output instead of throwing", () => { + expect(parseGiteaLogins("not json at all")).toEqual([]); + expect(parseGiteaLogins("{}")).toEqual([]); + expect(parseGiteaLogins('"a string"')).toEqual([]); + expect(parseGiteaLogins("[1, 2, null]")).toEqual([]); + // An entry with neither a URL nor an SSH host cannot be matched to a remote, so it is dropped. + expect(parseGiteaLogins('[{"name":"broken","user":"x"}]')).toEqual([]); + }); + + it("treats a missing user as unauthenticated rather than an empty name", () => { + const logins = parseGiteaLogins('[{"name":"n","url":"https://git.example.com","user":""}]'); + expect(logins[0]?.user).toBeNull(); + }); +}); + +describe("normalizeGiteaHostname", () => { + it("lowercases and strips ports", () => { + expect(normalizeGiteaHostname("GIT.Example.COM")).toBe("git.example.com"); + expect(normalizeGiteaHostname("git.example.com:3000")).toBe("git.example.com"); + expect(normalizeGiteaHostname("https://GIT.example.com:3000")).toBe("git.example.com"); + expect(normalizeGiteaHostname("http://git.example.com")).toBe("git.example.com"); + }); + + it("handles bare IPs and IPv6 literals", () => { + expect(normalizeGiteaHostname("192.168.1.10:3000")).toBe("192.168.1.10"); + expect(normalizeGiteaHostname("[::1]:3000")).toBe("[::1]"); + }); + + it("returns empty for blank input", () => { + expect(normalizeGiteaHostname("")).toBe(""); + expect(normalizeGiteaHostname(" ")).toBe(""); + }); +}); + +describe("findGiteaLoginForHost", () => { + const logins = parseGiteaLogins(TWO_LOGINS); + + it("matches an HTTPS remote host", () => { + expect(findGiteaLoginForHost(logins, "git.example.internal")?.name).toBe("local"); + }); + + it("matches regardless of port, since HTTPS and SSH commonly differ", () => { + // The login is configured on :3000 but an SSH remote reports no port at all. + expect(findGiteaLoginForHost(logins, "code.home.arpa")?.name).toBe("second"); + expect(findGiteaLoginForHost(logins, "code.home.arpa:3000")?.name).toBe("second"); + expect(findGiteaLoginForHost(logins, "code.home.arpa:22")?.name).toBe("second"); + }); + + it("matches case-insensitively", () => { + expect(findGiteaLoginForHost(logins, "GIT.EXAMPLE.INTERNAL")?.name).toBe("local"); + }); + + it("does not match hosts tea knows nothing about", () => { + expect(findGiteaLoginForHost(logins, "git.unrelated.com")).toBeUndefined(); + expect(findGiteaLoginForHost(logins, "")).toBeUndefined(); + // Substrings must not match: a suffix is a different host. + expect(findGiteaLoginForHost(logins, "evil-git.example.internal")).toBeUndefined(); + expect(findGiteaLoginForHost(logins, "example.internal")).toBeUndefined(); + }); + + it("matches via ssh_host when it differs from the web URL host", () => { + const split = parseGiteaLogins( + JSON.stringify([ + { + name: "split", + url: "https://gitea.example.com", + ssh_host: "ssh.example.com", + user: "sam", + default: "true", + }, + ]), + ); + expect(findGiteaLoginForHost(split, "gitea.example.com")?.name).toBe("split"); + expect(findGiteaLoginForHost(split, "ssh.example.com")?.name).toBe("split"); + }); +}); + +describe("findPrimaryGiteaLogin", () => { + it("prefers the default login", () => { + expect(findPrimaryGiteaLogin(parseGiteaLogins(TWO_LOGINS))?.name).toBe("local"); + }); + + it("falls back to the first authenticated login when none is marked default", () => { + const logins = parseGiteaLogins( + JSON.stringify([ + { name: "a", url: "https://a.example.com", user: "", default: "false" }, + { name: "b", url: "https://b.example.com", user: "sam", default: "false" }, + ]), + ); + expect(findPrimaryGiteaLogin(logins)?.name).toBe("b"); + }); + + it("returns undefined when there are no logins", () => { + expect(findPrimaryGiteaLogin([])).toBeUndefined(); + }); +}); diff --git a/apps/server/src/sourceControl/giteaLogins.ts b/apps/server/src/sourceControl/giteaLogins.ts new file mode 100644 index 000000000000..f871e75031e3 --- /dev/null +++ b/apps/server/src/sourceControl/giteaLogins.ts @@ -0,0 +1,117 @@ +/** + * Parses `tea logins list --output json`, which is how T3 learns which Gitea instances the server + * is authenticated against. Gitea is nearly always self-hosted on a hostname that carries no hint + * of it, so this list is also the evidence used to refine an otherwise-`unknown` remote to `gitea`. + */ + +export interface GiteaLogin { + /** `tea`'s name for the login, e.g. the value passed to `tea login add --name`. */ + readonly name: string; + readonly url: string; + /** Host portion of `url`, lowercased, port stripped. Empty when `url` could not be parsed. */ + readonly hostname: string; + /** Host `tea` uses for SSH remotes, lowercased, port stripped. Empty when not configured. */ + readonly sshHostname: string; + readonly user: string | null; + readonly isDefault: boolean; +} + +function asRecordArray(value: unknown): ReadonlyArray> { + if (!Array.isArray(value)) return []; + return value.filter( + (entry): entry is Record => + typeof entry === "object" && entry !== null && !Array.isArray(entry), + ); +} + +function readString(record: Record, key: string): string { + const value = record[key]; + return typeof value === "string" ? value.trim() : ""; +} + +/** Strips an optional port and lowercases, so `Git.Example.COM:3000` and `git.example.com` match. */ +export function normalizeGiteaHostname(value: string): string { + const trimmed = value.trim().toLowerCase(); + if (trimmed.length === 0) return ""; + + // Bracketed IPv6 literals keep their brackets so `[::1]:3000` does not lose its address. + const bracketed = /^(\[[0-9a-f:.]+\])(?::\d+)?$/u.exec(trimmed); + if (bracketed?.[1]) return bracketed[1]; + + try { + return new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`).hostname; + } catch { + return trimmed.replace(/:\d+$/u, ""); + } +} + +/** + * `tea` reports `default` as the string "true"/"false" rather than a boolean, so this reads it + * loosely instead of trusting the JSON type. + */ +function readDefaultFlag(record: Record): boolean { + const value = record["default"]; + if (typeof value === "boolean") return value; + return typeof value === "string" && value.trim().toLowerCase() === "true"; +} + +/** Returns an empty list for absent, malformed, or non-JSON output rather than throwing. */ +export function parseGiteaLogins(text: string): ReadonlyArray { + const trimmed = text.trim(); + if (trimmed.length === 0) return []; + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return []; + } + + const logins: GiteaLogin[] = []; + for (const record of asRecordArray(parsed)) { + const url = readString(record, "url"); + const sshHost = readString(record, "ssh_host"); + const hostname = normalizeGiteaHostname(url); + const sshHostname = normalizeGiteaHostname(sshHost); + if (hostname.length === 0 && sshHostname.length === 0) continue; + + const user = readString(record, "user"); + logins.push({ + name: readString(record, "name"), + url, + hostname, + sshHostname, + user: user.length > 0 ? user : null, + isDefault: readDefaultFlag(record), + }); + } + return logins; +} + +/** The login T3 reports on the Source Control settings card when several instances are configured. */ +export function findPrimaryGiteaLogin(logins: ReadonlyArray): GiteaLogin | undefined { + return ( + logins.find((login) => login.isDefault && login.user !== null) ?? + logins.find((login) => login.user !== null) ?? + logins[0] + ); +} + +/** + * Matches on hostname alone, ignoring ports: a Gitea instance is routinely reached over HTTPS on + * one port and SSH on another, so an SSH remote would never match its own login if ports had to + * agree. Scope stays safe because only hosts `tea` is actually authenticated against are consulted. + */ +export function findGiteaLoginForHost( + logins: ReadonlyArray, + host: string, +): GiteaLogin | undefined { + const hostname = normalizeGiteaHostname(host); + if (hostname.length === 0) return undefined; + + return logins.find( + (login) => + (login.hostname.length > 0 && login.hostname === hostname) || + (login.sshHostname.length > 0 && login.sshHostname === hostname), + ); +} diff --git a/apps/server/src/sourceControl/giteaPullRequests.ts b/apps/server/src/sourceControl/giteaPullRequests.ts new file mode 100644 index 000000000000..f465af1ab41e --- /dev/null +++ b/apps/server/src/sourceControl/giteaPullRequests.ts @@ -0,0 +1,153 @@ +import * as Cause from "effect/Cause"; +import type * as DateTime from "effect/DateTime"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; + +export interface NormalizedGiteaPullRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: Option.Option; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +const GiteaRepositoryReferenceSchema = Schema.Struct({ + full_name: Schema.optional(Schema.NullOr(Schema.String)), + owner: Schema.optional( + Schema.NullOr( + Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +/** A PR branch endpoint. `repo` is null when the fork it came from has been deleted. */ +const GiteaBranchInfoSchema = Schema.Struct({ + ref: Schema.optional(Schema.NullOr(Schema.String)), + label: Schema.optional(Schema.NullOr(Schema.String)), + repo: Schema.optional(Schema.NullOr(GiteaRepositoryReferenceSchema)), +}); + +const GiteaPullRequestSchema = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + html_url: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + merged: Schema.optional(Schema.NullOr(Schema.Boolean)), + updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + base: GiteaBranchInfoSchema, + head: GiteaBranchInfoSchema, +}); + +export type GiteaPullRequestJson = Schema.Schema.Type; + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +/** + * Gitea models a merged PR as `state: "closed"` with `merged: true`, so merged has to be read off + * the flag rather than the state string. + */ +function normalizeGiteaPullRequestState( + state: string | null | undefined, + merged: boolean | null | undefined, +): "open" | "closed" | "merged" { + if (merged === true) return "merged"; + return state?.trim().toLowerCase() === "closed" ? "closed" : "open"; +} + +/** + * `ref` is the plain branch name. `label` is `owner:branch` for a fork and a bare branch name + * otherwise, so it is only a fallback when `ref` is missing. + */ +function branchRefName( + branch: Schema.Schema.Type | null | undefined, +): string { + const ref = trimOptionalString(branch?.ref); + if (ref) return ref; + + const label = trimOptionalString(branch?.label); + if (!label) return ""; + const separator = label.indexOf(":"); + return separator === -1 ? label : label.slice(separator + 1); +} + +function repositoryFullName( + branch: Schema.Schema.Type | null | undefined, +): string | null { + return trimOptionalString(branch?.repo?.full_name); +} + +function normalizeGiteaPullRequestRecord( + raw: GiteaPullRequestJson, +): NormalizedGiteaPullRequestRecord { + const headRepository = repositoryFullName(raw.head); + const baseRepository = repositoryFullName(raw.base); + const isCrossRepository = + headRepository !== null && baseRepository !== null + ? headRepository.toLowerCase() !== baseRepository.toLowerCase() + : undefined; + const headOwnerLogin = + trimOptionalString(raw.head.repo?.owner?.login) ?? + trimOptionalString(headRepository?.split("/")[0]); + + return { + number: raw.number, + title: raw.title, + url: raw.html_url, + baseRefName: branchRefName(raw.base), + headRefName: branchRefName(raw.head), + state: normalizeGiteaPullRequestState(raw.state, raw.merged), + updatedAt: raw.updated_at ?? Option.none(), + ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), + ...(headRepository ? { headRepositoryNameWithOwner: headRepository } : {}), + ...(headOwnerLogin ? { headRepositoryOwnerLogin: headOwnerLogin } : {}), + }; +} + +const decodeGiteaPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeGiteaPullRequestBody = decodeJsonResult(GiteaPullRequestSchema); +const decodeGiteaPullRequestEntry = Schema.decodeUnknownExit(GiteaPullRequestSchema); + +export const formatGiteaJsonDecodeError = formatSchemaError; + +/** Entries that fail to decode are skipped so one malformed PR cannot blank the whole list. */ +export function decodeGiteaPullRequestListJson( + raw: string, +): Result.Result, Cause.Cause> { + const result = decodeGiteaPullRequestList(raw); + if (Result.isSuccess(result)) { + const pullRequests: NormalizedGiteaPullRequestRecord[] = []; + for (const entry of result.success) { + const decodedEntry = decodeGiteaPullRequestEntry(entry); + if (Exit.isFailure(decodedEntry)) { + continue; + } + pullRequests.push(normalizeGiteaPullRequestRecord(decodedEntry.value)); + } + return Result.succeed(pullRequests); + } + return Result.fail(result.failure); +} + +export function decodeGiteaPullRequestJson( + raw: string, +): Result.Result> { + const result = decodeGiteaPullRequestBody(raw); + if (Result.isSuccess(result)) { + return Result.succeed(normalizeGiteaPullRequestRecord(result.success)); + } + return Result.fail(result.failure); +} diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index ac0ee4428046..05ad5b28eab4 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -39,13 +39,14 @@ const baseInput = { const captureProcessResult = ( result: Effect.Effect, + input: VcsProcess.VcsProcessInput = baseInput, ) => VcsProcess.make.pipe( Effect.provideService( ProcessRunner.ProcessRunner, ProcessRunner.ProcessRunner.of({ run: () => result }), ), - Effect.flatMap((service) => service.run(baseInput)), + Effect.flatMap((service) => service.run(input)), Effect.flip, ); @@ -202,6 +203,54 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); + it.effect("classifies tea without an available login as authentication", () => + Effect.gen(function* () { + const error = yield* captureProcessResult( + Effect.succeed({ + stdout: "", + stderr: "no available login", + code: ChildProcessSpawner.ExitCode(1), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + { ...baseInput, command: "tea" }, + ); + + expect(error).toMatchObject({ + command: "tea", + detail: "Authentication failed.", + failureKind: "authentication", + }); + }), + ); + + it.effect("does not classify another command with tea login wording as authentication", () => + Effect.gen(function* () { + const error = yield* captureProcessResult( + Effect.succeed({ + stdout: "", + stderr: "no available login", + code: ChildProcessSpawner.ExitCode(1), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + { ...baseInput, command: "git" }, + ); + + expect(error).toMatchObject({ + command: "git", + detail: "Process exited with a non-zero status.", + failureKind: "command-failed", + }); + }), + ); + it.effect("classifies API rate limits without retaining provider stderr", () => Effect.gen(function* () { const providerStderr = diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 74608e116eed..7557e4892489 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -68,7 +68,10 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai normalized.includes("az devops login") || normalized.includes("please run az login") || normalized.includes("no oauth token") || - normalized.includes("unauthorized") + normalized.includes("unauthorized") || + // `tea` reports an unconfigured or unmatched instance this way, and it is by far the most + // common Gitea setup mistake. Scoped to tea so the phrase cannot misclassify another CLI. + (command === "tea" && normalized.includes("no available login")) ) { return "authentication"; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1f960c488d8a..461028540dba 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -153,6 +153,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; +import * as GiteaCli from "./sourceControl/GiteaCli.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; @@ -3178,6 +3179,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, + GiteaCli.layer, GitHubCli.layer, GitLabCli.layer, ForgejoCli.layer, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8af4419fca31..090a1b3b111d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -244,7 +244,7 @@ interface AddProjectEnvironmentOption { type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitea" >; type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; @@ -268,6 +268,7 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ "github", "gitlab", "forgejo", + "gitea", "bitbucket", "azure-devops", ]; @@ -275,6 +276,7 @@ const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray; case "forgejo": return ; + case "gitea": + return ; case "gitlab": return ; case "bitbucket": @@ -378,6 +385,7 @@ function buildAddProjectRemoteSourceReadiness( github: unavailable, gitlab: unavailable, forgejo: unavailable, + gitea: unavailable, bitbucket: unavailable, "azure-devops": unavailable, }; diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f302e976ca70..1ba404e5cec5 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -7,11 +7,32 @@ import { resolveAutoFeatureBranchName, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, + resolvePublishHost, resolveQuickAction, resolveThreadBranchUpdate, resolveThreadBranchMetadataPatch, } from "./GitActionsControl.logic"; +describe("resolvePublishHost", () => { + it("uses the discovered host when one is available", () => { + assert.equal( + resolvePublishHost({ discoveredHost: "git.example.com", fallbackHost: null }), + "git.example.com", + ); + }); + + it("keeps the provider fallback for providers with a canonical host", () => { + assert.equal( + resolvePublishHost({ discoveredHost: null, fallbackHost: "github.com" }), + "github.com", + ); + }); + + it("does not invent a Gitea hostname when discovery has no host", () => { + assert.equal(resolvePublishHost({ discoveredHost: null, fallbackHost: null }), null); + }); +}); + function status(overrides: Partial = {}): VcsStatusResult { return { isRepo: true, diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 96f7af794ace..7668f88b7e7c 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -43,6 +43,13 @@ export type DefaultBranchConfirmableAction = | "commit_push" | "commit_push_pr"; +export function resolvePublishHost(input: { + discoveredHost: string | null | undefined; + fallbackHost: string | null; +}): string | null { + return input.discoveredHost ?? input.fallbackHost; +} + function resolveChangeRequestTerminology( gitStatus: VcsStatusResult | null, ): ChangeRequestTerminology { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 99db055b667b..4083dc8597da 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -129,7 +129,7 @@ interface PendingDefaultBranchAction { type PublishProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitea" >; type GitActionToastId = ReturnType; @@ -177,6 +177,14 @@ function requestVcsStatusRefresh( const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; const PUBLISH_PROVIDER_OPTIONS = [ + { + value: "gitea", + label: "Gitea", + description: "Your authenticated instance", + host: null, + pathPlaceholder: "owner/repository", + Icon: GitBranchPlusIcon, + }, { value: "forgejo", label: "Forgejo / Gitea", @@ -221,7 +229,7 @@ const PUBLISH_PROVIDER_OPTIONS = [ readonly value: PublishProviderKind; readonly label: string; readonly description: string; - readonly host: string; + readonly host: string | null; readonly pathPlaceholder: string; readonly Icon: typeof GitHubIcon; }>; @@ -440,6 +448,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { github: null, gitlab: null, forgejo: null, + gitea: null, bitbucket: null, "azure-devops": null, }; @@ -492,10 +501,10 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill; const currentPublishProvider = publishProviderOption(publishProvider); const publishHost = - publishProvider === "forgejo" + publishProvider === "forgejo" || publishProvider === "gitea" ? (Option.getOrNull( sourceControlDiscovery.data?.sourceControlProviders.find( - (provider) => provider.kind === "forgejo", + (provider) => provider.kind === publishProvider, )?.auth.host ?? Option.none(), ) ?? currentPublishProvider.host) : currentPublishProvider.host; diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index be71c95ddc40..d700559dda5f 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -115,6 +115,8 @@ export function pullRequestCheckoutCommand( return repositoryUrl ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' refs/pull/${number}/head && git checkout -B pulls/${number} FETCH_HEAD` : null; + case "gitea": + return `tea pulls checkout ${number}`; case "azure-devops": return `az repos pr checkout --id ${number}`; case "bitbucket": { @@ -1004,7 +1006,7 @@ const OPERATION_PREFIX = /^Pull request operation \w+ failed:\s*/iu; * host says is worth more than what this page could invent, so only these are replaced. */ const TOOL_NOISE = [ - /^(github|gitlab|bitbucket|azure devops)?\s*(cli|api)?\s*(command\s*)?failed\.?$/iu, + /^(github|gitlab|bitbucket|azure devops|gitea)?\s*(cli|api)?\s*(command\s*)?failed\.?$/iu, /^exited? with (code|status) \d+\.?$/iu, /^unknown error\.?$/iu, ]; diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 16c4fa5d3559..3df8515eef59 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -14,6 +14,7 @@ const OPEN_ON_HOST_LABELS: Partial> = { forgejo: "Open on Forgejo", bitbucket: "Open on Bitbucket", "azure-devops": "Open on Azure DevOps", + gitea: "Open on Gitea", }; export const openOnHostLabel = (provider: string): string => diff --git a/apps/web/src/components/settings/SourceControlSettings.logic.test.ts b/apps/web/src/components/settings/SourceControlSettings.logic.test.ts new file mode 100644 index 000000000000..abee92b92941 --- /dev/null +++ b/apps/web/src/components/settings/SourceControlSettings.logic.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formattedAuthSuffix, formattedSetupGuidance } from "./SourceControlSettings.logic"; + +describe("formattedAuthSuffix", () => { + it("returns empty string when host and detail are null", () => { + expect(formattedAuthSuffix(null, null)).toBe(""); + }); + + it("returns host segment when host is present", () => { + expect(formattedAuthSuffix("git.example.com", null)).toBe(" on git.example.com"); + }); + + it("returns detail segment when detail is present", () => { + expect(formattedAuthSuffix(null, "2 Gitea instances configured")).toBe( + " \u2014 2 Gitea instances configured", + ); + }); + + it("returns host and detail segments when both are present", () => { + expect(formattedAuthSuffix("git.example.com", "2 Gitea instances configured")).toBe( + " on git.example.com \u2014 2 Gitea instances configured", + ); + }); +}); + +describe("formattedSetupGuidance", () => { + it("returns provider-neutral guidance before the executable chip", () => { + expect(formattedSetupGuidance("Gitea")).toBe( + "Gitea is not authenticated on this server. Sign in or configure credentials using the", + ); + }); + + it("uses the same neutral guidance for other CLI providers", () => { + expect(formattedSetupGuidance("GitHub")).toBe( + "GitHub is not authenticated on this server. Sign in or configure credentials using the", + ); + }); +}); diff --git a/apps/web/src/components/settings/SourceControlSettings.logic.ts b/apps/web/src/components/settings/SourceControlSettings.logic.ts new file mode 100644 index 000000000000..33dff047c8a3 --- /dev/null +++ b/apps/web/src/components/settings/SourceControlSettings.logic.ts @@ -0,0 +1,19 @@ +/** + * Pure formatting helpers for the Source Control settings card. + * Extracted to enable focused unit tests without a React render harness. + */ + +export function formattedAuthSuffix(host: string | null, detail: string | null): string { + let text = ""; + if (host !== null) { + text += ` on ${host}`; + } + if (detail !== null) { + text += ` \u2014 ${detail}`; + } + return text; +} + +export function formattedSetupGuidance(label: string): string { + return `${label} is not authenticated on this server. Sign in or configure credentials using the`; +} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 733791518d35..b0155678bd09 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -66,6 +66,7 @@ import { SettingsSection, useSettingsSearchTargetId, } from "./settingsLayout"; +import { formattedAuthSuffix, formattedSetupGuidance } from "./SourceControlSettings.logic"; import { searchableSetting } from "./settingsSearch"; const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { @@ -79,6 +80,9 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { @@ -220,6 +224,7 @@ function itemSummary({ if (auth) { if (auth.status === "authenticated") { + const suffix = formattedAuthSuffix(optionLabel(auth.host), optionLabel(auth.detail)); return ( <> Authenticated @@ -229,6 +234,7 @@ function itemSummary({ ) : null} + {suffix ? {suffix} : null} ); } @@ -240,9 +246,9 @@ function itemSummary({ if (auth.status === "unauthenticated") { return ( - {item.label} is not authenticated on this server. Sign in or configure credentials using - the {item.executable}{" "} - tool on the server host to enable change request features. + {formattedSetupGuidance(item.label)}{" "} + {item.executable} tool on + the server host to enable change request features. ); } diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index a709b7d8add1..953f487e41f8 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -516,3 +516,49 @@ describe("findProjectForChangeRequest", () => { ).toBeUndefined(); }); }); + +describe("Gitea change request links", () => { + it("recognizes Gitea PR URLs for the in-app reader", () => { + expect(parseChangeRequestUrl("https://git.example.com/owner/repo/pulls/42")).toEqual({ + host: "git.example.com", + authority: "git.example.com", + repository: "owner/repo", + number: 42, + }); + expect(parseChangeRequestUrl("https://gitea.com/foo/bar/pulls/1")).toEqual({ + host: "gitea.com", + authority: "gitea.com", + repository: "foo/bar", + number: 1, + }); + }); + + it("does not read a Gitea PR list or a non-numeric index", () => { + expect(parseChangeRequestUrl("https://git.example.com/owner/repo/pulls")).toBeNull(); + expect(parseChangeRequestUrl("https://git.example.com/owner/repo/pulls/abc")).toBeNull(); + expect(parseChangeRequestUrl("https://git.example.com/owner/repo/issues/42")).toBeNull(); + }); + + it("leaves GitHub URLs on the GitHub rule", () => { + expect(parseChangeRequestUrl("https://github.com/owner/repo/pull/42")).toEqual({ + host: "github.com", + repository: "owner/repo", + number: 42, + }); + }); + + it("still matches native paths on self-hosted lookalike hosts", () => { + expect(parseChangeRequestUrl("https://github.internal/owner/repo/pull/42")).toEqual({ + host: "github.internal", + repository: "owner/repo", + number: 42, + }); + expect( + parseChangeRequestUrl("https://bitbucket.internal/workspace/repo/pull-requests/5"), + ).toEqual({ + host: "bitbucket.internal", + repository: "workspace/repo", + number: 5, + }); + }); +}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index b691aa58cce3..64052a96b035 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -28,7 +28,8 @@ export { function resolvedForgejoRepository(project: EnvironmentProject): URL | null { const identity = project.repositoryIdentity; - if (identity?.provider !== "forgejo" || !identity.webUrl) return null; + if ((identity?.provider !== "forgejo" && identity?.provider !== "gitea") || !identity.webUrl) + return null; try { const url = new URL(identity.webUrl); return url.protocol === "http:" || url.protocol === "https:" ? url : null; diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 5e534af0a0be..c58f98249bf5 100644 --- a/apps/web/src/pullRequestReference.test.ts +++ b/apps/web/src/pullRequestReference.test.ts @@ -70,4 +70,36 @@ describe("parsePullRequestReference", () => { it("rejects non-pull-request input", () => { expect(parsePullRequestReference("feature/my-branch")).toBeNull(); }); + + it("accepts Gitea pull request URLs", () => { + expect(parsePullRequestReference("https://git.example.com/owner/repo/pulls/42")).toBe( + "https://git.example.com/owner/repo/pulls/42", + ); + expect(parsePullRequestReference("https://gitea.com/foo/bar/pulls/1")).toBe( + "https://gitea.com/foo/bar/pulls/1", + ); + }); + + it("rejects public github.com and bitbucket.org /pulls/ URLs", () => { + expect(parsePullRequestReference("https://github.com/owner/repo/pulls/42")).toBeNull(); + expect(parsePullRequestReference("https://bitbucket.org/owner/repo/pulls/42")).toBeNull(); + expect(parsePullRequestReference("http://github.com/o/r/pulls/1")).toBeNull(); + }); + + it("accepts self-hosted Gitea URLs on github.internal and bitbucket.internal", () => { + expect(parsePullRequestReference("https://github.internal/owner/repo/pulls/42")).toBe( + "https://github.internal/owner/repo/pulls/42", + ); + expect(parsePullRequestReference("https://bitbucket.internal/owner/repo/pulls/42")).toBe( + "https://bitbucket.internal/owner/repo/pulls/42", + ); + }); + + it("accepts tea pulls checkout commands", () => { + expect(parsePullRequestReference("tea pulls checkout 42")).toBe("42"); + expect(parsePullRequestReference("tea pulls checkout #42")).toBe("42"); + expect( + parsePullRequestReference("tea pulls checkout https://git.example.com/owner/repo/pulls/42"), + ).toBe("https://git.example.com/owner/repo/pulls/42"); + }); }); diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index 421f60f03687..dcfe4beabfc5 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -7,10 +7,13 @@ const GITLAB_MERGE_REQUEST_URL_PATTERN = /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN = /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; +const GITEA_PULL_REQUEST_URL_PATTERN = + /^https?:\/\/(?!(?:github\.com|bitbucket\.org)(?:\/|$))[^/\s]+\/[^/\s]+\/[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i; const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i; const GITLAB_CLI_MR_CHECKOUT_PATTERN = /^glab\s+mr\s+checkout\s+(.+)$/i; const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; +const TEA_PULLS_CHECKOUT_PATTERN = /^tea\s+pulls\s+checkout\s+(.+)$/i; function parseAzureDevOpsCheckoutReference(args: string): string | null { const parts = args.trim().split(/\s+/).filter(Boolean); @@ -34,6 +37,7 @@ export function parsePullRequestReference(input: string): string | null { const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed); const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); + const teaCheckoutMatch = TEA_PULLS_CHECKOUT_PATTERN.exec(trimmed); const normalizedInput = FORGEJO_CLI_PR_CHECKOUT_PATTERN.exec(trimmed)?.[1]?.trim() ?? ghCliCheckoutMatch?.[1]?.trim() ?? @@ -41,6 +45,7 @@ export function parsePullRequestReference(input: string): string | null { (azureDevOpsCliCheckoutMatch?.[1] ? parseAzureDevOpsCheckoutReference(azureDevOpsCliCheckoutMatch[1]) : null) ?? + teaCheckoutMatch?.[1]?.trim() ?? trimmed; if (normalizedInput.length === 0) { return null; @@ -50,7 +55,8 @@ export function parsePullRequestReference(input: string): string | null { FORGEJO_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ?? - AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput); + AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? + GITEA_PULL_REQUEST_URL_PATTERN.exec(normalizedInput); if (urlMatch?.[1]) { return normalizedInput; } diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index 0f627bca5a17..4e92066c6362 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -62,6 +62,9 @@ export function getSourceControlPresentation( terminology: getChangeRequestTerminology(provider), Icon: BitbucketIcon, }; + // Gitea ships no bundled logo here yet, so it borrows the neutral change-request mark rather + // than another host's brand. Swap in a real Gitea icon when one is added to Icons.tsx. + case "gitea": case "change-request": return { providerName: provider?.name || presentation.providerName, diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index efda37c14403..77d1a6624f33 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -269,7 +269,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi ); const action = useCallback( async (input: { - provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; + provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitea"; repository: string; visibility: SourceControlRepositoryVisibility; remoteName: string; diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 597b0b5d7b4f..ec717ee0be51 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -25,7 +25,7 @@ import type { EnvironmentProject } from "../state/models.ts"; export type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitea" >; export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; @@ -60,6 +60,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ "github", "gitlab", "forgejo", + "gitea", "bitbucket", "azure-devops", ]; @@ -68,6 +69,7 @@ const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray, ): string { - return repository.provider === "github" || repository.provider === "forgejo" + return repository.provider === "github" || + repository.provider === "forgejo" || + repository.provider === "gitea" ? repository.url : repository.sshUrl; } @@ -161,6 +168,7 @@ export function buildAddProjectRemoteSourceReadiness( github: unavailable, gitlab: unavailable, forgejo: unavailable, + gitea: unavailable, bitbucket: unavailable, "azure-devops": unavailable, }; diff --git a/packages/contracts/src/sourceControl.test.ts b/packages/contracts/src/sourceControl.test.ts new file mode 100644 index 000000000000..ada0547d6674 --- /dev/null +++ b/packages/contracts/src/sourceControl.test.ts @@ -0,0 +1,88 @@ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + ChangeRequest, + SourceControlProviderDiscoveryItem, + SourceControlProviderInfo, + SourceControlProviderKind, +} from "./sourceControl.ts"; + +const decodeKind = Schema.decodeUnknownSync(SourceControlProviderKind); +const encodeKind = Schema.encodeSync(SourceControlProviderKind); +const decodeProviderInfo = Schema.decodeUnknownSync(SourceControlProviderInfo); +const decodeChangeRequest = Schema.decodeUnknownSync(ChangeRequest); +const decodeDiscoveryItem = Schema.decodeUnknownSync(SourceControlProviderDiscoveryItem); + +describe("SourceControlProviderKind", () => { + it("round-trips every supported provider kind, including gitea", () => { + for (const kind of [ + "github", + "gitlab", + "forgejo", + "azure-devops", + "bitbucket", + "gitea", + "unknown", + ]) { + expect(encodeKind(decodeKind(kind))).toBe(kind); + } + }); + + it("still rejects hosts this build does not support", () => { + expect(() => decodeKind("sourcehut")).toThrow(); + }); +}); + +describe("gitea across source-control contracts", () => { + it("decodes a Gitea provider info", () => { + expect( + decodeProviderInfo({ + kind: "gitea", + name: "Gitea Self-Hosted", + baseUrl: "https://git.example.com", + }), + ).toEqual({ + kind: "gitea", + name: "Gitea Self-Hosted", + baseUrl: "https://git.example.com", + }); + }); + + it("decodes a Gitea change request", () => { + const decoded = decodeChangeRequest({ + provider: "gitea", + number: 42, + title: "Add widget", + url: "https://git.example.com/owner/repo/pulls/42", + baseRefName: "main", + headRefName: "t3code/abcd1234", + state: "open", + updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T03:04:05.000Z")), + }); + expect(decoded.provider).toBe("gitea"); + expect(decoded.number).toBe(42); + }); + + it("decodes a Gitea discovery item", () => { + const decoded = decodeDiscoveryItem({ + kind: "gitea", + label: "Gitea", + executable: "tea", + status: "available", + version: Option.some("0.15.1"), + installHint: "Install tea.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("mario"), + host: Option.some("git.example.com"), + detail: Option.none(), + }, + }); + expect(decoded.kind).toBe("gitea"); + expect(decoded.auth.status).toBe("authenticated"); + }); +}); diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index b0d2bd4a75bd..4507ac8eefe6 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -8,6 +8,7 @@ export const SourceControlProviderKind = Schema.Literals([ "forgejo", "azure-devops", "bitbucket", + "gitea", "unknown", ]); export type SourceControlProviderKind = typeof SourceControlProviderKind.Type; diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 5be8d355a166..87431e36cde7 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -96,7 +96,8 @@ export function changeRequestUrlFor( switch (kind) { case "github": return `https://${host}/${repository}/pull/${number}`; - case "forgejo": { + case "forgejo": + case "gitea": { try { const remote = new URL(remoteUrl ?? ""); if ( diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index f9c009fac044..800216f338c5 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -59,8 +59,8 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { ).toBe("bitbucket"); }); - it("detects Forgejo and Gitea hosts while preserving HTTP origins", () => { - for (const host of ["codeberg.org", "forgejo.example.test", "gitea.example.test"]) { + it("detects Forgejo hosts while preserving HTTP origins", () => { + for (const host of ["codeberg.org", "forgejo.example.test"]) { expect(detectSourceControlProviderFromRemoteUrl(`http://${host}:3000/team/repo.git`)).toEqual( { kind: "forgejo", @@ -75,6 +75,22 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { }); }); + it("detects Gitea hosts while preserving HTTP origins", () => { + for (const host of ["gitea.com", "gitea.example.test"]) { + expect(detectSourceControlProviderFromRemoteUrl(`http://${host}:3000/team/repo.git`)).toEqual( + { + kind: "gitea", + name: host === "gitea.com" ? "Gitea" : "Gitea Self-Hosted", + baseUrl: `http://${host}:3000`, + }, + ); + } + expect(getChangeRequestTerminologyForKind("gitea")).toEqual({ + shortLabel: "PR", + singular: "pull request", + }); + }); + it("detects Azure DevOps SSH remotes", () => { // The default Azure DevOps SSH clone URL uses the ssh.dev.azure.com host. expect( @@ -121,6 +137,9 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { detectSourceControlProviderFromRemoteUrl("https://bitbucket.example.com/workspace/repo.git") ?.kind, ).toBe("bitbucket"); + expect( + detectSourceControlProviderFromRemoteUrl("https://gitea.example.com/owner/repo.git")?.kind, + ).toBe("gitea"); }); it("does not match provider names embedded in unrelated DNS labels", () => { @@ -137,6 +156,9 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { "https://notbitbucket.example.com/workspace/repo.git", )?.kind, ).toBe("unknown"); + expect( + detectSourceControlProviderFromRemoteUrl("https://notgitea.example.com/owner/repo.git")?.kind, + ).toBe("unknown"); }); it("detects SSH remotes with non-git SSH users (e.g. gitlab@, deploy@)", () => { diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 0b2e0e2699c6..a9906f92a153 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -5,7 +5,14 @@ import type { } from "@t3tools/contracts"; export interface ChangeRequestPresentation { - readonly icon: "github" | "gitlab" | "forgejo" | "azure-devops" | "bitbucket" | "change-request"; + readonly icon: + | "github" + | "gitlab" + | "forgejo" + | "azure-devops" + | "bitbucket" + | "gitea" + | "change-request"; readonly providerName: string; readonly shortName: string; readonly longName: string; @@ -58,6 +65,17 @@ const FORGEJO_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { urlExample: "https://codeberg.org/owner/repo/pulls/42", }; +const GITEA_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "gitea", + providerName: "Gitea", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "Gitea pull request", + checkoutCommandExample: "tea pulls checkout 123", + urlExample: "https://git.example.com/owner/repo/pulls/42", +}; + const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "azure-devops", providerName: "Azure DevOps", @@ -100,6 +118,8 @@ export function resolveChangeRequestPresentation( return GITLAB_CHANGE_REQUEST_PRESENTATION; case "forgejo": return FORGEJO_CHANGE_REQUEST_PRESENTATION; + case "gitea": + return GITEA_CHANGE_REQUEST_PRESENTATION; case "azure-devops": return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; case "bitbucket": @@ -213,11 +233,7 @@ export function detectSourceControlProviderFromRemoteUrl( } const hostname = parseHostName(host); - if ( - hostname === "codeberg.org" || - hasDnsLabel(hostname, "forgejo") || - hasDnsLabel(hostname, "gitea") - ) { + if (hostname === "codeberg.org" || hasDnsLabel(hostname, "forgejo")) { return { kind: "forgejo", name: "Forgejo", @@ -227,6 +243,16 @@ export function detectSourceControlProviderFromRemoteUrl( }; } + if (hostname === "gitea.com" || hasDnsLabel(hostname, "gitea")) { + return { + kind: "gitea", + name: hostname === "gitea.com" ? "Gitea" : "Gitea Self-Hosted", + baseUrl: /^https?:/iu.test(remoteUrl.trim()) + ? new URL(remoteUrl.trim()).origin + : toBaseUrl(host), + }; + } + if (isGitHubHost(hostname)) { return { kind: "github",