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 }) => ( > = { diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index 68271228ed6..550a0196719 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -15,7 +15,7 @@ import { resolveChangeRequestPresentation, type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./components/Icons"; +import { AzureDevOpsIcon, BitbucketIcon, ForgejoIcon, GitHubIcon, GitLabIcon } from "./components/Icons"; export interface SourceControlPresentation { readonly providerName: string; @@ -52,6 +52,12 @@ export function getSourceControlPresentation( terminology: getChangeRequestTerminology(provider), Icon: BitbucketIcon, }; + case "forgejo": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: ForgejoIcon, + }; case "change-request": return { providerName: provider?.name || presentation.providerName, diff --git a/packages/client-runtime/src/addProject.ts b/packages/client-runtime/src/addProject.ts index fb4e599317f..fb39f421512 100644 --- a/packages/client-runtime/src/addProject.ts +++ b/packages/client-runtime/src/addProject.ts @@ -24,7 +24,7 @@ import type { EnvironmentScopedProjectShell } from "./shellTypes.ts"; export type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "bitbucket" | "azure-devops" | "forgejo" >; export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; @@ -54,6 +54,7 @@ export const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = "gitlab", "bitbucket", "azure-devops", + "forgejo", ]; export const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [ @@ -61,6 +62,7 @@ export const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray { }); }); }); + +describe("forgejo support", () => { + it("resolves Forgejo presentation", () => { + const p = resolveChangeRequestPresentation({ + kind: "forgejo", + name: "Forgejo", + baseUrl: "https://codeberg.org", + }); + expect(p.icon).toBe("forgejo"); + expect(p.providerName).toBe("Forgejo"); + expect(p.shortName).toBe("PR"); + }); + + it("detects codeberg.org as forgejo", () => { + expect( + detectSourceControlProviderFromRemoteUrl("git@codeberg.org:owner/repo.git")?.kind, + ).toBe("forgejo"); + }); + + it("detects a host containing 'forgejo' as forgejo", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://forgejo.example.org/owner/repo.git")?.kind, + ).toBe("forgejo"); + }); + + it("leaves an arbitrary self-hosted host as unknown (refined later via fj)", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://git.example.org/owner/repo.git")?.kind, + ).toBe("unknown"); + }); +}); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 15a98dc7355..b7956f6ee25 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" | "forgejo" | "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 FORGEJO_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "forgejo", + providerName: "Forgejo", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "Forgejo pull request", + checkoutCommandExample: "fj pr checkout 123", + urlExample: "https://codeberg.org/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 "forgejo": + return FORGEJO_CHANGE_REQUEST_PRESENTATION; case "unknown": return GENERIC_CHANGE_REQUEST_PRESENTATION; } @@ -183,6 +196,10 @@ function isBitbucketHost(host: string): boolean { return host === "bitbucket.org" || host.includes("bitbucket"); } +function isForgejoHost(host: string): boolean { + return host === "codeberg.org" || host.includes("forgejo") || host.includes("codeberg"); +} + export function detectSourceControlProviderFromRemoteUrl( remoteUrl: string, ): SourceControlProviderInfo | null { @@ -224,6 +241,14 @@ export function detectSourceControlProviderFromRemoteUrl( }; } + if (isForgejoHost(hostname)) { + return { + kind: "forgejo", + name: hostname === "codeberg.org" ? "Codeberg" : "Forgejo", + baseUrl: toBaseUrl(host), + }; + } + return { kind: "unknown", name: host,