diff --git a/apps/mobile/src/components/SourceControlIcon.tsx b/apps/mobile/src/components/SourceControlIcon.tsx
index b1d4918037ce..c6e1cd9bd1c0 100644
--- a/apps/mobile/src/components/SourceControlIcon.tsx
+++ b/apps/mobile/src/components/SourceControlIcon.tsx
@@ -1,6 +1,6 @@
-import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg";
+import Svg, { Circle, Defs, LinearGradient, Line, Path, Stop } from "react-native-svg";
-export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops";
+export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops" | "gitea";
export function SourceControlIcon(props: {
readonly kind: SourceControlIconKind;
@@ -95,5 +95,44 @@ export function SourceControlIcon(props: {
/>
);
+ // Gitea ships no bundled logo here yet, so it uses the neutral pull-request mark rather than
+ // another host's brand, matching the web client.
+ case "gitea":
+ return (
+
+ );
}
}
diff --git a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx
index cdf52022a44a..ec86026b4513 100644
--- a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx
+++ b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx
@@ -18,7 +18,8 @@ export function AddProjectRepositoryRoute({
source === "github" ||
source === "gitlab" ||
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 b48c7a0bdd94..3c050c4e3b69 100644
--- a/apps/mobile/src/features/projects/AddProjectScreen.tsx
+++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx
@@ -104,7 +104,8 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote
source === "github" ||
source === "gitlab" ||
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 01a9d43195a9..554252a1c710 100644
--- a/apps/server/src/git/GitManager.test.ts
+++ b/apps/server/src/git/GitManager.test.ts
@@ -33,7 +33,10 @@ import * as GitHubCli from "../sourceControl/GitHubCli.ts";
import * as TextGeneration from "../textGeneration/TextGeneration.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
+import * as GiteaCli from "../sourceControl/GiteaCli.ts";
+import * as GiteaSourceControlProvider from "../sourceControl/GiteaSourceControlProvider.ts";
import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
+import * as SourceControlProvider from "../sourceControl/SourceControlProvider.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as ServerConfig from "../config.ts";
import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
@@ -620,6 +623,8 @@ function makeManager(input?: {
textGeneration?: Partial;
serverSettings?: Parameters[0];
setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
+ /** Replaces the default GitHub provider, for exercising another host's workflow. */
+ sourceControlProvider?: SourceControlProvider.SourceControlProvider["Service"];
}) {
const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
const textGeneration = createTextGeneration(input?.textGeneration);
@@ -637,14 +642,15 @@ function makeManager(input?: {
const sourceControlRegistryLayer = Layer.effect(
SourceControlProviderRegistry.SourceControlProviderRegistry,
GitHubSourceControlProvider.make.pipe(
- Effect.map((provider) =>
- SourceControlProviderRegistry.SourceControlProviderRegistry.of({
+ Effect.map((gitHubProvider) => {
+ const provider = input?.sourceControlProvider ?? gitHubProvider;
+ return SourceControlProviderRegistry.SourceControlProviderRegistry.of({
get: () => Effect.succeed(provider),
resolveHandle: () => Effect.succeed({ provider, context: null }),
resolve: () => Effect.succeed(provider),
discover: Effect.succeed([]),
- }),
- ),
+ });
+ }),
Effect.provide(Layer.succeed(GitHubCli.GitHubCli, gitHubCli)),
),
);
@@ -4835,4 +4841,109 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
]);
}),
);
+ // The original bug this provider fixes: a Gitea remote resolved to `unknown`, whose stub failed
+ // every call, so "Commit, push & create PR" died with "No unknown source control provider is
+ // registered." This drives the whole stacked action through the real Gitea provider.
+ it.effect("commits, pushes and creates a PR against a Gitea remote", () =>
+ Effect.gen(function* () {
+ const repoDir = yield* makeTempDir("t3code-git-manager-");
+ yield* initRepo(repoDir);
+ yield* runGit(repoDir, ["checkout", "-b", "t3code/gitea-flow"]);
+ const remoteDir = yield* createBareRemote();
+ yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
+ NodeFS.writeFileSync(NodePath.join(repoDir, "gitea.txt"), "gitea\n");
+
+ const createdPullRequest = {
+ number: 7,
+ title: "Add Gitea flow",
+ url: "https://git.example.com/owner/repo/pulls/7",
+ baseRefName: "main",
+ headRefName: "t3code/gitea-flow",
+ state: "open" as const,
+ };
+ // GitManager looks for an existing PR, creates one when there is none, then looks again.
+ let listCalls = 0;
+ let createCalls = 0;
+
+ const giteaProvider = yield* GiteaSourceControlProvider.make.pipe(
+ Effect.provide(
+ Layer.mock(GiteaCli.GiteaCli)({
+ listPullRequests: () => {
+ listCalls += 1;
+ return Effect.succeed(listCalls === 1 ? [] : [createdPullRequest]);
+ },
+ createPullRequest: () => {
+ createCalls += 1;
+ return Effect.void;
+ },
+ getDefaultBranch: () => Effect.succeed("main"),
+ }),
+ ),
+ );
+
+ const { manager } = yield* makeManager({ sourceControlProvider: giteaProvider });
+
+ const result = yield* runStackedAction(manager, {
+ cwd: repoDir,
+ action: "commit_push_pr",
+ });
+
+ expect(result.commit.status).toBe("created");
+ expect(result.push.status).toBe("pushed");
+ expect(result.pr.status).toBe("created");
+ expect(result.pr.number).toBe(7);
+ expect(createCalls).toBe(1);
+ expect(result.toast?.cta).toEqual({
+ kind: "open_pr",
+ label: "View PR",
+ url: "https://git.example.com/owner/repo/pulls/7",
+ });
+ }),
+ );
+
+ it.effect("reports an existing Gitea PR instead of opening a duplicate", () =>
+ Effect.gen(function* () {
+ const repoDir = yield* makeTempDir("t3code-git-manager-");
+ yield* initRepo(repoDir);
+ yield* runGit(repoDir, ["checkout", "-b", "t3code/gitea-existing"]);
+ const remoteDir = yield* createBareRemote();
+ yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
+ yield* runGit(repoDir, ["push", "-u", "origin", "t3code/gitea-existing"]);
+
+ let createCalls = 0;
+ const giteaProvider = yield* GiteaSourceControlProvider.make.pipe(
+ Effect.provide(
+ Layer.mock(GiteaCli.GiteaCli)({
+ listPullRequests: () =>
+ Effect.succeed([
+ {
+ number: 12,
+ title: "Already open",
+ url: "https://git.example.com/owner/repo/pulls/12",
+ baseRefName: "main",
+ headRefName: "t3code/gitea-existing",
+ state: "open" as const,
+ },
+ ]),
+ createPullRequest: () => {
+ createCalls += 1;
+ return Effect.void;
+ },
+ getDefaultBranch: () => Effect.succeed("main"),
+ }),
+ ),
+ );
+
+ const { manager } = yield* makeManager({ sourceControlProvider: giteaProvider });
+
+ const result = yield* runStackedAction(manager, {
+ cwd: repoDir,
+ action: "commit_push_pr",
+ });
+
+ expect(result.pr.status).toBe("opened_existing");
+ expect(result.pr.number).toBe(12);
+ expect(createCalls).toBe(0);
+ }),
+ );
});
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 3e41b4390f82..6ac6ef93dfcb 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -39,6 +39,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 TextGeneration from "./textGeneration/TextGeneration.ts";
@@ -272,7 +273,13 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe(
const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe(
Layer.provide(
- Layer.mergeAll(AzureDevOpsCli.layer, BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer),
+ Layer.mergeAll(
+ AzureDevOpsCli.layer,
+ BitbucketApi.layer,
+ GiteaCli.layer,
+ GitHubCli.layer,
+ GitLabCli.layer,
+ ),
),
Layer.provideMerge(GitVcsDriver.layer),
Layer.provideMerge(VcsDriverRegistryLayerLive),
diff --git a/apps/server/src/sourceControl/GiteaCli.test.ts b/apps/server/src/sourceControl/GiteaCli.test.ts
new file mode 100644
index 000000000000..eda7c660c014
--- /dev/null
+++ b/apps/server/src/sourceControl/GiteaCli.test.ts
@@ -0,0 +1,1096 @@
+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 {
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
+ 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..fae415609d1f
--- /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.TaggedErrorClass()(
+ "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.TaggedErrorClass()(
+ "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.TaggedErrorClass()(
+ "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.TaggedErrorClass()(
+ "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.TaggedErrorClass()(
+ "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.TaggedErrorClass()(
+ "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.TaggedErrorClass()(
+ "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.TaggedErrorClass()(
+ "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..8fbb164edbae
--- /dev/null
+++ b/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts
@@ -0,0 +1,376 @@
+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 {
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
+ 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 9e4702af04cd..7d816aca5210 100644
--- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts
+++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts
@@ -12,6 +12,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 SourceControlDiscovery from "./SourceControlDiscovery.ts";
import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
@@ -30,6 +31,7 @@ const sourceControlProviderRegistryTestLayer = (input: {
Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
Layer.mock(GitHubCli.GitHubCli)({}),
Layer.mock(GitLabCli.GitLabCli)({}),
+ Layer.mock(GiteaCli.GiteaCli)({}),
Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
Layer.mock(VcsProcess.VcsProcess)(input.process),
),
@@ -161,6 +163,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(),
+ },
],
);
const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket");
@@ -208,6 +216,22 @@ Logged in to gitlab.com as gitlab-user
) {
return Effect.succeed(processOutput("azure-user@example.com\n"));
}
+ if (input.command === "tea" && input.args.join(" ") === "logins list --output json") {
+ // Shape captured from tea 0.15.1: `default` is a string, and no token is included.
+ return Effect.succeed(
+ processOutput(
+ JSON.stringify([
+ {
+ name: "self-hosted",
+ url: "https://git.example.com",
+ ssh_host: "git.example.com",
+ user: "gitea-user",
+ default: "true",
+ },
+ ]),
+ ),
+ );
+ }
return Effect.fail(
new VcsProcessSpawnError({
operation: input.operation,
@@ -277,6 +301,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(),
+ },
],
);
}).pipe(Effect.provide(testLayer));
diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts
index 54038502bfde..d55aec4eb640 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 SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
@@ -92,6 +93,7 @@ function makeRegistry(input: {
Layer.mock(BitbucketApi.BitbucketApi)({}),
Layer.mock(GitHubCli.GitHubCli)({}),
Layer.mock(GitLabCli.GitLabCli)({}),
+ Layer.mock(GiteaCli.GiteaCli)({}),
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-source-control-registry-test-",
}).pipe(Layer.provide(NodeServices.layer)),
@@ -293,3 +295,103 @@ it.effect("falls back to a non-origin remote when origin is not configured", ()
assert.strictEqual(provider.kind, "azure-devops");
}),
);
+
+/**
+ * `tea logins list --output json` output for a mock. Keyed on the command so the GitLab spec, which
+ * also refines unknown remotes, is never handed Gitea's JSON.
+ */
+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 when tea is authenticated for it", () =>
+ Effect.gen(function* () {
+ const registry = yield* makeRegistry({
+ // Nothing in this hostname says "gitea"; only tea's login list can identify it.
+ 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("refines a self-hosted Gitea remote whose SSH and HTTPS ports differ", () =>
+ Effect.gen(function* () {
+ const registry = yield* makeRegistry({
+ remotes: [{ name: "origin", url: "https://code.home.internal:3000/team/project.git" }],
+ process: teaLoginsProcess([
+ {
+ name: "home",
+ url: "https://code.home.internal:3000",
+ ssh_host: "code.home.internal",
+ 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 login for that host", () =>
+ 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");
+ }),
+);
+
+it.effect("leaves a remote unknown when tea reports no logins at all", () =>
+ Effect.gen(function* () {
+ const registry = yield* makeRegistry({
+ remotes: [{ name: "origin", url: "git@git.example.com:owner/repo.git" }],
+ process: teaLoginsProcess([]),
+ });
+
+ 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 9fe089a4184c..6b2cb60dd4a0 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 SourceControlProvider from "./SourceControlProvider.ts";
@@ -298,6 +299,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",
@@ -319,6 +321,11 @@ export const make = Effect.gen(function* () {
provider: bitbucket,
discovery: bitbucketDiscovery,
},
+ {
+ kind: "gitea",
+ provider: gitea,
+ discovery: GiteaSourceControlProvider.discovery,
+ },
]);
});
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 bd3e5b4cdce2..2396c535610c 100644
--- a/apps/server/src/vcs/VcsProcess.test.ts
+++ b/apps/server/src/vcs/VcsProcess.test.ts
@@ -4,6 +4,7 @@ import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
+import { ChildProcessSpawner } from "effect/unstable/process";
import { TestClock } from "effect/testing";
import {
@@ -34,13 +35,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,
);
@@ -140,6 +142,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 ec245fa13604..c96f992b081b 100644
--- a/apps/server/src/vcs/VcsProcess.ts
+++ b/apps/server/src/vcs/VcsProcess.ts
@@ -64,7 +64,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 11c659e28a70..706d779f54b0 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -118,6 +118,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 SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
@@ -2438,6 +2439,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
Layer.mergeAll(
AzureDevOpsCli.layer,
BitbucketApi.layer,
+ GiteaCli.layer,
GitHubCli.layer,
GitLabCli.layer,
),
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index c5ec3f095167..222d220f75d1 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -8,6 +8,9 @@ import {
getCloneDirectoryName,
getDefaultCloneUrl,
normalizePastedCloneUrl,
+ type AddProjectCloneFlow,
+ type AddProjectRemoteProviderKind,
+ type AddProjectRemoteSource,
} from "@t3tools/client-runtime/operations/projects";
import { connectionStatusText } from "@t3tools/client-runtime/connection";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
@@ -28,8 +31,6 @@ import {
type FilesystemBrowseResult,
type ProjectId,
type SourceControlDiscoveryResult,
- type SourceControlProviderKind,
- type SourceControlRepositoryInfo,
PRIMARY_LOCAL_ENVIRONMENT_ID,
} from "@t3tools/contracts";
import { useNavigate, useParams } from "@tanstack/react-router";
@@ -40,6 +41,7 @@ import {
FileSearchIcon,
FolderIcon,
FolderPlusIcon,
+ GitPullRequestIcon,
LinkIcon,
MessageSquareIcon,
PaletteIcon,
@@ -202,39 +204,20 @@ interface AddProjectEnvironmentOption {
readonly status: string;
}
-type AddProjectRemoteProviderKind = Extract<
- SourceControlProviderKind,
- "github" | "gitlab" | "bitbucket" | "azure-devops"
->;
-type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
-
-type AddProjectCloneFlow =
- | {
- readonly step: "repository";
- readonly environmentId: EnvironmentId;
- readonly source: AddProjectRemoteSource;
- }
- | {
- readonly step: "confirm";
- readonly environmentId: EnvironmentId;
- readonly source: AddProjectRemoteSource;
- readonly repositoryInput: string;
- readonly repository: SourceControlRepositoryInfo | null;
- readonly remoteUrl: string;
- };
-
const REMOTE_PROJECT_SOURCES: ReadonlyArray = [
"url",
"github",
"gitlab",
"bitbucket",
"azure-devops",
+ "gitea",
];
const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [
"github",
"gitlab",
"bitbucket",
"azure-devops",
+ "gitea",
];
function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {
@@ -247,6 +230,8 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {
return "Bitbucket";
case "azure-devops":
return "Azure DevOps";
+ case "gitea":
+ return "Gitea";
case "url":
return "Git URL";
}
@@ -262,6 +247,8 @@ function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string {
return "workspace/repository";
case "azure-devops":
return "project/repository";
+ case "gitea":
+ return "owner/repository";
case "url":
return "URL";
}
@@ -283,6 +270,8 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri
return ;
case "azure-devops":
return ;
+ case "gitea":
+ return ;
case "url":
return ;
}
@@ -332,6 +321,7 @@ function buildAddProjectRemoteSourceReadiness(
gitlab: unavailable,
bitbucket: unavailable,
"azure-devops": unavailable,
+ gitea: unavailable,
};
if (!discovery) {
@@ -1569,6 +1559,7 @@ function OpenCommandPaletteDialog(props: {
"bitbucket",
"azure",
"devops",
+ "gitea",
"url",
"environment",
],
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 d448a720ebfc..2cc2b90c64d8 100644
--- a/apps/web/src/components/GitActionsControl.tsx
+++ b/apps/web/src/components/GitActionsControl.tsx
@@ -27,6 +27,7 @@ import {
ExternalLinkIcon,
GitBranchPlusIcon,
GitCommitIcon,
+ GitPullRequestIcon,
InfoIcon,
LockIcon,
GlobeIcon,
@@ -48,6 +49,7 @@ import {
resolveLiveThreadBranchUpdate,
resolveThreadBranchMetadataPatch,
resolveQuickAction,
+ resolvePublishHost,
resolveThreadBranchUpdate,
} from "./GitActionsControl.logic";
import { AnimatedHeight } from "./AnimatedHeight";
@@ -115,7 +117,7 @@ interface PendingDefaultBranchAction {
type PublishProviderKind = Extract<
SourceControlProviderKind,
- "github" | "gitlab" | "bitbucket" | "azure-devops"
+ "github" | "gitlab" | "bitbucket" | "azure-devops" | "gitea"
>;
type GitActionToastId = ReturnType;
@@ -195,11 +197,20 @@ const PUBLISH_PROVIDER_OPTIONS = [
pathPlaceholder: "project/repository",
Icon: AzureDevOpsIcon,
},
+ {
+ value: "gitea",
+ // A self-hosted Gitea has no canonical host, so the real one is read from discovery below.
+ label: "Gitea",
+ description: "Your authenticated instance",
+ host: null,
+ pathPlaceholder: "owner/repository",
+ Icon: GitPullRequestIcon,
+ },
] as const satisfies ReadonlyArray<{
readonly value: PublishProviderKind;
readonly label: string;
readonly description: string;
- readonly host: string;
+ readonly host: string | null;
readonly pathPlaceholder: string;
readonly Icon: typeof GitHubIcon;
}>;
@@ -416,6 +427,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
gitlab: null,
bitbucket: null,
"azure-devops": null,
+ gitea: null,
};
for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) {
if (isPublishProviderKind(provider.kind)) {
@@ -424,6 +436,16 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
}
return accounts;
}, [sourceControlDiscovery.data]);
+ const publishHostByProvider = useMemo(() => {
+ const hosts: Partial> = {};
+ for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) {
+ const host = Option.getOrNull(provider.auth.host);
+ if (isPublishProviderKind(provider.kind) && host) {
+ hosts[provider.kind] = host;
+ }
+ }
+ return hosts;
+ }, [sourceControlDiscovery.data]);
const publishProviderReadiness = useMemo(() => {
const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? [];
return Object.fromEntries(
@@ -465,7 +487,10 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
: "";
const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill;
const currentPublishProvider = publishProviderOption(publishProvider);
- const publishHost = currentPublishProvider.host;
+ const publishHost = resolvePublishHost({
+ discoveredHost: publishHostByProvider[publishProvider],
+ fallbackHost: currentPublishProvider.host,
+ });
const publishPathPlaceholder = currentPublishProvider.pathPlaceholder;
const publishProviderLabel = currentPublishProvider.label;
const publishWizardSteps = ["Provider", "Repository", "Summary"] as const;
@@ -701,9 +726,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
Repository
-
+
- {publishHost}/
+ {publishHost === null ? currentPublishProvider.label : `${publishHost}/`}
> = {
gitlab: "Open on GitLab",
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 a43c116467ed..fe0978ea65bd 100644
--- a/apps/web/src/components/settings/SourceControlSettings.tsx
+++ b/apps/web/src/components/settings/SourceControlSettings.tsx
@@ -61,6 +61,7 @@ import {
SettingsPageContainer,
SettingsSection,
} from "./settingsLayout";
+import { formattedAuthSuffix, formattedSetupGuidance } from "./SourceControlSettings.logic";
import { searchableSetting } from "./settingsSearch";
const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
@@ -73,6 +74,9 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = {
@@ -214,6 +218,7 @@ function itemSummary({
if (auth) {
if (auth.status === "authenticated") {
+ const suffix = formattedAuthSuffix(optionLabel(auth.host), optionLabel(auth.detail));
return (
<>
Authenticated
@@ -223,6 +228,7 @@ function itemSummary({
>
) : null}
+ {suffix ? {suffix} : null}
>
);
}
@@ -234,9 +240,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 edba97fa7d3d..24524281bfc1 100644
--- a/apps/web/src/lib/openPullRequestLink.test.ts
+++ b/apps/web/src/lib/openPullRequestLink.test.ts
@@ -228,3 +228,45 @@ describe("findProjectForChangeRequest", () => {
).toBeUndefined();
});
});
+
+describe("unsupported Gitea change request links", () => {
+ it("leaves Gitea PR URLs for the system browser until an in-app reader exists", () => {
+ expect(parseChangeRequestUrl("https://git.example.com/owner/repo/pulls/42")).toBeNull();
+ expect(parseChangeRequestUrl("https://gitea.com/foo/bar/pulls/1")).toBeNull();
+ });
+
+ 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,
+ });
+ expect(parseChangeRequestUrl("https://github.com/owner/repo/pulls/42")).toBeNull();
+ });
+
+ it("does not treat public GitHub or Bitbucket /pulls/ paths as change requests", () => {
+ expect(parseChangeRequestUrl("https://github.com/owner/repo/pulls/42")).toBeNull();
+ expect(parseChangeRequestUrl("https://bitbucket.org/owner/repo/pulls/42")).toBeNull();
+ });
+
+ 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 c8ec1b7a628c..c29dac8d7409 100644
--- a/apps/web/src/lib/openPullRequestLink.ts
+++ b/apps/web/src/lib/openPullRequestLink.ts
@@ -98,7 +98,7 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
// GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n}
if (isHostOf(host, "github.com", "github")) {
const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname);
- return claim(host, match);
+ if (match) return claim(host, match);
}
// GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/`
// separator is GitLab's own, so the hostname is not asked about.
@@ -107,7 +107,7 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
// Bitbucket Cloud: /{workspace}/{repo}/pull-requests/{n}
if (isHostOf(host, "bitbucket.org", "bitbucket")) {
const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname);
- return claim(host, match);
+ if (match) return claim(host, match);
}
// Azure DevOps, both the current host and the per-organisation one it replaced. `_git` is part
// of the repository path there, as it is in the remote URL the identity is read from.
@@ -115,6 +115,8 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname);
return claim(host, match);
}
+ // Gitea's `/pulls/{n}` shape is intentionally not claimed here. The in-app pull-request
+ // registry does not have a Gitea reader yet, so those links must remain ordinary external links.
return null;
}
@@ -125,7 +127,7 @@ export function changeRequestRepositoryUrl(targetUrl: string): string | null {
const url = new URL(targetUrl);
const repositoryPath =
/^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ??
- /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec(
+ /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+|\/pulls\/\d+)(?:\/|$)/iu.exec(
url.pathname,
)?.[1];
if (!repositoryPath) return 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 b919e736cc09..d699268680d4 100644
--- a/apps/web/src/pullRequestReference.ts
+++ b/apps/web/src/pullRequestReference.ts
@@ -4,10 +4,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);
@@ -31,12 +34,14 @@ 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 =
ghCliCheckoutMatch?.[1]?.trim() ??
glabCliCheckoutMatch?.[1]?.trim() ??
(azureDevOpsCliCheckoutMatch?.[1]
? parseAzureDevOpsCheckoutReference(azureDevOpsCliCheckoutMatch[1])
: null) ??
+ teaCheckoutMatch?.[1]?.trim() ??
trimmed;
if (normalizedInput.length === 0) {
return null;
@@ -45,7 +50,8 @@ export function parsePullRequestReference(input: string): string | null {
const urlMatch =
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 116f27b95f97..be858d3cd2a7 100644
--- a/apps/web/src/sourceControlPresentation.ts
+++ b/apps/web/src/sourceControlPresentation.ts
@@ -52,6 +52,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 297ae5717df1..33e711bc3cfd 100644
--- a/apps/web/src/state/sourceControlActions.ts
+++ b/apps/web/src/state/sourceControlActions.ts
@@ -267,7 +267,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi
);
const action = useCallback(
async (input: {
- provider: "github" | "gitlab" | "bitbucket" | "azure-devops";
+ provider: "github" | "gitlab" | "bitbucket" | "azure-devops" | "gitea";
repository: string;
visibility: SourceControlRepositoryVisibility;
remoteName: string;
diff --git a/docs/user/source-control.md b/docs/user/source-control.md
index 916536bbe736..138697ce7fa6 100644
--- a/docs/user/source-control.md
+++ b/docs/user/source-control.md
@@ -10,6 +10,7 @@ T3 Code works with the platforms your team already uses:
- **GitLab** – Merge requests, repository publishing, and hosted clones
- **Bitbucket** – Pull request workflows (via API token authentication)
- **Azure DevOps** – Pull request support for Microsoft-hosted repositories
+- **Gitea** – Pull requests, repository publishing, and clones, including self-hosted instances
## What You Can Do
@@ -18,13 +19,14 @@ T3 Code works with the platforms your team already uses:
**Clone repositories directly**
- Open the Command Palette (`Cmd/Ctrl + K`) → **Add Project**
-- Choose **GitHub repository**, **GitLab repository**, **Bitbucket repository**, **Azure DevOps repository**, or paste any **Git URL**
+- Choose **GitHub repository**, **GitLab repository**, **Bitbucket repository**, **Azure DevOps repository**, **Gitea repository**, or paste any **Git URL**
- Enter the repository path (`owner/repo`, `group/project`, `workspace/repository`, or `project/repository`) or a full Git URL, pick a destination, and start coding
+- For Gitea, a short `owner/repository` path resolves against your default `tea` login. To clone from a different Gitea instance, paste its full Git URL
**Publish local projects to the cloud**
- Have a local Git repository without a remote?
-- Use the **Publish Repository** action to create a new hosted repository (GitHub, GitLab, Bitbucket, or Azure DevOps), add it as your origin remote, and push, in one flow
+- Use the **Publish Repository** action to create a new hosted repository (GitHub, GitLab, Bitbucket, Azure DevOps, or Gitea), add it as your origin remote, and push, in one flow
- If the local repository has no commits yet, publishing creates the remote and wires it up but does not push. Make a commit, then push normally.
### Manage Code Reviews Without Context Switching
@@ -33,7 +35,7 @@ T3 Code works with the platforms your team already uses:
- Push a branch and create a pull request from the Git actions controls in the toolbar
- T3 Code can suggest titles and descriptions based on your commits
-- Supports GitHub Pull Requests, GitLab Merge Requests, Bitbucket Pull Requests, and Azure DevOps Pull Requests
+- Supports GitHub Pull Requests, GitLab Merge Requests, Bitbucket Pull Requests, Azure DevOps Pull Requests, and Gitea Pull Requests
**Stay on top of open reviews**
@@ -91,6 +93,26 @@ You can now clone, publish, and create pull requests.
```
3. Check **Settings → Source Control** to confirm the connection
+### For Gitea
+
+Works with gitea.com and with self-hosted instances on any hostname.
+
+1. Install the Gitea CLI:
+ ```bash
+ brew install tea
+ ```
+2. Authenticate against your instance:
+ ```bash
+ tea login add
+ ```
+ You will be asked for the instance URL and a token. Repeat this for each Gitea instance you use.
+3. Check **Settings → Source Control** to confirm the connection
+
+Because a self-hosted Gitea can live on any hostname, T3 Code identifies your instance from the
+logins `tea` already holds. A remote is only treated as Gitea when `tea` is authenticated against
+that exact host, so unrelated Git remotes are never misidentified, and T3 Code never probes unknown
+hosts over the network to find out.
+
### For Bitbucket
Bitbucket uses tokens instead of a CLI tool. Two options, both set as environment variables on the
@@ -142,6 +164,7 @@ Control settings**.
- **Provider shows "Not authenticated"** – Run the login command for that provider (e.g., `gh auth login`) in a terminal on the server, then rescan in Settings
- **GitHub says it could not verify sign-in status** – T3 Code needs GitHub CLI 2.81.0 or newer to check sign-in status. Update `gh` (e.g., `brew upgrade gh`), then rescan
- **Bitbucket not connecting** – Double-check your environment variables are set in the correct shell profile and the server was restarted
+- **Gitea repository not recognized** – Run `tea logins list` on the server and confirm a login exists for that exact hostname. T3 Code matches the remote host against your `tea` logins, ignoring the port, so an instance reached over HTTPS on one port and SSH on another still matches
- **Can't push to a remote** – Verify your Git remote URL matches the provider you've authenticated with (SSH vs HTTPS remotes may need different credentials)
**Need more help?** Check your provider's CLI documentation:
@@ -149,3 +172,4 @@ Control settings**.
- [GitHub CLI](https://cli.github.com/)
- [GitLab CLI](https://gitlab.com/gitlab-org/cli)
- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/)
+- [Gitea CLI (tea)](https://gitea.com/gitea/tea)
diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts
index 3f5fc3667f31..b18214093253 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" | "bitbucket" | "azure-devops"
+ "github" | "gitlab" | "bitbucket" | "azure-devops" | "gitea"
>;
export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
@@ -61,6 +61,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [
"gitlab",
"bitbucket",
"azure-devops",
+ "gitea",
];
const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [
@@ -68,6 +69,7 @@ const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray {
+ it("round-trips every supported provider kind, including gitea", () => {
+ for (const kind of ["github", "gitlab", "azure-devops", "bitbucket", "gitea", "unknown"]) {
+ expect(encodeKind(decodeKind(kind))).toBe(kind);
+ }
+ });
+
+ it("still rejects hosts this build does not support", () => {
+ expect(() => decodeKind("forgejo")).toThrow();
+ 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 104aadd9161f..d77200b439cf 100644
--- a/packages/contracts/src/sourceControl.ts
+++ b/packages/contracts/src/sourceControl.ts
@@ -7,6 +7,7 @@ export const SourceControlProviderKind = Schema.Literals([
"gitlab",
"azure-devops",
"bitbucket",
+ "gitea",
"unknown",
]);
export type SourceControlProviderKind = typeof SourceControlProviderKind.Type;
diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts
index 86b1ba5912bd..292e2cba36b0 100644
--- a/packages/shared/src/sourceControl.test.ts
+++ b/packages/shared/src/sourceControl.test.ts
@@ -28,6 +28,10 @@ describe("source control presentation", () => {
shortLabel: "PR",
singular: "pull request",
});
+ expect(getChangeRequestTerminologyForKind("gitea")).toEqual({
+ shortLabel: "PR",
+ singular: "pull request",
+ });
});
it("falls back to generic change request copy for unknown providers", () => {
@@ -56,6 +60,9 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {
expect(
detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind,
).toBe("bitbucket");
+ expect(detectSourceControlProviderFromRemoteUrl("https://gitea.com/owner/repo.git")?.kind).toBe(
+ "gitea",
+ );
});
it("detects Azure DevOps SSH remotes", () => {
@@ -104,6 +111,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", () => {
@@ -120,6 +130,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@)", () => {
@@ -136,6 +149,9 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {
expect(
detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind,
).toBe("bitbucket");
+ expect(detectSourceControlProviderFromRemoteUrl("https://gitea.com/owner/repo.git")?.kind).toBe(
+ "gitea",
+ );
});
});
@@ -160,3 +176,57 @@ describe("isSshRemoteUrl", () => {
expect(isSshRemoteUrl("deploy@github.com/project/repo")).toBe(false);
});
});
+
+describe("Gitea remote detection", () => {
+ it("names gitea.com and self-hosted installations distinctly", () => {
+ expect(detectSourceControlProviderFromRemoteUrl("https://gitea.com/owner/repo.git")).toEqual({
+ kind: "gitea",
+ name: "Gitea",
+ baseUrl: "https://gitea.com",
+ });
+ expect(
+ detectSourceControlProviderFromRemoteUrl("https://gitea.example.com/owner/repo.git"),
+ ).toEqual({
+ kind: "gitea",
+ name: "Gitea Self-Hosted",
+ baseUrl: "https://gitea.example.com",
+ });
+ });
+
+ it("detects gitea.com across HTTPS, SCP-style, and ssh:// remotes", () => {
+ for (const remote of [
+ "https://gitea.com/owner/repo.git",
+ "git@gitea.com:owner/repo.git",
+ "ssh://git@gitea.com/owner/repo.git",
+ ]) {
+ expect(detectSourceControlProviderFromRemoteUrl(remote)?.kind).toBe("gitea");
+ }
+ });
+
+ it("normalizes case and preserves explicit ports", () => {
+ expect(detectSourceControlProviderFromRemoteUrl("https://GITEA.example.com/o/r.git")).toEqual({
+ kind: "gitea",
+ name: "Gitea Self-Hosted",
+ baseUrl: "https://gitea.example.com",
+ });
+ expect(
+ detectSourceControlProviderFromRemoteUrl("https://gitea.example.com:3000/o/r.git"),
+ ).toEqual({
+ kind: "gitea",
+ name: "Gitea Self-Hosted",
+ baseUrl: "https://gitea.example.com:3000",
+ });
+ });
+
+ // Gitea is usually self-hosted on a hostname that says nothing about it. The static detector must
+ // leave those alone; GiteaSourceControlProvider refines them from `tea`'s authenticated logins.
+ it("leaves arbitrary self-hosted hostnames unknown for tea-based refinement", () => {
+ for (const remote of [
+ "git@git.example.com:owner/repo.git",
+ "https://code.home.internal/team/project.git",
+ "https://192.168.1.10:3000/team/project.git",
+ ]) {
+ expect(detectSourceControlProviderFromRemoteUrl(remote)?.kind).toBe("unknown");
+ }
+ });
+});
diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts
index df88de595a3f..9d6131e3fefc 100644
--- a/packages/shared/src/sourceControl.ts
+++ b/packages/shared/src/sourceControl.ts
@@ -1,7 +1,7 @@
import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts";
export interface ChangeRequestPresentation {
- readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request";
+ readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "gitea" | "change-request";
readonly providerName: string;
readonly shortName: string;
readonly longName: string;
@@ -64,6 +64,17 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
urlExample: "https://bitbucket.org/workspace/repo/pull-requests/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 GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
icon: "change-request",
providerName: "source control",
@@ -87,6 +98,8 @@ export function resolveChangeRequestPresentation(
return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION;
case "bitbucket":
return BITBUCKET_CHANGE_REQUEST_PRESENTATION;
+ case "gitea":
+ return GITEA_CHANGE_REQUEST_PRESENTATION;
case "unknown":
return GENERIC_CHANGE_REQUEST_PRESENTATION;
}
@@ -198,6 +211,13 @@ function isBitbucketHost(host: string): boolean {
return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket");
}
+// Only the obvious installations. Gitea is overwhelmingly self-hosted on hostnames that carry no
+// hint of it, so the rest are recognized by GiteaSourceControlProvider's unknown-remote refinement,
+// which asks `tea` whether the host is one of its authenticated logins.
+function isGiteaHost(host: string): boolean {
+ return host === "gitea.com" || hasDnsLabel(host, "gitea");
+}
+
export function detectSourceControlProviderFromRemoteUrl(
remoteUrl: string,
): SourceControlProviderInfo | null {
@@ -239,6 +259,14 @@ export function detectSourceControlProviderFromRemoteUrl(
};
}
+ if (isGiteaHost(hostname)) {
+ return {
+ kind: "gitea",
+ name: hostname === "gitea.com" ? "Gitea" : "Gitea Self-Hosted",
+ baseUrl: toBaseUrl(host),
+ };
+ }
+
return {
kind: "unknown",
name: host,