From 818bdab66130193342a590d0ff50b3bdbbb75638 Mon Sep 17 00:00:00 2001 From: mario Date: Mon, 24 Aug 2026 03:36:20 +0200 Subject: [PATCH 01/15] feat(contracts): recognize gitea as a source control provider kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `gitea` to `SourceControlProviderKind` and gives it provider-neutral presentation metadata (PR / pull request terminology, `tea pulls checkout` example). Static remote detection matches only obvious installations — `gitea.com` and hosts carrying a `gitea` DNS label — because Gitea is usually self-hosted on a hostname that says nothing about it. Arbitrary hosts stay `unknown` here and are refined later from `tea`'s authenticated logins. No provider is registered yet, so behavior is unchanged for every existing host. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/sourceControlPresentation.ts | 3 + packages/contracts/src/sourceControl.test.ts | 81 ++++++++++++++++++++ packages/contracts/src/sourceControl.ts | 1 + packages/shared/src/sourceControl.test.ts | 70 +++++++++++++++++ packages/shared/src/sourceControl.ts | 30 +++++++- 5 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 packages/contracts/src/sourceControl.test.ts 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/packages/contracts/src/sourceControl.test.ts b/packages/contracts/src/sourceControl.test.ts new file mode 100644 index 000000000000..454776943a22 --- /dev/null +++ b/packages/contracts/src/sourceControl.test.ts @@ -0,0 +1,81 @@ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + ChangeRequest, + SourceControlProviderDiscoveryItem, + SourceControlProviderInfo, + SourceControlProviderKind, +} from "./sourceControl.ts"; + +const decodeKind = Schema.decodeUnknownSync(SourceControlProviderKind); +const encodeKind = Schema.encodeSync(SourceControlProviderKind); +const decodeProviderInfo = Schema.decodeUnknownSync(SourceControlProviderInfo); +const decodeChangeRequest = Schema.decodeUnknownSync(ChangeRequest); +const decodeDiscoveryItem = Schema.decodeUnknownSync(SourceControlProviderDiscoveryItem); + +describe("SourceControlProviderKind", () => { + it("round-trips every supported provider kind, including gitea", () => { + for (const kind of ["github", "gitlab", "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, From 1b36a9ca4aa69a727ba8b8e0931a7f662cccff35 Mon Sep 17 00:00:00 2001 From: mario Date: Mon, 24 Aug 2026 03:37:51 +0200 Subject: [PATCH 02/15] feat(server): parse tea logins to identify authenticated Gitea instances `tea logins list --output json` is how the server learns which Gitea instances it can talk to. This parses that output into a typed list and adds host lookup used both by the Source Control settings card and by unknown-remote refinement. Two details verified against tea 0.15.1 rather than assumed: - `default` is reported as the string "true"/"false", not a boolean. - No token appears in the output, so it is safe to parse and log around. Host matching compares hostnames with ports stripped, because a Gitea instance is routinely reached over HTTPS on one port and SSH on another; an SSH remote would otherwise never match its own login. Matching stays exact per DNS label, so a suffix like evil-git.example.com cannot impersonate git.example.com. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/sourceControl/giteaLogins.test.ts | 163 ++++++++++++++++++ apps/server/src/sourceControl/giteaLogins.ts | 117 +++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 apps/server/src/sourceControl/giteaLogins.test.ts create mode 100644 apps/server/src/sourceControl/giteaLogins.ts 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), + ); +} From 7ad9f64601f1c59a087425672b1a51e762bbfe6a Mon Sep 17 00:00:00 2001 From: mario Date: Mon, 24 Aug 2026 03:45:32 +0200 Subject: [PATCH 03/15] feat(server): add GiteaCli on top of tea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the official Gitea CLI for the operations SourceControlProvider needs, going through `tea api` rather than `tea pulls`. Two verified gaps forced that choice: `tea pulls list` cannot filter by head branch, and its --state accepts only all/open/closed, while T3 needs head filtering and a distinct merged state. `tea api` is the same escape hatch GitLabCli uses with `glab api`. The important detail is error handling. `tea api` exits 0 even for HTTP 401, 403, 404 and 429, so exit-code classification silently reports failures as success — a 404 would look like "no pull request exists" and T3 would open a duplicate. Every call therefore passes -i, which puts the status line on stderr and leaves clean JSON on stdout, and failures are classified from that status. Gitea has no head filter on its list endpoint, so pages are walked and matched locally, bounded to 5 pages of 50 and exiting early. The common case is one request. Merged is read off the `merged` flag, since Gitea models a merged PR as closed. PR bodies are passed as `-F body=@file`, which was verified to encode file contents as a JSON string even when they begin with `{`, keeping bodies out of argv. Co-Authored-By: Claude Opus 5 (1M context) --- .../server/src/sourceControl/GiteaCli.test.ts | 716 +++++++++++++++++ apps/server/src/sourceControl/GiteaCli.ts | 759 ++++++++++++++++++ .../src/sourceControl/giteaPullRequests.ts | 153 ++++ 3 files changed, 1628 insertions(+) create mode 100644 apps/server/src/sourceControl/GiteaCli.test.ts create mode 100644 apps/server/src/sourceControl/GiteaCli.ts create mode 100644 apps/server/src/sourceControl/giteaPullRequests.ts diff --git a/apps/server/src/sourceControl/GiteaCli.test.ts b/apps/server/src/sourceControl/GiteaCli.test.ts new file mode 100644 index 000000000000..7ac44ad15701 --- /dev/null +++ b/apps/server/src/sourceControl/GiteaCli.test.ts @@ -0,0 +1,716 @@ +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&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&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 a repository under an organization when an owner is given", () => + Effect.gen(function* () { + 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(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(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.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"); + }), + ); +}); diff --git a/apps/server/src/sourceControl/GiteaCli.ts b/apps/server/src/sourceControl/GiteaCli.ts new file mode 100644 index 000000000000..e467ca9e5ac2 --- /dev/null +++ b/apps/server/src/sourceControl/GiteaCli.ts @@ -0,0 +1,759 @@ +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 = { + operation: Schema.Literal("execute"), + command: Schema.Literal("tea"), + cwd: Schema.String, + cause: Schema.Defect(), +}; + +const giteaCliDecodeErrorContext = { + command: Schema.Literal("tea"), + cwd: Schema.String, + cause: Schema.Defect(), +}; + +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 in ${this.operation}: ${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 in ${this.operation}: ${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 in ${this.operation}: ${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 in ${this.operation}: ${this.detail}`; + } + + static fromVcsError( + context: { + readonly operation: "execute"; + 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( + { operation: context.operation, 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 in ${this.operation}: ${this.detail}`; + } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "tea"; + readonly cwd: string; + }, + error: VcsError, + ): GiteaCliError { + return Match.valueTags(error, { + VcsProcessSpawnError: (cause) => new GiteaCliUnavailableError({ ...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, + operation: Schema.Literal("listPullRequests"), + }, +) { + get detail(): string { + return "Gitea CLI returned invalid pull request list JSON."; + } + + override get message(): string { + return `Gitea CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GiteaPullRequestDecodeError extends Schema.TaggedErrorClass()( + "GiteaPullRequestDecodeError", + { + ...giteaCliDecodeErrorContext, + operation: Schema.Literals(["getPullRequest", "createPullRequest"]), + reference: Schema.String, + }, +) { + get detail(): string { + return "Gitea CLI returned invalid pull request JSON."; + } + + override get message(): string { + return `Gitea CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GiteaRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GiteaRepositoryDecodeError", + { + ...giteaCliDecodeErrorContext, + operation: Schema.Literals(["getRepositoryCloneUrls", "createRepository", "getDefaultBranch"]), + repository: Schema.optional(Schema.String), + }, +) { + get detail(): string { + return "Gitea CLI returned invalid repository JSON."; + } + + override get message(): string { + return `Gitea CLI failed in ${this.operation}: ${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, +}); + +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), +); + +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; +} + +/** Carries only the status code, never response bodies or headers that could hold credentials. */ +function httpStatusCause(status: number): Error { + return new Error(`Gitea API responded with HTTP ${status}.`); +} + +function httpStatusFailure( + status: number, + context: { readonly cwd: string; readonly reference?: string }, +): GiteaCliError { + const base = { operation: "execute", command: "tea", cwd: context.cwd } as const; + const cause = httpStatusCause(status); + + if (status === 401 || status === 403) { + return new GiteaCliAuthenticationError({ ...base, cause }); + } + if (status === 429) { + return new GiteaCliRateLimitError({ ...base, cause }); + } + if (status === 404 && context.reference !== undefined) { + return new GiteaPullRequestNotFoundError({ ...base, reference: context.reference, cause }); + } + return new GiteaCliCommandError({ ...base, cause }); +} + +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; + + return { index, repository: `${decodeURIComponent(owner)}/${decodeURIComponent(repo)}` }; +} + +/** 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( + { operation: "execute", 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)}&limit=${LIST_PAGE_SIZE}&page=${input.page}`, + ], + }).pipe( + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([] as ReadonlyArray) + : Effect.sync(() => decodeGiteaPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => + Result.isSuccess(decoded) + ? Effect.succeed(decoded.success.map(toSummaryWithOptionalUpdatedAt)) + : Effect.fail( + new GiteaPullRequestListDecodeError({ + operation: "listPullRequests", + 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 matches: Array = []; + + for (let page = 1; page <= MAX_LIST_PAGES; page += 1) { + const entries = yield* listPage({ cwd: input.cwd, state: input.state, page }); + + for (const entry of entries) { + if (entry.headRefName === headRefName && matchesRequestedState(entry, input.state)) { + matches.push(entry); + } + } + + if (matches.length >= wanted || entries.length < 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({ + operation: "execute", + command: "tea", + cwd: input.cwd, + reference: input.reference, + cause: new Error("Reference is neither a pull request number nor a Gitea PR URL."), + }), + ); + } + + 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({ + operation: "getPullRequest", + 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); + // An explicit owner creates under that organization; otherwise the repo lands on the + // authenticated user, matching how the other providers read `owner/name`. + const endpoint = owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`; + + return api({ + cwd: input.cwd, + args: [ + "-X", + "POST", + endpoint, + "-f", + `name=${name}`, + "-F", + `private=${input.visibility === "private"}`, + ], + }).pipe( + 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, + reference: input.headSelector, + 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) => + run( + { + cwd: input.cwd, + args: [ + "pulls", + "checkout", + parseGiteaPullRequestReference(input.reference)?.index ?? input.reference, + "--branch", + ], + }, + (error) => + GiteaPullRequestNotFoundError.fromVcsError( + { + operation: "execute", + 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/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); +} From 3a1c22b4bcb24f9f1e1f8a2c48a329afdbda6dd5 Mon Sep 17 00:00:00 2001 From: mario Date: Mon, 24 Aug 2026 03:50:25 +0200 Subject: [PATCH 04/15] feat(server): register Gitea as a source control provider Adds GiteaSourceControlProvider and wires it into the registry, so a Gitea remote now resolves to a real provider instead of the `unknown` stub whose every method fails. Detection works in two stages. Obvious hosts are matched statically, and any other host stays `unknown` until discovery asks `tea logins list` whether it is an instance the server is authenticated against. That keeps arbitrary Git hosts untouched and avoids probing unknown remotes over the network. Host comparison ignores ports, since a Gitea instance is commonly reached over HTTPS and SSH on different ones. The settings card reports the default `tea` login; additional instances are named in the detail rather than dropped, since the discovery contract holds a single account but refinement still consults every login. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/server.ts | 9 +- .../GiteaSourceControlProvider.test.ts | 356 ++++++++++++++++++ .../GiteaSourceControlProvider.ts | 265 +++++++++++++ .../SourceControlDiscovery.test.ts | 30 ++ .../SourceControlProviderRegistry.test.ts | 102 +++++ .../SourceControlProviderRegistry.ts | 7 + apps/server/src/ws.ts | 2 + 7 files changed, 770 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts create mode 100644 apps/server/src/sourceControl/GiteaSourceControlProvider.ts 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/GiteaSourceControlProvider.test.ts b/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts new file mode 100644 index 000000000000..4922879a6cf5 --- /dev/null +++ b/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts @@ -0,0 +1,356 @@ +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({ + operation: "execute", + 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({ + operation: "execute", + 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); +}); diff --git a/apps/server/src/sourceControl/GiteaSourceControlProvider.ts b/apps/server/src/sourceControl/GiteaSourceControlProvider.ts new file mode 100644 index 000000000000..3da6ff77162e --- /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) { + 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/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, ), From 1a13b05a523ff86e3ce807ada964387edd5f44be Mon Sep 17 00:00:00 2001 From: mario Date: Mon, 24 Aug 2026 03:53:24 +0200 Subject: [PATCH 05/15] test(server): cover the commit, push and create PR flow against Gitea Drives the whole stacked action through the real Gitea provider, which is the workflow the original report failed on: a Gitea remote resolved to `unknown`, whose stub failed every call, so the action died with "No unknown source control provider is registered." Also covers the duplicate case. GitManager looks for an existing PR before creating one, and swallowing a provider error there would open a second PR, so the test asserts create is never called when one is already open. makeManager gained an optional sourceControlProvider override; it defaults to the GitHub provider, so every existing test is unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/git/GitManager.test.ts | 119 ++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 4 deletions(-) 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); + }), + ); }); From c0ca96cee8a4225f0dfb0c44c484329b2843a1fb Mon Sep 17 00:00:00 2001 From: mario Date: Mon, 24 Aug 2026 03:55:07 +0200 Subject: [PATCH 06/15] feat(web): offer Gitea when adding and publishing repositories Adds Gitea to the Add Project source list and the publish provider options, and documents setup in the user guide. The mobile and web clients both draw Gitea with the neutral pull-request mark: no Gitea logo is bundled here yet, and borrowing another host's brand would be wrong. A real icon can drop in later without touching this wiring. One bounded limitation is documented rather than designed around: a short owner/repository path resolves against tea's default login, so cloning from a second Gitea instance needs a full Git URL. Representing per-instance selection would mean a new account-selection contract, which this change does not add. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/SourceControlIcon.tsx | 43 ++++++++++++++++++- docs/user/source-control.md | 30 +++++++++++-- .../client-runtime/src/operations/projects.ts | 10 ++++- 3 files changed, 77 insertions(+), 6 deletions(-) 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/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 Date: Tue, 25 Aug 2026 22:22:54 +0200 Subject: [PATCH 07/15] fix(server): create Gitea repositories under the right owner, and finish the UI surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pass over the Gitea provider. The important fix is repository creation. Gitea splits 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. Every owner was being sent to the orgs endpoint. Because the publish dialog prefills the signed-in account as the owner and refuses to submit without one, the ordinary input is `/name` — so publishing to Gitea would have failed for essentially every user. Creation now resolves the authenticated account first and picks the endpoint accordingly. The rest is reach. Gitea was in the Add Project list but missing from four surfaces that each keep their own provider enumeration: - The publish dialog had a separate PublishProviderKind that never included it, so the previous commit's message overstated what worked. Its host label reads the authenticated instance from discovery, since a self-hosted Gitea has no canonical host to hardcode. - CommandPalette kept private copies of the Add Project types, which is why it drifted. It now imports the canonical ones, which turned the gap into three compiler errors and one silent one: an icon switch returning ReactNode, whose missing arm type-checks fine and renders nothing. - Two mobile guards narrowed by string equality and dropped gitea, sending the Gitea entry to the URL flow. - The settings icon map and the PR link context menu are Partial records, so neither complained about the missing key. Gitea PR URLs (/{owner}/{repo}/pulls/{n}) are now recognized by the in-app link handler. The plural path is Gitea's own; the GitHub-ish hosts return earlier, so the singular /pull/ shape is untouched. `tea` reports an unconfigured instance as "no available login" and exits 1, which classified as a generic command failure. It is now an authentication failure, so the user is told to run `tea login add`. Deliberately not changed: PROVIDER_REQUIREMENT in the pullRequest contract. Gitea is not registered in the PR dashboard registry, so its reason is provider-unsupported, which returns null before that table is consulted. An entry would be dead code implying dashboard support that does not exist. Co-Authored-By: Claude Opus 5 (1M context) --- .../projects/AddProjectRepositoryRoute.tsx | 3 +- .../features/projects/AddProjectScreen.tsx | 3 +- .../server/src/sourceControl/GiteaCli.test.ts | 90 +++++++++++++++++++ apps/server/src/sourceControl/GiteaCli.ts | 70 +++++++++++---- apps/server/src/vcs/VcsProcess.ts | 5 +- apps/web/src/components/CommandPalette.tsx | 36 +++----- apps/web/src/components/GitActionsControl.tsx | 28 +++++- .../pullRequest/pullRequestLinkContextMenu.ts | 1 + .../settings/SourceControlSettings.tsx | 3 + apps/web/src/lib/openPullRequestLink.test.ts | 43 +++++++++ apps/web/src/lib/openPullRequestLink.ts | 8 +- apps/web/src/state/sourceControlActions.ts | 2 +- 12 files changed, 247 insertions(+), 45 deletions(-) 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/sourceControl/GiteaCli.test.ts b/apps/server/src/sourceControl/GiteaCli.test.ts index 7ac44ad15701..f4d63fed1649 100644 --- a/apps/server/src/sourceControl/GiteaCli.test.ts +++ b/apps/server/src/sourceControl/GiteaCli.test.ts @@ -476,8 +476,96 @@ layer("GiteaCli.layer", (it) => { }), ); + 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({ @@ -495,6 +583,8 @@ layer("GiteaCli.layer", (it) => { visibility: "public", }); + expect(mockedRun.mock.calls[0]?.[0].args).toEqual(["api", "-i", "user"]); + expect(lastArgs()).toEqual([ "api", "-i", diff --git a/apps/server/src/sourceControl/GiteaCli.ts b/apps/server/src/sourceControl/GiteaCli.ts index e467ca9e5ac2..bfad0f8406fc 100644 --- a/apps/server/src/sourceControl/GiteaCli.ts +++ b/apps/server/src/sourceControl/GiteaCli.ts @@ -304,6 +304,12 @@ const RawGiteaRepositorySchema = Schema.Struct({ 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)), }); @@ -312,6 +318,7 @@ const decodeGiteaRepository = Schema.decodeEffect(Schema.fromJsonString(RawGitea const decodeGiteaDefaultBranch = Schema.decodeEffect( Schema.fromJsonString(RawGiteaDefaultBranchSchema), ); +const decodeGiteaUser = Schema.decodeEffect(Schema.fromJsonString(RawGiteaUserSchema)); function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, @@ -659,22 +666,55 @@ export const make = Effect.gen(function* () { ), createRepository: (input) => { const { owner, name } = parseRepositoryPath(input.repository); - // An explicit owner creates under that organization; otherwise the repo lands on the - // authenticated user, matching how the other providers read `owner/name`. - const endpoint = owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`; - return api({ - cwd: input.cwd, - args: [ - "-X", - "POST", - endpoint, - "-f", - `name=${name}`, - "-F", - `private=${input.visibility === "private"}`, - ], - }).pipe( + /** + * 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( 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/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c5ec3f095167..08d2f0faa5e1 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) { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index d448a720ebfc..3ab203e9dbb8 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, @@ -115,7 +116,7 @@ interface PendingDefaultBranchAction { type PublishProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "bitbucket" | "azure-devops" | "gitea" >; type GitActionToastId = ReturnType; @@ -195,6 +196,15 @@ 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: "gitea", + pathPlaceholder: "owner/repository", + Icon: GitPullRequestIcon, + }, ] as const satisfies ReadonlyArray<{ readonly value: PublishProviderKind; readonly label: string; @@ -416,6 +426,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 +435,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 +486,10 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { : ""; const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill; const currentPublishProvider = publishProviderOption(publishProvider); - const publishHost = currentPublishProvider.host; + const publishHost = + publishProvider === "gitea" + ? (publishHostByProvider.gitea ?? currentPublishProvider.host) + : currentPublishProvider.host; const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; const publishProviderLabel = currentPublishProvider.label; const publishWizardSteps = ["Provider", "Repository", "Summary"] as const; diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 16b749445d4c..0a0ec89c5bff 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -13,6 +13,7 @@ export const OPEN_ON_HOST_LABELS: Partial> = { 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.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index a43c116467ed..0dc298703f43 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -73,6 +73,9 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index edba97fa7d3d..ab6ba75c9e20 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -228,3 +228,46 @@ describe("findProjectForChangeRequest", () => { ).toBeUndefined(); }); }); + +describe("Gitea change request links", () => { + it("reads a Gitea PR URL on an arbitrary self-hosted host", () => { + expect(parseChangeRequestUrl("https://git.example.com/owner/repo/pulls/42")).toEqual({ + host: "git.example.com", + repository: "owner/repo", + number: 42, + }); + expect(parseChangeRequestUrl("https://gitea.com/foo/bar/pulls/1")).toEqual({ + host: "gitea.com", + repository: "foo/bar", + number: 1, + }); + expect(parseChangeRequestUrl("https://code.home.internal/team/project/pulls/999")).toEqual({ + host: "code.home.internal", + repository: "team/project", + number: 999, + }); + }); + + 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(); + }); + + // GitHub is checked first and returns early, so its singular /pull/ shape is unaffected by the + // plural rule that follows it. + 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("finds the repository root behind a Gitea PR URL", () => { + expect(changeRequestRepositoryUrl("https://git.example.com/owner/repo/pulls/42")).toBe( + "https://git.example.com/owner/repo", + ); + }); +}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index c8ec1b7a628c..de8699ffebe5 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -115,6 +115,12 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); return claim(host, match); } + // Gitea, self-hosted included: /{owner}/{repo}/pulls/{n}. Like GitLab, the hostname is not asked + // about, because a Gitea install is named whatever its admin chose. The plural `pulls` is what + // makes this safe to read on any host: GitHub's equivalent is the singular `pull`, and the + // GitHub-ish hosts above have already returned by the time this runs. + const gitea = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (gitea) return claim(host, gitea); return null; } @@ -125,7 +131,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/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; From 3e5118df6e53439bfec3c6eabd75826f3391fe6f Mon Sep 17 00:00:00 2001 From: mario Date: Wed, 26 Aug 2026 00:56:45 +0200 Subject: [PATCH 08/15] fix(gitea): address provider review findings --- .../server/src/sourceControl/GiteaCli.test.ts | 179 ++++++++++++++++++ apps/server/src/sourceControl/GiteaCli.ts | 171 +++++++++++------ .../GiteaSourceControlProvider.test.ts | 24 ++- .../GiteaSourceControlProvider.ts | 2 +- apps/web/src/components/CommandPalette.tsx | 1 + .../pullRequest/pullRequestDetail.logic.ts | 2 +- apps/web/src/lib/openPullRequestLink.test.ts | 41 ++++ apps/web/src/lib/openPullRequestLink.ts | 10 +- apps/web/src/pullRequestReference.test.ts | 17 ++ apps/web/src/pullRequestReference.ts | 8 +- 10 files changed, 393 insertions(+), 62 deletions(-) diff --git a/apps/server/src/sourceControl/GiteaCli.test.ts b/apps/server/src/sourceControl/GiteaCli.test.ts index f4d63fed1649..469c17b2f5ba 100644 --- a/apps/server/src/sourceControl/GiteaCli.test.ts +++ b/apps/server/src/sourceControl/GiteaCli.test.ts @@ -752,6 +752,185 @@ layer("GiteaCli failures", (it) => { }), ); + 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("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"))); diff --git a/apps/server/src/sourceControl/GiteaCli.ts b/apps/server/src/sourceControl/GiteaCli.ts index bfad0f8406fc..c9801b7f4b20 100644 --- a/apps/server/src/sourceControl/GiteaCli.ts +++ b/apps/server/src/sourceControl/GiteaCli.ts @@ -27,10 +27,10 @@ const LIST_PAGE_SIZE = 50; const MAX_LIST_PAGES = 5; const giteaCliExecutionErrorContext = { - operation: Schema.Literal("execute"), command: Schema.Literal("tea"), cwd: Schema.String, - cause: Schema.Defect(), + status: Schema.optional(Schema.Int), + cause: Schema.optional(Schema.Defect()), }; const giteaCliDecodeErrorContext = { @@ -48,7 +48,7 @@ export class GiteaCliUnavailableError extends Schema.TaggedErrorClass new GiteaCliUnavailableError({ ...context, cause }), + VcsProcessSpawnError: (cause) => { + if (isSpawnNotFound(cause)) { + return new GiteaCliUnavailableError({ ...context, cause }); + } + return new GiteaCliCommandError({ ...context, cause }); + }, VcsProcessExitError: (cause) => { switch (cause.failureKind) { case "authentication": @@ -170,7 +170,7 @@ export class GiteaPullRequestListDecodeError extends Schema.TaggedErrorClass run(input, (error) => - GiteaCliCommandError.fromVcsError( - { operation: "execute", command: "tea", cwd: input.cwd }, - error, - ), + GiteaCliCommandError.fromVcsError({ command: "tea", cwd: input.cwd }, error), ); /** @@ -564,24 +586,55 @@ export const make = Effect.gen(function* () { `repos/{owner}/{repo}/pulls?state=${listStateParameter(input.state)}&limit=${LIST_PAGE_SIZE}&page=${input.page}`, ], }).pipe( - Effect.flatMap((raw) => - raw.length === 0 - ? Effect.succeed([] as ReadonlyArray) - : Effect.sync(() => decodeGiteaPullRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => - Result.isSuccess(decoded) - ? Effect.succeed(decoded.success.map(toSummaryWithOptionalUpdatedAt)) - : Effect.fail( - new GiteaPullRequestListDecodeError({ - operation: "listPullRequests", - command: "tea", - cwd: input.cwd, - cause: decoded.failure, - }), - ), - ), - ), - ), + 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({ + operation: "listPullRequests", + command: "tea", + cwd: input.cwd, + cause: error, + }), + ); + } + if (!Array.isArray(parsed)) { + return Effect.fail( + new GiteaPullRequestListDecodeError({ + operation: "listPullRequests", + command: "tea", + cwd: input.cwd, + cause: new Error("Expected a JSON array"), + }), + ); + } + const rawList = parsed as ReadonlyArray; + return Effect.sync(() => decodeGiteaPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => + Result.isSuccess(decoded) + ? Effect.succeed({ + entries: decoded.success.map(toSummaryWithOptionalUpdatedAt), + rawCount: rawList.length, + }) + : Effect.fail( + new GiteaPullRequestListDecodeError({ + operation: "listPullRequests", + command: "tea", + cwd: input.cwd, + cause: decoded.failure, + }), + ), + ), + ); + }), ); return GiteaCli.of({ @@ -595,18 +648,28 @@ export const make = Effect.gen(function* () { 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 = yield* listPage({ cwd: input.cwd, state: input.state, page }); + 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)) { + if ( + entry.headRefName === headRefName && + matchesRequestedState(entry, input.state) && + (sourceOwner === null || + entry.headRepositoryOwnerLogin?.toLowerCase() === sourceOwner) + ) { matches.push(entry); } } - if (matches.length >= wanted || entries.length < LIST_PAGE_SIZE) break; + if (matches.length >= wanted || rawCount < LIST_PAGE_SIZE) break; } return matches.slice(0, wanted); @@ -617,11 +680,9 @@ export const make = Effect.gen(function* () { if (reference === null) { return yield* Effect.fail( new GiteaPullRequestNotFoundError({ - operation: "execute", command: "tea", cwd: input.cwd, reference: input.reference, - cause: new Error("Reference is neither a pull request number nor a Gitea PR URL."), }), ); } @@ -780,12 +841,12 @@ export const make = Effect.gen(function* () { "checkout", parseGiteaPullRequestReference(input.reference)?.index ?? input.reference, "--branch", + ...(input.force ? ["--force"] : []), ], }, (error) => GiteaPullRequestNotFoundError.fromVcsError( { - operation: "execute", command: "tea", cwd: input.cwd, reference: input.reference, diff --git a/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts b/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts index 4922879a6cf5..8fbb164edbae 100644 --- a/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts @@ -64,7 +64,6 @@ it.effect("maps Gitea PR summaries into provider-neutral change requests", () => it.effect("adds repository context while retaining Gitea CLI causes", () => Effect.gen(function* () { const cause = new GiteaCli.GiteaCliCommandError({ - operation: "execute", command: "tea", cwd: "/repo", cause: new Error("raw upstream detail that should remain in the cause"), @@ -101,7 +100,6 @@ it.effect("adds repository context while retaining Gitea CLI causes", () => it.effect("reports the right operation for each failing Gitea call", () => Effect.gen(function* () { const cause = new GiteaCli.GiteaCliAuthenticationError({ - operation: "execute", command: "tea", cwd: "/repo", cause: new Error("http 401"), @@ -354,3 +352,25 @@ it("does not refine a host tea knows nothing about", () => { 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 index 3da6ff77162e..0edd62558da0 100644 --- a/apps/server/src/sourceControl/GiteaSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GiteaSourceControlProvider.ts @@ -90,7 +90,7 @@ function refineUnknownGiteaRemote(input: SourceControlUnknownRemoteRefinementInp parseGiteaLogins(input.auth.stdout), input.context.provider.name, ); - if (!login) { + if (!login || login.user === null) { return null; } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 08d2f0faa5e1..222d220f75d1 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1559,6 +1559,7 @@ function OpenCommandPaletteDialog(props: { "bitbucket", "azure", "devops", + "gitea", "url", "environment", ], diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index a616a8395239..7a6c9714c199 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -852,7 +852,7 @@ const OPERATION_PREFIX = /^Pull request operation \w+ failed:\s*/iu; * host says is worth more than what this page could invent, so only these are replaced. */ const TOOL_NOISE = [ - /^(github|gitlab|bitbucket|azure devops)?\s*(cli|api)?\s*(command\s*)?failed\.?$/iu, + /^(github|gitlab|bitbucket|azure devops|gitea)?\s*(cli|api)?\s*(command\s*)?failed\.?$/iu, /^exited? with (code|status) \d+\.?$/iu, /^unknown error\.?$/iu, ]; diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index ab6ba75c9e20..75660acd4437 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -265,6 +265,47 @@ describe("Gitea change request links", () => { 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("recognizes generic Gitea /pulls/ URLs on self-hosted hostnames", () => { + expect(parseChangeRequestUrl("https://git.example.com/owner/repo/pulls/42")).toEqual({ + host: "git.example.com", + repository: "owner/repo", + number: 42, + }); + }); + + it("falls through to Gitea parser for self-hosted lookalike hosts with unmatched native paths", () => { + expect(parseChangeRequestUrl("https://github.internal/owner/repo/pulls/42")).toEqual({ + host: "github.internal", + repository: "owner/repo", + number: 42, + }); + expect(parseChangeRequestUrl("https://bitbucket.internal/owner/repo/pulls/42")).toEqual({ + host: "bitbucket.internal", + repository: "owner/repo", + number: 42, + }); + }); + + it("still matches native paths on self-hosted lookalike hosts", () => { + expect(parseChangeRequestUrl("https://github.internal/owner/repo/pull/42")).toEqual({ + host: "github.internal", + repository: "owner/repo", + number: 42, + }); + expect( + parseChangeRequestUrl("https://bitbucket.internal/workspace/repo/pull-requests/5"), + ).toEqual({ + host: "bitbucket.internal", + repository: "workspace/repo", + number: 5, + }); + }); + it("finds the repository root behind a Gitea PR URL", () => { expect(changeRequestRepositoryUrl("https://git.example.com/owner/repo/pulls/42")).toBe( "https://git.example.com/owner/repo", diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index de8699ffebe5..90dd82b02e3d 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. @@ -119,6 +119,12 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu // about, because a Gitea install is named whatever its admin chose. The plural `pulls` is what // makes this safe to read on any host: GitHub's equivalent is the singular `pull`, and the // GitHub-ish hosts above have already returned by the time this runs. + // + // Public hosts whose own URL shapes could collide must be excluded: github.com uses `/pull/` + // (singular) and bitbucket.org uses `/pull-requests/`, so a `/pulls/` path on either is + // unrecognisable as their native format and must not fall through to the Gitea rule. + if (isHostOf(host, "github.com")) return null; + if (isHostOf(host, "bitbucket.org")) return null; const gitea = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); if (gitea) return claim(host, gitea); return null; diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 5e534af0a0be..33e6b277e1de 100644 --- a/apps/web/src/pullRequestReference.test.ts +++ b/apps/web/src/pullRequestReference.test.ts @@ -70,4 +70,21 @@ 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("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..8540acfb3815 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?:\/\/[^/\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; } From 3f98c416922947310c4d132bb3bdb0d855a5e85b Mon Sep 17 00:00:00 2001 From: mario Date: Wed, 26 Aug 2026 01:00:18 +0200 Subject: [PATCH 09/15] fix(gitea): correct PR discovery errors --- .../server/src/sourceControl/GiteaCli.test.ts | 55 ++++++++++++++++++- apps/server/src/sourceControl/GiteaCli.ts | 3 +- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/GiteaCli.test.ts b/apps/server/src/sourceControl/GiteaCli.test.ts index 469c17b2f5ba..b4287cd1a47d 100644 --- a/apps/server/src/sourceControl/GiteaCli.test.ts +++ b/apps/server/src/sourceControl/GiteaCli.test.ts @@ -252,7 +252,9 @@ layer("GiteaCli.layer", (it) => { }); expect(result.map((entry) => entry.number)).toEqual([2]); - expect(lastArgs()[2]).toBe("repos/{owner}/{repo}/pulls?state=open&limit=50&page=1"); + expect(lastArgs()[2]).toBe( + "repos/{owner}/{repo}/pulls?state=open&sort=recentupdate&limit=50&page=1", + ); }), ); @@ -304,7 +306,9 @@ layer("GiteaCli.layer", (it) => { expect(result.map((entry) => entry.number)).toEqual([77]); expect(mockedRun).toHaveBeenCalledTimes(2); - expect(lastArgs()[2]).toBe("repos/{owner}/{repo}/pulls?state=open&limit=50&page=2"); + expect(lastArgs()[2]).toBe( + "repos/{owner}/{repo}/pulls?state=open&sort=recentupdate&limit=50&page=2", + ); }), ); @@ -982,4 +986,51 @@ layer("GiteaCli failures", (it) => { 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 index c9801b7f4b20..662176a98b0f 100644 --- a/apps/server/src/sourceControl/GiteaCli.ts +++ b/apps/server/src/sourceControl/GiteaCli.ts @@ -583,7 +583,7 @@ export const make = Effect.gen(function* () { api({ cwd: input.cwd, args: [ - `repos/{owner}/{repo}/pulls?state=${listStateParameter(input.state)}&limit=${LIST_PAGE_SIZE}&page=${input.page}`, + `repos/{owner}/{repo}/pulls?state=${listStateParameter(input.state)}&sort=recentupdate&limit=${LIST_PAGE_SIZE}&page=${input.page}`, ], }).pipe( Effect.flatMap((raw) => { @@ -796,7 +796,6 @@ export const make = Effect.gen(function* () { createPullRequest: (input) => api({ cwd: input.cwd, - reference: input.headSelector, args: [ "-X", "POST", From c0599af9469ff05e8a34fd85c20ac8e9379f9496 Mon Sep 17 00:00:00 2001 From: mario Date: Wed, 26 Aug 2026 01:05:31 +0200 Subject: [PATCH 10/15] fix(gitea): remove placeholder decode errors --- apps/server/src/sourceControl/GiteaCli.ts | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/apps/server/src/sourceControl/GiteaCli.ts b/apps/server/src/sourceControl/GiteaCli.ts index 662176a98b0f..60281094fe21 100644 --- a/apps/server/src/sourceControl/GiteaCli.ts +++ b/apps/server/src/sourceControl/GiteaCli.ts @@ -160,10 +160,7 @@ export class GiteaCliCommandError extends Schema.TaggedErrorClass()( "GiteaPullRequestListDecodeError", - { - ...giteaCliDecodeErrorContext, - operation: Schema.Literal("listPullRequests"), - }, + giteaCliDecodeErrorContext, ) { get detail(): string { return "Gitea CLI returned invalid pull request list JSON."; @@ -599,34 +596,22 @@ export const make = Effect.gen(function* () { } catch (error) { return Effect.fail( new GiteaPullRequestListDecodeError({ - operation: "listPullRequests", command: "tea", cwd: input.cwd, cause: error, }), ); } - if (!Array.isArray(parsed)) { - return Effect.fail( - new GiteaPullRequestListDecodeError({ - operation: "listPullRequests", - command: "tea", - cwd: input.cwd, - cause: new Error("Expected a JSON array"), - }), - ); - } - const rawList = parsed as ReadonlyArray; + 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: rawList.length, + rawCount, }) : Effect.fail( new GiteaPullRequestListDecodeError({ - operation: "listPullRequests", command: "tea", cwd: input.cwd, cause: decoded.failure, From d257b1122d8177212c505a8dc74dd921f899d270 Mon Sep 17 00:00:00 2001 From: mario Date: Wed, 26 Aug 2026 01:27:00 +0200 Subject: [PATCH 11/15] fix(gitea): guard cross-repository checkout --- .../server/src/sourceControl/GiteaCli.test.ts | 60 +++++++++++++ apps/server/src/sourceControl/GiteaCli.ts | 84 ++++++++++++++----- apps/web/src/pullRequestReference.test.ts | 15 ++++ apps/web/src/pullRequestReference.ts | 2 +- 4 files changed, 141 insertions(+), 20 deletions(-) diff --git a/apps/server/src/sourceControl/GiteaCli.test.ts b/apps/server/src/sourceControl/GiteaCli.test.ts index b4287cd1a47d..eda7c660c014 100644 --- a/apps/server/src/sourceControl/GiteaCli.test.ts +++ b/apps/server/src/sourceControl/GiteaCli.test.ts @@ -616,6 +616,15 @@ layer("GiteaCli.layer", (it) => { 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; @@ -893,6 +902,57 @@ layer("GiteaCli failures", (it) => { }), ); + 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( diff --git a/apps/server/src/sourceControl/GiteaCli.ts b/apps/server/src/sourceControl/GiteaCli.ts index 60281094fe21..343938d8638a 100644 --- a/apps/server/src/sourceControl/GiteaCli.ts +++ b/apps/server/src/sourceControl/GiteaCli.ts @@ -192,7 +192,12 @@ export class GiteaRepositoryDecodeError extends Schema.TaggedErrorClass - run( - { - cwd: input.cwd, - args: [ - "pulls", - "checkout", - parseGiteaPullRequestReference(input.reference)?.index ?? input.reference, - "--branch", - ...(input.force ? ["--force"] : []), - ], - }, - (error) => - GiteaPullRequestNotFoundError.fromVcsError( - { + 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, - }, - error, - ), - ).pipe(Effect.asVoid), + }), + ); + } + + 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), }); }); diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 33e6b277e1de..c58f98249bf5 100644 --- a/apps/web/src/pullRequestReference.test.ts +++ b/apps/web/src/pullRequestReference.test.ts @@ -80,6 +80,21 @@ describe("parsePullRequestReference", () => { ); }); + 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"); diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index 8540acfb3815..d699268680d4 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -5,7 +5,7 @@ const GITLAB_MERGE_REQUEST_URL_PATTERN = 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?:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i; + /^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; From 556ce2df4c737cfdb740c05eb33d25df0484cf0b Mon Sep 17 00:00:00 2001 From: mario Date: Wed, 26 Aug 2026 01:32:55 +0200 Subject: [PATCH 12/15] test(gitea): cover tea login classification --- apps/server/src/sourceControl/GiteaCli.ts | 14 +++--- apps/server/src/vcs/VcsProcess.test.ts | 52 ++++++++++++++++++++++- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/apps/server/src/sourceControl/GiteaCli.ts b/apps/server/src/sourceControl/GiteaCli.ts index 343938d8638a..fae415609d1f 100644 --- a/apps/server/src/sourceControl/GiteaCli.ts +++ b/apps/server/src/sourceControl/GiteaCli.ts @@ -39,6 +39,13 @@ const giteaCliDecodeErrorContext = { 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, @@ -173,11 +180,7 @@ export class GiteaPullRequestListDecodeError extends Schema.TaggedErrorClass()( "GiteaPullRequestDecodeError", - { - ...giteaCliDecodeErrorContext, - operation: Schema.Literals(["getPullRequest", "createPullRequest"]), - reference: Schema.String, - }, + giteaPullRequestDecodeErrorContext, ) { get detail(): string { return "Gitea CLI returned invalid pull request JSON."; @@ -687,7 +690,6 @@ export const make = Effect.gen(function* () { if (!Result.isSuccess(decoded)) { return yield* Effect.fail( new GiteaPullRequestDecodeError({ - operation: "getPullRequest", command: "tea", cwd: input.cwd, reference: input.reference, 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 = From d33de81a0fa516eb9c28aa5c1117983a4169edc2 Mon Sep 17 00:00:00 2001 From: mario Date: Wed, 26 Aug 2026 10:17:28 +0200 Subject: [PATCH 13/15] fix(web): keep unsupported Gitea PR links external --- .../GitActionsControl.logic.test.ts | 21 ++++++++ .../src/components/GitActionsControl.logic.ts | 7 +++ apps/web/src/components/GitActionsControl.tsx | 22 +++++--- apps/web/src/lib/openPullRequestLink.test.ts | 50 ++----------------- apps/web/src/lib/openPullRequestLink.ts | 14 +----- 5 files changed, 48 insertions(+), 66 deletions(-) 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 3ab203e9dbb8..2cc2b90c64d8 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -49,6 +49,7 @@ import { resolveLiveThreadBranchUpdate, resolveThreadBranchMetadataPatch, resolveQuickAction, + resolvePublishHost, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { AnimatedHeight } from "./AnimatedHeight"; @@ -201,7 +202,7 @@ const PUBLISH_PROVIDER_OPTIONS = [ // A self-hosted Gitea has no canonical host, so the real one is read from discovery below. label: "Gitea", description: "Your authenticated instance", - host: "gitea", + host: null, pathPlaceholder: "owner/repository", Icon: GitPullRequestIcon, }, @@ -209,7 +210,7 @@ const PUBLISH_PROVIDER_OPTIONS = [ readonly value: PublishProviderKind; readonly label: string; readonly description: string; - readonly host: string; + readonly host: string | null; readonly pathPlaceholder: string; readonly Icon: typeof GitHubIcon; }>; @@ -486,10 +487,10 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { : ""; const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill; const currentPublishProvider = publishProviderOption(publishProvider); - const publishHost = - publishProvider === "gitea" - ? (publishHostByProvider.gitea ?? currentPublishProvider.host) - : 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; @@ -725,9 +726,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { Repository
- + - {publishHost}/ + {publishHost === null ? currentPublishProvider.label : `${publishHost}/`} { }); }); -describe("Gitea change request links", () => { - it("reads a Gitea PR URL on an arbitrary self-hosted host", () => { - expect(parseChangeRequestUrl("https://git.example.com/owner/repo/pulls/42")).toEqual({ - host: "git.example.com", - repository: "owner/repo", - number: 42, - }); - expect(parseChangeRequestUrl("https://gitea.com/foo/bar/pulls/1")).toEqual({ - host: "gitea.com", - repository: "foo/bar", - number: 1, - }); - expect(parseChangeRequestUrl("https://code.home.internal/team/project/pulls/999")).toEqual({ - host: "code.home.internal", - repository: "team/project", - number: 999, - }); +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", () => { @@ -254,8 +241,6 @@ describe("Gitea change request links", () => { expect(parseChangeRequestUrl("https://git.example.com/owner/repo/issues/42")).toBeNull(); }); - // GitHub is checked first and returns early, so its singular /pull/ shape is unaffected by the - // plural rule that follows it. it("leaves GitHub URLs on the GitHub rule", () => { expect(parseChangeRequestUrl("https://github.com/owner/repo/pull/42")).toEqual({ host: "github.com", @@ -270,27 +255,6 @@ describe("Gitea change request links", () => { expect(parseChangeRequestUrl("https://bitbucket.org/owner/repo/pulls/42")).toBeNull(); }); - it("recognizes generic Gitea /pulls/ URLs on self-hosted hostnames", () => { - expect(parseChangeRequestUrl("https://git.example.com/owner/repo/pulls/42")).toEqual({ - host: "git.example.com", - repository: "owner/repo", - number: 42, - }); - }); - - it("falls through to Gitea parser for self-hosted lookalike hosts with unmatched native paths", () => { - expect(parseChangeRequestUrl("https://github.internal/owner/repo/pulls/42")).toEqual({ - host: "github.internal", - repository: "owner/repo", - number: 42, - }); - expect(parseChangeRequestUrl("https://bitbucket.internal/owner/repo/pulls/42")).toEqual({ - host: "bitbucket.internal", - repository: "owner/repo", - number: 42, - }); - }); - it("still matches native paths on self-hosted lookalike hosts", () => { expect(parseChangeRequestUrl("https://github.internal/owner/repo/pull/42")).toEqual({ host: "github.internal", @@ -305,10 +269,4 @@ describe("Gitea change request links", () => { number: 5, }); }); - - it("finds the repository root behind a Gitea PR URL", () => { - expect(changeRequestRepositoryUrl("https://git.example.com/owner/repo/pulls/42")).toBe( - "https://git.example.com/owner/repo", - ); - }); }); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 90dd82b02e3d..c29dac8d7409 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -115,18 +115,8 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); return claim(host, match); } - // Gitea, self-hosted included: /{owner}/{repo}/pulls/{n}. Like GitLab, the hostname is not asked - // about, because a Gitea install is named whatever its admin chose. The plural `pulls` is what - // makes this safe to read on any host: GitHub's equivalent is the singular `pull`, and the - // GitHub-ish hosts above have already returned by the time this runs. - // - // Public hosts whose own URL shapes could collide must be excluded: github.com uses `/pull/` - // (singular) and bitbucket.org uses `/pull-requests/`, so a `/pulls/` path on either is - // unrecognisable as their native format and must not fall through to the Gitea rule. - if (isHostOf(host, "github.com")) return null; - if (isHostOf(host, "bitbucket.org")) return null; - const gitea = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (gitea) return claim(host, gitea); + // 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; } From 16d32bcf46f9709a63801d4ee3923b9518df513f Mon Sep 17 00:00:00 2001 From: mario Date: Wed, 26 Aug 2026 10:54:58 +0200 Subject: [PATCH 14/15] fix(web): show Gitea setup details in settings --- .../SourceControlSettings.logic.test.ts | 41 +++++++++++++++++++ .../settings/SourceControlSettings.logic.ts | 26 ++++++++++++ .../settings/SourceControlSettings.tsx | 11 ++--- 3 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/settings/SourceControlSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/SourceControlSettings.logic.ts 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..22546e51ee3e --- /dev/null +++ b/apps/web/src/components/settings/SourceControlSettings.logic.test.ts @@ -0,0 +1,41 @@ +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("names tea login add when executable is present", () => { + expect(formattedSetupGuidance("Gitea", "tea", "Install tea from https://example.com")).toBe( + "Gitea is not authenticated on this server. Run `tea login add` on the server host to enable change request features.", + ); + }); + + it("falls back to installHint when executable is null", () => { + expect( + formattedSetupGuidance("GitHub", null, "Install the GitHub CLI from https://cli.github.com"), + ).toBe( + "GitHub is not authenticated on this server. Install the GitHub CLI from https://cli.github.com", + ); + }); +}); 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..30e2a1660e39 --- /dev/null +++ b/apps/web/src/components/settings/SourceControlSettings.logic.ts @@ -0,0 +1,26 @@ +/** + * 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, + executable: string | null, + installHint: string, +): string { + if (executable !== null) { + return `${label} is not authenticated on this server. Run \`${executable} login add\` on the server host to enable change request features.`; + } + return `${label} is not authenticated on this server. ${installHint}`; +} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 0dc298703f43..c00b0139ed0a 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 = { @@ -217,6 +218,7 @@ function itemSummary({ if (auth) { if (auth.status === "authenticated") { + const suffix = formattedAuthSuffix(optionLabel(auth.host), optionLabel(auth.detail)); return ( <> Authenticated @@ -226,6 +228,7 @@ function itemSummary({ ) : null} + {suffix ? {suffix} : null} ); } @@ -235,13 +238,7 @@ 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. - - ); + return {formattedSetupGuidance(item.label, item.executable, item.installHint)}; } const authDetail = optionLabel(auth.detail); return ( From d1b9069f709d3a6381bf93ce71d3b4230314b219 Mon Sep 17 00:00:00 2001 From: mario Date: Wed, 26 Aug 2026 11:13:07 +0200 Subject: [PATCH 15/15] fix(web): keep source control setup guidance neutral --- .../settings/SourceControlSettings.logic.test.ts | 14 ++++++-------- .../settings/SourceControlSettings.logic.ts | 11 ++--------- .../components/settings/SourceControlSettings.tsx | 8 +++++++- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/settings/SourceControlSettings.logic.test.ts b/apps/web/src/components/settings/SourceControlSettings.logic.test.ts index 22546e51ee3e..abee92b92941 100644 --- a/apps/web/src/components/settings/SourceControlSettings.logic.test.ts +++ b/apps/web/src/components/settings/SourceControlSettings.logic.test.ts @@ -25,17 +25,15 @@ describe("formattedAuthSuffix", () => { }); describe("formattedSetupGuidance", () => { - it("names tea login add when executable is present", () => { - expect(formattedSetupGuidance("Gitea", "tea", "Install tea from https://example.com")).toBe( - "Gitea is not authenticated on this server. Run `tea login add` on the server host to enable change request features.", + 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("falls back to installHint when executable is null", () => { - expect( - formattedSetupGuidance("GitHub", null, "Install the GitHub CLI from https://cli.github.com"), - ).toBe( - "GitHub is not authenticated on this server. Install the GitHub CLI from https://cli.github.com", + 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 index 30e2a1660e39..33dff047c8a3 100644 --- a/apps/web/src/components/settings/SourceControlSettings.logic.ts +++ b/apps/web/src/components/settings/SourceControlSettings.logic.ts @@ -14,13 +14,6 @@ export function formattedAuthSuffix(host: string | null, detail: string | null): return text; } -export function formattedSetupGuidance( - label: string, - executable: string | null, - installHint: string, -): string { - if (executable !== null) { - return `${label} is not authenticated on this server. Run \`${executable} login add\` on the server host to enable change request features.`; - } - return `${label} is not authenticated on this server. ${installHint}`; +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 c00b0139ed0a..fe0978ea65bd 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -238,7 +238,13 @@ function itemSummary({ } if (auth.status === "unauthenticated") { - return {formattedSetupGuidance(item.label, item.executable, item.installHint)}; + return ( + + {formattedSetupGuidance(item.label)}{" "} + {item.executable} tool on + the server host to enable change request features. + + ); } const authDetail = optionLabel(auth.detail); return (