From 5d9a665a5bc8548a46feb9608a8045dde6e34d96 Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Fri, 11 Sep 2026 16:24:38 +0000 Subject: [PATCH 1/9] feat(source-control): add Forgejo provider support - Detect authenticated Forgejo instances via the fj CLI and refine unknown remotes, mapping separate SSH hostnames to the web instance (port retained in canonical keys) - Add Forgejo across the provider registry, GitManager/identity resolution, PR references, and pull request repository keys - Add Forgejo icons, add-project flows, settings, and command palette entries on web and mobile - Document Forgejo setup in the source control guide --- .../src/components/SourceControlIcon.tsx | 17 +- .../projects/AddProjectRepositoryRoute.tsx | 1 + .../features/projects/AddProjectScreen.tsx | 1 + apps/server/src/git/GitManager.test.ts | 34 +- apps/server/src/git/GitManager.ts | 18 +- .../RepositoryIdentityResolver.test.ts | 98 ++++- .../src/project/RepositoryIdentityResolver.ts | 45 +- apps/server/src/server.ts | 9 +- .../src/sourceControl/ForgejoCli.test.ts | 294 +++++++++++++ apps/server/src/sourceControl/ForgejoCli.ts | 349 +++++++++++++++ .../ForgejoSourceControlProvider.test.ts | 328 ++++++++++++++ .../ForgejoSourceControlProvider.ts | 415 ++++++++++++++++++ .../SourceControlDiscovery.test.ts | 20 + .../SourceControlProviderDiscovery.ts | 24 +- .../SourceControlProviderRegistry.test.ts | 73 ++- .../SourceControlProviderRegistry.ts | 8 + .../src/sourceControl/forgejoAuth.test.ts | 94 ++++ apps/server/src/sourceControl/forgejoAuth.ts | 65 +++ apps/server/src/ws.ts | 2 + apps/web/src/components/CommandPalette.tsx | 14 +- apps/web/src/components/GitActionsControl.tsx | 21 +- apps/web/src/components/Icons.tsx | 7 + .../src/components/ThreadStatusIndicators.tsx | 8 +- .../pullRequest/pullRequestDetail.logic.ts | 2 + .../settings/SourceControlSettings.tsx | 2 + .../src/components/settings/settingsSearch.ts | 2 +- apps/web/src/pullRequestReference.test.ts | 10 + apps/web/src/pullRequestReference.ts | 5 + apps/web/src/sourceControlPresentation.ts | 14 +- apps/web/src/state/sourceControlActions.ts | 2 +- docs/user/source-control.md | 20 +- .../client-runtime/src/operations/projects.ts | 9 +- packages/contracts/src/sourceControl.ts | 1 + packages/shared/src/sourceControl.test.ts | 16 + packages/shared/src/sourceControl.ts | 15 +- 35 files changed, 1991 insertions(+), 52 deletions(-) create mode 100644 apps/server/src/sourceControl/ForgejoCli.test.ts create mode 100644 apps/server/src/sourceControl/ForgejoCli.ts create mode 100644 apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts create mode 100644 apps/server/src/sourceControl/ForgejoSourceControlProvider.ts create mode 100644 apps/server/src/sourceControl/forgejoAuth.test.ts create mode 100644 apps/server/src/sourceControl/forgejoAuth.ts diff --git a/apps/mobile/src/components/SourceControlIcon.tsx b/apps/mobile/src/components/SourceControlIcon.tsx index 3b371c021adc..0648152363ec 100644 --- a/apps/mobile/src/components/SourceControlIcon.tsx +++ b/apps/mobile/src/components/SourceControlIcon.tsx @@ -3,7 +3,7 @@ import { withUniwind } from "uniwind"; const ThemedSvg = withUniwind(Svg); -export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops"; +export type SourceControlIconKind = "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; export function SourceControlIcon(props: { readonly kind: SourceControlIconKind; @@ -14,6 +14,21 @@ export function SourceControlIcon(props: { const size = props.size ?? 18; switch (props.kind) { + case "forgejo": + return ( + + + + ); case "github": return ( ; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; @@ -673,7 +675,14 @@ function makeManager(input?: { Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ get: () => Effect.succeed(provider), - resolveHandle: () => Effect.succeed({ provider, context: null }), + resolveHandle: ({ context }) => + Effect.succeed({ + provider, + context: + context && input?.hostingProvider + ? { ...context, provider: input.hostingProvider } + : null, + }), resolve: () => Effect.succeed(provider), discover: Effect.succeed([]), }), @@ -712,6 +721,27 @@ const GitManagerTestLayer = GitVcsDriver.layer.pipe( ); it.layer(GitManagerTestLayer)("GitManager", (it) => { + it.effect("status reports the discovered Forgejo web instance for a separate SSH host", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, [ + "remote", + "add", + "origin", + "ssh://git@ssh.example.test:2222/Owner/Repo.git", + ]); + const hostingProvider = { + kind: "forgejo", + name: "Forgejo", + baseUrl: "https://git.example.test:8443", + } as const; + const { manager } = yield* makeManager({ hostingProvider }); + const status = yield* manager.status({ cwd: repoDir }); + expect(status.sourceControlProvider).toEqual(hostingProvider); + }), + ); + it.effect("status includes draft PR metadata when branch already has a draft PR", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1645,6 +1675,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { "gitlab.example.com/group/subgroup/repository", ], ["https://bitbucket.org/team/repository/pull-requests/42", "bitbucket.org/team/repository"], + ["https://git.example.test/Owner/Repo/pulls/42", "git.example.test/owner/repo"], + ["https://git.example.test:8443/Owner/Repo/pulls/42/files", "git.example.test:8443/owner/repo"], [ "https://dev.azure.com/org/project/_git/repository/pullrequest/42", "dev.azure.com/org/project/_git/repository", diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f60eb2781872..a6620410822b 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -217,14 +217,18 @@ export function pullRequestRepositoryKey(value: string): string | null { try { const url = new URL(value); const match = - /^(.*)(?:\/pull\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec( + /^(.*)\/(pull|pulls|-\/merge_requests|pull-requests|pullrequest)\/\d+(?:\/.*)?$/iu.exec( url.pathname, ); if (match?.[1] === undefined) return null; url.pathname = match[1]; url.search = ""; url.hash = ""; - return normalizeGitRemoteUrl(url.toString()); + const key = normalizeGitRemoteUrl(url.toString()); + // Forgejo's web authority identifies the instance independently of SSH. + return match[2]?.toLowerCase() === "pulls" + ? `${url.host.toLowerCase()}/${key.split("/").slice(1).join("/")}` + : key; } catch { return null; } @@ -1262,7 +1266,15 @@ export const make = Effect.gen(function* () { (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? (yield* readConfigValueNullable(cwd, "remote.origin.url")); - return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; + const provider = remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; + if (!remoteUrl || !provider || provider.kind !== "unknown") return provider; + const handle = yield* sourceControlProviders + .resolveHandle({ + cwd, + context: { provider, remoteName: preferredRemoteName, remoteUrl }, + }) + .pipe(Effect.orElseSucceed(() => null)); + return handle?.context?.provider ?? provider; }); const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index ac1cbfb44d0e..41f939a69a56 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -9,8 +9,12 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { TestClock } from "effect/testing"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ProcessRunner from "../processRunner.ts"; +import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; +import { discovery as forgejoDiscovery } from "../sourceControl/forgejoAuth.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); @@ -37,7 +41,99 @@ const makeRepositoryIdentityResolverTestLayer = (options: { }), ).pipe(Layer.provide(ProcessRunner.layer)); -it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { +it.layer( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ForgejoCli.ForgejoCli)({ + refineUnknownRemote: (input) => Effect.succeed(forgejoDiscovery.refineUnknownRemote(input)), + }), + ), +)("RepositoryIdentityResolverLive", (it) => { + it.effect( + "recognizes authenticated Forgejo instances in repository identities and retains the web port", + () => { + const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.succeed({ + stdout: + input.command === "fj" + ? "codeberg.org\ncode.example.test:8443\n" + : input.args.includes("rev-parse") + ? "/repo\n" + : "origin\tssh://git@code.example.test:2222/Owner/Repo.git (fetch)\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + }); + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.make(); + const identity = yield* resolver.resolve("/repo"); + expect(identity?.provider).toBe("forgejo"); + expect(identity?.canonicalKey).toBe("code.example.test:8443/owner/repo"); + }).pipe(Effect.provide(processRunner)); + }, + ); + + it.effect( + "unifies SSH and HTTPS identities when Forgejo advertises a separate SSH hostname", + () => + Effect.gen(function* () { + let remoteUrl = "ssh://git@ssh.example.test:2222/Owner/Repo.git"; + const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.succeed({ + stdout: + input.command === "fj" + ? "git.example.test:8443" + : input.args.includes("rev-parse") + ? "/repo" + : `origin\t${remoteUrl} (fetch)`, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + }); + const fj = yield* ForgejoCli.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(VcsProcess.VcsProcess)({}), + FileSystem.layerNoop({ exists: () => Effect.succeed(false) }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ ssh_url: "ssh://git@ssh.example.test:2222/Owner/Repo.git" }), + ), + ), + ), + ), + ), + ), + ); + const resolver = yield* RepositoryIdentityResolver.make().pipe( + Effect.provide(processRunner), + Effect.provideService(ForgejoCli.ForgejoCli, fj), + ); + const sshIdentity = yield* resolver.resolve("/repo"); + remoteUrl = "https://git.example.test:8443/Owner/Repo.git"; + const httpsIdentity = yield* resolver.resolve("/repo", { refresh: true }); + expect(sshIdentity?.provider).toBe("forgejo"); + expect(sshIdentity?.canonicalKey).toBe("git.example.test:8443/owner/repo"); + expect(httpsIdentity?.canonicalKey).toBe(sshIdentity?.canonicalKey); + }), + ); + it.effect("refreshes the Git root only when requested", () => { const calls: Array> = []; let rootPath = "/repo"; diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 755008f6ded1..2e18382574a4 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -9,7 +9,12 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { FetchHttpClient } from "effect/unstable/http"; +import { discovery as forgejoDiscovery } from "../sourceControl/forgejoAuth.ts"; +import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as ProcessRunner from "../processRunner.ts"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; @@ -116,7 +121,11 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( "RepositoryIdentityResolver.resolveFromCacheKey", )(function* ( cacheKey: string, -): Effect.fn.Return { +): Effect.fn.Return< + RepositoryIdentity | null, + never, + ProcessRunner.ProcessRunner | ForgejoCli.ForgejoCli +> { const processRunner = yield* ProcessRunner.ProcessRunner; const remoteResult = yield* processRunner .run({ @@ -130,13 +139,41 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( } const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); - return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; + if (!remote) return null; + const identity = buildRepositoryIdentity({ ...remote, rootPath: cacheKey }); + const provider = detectSourceControlProviderFromGitRemoteUrl(remote.remoteUrl); + if (provider?.kind !== "unknown") return identity; + const auth = yield* processRunner + .run({ + command: "fj", + args: forgejoDiscovery.authArgs, + cwd: cacheKey, + timeout: 5_000, + maxOutputBytes: 8_000, + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.option); + if (Option.isNone(auth) || auth.value.code === null) return identity; + const fj = yield* ForgejoCli.ForgejoCli; + const refined = yield* fj.refineUnknownRemote({ + cwd: cacheKey, + context: { ...remote, provider }, + auth: { stdout: auth.value.stdout, stderr: auth.value.stderr, exitCode: auth.value.code }, + }); + return refined + ? { + ...identity, + provider: refined.kind, + canonicalKey: `${new URL(refined.baseUrl).host}/${identity.canonicalKey.split("/").slice(1).join("/")}`, + } + : identity; }); export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( options: RepositoryIdentityResolverOptions = {}, ) { const processRunner = yield* ProcessRunner.ProcessRunner; + const fj = yield* ForgejoCli.ForgejoCli; const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; const repositoryRootCache = yield* Cache.makeWith( @@ -158,6 +195,7 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( (cacheKey) => resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.provideService(ForgejoCli.ForgejoCli, fj), ), { capacity: cacheCapacity, @@ -185,5 +223,8 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( }); export const layer = Layer.effect(RepositoryIdentityResolver, make()).pipe( + Layer.provide( + ForgejoCli.layer.pipe(Layer.provide(VcsProcess.layer), Layer.provide(FetchHttpClient.layer)), + ), Layer.provide(ProcessRunner.layer), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e3b42a2637a1..fa05bd68b62c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,6 +50,7 @@ import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; @@ -313,7 +314,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, + GitHubCli.layer, + GitLabCli.layer, + ForgejoCli.layer, + ), ), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(VcsDriverRegistryLayerLive), diff --git a/apps/server/src/sourceControl/ForgejoCli.test.ts b/apps/server/src/sourceControl/ForgejoCli.test.ts new file mode 100644 index 000000000000..1fa22acf9d97 --- /dev/null +++ b/apps/server/src/sourceControl/ForgejoCli.test.ts @@ -0,0 +1,294 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as ForgejoCli from "./ForgejoCli.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const refinement = (remoteUrl: string, hosts: string) => ({ + cwd: "/repo", + context: { + remoteName: "origin", + remoteUrl, + provider: detectSourceControlProviderFromRemoteUrl(remoteUrl)!, + }, + auth: { stdout: hosts, stderr: "", exitCode: ChildProcessSpawner.ExitCode(0) }, +}); + +function makeCli(input: { + readonly credentials: () => string; + readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; + readonly execute?: (input: VcsProcess.VcsProcessInput) => void; +}) { + return ForgejoCli.make.pipe( + Effect.provide( + Layer.mergeAll( + Path.layer, + FileSystem.layerNoop({ + exists: () => Effect.succeed(true), + readFileString: () => Effect.sync(input.credentials), + }), + Layer.mock(VcsProcess.VcsProcess)({ + run: (request) => + Effect.sync(() => { + input.execute?.(request); + return { + stdout: "", + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => HttpClientResponse.fromWeb(request, input.response(request))), + ), + ), + ), + ), + ); +} + +it.effect("reads REST metadata using only the selected instance's token", () => + Effect.gen(function* () { + const requests: HttpClientRequest.HttpClientRequest[] = []; + const cli = yield* makeCli({ + credentials: () => + encodeJson({ + hosts: { + "git.example.test:3000": { type: "Application", token: "first-instance-token" }, + "git.example.test:4000": { type: "Application", token: "second-instance-token" }, + }, + }), + response: (request) => { + requests.push(request); + return Response.json( + { full_name: "Owner/Repo" }, + { + headers: { + link: '; rel="next"', + }, + }, + ); + }, + }); + const result = yield* cli.read({ + cwd: "/repo", + operation: "getRepositoryCloneUrls", + baseUrl: "http://git.example.test:4000/forge", + path: "/repos/Owner/Repo", + }); + assert.deepStrictEqual(result, { body: { full_name: "Owner/Repo" }, hasNextPage: true }); + assert.strictEqual( + requests[0]?.url, + "http://git.example.test:4000/forge/api/v1/repos/Owner/Repo", + ); + assert.strictEqual(requests[0]?.headers.authorization, "token second-instance-token"); + }), +); + +it.effect("lets fj refresh expired OAuth credentials and retries with the saved replacement", () => + Effect.gen(function* () { + let token = "expired"; + const commands: VcsProcess.VcsProcessInput[] = []; + const tokens: Array = []; + const cli = yield* makeCli({ + credentials: () => encodeJson({ hosts: { "git.example.test": { type: "OAuth", token } } }), + response: (request) => { + tokens.push(request.headers.authorization); + return request.headers.authorization === "token expired" + ? new Response(null, { status: 401 }) + : Response.json({ login: "alice" }); + }, + execute: (request) => { + commands.push(request); + token = "refreshed"; + }, + }); + yield* cli.read({ + cwd: "/repo", + operation: "getViewer", + baseUrl: "https://git.example.test", + path: "/user", + }); + assert.deepStrictEqual(tokens, ["token expired", "token refreshed"]); + assert.deepStrictEqual(commands[0]?.args, [ + "--style", + "minimal", + "--host", + "https://git.example.test/", + "whoami", + ]); + assert.strictEqual(commands[0]?.command, "fj"); + assert.strictEqual(commands[0]?.cwd, "/repo"); + }), +); + +it.effect("does not expose tokens or raw responses in credential and JSON errors", () => + Effect.gen(function* () { + for (const credentials of [ + encodeJson({ hosts: { "git.example.test": { type: "invalid", token: "secret" } } }), + encodeJson({ hosts: {} }), + ]) { + const cli = yield* makeCli({ + credentials: () => credentials, + response: () => new Response("secret invalid json"), + }); + const error = yield* cli + .read({ + cwd: "/repo", + operation: "getDefaultBranch", + baseUrl: "https://git.example.test", + path: "/repos/owner/repo", + }) + .pipe(Effect.flip); + assert.strictEqual(encodeJson(error).includes("secret"), false); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.operation, "getDefaultBranch"); + } + }), +); + +it.effect("does not refresh an application token rejected by Forgejo", () => + Effect.gen(function* () { + let commands = 0; + const cli = yield* makeCli({ + credentials: () => + encodeJson({ + hosts: { "git.example.test": { type: "Application", token: "expired" } }, + }), + response: () => new Response(null, { status: 401 }), + execute: () => { + commands++; + }, + }); + const error = yield* cli + .read({ + cwd: "/repo", + operation: "getViewer", + baseUrl: "https://git.example.test", + path: "/user", + }) + .pipe(Effect.flip); + assert.strictEqual(commands, 0); + assert.include(error.detail, "auth login"); + }), +); + +it.effect("uses fj's saved SSH aliases without probing unrelated hosts", () => + Effect.gen(function* () { + const cli = yield* makeCli({ + credentials: () => + encodeJson({ + hosts: { "git.example.test:8443": { type: "Application", token: "saved-token" } }, + aliases: { "ssh.example.test:2222": "git.example.test:8443" }, + }), + response: () => { + throw new Error("No HTTP request expected for a saved alias"); + }, + }); + assert.deepStrictEqual( + yield* cli.refineUnknownRemote( + refinement( + "ssh://git@ssh.example.test:2222/Owner/Repo.git", + "codeberg.org\nssh.example.test:2222\ngit.example.test:8443", + ), + ), + { kind: "forgejo", name: "Forgejo", baseUrl: "https://git.example.test:8443" }, + ); + }), +); + +it.effect("maps a separate SSH hostname using clone metadata and caches instance probes", () => + Effect.gen(function* () { + const requests: HttpClientRequest.HttpClientRequest[] = []; + const cli = yield* makeCli({ + credentials: () => + encodeJson({ + hosts: { + "git.example.test:8443": { type: "Application", token: "first-token" }, + "other.example.test": { type: "Application", token: "second-token" }, + }, + }), + response: (request) => { + requests.push(request); + return Response.json({ + ssh_url: request.url.startsWith("https://git.example.test:8443/") + ? "ssh://git@ssh.example.test:2222/Owner/Repo.git" + : "git@other.example.test:Owner/Repo.git", + }); + }, + }); + const input = refinement( + "ssh://git@ssh.example.test:2222/Owner/Repo.git", + "other.example.test\ngit.example.test:8443", + ); + const provider = yield* cli.refineUnknownRemote(input); + assert.deepStrictEqual(provider, { + kind: "forgejo", + name: "Forgejo", + baseUrl: "https://git.example.test:8443", + }); + assert.deepStrictEqual(yield* cli.refineUnknownRemote(input), provider); + assert.strictEqual(requests.length, 2); + assert.deepStrictEqual( + requests.map((request) => [request.url, request.headers.authorization]), + [ + ["https://other.example.test/api/v1/repos/Owner/Repo", "token second-token"], + ["https://git.example.test:8443/api/v1/repos/Owner/Repo", "token first-token"], + ], + ); + }), +); + +it.effect("matches scp syntax to advertised SSH URLs with the default port", () => + Effect.gen(function* () { + const cli = yield* makeCli({ + credentials: () => encodeJson({ hosts: {} }), + response: () => Response.json({ ssh_url: "ssh://git@ssh.example.test:22/Owner/Repo.git" }), + }); + assert.deepStrictEqual( + yield* cli.refineUnknownRemote( + refinement("git@ssh.example.test:Owner/Repo.git", "git.example.test"), + ), + { + kind: "forgejo", + name: "Forgejo", + baseUrl: "https://git.example.test", + }, + ); + }), +); + +it.effect("rejects ambiguous SSH mappings, unmatched clone URLs, and failed instance reads", () => + Effect.gen(function* () { + for (const response of [ + () => Response.json({ ssh_url: "git@ssh.example.test:Owner/Repo.git" }), + () => Response.json({ ssh_url: "git@another.example.test:Owner/Repo.git" }), + () => Response.json({ ssh_url: "ssh://git@ssh.example.test:2222/Owner/Repo.git" }), + () => new Response(null, { status: 404 }), + ]) { + const cli = yield* makeCli({ credentials: () => encodeJson({ hosts: {} }), response }); + assert.strictEqual( + yield* cli.refineUnknownRemote( + refinement( + "git@ssh.example.test:Owner/Repo.git", + "first.example.test\nsecond.example.test", + ), + ), + null, + ); + } + }), +); diff --git a/apps/server/src/sourceControl/ForgejoCli.ts b/apps/server/src/sourceControl/ForgejoCli.ts new file mode 100644 index 000000000000..5513bd94bd87 --- /dev/null +++ b/apps/server/src/sourceControl/ForgejoCli.ts @@ -0,0 +1,349 @@ +import * as NodeOS from "node:os"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { SourceControlProviderError, type SourceControlProviderInfo } from "@t3tools/contracts"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { discovery, parseForgejoAuthHosts } from "./forgejoAuth.ts"; +import type { SourceControlUnknownRemoteRefinementInput } from "./SourceControlProviderDiscovery.ts"; + +interface ForgejoOperation { + readonly operation: string; + readonly cwd: string; +} + +const Credentials = Schema.fromJsonString( + Schema.Struct({ + hosts: Schema.Record( + Schema.String, + Schema.Struct({ + type: Schema.Literals(["Application", "OAuth"]), + token: Schema.String.check(Schema.isMinLength(1)), + }), + ), + aliases: Schema.optional(Schema.Record(Schema.String, Schema.String)), + }), +); + +const decodeCredentials = Schema.decodeEffect(Credentials); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeCloneUrls = Schema.decodeUnknownEffect(Schema.Struct({ ssh_url: Schema.String })); + +class RemoteLookup extends Data.Class<{ + readonly cwd: string; + readonly remoteUrl: string; + readonly hosts: string; + readonly directBaseUrl: string | null; +}> {} + +function sshRemote(value: string) { + if (!isSshRemoteUrl(value)) return null; + try { + const scp = value.startsWith("ssh://") ? null : /^(?:[^@/]+@)?([^:/]+):(.+)$/u.exec(value); + const url = new URL(scp ? `ssh://${scp[1]}/${scp[2]}` : value); + const repository = url.pathname.replace(/^\/+|\/+$/gu, "").replace(/\.git$/u, ""); + if (repository.split("/").length !== 2) return null; + return { + host: `${url.hostname.toLowerCase()}${url.port && url.port !== "22" ? `:${url.port}` : ""}`, + repository, + }; + } catch { + return null; + } +} + +export class ForgejoCli extends Context.Service< + ForgejoCli, + { + readonly refineUnknownRemote: ( + input: SourceControlUnknownRemoteRefinementInput, + ) => Effect.Effect; + readonly execute: ( + input: ForgejoOperation & { + readonly args: ReadonlyArray; + readonly host?: string; + }, + ) => Effect.Effect; + readonly read: ( + input: ForgejoOperation & { + readonly baseUrl: string; + readonly path: string; + }, + ) => Effect.Effect< + { readonly body: unknown; readonly hasNextPage: boolean }, + SourceControlProviderError + >; + } +>()("t3/sourceControl/ForgejoCli") {} + +export const make = Effect.gen(function* () { + const vcsProcess = yield* VcsProcess.VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const refreshLock = yield* Semaphore.make(1); + + const fail = (input: ForgejoOperation, detail: string) => + new SourceControlProviderError({ + provider: "forgejo", + command: "fj", + operation: input.operation, + cwd: input.cwd, + detail, + }); + + const execute: ForgejoCli["Service"]["execute"] = Effect.fn("ForgejoCli.execute")( + function* (input) { + const result = yield* vcsProcess + .run({ + operation: input.operation, + command: "fj", + cwd: input.cwd, + args: [ + "--style", + "minimal", + ...(input.host ? ["--host", input.host] : []), + ...input.args, + ], + stdin: "", + timeoutMs: 30_000, + maxOutputBytes: 1_000_000, + outputMode: "error", + }) + .pipe( + Effect.mapError( + (cause) => + new SourceControlProviderError({ + provider: "forgejo", + command: "fj", + operation: input.operation, + cwd: input.cwd, + detail: + cause._tag === "VcsProcessSpawnError" + ? "Forgejo CLI (`fj`) is required but not available on PATH." + : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication" + ? "Forgejo CLI is not authenticated. Run `fj --host auth login` and retry." + : "Forgejo CLI could not complete the operation.", + cause, + }), + ), + ); + return result.stdout; + }, + ); + + // fj uses Rust's directories::ProjectDirs. The old organization name only + // changes the path on macOS/Windows; fj still reads it when migrating logins. + const home = NodeOS.homedir(); + const platform = yield* HostProcessPlatform; + const environment = yield* HostProcessEnvironment; + const credentialPaths = processEnvCredentialPaths(); + function processEnvCredentialPaths(): ReadonlyArray { + switch (platform) { + case "darwin": + return ["forgejo-cli.forgejo-cli", "Cyborus.forgejo-cli"].map((directory) => + path.join(home, "Library", "Application Support", directory, "keys.json"), + ); + case "win32": + return ["forgejo-cli", "Cyborus"].map((organization) => + path.join( + environment.APPDATA ?? path.join(home, "AppData", "Roaming"), + organization, + "forgejo-cli", + "data", + "keys.json", + ), + ); + default: + return [ + path.join( + environment.XDG_DATA_HOME || path.join(home, ".local", "share"), + "forgejo-cli", + "keys.json", + ), + ]; + } + } + + const readSavedAuth = Effect.fn("ForgejoCli.readSavedAuth")(function* (input: ForgejoOperation) { + for (const credentialPath of credentialPaths) { + if ( + !(yield* fileSystem + .exists(credentialPath) + .pipe(Effect.mapError(() => fail(input, "Could not read Forgejo CLI credentials.")))) + ) + continue; + // Never attach a decoding error here: its input contains every saved token. + const text = yield* fileSystem + .readFileString(credentialPath) + .pipe(Effect.mapError(() => fail(input, "Could not read Forgejo CLI credentials."))); + const credentials = yield* decodeCredentials(text).pipe( + Effect.mapError(() => + fail(input, "Could not read Forgejo CLI credentials. Sign in with fj again."), + ), + ); + return credentials; + } + return null; + }); + + const readCredentials = (input: ForgejoOperation, host: string) => + readSavedAuth(input).pipe(Effect.map((saved) => saved?.hosts[host] ?? null)); + + const read: ForgejoCli["Service"]["read"] = Effect.fn("ForgejoCli.read")(function* (input) { + const base = yield* Effect.try({ + try: () => new URL(input.baseUrl), + catch: () => fail(input, "Invalid Forgejo instance URL."), + }); + if ( + !/^https?:$/u.test(base.protocol) || + base.username || + base.password || + base.search || + base.hash || + !input.path.startsWith("/") || + input.path.startsWith("//") + ) { + return yield* fail(input, "Invalid Forgejo instance URL or API path."); + } + const url = `${base.href.replace(/\/+$/u, "")}/api/v1${input.path}`; + let credentials = yield* readCredentials(input, base.host); + + const request = (token: string | undefined) => { + let request = HttpClientRequest.get(url).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.setHeader("User-Agent", "T3-Code"), + ); + if (token) + request = request.pipe(HttpClientRequest.setHeader("Authorization", `token ${token}`)); + return httpClient.execute(request).pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), + Effect.mapError(() => fail(input, "Could not reach the Forgejo instance.")), + ); + }; + + let response = yield* request(credentials?.token); + if (response.status === 401 && credentials?.type === "OAuth") { + const expiredToken = credentials.token; + // Let fj own OAuth refresh, and serialize refreshes so concurrent reads + // cannot spend the same refresh token twice. + credentials = yield* refreshLock.withPermits(1)( + Effect.gen(function* () { + const current = yield* readCredentials(input, base.host); + if (current?.token !== expiredToken) return current; + yield* execute({ ...input, host: base.href, args: ["whoami"] }); + return yield* readCredentials(input, base.host); + }), + ); + response = yield* request(credentials?.token); + } + if (response.status < 200 || response.status >= 300) { + return yield* fail( + input, + response.status === 401 || response.status === 403 + ? "Forgejo denied access. Run `fj --host auth login` and check your repository permissions." + : `Forgejo returned HTTP ${response.status}.`, + ); + } + const collected = yield* collectUint8StreamText({ + stream: response.stream, + maxBytes: 4 * 1024 * 1024, + }).pipe(Effect.mapError(() => fail(input, "Could not read the Forgejo response."))); + if (collected.truncated || collected.invalidUtf8) { + return yield* fail(input, "Forgejo returned an oversized or invalid response."); + } + const body = yield* decodeJson(collected.text).pipe( + Effect.mapError(() => fail(input, "Forgejo returned invalid JSON.")), + ); + return { body, hasNextPage: /rel="next"/u.test(response.headers.link ?? "") }; + }); + + const remoteCache = yield* Cache.makeWith( + Effect.fn("ForgejoCli.resolveSshRemote")(function* (input) { + const remote = sshRemote(input.remoteUrl); + if (!remote) return null; + const hosts = input.hosts.split("\n"); + const operation = "detectProvider"; + const saved = yield* readSavedAuth({ ...input, operation }).pipe( + Effect.orElseSucceed(() => null), + ); + const alias = saved?.aliases?.[remote.host]; + if (alias && hosts.includes(alias)) { + return { kind: "forgejo", name: "Forgejo", baseUrl: `https://${alias}` }; + } + if (input.directBaseUrl) { + return { kind: "forgejo", name: "Forgejo", baseUrl: input.directBaseUrl }; + } + + // An instance can advertise an entirely different SSH hostname. Only the + // exact clone URL is evidence: the same owner/repo may exist on many hosts. + const matches = yield* Effect.forEach( + hosts, + (host) => + read({ + cwd: input.cwd, + operation, + baseUrl: `https://${host}`, + path: `/repos/${remote.repository.split("/").map(encodeURIComponent).join("/")}`, + }).pipe( + Effect.flatMap((response) => decodeCloneUrls(response.body)), + Effect.map((repository) => { + const clone = sshRemote(repository.ssh_url); + return clone?.host === remote.host && + clone.repository.toLowerCase() === remote.repository.toLowerCase() + ? host + : null; + }), + Effect.timeout("5 seconds"), + Effect.orElseSucceed(() => null), + ), + { concurrency: 3 }, + ); + const matchingHosts = matches.filter((host) => host !== null); + return matchingHosts.length === 1 + ? { kind: "forgejo", name: "Forgejo", baseUrl: `https://${matchingHosts[0]}` } + : null; + }), + { capacity: 512, timeToLive: () => "1 minute" }, + ); + + return ForgejoCli.of({ + refineUnknownRemote: (input) => { + const direct = discovery.refineUnknownRemote(input); + const hosts = parseForgejoAuthHosts(input.auth); + return hosts.length === 0 || !isSshRemoteUrl(input.context.remoteUrl) + ? Effect.succeed(direct) + : Cache.get( + remoteCache, + new RemoteLookup({ + cwd: input.cwd, + remoteUrl: input.context.remoteUrl, + hosts: hosts.join("\n"), + directBaseUrl: direct?.baseUrl ?? null, + }), + ); + }, + execute, + read: (input) => + read(input).pipe( + Effect.timeoutOrElse({ + duration: "30 seconds", + orElse: () => Effect.fail(fail(input, "Forgejo request timed out.")), + }), + ), + }); +}); + +export const layer = Layer.effect(ForgejoCli, make); diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts new file mode 100644 index 000000000000..e80d80db032f --- /dev/null +++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts @@ -0,0 +1,328 @@ +import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ForgejoCli from "./ForgejoCli.ts"; +import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; + +const context = { + provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://git.example.test:8443" }, + remoteName: "upstream", + remoteUrl: "ssh://git@ssh.example.test:2222/Owner/Repo.git", +} as const; + +const repository = { + full_name: "Owner/Repo", + clone_url: "https://git.example.test:8443/Owner/Repo.git", + ssh_url: "ssh://forge@ssh.example.test:2222/Owner/Repo.git", + default_branch: "trunk", +}; +const pullRequest = { + number: 42, + title: "Support Forgejo", + html_url: "https://git.example.test:8443/Owner/Repo/pulls/42", + state: "open", + merged: false, + draft: true, + base: { ref: "trunk", repo: { full_name: "Owner/Repo" } }, + head: { ref: "feature/forgejo", repo: { full_name: "Contributor/Repo" } }, + updated_at: "2026-09-11T00:00:00Z", +} as const; + +function makeProvider( + input: { + readonly read?: ForgejoCli.ForgejoCli["Service"]["read"]; + readonly execute?: ForgejoCli.ForgejoCli["Service"]["execute"]; + readonly refineUnknownRemote?: ForgejoCli.ForgejoCli["Service"]["refineUnknownRemote"]; + } = {}, +) { + return ForgejoSourceControlProvider.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ForgejoCli.ForgejoCli)({ + read: input.read ?? (() => Effect.succeed({ body: repository, hasNextPage: false })), + execute: input.execute ?? (() => Effect.succeed("")), + refineUnknownRemote: + input.refineUnknownRemote ?? (() => Effect.succeed(context.provider)), + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({ + readConfigValue: () => Effect.succeed(context.remoteUrl), + ensureRemote: () => Effect.succeed("forgejo"), + }), + ), + ), + ); +} + +it.effect("reads canonical clone URLs and the default branch from the selected instance", () => + Effect.gen(function* () { + const requests: Parameters[0][] = []; + const provider = yield* makeProvider({ + read: (input) => { + requests.push(input); + return Effect.succeed({ body: repository, hasNextPage: false }); + }, + }); + const urls = yield* provider.getRepositoryCloneUrls({ + cwd: "/repo", + context, + repository: "Owner/Repo", + }); + assert.deepStrictEqual(urls, { + nameWithOwner: "Owner/Repo", + url: repository.clone_url, + sshUrl: repository.ssh_url, + }); + assert.strictEqual(yield* provider.getDefaultBranch({ cwd: "/repo", context }), "trunk"); + assert.deepStrictEqual( + requests.map(({ baseUrl, path }) => ({ baseUrl, path })), + [ + { baseUrl: context.provider.baseUrl, path: "/repos/Owner/Repo" }, + { baseUrl: context.provider.baseUrl, path: "/repos/Owner/Repo" }, + ], + ); + }), +); + +it.effect("accepts remote-first repository URLs with web ports and subpaths", () => + Effect.gen(function* () { + const requests: Parameters[0][] = []; + const provider = yield* makeProvider({ + read: (input) => { + requests.push(input); + return Effect.succeed({ body: repository, hasNextPage: false }); + }, + }); + yield* provider.getRepositoryCloneUrls({ + cwd: "/tmp", + repository: "http://git.example.test:3000/forge/Owner/Repo.git", + }); + yield* provider.getRepositoryCloneUrls({ + cwd: "/tmp", + repository: "other.example.test/Owner/Repo", + }); + assert.strictEqual(requests[0]?.baseUrl, "http://git.example.test:3000/forge"); + assert.strictEqual(requests[1]?.baseUrl, "https://other.example.test"); + assert.strictEqual(requests[0]?.path, "/repos/Owner/Repo"); + const error = yield* provider + .getRepositoryCloneUrls({ cwd: "/tmp", repository: "Owner/Repo" }) + .pipe(Effect.flip); + assert.include(error.detail, "host/owner/repo"); + }), +); + +it.effect("resolves SSH clone inputs to their web instance before reading metadata", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + read: (input) => { + assert.strictEqual(input.baseUrl, context.provider.baseUrl); + return Effect.succeed({ body: repository, hasNextPage: false }); + }, + }); + assert.strictEqual( + (yield* provider.getRepositoryCloneUrls({ cwd: "/repo", repository: context.remoteUrl })).url, + repository.clone_url, + ); + const unknown = yield* makeProvider({ refineUnknownRemote: () => Effect.succeed(null) }); + const error = yield* unknown + .getRepositoryCloneUrls({ cwd: "/repo", repository: context.remoteUrl }) + .pipe(Effect.flip); + assert.include(error.detail, "HTTPS repository URL"); + }), +); + +it.effect("preserves fork identity, draft state and timestamps when reading a PR URL", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + read: (input) => { + assert.strictEqual(input.path, "/repos/Owner/Repo/pulls/42"); + assert.strictEqual(input.baseUrl, context.provider.baseUrl); + return Effect.succeed({ body: pullRequest, hasNextPage: false }); + }, + }); + const pr = yield* provider.getChangeRequest({ cwd: "/repo", reference: pullRequest.html_url }); + assert.deepStrictEqual(pr, { + provider: "forgejo", + number: 42, + title: "Support Forgejo", + url: pullRequest.html_url, + baseRefName: "trunk", + headRefName: "feature/forgejo", + state: "open", + isDraft: true, + closedAt: null, + mergedAt: null, + updatedAt: Option.some(DateTime.makeUnsafe(pullRequest.updated_at)), + isCrossRepository: true, + headRepositoryNameWithOwner: "Contributor/Repo", + headRepositoryOwnerLogin: "Contributor", + }); + }), +); + +it.effect("pages past other forks and closed unmerged PRs before applying the result limit", () => + Effect.gen(function* () { + let page = 0; + const provider = yield* makeProvider({ + read: (input) => { + page++; + const query = new URL(`${input.baseUrl}${input.path}`).searchParams; + assert.strictEqual(query.get("head"), "feature/forgejo"); + assert.strictEqual(query.get("state"), "closed"); + assert.strictEqual(query.get("page"), String(page)); + return Effect.succeed({ + body: + page === 1 + ? [ + { ...pullRequest, state: "closed", merged: false }, + { + ...pullRequest, + state: "closed", + merged: true, + head: { ref: "feature/forgejo", repo: { full_name: "SomeoneElse/Repo" } }, + }, + ] + : [ + { + ...pullRequest, + state: "closed", + merged: true, + merged_at: "2026-09-11T01:00:00Z", + }, + ], + hasNextPage: page === 1, + }); + }, + }); + const prs = yield* provider.listChangeRequests({ + cwd: "/repo", + context, + headSelector: "Contributor:feature/forgejo", + state: "merged", + limit: 1, + }); + assert.strictEqual(page, 2); + assert.strictEqual(prs.length, 1); + assert.strictEqual(prs[0]?.state, "merged"); + assert.strictEqual(prs[0]?.mergedAt, "2026-09-11T01:00:00Z"); + }), +); + +it.effect("creates a fork PR with explicit target, branches, title and body file", () => + Effect.gen(function* () { + const calls: Parameters[0][] = []; + const provider = yield* makeProvider({ + execute: (input) => { + calls.push(input); + return Effect.succeed(""); + }, + }); + yield* provider.createChangeRequest({ + cwd: "/repo", + context, + source: { owner: "Contributor", repository: "Contributor/Repo", refName: "feature/forgejo" }, + target: { repository: "Owner/Repo", refName: "trunk" }, + headSelector: "feature/forgejo", + baseRefName: "main", + title: "--literal title $(not a command)", + bodyFile: "/tmp/body file.md", + }); + assert.deepStrictEqual(calls, [ + { + cwd: "/repo", + operation: "createChangeRequest", + host: context.provider.baseUrl, + args: [ + "pr", + "create", + "--repo", + "Owner/Repo", + "--base", + "trunk", + "--head", + "Contributor:feature/forgejo", + "--body-file", + "/tmp/body file.md", + "--", + "--literal title $(not a command)", + ], + }, + ]); + }), +); + +it.effect( + "publishes personal and organization repositories using their respective fj commands", + () => + Effect.gen(function* () { + for (const owner of ["Alice", "Team"]) { + const calls: Parameters[0][] = []; + const provider = yield* makeProvider({ + read: (input) => + Effect.succeed({ + body: input.path === "/user" ? { login: "alice" } : repository, + hasNextPage: false, + }), + execute: (input) => { + calls.push(input); + return Effect.succeed(""); + }, + }); + yield* provider.createRepository({ + cwd: "/repo", + repository: `git.example.test:8443/${owner}/Repo`, + visibility: "private", + }); + assert.deepStrictEqual( + calls[0]?.args, + owner === "Alice" + ? ["repo", "create", "Repo", "--private"] + : ["org", "repo", "create", "Team", "Repo", "--private"], + ); + assert.strictEqual(calls[0]?.host, context.provider.baseUrl); + } + }), +); + +it.effect("checks out through fj using the selected remote and a separate fork branch", () => + Effect.gen(function* () { + const calls: Parameters[0][] = []; + const provider = yield* makeProvider({ + read: (input) => + Effect.succeed({ + body: input.path.endsWith("/pulls/42") ? pullRequest : repository, + hasNextPage: false, + }), + execute: (input) => { + calls.push(input); + return Effect.succeed(""); + }, + }); + yield* provider.checkoutChangeRequest({ cwd: "/repo", context, reference: "42" }); + assert.deepStrictEqual(calls[0]?.args, [ + "pr", + "--remote", + "forgejo", + "checkout", + "42", + "--branch-name", + "pr-42/feature/forgejo", + ]); + }), +); + +it.effect("rejects malformed metadata instead of returning incomplete change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + read: () => Effect.succeed({ body: { number: 42 }, hasNextPage: false }), + }); + const error = yield* provider + .getChangeRequest({ cwd: "/repo", context, reference: "42" }) + .pipe(Effect.flip); + assert.strictEqual(error.operation, "getChangeRequest"); + assert.include(error.detail, "unexpected response"); + }), +); diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts new file mode 100644 index 000000000000..94e0d79f2347 --- /dev/null +++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts @@ -0,0 +1,415 @@ +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; +import { + detectSourceControlProviderFromRemoteUrl, + isSshRemoteUrl, +} from "@t3tools/shared/sourceControl"; +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 { ChildProcessSpawner } from "effect/unstable/process"; + +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ForgejoCli from "./ForgejoCli.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { discovery } from "./forgejoAuth.ts"; + +export const makeDiscovery = Effect.gen(function* () { + const fj = yield* ForgejoCli.ForgejoCli; + return { ...discovery, refineUnknownRemote: fj.refineUnknownRemote }; +}); + +const Repository = Schema.Struct({ + full_name: Schema.String, + clone_url: Schema.String, + ssh_url: Schema.String, + default_branch: Schema.optional(Schema.String), +}); +const Branch = Schema.Struct({ + ref: Schema.String, + repo: Schema.NullOr(Schema.Struct({ full_name: Schema.String })), +}); +const PullRequest = Schema.Struct({ + number: Schema.Int.check(Schema.isGreaterThan(0)), + title: Schema.String, + html_url: Schema.String, + state: Schema.Literals(["open", "closed"]), + merged: Schema.Boolean, + draft: Schema.optional(Schema.Boolean), + base: Branch, + head: Branch, + updated_at: Schema.optional(Schema.NullOr(Schema.String)), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), +}); + +interface RepositoryLocator { + readonly baseUrl: string; + readonly repository: string; +} + +/** Keep the web authority separate from the repository, including nonstandard ports/subpaths. */ +function parseRepository(value: string, baseUrl?: string): RepositoryLocator | null { + const trimmed = value + .trim() + .replace(/\/+$/u, "") + .replace(/\.git$/u, ""); + let url: URL; + try { + const scp = /^[^@/]+@([^:/]+):(.+)$/u.exec(trimmed); + if (scp) { + url = new URL(`${baseUrl ?? `https://${scp[1]}`}/${scp[2]}`); + } else if (/^(?:https?|ssh):\/\//u.test(trimmed)) { + url = new URL(trimmed); + if (url.protocol === "ssh:") { + url = new URL(url.pathname.replace(/^\//u, ""), `${baseUrl ?? `https://${url.host}`}/`); + } + } else if (trimmed.split("/").length === 2 && baseUrl) { + url = new URL(`${baseUrl.replace(/\/+$/u, "")}/${trimmed}`); + } else if (trimmed.split("/").length >= 3) { + url = new URL(`https://${trimmed}`); + } else { + return null; + } + if (url.username || url.password || url.search || url.hash) return null; + const segments = url.pathname.split("/").filter(Boolean); + const repository = segments.slice(-2).map(decodeURIComponent); + if ( + repository.length !== 2 || + repository.some( + (part) => + !part || part.startsWith("-") || /[\s/:?#]/u.test(part) || part === "." || part === "..", + ) + ) + return null; + return { + baseUrl: `${url.origin}${segments.length > 2 ? `/${segments.slice(0, -2).join("/")}` : ""}`, + repository: repository.join("/"), + }; + } catch { + return null; + } +} + +function repositoryPath(locator: RepositoryLocator): string { + return `/repos/${locator.repository.split("/").map(encodeURIComponent).join("/")}`; +} + +function cloneUrls(repository: typeof Repository.Type) { + return { + nameWithOwner: repository.full_name, + url: repository.clone_url, + sshUrl: repository.ssh_url || repository.clone_url, + }; +} + +function toChangeRequest(pr: typeof PullRequest.Type): ChangeRequest { + const headRepository = pr.head.repo?.full_name; + return { + provider: "forgejo", + number: pr.number, + title: pr.title, + url: pr.html_url, + baseRefName: pr.base.ref, + headRefName: pr.head.ref, + state: pr.merged ? "merged" : pr.state, + isDraft: pr.draft ?? pr.title.startsWith("WIP:"), + closedAt: pr.closed_at ?? null, + mergedAt: pr.merged_at ?? null, + updatedAt: pr.updated_at ? DateTime.make(pr.updated_at) : Option.none(), + ...(headRepository + ? { + isCrossRepository: headRepository !== pr.base.repo?.full_name, + headRepositoryNameWithOwner: headRepository, + headRepositoryOwnerLogin: headRepository.split("/")[0] ?? null, + } + : {}), + }; +} + +export const make = Effect.gen(function* () { + const fj = yield* ForgejoCli.ForgejoCli; + const git = yield* GitVcsDriver.GitVcsDriver; + + const fail = (operation: string, cwd: string, detail: string) => + new SourceControlProviderError({ + provider: "forgejo", + command: "fj", + operation, + cwd, + detail, + }); + + const resolveRepository = Effect.fn("ForgejoSourceControlProvider.resolveRepository")(function* ( + input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; + }, + operation: string, + ) { + const remote = + input.repository ?? + input.context?.remoteUrl ?? + (yield* git + .readConfigValue(input.cwd, "remote.origin.url") + .pipe(Effect.orElseSucceed(() => null))); + let baseUrl = input.context?.provider.baseUrl; + if (remote && isSshRemoteUrl(remote) && (input.repository || !baseUrl)) { + const provider = detectSourceControlProviderFromRemoteUrl(remote); + const stdout = yield* fj.execute({ cwd: input.cwd, operation, args: discovery.authArgs }); + const refined = provider + ? yield* fj.refineUnknownRemote({ + cwd: input.cwd, + context: { provider, remoteUrl: remote, remoteName: "origin" }, + auth: { stdout, stderr: "", exitCode: ChildProcessSpawner.ExitCode(0) }, + }) + : null; + if (!refined) + return yield* fail( + operation, + input.cwd, + "Could not match this SSH remote to a Forgejo instance. Sign in with fj or use the HTTPS repository URL.", + ); + baseUrl = refined.baseUrl; + } + const locator = remote ? parseRepository(remote, baseUrl) : null; + if (!locator) + return yield* fail( + operation, + input.cwd, + "Specify the Forgejo repository as host/owner/repo or a full repository URL.", + ); + return locator; + }); + + const read = ( + input: { + readonly cwd: string; + readonly operation: string; + readonly baseUrl: string; + readonly path: string; + }, + schema: S, + ) => + fj.read(input).pipe( + Effect.flatMap((response) => + Schema.decodeUnknownEffect(schema)(response.body).pipe( + Effect.map((body) => ({ body, hasNextPage: response.hasNextPage })), + Effect.mapError(() => + fail(input.operation, input.cwd, "Forgejo returned unexpected response data."), + ), + ), + ), + ); + + const readRepository = (cwd: string, operation: string, locator: RepositoryLocator) => + read( + { cwd, operation, baseUrl: locator.baseUrl, path: repositoryPath(locator) }, + Repository, + ).pipe(Effect.map((response) => response.body)); + + const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = + Effect.fn("ForgejoSourceControlProvider.listChangeRequests")(function* (input) { + const operation = "listChangeRequests"; + const locator = yield* resolveRepository(input, operation); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const branch = SourceControlProvider.sourceBranch(input); + const limit = Math.max(1, Math.min(input.limit ?? 20, 100)); + const items: ChangeRequest[] = []; + let page = 1; + do { + const query = new URLSearchParams({ + state: input.state === "merged" ? "closed" : input.state, + head: branch, + sort: "recentupdate", + limit: "50", + page: String(page), + }); + const response = yield* read( + { + cwd: input.cwd, + operation, + baseUrl: locator.baseUrl, + path: `${repositoryPath(locator)}/pulls?${query}`, + }, + Schema.Array(PullRequest), + ); + for (const raw of response.body) { + const pr = toChangeRequest(raw); + if (pr.headRefName !== branch) continue; + if ( + source?.repository && + pr.headRepositoryNameWithOwner?.toLowerCase() !== source.repository.toLowerCase() + ) + continue; + if ( + source?.owner && + pr.headRepositoryOwnerLogin?.toLowerCase() !== source.owner.toLowerCase() + ) + continue; + if (input.state !== "all" && pr.state !== input.state) continue; + items.push(pr); + if (items.length === limit) return items; + } + if (!response.hasNextPage || response.body.length === 0) return items; + page += 1; + } while (true); + }); + + const resolveReference = Effect.fn("ForgejoSourceControlProvider.resolveReference")(function* ( + input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }, + operation: string, + ) { + const reference = input.reference.trim(); + const urlMatch = /^(https?:\/\/.+)\/pulls\/(\d+)(?:[/?#].*)?$/u.exec(reference); + const locator = yield* resolveRepository( + { ...input, ...(urlMatch ? { repository: urlMatch[1] } : {}) }, + operation, + ); + const number = Number(urlMatch?.[2] ?? reference.replace(/^#/u, "")); + if (!Number.isSafeInteger(number) || number <= 0) + return yield* fail(operation, input.cwd, "Enter a Forgejo pull request number or URL."); + return { ...locator, number }; + }); + + const getChangeRequest: SourceControlProvider.SourceControlProvider["Service"]["getChangeRequest"] = + Effect.fn("ForgejoSourceControlProvider.getChangeRequest")(function* (input) { + if (!/^#?\d+$/u.test(input.reference) && !/^https?:\/\//u.test(input.reference)) { + const [pr] = yield* listChangeRequests({ + ...input, + headSelector: input.reference, + state: "all", + limit: 1, + }); + if (!pr) + return yield* fail( + "getChangeRequest", + input.cwd, + "No Forgejo pull request was found for this branch.", + ); + return pr; + } + const locator = yield* resolveReference(input, "getChangeRequest"); + const response = yield* read( + { + cwd: input.cwd, + operation: "getChangeRequest", + baseUrl: locator.baseUrl, + path: `${repositoryPath(locator)}/pulls/${locator.number}`, + }, + PullRequest, + ); + return toChangeRequest(response.body); + }); + + return SourceControlProvider.SourceControlProvider.of({ + kind: "forgejo", + listChangeRequests, + getChangeRequest, + getRepositoryCloneUrls: Effect.fn("ForgejoSourceControlProvider.getRepositoryCloneUrls")( + function* (input) { + const locator = yield* resolveRepository(input, "getRepositoryCloneUrls"); + return cloneUrls(yield* readRepository(input.cwd, "getRepositoryCloneUrls", locator)); + }, + ), + getDefaultBranch: Effect.fn("ForgejoSourceControlProvider.getDefaultBranch")(function* (input) { + const locator = yield* resolveRepository(input, "getDefaultBranch"); + const repository = yield* readRepository(input.cwd, "getDefaultBranch", locator); + return repository.default_branch || null; + }), + createRepository: Effect.fn("ForgejoSourceControlProvider.createRepository")(function* (input) { + const operation = "createRepository"; + const locator = yield* resolveRepository(input, operation); + const [owner, name] = locator.repository.split("/"); + const user = yield* read( + { cwd: input.cwd, operation, baseUrl: locator.baseUrl, path: "/user" }, + Schema.Struct({ login: Schema.String }), + ); + const args = + user.body.login.toLowerCase() === owner?.toLowerCase() + ? ["repo", "create", name!] + : ["org", "repo", "create", owner!, name!]; + yield* fj.execute({ + cwd: input.cwd, + operation, + host: locator.baseUrl, + args: [...args, ...(input.visibility === "private" ? ["--private"] : [])], + }); + return cloneUrls(yield* readRepository(input.cwd, operation, locator)); + }), + createChangeRequest: Effect.fn("ForgejoSourceControlProvider.createChangeRequest")( + function* (input) { + const operation = "createChangeRequest"; + const locator = yield* resolveRepository(input, operation); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const owner = source?.owner ?? source?.repository?.split("/")[0]; + const branch = SourceControlProvider.sourceBranch(input); + const head = owner ? `${owner}:${branch}` : branch; + yield* fj.execute({ + cwd: input.cwd, + operation, + host: locator.baseUrl, + args: [ + "pr", + "create", + "--repo", + input.target?.repository ?? locator.repository, + "--base", + input.target?.refName ?? input.baseRefName, + "--head", + head, + "--body-file", + input.bodyFile, + "--", + input.title, + ], + }); + }, + ), + checkoutChangeRequest: Effect.fn("ForgejoSourceControlProvider.checkoutChangeRequest")( + function* (input) { + const operation = "checkoutChangeRequest"; + const locator = yield* resolveReference(input, operation); + const pr = yield* getChangeRequest(input); + const repository = yield* readRepository(input.cwd, operation, locator); + const contextRepository = input.context ? parseRepository(input.context.remoteUrl) : null; + // fj resolves the API host from --remote even when --host is supplied. + // A web remote keeps checkout working when SSH has its own authority. + const remoteName = + contextRepository?.repository === locator.repository && + contextRepository.baseUrl === locator.baseUrl + ? input.context!.remoteName + : yield* git + .ensureRemote({ + cwd: input.cwd, + preferredName: "forgejo", + url: repository.clone_url, + }) + .pipe( + Effect.mapError(() => + fail(operation, input.cwd, "Could not configure the Forgejo Git remote."), + ), + ); + yield* fj.execute({ + cwd: input.cwd, + operation, + host: locator.baseUrl, + args: [ + "pr", + "--remote", + remoteName, + "checkout", + String(locator.number), + "--branch-name", + pr.isCrossRepository ? `pr-${pr.number}/${pr.headRefName}` : pr.headRefName, + ], + }); + }, + ), + }); +}); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 9e4702af04cd..887c8ed67130 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -12,6 +12,8 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; +import * as ForgejoCli from "./ForgejoCli.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -30,6 +32,8 @@ const sourceControlProviderRegistryTestLayer = (input: { Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), Layer.mock(GitHubCli.GitHubCli)({}), Layer.mock(GitLabCli.GitLabCli)({}), + Layer.mock(ForgejoCli.ForgejoCli)({}), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), ), @@ -143,6 +147,12 @@ it.effect("reports implemented tools separately from locally available executabl auth: "authenticated", account: Option.some("juliusmarminge"), }, + { + kind: "forgejo", + status: "missing", + auth: "unknown", + account: Option.none(), + }, { kind: "gitlab", status: "missing", @@ -175,6 +185,10 @@ it.effect("probes provider authentication without exposing token details", () => if (input.args[0] === "--version") { return Effect.succeed(processOutput(`${input.command} version test\n`)); } + if (input.command === "fj" && input.args.join(" ") === "version") + return Effect.succeed(processOutput("fj v0.6.0")); + if (input.command === "fj" && input.args.join(" ") === "auth list") + return Effect.succeed(processOutput("codeberg.org\ngit.example.test")); if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { return Effect.succeed( processOutput( @@ -259,6 +273,12 @@ Logged in to gitlab.com as gitlab-user account: Option.some("octocat"), detail: Option.none(), }, + { + kind: "forgejo", + auth: "authenticated", + account: Option.none(), + detail: Option.none(), + }, { kind: "gitlab", auth: "authenticated", diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index 69ac90edbfb3..c092239c8efb 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -37,7 +37,7 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, - ) => SourceControlProviderInfo | null; + ) => SourceControlProviderInfo | null | Effect.Effect; }; export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { @@ -288,8 +288,8 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide } const context = input.context; - const providers = yield* Effect.forEach(input.specs.filter(isCliRemoteRefinementSpec), (spec) => - input.process + for (const spec of input.specs.filter(isCliRemoteRefinementSpec)) { + const provider = yield* input.process .run({ operation: "source-control.discovery.refine-unknown-remote", command: spec.executable, @@ -301,18 +301,18 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide appendTruncationMarker: true, }) .pipe( - Effect.map((auth) => - spec.refineUnknownRemote({ + Effect.flatMap((auth) => { + const result = spec.refineUnknownRemote({ cwd: input.cwd, context, auth, - }), - ), + }); + return Effect.isEffect(result) ? result : Effect.succeed(result); + }), Effect.orElseSucceed(() => null), - ), - ); - const provider = providers.find((candidate) => candidate !== null); - - return provider ? { ...context, provider } : context; + ); + if (provider) return { ...context, provider }; + } + return context; }, ); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 54038502bfde..e9f2f68c5735 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -14,6 +14,9 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; +import * as ForgejoCli from "./ForgejoCli.ts"; +import { discovery as forgejoDiscovery } from "./forgejoAuth.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -92,6 +95,11 @@ function makeRegistry(input: { Layer.mock(BitbucketApi.BitbucketApi)({}), Layer.mock(GitHubCli.GitHubCli)({}), Layer.mock(GitLabCli.GitLabCli)({}), + Layer.mock(ForgejoCli.ForgejoCli)({ + refineUnknownRemote: (input) => + Effect.succeed(forgejoDiscovery.refineUnknownRemote(input)), + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", }).pipe(Layer.provide(NodeServices.layer)), @@ -176,24 +184,51 @@ it.effect("routes GitLab remotes to the GitLab provider", () => }), ); +it.effect("routes all authenticated Forgejo instances and caches detection", () => + Effect.gen(function* () { + const calls: VcsProcess.VcsProcessInput[] = []; + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "ssh://git@code.example.test:2222/Owner/Repo.git" }], + process: { + run: (input) => + Effect.sync(() => { + calls.push(input); + return processOutput( + input.command === "fj" ? "codeberg.org\ncode.example.test:8443" : "", + ); + }), + }, + }); + const handle = yield* registry.resolveHandle({ cwd: "/repo" }); + assert.strictEqual(handle.provider.kind, "forgejo"); + assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test:8443"); + const count = calls.length; + yield* registry.resolve({ cwd: "/repo" }); + assert.strictEqual(calls.length, count); + assert.deepStrictEqual(calls.find((call) => call.command === "fj")?.args, ["auth", "list"]); + }), +); + it.effect("routes authenticated self-hosted GitLab remotes without relying on host naming", () => Effect.gen(function* () { const registry = yield* makeRegistry({ remotes: [{ name: "origin", url: "https://self-hosted.example.test/group/project.git" }], process: { - run: () => - Effect.succeed( - processOutput( - `gitlab.com + run: ({ command }) => + command !== "glab" + ? Effect.succeed(processOutput("")) + : Effect.succeed( + processOutput( + `gitlab.com x gitlab.com: API call failed: 401 Unauthorized ! No token found self-hosted.example.test ✓ Logged in to self-hosted.example.test as gitlab-user ✓ Token found: ****** `, - { exitCode: ChildProcessSpawner.ExitCode(1) }, - ), - ), + { exitCode: ChildProcessSpawner.ExitCode(1) }, + ), + ), }, }); @@ -208,12 +243,14 @@ it.effect("refines the caller-selected remote instead of choosing another config const registry = yield* makeRegistry({ remotes: [{ name: "origin", url: "git@github.com:fork/project.git" }], process: { - run: () => - Effect.succeed( - processOutput(`self-hosted.example.test + run: ({ command }) => + command !== "glab" + ? Effect.succeed(processOutput("")) + : Effect.succeed( + processOutput(`self-hosted.example.test ✓ Logged in to self-hosted.example.test as gitlab-user `), - ), + ), }, }); @@ -240,15 +277,17 @@ it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports const registry = yield* makeRegistry({ remotes: [{ name: "origin", url: "https://self-hosted.example.test:8443/group/project.git" }], process: { - run: () => - Effect.succeed( - processOutput( - `self-hosted.example.test:8443 + run: ({ command }) => + command !== "glab" + ? Effect.succeed(processOutput("")) + : Effect.succeed( + processOutput( + `self-hosted.example.test:8443 ✓ Logged in to self-hosted.example.test:8443 as gitlab-user ✓ Token found: ****** `, - ), - ), + ), + ), }, }); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index e9b61c17a4f4..2a62a3544b18 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -13,6 +13,7 @@ import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/source import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; +import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; @@ -296,6 +297,8 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit export const make = Effect.gen(function* () { const github = yield* GitHubSourceControlProvider.make; const gitlab = yield* GitLabSourceControlProvider.make; + const forgejo = yield* ForgejoSourceControlProvider.make; + const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery; const bitbucket = yield* BitbucketSourceControlProvider.make; const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; const azureDevOps = yield* AzureDevOpsSourceControlProvider.make; @@ -305,6 +308,11 @@ export const make = Effect.gen(function* () { provider: github, discovery: GitHubSourceControlProvider.discovery, }, + { + kind: "forgejo", + provider: forgejo, + discovery: forgejoDiscovery, + }, { kind: "gitlab", provider: gitlab, diff --git a/apps/server/src/sourceControl/forgejoAuth.test.ts b/apps/server/src/sourceControl/forgejoAuth.test.ts new file mode 100644 index 000000000000..18f54b21e6e5 --- /dev/null +++ b/apps/server/src/sourceControl/forgejoAuth.test.ts @@ -0,0 +1,94 @@ +import { assert, it } from "@effect/vitest"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import * as Option from "effect/Option"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { discovery, parseForgejoAuthHosts } from "./forgejoAuth.ts"; + +const auth = (stdout: string, code = 0, stderr = "") => ({ + stdout, + stderr, + exitCode: ChildProcessSpawner.ExitCode(code), +}); + +const refine = (remoteUrl: string, stdout: string, code = 0) => + discovery.refineUnknownRemote({ + cwd: "/repo", + context: { + remoteName: "origin", + remoteUrl, + provider: detectSourceControlProviderFromRemoteUrl(remoteUrl)!, + }, + auth: auth(stdout, code), + }); + +it("recognizes every authenticated instance without hostname heuristics", () => { + for (const host of ["codeberg.org", "git.example.test", "git.gay"]) { + assert.deepStrictEqual( + refine(`git@${host}:owner/repo.git`, "codeberg.org\ngit.example.test\ngit.gay\n"), + { + kind: "forgejo", + name: "Forgejo", + baseUrl: `https://${host}`, + }, + ); + } +}); + +it("keeps ports and HTTP origins distinct, and maps an unambiguous SSH host", () => { + assert.deepStrictEqual( + refine( + "http://git.example.test:3000/owner/repo.git", + "git.example.test:3000\ngit.example.test:4000", + ), + { + kind: "forgejo", + name: "Forgejo", + baseUrl: "http://git.example.test:3000", + }, + ); + assert.strictEqual( + refine( + "https://git.example.test:5000/owner/repo", + "git.example.test:3000\ngit.example.test:4000", + ), + null, + ); + assert.strictEqual( + refine( + "ssh://git@git.example.test:2222/owner/repo", + "git.example.test:3000\ngit.example.test:4000", + ), + null, + ); + assert.deepStrictEqual( + refine("ssh://git@git.example.test:2222/owner/repo", "git.example.test:3000"), + { + kind: "forgejo", + name: "Forgejo", + baseUrl: "https://git.example.test:3000", + }, + ); +}); + +it("does not classify unauthenticated hosts or trust failed probes and stderr", () => { + assert.strictEqual(refine("https://other.example.test/owner/repo", "git.example.test"), null); + assert.strictEqual(refine("https://git.example.test/owner/repo", "git.example.test", 1), null); + assert.deepStrictEqual(parseForgejoAuthHosts(auth("", 0, "git.example.test")), []); + assert.deepStrictEqual( + parseForgejoAuthHosts( + auth( + " GIT.EXAMPLE.TEST:8443\r\ngit.example.test:8443\nerror: not logged in\nhttps://user:secret@git.other.test\n", + ), + ), + ["git.example.test:8443"], + ); +}); + +it("reports all configured hosts and provides login guidance for an empty list", () => { + const authenticated = discovery.parseAuth(auth("codeberg.org\ngit.example.test")); + assert.strictEqual(authenticated.status, "authenticated"); + assert.deepStrictEqual(authenticated.host, Option.some("codeberg.org, git.example.test")); + assert.strictEqual(discovery.parseAuth(auth("")).status, "unauthenticated"); + assert.strictEqual(discovery.parseAuth(auth("", 1)).status, "unknown"); +}); diff --git a/apps/server/src/sourceControl/forgejoAuth.ts b/apps/server/src/sourceControl/forgejoAuth.ts new file mode 100644 index 000000000000..7e34dc164991 --- /dev/null +++ b/apps/server/src/sourceControl/forgejoAuth.ts @@ -0,0 +1,65 @@ +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; + +import { + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, + type SourceControlUnknownRemoteRefinementInput, +} from "./SourceControlProviderDiscovery.ts"; + +/** `fj auth list` prints one authenticated authority per line, without tokens. */ +export function parseForgejoAuthHosts(input: SourceControlAuthProbeInput): ReadonlyArray { + if (input.exitCode !== 0) return []; + const hosts = new Set(); + for (const line of input.stdout.split(/\r?\n/u)) { + const host = line.trim().toLowerCase(); + if (!/^(?:[a-z0-9][a-z0-9.-]*|\[[a-f0-9:]+\])(?::\d+)?$/u.test(host)) continue; + try { + const url = new URL(`https://${host}`); + hosts.add(url.host); + } catch { + // A malformed line is not evidence that a host runs Forgejo. + } + } + return [...hosts]; +} + +function refineUnknownForgejoRemote(input: SourceControlUnknownRemoteRefinementInput) { + const hosts = parseForgejoAuthHosts(input.auth); + const remote = new URL(input.context.provider.baseUrl); + let host = hosts.find((candidate) => candidate === remote.host.toLowerCase()); + if (!host && isSshRemoteUrl(input.context.remoteUrl)) { + // SSH and the web API can listen on different ports. Only infer that mapping + // when fj knows exactly one web authority for this hostname. + const matchingHosts = hosts.filter( + (candidate) => new URL(`https://${candidate}`).hostname === remote.hostname.toLowerCase(), + ); + if (matchingHosts.length === 1) host = matchingHosts[0]; + } + if (!host) return null; + const protocol = input.context.remoteUrl.startsWith("http://") ? "http:" : "https:"; + return { kind: "forgejo", name: "Forgejo", baseUrl: `${protocol}//${host}` } as const; +} + +export const discovery = { + type: "cli", + kind: "forgejo", + label: "Forgejo", + executable: "fj", + versionArgs: ["version"], + authArgs: ["auth", "list"], + parseAuth: (input: SourceControlAuthProbeInput) => { + const hosts = parseForgejoAuthHosts(input); + return providerAuth( + hosts.length > 0 + ? { status: "authenticated", host: hosts.join(", ") } + : { + status: input.exitCode === 0 ? "unauthenticated" : "unknown", + detail: "Run `fj --host auth login` to authenticate Forgejo CLI.", + }, + ); + }, + refineUnknownRemote: refineUnknownForgejoRemote, + installHint: + "Install Forgejo CLI (`fj`) from https://codeberg.org/forgejo-contrib/forgejo-cli or with `brew install forgejo-cli`.", +} satisfies SourceControlCliDiscoverySpec; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f59a753d9c72..80979b3cfe55 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -154,6 +154,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -3103,6 +3104,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer, + ForgejoCli.layer, ), ), Layer.provideMerge(GitVcsDriver.layer), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4a9877f87a02..80f7acc6eb26 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -149,7 +149,7 @@ import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sideb import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; import { CommandPaletteResults } from "./CommandPaletteResults"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; +import { AzureDevOpsIcon, BitbucketIcon, ForgejoIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; @@ -243,7 +243,7 @@ interface AddProjectEnvironmentOption { type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" >; type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; @@ -266,12 +266,14 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ "url", "github", "gitlab", + "forgejo", "bitbucket", "azure-devops", ]; const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [ "github", "gitlab", + "forgejo", "bitbucket", "azure-devops", ]; @@ -282,6 +284,8 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { return "GitHub"; case "gitlab": return "GitLab"; + case "forgejo": + return "Forgejo"; case "bitbucket": return "Bitbucket"; case "azure-devops": @@ -297,6 +301,8 @@ function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { return "owner/repo"; case "gitlab": return "group/project"; + case "forgejo": + return "host/owner/repo"; case "bitbucket": return "workspace/repository"; case "azure-devops": @@ -318,6 +324,8 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri return ; case "gitlab": return ; + case "forgejo": + return ; case "bitbucket": return ; case "azure-devops": @@ -369,6 +377,7 @@ function buildAddProjectRemoteSourceReadiness( url: { ready: true, hint: null }, github: unavailable, gitlab: unavailable, + forgejo: unavailable, bitbucket: unavailable, "azure-devops": unavailable, }; @@ -1757,6 +1766,7 @@ function OpenCommandPaletteDialog(props: { "git", "github", "gitlab", + "forgejo", "bitbucket", "azure", "devops", diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index aa3767a3155a..3ea5d242febb 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -39,7 +39,13 @@ import { GlobeIcon, } from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons"; +import { + AzureDevOpsIcon, + BitbucketIcon, + ForgejoIcon, + GitHubIcon, + GitLabIcon, +} from "~/components/Icons"; import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; import { toggleVariants } from "~/components/ui/toggle"; @@ -123,7 +129,7 @@ interface PendingDefaultBranchAction { type PublishProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" >; type GitActionToastId = ReturnType; @@ -179,6 +185,14 @@ const PUBLISH_PROVIDER_OPTIONS = [ pathPlaceholder: "owner/repo", Icon: GitHubIcon, }, + { + value: "forgejo", + label: "Forgejo", + description: "Your Forgejo instance", + host: "", + pathPlaceholder: "host/owner/repo", + Icon: ForgejoIcon, + }, { value: "gitlab", label: "GitLab", @@ -425,6 +439,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const accounts: Record = { github: null, gitlab: null, + forgejo: null, bitbucket: null, "azure-devops": null, }; @@ -668,7 +683,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
- {publishHost}/ + {publishHost ? `${publishHost}/` : "Forgejo"} ( ); +// Forgejo brand mark from Simple Icons (CC0). +export const ForgejoIcon: Icon = (props) => ( + +); + export const GitHubIcon: Icon = (props) => ( > = { github: GitHubIcon, gitlab: GitLabIcon, + forgejo: ForgejoIcon, "azure-devops": AzureDevOpsIcon, bitbucket: BitbucketIcon, }; diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f56579990714..fa02352db9df 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -481,7 +481,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Source control", to: "/settings/source-control", searchTerms: [ - "version control git github gitlab bitbucket azure devops hosting integrations credentials scan server environment", + "version control git github gitlab forgejo codeberg fj bitbucket azure devops hosting integrations credentials scan server environment", ], }, { diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 5e534af0a0be..f95d2b2d7127 100644 --- a/apps/web/src/pullRequestReference.test.ts +++ b/apps/web/src/pullRequestReference.test.ts @@ -3,6 +3,16 @@ import { describe, expect, it } from "vite-plus/test"; import { parsePullRequestReference } from "./pullRequestReference"; describe("parsePullRequestReference", () => { + it("accepts Forgejo URLs on any instance and fj checkout commands", () => { + for (const url of [ + "https://codeberg.org/owner/repo/pulls/42", + "http://git.example.test:3000/forge/owner/repo/pulls/42#discussion", + ]) { + expect(parsePullRequestReference(url)).toBe(url); + } + expect(parsePullRequestReference("fj pr checkout 42")).toBe("42"); + }); + it("accepts GitHub pull request URLs", () => { expect(parsePullRequestReference("https://github.com/pingdotgg/t3code/pull/42")).toBe( "https://github.com/pingdotgg/t3code/pull/42", diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index b919e736cc09..5f8326df10f3 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -4,6 +4,9 @@ const GITLAB_MERGE_REQUEST_URL_PATTERN = /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN = /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; +const FORGEJO_PULL_REQUEST_URL_PATTERN = + /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)*[^/\s]+\/[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i; +const FORGEJO_CLI_PR_CHECKOUT_PATTERN = /^fj\s+pr\s+checkout\s+(.+)$/i; const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i; const GITLAB_CLI_MR_CHECKOUT_PATTERN = /^glab\s+mr\s+checkout\s+(.+)$/i; @@ -32,6 +35,7 @@ export function parsePullRequestReference(input: string): string | null { const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed); const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); const normalizedInput = + FORGEJO_CLI_PR_CHECKOUT_PATTERN.exec(trimmed)?.[1]?.trim() ?? ghCliCheckoutMatch?.[1]?.trim() ?? glabCliCheckoutMatch?.[1]?.trim() ?? (azureDevOpsCliCheckoutMatch?.[1] @@ -43,6 +47,7 @@ export function parsePullRequestReference(input: string): string | null { } const urlMatch = + FORGEJO_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ?? AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput); diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index 4e757e6954a5..dbdf83e92699 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -13,7 +13,13 @@ 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; @@ -32,6 +38,12 @@ export function getSourceControlPresentation( terminology: getChangeRequestTerminology(provider), Icon: GitHubIcon, }; + case "forgejo": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: ForgejoIcon, + }; case "gitlab": return { providerName: provider?.name || presentation.providerName, diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 021fb255a4b9..efda37c14403 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -269,7 +269,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi ); const action = useCallback( async (input: { - provider: "github" | "gitlab" | "bitbucket" | "azure-devops"; + provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; repository: string; visibility: SourceControlRepositoryVisibility; remoteName: string; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 48e3eb1992cd..9724df7ac34b 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -1,6 +1,6 @@ # Source control -T3 Code integrates with GitHub, GitLab, Bitbucket, and Azure DevOps to clone and publish +T3 Code integrates with GitHub, GitLab, Forgejo, Bitbucket, and Azure DevOps to clone and publish repositories, create pull requests, and review changes. ## Connect an account @@ -25,6 +25,24 @@ Install [GitLab CLI](https://gitlab.com/gitlab-org/cli), then sign in: glab auth login ``` +### Forgejo + +Install [Forgejo CLI (`fj`)](https://codeberg.org/forgejo-contrib/forgejo-cli), then sign in to each +instance you use: + +```bash +fj --host codeberg.org auth login +fj --host git.example.com auth login +``` + +T3 Code recognizes remotes on the instances listed by `fj auth list`, including instances that +advertise a different hostname or port for SSH. When cloning or publishing, +enter `host/owner/repo` or the full repository URL to choose the instance. T3 Code uses your saved +`fj` credentials; no separate token configuration is needed. + +You can create and check out Forgejo pull requests from a thread's Git actions. Use the instance's +website to review and merge them. Commit or stash local changes before checking out a pull request. + ### Bitbucket Set an access token in the server's environment: diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 3f5fc3667f31..1f200b1dc233 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -25,7 +25,7 @@ import type { EnvironmentProject } from "../state/models.ts"; export type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" >; export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; @@ -59,6 +59,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ "url", "github", "gitlab", + "forgejo", "bitbucket", "azure-devops", ]; @@ -66,6 +67,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [ "github", "gitlab", + "forgejo", "bitbucket", "azure-devops", ]; @@ -76,6 +78,8 @@ export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): str return "GitHub"; case "gitlab": return "GitLab"; + case "forgejo": + return "Forgejo"; case "bitbucket": return "Bitbucket"; case "azure-devops": @@ -91,6 +95,8 @@ export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): return "owner/repo"; case "gitlab": return "group/project"; + case "forgejo": + return "host/owner/repo"; case "bitbucket": return "workspace/repository"; case "azure-devops": @@ -153,6 +159,7 @@ export function buildAddProjectRemoteSourceReadiness( url: { ready: true, hint: null }, github: unavailable, gitlab: unavailable, + forgejo: unavailable, bitbucket: unavailable, "azure-devops": unavailable, }; diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index cc98fbc1bbcc..b0d2bd4a75bd 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -5,6 +5,7 @@ import { VcsDriverKind } from "./vcs.ts"; export const SourceControlProviderKind = Schema.Literals([ "github", "gitlab", + "forgejo", "azure-devops", "bitbucket", "unknown", diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 6a60d6f9b38d..d1922cd5f310 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -9,6 +9,22 @@ import { } from "./sourceControl.ts"; describe("source control presentation", () => { + it("uses Forgejo pull request terminology and fj checkout instructions", () => { + expect( + resolveChangeRequestPresentation({ + kind: "forgejo", + name: "Forgejo", + baseUrl: "https://git.example.test", + }), + ).toMatchObject({ + icon: "forgejo", + providerName: "Forgejo", + shortName: "PR", + longName: "pull request", + checkoutCommandExample: "fj pr checkout 123", + }); + }); + it("uses merge request terminology for GitLab", () => { expect(getChangeRequestTerminologyForKind("gitlab")).toEqual({ shortLabel: "MR", diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 9f1dc384e749..c0d492e71f12 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -5,7 +5,7 @@ import type { } from "@t3tools/contracts"; export interface ChangeRequestPresentation { - readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; + readonly icon: "github" | "gitlab" | "forgejo" | "azure-devops" | "bitbucket" | "change-request"; readonly providerName: string; readonly shortName: string; readonly longName: string; @@ -58,6 +58,17 @@ const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { urlExample: "https://dev.azure.com/org/project/_git/repo/pullrequest/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 BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "bitbucket", providerName: "Bitbucket", @@ -87,6 +98,8 @@ export function resolveChangeRequestPresentation( return GITHUB_CHANGE_REQUEST_PRESENTATION; case "gitlab": return GITLAB_CHANGE_REQUEST_PRESENTATION; + case "forgejo": + return FORGEJO_CHANGE_REQUEST_PRESENTATION; case "azure-devops": return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; case "bitbucket": From 7b0b83c00feb5e4f6a442a8c98f7698909df13ab Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Fri, 11 Sep 2026 18:08:46 +0000 Subject: [PATCH 2/9] fix(source-control): cache discovery and recognize Forgejo PR links --- .../toolkits/pullRequests/handlers.test.ts | 35 ++++++++ .../SourceControlProviderRegistry.test.ts | 88 +++++++++++++++++++ .../SourceControlProviderRegistry.ts | 61 ++++++++++--- packages/shared/src/changeRequestUrl.test.ts | 80 +++++++++++++++++ packages/shared/src/changeRequestUrl.ts | 16 ++-- 5 files changed, 263 insertions(+), 17 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 18062a216bb4..3ed1bb5f9e0c 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -265,6 +265,41 @@ describe("pull request toolkit handlers", () => { }), ); + it.effect("links Forgejo URLs and repository inputs using the canonical web instance", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + project: makeProject({ + canonicalKey: "forge.example.test:8443/owner/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "ssh://git@ssh.example.test:2222/Owner/Repo.git", + }, + provider: "forgejo", + displayName: "Owner/Repo", + }), + }); + const byRepository = yield* harness.call("link_pull_request", { + repository: "Owner/Repo", + number: 42, + }); + expect(byRepository).toMatchObject({ + host: "forge.example.test:8443", + repository: "owner/repo", + number: 42, + url: "https://forge.example.test:8443/owner/repo/pulls/42", + }); + const byUrl = yield* harness.call("link_pull_request", { + url: "https://forge.example.test:9443/Owner/Repo/pulls/43", + }); + expect(byUrl).toMatchObject({ + host: "forge.example.test:9443", + repository: "owner/repo", + number: 43, + }); + }), + ); + it.effect("rejects a target that names neither a URL nor repository and number", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index e9f2f68c5735..55dd43dc3e61 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -4,6 +4,8 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { TestClock } from "effect/testing"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsRepositoryDetectionError } from "@t3tools/contracts"; @@ -209,6 +211,92 @@ it.effect("routes all authenticated Forgejo instances and caches detection", () }), ); +it.effect("shares cached refinement across status contexts and default-remote detection", () => + Effect.gen(function* () { + const remoteUrl = "ssh://git@code.example.test:2222/Owner/Repo.git"; + let probes = 0; + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: remoteUrl }], + process: { + run: () => + Effect.sync(() => { + probes++; + return processOutput("code.example.test:8443"); + }), + }, + }); + const context = (url = remoteUrl) => ({ + provider: detectSourceControlProviderFromRemoteUrl(url)!, + remoteName: "origin", + remoteUrl: url, + }); + const handles = yield* Effect.all( + [ + registry.resolveHandle({ cwd: "/repo", context: context() }), + registry.resolveHandle({ cwd: "/repo", context: context() }), + ], + { concurrency: "unbounded" }, + ); + assert.strictEqual(handles[0]?.provider.kind, "forgejo"); + assert.strictEqual(probes, 1); + yield* TestClock.adjust("6 seconds"); + yield* registry.resolveHandle({ cwd: "/repo", context: context() }); + yield* registry.resolveHandle({ cwd: "/repo" }); + assert.strictEqual(probes, 1); + const changed = yield* registry.resolveHandle({ + cwd: "/repo", + context: context("ssh://git@code.example.test:2222/Other/Repo.git"), + }); + assert.strictEqual( + changed.context?.remoteUrl, + "ssh://git@code.example.test:2222/Other/Repo.git", + ); + assert.strictEqual(probes, 2); + yield* TestClock.adjust("1 minute"); + yield* registry.resolveHandle({ cwd: "/repo", context: context() }); + assert.strictEqual(probes, 3); + }), +); + +it.effect("caches unmatched remotes but discovers a new login after expiry", () => + Effect.gen(function* () { + const remoteUrl = "https://code.example.test/Owner/Repo.git"; + let authenticated = false; + const calls: VcsProcess.VcsProcessInput[] = []; + const registry = yield* makeRegistry({ + remotes: [], + process: { + run: (input) => + Effect.sync(() => { + calls.push(input); + return processOutput( + authenticated && input.command === "fj" ? "code.example.test" : "", + ); + }), + }, + }); + const resolve = () => + registry.resolveHandle({ + cwd: "/repo", + context: { + remoteName: "origin", + remoteUrl, + provider: detectSourceControlProviderFromRemoteUrl(remoteUrl)!, + }, + }); + assert.strictEqual((yield* resolve()).provider.kind, "unknown"); + const firstProbeCount = calls.length; + assert.isAbove(firstProbeCount, 0); + authenticated = true; + yield* TestClock.adjust("6 seconds"); + assert.strictEqual((yield* resolve()).provider.kind, "unknown"); + assert.strictEqual(calls.length, firstProbeCount); + yield* TestClock.adjust("1 minute"); + assert.strictEqual((yield* resolve()).provider.kind, "forgejo"); + assert.strictEqual(calls.length, firstProbeCount + 1); + }), +); + it.effect("routes authenticated self-hosted GitLab remotes without relying on host naming", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 2a62a3544b18..ac0f7f8520d0 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -1,5 +1,6 @@ import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; +import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -28,6 +29,15 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); +const REMOTE_REFINEMENT_CACHE_TTL = Duration.minutes(1); + +class RemoteRefinementKey extends Data.Class<{ + readonly cwd: string; + readonly remoteName: string; + readonly remoteUrl: string; + readonly providerName: string; + readonly baseUrl: string; +}> {} export interface SourceControlProviderRegistration { readonly kind: SourceControlProviderKind; @@ -210,6 +220,43 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const get: SourceControlProviderRegistry["Service"]["get"] = (kind) => Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); + const remoteRefinementCache = yield* Cache.makeWith( + (key: RemoteRefinementKey) => + refineUnknownRemoteProvider({ + specs: discoverySpecs, + process, + cwd: key.cwd, + context: { + remoteName: key.remoteName, + remoteUrl: key.remoteUrl, + provider: { kind: "unknown", name: key.providerName, baseUrl: key.baseUrl }, + }, + }), + { + capacity: PROVIDER_DETECTION_CACHE_CAPACITY, + timeToLive: () => REMOTE_REFINEMENT_CACHE_TTL, + }, + ); + + // Status supplies a fresh context on every read. Cache by its values so both + // that path and default-remote discovery share the CLI authentication probe. + const refineContext = ( + cwd: string, + context: SourceControlProvider.SourceControlProviderContext | null, + ) => + context?.provider.kind === "unknown" + ? Cache.get( + remoteRefinementCache, + new RemoteRefinementKey({ + cwd, + remoteName: context.remoteName, + remoteUrl: context.remoteUrl, + providerName: context.provider.name, + baseUrl: context.provider.baseUrl, + }), + ) + : Effect.succeed(context); + const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( function* (cwd: string) { const handle = yield* vcsRegistry.resolve({ cwd }).pipe( @@ -238,12 +285,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit ); const context = selectProviderContext(remotes.remotes); - return yield* refineUnknownRemoteProvider({ - specs: discoverySpecs, - process, - cwd, - context, - }); + return yield* refineContext(cwd, context); }, ); @@ -259,12 +301,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) => (input.context === undefined ? Cache.get(providerContextCache, input.cwd) - : refineUnknownRemoteProvider({ - specs: discoverySpecs, - process, - cwd: input.cwd, - context: input.context, - }) + : refineContext(input.cwd, input.context) ).pipe( Effect.map((context) => { const kind = context?.provider.kind ?? "unknown"; diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts index 2af718e453ff..3bb92e6a8f63 100644 --- a/packages/shared/src/changeRequestUrl.test.ts +++ b/packages/shared/src/changeRequestUrl.test.ts @@ -1,13 +1,33 @@ import { describe, expect, it } from "vite-plus/test"; +import { ProjectId } from "@t3tools/contracts"; import { changeRequestUrlFor, + changeRequestRepositoryUrl, + matchesLinkedPullRequestUrl, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, siblingPullRequestUrl, } from "./changeRequestUrl.ts"; describe("parseChangeRequestUrl", () => { + it.each([ + ["https://codeberg.org/Owner/Repo/pulls/42", "codeberg.org", "owner/repo"], + ["https://git.fryuni.dev/Owner/Repo/pulls/42/files?w=1", "git.fryuni.dev", "owner/repo"], + [ + "http://forge.example.test:3000/forge/Owner/Repo/pulls/42", + "forge.example.test:3000", + "forge/owner/repo", + ], + [ + "https://github.example.test:8443/Owner/Repo/pulls/42", + "github.example.test:8443", + "owner/repo", + ], + ])("reads the Forgejo instance and repository from %s", (url, host, repository) => { + expect(parseChangeRequestUrl(url)).toEqual({ host, repository, number: 42 }); + }); + it("reads a GitHub pull request, lower-casing the repository", () => { expect(parseChangeRequestUrl("https://github.com/T3Tools/T3Code/pull/123")).toEqual({ host: "github.com", @@ -83,6 +103,9 @@ describe("parseChangeRequestUrl", () => { "https://blog.example.test/2026/updates/pull/3", "javascript:alert(1)//github.com/t3tools/t3code/pull/1", "not a url", + "https://git.fryuni.dev/Owner/Repo/pulls/0", + "https://git.fryuni.dev/Owner/Repo/pulls/9007199254740992", + "https://git.fryuni.dev/Owner/Repo/pulls/not-a-number", ]) { expect(parseChangeRequestUrl(link), link).toBeNull(); } @@ -91,6 +114,14 @@ describe("parseChangeRequestUrl", () => { describe("siblingPullRequestUrl", () => { it.each([ + [ + "https://git.fryuni.dev/Owner/Repo/pulls/42/files#note", + "https://git.fryuni.dev/owner/repo/pulls/43", + ], + [ + "http://forge.example.test:3000/forge/Owner/Repo/pulls/42", + "http://forge.example.test:3000/forge/owner/repo/pulls/43", + ], ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"], [ "https://git.acme.test/team/merge_requests/1/repo/-/merge_requests/42/diffs", @@ -125,6 +156,15 @@ describe("siblingPullRequestUrl", () => { }); describe("changeRequestUrlFor", () => { + it("builds Forgejo links on the canonical web port", () => { + const url = changeRequestUrlFor("forgejo", "forge.example.test:8443", "owner/repo", 42); + expect(url).toBe("https://forge.example.test:8443/owner/repo/pulls/42"); + expect(parseChangeRequestUrl(url!)).toEqual({ + host: "forge.example.test:8443", + repository: "owner/repo", + number: 42, + }); + }); it.each([ ["ssh.dev.azure.com", "v3/org/project/web"], ["vs-ssh.visualstudio.com", "v3/org/project/web"], @@ -140,3 +180,43 @@ describe("changeRequestUrlFor", () => { }); }); }); + +describe("Forgejo repository and stored links", () => { + it.each([ + [ + "http://forge.example.test:3000/forge/Owner/Repo/pulls/42/files?w=1#note", + "http://forge.example.test:3000/forge/Owner/Repo", + ], + [ + "https://forge.example.test/forge/pull/123/Owner/Repo/pulls/42", + "https://forge.example.test/forge/pull/123/Owner/Repo", + ], + [ + "https://gitlab.example.test/group/sub/pulls/123/repo/-/merge_requests/42", + "https://gitlab.example.test/group/sub/pulls/123/repo", + ], + ])("extracts the repository root from %s", (url, repositoryUrl) => { + expect(changeRequestRepositoryUrl(url)).toBe(repositoryUrl); + }); + + it("matches stored Forgejo links without conflating instances on different ports", () => { + const linked = { + projectId: ProjectId.make("project-1"), + repository: "owner/repo", + number: 42, + url: "https://forge.example.test:8443/Owner/Repo/pulls/42", + }; + expect( + matchesLinkedPullRequestUrl( + linked, + "https://forge.example.test:8443/owner/repo/pulls/42/files", + ), + ).toBe(true); + expect( + matchesLinkedPullRequestUrl(linked, "https://forge.example.test:9443/owner/repo/pulls/42"), + ).toBe(false); + expect( + matchesLinkedPullRequestUrl(linked, "https://forge.example.test:8443/owner/repo/pulls/43"), + ).toBe(false); + }); +}); diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 0687afd38eba..b4803c1051dc 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -6,8 +6,8 @@ import { canonicalRepositoryKey } from "./sourceControl.ts"; * is addressed, the repository path as that host writes it, and the number. * * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce - * from a git remote — lower case, no port, the full path below the host — because links are - * matched against those. Anything else matches nothing. + * from a git remote: lower case and the full path below the host. Forgejo retains its web + * port because different ports can serve different instances. */ export interface ChangeRequestLink { readonly host: string; @@ -49,12 +49,15 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} if (isHostOf(host, "github.com", "github")) { const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match); + if (match) return claim(host, match); } // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/` // separator is GitLab's own, so the hostname is not asked about. const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); if (gitlab) return claim(host, gitlab); + // Forgejo uses /pulls/ on arbitrary instance hosts, optionally below a subpath. + const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (forgejo) return claim(url.host.toLowerCase(), forgejo); // Bitbucket Cloud: /{workspace}/{repo}/pull-requests/{n} if (isHostOf(host, "bitbucket.org", "bitbucket")) { const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); @@ -89,6 +92,8 @@ export function changeRequestUrlFor( return `https://${host}/${repository}/pull/${number}`; case "gitlab": return `https://${host}/${repository}/-/merge_requests/${number}`; + case "forgejo": + return `https://${host}/${repository}/pulls/${number}`; case "bitbucket": return `https://${host}/${repository}/pull-requests/${number}`; case "azure-devops": @@ -185,7 +190,8 @@ export function changeRequestRepositoryUrl(targetUrl: string): string | null { const url = new URL(targetUrl); const repositoryPath = /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ?? - /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec( + /^(.*)\/pulls\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ?? + /^(.*?)(?:\/pulls?\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec( url.pathname, )?.[1]; if (!repositoryPath) return null; @@ -199,7 +205,7 @@ export function siblingPullRequestUrl(url: string, number: number): string | nul const reference = parseChangeRequestUrl(url); if (reference === null || !Number.isSafeInteger(number) || number < 1) return null; const sibling = new URL(url); - const route = /^\/(-\/merge_requests|pull|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec( + const route = /^\/(-\/merge_requests|pulls?|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec( sibling.pathname.slice(reference.repository.length + 1), )?.[1]; if (route === undefined) return null; From c63675b282e8377347889c7b04bac4a321f0fce4 Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Fri, 11 Sep 2026 18:19:07 +0000 Subject: [PATCH 3/9] fix(source-control): preserve Forgejo HTTP origins in PR links --- .../toolkits/pullRequests/handlers.test.ts | 27 +++++++++++++++++ .../src/mcp/toolkits/pullRequests/handlers.ts | 1 + .../LinkPullRequestDialog.logic.test.ts | 30 ++++++++++++++++++- .../pullRequest/LinkPullRequestDialog.tsx | 3 +- packages/shared/src/changeRequestUrl.test.ts | 13 ++++++++ packages/shared/src/changeRequestUrl.ts | 25 ++++++++++++++-- 6 files changed, 94 insertions(+), 5 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 3ed1bb5f9e0c..52ae8d8fa14c 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -300,6 +300,33 @@ describe("pull request toolkit handlers", () => { }), ); + it.effect("preserves an HTTP Forgejo origin for repository-and-number links", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + project: makeProject({ + canonicalKey: "forge.example.test:3000/owner/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "http://forge.example.test:3000/Owner/Repo.git", + }, + provider: "forgejo", + displayName: "Owner/Repo", + }), + }); + const result = yield* harness.call("link_pull_request", { + repository: "Owner/Other", + number: 42, + }); + expect(result).toMatchObject({ + host: "forge.example.test:3000", + repository: "owner/other", + number: 42, + url: "http://forge.example.test:3000/owner/other/pulls/42", + }); + }), + ); + it.effect("rejects a target that names neither a URL nor repository and number", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 1106bf435327..06fe408c5003 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -89,6 +89,7 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( host, repository, input.number, + project?.repositoryIdentity?.locator.remoteUrl, ) ?? `https://${host}/${repository}/pull/${input.number}`; return { host, repository, number: input.number, url } satisfies ResolvedTarget; }); diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts b/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts index 6d11ce7cd944..630df4de60c7 100644 --- a/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts @@ -70,6 +70,34 @@ describe("resolveLinkPullRequestInput", () => { ).toBeNull(); }); + it("resolves a bare Forgejo number using the project's HTTP origin", () => { + expect( + resolveLinkPullRequestInput({ + reference: "#42", + project: { + host: "forge.example.test:3000", + repository: "owner/repo", + webUrl: (number) => + changeRequestWebUrl( + "forgejo", + "forge.example.test:3000", + "owner/repo", + number, + "http://forge.example.test:3000/owner/repo.git", + ), + }, + hasProject: () => true, + }), + ).toEqual({ + link: { + host: "forge.example.test:3000", + repository: "owner/repo", + number: 42, + url: "http://forge.example.test:3000/owner/repo/pulls/42", + }, + }); + }); + it("resolves a bare number against the thread's own repository", () => { expect( resolveLinkPullRequestInput({ reference: "#42", project, hasProject: () => true }), @@ -127,7 +155,7 @@ describe("resolveLinkPullRequestInput", () => { }); describe("changeRequestWebUrl", () => { - it("knows the four hosts and nothing else", () => { + it("builds known provider routes and rejects unknown kinds", () => { expect(changeRequestWebUrl("gitlab", "gitlab.com", "g/sub/repo", 5)).toBe( "https://gitlab.com/g/sub/repo/-/merge_requests/5", ); diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx index aa5dfa789524..f31cead449b4 100644 --- a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx @@ -144,7 +144,8 @@ function LinkPullRequestDialog({ return { host, repository, - webUrl: (number: number) => changeRequestWebUrl(kind, host, repository, number), + webUrl: (number: number) => + changeRequestWebUrl(kind, host, repository, number, identity.locator.remoteUrl), }; }, [environmentProjects, projectId]); const linking = usePullRequestLinking(threadRef.environmentId); diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts index 3bb92e6a8f63..4bf93b1cf460 100644 --- a/packages/shared/src/changeRequestUrl.test.ts +++ b/packages/shared/src/changeRequestUrl.test.ts @@ -156,6 +156,19 @@ describe("siblingPullRequestUrl", () => { }); describe("changeRequestUrlFor", () => { + it.each([ + ["http://token@forge.example.test:3000/forge/owner/repo.git", "http"], + ["https://forge.example.test:3000/forge/owner/repo.git", "https"], + ["ssh://git@ssh.example.test:2222/owner/repo.git", "https"], + ["git@ssh.example.test:owner/repo.git", "https"], + ["http://other.example.test:3000/owner/repo.git", "https"], + ["http://forge.example.test:4000/owner/repo.git", "https"], + ])("uses the matching Forgejo web origin from %s", (remoteUrl, scheme) => { + expect( + changeRequestUrlFor("forgejo", "forge.example.test:3000", "forge/owner/other", 42, remoteUrl), + ).toBe(`${scheme}://forge.example.test:3000/forge/owner/other/pulls/42`); + }); + it("builds Forgejo links on the canonical web port", () => { const url = changeRequestUrlFor("forgejo", "forge.example.test:8443", "owner/repo", 42); expect(url).toBe("https://forge.example.test:8443/owner/repo/pulls/42"); diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index b4803c1051dc..16f8061be3b5 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -80,20 +80,39 @@ function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | : null; } -/** The web URL a host writes for a change request; null when the host shape is unknown. */ +/** + * The web URL a host writes for a change request; null when its shape is unknown. + * A matching HTTP remote preserves a Forgejo instance's web scheme. + */ export function changeRequestUrlFor( kind: string | null | undefined, host: string, repository: string, number: number, + repositoryRemoteUrl?: string, ): string | null { switch (kind) { case "github": return `https://${host}/${repository}/pull/${number}`; case "gitlab": return `https://${host}/${repository}/-/merge_requests/${number}`; - case "forgejo": - return `https://${host}/${repository}/pulls/${number}`; + case "forgejo": { + let origin = `https://${host}`; + if (repositoryRemoteUrl) { + try { + const remote = new URL(repositoryRemoteUrl); + if ( + (remote.protocol === "http:" || remote.protocol === "https:") && + remote.host.toLowerCase() === host.toLowerCase() + ) { + origin = remote.origin; + } + } catch { + // SSH remotes do not establish a web scheme; use the canonical web host. + } + } + return `${origin}/${repository}/pulls/${number}`; + } case "bitbucket": return `https://${host}/${repository}/pull-requests/${number}`; case "azure-devops": From b259790f569b872f977c7d85aad681d43cc72cea Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Fri, 11 Sep 2026 18:36:47 +0000 Subject: [PATCH 4/9] fix(source-control): synchronize linked Forgejo pull requests --- .../pullRequest/PullRequestService.test.ts | 119 +++++++++++++++ .../src/pullRequest/PullRequestService.ts | 135 +++++++++++++++--- 2 files changed, 236 insertions(+), 18 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 3117c0072a78..396037cb6bd9 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -18,8 +18,11 @@ import type { } from "@t3tools/contracts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; +import * as ForgejoSourceControlProvider from "../sourceControl/ForgejoSourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import { PullRequestProviderError, type ProviderChangeRequest, @@ -183,12 +186,16 @@ function makeService(input: { readonly projects: ReadonlyArray; readonly providers: ReadonlyArray; readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; + readonly getSourceControlProvider?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["get"]; }) { return PullRequestService.make.pipe( Effect.provide( Layer.mergeAll( Layer.succeed(PullRequestProviderRegistry, fromProviders(input.providers)), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + get: + input.getSourceControlProvider ?? + (() => Effect.die("Unexpected source control lookup")), resolveHandle: input.resolveHandle ?? (() => Effect.die("Unexpected provider refinement")), }), @@ -212,6 +219,118 @@ function makeService(input: { ); } +for (const checkout of [ + { + host: "git.example.test:8443", + repository: "acme/web", + remoteUrl: "ssh://git@ssh.example.test:2222/acme/web.git", + baseUrl: "https://git.example.test:8443", + }, + { + host: "git.example.test:3000", + repository: "forge/acme/web", + remoteUrl: "http://git.example.test:3000/forge/acme/web.git", + baseUrl: "http://git.example.test:3000/forge", + }, +]) { + it.effect(`syncs linked Forgejo summaries through REST for ${checkout.remoteUrl}`, () => + Effect.gen(function* () { + let merged = false; + const reads: Array<{ baseUrl: string; path: string }> = []; + const url = `${checkout.baseUrl}/acme/web/pulls/7`; + const provider = yield* ForgejoSourceControlProvider.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ForgejoCli.ForgejoCli)({ + read: (input) => + Effect.sync(() => { + reads.push({ baseUrl: input.baseUrl, path: input.path }); + return { + body: { + number: 7, + title: "Support Forgejo", + html_url: url, + state: merged ? "closed" : "open", + merged, + draft: !merged, + base: { ref: "main", repo: { full_name: "acme/web" } }, + head: { ref: "feature/forgejo", repo: { full_name: "acme/web" } }, + updated_at: merged ? "2026-09-11T12:00:00Z" : "2026-09-11T00:00:00Z", + closed_at: merged ? "2026-09-11T12:00:00Z" : null, + merged_at: merged ? "2026-09-11T12:00:00Z" : null, + }, + hasNextPage: false, + }; + }), + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + ), + ), + ); + const service = yield* makeService({ + projects: [ + project({ + id: "forgejo", + title: "Forgejo", + workspaceRoot: "/forgejo", + provider: "forgejo", + ...checkout, + }), + ], + providers: [], + getSourceControlProvider: (kind) => { + assert.strictEqual(kind, "forgejo"); + return Effect.succeed(provider); + }, + }); + const ref = { + projectId: "forgejo" as ProjectId, + host: checkout.host, + repository: checkout.repository, + number: 7, + }; + const summary = yield* service.summary(ref, { recoverTransientFailure: false }); + assert.deepStrictEqual(summary, { + provider: "forgejo", + projectId: ref.projectId, + repository: checkout.repository, + number: 7, + title: "Support Forgejo", + url, + state: "open", + isDraft: true, + headBranch: "feature/forgejo", + baseBranch: "main", + closedAt: null, + mergedAt: null, + updatedAt: "2026-09-11T00:00:00.000Z", + }); + assert.isNull(yield* service.stack(ref, { includeDetails: false })); + yield* service.summary(ref, { recoverTransientFailure: false }); + assert.strictEqual(reads.length, 1); + merged = true; + yield* service.invalidate({ reference: ref }); + const refreshed = yield* service.summary(ref, { recoverTransientFailure: false }); + assert.strictEqual(refreshed.state, "merged"); + assert.strictEqual(refreshed.isDraft, false); + assert.strictEqual(refreshed.mergedAt, "2026-09-11T12:00:00Z"); + assert.strictEqual(refreshed.closedAt, "2026-09-11T12:00:00Z"); + assert.strictEqual(refreshed.updatedAt, "2026-09-11T12:00:00.000Z"); + assert.deepStrictEqual(reads, [ + { baseUrl: checkout.baseUrl, path: "/repos/acme/web/pulls/7" }, + { baseUrl: checkout.baseUrl, path: "/repos/acme/web/pulls/7" }, + ]); + const detailError = yield* service.detail(ref).pipe(Effect.flip); + assert.strictEqual(detailError._tag, "PullRequestUnavailableError"); + const wrongHost = yield* service + .summary({ ...ref, host: "other.example.test" }) + .pipe(Effect.flip); + assert.strictEqual(wrongHost._tag, "PullRequestUnavailableError"); + assert.strictEqual(reads.length, 2); + }), + ); +} + it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () => Effect.gen(function* () { let refinementCalls = 0; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 37716da44005..89ff95375fd1 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -2,6 +2,7 @@ import { canonicalRepositoryKey, sourceControlRepositorySelector, } from "@t3tools/shared/sourceControl"; +import { changeRequestUrlFor } from "@t3tools/shared/changeRequestUrl"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -253,11 +254,19 @@ const ACTION_ACCESS_REFUSALS: Record = { const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to ask for a review."; const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to change its labels."; +/** Status synchronization uses a smaller interface than the full review page. */ +type SummaryProvider = Pick< + PullRequestProviderApi, + "kind" | "getChangeRequestSummary" | "getChangeRequestStack" +> & { + readonly getChangeRequest: NonNullable; +}; + /** A project this page can read: its remote is on a host with an implementation. */ -interface SupportedProject { +interface SupportedProject { readonly cursorKey: string; readonly project: OrchestrationProjectShell; - readonly api: PullRequestProviderApi; + readonly api: Api; readonly repository: string; /** The host the repository lives on, which is the account boundary rather than the kind. */ readonly host: string; @@ -268,8 +277,8 @@ interface SupportedProject { * implementation are counted rather than dropped, so their projects are explained in the * provider list instead of quietly missing from the page. */ -interface WorkspaceProjects { - readonly supported: ReadonlyArray; +interface WorkspaceProjects { + readonly supported: ReadonlyArray>; /** Keyed by host, as the readable ones are: an unimplemented host is its own switcher entry. */ readonly unimplemented: ReadonlyMap< string, @@ -600,9 +609,14 @@ export const make = Effect.gen(function* () { ).pipe(Effect.map((resolved) => new Map(resolved))); }; - const listWorkspaceProjects = ( + const listWorkspaceProjectsWith = ( filter: Pick, - ): Effect.Effect => + providerFor: ( + kind: SourceControlProviderKind, + host: string, + project: OrchestrationProjectShell, + ) => Api | null, + ): Effect.Effect, PullRequestError> => projections.getShellSnapshot().pipe( Effect.mapError( (error) => @@ -618,7 +632,7 @@ export const make = Effect.gen(function* () { ), ), Effect.map(({ refinedKinds, snapshot }) => { - const supported: SupportedProject[] = []; + const supported: SupportedProject[] = []; const unimplemented = new Map< string, { kind: SourceControlProviderKind; projectCount: number } @@ -641,7 +655,7 @@ export const make = Effect.gen(function* () { } const host = pullRequestHostOf(identity, kind); if (filter.host !== undefined && host !== filter.host.toLowerCase()) continue; - const api = registry.get(kind); + const api = providerFor(kind, host, project); // Recorded before the de-duplication below, so the viewer lookup keeps the alternates // the listing is about to drop. if (api !== null) { @@ -664,7 +678,7 @@ export const make = Effect.gen(function* () { supported.push({ cursorKey: key, project, - api: withRateLimitBackoff(api, host, rateLimits), + api, repository, host, }); @@ -673,6 +687,84 @@ export const make = Effect.gen(function* () { }), ); + const reviewProviderFor = (kind: SourceControlProviderKind, host: string) => { + const api = registry.get(kind); + return api === null ? null : withRateLimitBackoff(api, host, rateLimits); + }; + + // Linked status only needs metadata. Keep Forgejo out of the full review interface until + // it implements that interface's listing, permissions, diffs, and review actions. + const summaryProviderFor = ( + kind: SourceControlProviderKind, + host: string, + project: OrchestrationProjectShell, + ): SummaryProvider | null => + reviewProviderFor(kind, host) ?? + (kind === "forgejo" + ? { + kind, + getChangeRequest: Effect.fn("PullRequestService.forgejoSummary")(function* (input) { + const reference = changeRequestUrlFor( + kind, + input.host, + input.repository, + input.number, + project.repositoryIdentity?.locator.remoteUrl, + ); + if (reference === null) { + return yield* new PullRequestProviderError({ + provider: kind, + operation: "getChangeRequestSummary", + reason: "failed", + detail: "The Forgejo pull request URL could not be resolved.", + }); + } + const read = Effect.gen(function* () { + const provider = yield* sourceControlProviders.get(kind); + return yield* provider.getChangeRequest({ + cwd: input.cwd, + reference, + }); + }); + const changeRequest = yield* read.pipe( + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: kind, + operation: "getChangeRequestSummary", + reason: "failed", + detail: cause.detail, + cause, + }), + ), + ); + return { + number: changeRequest.number, + title: changeRequest.title, + url: changeRequest.url, + state: changeRequest.state, + ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), + closedAt: changeRequest.closedAt ?? null, + mergedAt: changeRequest.mergedAt ?? null, + headBranch: changeRequest.headRefName, + baseBranch: changeRequest.baseRefName, + updatedAt: Option.match(changeRequest.updatedAt, { + // Missing timestamps must stay stable across polls. + onNone: () => "1970-01-01T00:00:00.000Z", + onSome: DateTime.formatIso, + }), + }; + }), + } + : null); + + const listWorkspaceProjects = ( + filter: Pick, + ) => listWorkspaceProjectsWith(filter, reviewProviderFor); + const listSummaryProjects = ( + filter: Pick, + ) => listWorkspaceProjectsWith(filter, summaryProviderFor); + /** * The project whose checkout and credentials serve a reference. The project's own * repository is the default; a reference that names a `host` may instead point at any @@ -680,9 +772,14 @@ export const make = Effect.gen(function* () { * targeting can fall back to another checkout on the host. Azure derives its organization * from the checkout, so it requires a matching repository. */ - const requireProject = (ref: PullRequestRef): Effect.Effect => - listWorkspaceProjects({ projectId: ref.projectId }).pipe( - Effect.flatMap(({ supported }): Effect.Effect => { + const requireProjectWith = >( + ref: PullRequestRef, + listProjects: ( + filter: Pick, + ) => Effect.Effect, PullRequestError>, + ): Effect.Effect, PullRequestError> => + listProjects({ projectId: ref.projectId }).pipe( + Effect.flatMap(({ supported }): Effect.Effect, PullRequestError> => { const own = supported[0]; const repository = ref.repository.trim(); const host = ref.host?.trim().toLowerCase(); @@ -707,9 +804,7 @@ export const make = Effect.gen(function* () { const repositoryKey = canonicalRepositoryKey(`${host}/${repository}`.toLowerCase()); // Azure SSH and legacy clone hosts differ from the browser URL's host. Compare // the complete repository identity before narrowing those checkouts by host. - return listWorkspaceProjects( - repositoryKey.startsWith("dev.azure.com/") ? {} : { host }, - ).pipe( + return listProjects(repositoryKey.startsWith("dev.azure.com/") ? {} : { host }).pipe( Effect.flatMap(({ supported }) => { const onHost = supported.filter((candidate) => candidate.host === host); const route = @@ -740,6 +835,10 @@ export const make = Effect.gen(function* () { }), ); + const requireProject = (ref: PullRequestRef) => requireProjectWith(ref, listWorkspaceProjects); + const requireSummaryProject = (ref: PullRequestRef) => + requireProjectWith(ref, listSummaryProjects); + /** * What the signed-in account may do with this change request, asked of the host itself. Every * write goes through it: the page hides what a viewer may not do, and a request that arrived @@ -1283,7 +1382,7 @@ export const make = Effect.gen(function* () { resolveViewers([project], new Map()).pipe(Effect.map(([resolved]) => resolved?.viewer ?? null)); const summaryUncached: PullRequestService["Service"]["summary"] = (input) => - requireProject(input).pipe( + requireSummaryProject(input).pipe( Effect.flatMap((project) => { const providerInput = { cwd: project.project.workspaceRoot, @@ -1336,7 +1435,7 @@ export const make = Effect.gen(function* () { ); const stackUncached: PullRequestService["Service"]["stack"] = (input, options) => - requireProject(input).pipe( + requireSummaryProject(input).pipe( Effect.flatMap((project) => { const read = project.api.getChangeRequestStack; if (read === undefined) return Effect.succeed(null); @@ -2341,7 +2440,7 @@ export const make = Effect.gen(function* () { codec: Schema.Codec, read: Effect.Effect, ) { - const project = yield* requireProject(input); + const project = yield* requireSummaryProject(input); const key = [ operation, project.api.kind, From 066d6ba0beac3430d3e35227c200f1ce8535d234 Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Fri, 11 Sep 2026 18:45:45 +0000 Subject: [PATCH 5/9] docs(source-control): explain Forgejo SSH scheme limitation --- apps/server/src/sourceControl/forgejoAuth.ts | 2 ++ docs/user/source-control.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/apps/server/src/sourceControl/forgejoAuth.ts b/apps/server/src/sourceControl/forgejoAuth.ts index 7e34dc164991..ae1af762848c 100644 --- a/apps/server/src/sourceControl/forgejoAuth.ts +++ b/apps/server/src/sourceControl/forgejoAuth.ts @@ -37,6 +37,8 @@ function refineUnknownForgejoRemote(input: SourceControlUnknownRemoteRefinementI if (matchingHosts.length === 1) host = matchingHosts[0]; } if (!host) return null; + // fj 0.6 saves host names and aliases without a scheme (src/keys.rs and host_name in + // src/main.rs). Like fj, SSH defaults to HTTPS; HTTP requires an explicit web remote. const protocol = input.context.remoteUrl.startsWith("http://") ? "http:" : "https:"; return { kind: "forgejo", name: "Forgejo", baseUrl: `${protocol}//${host}` } as const; } diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 9724df7ac34b..a0f411bafc37 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -40,6 +40,9 @@ advertise a different hostname or port for SSH. When cloning or publishing, enter `host/owner/repo` or the full repository URL to choose the instance. T3 Code uses your saved `fj` credentials; no separate token configuration is needed. +For an HTTP-only instance, use its full `http://` repository URL as the Git remote. SSH remotes +assume HTTPS because `fj` 0.6 does not remember whether an instance uses HTTP. + You can create and check out Forgejo pull requests from a thread's Git actions. Use the instance's website to review and merge them. Commit or stash local changes before checking out a pull request. From 6ed94ae1b1ccbdf8c4490059ab4a9ebd7c4fc337 Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Fri, 11 Sep 2026 18:53:30 +0000 Subject: [PATCH 6/9] fix(source-control): retain Forgejo instance paths --- .../RepositoryIdentityResolver.test.ts | 20 ++-- .../src/project/RepositoryIdentityResolver.ts | 22 +++-- .../src/sourceControl/ForgejoCli.test.ts | 94 ++++++++++--------- apps/server/src/sourceControl/ForgejoCli.ts | 8 +- .../ForgejoSourceControlProvider.ts | 2 + .../src/sourceControl/forgejoAuth.test.ts | 24 +++++ apps/server/src/sourceControl/forgejoAuth.ts | 20 ++-- 7 files changed, 122 insertions(+), 68 deletions(-) diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 41f939a69a56..afdf3540c059 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -79,9 +79,8 @@ it.layer( }, ); - it.effect( - "unifies SSH and HTTPS identities when Forgejo advertises a separate SSH hostname", - () => + for (const basePath of ["", "/Forge"]) { + it.effect(`unifies Forgejo SSH and HTTPS identities with instance path '${basePath}'`, () => Effect.gen(function* () { let remoteUrl = "ssh://git@ssh.example.test:2222/Owner/Repo.git"; const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { @@ -89,7 +88,7 @@ it.layer( Effect.succeed({ stdout: input.command === "fj" - ? "git.example.test:8443" + ? `git.example.test:8443${basePath}` : input.args.includes("rev-parse") ? "/repo" : `origin\t${remoteUrl} (fetch)`, @@ -126,13 +125,20 @@ it.layer( Effect.provideService(ForgejoCli.ForgejoCli, fj), ); const sshIdentity = yield* resolver.resolve("/repo"); - remoteUrl = "https://git.example.test:8443/Owner/Repo.git"; + remoteUrl = `https://git.example.test:8443${basePath}/Owner/Repo.git`; const httpsIdentity = yield* resolver.resolve("/repo", { refresh: true }); expect(sshIdentity?.provider).toBe("forgejo"); - expect(sshIdentity?.canonicalKey).toBe("git.example.test:8443/owner/repo"); + expect(sshIdentity?.canonicalKey).toBe( + `git.example.test:8443${basePath.toLowerCase()}/owner/repo`, + ); expect(httpsIdentity?.canonicalKey).toBe(sshIdentity?.canonicalKey); + expect(sshIdentity?.displayName).toBe(`${basePath}/owner/repo`.replace(/^\//u, "")); + expect(httpsIdentity?.displayName).toBe(sshIdentity?.displayName); + expect(sshIdentity?.owner).toBe("owner"); + expect(httpsIdentity?.owner).toBe("owner"); }), - ); + ); + } it.effect("refreshes the Git root only when requested", () => { const calls: Array> = []; diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 2e18382574a4..b9571ad1e99d 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -160,13 +160,21 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( context: { ...remote, provider }, auth: { stdout: auth.value.stdout, stderr: auth.value.stderr, exitCode: auth.value.code }, }); - return refined - ? { - ...identity, - provider: refined.kind, - canonicalKey: `${new URL(refined.baseUrl).host}/${identity.canonicalKey.split("/").slice(1).join("/")}`, - } - : identity; + if (!refined) return identity; + const instance = new URL(refined.baseUrl); + const repository = identity.canonicalKey.split("/").slice(-2).join("/"); + const owner = repository.split("/")[0]; + const displayName = `${instance.pathname.replace(/^\/+|\/+$/gu, "")}/${repository}`.replace( + /^\//u, + "", + ); + return { + ...identity, + provider: refined.kind, + canonicalKey: `${instance.host}/${displayName}`.toLowerCase(), + displayName, + ...(owner ? { owner } : {}), + }; }); export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( diff --git a/apps/server/src/sourceControl/ForgejoCli.test.ts b/apps/server/src/sourceControl/ForgejoCli.test.ts index 1fa22acf9d97..91c7d03cff04 100644 --- a/apps/server/src/sourceControl/ForgejoCli.test.ts +++ b/apps/server/src/sourceControl/ForgejoCli.test.ts @@ -68,7 +68,8 @@ it.effect("reads REST metadata using only the selected instance's token", () => encodeJson({ hosts: { "git.example.test:3000": { type: "Application", token: "first-instance-token" }, - "git.example.test:4000": { type: "Application", token: "second-instance-token" }, + "git.example.test:4000": { type: "Application", token: "root-instance-token" }, + "git.example.test:4000/forge": { type: "Application", token: "second-instance-token" }, }, }), response: (request) => { @@ -98,42 +99,45 @@ it.effect("reads REST metadata using only the selected instance's token", () => }), ); -it.effect("lets fj refresh expired OAuth credentials and retries with the saved replacement", () => - Effect.gen(function* () { - let token = "expired"; - const commands: VcsProcess.VcsProcessInput[] = []; - const tokens: Array = []; - const cli = yield* makeCli({ - credentials: () => encodeJson({ hosts: { "git.example.test": { type: "OAuth", token } } }), - response: (request) => { - tokens.push(request.headers.authorization); - return request.headers.authorization === "token expired" - ? new Response(null, { status: 401 }) - : Response.json({ login: "alice" }); - }, - execute: (request) => { - commands.push(request); - token = "refreshed"; - }, - }); - yield* cli.read({ - cwd: "/repo", - operation: "getViewer", - baseUrl: "https://git.example.test", - path: "/user", - }); - assert.deepStrictEqual(tokens, ["token expired", "token refreshed"]); - assert.deepStrictEqual(commands[0]?.args, [ - "--style", - "minimal", - "--host", - "https://git.example.test/", - "whoami", - ]); - assert.strictEqual(commands[0]?.command, "fj"); - assert.strictEqual(commands[0]?.cwd, "/repo"); - }), -); +for (const basePath of ["", "/Forge"]) { + it.effect(`lets fj refresh expired OAuth credentials for the instance at '${basePath}'`, () => + Effect.gen(function* () { + let token = "expired"; + const commands: VcsProcess.VcsProcessInput[] = []; + const tokens: Array = []; + const cli = yield* makeCli({ + credentials: () => + encodeJson({ hosts: { [`git.example.test${basePath}`]: { type: "OAuth", token } } }), + response: (request) => { + tokens.push(request.headers.authorization); + return request.headers.authorization === "token expired" + ? new Response(null, { status: 401 }) + : Response.json({ login: "alice" }); + }, + execute: (request) => { + commands.push(request); + token = "refreshed"; + }, + }); + yield* cli.read({ + cwd: "/repo", + operation: "getViewer", + baseUrl: `https://git.example.test${basePath}/`, + path: "/user", + }); + assert.deepStrictEqual(tokens, ["token expired", "token refreshed"]); + assert.deepStrictEqual(commands[0]?.args, [ + "--style", + "minimal", + "--host", + `https://git.example.test${basePath}/`, + "whoami", + ]); + assert.strictEqual(commands[0]?.command, "fj"); + assert.strictEqual(commands[0]?.cwd, "/repo"); + }), + ); +} it.effect("does not expose tokens or raw responses in credential and JSON errors", () => Effect.gen(function* () { @@ -191,8 +195,8 @@ it.effect("uses fj's saved SSH aliases without probing unrelated hosts", () => const cli = yield* makeCli({ credentials: () => encodeJson({ - hosts: { "git.example.test:8443": { type: "Application", token: "saved-token" } }, - aliases: { "ssh.example.test:2222": "git.example.test:8443" }, + hosts: { "git.example.test:8443/Forge": { type: "Application", token: "saved-token" } }, + aliases: { "ssh.example.test:2222": "git.example.test:8443/Forge" }, }), response: () => { throw new Error("No HTTP request expected for a saved alias"); @@ -202,10 +206,10 @@ it.effect("uses fj's saved SSH aliases without probing unrelated hosts", () => yield* cli.refineUnknownRemote( refinement( "ssh://git@ssh.example.test:2222/Owner/Repo.git", - "codeberg.org\nssh.example.test:2222\ngit.example.test:8443", + "codeberg.org\nssh.example.test:2222\ngit.example.test:8443/Forge", ), ), - { kind: "forgejo", name: "Forgejo", baseUrl: "https://git.example.test:8443" }, + { kind: "forgejo", name: "Forgejo", baseUrl: "https://git.example.test:8443/Forge" }, ); }), ); @@ -217,7 +221,7 @@ it.effect("maps a separate SSH hostname using clone metadata and caches instance credentials: () => encodeJson({ hosts: { - "git.example.test:8443": { type: "Application", token: "first-token" }, + "git.example.test:8443/Forge": { type: "Application", token: "first-token" }, "other.example.test": { type: "Application", token: "second-token" }, }, }), @@ -232,13 +236,13 @@ it.effect("maps a separate SSH hostname using clone metadata and caches instance }); const input = refinement( "ssh://git@ssh.example.test:2222/Owner/Repo.git", - "other.example.test\ngit.example.test:8443", + "other.example.test\ngit.example.test:8443/Forge", ); const provider = yield* cli.refineUnknownRemote(input); assert.deepStrictEqual(provider, { kind: "forgejo", name: "Forgejo", - baseUrl: "https://git.example.test:8443", + baseUrl: "https://git.example.test:8443/Forge", }); assert.deepStrictEqual(yield* cli.refineUnknownRemote(input), provider); assert.strictEqual(requests.length, 2); @@ -246,7 +250,7 @@ it.effect("maps a separate SSH hostname using clone metadata and caches instance requests.map((request) => [request.url, request.headers.authorization]), [ ["https://other.example.test/api/v1/repos/Owner/Repo", "token second-token"], - ["https://git.example.test:8443/api/v1/repos/Owner/Repo", "token first-token"], + ["https://git.example.test:8443/Forge/api/v1/repos/Owner/Repo", "token first-token"], ], ); }), diff --git a/apps/server/src/sourceControl/ForgejoCli.ts b/apps/server/src/sourceControl/ForgejoCli.ts index 5513bd94bd87..f91af48cca7c 100644 --- a/apps/server/src/sourceControl/ForgejoCli.ts +++ b/apps/server/src/sourceControl/ForgejoCli.ts @@ -219,7 +219,9 @@ export const make = Effect.gen(function* () { return yield* fail(input, "Invalid Forgejo instance URL or API path."); } const url = `${base.href.replace(/\/+$/u, "")}/api/v1${input.path}`; - let credentials = yield* readCredentials(input, base.host); + // fj's host_name key includes the instance path, so two instances can share an authority. + const credentialHost = `${base.host}${base.pathname.replace(/\/+$/u, "")}`; + let credentials = yield* readCredentials(input, credentialHost); const request = (token: string | undefined) => { let request = HttpClientRequest.get(url).pipe( @@ -241,10 +243,10 @@ export const make = Effect.gen(function* () { // cannot spend the same refresh token twice. credentials = yield* refreshLock.withPermits(1)( Effect.gen(function* () { - const current = yield* readCredentials(input, base.host); + const current = yield* readCredentials(input, credentialHost); if (current?.token !== expiredToken) return current; yield* execute({ ...input, host: base.href, args: ["whoami"] }); - return yield* readCredentials(input, base.host); + return yield* readCredentials(input, credentialHost); }), ); response = yield* request(credentials?.token); diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts index 94e0d79f2347..813319ddd152 100644 --- a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts +++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts @@ -221,6 +221,8 @@ export const make = Effect.gen(function* () { do { const query = new URLSearchParams({ state: input.state === "merged" ? "closed" : input.state, + // Forgejo's list endpoint compares head_branch literally. Unlike PR creation, + // it does not accept owner:branch; filter the fork's owner/repository below. head: branch, sort: "recentupdate", limit: "50", diff --git a/apps/server/src/sourceControl/forgejoAuth.test.ts b/apps/server/src/sourceControl/forgejoAuth.test.ts index 18f54b21e6e5..3cce840b5591 100644 --- a/apps/server/src/sourceControl/forgejoAuth.test.ts +++ b/apps/server/src/sourceControl/forgejoAuth.test.ts @@ -71,6 +71,30 @@ it("keeps ports and HTTP origins distinct, and maps an unambiguous SSH host", () ); }); +it("preserves instance paths and distinguishes instances on the same authority", () => { + const hosts = "GIT.EXAMPLE.TEST:3000/Forge\ngit.example.test:3000/other"; + assert.deepStrictEqual(parseForgejoAuthHosts(auth(hosts)), [ + "git.example.test:3000/Forge", + "git.example.test:3000/other", + ]); + assert.deepStrictEqual(refine("http://git.example.test:3000/Forge/owner/repo.git", hosts), { + kind: "forgejo", + name: "Forgejo", + baseUrl: "http://git.example.test:3000/Forge", + }); + assert.isNull(refine("https://git.example.test:3000/forge/owner/repo.git", hosts)); + assert.isNull(refine("https://git.example.test:3000/owner/repo.git", hosts)); + assert.isNull(refine("ssh://git@git.example.test:2222/owner/repo.git", hosts)); + assert.deepStrictEqual( + refine("ssh://git@git.example.test:2222/owner/repo.git", hosts.split("\n")[0]!), + { + kind: "forgejo", + name: "Forgejo", + baseUrl: "https://git.example.test:3000/Forge", + }, + ); +}); + it("does not classify unauthenticated hosts or trust failed probes and stderr", () => { assert.strictEqual(refine("https://other.example.test/owner/repo", "git.example.test"), null); assert.strictEqual(refine("https://git.example.test/owner/repo", "git.example.test", 1), null); diff --git a/apps/server/src/sourceControl/forgejoAuth.ts b/apps/server/src/sourceControl/forgejoAuth.ts index ae1af762848c..f545950dbe01 100644 --- a/apps/server/src/sourceControl/forgejoAuth.ts +++ b/apps/server/src/sourceControl/forgejoAuth.ts @@ -7,16 +7,17 @@ import { type SourceControlUnknownRemoteRefinementInput, } from "./SourceControlProviderDiscovery.ts"; -/** `fj auth list` prints one authenticated authority per line, without tokens. */ +/** `fj auth list` prints each instance's authority and optional base path, without tokens. */ export function parseForgejoAuthHosts(input: SourceControlAuthProbeInput): ReadonlyArray { if (input.exitCode !== 0) return []; const hosts = new Set(); for (const line of input.stdout.split(/\r?\n/u)) { - const host = line.trim().toLowerCase(); - if (!/^(?:[a-z0-9][a-z0-9.-]*|\[[a-f0-9:]+\])(?::\d+)?$/u.test(host)) continue; + const host = line.trim(); + if (!/^(?:[a-z0-9][a-z0-9.-]*|\[[a-f0-9:]+\])(?::\d+)?(?:\/[^\s?#@\\]*)?$/iu.test(host)) + continue; try { const url = new URL(`https://${host}`); - hosts.add(url.host); + hosts.add(`${url.host}${url.pathname.replace(/\/+$/u, "")}`); } catch { // A malformed line is not evidence that a host runs Forgejo. } @@ -27,8 +28,15 @@ export function parseForgejoAuthHosts(input: SourceControlAuthProbeInput): Reado function refineUnknownForgejoRemote(input: SourceControlUnknownRemoteRefinementInput) { const hosts = parseForgejoAuthHosts(input.auth); const remote = new URL(input.context.provider.baseUrl); - let host = hosts.find((candidate) => candidate === remote.host.toLowerCase()); - if (!host && isSshRemoteUrl(input.context.remoteUrl)) { + const isSsh = isSshRemoteUrl(input.context.remoteUrl); + let instance = remote.host.toLowerCase(); + if (!isSsh) { + const url = new URL(input.context.remoteUrl); + const basePath = url.pathname.split("/").filter(Boolean).slice(0, -2).join("/"); + instance = `${url.host}${basePath ? `/${basePath}` : ""}`; + } + let host = hosts.find((candidate) => candidate === instance); + if (!host && isSsh) { // SSH and the web API can listen on different ports. Only infer that mapping // when fj knows exactly one web authority for this hostname. const matchingHosts = hosts.filter( From 92f85066b76118c3b31a6c116a8566a62870221b Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Fri, 11 Sep 2026 19:10:06 +0000 Subject: [PATCH 7/9] fix(source-control): preserve Forgejo instance path case --- apps/server/src/git/GitManager.test.ts | 1 + apps/server/src/git/GitManager.ts | 3 +- .../toolkits/pullRequests/handlers.test.ts | 12 ++-- .../src/mcp/toolkits/pullRequests/handlers.ts | 6 +- .../Layers/ProjectionPipeline.test.ts | 35 ++++++----- .../Layers/ProjectionPipeline.ts | 2 +- .../orchestration/ThreadPullRequestReactor.ts | 4 +- .../orchestration/ThreadSettlementReactor.ts | 4 +- .../decider.pullRequests.test.ts | 44 +++++++++++++ apps/server/src/orchestration/decider.ts | 3 +- .../RepositoryIdentityResolver.test.ts | 6 +- .../src/project/RepositoryIdentityResolver.ts | 2 +- .../pullRequest/PullRequestService.test.ts | 63 +++++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 23 ++++--- .../src/pullRequest/linkedThreads.test.ts | 26 ++++++++ apps/server/src/pullRequest/linkedThreads.ts | 2 +- apps/web/src/components/ChatView.logic.ts | 7 ++- .../pullRequest/PullRequestDetailPanel.tsx | 4 +- .../pullRequest/pullRequestDetail.logic.ts | 6 +- .../pullRequestProjectAssignment.logic.ts | 7 ++- .../pullRequestProjectFilter.logic.ts | 5 +- .../pullRequest/pullRequestReviewStore.ts | 3 +- .../pullRequest/pullRequestStackSnapshot.ts | 4 +- apps/web/src/lib/openPullRequestLink.test.ts | 24 +++++++ apps/web/src/lib/openPullRequestLink.ts | 4 +- apps/web/src/routes/_chat.pull-requests.tsx | 6 +- apps/web/src/state/pullRequests.ts | 3 +- packages/shared/src/changeRequestUrl.test.ts | 5 ++ packages/shared/src/changeRequestUrl.ts | 23 +++++-- packages/shared/src/sourceControl.test.ts | 17 +++++ packages/shared/src/sourceControl.ts | 21 ++++++- .../shared/src/threadPullRequests.test.ts | 25 ++++++++ packages/shared/src/threadPullRequests.ts | 29 +++++---- 33 files changed, 353 insertions(+), 76 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6260f82329ea..eb71c7373cfc 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1676,6 +1676,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ], ["https://bitbucket.org/team/repository/pull-requests/42", "bitbucket.org/team/repository"], ["https://git.example.test/Owner/Repo/pulls/42", "git.example.test/owner/repo"], + ["https://git.example.test/Forge/Owner/Repo/pulls/42", "git.example.test/Forge/owner/repo"], ["https://git.example.test:8443/Owner/Repo/pulls/42/files", "git.example.test:8443/owner/repo"], [ "https://dev.azure.com/org/project/_git/repository/pullrequest/42", diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index a6620410822b..2621ff3df8c4 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -43,6 +43,7 @@ import { import { getChangeRequestTerminologyForKind, isSshRemoteUrl, + normalizeSourceControlRepository, type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; @@ -227,7 +228,7 @@ export function pullRequestRepositoryKey(value: string): string | null { const key = normalizeGitRemoteUrl(url.toString()); // Forgejo's web authority identifies the instance independently of SSH. return match[2]?.toLowerCase() === "pulls" - ? `${url.host.toLowerCase()}/${key.split("/").slice(1).join("/")}` + ? `${url.host.toLowerCase()}/${normalizeSourceControlRepository(match[1].replace(/^\//u, ""), "forgejo")}` : key; } catch { return null; diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 52ae8d8fa14c..2cbfceaed21c 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -304,25 +304,25 @@ describe("pull request toolkit handlers", () => { Effect.gen(function* () { const harness = yield* makeHarness({ project: makeProject({ - canonicalKey: "forge.example.test:3000/owner/repo", + canonicalKey: "forge.example.test:3000/Forge/owner/repo", locator: { source: "git-remote", remoteName: "origin", - remoteUrl: "http://forge.example.test:3000/Owner/Repo.git", + remoteUrl: "http://forge.example.test:3000/Forge/Owner/Repo.git", }, provider: "forgejo", - displayName: "Owner/Repo", + displayName: "Forge/Owner/Repo", }), }); const result = yield* harness.call("link_pull_request", { - repository: "Owner/Other", + repository: "Forge/Owner/Other", number: 42, }); expect(result).toMatchObject({ host: "forge.example.test:3000", - repository: "owner/other", + repository: "Forge/owner/other", number: 42, - url: "http://forge.example.test:3000/owner/other/pulls/42", + url: "http://forge.example.test:3000/Forge/owner/other/pulls/42", }); }), ); diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 06fe408c5003..20d6eed9d21b 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -8,6 +8,7 @@ import { type ThreadPullRequestLink, } from "@t3tools/contracts"; import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl"; import { resolveThreadPullRequestChains, threadPullRequestKeyOf, @@ -81,7 +82,10 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( if (host === undefined) { return yield* new PullRequestHostRequiredError({}); } - const repository = input.repository.toLowerCase(); + const repository = normalizeSourceControlRepository( + input.repository, + host === projectHost.host ? projectHost.kind : undefined, + ); const url = changeRequestUrlFor( // The project's kind only describes its own host; another host gets no URL guess. diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index a038a5662169..6fe508aa1c8b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -690,7 +690,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull-requests-")))( "OrchestrationProjectionPipeline pull request links", (it) => { - it.effect("projects link, sync, unlink, legacy replay and delete into the link table", () => + it.effect("projects Forgejo link lifecycle with a case-sensitive instance path", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -746,7 +746,10 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull threadId, projectId, title: "Thread PR", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -776,10 +779,10 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull payload: { threadId, link: { - host: "github.com", - repository: "pingdotgg/t3code", + host: "git.example.test", + repository: "Forge/pingdotgg/t3code", number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", + url: "https://git.example.test/Forge/pingdotgg/t3code/pulls/42", source: "created", linkedAt: "2026-01-01T00:00:02.000Z", snapshot: null, @@ -801,8 +804,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull stackJson: null, }, { - host: "github.com", - repository: "pingdotgg/t3code", + host: "git.example.test", + repository: "Forge/pingdotgg/t3code", number: 42, source: "created", linkedAt: "2026-01-01T00:00:02.000Z", @@ -828,8 +831,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull type: "thread.pull-request-synced", payload: { threadId, - host: "github.com", - repository: "pingdotgg/t3code", + host: "git.example.test", + repository: "Forge/pingdotgg/t3code", number: 42, snapshot, stack: null, @@ -841,8 +844,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull type: "thread.pull-request-synced", payload: { threadId, - host: "github.com", - repository: "pingdotgg/t3code", + host: "git.example.test", + repository: "Forge/pingdotgg/t3code", number: 99, snapshot, stack: null, @@ -879,8 +882,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull type: "thread.pull-request-unlinked", payload: { threadId, - host: "GitHub.COM", - repository: "PingDotGG/T3Code", + host: "Git.Example.Test", + repository: "Forge/PingDotGG/T3Code", number: 42, updatedAt: "2026-01-01T00:00:05.000Z", }, @@ -896,10 +899,10 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull payload: { threadId, link: { - host: "github.com", - repository: "pingdotgg/t3code", + host: "git.example.test", + repository: "Forge/pingdotgg/t3code", number: 43, - url: "https://github.com/pingdotgg/t3code/pull/43", + url: "https://git.example.test/Forge/pingdotgg/t3code/pulls/43", source: "agent", linkedAt: "2026-01-01T00:00:06.000Z", snapshot: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e303e7323729..4d84b5063f9c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -883,7 +883,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadPullRequestRepository.delete({ threadId: event.payload.threadId, host: event.payload.host.toLowerCase(), - repository: event.payload.repository.toLowerCase(), + repository: event.payload.repository, number: event.payload.number, }); yield* projectionThreadRepository.upsert({ diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index 86026632a6af..d50fa3df68fe 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -1,3 +1,4 @@ +import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl"; import { canonicalRepositoryKey, sourceControlRepositorySelector, @@ -42,7 +43,8 @@ function samePullRequest( if (left == null || right === null) return left == null && right === null; return ( left.projectId === right.projectId && - left.repository.toLowerCase() === right.repository.toLowerCase() && + normalizeSourceControlRepository(left.repository) === + normalizeSourceControlRepository(right.repository) && left.number === right.number && left.url === right.url ); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 61fc5d4ab863..6e262250de37 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -1,3 +1,4 @@ +import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl"; import { CommandId } from "@t3tools/contracts"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; @@ -164,7 +165,8 @@ export const make = Effect.gen(function* () { const matchesMerge = mergedPullRequest !== null && reference.projectId === mergedPullRequest.projectId && - reference.repository.toLowerCase() === mergedPullRequest.repository.toLowerCase() && + normalizeSourceControlRepository(reference.repository) === + normalizeSourceControlRepository(mergedPullRequest.repository) && reference.number === mergedPullRequest.number; if (!matchesMerge && !projects.has(reference.projectId)) { return yield* Effect.die(new Error("linked pull request project not found")); diff --git a/apps/server/src/orchestration/decider.pullRequests.test.ts b/apps/server/src/orchestration/decider.pullRequests.test.ts index 49bfd4bea4de..821d9862443f 100644 --- a/apps/server/src/orchestration/decider.pullRequests.test.ts +++ b/apps/server/src/orchestration/decider.pullRequests.test.ts @@ -115,6 +115,50 @@ const snapshot: ThreadPullRequestSnapshot = { }; it.layer(NodeServices.layer)("pull request link decider", (it) => { + it.effect( + "links PRs from case-distinct Forgejo instances without replacing or unlinking each other", + () => + Effect.gen(function* () { + const upper = makeLink({ + host: "git.example.test", + repository: "Forge/acme/web", + url: "https://git.example.test/Forge/acme/web/pulls/42", + }); + const model = makeReadModel([upper]); + const command = yield* decodeCommand({ + type: "thread.pull-request.link", + commandId: "link", + threadId: THREAD_ID, + host: upper.host, + repository: "forge/ACME/WEB", + number: 42, + url: "https://git.example.test/forge/acme/web/pulls/42", + source: "manual", + }); + const event = expectSingleEvent( + yield* decideOrchestrationCommand({ readModel: model, command }), + "thread.pull-request-linked", + ); + expect(event.payload.link.repository).toBe("forge/acme/web"); + const unlink = yield* decodeCommand({ + type: "thread.pull-request.unlink", + commandId: "unlink", + threadId: THREAD_ID, + host: upper.host, + repository: "Forge/ACME/WEB", + number: 42, + }); + const unlinked = expectSingleEvent( + yield* decideOrchestrationCommand({ + readModel: makeReadModel([upper, event.payload.link]), + command: unlink, + }), + "thread.pull-request-unlinked", + ); + expect(unlinked.payload.repository).toBe("Forge/acme/web"); + }), + ); + it.effect("legacy unlink cannot remove a newer cross-host link", () => Effect.gen(function* () { const own = makeLink(); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c0787a18d096..10676fd8879c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1083,8 +1083,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" existing.stack !== null || thread.pullRequests.some( (link) => - link.host.toLowerCase() === key.host && - link.repository.toLowerCase() === key.repository && + threadPullRequestKeysEqual({ ...link, number: key.number }, key) && link.stack?.layers.some((layer) => layer.number === key.number), ); if (belongsToStack) { diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index afdf3540c059..9b01f3265dea 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -79,7 +79,7 @@ it.layer( }, ); - for (const basePath of ["", "/Forge"]) { + for (const basePath of ["", "/Forge", "/forge"]) { it.effect(`unifies Forgejo SSH and HTTPS identities with instance path '${basePath}'`, () => Effect.gen(function* () { let remoteUrl = "ssh://git@ssh.example.test:2222/Owner/Repo.git"; @@ -128,9 +128,7 @@ it.layer( remoteUrl = `https://git.example.test:8443${basePath}/Owner/Repo.git`; const httpsIdentity = yield* resolver.resolve("/repo", { refresh: true }); expect(sshIdentity?.provider).toBe("forgejo"); - expect(sshIdentity?.canonicalKey).toBe( - `git.example.test:8443${basePath.toLowerCase()}/owner/repo`, - ); + expect(sshIdentity?.canonicalKey).toBe(`git.example.test:8443${basePath}/owner/repo`); expect(httpsIdentity?.canonicalKey).toBe(sshIdentity?.canonicalKey); expect(sshIdentity?.displayName).toBe(`${basePath}/owner/repo`.replace(/^\//u, "")); expect(httpsIdentity?.displayName).toBe(sshIdentity?.displayName); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index b9571ad1e99d..be04ff0b2036 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -171,7 +171,7 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( return { ...identity, provider: refined.kind, - canonicalKey: `${instance.host}/${displayName}`.toLowerCase(), + canonicalKey: `${instance.host}/${displayName}`, displayName, ...(owner ? { owner } : {}), }; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 396037cb6bd9..dce149a23ef1 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -331,6 +331,69 @@ for (const checkout of [ ); } +it.effect("routes and caches Forgejo summaries separately for case-distinct instance paths", () => + Effect.gen(function* () { + const requests: Array<{ cwd: string; baseUrl: string }> = []; + const provider = yield* ForgejoSourceControlProvider.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ForgejoCli.ForgejoCli)({ + read: (input) => + Effect.sync(() => { + requests.push({ cwd: input.cwd, baseUrl: input.baseUrl }); + const upper = input.baseUrl.endsWith("/Forge"); + return { + body: { + number: 7, + title: upper ? "Upper" : "Lower", + html_url: `${input.baseUrl}/acme/web/pulls/7`, + state: upper ? "open" : "closed", + merged: !upper, + base: { ref: "main", repo: null }, + head: { ref: "feature", repo: null }, + updated_at: "2026-09-11T00:00:00Z", + }, + hasNextPage: false, + }; + }), + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + ), + ), + ); + const service = yield* makeService({ + projects: ["Forge", "forge"].map((path) => + project({ + id: path, + title: path, + workspaceRoot: `/${path}`, + provider: "forgejo", + host: "git.example.test", + repository: `${path}/acme/web`, + }), + ), + providers: [], + getSourceControlProvider: () => Effect.succeed(provider), + }); + const ref = { + projectId: "Forge" as ProjectId, + host: "git.example.test", + repository: "Forge/ACME/WEB", + number: 7, + }; + assert.strictEqual((yield* service.summary(ref)).title, "Upper"); + assert.strictEqual( + (yield* service.summary({ ...ref, repository: "forge/acme/web" })).title, + "Lower", + ); + assert.strictEqual((yield* service.summary(ref)).state, "open"); + assert.deepStrictEqual(requests, [ + { cwd: "/Forge", baseUrl: "https://git.example.test/Forge" }, + { cwd: "/forge", baseUrl: "https://git.example.test/forge" }, + ]); + }), +); + it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () => Effect.gen(function* () { let refinementCalls = 0; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 89ff95375fd1..64723c0d5d85 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,5 +1,6 @@ import { canonicalRepositoryKey, + normalizeSourceControlRepository, sourceControlRepositorySelector, } from "@t3tools/shared/sourceControl"; import { changeRequestUrlFor } from "@t3tools/shared/changeRequestUrl"; @@ -335,8 +336,8 @@ function parseListCursor(raw: string): ListCursor | null { * How a listing tells two repositories apart. The host is part of it because the same * `owner/repo` exists on github.com and on an Enterprise install, and they are two repositories. */ -function listCursorKey(host: string, repository: string): string { - return `${host} ${repository.toLowerCase()}`; +function listCursorKey(host: string, repository: string, kind: SourceControlProviderKind): string { + return `${host} ${normalizeSourceControlRepository(repository, kind)}`; } /** @@ -666,6 +667,7 @@ export const make = Effect.gen(function* () { const key = listCursorKey( host, kind === "azure-devops" ? identity.canonicalKey : repository, + kind, ); if (seen.has(key)) continue; seen.add(key); @@ -783,7 +785,11 @@ export const make = Effect.gen(function* () { const own = supported[0]; const repository = ref.repository.trim(); const host = ref.host?.trim().toLowerCase(); - if (own !== undefined && own.repository.toLowerCase() === repository.toLowerCase()) { + if ( + own !== undefined && + normalizeSourceControlRepository(own.repository, own.api.kind) === + normalizeSourceControlRepository(repository, own.api.kind) + ) { // Hostless references only ever meant the project's own repository, and a hosted one // naming it still is; either way the project serves itself. if (host === undefined || host === own.host) return Effect.succeed(own); @@ -819,7 +825,8 @@ export const make = Effect.gen(function* () { onHost.find( (candidate) => candidate.api.kind !== "azure-devops" && - candidate.repository.toLowerCase() === repository.toLowerCase(), + normalizeSourceControlRepository(candidate.repository, candidate.api.kind) === + normalizeSourceControlRepository(repository, candidate.api.kind), ) ?? onHost.find((candidate) => candidate.api.kind !== "azure-devops"); if (route === undefined) { @@ -2365,7 +2372,7 @@ export const make = Effect.gen(function* () { const refEpochs = new Map(); const REF_EPOCH_CAPACITY = 2_048; const refScope = (ref: PullRequestRef) => - `${ref.projectId} ${ref.host?.toLowerCase() ?? ""} ${ref.repository.toLowerCase()} ${ref.number}`; + `${ref.projectId} ${ref.host?.toLowerCase() ?? ""} ${normalizeSourceControlRepository(ref.repository)} ${ref.number}`; const refEpoch = (ref: PullRequestRef) => Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0); // Keys carry the reference back out of the cache loader, so the slot layout is shared with @@ -2375,7 +2382,7 @@ export const make = Effect.gen(function* () { refEpoch(ref), ref.projectId, ref.host?.toLowerCase() ?? null, - ref.repository.toLowerCase(), + normalizeSourceControlRepository(ref.repository), ref.number, ]); const refOfCacheKey = (key: string): PullRequestRef => { @@ -2445,7 +2452,7 @@ export const make = Effect.gen(function* () { operation, project.api.kind, project.host.toLowerCase(), - project.repository.toLowerCase(), + normalizeSourceControlRepository(project.repository, project.api.kind), project.project.id, project.project.workspaceRoot, String(input.number), @@ -2698,7 +2705,7 @@ export const make = Effect.gen(function* () { refEpoch(input), input.projectId, input.host?.toLowerCase() ?? null, - input.repository.toLowerCase(), + normalizeSourceControlRepository(input.repository), input.number, input.cursor ?? null, input.commit ?? null, diff --git a/apps/server/src/pullRequest/linkedThreads.test.ts b/apps/server/src/pullRequest/linkedThreads.test.ts index 08237d0e3ef4..0b1a192934bc 100644 --- a/apps/server/src/pullRequest/linkedThreads.test.ts +++ b/apps/server/src/pullRequest/linkedThreads.test.ts @@ -17,6 +17,20 @@ it.effect( VALUES ('project-1', 'Project', '/tmp/project', '[]', ${createdAt}, ${createdAt}) `; const fixtures = [ + { + id: "upper-instance", + host: "git.example.test", + repository: "Forge/acme/web", + number: 7, + source: "manual", + }, + { + id: "lower-instance", + host: "git.example.test", + repository: "forge/acme/web", + number: 7, + source: "manual", + }, { id: "azure", host: "dev.azure.com", @@ -94,6 +108,18 @@ it.effect( number: 7, })).threads.map((thread) => thread.id), ).toEqual(["azure"]); + for (const [path, id] of [ + ["Forge", "upper-instance"], + ["forge", "lower-instance"], + ]) { + expect( + (yield* listLinkedPullRequestThreads({ + host: "git.example.test", + repository: `${path}/ACME/WEB`, + number: 7, + })).threads.map((thread) => thread.id), + ).toEqual([id]); + } const result = yield* listLinkedPullRequestThreads({ host: "GitHub.Com", repository: "ACME/WEB", diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts index 26c751f0b817..12b1cfa30080 100644 --- a/apps/server/src/pullRequest/linkedThreads.ts +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -20,7 +20,7 @@ export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThre FROM projection_thread_pull_requests AS link JOIN projection_threads AS t ON t.thread_id = link.thread_id WHERE link.host = ${key.host.toLowerCase()} - AND link.repository = ${key.repository.toLowerCase()} + AND link.repository = ${key.repository} AND link.number = ${key.number} AND link.source != 'stack-dismissed' AND t.deleted_at IS NULL diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 772a0f3cf2fa..a3200179cf47 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,3 +1,4 @@ +import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl"; import { ANTIGRAVITY_DEFAULT_MODEL, type AssetCreateUrlInput, @@ -141,13 +142,13 @@ export function shouldRetargetThreadPullRequestPanel( surface: RightPanelSurface | null, ): boolean { if (previous === null || current === null || surface?.kind !== "pull-request") return false; - const previousRepository = previous.repository.toLowerCase(); + const previousRepository = normalizeSourceControlRepository(previous.repository); return ( (previous.projectId !== current.projectId || - previousRepository !== current.repository.toLowerCase() || + previousRepository !== normalizeSourceControlRepository(current.repository) || previous.number !== current.number) && surface.projectId === previous.projectId && - surface.repository.toLowerCase() === previousRepository && + normalizeSourceControlRepository(surface.repository) === previousRepository && surface.number === previous.number ); } diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 0284a2e6cdec..9621613c7337 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,3 +1,4 @@ +import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl"; import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { usePullRequestStack } from "~/state/usePullRequestStack"; import { RefreshIcon } from "~/components/ui/refresh-icon"; @@ -524,7 +525,8 @@ export function PullRequestDetailPanel({ const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; const matchingListEntry = listEntry?.projectId === reference.projectId && - listEntry.repository.toLowerCase() === reference.repository.toLowerCase() && + normalizeSourceControlRepository(listEntry.repository) === + normalizeSourceControlRepository(reference.repository) && listEntry.number === reference.number ? listEntry : null; diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 2f71104e8f7d..084fb5b3f108 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -1,3 +1,4 @@ +import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl"; import * as Schema from "effect/Schema"; import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; @@ -1055,7 +1056,7 @@ const pullRequestDetailSnapshotKey = ( reference: PullRequestDetailSnapshotRef, ) => reference.host - ? `t3.pullRequests.detail:${JSON.stringify([environmentId, reference.projectId, reference.host.toLowerCase(), reference.repository.toLowerCase(), reference.number])}` + ? `t3.pullRequests.detail:${JSON.stringify([environmentId, reference.projectId, reference.host.toLowerCase(), normalizeSourceControlRepository(reference.repository), reference.number])}` : `t3.pullRequests.detail:${environmentId}:${reference.projectId}:${reference.repository}#${reference.number}`; const decodeDetailSnapshot = Schema.decodeUnknownOption(PullRequestDetail); @@ -1110,7 +1111,8 @@ export function resolveDisplayedPullRequestDetail(input: { if ( input.cached !== null && input.cached.projectId === input.reference.projectId && - input.cached.repository.toLowerCase() === input.reference.repository.toLowerCase() && + normalizeSourceControlRepository(input.cached.repository) === + normalizeSourceControlRepository(input.reference.repository) && input.cached.number === input.reference.number && (input.reference.host === undefined || parseChangeRequestUrl(input.cached.url)?.host === input.reference.host.toLowerCase()) diff --git a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts index d52b72ca395c..0b51b8a3ded2 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts @@ -1,4 +1,5 @@ import type { EnvironmentId, EnvironmentMachineKind, ProjectId } from "@t3tools/contracts"; +import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; /** The little of a project this needs: who holds it, and which repository it is a copy of. */ export interface AssignableProject { @@ -11,11 +12,11 @@ export interface AssignableProject { * The remote's normalized URL is what says "same repository" across machines — it comes from the * remote, not from a local path. Empty where the project has no identity to compare with. * - * `normalizeGitRemoteUrl` already lower-cases the whole remote, so the key arrives cased one way - * whatever the remote said; the fold here only guards a key assembled some other way. + * Preserve the case of instance paths while normalizing the authority and repository names. */ function repositoryKey(project: AssignableProject): string | undefined { - return project.repositoryIdentity?.canonicalKey?.toLowerCase(); + const key = project.repositoryIdentity?.canonicalKey; + return key === undefined ? undefined : canonicalRepositoryKey(key); } /** diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts index 5b214e3f37f6..5ac2fae4a2a0 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts @@ -1,4 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; +import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; import type { AssignableProject } from "./pullRequestProjectAssignment.logic"; @@ -30,7 +31,9 @@ export function pullRequestFilterProjects( ) { const byRepository = new Map(); for (const project of projects) { - const repository = project.repositoryIdentity?.canonicalKey?.toLowerCase(); + const canonicalKey = project.repositoryIdentity?.canonicalKey; + const repository = + canonicalKey === undefined ? undefined : canonicalRepositoryKey(canonicalKey); const key = JSON.stringify([ project.environmentId, repository ? ["repository", repository] : ["project", project.id], diff --git a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts index fba8d4c56e13..cb9c0dfc18ba 100644 --- a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts +++ b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts @@ -1,3 +1,4 @@ +import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl"; /** * A review being written, held until it is sent. * @@ -29,7 +30,7 @@ export function pullRequestReviewKey(reference: PullRequestRef): string { return JSON.stringify([ reference.projectId, reference.host?.toLowerCase() ?? null, - reference.repository.toLowerCase(), + normalizeSourceControlRepository(reference.repository), reference.number, ]); } diff --git a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts index 4e48a2f3705b..3659be9fefb3 100644 --- a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts +++ b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts @@ -1,3 +1,4 @@ +import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl"; import type { PullRequestRef, PullRequestStack, ThreadPullRequestLink } from "@t3tools/contracts"; /** Saved native membership is enough for navigation, but never supplies action head SHAs. */ @@ -10,7 +11,8 @@ export function savedPullRequestStack( const matching = links.filter( (link) => link.host.toLowerCase() === host && - link.repository.toLowerCase() === reference.repository.toLowerCase(), + normalizeSourceControlRepository(link.repository) === + normalizeSourceControlRepository(reference.repository), ); const exact = matching.filter((link) => link.number === reference.number); const candidates = diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index ce9c2193a58e..9a39a6fc0edf 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -375,6 +375,30 @@ describe("findProjectForChangeRequest", () => { const project = (identity: Record) => ({ id: "p1", repositoryIdentity: identity }) as never; + it("matches Forgejo instance paths by case", () => { + const projects = ["Forge", "forge"].map((path) => + project({ + canonicalKey: `git.example.test/${path}/owner/repo`, + provider: "forgejo", + displayName: `${path}/owner/repo`, + }), + ); + expect( + findProjectForChangeRequest(projects, { + host: "git.example.test", + repository: "Forge/Owner/Repo", + number: 7, + }), + ).toBe(projects[0]); + expect( + findProjectForChangeRequest(projects, { + host: "git.example.test", + repository: "forge/Owner/Repo", + number: 7, + }), + ).toBe(projects[1]); + }); + it("matches a nested GitLab group by the whole path below the host", () => { // The server identifies a repository by `displayName`, which keeps every group segment; the // two-segment owner/name form would look for `t3tools/t3code` and find nothing. diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 35a9fed4cc5d..944360e19d99 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -6,6 +6,7 @@ import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/cont import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; import { canonicalRepositoryKey, + normalizeSourceControlRepository, sourceControlRepositorySelector, } from "@t3tools/shared/sourceControl"; @@ -59,7 +60,8 @@ export function findProjectForChangeRequest( (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); return ( repository !== null && - repository.toLowerCase() === link.repository.toLowerCase() && + normalizeSourceControlRepository(repository, kind) === + normalizeSourceControlRepository(link.repository, kind) && pullRequestHostOf(identity, kind) === link.host.toLowerCase() ); }); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 5a21a2cc37ff..71567912c8d7 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1,3 +1,4 @@ +import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { Spinner } from "~/components/ui/spinner"; import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; @@ -222,7 +223,10 @@ const EMPTY_PENDING_SURFACES = new Set(); const MAX_SEARCH_LABEL_CANDIDATES = 100; const pullRequestListEntryId = (target: Parameters[0]) => - pullRequestSurfaceId({ ...target, repository: target.repository.toLowerCase() }); + pullRequestSurfaceId({ + ...target, + repository: normalizeSourceControlRepository(target.repository), + }); function pullRequestSearchLabels(raw: unknown): Partial> { const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).slice( diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index e2e840c89c49..dfc1342850a1 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -1,3 +1,4 @@ +import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl"; import { useAtomValue } from "@effect/atom-react"; import { createLinkedPullRequestSummaryAtomFamily, @@ -60,7 +61,7 @@ export function useSharedPullRequestSummary( environmentId, reference.projectId, reference.host?.toLowerCase() ?? null, - reference.repository.toLowerCase(), + normalizeSourceControlRepository(reference.repository), reference.number, ]); const atom = observedPullRequestSummaryAtom(key); diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts index 4bf93b1cf460..1a72e90177c9 100644 --- a/packages/shared/src/changeRequestUrl.test.ts +++ b/packages/shared/src/changeRequestUrl.test.ts @@ -13,6 +13,7 @@ import { describe("parseChangeRequestUrl", () => { it.each([ ["https://codeberg.org/Owner/Repo/pulls/42", "codeberg.org", "owner/repo"], + ["https://git.example.test/Forge/Owner/Repo/pulls/42", "git.example.test", "Forge/owner/repo"], ["https://git.fryuni.dev/Owner/Repo/pulls/42/files?w=1", "git.fryuni.dev", "owner/repo"], [ "http://forge.example.test:3000/forge/Owner/Repo/pulls/42", @@ -114,6 +115,10 @@ describe("parseChangeRequestUrl", () => { describe("siblingPullRequestUrl", () => { it.each([ + [ + "https://git.example.test/Forge/Owner/Repo/pulls/42", + "https://git.example.test/Forge/owner/repo/pulls/43", + ], [ "https://git.fryuni.dev/Owner/Repo/pulls/42/files#note", "https://git.fryuni.dev/owner/repo/pulls/43", diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 16f8061be3b5..528b92493cf3 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -1,13 +1,13 @@ import type { RepositoryIdentity, ThreadLinkedPullRequest } from "@t3tools/contracts"; -import { canonicalRepositoryKey } from "./sourceControl.ts"; +import { canonicalRepositoryKey, normalizeSourceControlRepository } from "./sourceControl.ts"; /** * A change request named the way a thread link names one: the host below which the repository * is addressed, the repository path as that host writes it, and the number. * * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce - * from a git remote: lower case and the full path below the host. Forgejo retains its web - * port because different ports can serve different instances. + * from a git remote: the full path below the host. Forgejo retains its web port and instance + * path's case because different ports or paths can serve different instances. */ export interface ChangeRequestLink { readonly host: string; @@ -57,7 +57,7 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu if (gitlab) return claim(host, gitlab); // Forgejo uses /pulls/ on arbitrary instance hosts, optionally below a subpath. const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (forgejo) return claim(url.host.toLowerCase(), forgejo); + if (forgejo) return claim(url.host.toLowerCase(), forgejo, "forgejo"); // Bitbucket Cloud: /{workspace}/{repo}/pull-requests/{n} if (isHostOf(host, "bitbucket.org", "bitbucket")) { const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); @@ -72,11 +72,22 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu return null; } -function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { +function claim( + host: string, + match: RegExpExecArray | null, + kind?: string, +): ChangeRequestLink | null { const repository = match?.[1]; const number = Number(match?.[2]); return repository && Number.isSafeInteger(number) && number > 0 - ? { host, repository: repository.toLowerCase(), number } + ? { + host, + repository: + kind === "forgejo" + ? normalizeSourceControlRepository(repository, kind) + : repository.toLowerCase(), + number, + } : null; } diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index d1922cd5f310..09af04ba3fac 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; import { + canonicalRepositoryKey, + normalizeSourceControlRepository, sourceControlRepositorySelector, detectSourceControlProviderFromRemoteUrl, getChangeRequestTerminologyForKind, @@ -8,6 +10,21 @@ import { resolveChangeRequestPresentation, } from "./sourceControl.ts"; +it("normalizes repository names without losing Forgejo instance path case", () => { + expect(canonicalRepositoryKey("GIT.EXAMPLE.TEST/Forge/Owner/Repo", "forgejo")).toBe( + "git.example.test/Forge/owner/repo", + ); + expect(canonicalRepositoryKey("GIT.EXAMPLE.TEST/forge/Owner/Repo", "forgejo")).toBe( + "git.example.test/forge/owner/repo", + ); + expect(normalizeSourceControlRepository("Group/Subgroup/Repo", "gitlab")).toBe( + "group/subgroup/repo", + ); + expect(canonicalRepositoryKey("SSH.DEV.AZURE.COM/v3/Org/Project/Repo")).toBe( + "dev.azure.com/org/project/_git/repo", + ); +}); + describe("source control presentation", () => { it("uses Forgejo pull request terminology and fj checkout instructions", () => { expect( diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index c0d492e71f12..447a797fbc79 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -280,7 +280,26 @@ export function sourceControlRepositorySelector( return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; } -export function canonicalRepositoryKey(key: string): string { +/** Instance paths can be case-sensitive. Without a known provider, only fold owner/name. */ +export function normalizeSourceControlRepository(repository: string, kind?: string | null): string { + if (kind != null && kind !== "unknown" && kind !== "forgejo") + return repository.trim().toLowerCase(); + const segments = repository.trim().split("/"); + return segments + .map((segment, index) => (index >= segments.length - 2 ? segment.toLowerCase() : segment)) + .join("/"); +} + +export function canonicalRepositoryKey(key: string, kind?: string | null): string { + const separator = key.indexOf("/"); + if (separator >= 0) { + const host = key.slice(0, separator).toLowerCase(); + const provider = + host === "dev.azure.com" || host.endsWith(".visualstudio.com") || host === "ssh.dev.azure.com" + ? "azure-devops" + : kind; + key = `${host}/${normalizeSourceControlRepository(key.slice(separator + 1), provider)}`; + } return key .replace( /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts index 05fc7b12b021..31c54c748779 100644 --- a/packages/shared/src/threadPullRequests.test.ts +++ b/packages/shared/src/threadPullRequests.test.ts @@ -46,6 +46,31 @@ function link( } describe("threadPullRequestKeysEqual", () => { + it("keeps instance paths case-sensitive while folding owner and repository names", () => { + const upper = { host: "Git.Example.Test", repository: "Forge/Owner/Repo", number: 1 }; + expect( + threadPullRequestKeysEqual(upper, { + ...upper, + host: "git.example.test", + repository: "Forge/owner/repo", + }), + ).toBe(true); + expect(threadPullRequestKeysEqual(upper, { ...upper, repository: "forge/owner/repo" })).toBe( + false, + ); + const links = [ + link(1, { ...upper, snapshot: snapshot({ headBranch: "first" }) }), + link(2, { + host: upper.host, + repository: "forge/owner/repo", + snapshot: snapshot({ baseBranch: "first" }), + }), + ]; + expect(resolveThreadPullRequestChains(links).map((chain) => chain.layers.length)).toEqual([ + 1, 1, + ]); + }); + it("ignores host and repository case", () => { expect( threadPullRequestKeysEqual( diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 5d91d98d52c9..c15e98826eed 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -8,13 +8,16 @@ import type { import { pullRequestHostOf } from "@t3tools/contracts"; import { parseChangeRequestUrl } from "./changeRequestUrl.ts"; -import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts"; +import { + canonicalRepositoryKey, + normalizeSourceControlRepository, + sourceControlRepositorySelector, +} from "./sourceControl.ts"; /** Normalize stored link identity, including Azure's SSH and browser host aliases. */ export function normalizeThreadPullRequestKey(key: ThreadPullRequestKey): ThreadPullRequestKey { - const canonical = canonicalRepositoryKey( - `${key.host.trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, - ); + const host = key.host.trim().toLowerCase(); + const canonical = canonicalRepositoryKey(`${host}/${key.repository.trim()}`); const separator = canonical.indexOf("/"); return { host: canonical.slice(0, separator), @@ -23,7 +26,7 @@ export function normalizeThreadPullRequestKey(key: ThreadPullRequestKey): Thread }; } -/** Legacy Azure selectors omit the organization and project; recover those from the PR URL. */ +/** Recover Azure host aliases and Forgejo instance paths from legacy links' URLs. */ export function legacyThreadPullRequestKey( linked: Pick, fallbackHost?: string, @@ -31,7 +34,10 @@ export function legacyThreadPullRequestKey( const parsed = parseChangeRequestUrl(linked.url); if (parsed !== null && parsed.number === linked.number) { const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); - if (canonical.startsWith("dev.azure.com/")) { + if ( + canonical.startsWith("dev.azure.com/") || + /\/pulls\/\d+(?:\/|$)/u.test(new URL(linked.url).pathname) + ) { return normalizeThreadPullRequestKey(parsed); } } @@ -45,12 +51,12 @@ export function legacyThreadPullRequestKey( } return { host: host.trim().toLowerCase() || "unknown", - repository: linked.repository.trim().toLowerCase(), + repository: normalizeSourceControlRepository(linked.repository), number: linked.number, }; } -/** Identity comparison for links: host-level, case-insensitive on host and repository. */ +/** Compare normalized identities without folding case-sensitive instance paths. */ export function threadPullRequestKeysEqual( left: ThreadPullRequestKey, right: ThreadPullRequestKey, @@ -157,7 +163,8 @@ export function legacyLinkedPullRequestOf( } return ( link.host.toLowerCase() === host.toLowerCase() && - link.repository.toLowerCase() === repository.toLowerCase() + normalizeSourceControlRepository(link.repository, identity.provider) === + normalizeSourceControlRepository(repository, identity.provider) ); }), ); @@ -191,7 +198,7 @@ export function resolveThreadPullRequestChains( const nativeStacks = new Map>(); for (const link of visible) { if (link.stack === null) continue; - const stackKey = `${link.host.toLowerCase()}/${link.repository.toLowerCase()}#stack:${link.stack.id}`; + const stackKey = `${link.host.toLowerCase()}/${normalizeSourceControlRepository(link.repository)}#stack:${link.stack.id}`; const members = nativeStacks.get(stackKey) ?? []; members.push(link); nativeStacks.set(stackKey, members); @@ -205,7 +212,7 @@ export function resolveThreadPullRequestChains( const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link))); const branchKey = (link: ThreadPullRequestLink, branch: string) => - `${link.host.toLowerCase()}/${link.repository.toLowerCase()}:${branch}`; + `${link.host.toLowerCase()}/${normalizeSourceControlRepository(link.repository)}:${branch}`; // Reused head names cannot identify a parent unambiguously. const byHead = new Map(); for (const link of remaining) { From 3505ea0a0a545a5d099e93c83a8aa59f21293e7d Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Fri, 11 Sep 2026 19:20:20 +0000 Subject: [PATCH 8/9] fix(source-control): resolve Forgejo git protocol remotes --- .../ForgejoSourceControlProvider.test.ts | 46 ++++++++++++++----- .../ForgejoSourceControlProvider.ts | 12 +++-- .../src/sourceControl/forgejoAuth.test.ts | 23 ++++++++++ apps/server/src/sourceControl/forgejoAuth.ts | 9 ++-- 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts index e80d80db032f..1d11d9359e9b 100644 --- a/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts @@ -114,23 +114,47 @@ it.effect("accepts remote-first repository URLs with web ports and subpaths", () }), ); -it.effect("resolves SSH clone inputs to their web instance before reading metadata", () => +for (const remoteUrl of [context.remoteUrl, "git://git.example.test:9418/Owner/Repo.git"]) { + it.effect(`resolves ${remoteUrl} to its web instance before reading metadata`, () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + read: (input) => { + assert.strictEqual(input.baseUrl, context.provider.baseUrl); + return Effect.succeed({ body: repository, hasNextPage: false }); + }, + }); + assert.strictEqual( + (yield* provider.getRepositoryCloneUrls({ cwd: "/repo", repository: remoteUrl })).url, + repository.clone_url, + ); + const unknown = yield* makeProvider({ refineUnknownRemote: () => Effect.succeed(null) }); + const error = yield* unknown + .getRepositoryCloneUrls({ cwd: "/repo", repository: remoteUrl }) + .pipe(Effect.flip); + assert.include(error.detail, "HTTPS repository URL"); + }), + ); +} + +it.effect("reads PR metadata from a git-protocol checkout using its refined web instance", () => Effect.gen(function* () { const provider = yield* makeProvider({ read: (input) => { assert.strictEqual(input.baseUrl, context.provider.baseUrl); - return Effect.succeed({ body: repository, hasNextPage: false }); + assert.strictEqual(input.path, "/repos/Owner/Repo/pulls/42"); + return Effect.succeed({ body: pullRequest, hasNextPage: false }); }, }); - assert.strictEqual( - (yield* provider.getRepositoryCloneUrls({ cwd: "/repo", repository: context.remoteUrl })).url, - repository.clone_url, - ); - const unknown = yield* makeProvider({ refineUnknownRemote: () => Effect.succeed(null) }); - const error = yield* unknown - .getRepositoryCloneUrls({ cwd: "/repo", repository: context.remoteUrl }) - .pipe(Effect.flip); - assert.include(error.detail, "HTTPS repository URL"); + const pr = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + context: { + ...context, + remoteUrl: "git://git.example.test:9418/Owner/Repo.git", + }, + }); + assert.strictEqual(pr.number, 42); + assert.strictEqual(pr.url, pullRequest.html_url); }), ); diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts index 813319ddd152..fa5fd2563533 100644 --- a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts +++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts @@ -59,9 +59,9 @@ function parseRepository(value: string, baseUrl?: string): RepositoryLocator | n const scp = /^[^@/]+@([^:/]+):(.+)$/u.exec(trimmed); if (scp) { url = new URL(`${baseUrl ?? `https://${scp[1]}`}/${scp[2]}`); - } else if (/^(?:https?|ssh):\/\//u.test(trimmed)) { + } else if (/^(?:https?|ssh|git):\/\//u.test(trimmed)) { url = new URL(trimmed); - if (url.protocol === "ssh:") { + if (url.protocol === "ssh:" || url.protocol === "git:") { url = new URL(url.pathname.replace(/^\//u, ""), `${baseUrl ?? `https://${url.host}`}/`); } } else if (trimmed.split("/").length === 2 && baseUrl) { @@ -155,7 +155,11 @@ export const make = Effect.gen(function* () { .readConfigValue(input.cwd, "remote.origin.url") .pipe(Effect.orElseSucceed(() => null))); let baseUrl = input.context?.provider.baseUrl; - if (remote && isSshRemoteUrl(remote) && (input.repository || !baseUrl)) { + if ( + remote && + (isSshRemoteUrl(remote) || remote.startsWith("git://")) && + (input.repository || !baseUrl) + ) { const provider = detectSourceControlProviderFromRemoteUrl(remote); const stdout = yield* fj.execute({ cwd: input.cwd, operation, args: discovery.authArgs }); const refined = provider @@ -169,7 +173,7 @@ export const make = Effect.gen(function* () { return yield* fail( operation, input.cwd, - "Could not match this SSH remote to a Forgejo instance. Sign in with fj or use the HTTPS repository URL.", + "Could not match this Git remote to a Forgejo instance. Sign in with fj or use the HTTPS repository URL.", ); baseUrl = refined.baseUrl; } diff --git a/apps/server/src/sourceControl/forgejoAuth.test.ts b/apps/server/src/sourceControl/forgejoAuth.test.ts index 3cce840b5591..413e914069d0 100644 --- a/apps/server/src/sourceControl/forgejoAuth.test.ts +++ b/apps/server/src/sourceControl/forgejoAuth.test.ts @@ -95,6 +95,29 @@ it("preserves instance paths and distinguishes instances on the same authority", ); }); +it("maps git-protocol remotes to an unambiguous authenticated web authority", () => { + expectGitRemote( + "git://git.example.test/Owner/Repo.git", + "git.example.test", + "https://git.example.test", + ); + expectGitRemote( + "git://git.example.test:9418/Owner/Repo.git", + "git.example.test:8443/Forge", + "https://git.example.test:8443/Forge", + ); + assert.isNull( + refine( + "git://git.example.test:9418/Owner/Repo.git", + "git.example.test:3000\ngit.example.test:4000", + ), + ); +}); + +function expectGitRemote(remote: string, hosts: string, baseUrl: string) { + assert.deepStrictEqual(refine(remote, hosts), { kind: "forgejo", name: "Forgejo", baseUrl }); +} + it("does not classify unauthenticated hosts or trust failed probes and stderr", () => { assert.strictEqual(refine("https://other.example.test/owner/repo", "git.example.test"), null); assert.strictEqual(refine("https://git.example.test/owner/repo", "git.example.test", 1), null); diff --git a/apps/server/src/sourceControl/forgejoAuth.ts b/apps/server/src/sourceControl/forgejoAuth.ts index f545950dbe01..93c36831ad8b 100644 --- a/apps/server/src/sourceControl/forgejoAuth.ts +++ b/apps/server/src/sourceControl/forgejoAuth.ts @@ -28,16 +28,17 @@ export function parseForgejoAuthHosts(input: SourceControlAuthProbeInput): Reado function refineUnknownForgejoRemote(input: SourceControlUnknownRemoteRefinementInput) { const hosts = parseForgejoAuthHosts(input.auth); const remote = new URL(input.context.provider.baseUrl); - const isSsh = isSshRemoteUrl(input.context.remoteUrl); + const isGitTransport = + isSshRemoteUrl(input.context.remoteUrl) || input.context.remoteUrl.startsWith("git://"); let instance = remote.host.toLowerCase(); - if (!isSsh) { + if (!isGitTransport) { const url = new URL(input.context.remoteUrl); const basePath = url.pathname.split("/").filter(Boolean).slice(0, -2).join("/"); instance = `${url.host}${basePath ? `/${basePath}` : ""}`; } let host = hosts.find((candidate) => candidate === instance); - if (!host && isSsh) { - // SSH and the web API can listen on different ports. Only infer that mapping + if (!host && isGitTransport) { + // Git transports and the web API can listen on different ports. Only infer that mapping // when fj knows exactly one web authority for this hostname. const matchingHosts = hosts.filter( (candidate) => new URL(`https://${candidate}`).hostname === remote.hostname.toLowerCase(), From 1a20d5c9181de5676db81d223b9106e7b6cdc576 Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Fri, 11 Sep 2026 19:29:15 +0000 Subject: [PATCH 9/9] fix(source-control): normalize Forgejo URL inputs --- apps/server/src/sourceControl/ForgejoCli.test.ts | 2 +- apps/server/src/sourceControl/ForgejoCli.ts | 11 +++++++++-- .../ForgejoSourceControlProvider.test.ts | 13 +++++++++++-- .../sourceControl/ForgejoSourceControlProvider.ts | 15 ++++++++------- apps/server/src/sourceControl/forgejoAuth.test.ts | 10 ++++++++++ apps/server/src/sourceControl/forgejoAuth.ts | 13 +++++++++---- 6 files changed, 48 insertions(+), 16 deletions(-) diff --git a/apps/server/src/sourceControl/ForgejoCli.test.ts b/apps/server/src/sourceControl/ForgejoCli.test.ts index 91c7d03cff04..d37ee38d6fbb 100644 --- a/apps/server/src/sourceControl/ForgejoCli.test.ts +++ b/apps/server/src/sourceControl/ForgejoCli.test.ts @@ -205,7 +205,7 @@ it.effect("uses fj's saved SSH aliases without probing unrelated hosts", () => assert.deepStrictEqual( yield* cli.refineUnknownRemote( refinement( - "ssh://git@ssh.example.test:2222/Owner/Repo.git", + " SSH://git@ssh.example.test:2222/Owner/Repo.git ", "codeberg.org\nssh.example.test:2222\ngit.example.test:8443/Forge", ), ), diff --git a/apps/server/src/sourceControl/ForgejoCli.ts b/apps/server/src/sourceControl/ForgejoCli.ts index f91af48cca7c..4b67e93668cb 100644 --- a/apps/server/src/sourceControl/ForgejoCli.ts +++ b/apps/server/src/sourceControl/ForgejoCli.ts @@ -16,7 +16,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { discovery, parseForgejoAuthHosts } from "./forgejoAuth.ts"; +import { discovery, normalizeForgejoRemoteUrl, parseForgejoAuthHosts } from "./forgejoAuth.ts"; import type { SourceControlUnknownRemoteRefinementInput } from "./SourceControlProviderDiscovery.ts"; interface ForgejoOperation { @@ -322,7 +322,14 @@ export const make = Effect.gen(function* () { ); return ForgejoCli.of({ - refineUnknownRemote: (input) => { + refineUnknownRemote: (original) => { + const input = { + ...original, + context: { + ...original.context, + remoteUrl: normalizeForgejoRemoteUrl(original.context.remoteUrl), + }, + }; const direct = discovery.refineUnknownRemote(input); const hosts = parseForgejoAuthHosts(input.auth); return hosts.length === 0 || !isSshRemoteUrl(input.context.remoteUrl) diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts index 1d11d9359e9b..ffd19723a738 100644 --- a/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts @@ -114,7 +114,13 @@ it.effect("accepts remote-first repository URLs with web ports and subpaths", () }), ); -for (const remoteUrl of [context.remoteUrl, "git://git.example.test:9418/Owner/Repo.git"]) { +for (const remoteUrl of [ + context.remoteUrl, + "git://git.example.test:9418/Owner/Repo.git", + " git://git.example.test:9418/Owner/Repo.git ", + "GIT://git.example.test:9418/Owner/Repo.git", + " SSH://git@ssh.example.test:2222/Owner/Repo.git ", +]) { it.effect(`resolves ${remoteUrl} to its web instance before reading metadata`, () => Effect.gen(function* () { const provider = yield* makeProvider({ @@ -167,7 +173,10 @@ it.effect("preserves fork identity, draft state and timestamps when reading a PR return Effect.succeed({ body: pullRequest, hasNextPage: false }); }, }); - const pr = yield* provider.getChangeRequest({ cwd: "/repo", reference: pullRequest.html_url }); + const pr = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: ` ${pullRequest.html_url.replace("https:", "HTTPS:")} `, + }); assert.deepStrictEqual(pr, { provider: "forgejo", number: 42, diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts index fa5fd2563533..f52cc449deaa 100644 --- a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts +++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts @@ -12,7 +12,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import { discovery } from "./forgejoAuth.ts"; +import { discovery, normalizeForgejoRemoteUrl } from "./forgejoAuth.ts"; export const makeDiscovery = Effect.gen(function* () { const fj = yield* ForgejoCli.ForgejoCli; @@ -50,8 +50,7 @@ interface RepositoryLocator { /** Keep the web authority separate from the repository, including nonstandard ports/subpaths. */ function parseRepository(value: string, baseUrl?: string): RepositoryLocator | null { - const trimmed = value - .trim() + const trimmed = normalizeForgejoRemoteUrl(value) .replace(/\/+$/u, "") .replace(/\.git$/u, ""); let url: URL; @@ -148,12 +147,13 @@ export const make = Effect.gen(function* () { }, operation: string, ) { - const remote = + const remoteValue = input.repository ?? input.context?.remoteUrl ?? (yield* git .readConfigValue(input.cwd, "remote.origin.url") .pipe(Effect.orElseSucceed(() => null))); + const remote = remoteValue === null ? null : normalizeForgejoRemoteUrl(remoteValue); let baseUrl = input.context?.provider.baseUrl; if ( remote && @@ -271,7 +271,7 @@ export const make = Effect.gen(function* () { }, operation: string, ) { - const reference = input.reference.trim(); + const reference = normalizeForgejoRemoteUrl(input.reference); const urlMatch = /^(https?:\/\/.+)\/pulls\/(\d+)(?:[/?#].*)?$/u.exec(reference); const locator = yield* resolveRepository( { ...input, ...(urlMatch ? { repository: urlMatch[1] } : {}) }, @@ -285,10 +285,11 @@ export const make = Effect.gen(function* () { const getChangeRequest: SourceControlProvider.SourceControlProvider["Service"]["getChangeRequest"] = Effect.fn("ForgejoSourceControlProvider.getChangeRequest")(function* (input) { - if (!/^#?\d+$/u.test(input.reference) && !/^https?:\/\//u.test(input.reference)) { + const reference = normalizeForgejoRemoteUrl(input.reference); + if (!/^#?\d+$/u.test(reference) && !/^https?:\/\//u.test(reference)) { const [pr] = yield* listChangeRequests({ ...input, - headSelector: input.reference, + headSelector: reference, state: "all", limit: 1, }); diff --git a/apps/server/src/sourceControl/forgejoAuth.test.ts b/apps/server/src/sourceControl/forgejoAuth.test.ts index 413e914069d0..9b1004800d7e 100644 --- a/apps/server/src/sourceControl/forgejoAuth.test.ts +++ b/apps/server/src/sourceControl/forgejoAuth.test.ts @@ -96,6 +96,16 @@ it("preserves instance paths and distinguishes instances on the same authority", }); it("maps git-protocol remotes to an unambiguous authenticated web authority", () => { + expectGitRemote( + " GIT://git.example.test:9418/Owner/Repo.git ", + "git.example.test:8443/Forge", + "https://git.example.test:8443/Forge", + ); + expectGitRemote( + " HTTP://git.example.test:3000/Forge/Owner/Repo.git ", + "git.example.test:3000/Forge", + "http://git.example.test:3000/Forge", + ); expectGitRemote( "git://git.example.test/Owner/Repo.git", "git.example.test", diff --git a/apps/server/src/sourceControl/forgejoAuth.ts b/apps/server/src/sourceControl/forgejoAuth.ts index 93c36831ad8b..98308983353c 100644 --- a/apps/server/src/sourceControl/forgejoAuth.ts +++ b/apps/server/src/sourceControl/forgejoAuth.ts @@ -7,6 +7,11 @@ import { type SourceControlUnknownRemoteRefinementInput, } from "./SourceControlProviderDiscovery.ts"; +/** URL schemes are case-insensitive; repository paths and branch names are not. */ +export function normalizeForgejoRemoteUrl(value: string): string { + return value.trim().replace(/^[a-z][a-z\d+.-]*(?=:\/\/)/iu, (scheme) => scheme.toLowerCase()); +} + /** `fj auth list` prints each instance's authority and optional base path, without tokens. */ export function parseForgejoAuthHosts(input: SourceControlAuthProbeInput): ReadonlyArray { if (input.exitCode !== 0) return []; @@ -27,12 +32,12 @@ export function parseForgejoAuthHosts(input: SourceControlAuthProbeInput): Reado function refineUnknownForgejoRemote(input: SourceControlUnknownRemoteRefinementInput) { const hosts = parseForgejoAuthHosts(input.auth); + const remoteUrl = normalizeForgejoRemoteUrl(input.context.remoteUrl); const remote = new URL(input.context.provider.baseUrl); - const isGitTransport = - isSshRemoteUrl(input.context.remoteUrl) || input.context.remoteUrl.startsWith("git://"); + const isGitTransport = isSshRemoteUrl(remoteUrl) || remoteUrl.startsWith("git://"); let instance = remote.host.toLowerCase(); if (!isGitTransport) { - const url = new URL(input.context.remoteUrl); + const url = new URL(remoteUrl); const basePath = url.pathname.split("/").filter(Boolean).slice(0, -2).join("/"); instance = `${url.host}${basePath ? `/${basePath}` : ""}`; } @@ -48,7 +53,7 @@ function refineUnknownForgejoRemote(input: SourceControlUnknownRemoteRefinementI if (!host) return null; // fj 0.6 saves host names and aliases without a scheme (src/keys.rs and host_name in // src/main.rs). Like fj, SSH defaults to HTTPS; HTTP requires an explicit web remote. - const protocol = input.context.remoteUrl.startsWith("http://") ? "http:" : "https:"; + const protocol = remoteUrl.startsWith("http://") ? "http:" : "https:"; return { kind: "forgejo", name: "Forgejo", baseUrl: `${protocol}//${host}` } as const; }