diff --git a/apps/mobile/src/components/SourceControlIcon.tsx b/apps/mobile/src/components/SourceControlIcon.tsx
index b1d4918037c..07cd719a89f 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, Path, Stop } from "react-native-svg";
-export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops";
+export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops" | "forgejo";
export function SourceControlIcon(props: {
readonly kind: SourceControlIconKind;
@@ -95,5 +95,25 @@ export function SourceControlIcon(props: {
/>
);
+ case "forgejo":
+ return (
+
+ );
}
}
diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx
index a7423966f67..32213fce9f5 100644
--- a/apps/mobile/src/features/projects/AddProjectScreen.tsx
+++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx
@@ -86,7 +86,8 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote
source === "github" ||
source === "gitlab" ||
source === "bitbucket" ||
- source === "azure-devops"
+ source === "azure-devops" ||
+ source === "forgejo"
) {
return source;
}
diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts
index bee861677a5..846410988bb 100644
--- a/apps/server/src/git/GitManager.test.ts
+++ b/apps/server/src/git/GitManager.test.ts
@@ -673,6 +673,7 @@ function makeManager(input?: {
get: () => Effect.succeed(provider),
resolveHandle: () => Effect.succeed({ provider, context: null }),
resolve: () => Effect.succeed(provider),
+ refineRemoteProvider: () => Effect.succeed(null),
discover: Effect.succeed([]),
}),
),
diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts
index 5423f4f1476..5e1b2e5af21 100644
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -798,11 +798,28 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
branch === null
? "origin"
: ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin");
+ const preferredRemoteUrl = yield* readConfigValueNullable(
+ cwd,
+ `remote.${preferredRemoteName}.url`,
+ );
+ const remoteName = preferredRemoteUrl ? preferredRemoteName : "origin";
const remoteUrl =
- (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??
- (yield* readConfigValueNullable(cwd, "remote.origin.url"));
+ preferredRemoteUrl ?? (yield* readConfigValueNullable(cwd, "remote.origin.url"));
+ if (!remoteUrl) return null;
+
+ const detected = detectSourceControlProviderFromGitRemoteUrl(remoteUrl);
+ if (detected && detected.kind !== "unknown") {
+ return detected;
+ }
- return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null;
+ // Forgejo has no canonical hostname, so static detection returns "unknown" for its
+ // self-hosted instances. Refine THIS branch's remote via `fj auth list` (not origin),
+ // but only adopt the result when it resolves to Forgejo so other providers keep their
+ // existing status behavior.
+ const refined = yield* sourceControlProviders
+ .refineRemoteProvider({ cwd, remoteName, remoteUrl })
+ .pipe(Effect.orElseSucceed(() => null));
+ return refined?.kind === "forgejo" ? refined : detected;
});
const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 94b6cb753a2..f7f7587dfa7 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -30,6 +30,7 @@ import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQu
import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";
import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
+import * as ForgejoApi from "./sourceControl/ForgejoApi.ts";
import * as GitHubCli from "./sourceControl/GitHubCli.ts";
import * as GitLabCli from "./sourceControl/GitLabCli.ts";
import * as TextGeneration from "./textGeneration/TextGeneration.ts";
@@ -175,7 +176,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,
+ ForgejoApi.layer,
+ GitHubCli.layer,
+ GitLabCli.layer,
+ ),
),
Layer.provideMerge(GitVcsDriver.layer),
Layer.provideMerge(VcsDriverRegistryLayerLive),
diff --git a/apps/server/src/sourceControl/ForgejoApi.test.ts b/apps/server/src/sourceControl/ForgejoApi.test.ts
new file mode 100644
index 00000000000..97adf6178ca
--- /dev/null
+++ b/apps/server/src/sourceControl/ForgejoApi.test.ts
@@ -0,0 +1,504 @@
+import { assert, it, vi } from "@effect/vitest";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as ConfigProvider from "effect/ConfigProvider";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
+
+import * as ForgejoApi from "./ForgejoApi.ts";
+import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
+import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
+import type * as VcsDriver from "../vcs/VcsDriver.ts";
+
+const forgejoPullRequest = {
+ number: 42,
+ title: "Add Forgejo provider",
+ state: "open",
+ merged: false,
+ html_url: "https://git.example.org/owner/repo/pulls/42",
+ updated_at: "2026-01-02T00:00:00.000Z",
+ base: {
+ ref: "main",
+ repo: { full_name: "owner/repo" },
+ },
+ head: {
+ ref: "feature/forgejo",
+ repo: { full_name: "owner/repo" },
+ },
+};
+
+const repositoryJson = {
+ full_name: "owner/repo",
+ clone_url: "https://git.example.org/owner/repo.git",
+ ssh_url: "git@git.example.org:owner/repo.git",
+ html_url: "https://git.example.org/owner/repo",
+ default_branch: "main",
+};
+
+const keysJson = JSON.stringify({
+ hosts: {
+ "git.example.org": { type: "Token", name: "owner", token: "t" },
+ },
+});
+
+function makeLayer(input: {
+ readonly response: (request: HttpClientRequest.HttpClientRequest) => Response;
+ readonly git?: Partial;
+ readonly remotes?: ReadonlyArray<{ readonly name: string; readonly url: string }>;
+}) {
+ const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) =>
+ Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))),
+ );
+ const gitMock = {
+ readConfigValue: vi.fn(() =>
+ Effect.succeed("git@git.example.org:owner/repo.git"),
+ ),
+ resolvePrimaryRemoteName: vi.fn(
+ () => Effect.succeed("origin"),
+ ),
+ ensureRemote: vi.fn(() =>
+ Effect.succeed("fork-owner"),
+ ),
+ fetchRemoteBranch: vi.fn(
+ () => Effect.void,
+ ),
+ fetchRemoteTrackingBranch: vi.fn(
+ () => Effect.void,
+ ),
+ setBranchUpstream: vi.fn(
+ () => Effect.void,
+ ),
+ switchRef: vi.fn((request) =>
+ Effect.succeed({ refName: request.refName }),
+ ),
+ listLocalBranchNames: vi.fn(() =>
+ Effect.succeed([]),
+ ),
+ };
+ const git = {
+ ...gitMock,
+ ...input.git,
+ } satisfies Partial;
+
+ const remoteList = (
+ input.remotes ?? [{ name: "origin", url: "git@git.example.org:owner/repo.git" }]
+ ).map((remote) => ({
+ name: remote.name,
+ url: remote.url,
+ pushUrl: Option.none(),
+ isPrimary: remote.name === "origin",
+ }));
+ const driver = {
+ listRemotes: () =>
+ Effect.succeed({
+ remotes: remoteList,
+ freshness: {
+ source: "live-local" as const,
+ observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"),
+ expiresAt: Option.none(),
+ },
+ }),
+ } satisfies Partial;
+
+ // Build layer inside an Effect so we can create the temp keys file
+ const layerEffect = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const keysPath = yield* fileSystem.makeTempFileScoped({ prefix: "forgejo-keys-" });
+ yield* fileSystem.writeFileString(keysPath, keysJson);
+
+ return ForgejoApi.layer.pipe(
+ Layer.provide(
+ Layer.succeed(
+ HttpClient.HttpClient,
+ HttpClient.make((request) => execute(request)),
+ ),
+ ),
+ Layer.provide(
+ Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
+ resolve: () =>
+ Effect.succeed({
+ kind: "git",
+ repository: {
+ kind: "git",
+ rootPath: "/repo",
+ metadataPath: null,
+ freshness: {
+ source: "live-local" as const,
+ observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"),
+ expiresAt: Option.none(),
+ },
+ },
+ driver: driver as unknown as VcsDriver.VcsDriverShape,
+ }),
+ }),
+ ),
+ Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)),
+ Layer.provide(
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({ env: { T3CODE_FORGEJO_KEYS_PATH: keysPath } }),
+ ),
+ ),
+ Layer.provideMerge(NodeServices.layer),
+ );
+ });
+
+ return { execute, git: gitMock, layerEffect };
+}
+
+it("parseForgejoRepositorySpec handles clone URLs and bare specs", () => {
+ const expected = { host: "git.example.org", owner: "owner", repo: "repo", scheme: "https" };
+ assert.deepStrictEqual(
+ ForgejoApi.parseForgejoRepositorySpec("https://git.example.org/owner/repo", null),
+ expected,
+ );
+ assert.deepStrictEqual(
+ ForgejoApi.parseForgejoRepositorySpec("https://git.example.org/owner/repo.git", null),
+ expected,
+ );
+ assert.deepStrictEqual(
+ ForgejoApi.parseForgejoRepositorySpec("git@git.example.org:owner/repo.git", null),
+ expected,
+ );
+ assert.deepStrictEqual(
+ ForgejoApi.parseForgejoRepositorySpec("git.example.org/owner/repo", null),
+ expected,
+ );
+ assert.deepStrictEqual(ForgejoApi.parseForgejoRepositorySpec("owner/repo", "git.example.org"), {
+ host: "git.example.org",
+ owner: "owner",
+ repo: "repo",
+ scheme: "https",
+ });
+ assert.deepStrictEqual(
+ ForgejoApi.parseForgejoRepositorySpec("http://git.example.org/owner/repo", null),
+ { host: "git.example.org", owner: "owner", repo: "repo", scheme: "http" },
+ );
+ assert.strictEqual(ForgejoApi.parseForgejoRepositorySpec("owner/repo", null), null);
+});
+
+it.effect("parses pull request responses from the Forgejo REST API", () =>
+ Effect.gen(function* () {
+ const { execute, layerEffect } = makeLayer({
+ response: () => Response.json(forgejoPullRequest),
+ });
+
+ const layer = yield* layerEffect;
+ yield* Effect.gen(function* () {
+ const forgejo = yield* ForgejoApi.ForgejoApi;
+ const result = yield* forgejo.getPullRequest({
+ cwd: "/repo",
+ reference: "#42",
+ });
+
+ assert.deepStrictEqual(result, {
+ number: 42,
+ title: "Add Forgejo provider",
+ url: "https://git.example.org/owner/repo/pulls/42",
+ baseRefName: "main",
+ headRefName: "feature/forgejo",
+ state: "open",
+ updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")),
+ });
+ assert.strictEqual(
+ execute.mock.calls[0]?.[0].url,
+ "https://git.example.org/api/v1/repos/owner/repo/pulls/42",
+ );
+ }).pipe(Effect.provide(layer));
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+);
+
+it.effect("uses the http scheme for plain-http Forgejo remotes", () =>
+ Effect.gen(function* () {
+ const { execute, layerEffect } = makeLayer({
+ response: () => Response.json(forgejoPullRequest),
+ remotes: [{ name: "origin", url: "http://git.example.org/owner/repo.git" }],
+ });
+
+ const layer = yield* layerEffect;
+ yield* Effect.gen(function* () {
+ const forgejo = yield* ForgejoApi.ForgejoApi;
+ yield* forgejo.getPullRequest({ cwd: "/repo", reference: "#42" });
+ assert.strictEqual(
+ execute.mock.calls[0]?.[0].url,
+ "http://git.example.org/api/v1/repos/owner/repo/pulls/42",
+ );
+ }).pipe(Effect.provide(layer));
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+);
+
+it.effect("reads repository clone URLs and default branch from Forgejo", () =>
+ Effect.gen(function* () {
+ const { layerEffect } = makeLayer({
+ response: () => Response.json(repositoryJson),
+ });
+
+ const layer = yield* layerEffect;
+ yield* Effect.gen(function* () {
+ const forgejo = yield* ForgejoApi.ForgejoApi;
+ const cloneUrls = yield* forgejo.getRepositoryCloneUrls({
+ cwd: "/repo",
+ repository: "git.example.org/owner/repo",
+ });
+ const defaultBranch = yield* forgejo.getDefaultBranch({ cwd: "/repo" });
+
+ assert.deepStrictEqual(cloneUrls, {
+ nameWithOwner: "owner/repo",
+ url: "https://git.example.org/owner/repo.git",
+ sshUrl: "git@git.example.org:owner/repo.git",
+ });
+ assert.strictEqual(defaultBranch, "main");
+ }).pipe(Effect.provide(layer));
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+);
+
+it.effect("resolves a bare owner/repo spec via the sole logged-in host with no remotes", () =>
+ Effect.gen(function* () {
+ const { execute, layerEffect } = makeLayer({
+ response: () => Response.json(repositoryJson),
+ remotes: [],
+ });
+
+ const layer = yield* layerEffect;
+ yield* Effect.gen(function* () {
+ const forgejo = yield* ForgejoApi.ForgejoApi;
+ yield* forgejo.getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" });
+ assert.strictEqual(
+ execute.mock.calls[0]?.[0].url,
+ "https://git.example.org/api/v1/repos/owner/repo",
+ );
+ }).pipe(Effect.provide(layer));
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+);
+
+it.effect("creates pull requests using the Forgejo REST API payload shape", () =>
+ Effect.gen(function* () {
+ const { execute, layerEffect } = makeLayer({
+ response: () => Response.json(forgejoPullRequest),
+ });
+
+ const layer = yield* layerEffect;
+ yield* Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const bodyFile = yield* fileSystem.makeTempFileScoped({ prefix: "forgejo-pr-body-" });
+ yield* fileSystem.writeFileString(bodyFile, "PR body");
+
+ const forgejo = yield* ForgejoApi.ForgejoApi;
+ yield* forgejo.createPullRequest({
+ cwd: "/repo",
+ baseBranch: "main",
+ headSelector: "owner:feature/forgejo",
+ title: "Provider PR",
+ bodyFile,
+ });
+
+ const request = execute.mock.calls[0]?.[0];
+ assert.strictEqual(
+ request?.url,
+ "https://git.example.org/api/v1/repos/owner/repo/pulls",
+ );
+ assert.strictEqual(request?.method, "POST");
+ assert.ok(request);
+ const rawBody = (request.body as { readonly body?: Uint8Array }).body;
+ assert.ok(rawBody);
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
+ assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), {
+ head: "owner:feature/forgejo",
+ base: "main",
+ title: "Provider PR",
+ body: "PR body",
+ });
+ }).pipe(Effect.provide(layer), Effect.scoped);
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+);
+
+it.effect("listPullRequests filters by head branch and returns empty on no match", () =>
+ Effect.gen(function* () {
+ const twoItemList = [
+ {
+ ...forgejoPullRequest,
+ number: 42,
+ head: { ref: "feature/forgejo", repo: { full_name: "owner/repo" } },
+ },
+ {
+ ...forgejoPullRequest,
+ number: 99,
+ head: { ref: "other", repo: { full_name: "owner/repo" } },
+ },
+ ];
+ const { layerEffect } = makeLayer({
+ response: () => Response.json(twoItemList),
+ });
+
+ const layer = yield* layerEffect;
+ yield* Effect.gen(function* () {
+ const forgejo = yield* ForgejoApi.ForgejoApi;
+
+ const matched = yield* forgejo.listPullRequests({
+ cwd: "/repo",
+ headSelector: "owner:feature/forgejo",
+ state: "open",
+ limit: 20,
+ });
+ assert.strictEqual(matched.length, 1);
+ assert.strictEqual(matched[0]?.number, 42);
+
+ const empty = yield* forgejo.listPullRequests({
+ cwd: "/repo",
+ headSelector: "owner:nonexistent",
+ state: "open",
+ limit: 20,
+ });
+ assert.strictEqual(empty.length, 0);
+ }).pipe(Effect.provide(layer));
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+);
+
+it.effect("fetches a full page regardless of the caller limit so the branch PR is found", () =>
+ Effect.gen(function* () {
+ const list = [
+ { ...forgejoPullRequest, number: 7, head: { ref: "other", repo: { full_name: "owner/repo" } } },
+ {
+ ...forgejoPullRequest,
+ number: 42,
+ head: { ref: "feature/forgejo", repo: { full_name: "owner/repo" } },
+ },
+ ];
+ const { execute, layerEffect } = makeLayer({ response: () => Response.json(list) });
+
+ const layer = yield* layerEffect;
+ yield* Effect.gen(function* () {
+ const forgejo = yield* ForgejoApi.ForgejoApi;
+ const matched = yield* forgejo.listPullRequests({
+ cwd: "/repo",
+ headSelector: "owner:feature/forgejo",
+ state: "open",
+ limit: 1,
+ });
+
+ assert.strictEqual(matched.length, 1);
+ assert.strictEqual(matched[0]?.number, 42);
+ const params = execute.mock.calls[0]?.[0].urlParams.params ?? [];
+ assert.ok(params.some((param) => param[0] === "limit" && param[1] === "50"));
+ }).pipe(Effect.provide(layer));
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+);
+
+it.effect("matches the head fork owner when the selector is owner:branch", () =>
+ Effect.gen(function* () {
+ const list = [
+ {
+ ...forgejoPullRequest,
+ number: 10,
+ head: { ref: "shared", repo: { full_name: "owner/repo" } },
+ },
+ {
+ ...forgejoPullRequest,
+ number: 20,
+ head: { ref: "shared", repo: { full_name: "forker/repo", owner: { login: "forker" } } },
+ },
+ ];
+ const { layerEffect } = makeLayer({ response: () => Response.json(list) });
+
+ const layer = yield* layerEffect;
+ yield* Effect.gen(function* () {
+ const forgejo = yield* ForgejoApi.ForgejoApi;
+
+ const fork = yield* forgejo.listPullRequests({
+ cwd: "/repo",
+ headSelector: "forker:shared",
+ state: "open",
+ });
+ assert.deepStrictEqual(
+ fork.map((record) => record.number),
+ [20],
+ );
+
+ const base = yield* forgejo.listPullRequests({
+ cwd: "/repo",
+ headSelector: "owner:shared",
+ state: "open",
+ });
+ assert.deepStrictEqual(
+ base.map((record) => record.number),
+ [10],
+ );
+ }).pipe(Effect.provide(layer));
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+);
+
+it.effect("createRepository posts to /user/repos for own account", () =>
+ Effect.gen(function* () {
+ const { execute, layerEffect } = makeLayer({
+ response: () => Response.json(repositoryJson),
+ });
+
+ const layer = yield* layerEffect;
+ yield* Effect.gen(function* () {
+ const forgejo = yield* ForgejoApi.ForgejoApi;
+ yield* forgejo.createRepository({
+ cwd: "/repo",
+ repository: "owner/repo",
+ visibility: "private",
+ });
+
+ const request = execute.mock.calls[0]?.[0];
+ assert.strictEqual(request?.url, "https://git.example.org/api/v1/user/repos");
+ assert.strictEqual(request?.method, "POST");
+ assert.ok(request);
+ const rawBody = (request.body as { readonly body?: Uint8Array }).body;
+ assert.ok(rawBody);
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
+ assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), {
+ name: "repo",
+ private: true,
+ });
+ }).pipe(Effect.provide(layer));
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+);
+
+it.effect("checkoutPullRequest (same-repo, force) uses the context remote", () =>
+ Effect.gen(function* () {
+ const { git, layerEffect } = makeLayer({
+ response: () =>
+ Response.json({
+ ...forgejoPullRequest,
+ head: { ref: "feature/forgejo", repo: { full_name: "owner/repo" } },
+ }),
+ });
+
+ const layer = yield* layerEffect;
+ yield* Effect.gen(function* () {
+ const forgejo = yield* ForgejoApi.ForgejoApi;
+ yield* forgejo.checkoutPullRequest({
+ cwd: "/repo",
+ context: {
+ provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://git.example.org" },
+ remoteName: "origin",
+ remoteUrl: "git@git.example.org:owner/repo.git",
+ },
+ reference: "42",
+ force: true,
+ });
+
+ assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], {
+ cwd: "/repo",
+ remoteName: "origin",
+ remoteBranch: "feature/forgejo",
+ localBranch: "feature/forgejo",
+ });
+ assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], {
+ cwd: "/repo",
+ branch: "feature/forgejo",
+ remoteName: "origin",
+ remoteBranch: "feature/forgejo",
+ });
+ assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], {
+ cwd: "/repo",
+ refName: "feature/forgejo",
+ });
+ }).pipe(Effect.provide(layer));
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+);
diff --git a/apps/server/src/sourceControl/ForgejoApi.ts b/apps/server/src/sourceControl/ForgejoApi.ts
new file mode 100644
index 00000000000..9d02956aa5d
--- /dev/null
+++ b/apps/server/src/sourceControl/ForgejoApi.ts
@@ -0,0 +1,596 @@
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import {
+ TrimmedNonEmptyString,
+ type SourceControlRepositoryCloneUrls,
+ type SourceControlRepositoryVisibility,
+} from "@t3tools/contracts";
+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
+import { sanitizeBranchFragment } from "@t3tools/shared/git";
+import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
+
+import * as ForgejoKeyStore from "./ForgejoKeyStore.ts";
+import * as ForgejoPullRequests from "./forgejoPullRequests.ts";
+import * as SourceControlProvider from "./SourceControlProvider.ts";
+import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
+import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
+
+export class ForgejoApiError extends Schema.TaggedErrorClass()("ForgejoApiError", {
+ operation: Schema.String,
+ detail: Schema.String,
+ status: Schema.optional(Schema.Number),
+ cause: Schema.optional(Schema.Defect()),
+}) {
+ override get message(): string {
+ return `Forgejo API failed in ${this.operation}: ${this.detail}`;
+ }
+}
+const isForgejoApiErrorValue = Schema.is(ForgejoApiError);
+
+// Forgejo's pulls list cannot filter by head branch, so we over-fetch a page of recently
+// updated PRs and filter in memory. This is the per-fetch page size (Forgejo's API max),
+// independent of the caller's `limit` (which caps the matching results).
+const PULL_REQUEST_PAGE_SIZE = 50;
+
+const ForgejoRepositorySchema = Schema.Struct({
+ full_name: TrimmedNonEmptyString,
+ clone_url: Schema.optional(Schema.NullOr(Schema.String)),
+ ssh_url: Schema.optional(Schema.NullOr(Schema.String)),
+ html_url: Schema.optional(Schema.NullOr(Schema.String)),
+ default_branch: Schema.optional(Schema.NullOr(Schema.String)),
+});
+
+export interface ForgejoRepositoryLocator {
+ readonly host: string;
+ readonly owner: string;
+ readonly repo: string;
+ readonly scheme: "http" | "https";
+}
+
+export interface ForgejoApiShape {
+ readonly listPullRequests: (input: {
+ readonly cwd: string;
+ readonly context?: SourceControlProvider.SourceControlProviderContext;
+ readonly headSelector: string;
+ readonly source?: SourceControlProvider.SourceControlRefSelector;
+ readonly state: "open" | "closed" | "merged" | "all";
+ readonly limit?: number;
+ }) => Effect.Effect<
+ ReadonlyArray,
+ ForgejoApiError
+ >;
+ readonly getPullRequest: (input: {
+ readonly cwd: string;
+ readonly context?: SourceControlProvider.SourceControlProviderContext;
+ readonly reference: string;
+ }) => Effect.Effect;
+ readonly createPullRequest: (input: {
+ readonly cwd: string;
+ readonly context?: SourceControlProvider.SourceControlProviderContext;
+ readonly baseBranch: string;
+ readonly headSelector: string;
+ readonly source?: SourceControlProvider.SourceControlRefSelector;
+ readonly target?: SourceControlProvider.SourceControlRefSelector;
+ readonly title: string;
+ readonly bodyFile: string;
+ }) => Effect.Effect;
+ readonly getRepositoryCloneUrls: (input: {
+ readonly cwd: string;
+ readonly context?: SourceControlProvider.SourceControlProviderContext;
+ readonly repository: string;
+ }) => Effect.Effect;
+ readonly createRepository: (input: {
+ readonly cwd: string;
+ readonly repository: string;
+ readonly visibility: SourceControlRepositoryVisibility;
+ }) => Effect.Effect;
+ readonly getDefaultBranch: (input: {
+ readonly cwd: string;
+ readonly context?: SourceControlProvider.SourceControlProviderContext;
+ }) => Effect.Effect;
+ readonly checkoutPullRequest: (input: {
+ readonly cwd: string;
+ readonly context?: SourceControlProvider.SourceControlProviderContext;
+ readonly reference: string;
+ readonly force?: boolean;
+ }) => Effect.Effect;
+}
+
+export class ForgejoApi extends Context.Service()(
+ "t3/sourceControl/ForgejoApi",
+) {}
+
+function normalizeChangeRequestId(reference: string): string {
+ const trimmed = reference.trim().replace(/^#/u, "");
+ const urlMatch = /(?:pulls|pull)\/(\d+)(?:\D.*)?$/iu.exec(trimmed);
+ return urlMatch?.[1] ?? trimmed;
+}
+
+function parseRepoPath(pathname: string): { owner: string; repo: string } | null {
+ const normalized = pathname.trim().replace(/\.git$/u, "").replace(/^\/+/u, "");
+ const parts = normalized.split("/").filter((part) => part.length > 0);
+ if (parts.length < 2) return null;
+ const owner = parts.at(-2);
+ const repo = parts.at(-1);
+ return owner && repo ? { owner, repo } : null;
+}
+
+export function stripHostPort(host: string): string {
+ return host.trim().toLowerCase().replace(/:\d+$/u, "");
+}
+
+// `fj` keys its login store and `fj auth list` output by bare hostname, while remote URLs
+// can carry a `:port`. Match host identities port-insensitively (but keep the full host for
+// constructing API base URLs, which need the port).
+export function forgejoHostsMatch(a: string, b: string): boolean {
+ const an = a.trim().toLowerCase();
+ const bn = b.trim().toLowerCase();
+ return an === bn || stripHostPort(an) === stripHostPort(bn);
+}
+
+export function parseForgejoRemoteUrl(remoteUrl: string): ForgejoRepositoryLocator | null {
+ const trimmed = remoteUrl.trim();
+ if (trimmed.startsWith("git@")) {
+ const hostStart = "git@".length;
+ const colon = trimmed.indexOf(":");
+ if (colon < 0) return null;
+ const host = trimmed.slice(hostStart, colon).toLowerCase();
+ const parsed = parseRepoPath(trimmed.slice(colon + 1));
+ // SSH remotes don't imply an API scheme; default to https.
+ return parsed ? { host, scheme: "https", ...parsed } : null;
+ }
+ try {
+ const url = new URL(trimmed);
+ const parsed = parseRepoPath(url.pathname);
+ const scheme = url.protocol === "http:" ? "http" : "https";
+ return parsed ? { host: url.host.toLowerCase(), scheme, ...parsed } : null;
+ } catch {
+ return null;
+ }
+}
+
+export function parseForgejoRepositorySpec(
+ value: string,
+ fallbackHost: string | null,
+): ForgejoRepositoryLocator | null {
+ const trimmed = value.trim();
+ // A pasted clone URL (`https://host/owner/repo`, `ssh://…`, or `git@host:owner/repo`) carries
+ // its own host — parse it as a remote rather than splitting naively on `/`.
+ if (trimmed.startsWith("git@") || /^[a-z][a-z0-9+.-]*:\/\//iu.test(trimmed)) {
+ return parseForgejoRemoteUrl(trimmed);
+ }
+ const normalized = trimmed.replace(/\.git$/u, "").replace(/^\/+/u, "");
+ const parts = normalized.split("/").filter((part) => part.length > 0);
+ if (parts.length >= 3) {
+ const repo = parts.at(-1);
+ const owner = parts.at(-2);
+ const host = parts.slice(0, -2).join("/").toLowerCase();
+ return owner && repo && host ? { host, owner, repo, scheme: "https" } : null;
+ }
+ if (parts.length === 2 && fallbackHost) {
+ return { host: fallbackHost.toLowerCase(), owner: parts[0]!, repo: parts[1]!, scheme: "https" };
+ }
+ return null;
+}
+
+function normalizeRepositoryCloneUrls(
+ raw: typeof ForgejoRepositorySchema.Type,
+ host: string,
+): SourceControlRepositoryCloneUrls {
+ const httpUrl =
+ raw.clone_url?.trim() || raw.html_url?.trim() || `https://${host}/${raw.full_name}.git`;
+ const sshUrl = raw.ssh_url?.trim() || `git@${host}:${raw.full_name}.git`;
+ return { nameWithOwner: raw.full_name, url: httpUrl, sshUrl };
+}
+
+function shouldPreferSshRemote(originRemoteUrl: string | null): boolean {
+ const trimmed = originRemoteUrl?.trim() ?? "";
+ return trimmed.startsWith("git@") || trimmed.startsWith("ssh://");
+}
+
+function checkoutBranchName(input: {
+ readonly pullRequestId: number;
+ readonly headBranch: string;
+ readonly isCrossRepository: boolean;
+}): string {
+ if (!input.isCrossRepository) return input.headBranch;
+ return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`;
+}
+
+function requestError(operation: string, cause: unknown): ForgejoApiError {
+ return new ForgejoApiError({
+ operation,
+ detail: cause instanceof Error ? cause.message : String(cause),
+ cause,
+ });
+}
+
+function isForgejoApiError(cause: unknown): cause is ForgejoApiError {
+ return isForgejoApiErrorValue(cause);
+}
+
+function responseError(
+ operation: string,
+ response: HttpClientResponse.HttpClientResponse,
+): Effect.Effect {
+ return response.text.pipe(
+ Effect.orElseSucceed(() => ""),
+ Effect.flatMap((body) =>
+ Effect.fail(
+ new ForgejoApiError({
+ operation,
+ status: response.status,
+ detail:
+ body.trim().length > 0
+ ? `Forgejo returned HTTP ${response.status}: ${body.trim()}`
+ : `Forgejo returned HTTP ${response.status}.`,
+ }),
+ ),
+ ),
+ );
+}
+
+export const make = Effect.fn("makeForgejoApi")(function* () {
+ const httpClient = yield* HttpClient.HttpClient;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const keyStore = yield* ForgejoKeyStore.ForgejoKeyStore;
+ const git = yield* GitVcsDriver.GitVcsDriver;
+ const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
+
+ const apiUrl = (locator: Pick, path: string) =>
+ `${locator.scheme}://${locator.host}/api/v1${path}`;
+
+ const withAuth = (host: string, request: HttpClientRequest.HttpClientRequest) =>
+ keyStore.getCredential(host).pipe(
+ Effect.map((credential) =>
+ credential === null
+ ? request
+ : request.pipe(HttpClientRequest.setHeader(...keyStore.authHeader(credential))),
+ ),
+ );
+
+ const decodeResponse = (
+ operation: string,
+ schema: S,
+ response: HttpClientResponse.HttpClientResponse,
+ ): Effect.Effect =>
+ HttpClientResponse.matchStatus({
+ "2xx": (success) =>
+ HttpClientResponse.schemaBodyJson(schema)(success).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ForgejoApiError({
+ operation,
+ detail: "Forgejo returned invalid JSON for the requested resource.",
+ cause,
+ }),
+ ),
+ ),
+ orElse: (failed) => responseError(operation, failed),
+ })(response);
+
+ const executeJson = (
+ operation: string,
+ host: string,
+ request: HttpClientRequest.HttpClientRequest,
+ schema: S,
+ ): Effect.Effect =>
+ withAuth(host, request.pipe(HttpClientRequest.acceptJson)).pipe(
+ Effect.flatMap((authed) => httpClient.execute(authed)),
+ Effect.mapError((cause) => (isForgejoApiError(cause) ? cause : requestError(operation, cause))),
+ Effect.flatMap((response) => decodeResponse(operation, schema, response)),
+ );
+
+ const resolveRepository = Effect.fn("ForgejoApi.resolveRepository")(function* (input: {
+ readonly cwd: string;
+ readonly context?: SourceControlProvider.SourceControlProviderContext;
+ readonly repository?: string;
+ }) {
+ const hosts = yield* keyStore.listHosts;
+ const contextHost =
+ input.context?.provider.kind === "forgejo"
+ ? (parseForgejoRemoteUrl(input.context.remoteUrl)?.host ?? null)
+ : null;
+ // Mirror createRepository: when exactly one Forgejo instance is logged in, accept a bare
+ // `owner/repo` spec (e.g. the add-project clone flow, which runs in a dir with no remotes).
+ const fallbackHost = contextHost ?? (hosts.length === 1 ? hosts[0]! : null);
+
+ if (input.repository !== undefined) {
+ const fromSpec = parseForgejoRepositorySpec(input.repository, fallbackHost);
+ if (fromSpec) return fromSpec;
+ }
+
+ if (input.context?.provider.kind === "forgejo") {
+ const fromContext = parseForgejoRemoteUrl(input.context.remoteUrl);
+ if (fromContext) return fromContext;
+ }
+
+ const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ForgejoApiError({
+ operation: "resolveRepository",
+ detail: `Failed to resolve VCS repository for ${input.cwd}.`,
+ cause,
+ }),
+ ),
+ );
+ const remotes = yield* handle.driver.listRemotes(input.cwd).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ForgejoApiError({
+ operation: "resolveRepository",
+ detail: `Failed to list remotes for ${input.cwd}.`,
+ cause,
+ }),
+ ),
+ );
+
+ for (const remote of remotes.remotes) {
+ const parsed = parseForgejoRemoteUrl(remote.url);
+ if (!parsed) continue;
+ const isForgejo =
+ hosts.some((host) => forgejoHostsMatch(host, parsed.host)) ||
+ detectSourceControlProviderFromRemoteUrl(remote.url)?.kind === "forgejo";
+ if (isForgejo) return parsed;
+ }
+
+ return yield* new ForgejoApiError({
+ operation: "resolveRepository",
+ detail: `No Forgejo repository remote was detected for ${input.cwd}.`,
+ });
+ });
+
+ const getRepositoryFromLocator = (locator: ForgejoRepositoryLocator) =>
+ executeJson(
+ "getRepository",
+ locator.host,
+ HttpClientRequest.get(
+ apiUrl(
+ locator,
+ `/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}`,
+ ),
+ ),
+ ForgejoRepositorySchema,
+ );
+
+ const getRawPullRequestFromLocator = (locator: ForgejoRepositoryLocator, reference: string) =>
+ executeJson(
+ "getPullRequest",
+ locator.host,
+ HttpClientRequest.get(
+ apiUrl(
+ locator,
+ `/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/pulls/${encodeURIComponent(normalizeChangeRequestId(reference))}`,
+ ),
+ ),
+ ForgejoPullRequests.ForgejoPullRequestSchema,
+ );
+
+ const readConfigValueNullable = (cwd: string, key: string) =>
+ git.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null));
+
+ return ForgejoApi.of({
+ listPullRequests: (input) =>
+ resolveRepository(input).pipe(
+ Effect.flatMap((locator) => {
+ const apiState = input.state === "open" ? "open" : input.state === "all" ? "all" : "closed";
+ // Fetch a full page (not the caller's `limit`): Forgejo can't filter by head branch,
+ // so a small limit (e.g. status passes 1) would hide the branch's PR behind unrelated
+ // recently-updated PRs. The caller's `limit` is applied to the filtered matches below.
+ return executeJson(
+ "listPullRequests",
+ locator.host,
+ HttpClientRequest.get(
+ apiUrl(
+ locator,
+ `/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/pulls`,
+ ),
+ {
+ urlParams: {
+ state: apiState,
+ sort: "recentupdate",
+ limit: String(PULL_REQUEST_PAGE_SIZE),
+ },
+ },
+ ),
+ ForgejoPullRequests.ForgejoPullRequestListSchema,
+ ).pipe(
+ Effect.map((list) => {
+ const wanted = SourceControlProvider.sourceBranch(input);
+ const wantedOwner = (
+ input.source?.owner ??
+ SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner
+ )?.toLowerCase();
+ const byBranch = list
+ .map(ForgejoPullRequests.normalizeForgejoPullRequestRecord)
+ .filter((record) => {
+ if (record.headRefName !== wanted) return false;
+ if (wantedOwner === undefined) return true;
+ // Same-repo PRs omit the head owner, so fall back to the base repo owner.
+ const headOwner = (
+ record.headRepositoryOwnerLogin ?? locator.owner
+ ).toLowerCase();
+ return headOwner === wantedOwner;
+ });
+ const byState =
+ input.state === "merged"
+ ? byBranch.filter((record) => record.state === "merged")
+ : input.state === "closed"
+ ? byBranch.filter((record) => record.state === "closed")
+ : byBranch;
+ return input.limit !== undefined ? byState.slice(0, input.limit) : byState;
+ }),
+ );
+ }),
+ ),
+ getPullRequest: (input) =>
+ resolveRepository(input).pipe(
+ Effect.flatMap((locator) => getRawPullRequestFromLocator(locator, input.reference)),
+ Effect.map(ForgejoPullRequests.normalizeForgejoPullRequestRecord),
+ ),
+ getRepositoryCloneUrls: (input) =>
+ resolveRepository(input).pipe(
+ Effect.flatMap((locator) =>
+ getRepositoryFromLocator(locator).pipe(
+ Effect.map((raw) => normalizeRepositoryCloneUrls(raw, locator.host)),
+ ),
+ ),
+ ),
+ createRepository: (input) =>
+ Effect.gen(function* () {
+ const hosts = yield* keyStore.listHosts;
+ const fallbackHost = hosts.length === 1 ? hosts[0]! : null;
+ const locator = parseForgejoRepositorySpec(input.repository, fallbackHost);
+ if (!locator) {
+ return yield* new ForgejoApiError({
+ operation: "createRepository",
+ detail:
+ hosts.length === 1
+ ? "Forgejo repositories must be specified as owner/repository or host/owner/repository."
+ : "Multiple Forgejo instances are configured; specify the repository as host/owner/repository.",
+ });
+ }
+ const credential = yield* keyStore.getCredential(locator.host);
+ // Route to /user/repos when creating under your own account; otherwise treat the owner as an org.
+ const isOwnAccount = credential !== null && credential.name === locator.owner;
+ const endpoint = isOwnAccount
+ ? `/user/repos`
+ : `/orgs/${encodeURIComponent(locator.owner)}/repos`;
+ const raw = yield* executeJson(
+ "createRepository",
+ locator.host,
+ HttpClientRequest.post(apiUrl(locator, endpoint)).pipe(
+ HttpClientRequest.bodyJsonUnsafe({
+ name: locator.repo,
+ private: input.visibility === "private",
+ }),
+ ),
+ ForgejoRepositorySchema,
+ );
+ return normalizeRepositoryCloneUrls(raw, locator.host);
+ }),
+ createPullRequest: (input) =>
+ Effect.gen(function* () {
+ const locator = yield* resolveRepository(input);
+ const body = yield* fileSystem.readFileString(input.bodyFile).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ForgejoApiError({
+ operation: "createPullRequest",
+ detail: `Failed to read pull request body file ${input.bodyFile}.`,
+ cause,
+ }),
+ ),
+ );
+ const sourceOwner =
+ input.source?.owner ??
+ SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner;
+ const branch = SourceControlProvider.sourceBranch(input);
+ const head = sourceOwner ? `${sourceOwner}:${branch}` : branch;
+ yield* executeJson(
+ "createPullRequest",
+ locator.host,
+ HttpClientRequest.post(
+ apiUrl(
+ locator,
+ `/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/pulls`,
+ ),
+ ).pipe(
+ HttpClientRequest.bodyJsonUnsafe({
+ head,
+ base: input.target?.refName ?? input.baseBranch,
+ title: input.title,
+ body,
+ }),
+ ),
+ ForgejoPullRequests.ForgejoPullRequestSchema,
+ );
+ }),
+ getDefaultBranch: (input) =>
+ resolveRepository(input).pipe(
+ Effect.flatMap(getRepositoryFromLocator),
+ Effect.map((raw) => raw.default_branch?.trim() || null),
+ ),
+ checkoutPullRequest: (input) =>
+ Effect.gen(function* () {
+ const destination = yield* resolveRepository(input);
+ const pullRequest = yield* getRawPullRequestFromLocator(destination, input.reference);
+ const destinationName = `${destination.owner}/${destination.repo}`;
+ const sourceName = pullRequest.head.repo?.full_name?.trim() ?? destinationName;
+ const isCrossRepository = sourceName !== destinationName;
+ const remoteBranch = pullRequest.head.ref;
+
+ let remoteName: string;
+ if (
+ input.context?.provider.kind === "forgejo" &&
+ !isCrossRepository &&
+ parseForgejoRemoteUrl(input.context.remoteUrl) !== null
+ ) {
+ remoteName = input.context.remoteName;
+ } else if (!isCrossRepository) {
+ const primaryRemote = yield* git
+ .resolvePrimaryRemoteName(input.cwd)
+ .pipe(Effect.orElseSucceed(() => null));
+ if (primaryRemote) {
+ remoteName = primaryRemote;
+ } else {
+ const raw = yield* getRepositoryFromLocator(destination);
+ const cloneUrls = normalizeRepositoryCloneUrls(raw, destination.host);
+ const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url");
+ remoteName = yield* git.ensureRemote({
+ cwd: input.cwd,
+ preferredName: destination.owner,
+ url: shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url,
+ });
+ }
+ } else {
+ const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url");
+ const httpUrl = pullRequest.head.repo?.clone_url?.trim() ?? "";
+ const sshUrl = pullRequest.head.repo?.ssh_url?.trim() ?? "";
+ const cloneUrl = shouldPreferSshRemote(originRemoteUrl) ? sshUrl || httpUrl : httpUrl || sshUrl;
+ if (cloneUrl.length === 0) {
+ return yield* new ForgejoApiError({
+ operation: "checkoutPullRequest",
+ detail: "Forgejo pull request head repository has no clone URL.",
+ });
+ }
+ remoteName = yield* git.ensureRemote({
+ cwd: input.cwd,
+ preferredName: sourceName.split("/")[0] ?? destination.owner,
+ url: cloneUrl,
+ });
+ }
+
+ const localBranch = checkoutBranchName({
+ pullRequestId: pullRequest.number,
+ headBranch: remoteBranch,
+ isCrossRepository,
+ });
+ const localBranchExists = (yield* git.listLocalBranchNames(input.cwd)).includes(localBranch);
+
+ if (input.force === true || !localBranchExists) {
+ yield* git.fetchRemoteBranch({ cwd: input.cwd, remoteName, remoteBranch, localBranch });
+ } else {
+ yield* git.fetchRemoteTrackingBranch({ cwd: input.cwd, remoteName, remoteBranch });
+ }
+ yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, remoteName, remoteBranch });
+ yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch }));
+ }).pipe(
+ Effect.mapError((cause) =>
+ isForgejoApiError(cause)
+ ? cause
+ : new ForgejoApiError({
+ operation: "checkoutPullRequest",
+ detail: cause instanceof Error ? cause.message : String(cause),
+ cause,
+ }),
+ ),
+ ),
+ });
+});
+
+export const layer = Layer.effect(ForgejoApi, make()).pipe(Layer.provide(ForgejoKeyStore.layer));
diff --git a/apps/server/src/sourceControl/ForgejoKeyStore.test.ts b/apps/server/src/sourceControl/ForgejoKeyStore.test.ts
new file mode 100644
index 00000000000..5f66b6d098a
--- /dev/null
+++ b/apps/server/src/sourceControl/ForgejoKeyStore.test.ts
@@ -0,0 +1,74 @@
+import { assert, it } from "@effect/vitest";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as ConfigProvider from "effect/ConfigProvider";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+
+import * as ForgejoKeyStore from "./ForgejoKeyStore.ts";
+
+function layerWithKeysFile(contents: string) {
+ return Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const keysPath = yield* fileSystem.makeTempFileScoped({ prefix: "forgejo-keys-" });
+ yield* fileSystem.writeFileString(keysPath, contents);
+ return ForgejoKeyStore.layer.pipe(
+ Layer.provide(
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({ env: { T3CODE_FORGEJO_KEYS_PATH: keysPath } }),
+ ),
+ ),
+ Layer.provideMerge(NodeServices.layer),
+ );
+ });
+}
+
+const sampleKeys = JSON.stringify({
+ hosts: {
+ "codeberg.org": { type: "OAuth", name: "pat-s", token: "oauth-token" },
+ "git.example.org": { type: "Token", name: "pat-s", token: "pat-token" },
+ },
+});
+
+it.effect("lists hosts and returns credentials", () =>
+ Effect.gen(function* () {
+ const layer = yield* layerWithKeysFile(sampleKeys);
+ yield* Effect.gen(function* () {
+ const store = yield* ForgejoKeyStore.ForgejoKeyStore;
+ const hosts = yield* store.listHosts;
+ assert.deepStrictEqual([...hosts].toSorted(), ["codeberg.org", "git.example.org"]);
+
+ const oauth = yield* store.getCredential("codeberg.org");
+ assert.ok(oauth);
+ assert.deepStrictEqual(store.authHeader(oauth), ["Authorization", "Bearer oauth-token"]);
+
+ const pat = yield* store.getCredential("git.example.org");
+ assert.ok(pat);
+ assert.deepStrictEqual(store.authHeader(pat), ["Authorization", "token pat-token"]);
+
+ assert.strictEqual(yield* store.getCredential("missing.example.org"), null);
+ }).pipe(Effect.provide(layer));
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+);
+
+it.effect("matches a bare-hostname credential when the lookup host carries a port", () =>
+ Effect.gen(function* () {
+ const layer = yield* layerWithKeysFile(sampleKeys);
+ yield* Effect.gen(function* () {
+ const store = yield* ForgejoKeyStore.ForgejoKeyStore;
+ const credential = yield* store.getCredential("git.example.org:3000");
+ assert.ok(credential);
+ assert.strictEqual(credential.token, "pat-token");
+ }).pipe(Effect.provide(layer));
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+);
+
+it.effect("degrades to an empty store on malformed JSON", () =>
+ Effect.gen(function* () {
+ const layer = yield* layerWithKeysFile("{ not valid json");
+ yield* Effect.gen(function* () {
+ const store = yield* ForgejoKeyStore.ForgejoKeyStore;
+ assert.deepStrictEqual(yield* store.listHosts, []);
+ }).pipe(Effect.provide(layer));
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+);
diff --git a/apps/server/src/sourceControl/ForgejoKeyStore.ts b/apps/server/src/sourceControl/ForgejoKeyStore.ts
new file mode 100644
index 00000000000..29bb918cba4
--- /dev/null
+++ b/apps/server/src/sourceControl/ForgejoKeyStore.ts
@@ -0,0 +1,99 @@
+import * as os from "node:os";
+import * as Config from "effect/Config";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+
+export interface ForgejoCredential {
+ readonly host: string;
+ readonly type: string;
+ readonly name: string;
+ readonly token: string;
+}
+
+export interface ForgejoKeyStoreShape {
+ readonly listHosts: Effect.Effect>;
+ readonly getCredential: (host: string) => Effect.Effect;
+ readonly authHeader: (credential: ForgejoCredential) => readonly [string, string];
+}
+
+export class ForgejoKeyStore extends Context.Service()(
+ "t3/sourceControl/ForgejoKeyStore",
+) {}
+
+export const defaultKeysPath = Effect.fn("defaultKeysPath")(function* () {
+ const { join } = yield* Path.Path;
+ const home = os.homedir();
+ if (process.platform === "darwin") {
+ return join(home, "Library", "Application Support", "Cyborus.forgejo-cli", "keys.json");
+ }
+ if (process.platform === "win32") {
+ const base = process.env["APPDATA"] ?? join(home, "AppData", "Roaming");
+ return join(base, "Cyborus", "forgejo-cli", "data", "keys.json");
+ }
+ const base = process.env["XDG_DATA_HOME"] ?? join(home, ".local", "share");
+ return join(base, "forgejo-cli", "keys.json");
+});
+
+export function parseKeysFile(content: string): Map {
+ const store = new Map();
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(content);
+ } catch {
+ return store;
+ }
+ const hosts = (parsed as { hosts?: unknown } | null)?.hosts;
+ if (hosts === null || typeof hosts !== "object") return store;
+ for (const [rawHost, rawEntry] of Object.entries(hosts as Record)) {
+ if (rawEntry === null || typeof rawEntry !== "object") continue;
+ const token = (rawEntry as { token?: unknown }).token;
+ if (typeof token !== "string" || token.trim().length === 0) continue;
+ const type = (rawEntry as { type?: unknown }).type;
+ const name = (rawEntry as { name?: unknown }).name;
+ const host = rawHost.trim().toLowerCase();
+ if (host.length === 0) continue;
+ store.set(host, {
+ host,
+ token,
+ type: typeof type === "string" ? type : "",
+ name: typeof name === "string" ? name : "",
+ });
+ }
+ return store;
+}
+
+export const make = Effect.fn("makeForgejoKeyStore")(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const overridePath = yield* Config.string("T3CODE_FORGEJO_KEYS_PATH").pipe(Config.option);
+ const keysPath = Option.isSome(overridePath) ? overridePath.value : yield* defaultKeysPath();
+
+ const readStore = fileSystem
+ .readFileString(keysPath)
+ .pipe(
+ Effect.map(parseKeysFile),
+ Effect.orElseSucceed(() => new Map()),
+ );
+
+ return ForgejoKeyStore.of({
+ listHosts: readStore.pipe(Effect.map((store) => Array.from(store.keys()))),
+ getCredential: (host) =>
+ readStore.pipe(
+ Effect.map((store) => {
+ // Remote URLs may carry a `:port`, but `fj` keys keys.json by bare hostname.
+ // Try the exact host first, then fall back to the port-stripped hostname.
+ const wanted = host.trim().toLowerCase();
+ return store.get(wanted) ?? store.get(wanted.replace(/:\d+$/u, "")) ?? null;
+ }),
+ ),
+ authHeader: (credential) =>
+ /oauth/iu.test(credential.type)
+ ? (["Authorization", `Bearer ${credential.token}`] as const)
+ : (["Authorization", `token ${credential.token}`] as const),
+ });
+});
+
+export const layer = Layer.effect(ForgejoKeyStore, make());
diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts
new file mode 100644
index 00000000000..5d7724aab4e
--- /dev/null
+++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts
@@ -0,0 +1,92 @@
+import { assert, describe, it } from "@effect/vitest";
+import { ChildProcessSpawner } from "effect/unstable/process";
+
+import { discovery, parseForgejoAuthHosts } from "./ForgejoSourceControlProvider.ts";
+
+const authOutput = ["pat-s@codeberg.org", "pat-s@git.example.org"].join("\n");
+
+describe("Forgejo discovery", () => {
+ it("parses `fj auth list` user@host lines", () => {
+ assert.deepStrictEqual(parseForgejoAuthHosts(authOutput), [
+ { account: "pat-s", host: "codeberg.org" },
+ { account: "pat-s", host: "git.example.org" },
+ ]);
+ });
+
+ it("refines an unknown remote whose host is logged in", () => {
+ const refined = discovery.refineUnknownRemote!({
+ cwd: "/repo",
+ context: {
+ provider: { kind: "unknown", name: "git.example.org", baseUrl: "https://git.example.org" },
+ remoteName: "origin",
+ remoteUrl: "git@git.example.org:owner/repo.git",
+ },
+ auth: { stdout: authOutput, stderr: "", exitCode: ChildProcessSpawner.ExitCode(0) },
+ });
+ assert.deepStrictEqual(refined, {
+ kind: "forgejo",
+ name: "Forgejo",
+ baseUrl: "https://git.example.org",
+ });
+ });
+
+ it("refines an unknown remote whose host differs only in case", () => {
+ const refined = discovery.refineUnknownRemote!({
+ cwd: "/repo",
+ context: {
+ provider: { kind: "unknown", name: "Git.Example.Org", baseUrl: "https://Git.Example.Org" },
+ remoteName: "origin",
+ remoteUrl: "git@Git.Example.Org:owner/repo.git",
+ },
+ auth: { stdout: authOutput, stderr: "", exitCode: ChildProcessSpawner.ExitCode(0) },
+ });
+ assert.deepStrictEqual(refined, {
+ kind: "forgejo",
+ name: "Forgejo",
+ baseUrl: "https://Git.Example.Org",
+ });
+ });
+
+ it("refines a remote whose host carries a port not present in the login store", () => {
+ const refined = discovery.refineUnknownRemote!({
+ cwd: "/repo",
+ context: {
+ provider: {
+ kind: "unknown",
+ name: "git.example.org:3000",
+ baseUrl: "https://git.example.org:3000",
+ },
+ remoteName: "origin",
+ remoteUrl: "https://git.example.org:3000/owner/repo.git",
+ },
+ auth: { stdout: authOutput, stderr: "", exitCode: ChildProcessSpawner.ExitCode(0) },
+ });
+ assert.deepStrictEqual(refined, {
+ kind: "forgejo",
+ name: "Forgejo",
+ baseUrl: "https://git.example.org:3000",
+ });
+ });
+
+ it("does not refine a host that is not logged in", () => {
+ const refined = discovery.refineUnknownRemote!({
+ cwd: "/repo",
+ context: {
+ provider: { kind: "unknown", name: "git.other.org", baseUrl: "https://git.other.org" },
+ remoteName: "origin",
+ remoteUrl: "git@git.other.org:owner/repo.git",
+ },
+ auth: { stdout: authOutput, stderr: "", exitCode: ChildProcessSpawner.ExitCode(0) },
+ });
+ assert.strictEqual(refined, null);
+ });
+
+ it("reports authenticated status from auth output", () => {
+ const auth = discovery.parseAuth({
+ stdout: authOutput,
+ stderr: "",
+ exitCode: ChildProcessSpawner.ExitCode(0),
+ });
+ assert.strictEqual(auth.status, "authenticated");
+ });
+});
diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts
new file mode 100644
index 00000000000..1922b7766c3
--- /dev/null
+++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts
@@ -0,0 +1,183 @@
+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 ForgejoApi from "./ForgejoApi.ts";
+import * as ForgejoPullRequests from "./forgejoPullRequests.ts";
+import * as SourceControlProvider from "./SourceControlProvider.ts";
+import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
+
+function providerError(
+ operation: string,
+ cause: ForgejoApi.ForgejoApiError,
+): SourceControlProviderError {
+ return new SourceControlProviderError({
+ provider: "forgejo",
+ operation,
+ detail: cause.detail,
+ cause,
+ });
+}
+
+function toChangeRequest(
+ summary: ForgejoPullRequests.NormalizedForgejoPullRequestRecord,
+): ChangeRequest {
+ return {
+ provider: "forgejo",
+ number: summary.number,
+ title: summary.title,
+ url: summary.url,
+ baseRefName: summary.baseRefName,
+ headRefName: summary.headRefName,
+ state: summary.state,
+ updatedAt: summary.updatedAt ?? Option.none(),
+ ...(summary.isCrossRepository !== undefined
+ ? { isCrossRepository: summary.isCrossRepository }
+ : {}),
+ ...(summary.headRepositoryNameWithOwner !== undefined
+ ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
+ : {}),
+ ...(summary.headRepositoryOwnerLogin !== undefined
+ ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
+ : {}),
+ };
+}
+
+export function parseForgejoAuthHosts(
+ output: string,
+): ReadonlyArray<{ readonly account: string; readonly host: string }> {
+ const entries: Array<{ account: string; host: string }> = [];
+ for (const line of output.split(/\r?\n/)) {
+ const match = /^([^@\s]+)@([a-z0-9][a-z0-9.-]*(?::\d+)?)$/iu.exec(line.trim());
+ if (match?.[1] && match[2]) {
+ entries.push({ account: match[1], host: match[2].toLowerCase() });
+ }
+ }
+ return entries;
+}
+
+function parseForgejoAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {
+ const output = SourceControlProviderDiscovery.combinedAuthOutput(input);
+ const hosts = parseForgejoAuthHosts(output);
+ const first = hosts[0];
+ if (first) {
+ return SourceControlProviderDiscovery.providerAuth({
+ status: "authenticated",
+ account: first.account,
+ host: first.host,
+ });
+ }
+ if (input.exitCode !== 0) {
+ return SourceControlProviderDiscovery.providerAuth({
+ status: "unauthenticated",
+ detail:
+ SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
+ "Run `fj auth login ` to authenticate the Forgejo CLI.",
+ });
+ }
+ return SourceControlProviderDiscovery.providerAuth({
+ status: "unknown",
+ detail:
+ SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
+ "Forgejo CLI auth status could not be parsed.",
+ });
+}
+
+function refineUnknownForgejoRemote(
+ input: SourceControlProviderDiscovery.SourceControlUnknownRemoteRefinementInput,
+) {
+ const host = input.context.provider.name.toLowerCase();
+ const authenticated = parseForgejoAuthHosts(
+ SourceControlProviderDiscovery.combinedAuthOutput(input.auth),
+ ).some((entry) => ForgejoApi.forgejoHostsMatch(entry.host, host));
+ if (!authenticated) return null;
+ return {
+ kind: "forgejo",
+ name: "Forgejo",
+ baseUrl: input.context.provider.baseUrl,
+ } as const;
+}
+
+export const discovery = {
+ type: "cli",
+ kind: "forgejo",
+ label: "Forgejo",
+ executable: "fj",
+ versionArgs: ["version"],
+ authArgs: ["auth", "list"],
+ parseAuth: parseForgejoAuth,
+ refineUnknownRemote: refineUnknownForgejoRemote,
+ installHint:
+ "Install the Forgejo CLI (`fj`) from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj auth login `.",
+} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec;
+
+export const make = Effect.fn("makeForgejoSourceControlProvider")(function* () {
+ const forgejo = yield* ForgejoApi.ForgejoApi;
+
+ return SourceControlProvider.SourceControlProvider.of({
+ kind: "forgejo",
+ listChangeRequests: (input) => {
+ const source = SourceControlProvider.sourceControlRefFromInput(input);
+ return forgejo
+ .listPullRequests({
+ cwd: input.cwd,
+ ...(input.context ? { context: input.context } : {}),
+ headSelector: input.headSelector,
+ ...(source ? { source } : {}),
+ state: input.state,
+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
+ })
+ .pipe(
+ Effect.map((items) => items.map(toChangeRequest)),
+ Effect.mapError((error) => providerError("listChangeRequests", error)),
+ );
+ },
+ getChangeRequest: (input) =>
+ forgejo.getPullRequest(input).pipe(
+ Effect.map(toChangeRequest),
+ Effect.mapError((error) => providerError("getChangeRequest", error)),
+ ),
+ createChangeRequest: (input) => {
+ const source = SourceControlProvider.sourceControlRefFromInput(input);
+ return forgejo
+ .createPullRequest({
+ cwd: input.cwd,
+ ...(input.context ? { context: input.context } : {}),
+ baseBranch: input.baseRefName,
+ headSelector: input.headSelector,
+ ...(source ? { source } : {}),
+ ...(input.target ? { target: input.target } : {}),
+ title: input.title,
+ bodyFile: input.bodyFile,
+ })
+ .pipe(Effect.mapError((error) => providerError("createChangeRequest", error)));
+ },
+ getRepositoryCloneUrls: (input) =>
+ forgejo
+ .getRepositoryCloneUrls(input)
+ .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),
+ createRepository: (input) =>
+ forgejo
+ .createRepository(input)
+ .pipe(Effect.mapError((error) => providerError("createRepository", error))),
+ getDefaultBranch: (input) =>
+ forgejo
+ .getDefaultBranch({
+ cwd: input.cwd,
+ ...(input.context ? { context: input.context } : {}),
+ })
+ .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))),
+ checkoutChangeRequest: (input) =>
+ forgejo
+ .checkoutPullRequest({
+ cwd: input.cwd,
+ ...(input.context ? { context: input.context } : {}),
+ reference: input.reference,
+ ...(input.force !== undefined ? { force: input.force } : {}),
+ })
+ .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", 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 f65710c4c9c..0081e6f703e 100644
--- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts
+++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts
@@ -11,6 +11,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
import * as BitbucketApi from "./BitbucketApi.ts";
+import * as ForgejoApi from "./ForgejoApi.ts";
import * as GitHubCli from "./GitHubCli.ts";
import * as GitLabCli from "./GitLabCli.ts";
import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
@@ -28,6 +29,7 @@ const sourceControlProviderRegistryTestLayer = (input: {
),
Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
+ Layer.mock(ForgejoApi.ForgejoApi)({}),
Layer.mock(GitHubCli.GitHubCli)({}),
Layer.mock(GitLabCli.GitLabCli)({}),
Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts
index 833956ecc7e..49caefc0d43 100644
--- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts
+++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts
@@ -12,6 +12,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
import * as BitbucketApi from "./BitbucketApi.ts";
+import * as ForgejoApi from "./ForgejoApi.ts";
import * as GitHubCli from "./GitHubCli.ts";
import * as GitLabCli from "./GitLabCli.ts";
import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
@@ -86,6 +87,7 @@ function makeRegistry(input: {
processLayer,
Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
Layer.mock(BitbucketApi.BitbucketApi)({}),
+ Layer.mock(ForgejoApi.ForgejoApi)({}),
Layer.mock(GitHubCli.GitHubCli)({}),
Layer.mock(GitLabCli.GitLabCli)({}),
ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe(
@@ -217,3 +219,39 @@ it.effect("falls back to a non-origin remote when origin is not configured", ()
assert.strictEqual(provider.kind, "azure-devops");
}),
);
+
+it.effect("refineRemoteProvider refines a logged-in Forgejo remote on a non-origin upstream", () =>
+ Effect.gen(function* () {
+ const registry = yield* makeRegistry({
+ remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }],
+ process: {
+ run: (input) =>
+ input.command === "fj"
+ ? Effect.succeed(processOutput("pat-s@git.example.org"))
+ : Effect.succeed(processOutput("")),
+ },
+ });
+
+ const provider = yield* registry.refineRemoteProvider({
+ cwd: "/repo",
+ remoteName: "upstream",
+ remoteUrl: "git@git.example.org:owner/repo.git",
+ });
+
+ assert.strictEqual(provider?.kind, "forgejo");
+ }),
+);
+
+it.effect("refineRemoteProvider leaves a host with no matching login unrefined", () =>
+ Effect.gen(function* () {
+ const registry = yield* makeRegistry({ remotes: [] });
+
+ const provider = yield* registry.refineRemoteProvider({
+ cwd: "/repo",
+ remoteName: "origin",
+ remoteUrl: "git@git.example.org:owner/repo.git",
+ });
+
+ assert.strictEqual(provider?.kind, "unknown");
+ }),
+);
diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts
index 08f794d1f5c..a70c47cfbc6 100644
--- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts
+++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts
@@ -7,12 +7,14 @@ import * as Layer from "effect/Layer";
import {
SourceControlProviderError,
type SourceControlProviderDiscoveryItem,
+ type SourceControlProviderInfo,
} from "@t3tools/contracts";
import type { SourceControlProviderKind } from "@t3tools/contracts";
import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
+import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
import * as SourceControlProvider from "./SourceControlProvider.ts";
@@ -45,6 +47,11 @@ export interface SourceControlProviderRegistryShape {
readonly resolve: (input: {
readonly cwd: string;
}) => Effect.Effect;
+ readonly refineRemoteProvider: (input: {
+ readonly cwd: string;
+ readonly remoteName: string;
+ readonly remoteUrl: string;
+ }) => Effect.Effect;
readonly discover: Effect.Effect>;
}
@@ -214,6 +221,18 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
get,
resolveHandle,
resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)),
+ refineRemoteProvider: (input) => {
+ const provider = detectSourceControlProviderFromRemoteUrl(input.remoteUrl);
+ if (provider === null) {
+ return Effect.succeed(null);
+ }
+ return SourceControlProviderDiscovery.refineUnknownRemoteProvider({
+ specs: discoverySpecs,
+ process,
+ cwd: input.cwd,
+ context: { provider, remoteName: input.remoteName, remoteUrl: input.remoteUrl },
+ }).pipe(Effect.map((context) => context?.provider ?? provider));
+ },
discover: Effect.all(
discoverySpecs.map((spec) =>
SourceControlProviderDiscovery.probeSourceControlProvider({
@@ -233,6 +252,7 @@ export const make = Effect.fn("makeSourceControlProviderRegistry")(function* ()
const gitlab = yield* GitLabSourceControlProvider.make();
const bitbucket = yield* BitbucketSourceControlProvider.make();
const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery();
+ const forgejo = yield* ForgejoSourceControlProvider.make();
const azureDevOps = yield* AzureDevOpsSourceControlProvider.make();
return yield* makeWithProviders([
{
@@ -255,6 +275,11 @@ export const make = Effect.fn("makeSourceControlProviderRegistry")(function* ()
provider: bitbucket,
discovery: bitbucketDiscovery,
},
+ {
+ kind: "forgejo",
+ provider: forgejo,
+ discovery: ForgejoSourceControlProvider.discovery,
+ },
]);
});
diff --git a/apps/server/src/sourceControl/forgejoPullRequests.test.ts b/apps/server/src/sourceControl/forgejoPullRequests.test.ts
new file mode 100644
index 00000000000..ce4d48fe4a8
--- /dev/null
+++ b/apps/server/src/sourceControl/forgejoPullRequests.test.ts
@@ -0,0 +1,58 @@
+import { assert, it } from "@effect/vitest";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+
+import {
+ ForgejoPullRequestSchema,
+ normalizeForgejoPullRequestRecord,
+} from "./forgejoPullRequests.ts";
+
+const decode = Schema.decodeUnknownEffect(ForgejoPullRequestSchema);
+
+it.effect("normalizes a same-repository open PR", () =>
+ Effect.gen(function* () {
+ const raw = yield* decode({
+ number: 42,
+ title: "Add Forgejo provider",
+ state: "open",
+ merged: false,
+ html_url: "https://codeberg.org/owner/repo/pulls/42",
+ updated_at: "2026-01-02T00:00:00.000Z",
+ base: { ref: "main", repo: { full_name: "owner/repo" } },
+ head: { ref: "feature/forgejo", repo: { full_name: "owner/repo" } },
+ });
+ assert.deepStrictEqual(normalizeForgejoPullRequestRecord(raw), {
+ number: 42,
+ title: "Add Forgejo provider",
+ url: "https://codeberg.org/owner/repo/pulls/42",
+ baseRefName: "main",
+ headRefName: "feature/forgejo",
+ state: "open",
+ updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")),
+ });
+ }),
+);
+
+it.effect("marks merged PRs and cross-repository PRs", () =>
+ Effect.gen(function* () {
+ const raw = yield* decode({
+ number: 7,
+ title: "Fork PR",
+ state: "closed",
+ merged: true,
+ html_url: "https://codeberg.org/owner/repo/pulls/7",
+ base: { ref: "main", repo: { full_name: "owner/repo" } },
+ head: {
+ ref: "patch",
+ repo: { full_name: "forker/repo", owner: { login: "forker" } },
+ },
+ });
+ const record = normalizeForgejoPullRequestRecord(raw);
+ assert.strictEqual(record.state, "merged");
+ assert.strictEqual(record.isCrossRepository, true);
+ assert.strictEqual(record.headRepositoryNameWithOwner, "forker/repo");
+ assert.strictEqual(record.headRepositoryOwnerLogin, "forker");
+ }),
+);
diff --git a/apps/server/src/sourceControl/forgejoPullRequests.ts b/apps/server/src/sourceControl/forgejoPullRequests.ts
new file mode 100644
index 00000000000..2677f536fdb
--- /dev/null
+++ b/apps/server/src/sourceControl/forgejoPullRequests.ts
@@ -0,0 +1,87 @@
+import * as DateTime from "effect/DateTime";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
+
+export interface NormalizedForgejoPullRequestRecord {
+ 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 const ForgejoRepoRefSchema = Schema.Struct({
+ full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ clone_url: Schema.optional(Schema.NullOr(Schema.String)),
+ ssh_url: Schema.optional(Schema.NullOr(Schema.String)),
+ default_branch: Schema.optional(Schema.NullOr(Schema.String)),
+ owner: Schema.optional(
+ Schema.NullOr(
+ Schema.Struct({
+ login: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ }),
+ ),
+ ),
+});
+
+export const ForgejoPullBranchSchema = Schema.Struct({
+ ref: TrimmedNonEmptyString,
+ repo: Schema.optional(Schema.NullOr(ForgejoRepoRefSchema)),
+});
+
+export const ForgejoPullRequestSchema = Schema.Struct({
+ number: PositiveInt,
+ title: TrimmedNonEmptyString,
+ state: Schema.optional(Schema.NullOr(Schema.String)),
+ merged: Schema.optional(Schema.NullOr(Schema.Boolean)),
+ html_url: TrimmedNonEmptyString,
+ updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
+ base: ForgejoPullBranchSchema,
+ head: ForgejoPullBranchSchema,
+});
+
+export const ForgejoPullRequestListSchema = Schema.Array(ForgejoPullRequestSchema);
+
+function trimOptional(value: string | null | undefined): string | null {
+ const trimmed = value?.trim() ?? "";
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+function normalizeState(
+ state: string | null | undefined,
+ merged: boolean | null | undefined,
+): "open" | "closed" | "merged" {
+ if (merged === true) return "merged";
+ return state?.trim().toLowerCase() === "closed" ? "closed" : "open";
+}
+
+export function normalizeForgejoPullRequestRecord(
+ raw: Schema.Schema.Type,
+): NormalizedForgejoPullRequestRecord {
+ const headFullName = trimOptional(raw.head.repo?.full_name);
+ const baseFullName = trimOptional(raw.base.repo?.full_name);
+ const headOwner =
+ trimOptional(raw.head.repo?.owner?.login) ??
+ (headFullName?.includes("/") ? (headFullName.split("/")[0] ?? null) : null);
+ const isCrossRepository =
+ headFullName !== null && baseFullName !== null && headFullName !== baseFullName;
+
+ return {
+ number: raw.number,
+ title: raw.title,
+ url: raw.html_url,
+ baseRefName: raw.base.ref,
+ headRefName: raw.head.ref,
+ state: normalizeState(raw.state, raw.merged ?? null),
+ updatedAt: raw.updated_at ?? Option.none(),
+ ...(isCrossRepository ? { isCrossRepository: true } : {}),
+ ...(isCrossRepository && headFullName ? { headRepositoryNameWithOwner: headFullName } : {}),
+ ...(isCrossRepository && headOwner ? { headRepositoryOwnerLogin: headOwner } : {}),
+ };
+}
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 0f2a8f790bf..ce5cacd5deb 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -89,6 +89,7 @@ import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDisco
import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts";
import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
+import * as ForgejoApi from "./sourceControl/ForgejoApi.ts";
import * as GitHubCli from "./sourceControl/GitHubCli.ts";
import * as GitLabCli from "./sourceControl/GitLabCli.ts";
import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
@@ -1482,6 +1483,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
Layer.mergeAll(
AzureDevOpsCli.layer,
BitbucketApi.layer,
+ ForgejoApi.layer,
GitHubCli.layer,
GitLabCli.layer,
),
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index ebfc260ca43..35cd7f0a803 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -99,7 +99,7 @@ import {
} from "./CommandPalette.logic";
import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
import { CommandPaletteResults } from "./CommandPaletteResults";
-import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";
+import { AzureDevOpsIcon, BitbucketIcon, ForgejoIcon, GitHubIcon, GitLabIcon } from "./Icons";
import { ProjectFavicon } from "./ProjectFavicon";
import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
import { useServerKeybindings } from "../rpc/serverState";
@@ -153,7 +153,7 @@ interface AddProjectEnvironmentOption {
type AddProjectRemoteProviderKind = Extract<
SourceControlProviderKind,
- "github" | "gitlab" | "bitbucket" | "azure-devops"
+ "github" | "gitlab" | "bitbucket" | "azure-devops" | "forgejo"
>;
type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
@@ -178,12 +178,14 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray = [
"gitlab",
"bitbucket",
"azure-devops",
+ "forgejo",
];
const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [
"github",
"gitlab",
"bitbucket",
"azure-devops",
+ "forgejo",
];
function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {
@@ -196,6 +198,8 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {
return "Bitbucket";
case "azure-devops":
return "Azure DevOps";
+ case "forgejo":
+ return "Forgejo";
case "url":
return "Git URL";
}
@@ -211,6 +215,8 @@ function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string {
return "workspace/repository";
case "azure-devops":
return "project/repository";
+ case "forgejo":
+ return "host/owner/repo";
case "url":
return "URL";
}
@@ -232,6 +238,8 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri
return ;
case "azure-devops":
return ;
+ case "forgejo":
+ return ;
case "url":
return ;
}
@@ -281,6 +289,7 @@ function buildAddProjectRemoteSourceReadiness(
gitlab: unavailable,
bitbucket: unavailable,
"azure-devops": unavailable,
+ forgejo: unavailable,
};
if (!discovery) {
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx
index 8ea38c51958..301e64b8e93 100644
--- a/apps/web/src/components/Icons.tsx
+++ b/apps/web/src/components/Icons.tsx
@@ -189,6 +189,18 @@ export const BitbucketIcon: Icon = (props) => {
);
};
+export const ForgejoIcon: Icon = (props) => (
+
+);
+
export const CursorIcon: Icon = ({ className, ...props }) => (