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",