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,