From fb4f1f382706915c9fe9713d0f86f86858194803 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:28:57 -0700 Subject: [PATCH 1/2] refactor(source-control): Forgejo lives in @t3tools/source-control-forgejo Forgejo's CLI client, repository provider, pull request provider and JSON decoding move into their own package, which depends only on @t3tools/source-control-core. The package exports a driver; the server lists it in BUILT_IN_SOURCE_CONTROL_DRIVERS, and both registries iterate that list, so Forgejo is no longer named in either registry. - @t3tools/source-control-testing adds TestSourceControlHost for package tests. - The diff revision parser Forgejo and Bitbucket share moves to core as diffFileRevisions. - Forgejo's tests move with it; the generic discovery tests and the real-git checkout test stay in the server. Co-Authored-By: Claude Opus 5.5 --- apps/server/package.json | 1 + apps/server/scripts/evaluate-thread-titles.ts | 6 +- apps/server/src/git/GitManager.test.ts | 2 +- .../pullRequest/BitbucketPullRequestApi.ts | 2 +- .../PullRequestProviderRegistry.ts | 22 +- .../pullRequest/PullRequestService.test.ts | 4 +- apps/server/src/server.ts | 7 +- .../SourceControlDiscovery.test.ts | 1290 +---------------- .../SourceControlProviderRegistry.test.ts | 2 +- .../SourceControlProviderRegistry.ts | 15 +- .../src/sourceControl/builtInDrivers.ts | 20 + apps/server/src/ws.ts | 6 +- package.json | 2 +- packages/source-control-core/package.json | 9 + .../src/server/diffFileRevisions.test.ts | 2 +- .../src/server/diffFileRevisions.ts | 0 .../source-control-core/src/server/driver.ts | 36 + packages/source-control-forgejo/package.json | 48 + .../src/server}/ForgejoCli.ts | 10 +- .../src/server}/ForgejoPullRequestProvider.ts | 4 +- .../ForgejoSourceControlProvider.test.ts | 1283 ++++++++++++++++ .../server}/ForgejoSourceControlProvider.ts | 6 +- .../src/server/driver.ts | 20 + .../src/server}/forgejoPullRequestJson.ts | 0 .../src/server}/forgejoPullRequests.ts | 0 packages/source-control-forgejo/tsconfig.json | 7 + packages/source-control-testing/package.json | 22 + .../src/TestSourceControlHost.ts | 47 + packages/source-control-testing/tsconfig.json | 7 + pnpm-lock.yaml | 53 + 30 files changed, 1630 insertions(+), 1303 deletions(-) create mode 100644 apps/server/src/sourceControl/builtInDrivers.ts rename apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts => packages/source-control-core/src/server/diffFileRevisions.test.ts (99%) rename apps/server/src/pullRequest/bitbucketDiffRevisions.ts => packages/source-control-core/src/server/diffFileRevisions.ts (100%) create mode 100644 packages/source-control-core/src/server/driver.ts create mode 100644 packages/source-control-forgejo/package.json rename {apps/server/src/sourceControl => packages/source-control-forgejo/src/server}/ForgejoCli.ts (98%) rename {apps/server/src/pullRequest => packages/source-control-forgejo/src/server}/ForgejoPullRequestProvider.ts (99%) create mode 100644 packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts rename {apps/server/src/sourceControl => packages/source-control-forgejo/src/server}/ForgejoSourceControlProvider.ts (98%) create mode 100644 packages/source-control-forgejo/src/server/driver.ts rename {apps/server/src/pullRequest => packages/source-control-forgejo/src/server}/forgejoPullRequestJson.ts (100%) rename {apps/server/src/sourceControl => packages/source-control-forgejo/src/server}/forgejoPullRequests.ts (100%) create mode 100644 packages/source-control-forgejo/tsconfig.json create mode 100644 packages/source-control-testing/package.json create mode 100644 packages/source-control-testing/src/TestSourceControlHost.ts create mode 100644 packages/source-control-testing/tsconfig.json diff --git a/apps/server/package.json b/apps/server/package.json index ea7b9019e415..17b77a22244a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -46,6 +46,7 @@ "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", "@t3tools/source-control-core": "workspace:*", + "@t3tools/source-control-forgejo": "workspace:*", "diff": "8.0.3", "effect": "catalog:", "jose": "catalog:", diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 196bcbdfbcaa..3ebd7cbaf8d5 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -23,10 +23,9 @@ import { } from "@t3tools/provider-core/server/threadTitleContext"; import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; -import * as ServerSourceControlHost from "../src/sourceControl/ServerSourceControlHost.ts"; import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; -import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; +import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; import * as ServerSettings from "../src/serverSettings.ts"; @@ -155,11 +154,10 @@ await Effect.runPromise( Layer.mergeAll( GitHubApi.layerWithDependencies, GitLabCli.layer, - ForgejoCli.layer, + SourceControlBuiltInDrivers.layer, AzureDevOpsCli.layer, // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. BitbucketApi.layer, - ServerSourceControlHost.layer, ), ), // Default settings: no saved Bitbucket token, gh's own GitHub account choice. diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index c7e76f5e71ab..9df266d56c8f 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -57,7 +57,7 @@ import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceContr import { ForgejoPullRequestSchema, toForgejoChangeRequest, -} from "../sourceControl/forgejoPullRequests.ts"; +} from "@t3tools/source-control-forgejo/server/forgejoPullRequests"; import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index f5e108b05d45..b62b86ffe48f 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -22,7 +22,7 @@ import type { } from "@t3tools/contracts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; -import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; +import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions"; import { buildReviewThreads, decodeCommentsJson, diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 8e5a1d9cfd0b..3c2ad367d446 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -7,8 +7,6 @@ import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; -import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; -import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; @@ -17,6 +15,7 @@ import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; +import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider"; export class PullRequestProviderRegistry extends Context.Service< @@ -45,16 +44,21 @@ export function fromProviders( * * @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.map( - Effect.all([ +export const make = Effect.gen(function* () { + const providers = yield* Effect.all([ GitHubPullRequestProvider.make, GitLabPullRequestProvider.make, - ForgejoPullRequestProvider.make, BitbucketPullRequestProvider.make, AzureDevOpsPullRequestProvider.make, - ]), - fromProviders, -); + ]); + const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => + driver.make.pipe(Effect.map((instance) => instance.pullRequests)), + ); + return fromProviders([ + ...providers, + ...drivers.filter((provider): provider is PullRequestProviderApi => provider !== null), + ]); +}); export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( Layer.provide( @@ -66,7 +70,7 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( ), ), Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), - Layer.provide(ForgejoCli.layer), + Layer.provide(BuiltInDrivers.layer), Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))), ); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index ef84de264ec6..83f362fa20a0 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -30,8 +30,8 @@ import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.t import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; -import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; -import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; +import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import { PullRequestProviderError, type ProviderChangeRequest, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 913f8067d250..b1ac0b12dbd3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -56,7 +56,8 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; -import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; +import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -104,7 +105,6 @@ import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; -import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts"; import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -305,8 +305,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p BitbucketApi.layer, GitHubApi.layerWithDependencies, GitLabCli.layer, - ForgejoCli.layer, - ServerSourceControlHost.layer, + SourceControlBuiltInDrivers.layer, ), ), Layer.provideMerge(GitVcsDriver.layer), diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index e0d1c3efca23..fe7694cef545 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -20,13 +20,13 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; -import * as ForgejoCli from "./ForgejoCli.ts"; -import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; -import * as ForgejoPullRequestProvider from "../pullRequest/ForgejoPullRequestProvider.ts"; +import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; +import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; -import { firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; const layerSourceControlProviderRegistryTest = (input: { readonly bitbucket: Partial; @@ -76,434 +76,6 @@ const processOutput = ( const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); -it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => { - const methods: string[] = []; - const fetchReview = async ( - ...[input, init]: Parameters> - ) => { - const request = new Request(input instanceof Request ? input.url : String(input), { - ...(init?.method === undefined ? {} : { method: init.method }), - ...(init?.headers === undefined ? {} : { headers: init.headers }), - ...(init?.body === undefined ? {} : { body: init.body }), - }); - methods.push(request.method); - if (request.method === "GET") { - assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42"); - return new Response( - encodeJson({ - number: 42, - title: "Review target", - body: "", - html_url: "https://forgejo.test/maria/project/pulls/42", - user: { login: "maria" }, - state: "open", - merged: false, - head: { ref: "feature", sha: "head", repo: null }, - base: { ref: "main", sha: "base", repo: null }, - created_at: "2026-09-13T00:00:00Z", - updated_at: "2026-09-13T00:00:00Z", - closed_at: null, - merged_at: null, - labels: [], - }), - ); - } - assert.strictEqual(request.method, "POST"); - assert.strictEqual( - request.url, - "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews", - ); - assert.deepStrictEqual(JSON.parse(await request.text()), { - event: "COMMENT", - body: "Review summary", - commit_id: "head", - comments: [], - }); - return new Response('{"id":1}', { status: 200 }); - }; - return Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const provider = yield* ForgejoPullRequestProvider.make.pipe( - Effect.provideService(ForgejoCli.ForgejoCli, cli), - ); - yield* provider.submitReview({ - cwd: "/repo", - repository: "maria/project", - host: "forgejo.test", - number: 42, - verdict: "comment", - body: "Review summary", - comments: [], - }); - assert.deepStrictEqual(methods, ["GET", "POST"]); - }).pipe( - Effect.provideService( - FetchHttpClient.Fetch, - Object.assign(fetchReview, { preconnect: () => undefined }), - ), - Effect.provide(FetchHttpClient.layer), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }), - ), - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - if (input.command === "git") { - assert.deepStrictEqual(input.args, ["remote", "-v"]); - return Effect.succeed( - processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"), - ); - } - assert.strictEqual(input.command, "fj"); - assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]); - return Effect.succeed(processOutput("")); - }, - }), - ), - ); -}); - -it.effect("reads Forgejo checks without repository or viewer requests", () => { - const paths: string[] = []; - return Effect.gen(function* () { - const provider = yield* ForgejoPullRequestProvider.make; - const read = provider.getChangeRequestChecks; - if (read === undefined) return yield* Effect.die("checks read missing"); - const result = yield* read({ - cwd: "/repo", - repository: "acme/web", - host: "forgejo.test", - number: 1, - }); - assert.strictEqual(result.state, "open"); - assert.strictEqual(result.checks[0]?.status, "failure"); - assert.deepStrictEqual(paths, [ - "repos/acme/web/pulls/1", - "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=1", - "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=2", - ]); - }).pipe( - Effect.provide( - Layer.mock(ForgejoCli.ForgejoCli)({ - api: (input) => { - paths.push(input.path); - assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head)/); - return Effect.succeed( - processOutput( - input.path.endsWith("pulls/1") - ? `{"number":1,"title":"Checks","body":"","html_url":"https://forgejo.test/acme/web/pulls/1", "user":null,"state":"open","merged":false, - "head":{"ref":"feature","sha":"head","repo":null},"base":{"ref":"main","sha":"base","repo":null}, - "created_at":"2026-09-16T00:00:00Z","updated_at":"2026-09-16T00:00:00Z","closed_at":null,"merged_at":null,"labels":[]}` - : input.path.endsWith("page=1") - ? `[{"context":"build","status":"failure","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00Z"}]` - : "[]", - ), - ); - }, - }), - ), - ); -}); - -it.effect.each([ - ["Ready", false, true, false, "mergeable"], - ["Draft", true, false, true, "unknown"], - ["Blocked", false, false, false, "unknown"], - ["Unchecked", false, undefined, false, "unknown"], - ["WIP: Legacy draft", undefined, false, true, "unknown"], - ["[WIP] Legacy draft", undefined, false, true, "unknown"], - ["WIP: Explicitly ready", false, false, false, "unknown"], -] as const)( - "reads Forgejo mergeability for %s (draft=%s, mergeable=%s) across list, summary and detail", - ([title, draft, mergeable, isDraft, mergeability]) => { - const pr = { - number: 42, - title, - body: "", - html_url: "https://forgejo.test/maria/project/pulls/42", - user: { login: "maria" }, - state: "open", - merged: false, - ...(draft === undefined ? {} : { draft }), - ...(mergeable === undefined ? {} : { mergeable }), - head: { ref: "feature", sha: "head", repo: null }, - base: { ref: "main", sha: "base", repo: null }, - created_at: "2026-09-16T00:00:00Z", - updated_at: "2026-09-16T00:00:00Z", - closed_at: null, - merged_at: null, - labels: [], - }; - return Effect.gen(function* () { - const provider = yield* ForgejoPullRequestProvider.make; - const readSummary = provider.getChangeRequestSummary; - if (readSummary === undefined) return yield* Effect.die("summary read missing"); - const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 42 }; - const list = yield* provider.listChangeRequests({ - ...input, - state: "open", - involvement: "all", - viewer: "maria", - limit: 10, - }); - const summary = yield* readSummary(input); - const detail = yield* provider.getChangeRequest(input); - assert.strictEqual(list.items.length, 1); - for (const result of [list.items[0]!, summary, detail]) { - assert.strictEqual(result.mergeability, mergeability); - assert.strictEqual(result.isDraft, isDraft); - } - }).pipe( - Effect.provide( - Layer.mock(ForgejoCli.ForgejoCli)({ - api: (input) => { - const [path, query] = input.path.split("?"); - let response: unknown; - switch (path) { - case "repos/maria/project/pulls": - response = new URLSearchParams(query).get("page") === "1" ? [pr] : []; - break; - case "repos/maria/project/pulls/42": - response = pr; - break; - case "repos/maria/project": - response = { full_name: "maria/project", permissions: { push: true, admin: true } }; - break; - case "user": - response = pr.user; - break; - case "repos/maria/project/statuses/head": - response = []; - break; - default: - return Effect.die(`Unexpected Forgejo request: ${input.path}`); - } - return encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput)); - }, - }), - ), - ); - }, -); - -it.effect("loads Forgejo pull request references from files and commits views", () => - Effect.gen(function* () { - const provider = yield* ForgejoSourceControlProvider.make; - for (const reference of [ - "42", - "#42", - "https://forgejo.test/maria/project/pulls/42", - "https://forgejo.test/maria/project/pulls/42/", - "https://forgejo.test/maria/project/pulls/42/files?w=1#diff-1", - "http://forgejo.test:3000/git/maria/project/pulls/42/commits", - ]) { - const result = yield* provider.getChangeRequest({ cwd: "/repo", reference }); - assert.strictEqual(result.number, 42); - assert.strictEqual(result.title, "Forgejo view reference"); - } - const invalid = yield* provider - .getChangeRequest({ - cwd: "/repo", - reference: "https://forgejo.test/maria/project/pulls/42invalid/files", - }) - .pipe(Effect.result); - assert.strictEqual(invalid._tag, "Failure"); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})), - Layer.mock(VcsProcess.VcsProcess)({}), - Layer.mock(ForgejoCli.ForgejoCli)({ - resolveRepository: () => - Effect.succeed({ - login: "work", - repository: "maria/project", - baseUrl: "https://forgejo.test", - }), - api: (input) => { - assert.strictEqual(input.path, "repos/maria/project/pulls/42"); - return encodeJsonEffect({ - number: 42, - title: "Forgejo view reference", - html_url: "https://forgejo.test/maria/project/pulls/42", - state: "open", - merged: false, - base: { ref: "main", sha: "base", repo: null }, - head: { ref: "feature", sha: "head", repo: null }, - }).pipe(Effect.orDie, Effect.map(processOutput)); - }, - }), - ), - ), - ), -); - -it.effect( - "loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations", - () => { - const user = { login: "maria" }; - const review = { - id: 8, - body: "review body", - user, - state: "COMMENT", - submitted_at: "2026-09-12T00:00:00Z", - html_url: "https://forgejo.test/maria/project/pulls/2#issuecomment-37", - comments_count: 1, - }; - const comment = { id: 12, body: "ordinary", user, created_at: review.submitted_at }; - const responses: Record = { - user, - "repos/maria/project/issues/2/comments": [comment], - "repos/maria/project/pulls/2/reviews": [review], - "repos/maria/project/pulls/2/reviews/8": review, - "repos/maria/project/pulls/2/reviews/9": { ...review, id: 9, html_url: "" }, - "repos/maria/project/pulls/2/commits": [], - "repos/maria/project/issues/2/reactions": [], - "repos/maria/project/pulls/2/reviews/8/comments": [ - { - ...comment, - id: 38, - body: "inline", - path: "file.ts", - position: 1, - original_position: 1, - commit_id: "head", - original_commit_id: "head", - resolver: null, - }, - ], - "repos/maria/project/issues/comments/12/reactions": [{ content: "+1", user }], - "repos/maria/project/issues/comments/37/reactions": [{ content: "heart", user }], - "repos/maria/project/issues/comments/38/reactions": [ - { content: "rocket", user: { login: "reviewer" } }, - ], - }; - const writes: ForgejoCli.ForgejoApiInput[] = []; - let reactionReads = 0; - return Effect.gen(function* () { - const provider = yield* ForgejoPullRequestProvider.make; - const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 2 }; - const activity = yield* provider.getChangeRequestActivity(input); - assert.deepStrictEqual( - activity.comments.map((entry) => ({ - id: entry.id, - kind: entry.kind, - reactions: entry.reactions, - })), - [ - { - id: "12", - kind: "issue-comment", - reactions: [{ content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }], - }, - { - id: "review:8", - kind: "review", - reactions: [{ content: "heart", count: 1, actors: [], viewerHasReacted: true }], - }, - { - id: "38", - kind: "review-comment", - reactions: [ - { content: "rocket", count: 1, actors: ["reviewer"], viewerHasReacted: false }, - ], - }, - ], - ); - const inlineComment = activity.comments[2]; - assert.ok(inlineComment); - assert.deepStrictEqual(activity.reviewThreads[0]?.comments, [inlineComment]); - for (const reacted of [true, false]) { - yield* provider.setReaction({ ...input, subjectId: "review:8", content: "heart", reacted }); - yield* provider.setReaction({ ...input, subjectId: "38", content: "rocket", reacted }); - } - assert.deepStrictEqual( - writes.map(({ path, method, body }) => ({ path, method, body })), - [ - { - path: "repos/maria/project/issues/comments/37/reactions", - method: "POST", - body: { content: "heart" }, - }, - { - path: "repos/maria/project/issues/comments/38/reactions", - method: "POST", - body: { content: "rocket" }, - }, - { - path: "repos/maria/project/issues/comments/37/reactions", - method: "DELETE", - body: { content: "heart" }, - }, - { - path: "repos/maria/project/issues/comments/38/reactions", - method: "DELETE", - body: { content: "rocket" }, - }, - ], - ); - const missing = yield* provider - .setReaction({ ...input, subjectId: "review:9", content: "heart", reacted: true }) - .pipe(Effect.result); - assert.strictEqual(missing._tag, "Failure"); - if (missing._tag === "Failure") assert.include(missing.failure.detail, "comment ID"); - assert.strictEqual(writes.length, 4); - responses["repos/maria/project/pulls/2/reviews/8/comments"] = Array.from( - { length: 501 }, - (_, index) => ({ - ...comment, - id: 1000 + index, - path: "file.ts", - position: 1, - original_position: 1, - commit_id: "head", - original_commit_id: "head", - resolver: null, - }), - ); - for (let index = 0; index < 500; index++) { - responses[`repos/maria/project/issues/comments/${1000 + index}/reactions`] = []; - } - reactionReads = 0; - const bounded = yield* provider.getChangeRequestActivity(input); - assert.strictEqual(bounded.reviewThreads.length, 500); - assert.strictEqual( - bounded.comments.filter((entry) => entry.kind === "review-comment").length, - 500, - ); - assert.strictEqual(bounded.commentsTruncated, true); - assert.strictEqual(reactionReads, 502); - }).pipe( - Effect.provide( - Layer.mock(ForgejoCli.ForgejoCli)({ - api: (input) => { - if (input.method) { - writes.push(input); - return Effect.succeed(processOutput("{}")); - } - const path = input.path.split("?")[0]!; - if (/\/issues\/comments\/\d+\/reactions$/.test(path)) reactionReads++; - assert.ok(Object.hasOwn(responses, path), `Unexpected Forgejo request: ${path}`); - const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page")); - return encodeJsonEffect(page > 1 ? [] : responses[path]).pipe( - Effect.orDie, - Effect.map(processOutput), - ); - }, - }), - ), - ); - }, -); - it.effect("reports implemented tools separately from locally available executables", () => { const processMock = { run: (input: VcsProcess.VcsProcessInput) => { @@ -764,820 +336,6 @@ Logged in to gitlab.com as gitlab-user }).pipe(Effect.provide(layerTest)); }); -it.effect("discovers Forgejo accounts and retains the server port", () => - Effect.gen(function* () { - const auth = ForgejoSourceControlProvider.discovery.parseAuth( - processOutput( - yield* encodeJsonEffect([ - { - name: "work", - url: "http://forgejo.local:3000", - ssh_host: "git.forgejo.local", - user: "maria", - default: "true", - valid: "true", - }, - ]), - ), - ); - assert.deepStrictEqual( - firstNonEmptyLine("\u001b[1mtea version 0.16.0\u001b[0m\n"), - Option.some("tea version 0.16.0"), - ); - assert.strictEqual(auth.status, "authenticated"); - assert.deepStrictEqual(auth.account, Option.some("maria")); - assert.deepStrictEqual(auth.host, Option.some("forgejo.local:3000")); - const revoked = ForgejoSourceControlProvider.discovery.parseAuth( - processOutput( - encodeJson([ - { - name: "work", - url: "http://forgejo.local:3000", - user: "maria", - default: "true", - valid: "false", - }, - ]), - ), - ); - assert.strictEqual(revoked.status, "unauthenticated"); - const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ - cwd: "/repo", - context: { - provider: { - kind: "unknown", - name: "git.forgejo.local", - baseUrl: "https://git.forgejo.local", - }, - remoteName: "origin", - remoteUrl: "git@git.forgejo.local:maria/project.git", - }, - auth: processOutput( - yield* encodeJsonEffect([ - { - name: "work", - url: "http://forgejo.local:3000", - ssh_host: "git.forgejo.local", - user: "maria", - default: "true", - }, - ]), - ), - }); - assert.deepStrictEqual(refined, { - kind: "forgejo", - name: "Forgejo / Gitea", - baseUrl: "http://forgejo.local:3000", - }); - }), -); - -it.effect("does not choose a default Forgejo login across ambiguous SSH server ports", () => - Effect.gen(function* () { - const logins = ForgejoCli.parseForgejoLogins( - yield* encodeJsonEffect([ - { - name: "one", - url: "http://forgejo.local:3000", - ssh_host: "forgejo.local", - user: "maria", - default: "true", - }, - { - name: "two", - url: "http://forgejo.local:4000", - ssh_host: "forgejo.local", - user: "maria", - default: "false", - }, - ]), - ); - const remote = ForgejoCli.parseForgejoRemote("git@forgejo.local:maria/project.git"); - assert.isNotNull(remote); - assert.deepStrictEqual( - ForgejoCli.parseForgejoRemote("forgejo.local:maria/project.git"), - remote, - ); - assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!)); - assert.strictEqual( - ForgejoCli.matchForgejoLogin(logins, remote!, "forgejo.local:4000")?.name, - "two", - ); - assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!, "other.local:4000")); - const alias = ForgejoCli.parseForgejoRemote("git@ssh.forgejo.local:maria/project.git"); - assert.isNotNull(alias); - assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, alias!, "forgejo.local:4000")); - const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ - cwd: "/repo", - context: { - provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" }, - remoteName: "origin", - remoteUrl: "git@forgejo.local:maria/project.git", - requestedHost: "forgejo.local:4000", - }, - auth: processOutput(yield* encodeJsonEffect(logins)), - }); - assert.strictEqual(refined?.baseUrl, "http://forgejo.local:4000"); - const https = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000/maria/project.git"); - assert.isNotNull(https); - assert.strictEqual(ForgejoCli.matchForgejoLogin(logins, https!)?.name, "two"); - const hostOnly = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000"); - assert.strictEqual( - ForgejoCli.matchForgejoLogin(logins, hostOnly!, undefined, true)?.name, - "two", - ); - const mounted = logins.map((login) => ({ - ...login, - url: `http://forgejo.local:4000/${login.name}`, - })); - assert.isUndefined(ForgejoCli.matchForgejoLogin(mounted, hostOnly!, undefined, true)); - }), -); - -it.effect("rejects HTTP failures even when tea exits successfully", () => - Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const result = yield* cli - .api({ - cwd: "/repo", - repository: "http://forgejo.local:3000/maria/project", - path: "repos/maria/project/pulls/42", - method: "PATCH", - body: { state: "closed" }, - }) - .pipe(Effect.result); - assert.strictEqual(result._tag, "Failure"); - if (result._tag === "Failure") - assert.strictEqual( - result.failure.detail, - "Forgejo repository or pull request was not found.", - ); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(false), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => { - throw new Error("tea must handle its own HTTP request"); - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - if (input.args[0] === "api") { - assert.strictEqual(input.stdin, '{"state":"closed"}'); - assert.include(input.args, "work"); - assert.include( - input.args, - "http://forgejo.local:3000/api/v1/repos/maria/project/pulls/42", - ); - } - return Effect.succeed( - input.args[0] === "login" - ? processOutput( - encodeJson([ - { - name: "work", - url: "http://forgejo.local:3000", - ssh_host: "forgejo.local", - user: "maria", - default: "true", - }, - ]), - ) - : processOutput('{"message":"not found"}', { stderr: "HTTP/1.1 404 Not Found\n" }), - ); - }, - }), - ), - ), -); - -it.effect("routes mounted Forgejo repositories without repeating the mount in API paths", () => - Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const viewer = yield* cli.api({ cwd: "/upstream-only", host: "code.test", path: "user" }); - assert.strictEqual(viewer.stdout, "[]"); - const mountedRepository = yield* cli.resolveRepository({ - cwd: "/upstream-only", - host: "code.test", - repository: "maria/project", - }); - assert.strictEqual(mountedRepository.baseUrl, "https://code.test/forgejo"); - assert.strictEqual(mountedRepository.repository, "maria/project"); - for (const path of [ - "repos/forgejo/maria/project/pulls?state=open", - "repos/forgejo/maria/project", - "repos/reviewer/project/contents/file.ts", - ]) { - const result = yield* cli.api({ - cwd: "/repo", - repository: "forgejo/maria/project", - context: { - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, - remoteName: "origin", - remoteUrl: "https://code.test/forgejo/maria/project.git", - }, - path, - }); - assert.strictEqual(result.stdout, "[]"); - } - const sameOwnerAsMount = yield* cli.resolveRepository({ - cwd: "/repo", - repository: "forgejo/project", - context: { - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, - remoteName: "origin", - remoteUrl: "ssh://git@code.test/forgejo/project.git", - }, - }); - assert.strictEqual(sameOwnerAsMount.command, "tea"); - assert.strictEqual(sameOwnerAsMount.repository, "forgejo/project"); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - encodeJson({ - hosts: { "code.test/forgejo": { type: "Application", token: "test-token" } }, - }), - ), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => { - throw new Error("tea must handle its own HTTP request"); - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - if (input.command === "git") - return Effect.succeed(processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) })); - if (input.args[0] === "login") - return Effect.succeed( - processOutput( - encodeJson([ - { - name: "mounted", - url: "https://code.test/forgejo", - ssh_host: "code.test", - user: "maria", - default: "true", - }, - ]), - ), - ); - const supported = [ - "https://code.test/forgejo/api/v1/user", - "https://code.test/forgejo/api/v1/repos/maria/project/pulls?state=open", - "https://code.test/forgejo/api/v1/repos/maria/project", - "https://code.test/forgejo/api/v1/repos/reviewer/project/contents/file.ts", - ]; - if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo"); - assert.strictEqual(input.command, "tea"); - return Effect.succeed( - supported.includes(input.args.at(-1) ?? "") - ? processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }) - : processOutput("{}", { stderr: "HTTP/1.1 404 Not Found\n" }), - ); - }, - }), - ), - ), -); - -it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => { - const commands: string[] = []; - const requests: string[] = []; - return Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - for (const remoteUrl of [ - "http://forgejo.local:3000/maria/project.git", - "ssh://git@ssh.forgejo.local:2222/maria/project.git", - "ssh://git@forgejo.local:2222/maria/project.git", - ]) { - const result = yield* cli.api({ - cwd: "/repo", - repository: "maria/project", - context: { - provider: { - kind: "forgejo", - name: "Forgejo", - baseUrl: "http://forgejo.local:3000", - }, - remoteName: "origin", - remoteUrl, - requestedHost: "forgejo.local:3000", - }, - path: "repos/maria/project/issues/42/comments", - method: "POST", - body: { body: "verified through fj" }, - }); - assert.strictEqual(result.stdout, '{"id":99}'); - } - assert.deepStrictEqual(commands, ["fj"]); - assert.deepStrictEqual(requests, [ - "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", - "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", - "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", - ]); - const viewer = yield* cli.api({ - cwd: "/no-remotes", - host: "forgejo.local:3000", - path: "user", - }); - assert.strictEqual(viewer.stdout, '{"login":"maria"}'); - assert.strictEqual(requests.at(-1), "https://forgejo.local:3000/api/v1/user"); - const upstreamViewer = yield* cli.api({ - cwd: "/upstream-only", - host: "forgejo.local:3000", - path: "user", - }); - assert.strictEqual(upstreamViewer.stdout, '{"login":"maria"}'); - assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user"); - const upstreamRepository = yield* cli.resolveRepository({ - cwd: "/upstream-only", - host: "forgejo.local:3000", - repository: "maria/project", - }); - assert.strictEqual(upstreamRepository.baseUrl, "http://forgejo.local:3000"); - assert.strictEqual(upstreamRepository.repository, "maria/project"); - const httpViewer = yield* cli.api({ cwd: "/repo", host: "forgejo.local:3000", path: "user" }); - assert.strictEqual(httpViewer.stdout, '{"login":"maria"}'); - assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user"); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - encodeJson({ - hosts: { - "forgejo.local:3000": { type: "Application", token: "test-token" }, - "forgejo.local:4000": { type: "Application", token: "other-token" }, - }, - aliases: { "ssh.forgejo.local:2222": "forgejo.local:3000" }, - }), - ), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - requests.push(request.url); - if (request.url.endsWith("/user")) { - assert.strictEqual(request.method, "GET"); - assert.strictEqual(request.headers.authorization, "token test-token"); - return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response('{"login":"maria"}')), - ); - } - assert.strictEqual(request.method, "POST"); - assert.strictEqual(request.headers.authorization, "token test-token"); - assert.strictEqual(request.body._tag, "Uint8Array"); - if (request.body._tag === "Uint8Array") - assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), { - body: "verified through fj", - }); - return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })), - ); - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - commands.push(input.command); - if (input.command === "git") - return Effect.succeed( - input.cwd === "/no-remotes" - ? processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }) - : processOutput( - `${input.cwd === "/upstream-only" ? "upstream" : "origin"}\thttp://forgejo.local:3000/maria/project.git (fetch)\nother\thttp://forgejo.local:3000/maria/other.git (fetch)\nunrelated\thttp://other.local:3000/maria/project.git (fetch)`, - ), - ); - assert.strictEqual(input.command, "fj"); - assert.deepStrictEqual(input.args, [ - "--host", - input.cwd === "/no-remotes" - ? "https://forgejo.local:3000" - : "http://forgejo.local:3000", - "whoami", - ]); - return Effect.succeed(processOutput("")); - }, - }), - ), - ); -}); - -it.effect("loads later fj review pages when the server caps pages below the requested size", () => { - const pages: number[] = []; - let issueCommentRequests = 0; - return Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const provider = yield* ForgejoPullRequestProvider.make.pipe( - Effect.provideService(ForgejoCli.ForgejoCli, cli), - ); - const activity = yield* provider.getChangeRequestActivity({ - cwd: "/repo", - repository: "maria/project", - host: "forgejo.test", - number: 42, - }); - assert.strictEqual(activity.commentCount, 42); - assert.strictEqual(activity.comments.at(-1)?.id, "review:41"); - assert.strictEqual(activity.commentsTruncated, false); - assert.deepStrictEqual(pages, [1, 2, 3]); - assert.strictEqual(issueCommentRequests, 1); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }), - ), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - const url = new URL(request.url); - if (url.pathname === "/api/v1/repos/maria/project/issues/42/comments") { - issueCommentRequests++; - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response( - encodeJson([ - { - id: 100, - body: "Unpaginated issue comment", - user: { login: "maria" }, - created_at: "2026-09-13T00:00:00Z", - }, - ]), - ), - ), - ); - } - if (url.pathname.endsWith("/reviews")) { - const page = Number(url.searchParams.get("page")); - pages.push(page); - assert.ok(page >= 1 && page <= 3); - const reviews = Array.from({ length: page < 3 ? 20 : 1 }, (_, index) => ({ - id: (page - 1) * 20 + index + 1, - body: "Review from a capped page", - user: { login: "maria" }, - state: "COMMENT", - submitted_at: "2026-09-13T00:00:00Z", - comments_count: 0, - })); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(encodeJson(reviews), { - headers: - page === 2 - ? {} - : { - Link: - page === 1 - ? `<${url.origin}${url.pathname}?limit=50&page=2>; rel="next"` - : `<${url.origin}${url.pathname}?limit=50&page=1>; rel="prev"`, - }, - }), - ), - ); - } - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(encodeJson(url.pathname === "/api/v1/user" ? { login: "maria" } : [])), - ), - ); - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - if (input.command === "git") { - assert.deepStrictEqual(input.args, ["remote", "-v"]); - return Effect.succeed( - processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"), - ); - } - assert.strictEqual(input.command, "fj"); - assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]); - return Effect.succeed(processOutput("")); - }, - }), - ), - ); -}); - -it.effect("falls back to tea when fj is missing or has no account for this server", () => - Effect.gen(function* () { - for (const scenario of ["missing-cli", "missing-account", "stale-invalid-storage"] as const) { - const commands: string[] = []; - yield* Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const result = yield* cli.api({ - cwd: "/repo", - repository: "https://forgejo.local:3000/maria/project", - path: "repos/maria/project/pulls", - }); - assert.strictEqual(result.stdout, "[]"); - assert.deepStrictEqual( - commands, - scenario === "missing-account" ? ["tea", "tea"] : ["fj", "tea", "tea"], - ); - const viewer = yield* cli.api({ - cwd: "/upstream-only", - host: "forgejo.local:3000", - path: "user", - }); - assert.strictEqual(viewer.stdout, "[]"); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - scenario === "stale-invalid-storage" - ? "invalid json" - : encodeJson({ - hosts: { - [scenario === "missing-cli" ? "forgejo.local:3000" : "other.local"]: { - type: "Application", - token: "test-token", - }, - }, - }), - ), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => { - throw new Error("tea must handle its own HTTP request"); - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - commands.push(input.command); - if (input.command === "git") - return Effect.succeed( - processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }), - ); - if (input.command === "fj") - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error("fj not found"), - }), - ); - assert.strictEqual(input.command, "tea"); - if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo"); - return Effect.succeed( - input.args[0] === "login" - ? processOutput( - encodeJson([ - { - name: "work", - url: "https://forgejo.local:3000", - user: "maria", - default: "true", - valid: "true", - }, - ]), - ) - : processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }), - ); - }, - }), - ), - ); - } - }), -); - -it.effect("handles fj mutation statuses without retrying failures or reading absent bodies", () => - Effect.gen(function* () { - for (const status of [204, 205, 302, 401, 403, 404, 429, 500]) { - let writes = 0; - yield* Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const result = yield* cli - .api({ - cwd: "/repo", - repository: "https://forgejo.local/maria/project", - path: "repos/maria/project/issues/42/comments", - method: "POST", - body: { body: "only once" }, - }) - .pipe(Effect.result); - assert.strictEqual(result._tag, status < 300 ? "Success" : "Failure"); - if (result._tag === "Success") assert.strictEqual(result.success.stdout, ""); - if (result._tag === "Failure") { - assert.strictEqual(result.failure.command, "fj"); - assert.strictEqual(result.failure.httpStatus, status); - } - assert.strictEqual(writes, 1); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - encodeJson({ - hosts: { - "forgejo.local": { type: "Application", token: "test-token" }, - }, - }), - ), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - writes++; - assert.strictEqual( - request.url, - "https://forgejo.local/api/v1/repos/maria/project/issues/42/comments", - ); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(status < 300 ? null : "", { - status, - headers: { location: "https://other.local/" }, - }), - ), - ); - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - assert.strictEqual( - input.command, - "fj", - "a failed mutation must never switch accounts or CLI", - ); - return Effect.succeed(processOutput("")); - }, - }), - ), - ); - } - }), -); - -it.effect( - "discovers fj first and retains configured authentication failures instead of switching accounts", - () => - Effect.gen(function* () { - for (const scenario of ["authenticated", "revoked", "missing", "invalid-storage"] as const) { - const commands: string[] = []; - yield* Effect.gen(function* () { - const spec = yield* ForgejoSourceControlProvider.makeDiscovery; - assert.strictEqual(spec.type, "managed-cli"); - if (spec.type !== "managed-cli") return; - const result = yield* spec.probe("/repo"); - assert.strictEqual(result.executable, scenario === "missing" ? "tea" : "fj"); - assert.strictEqual( - result.auth.status, - scenario === "revoked" - ? "unauthenticated" - : scenario === "invalid-storage" - ? "unknown" - : "authenticated", - ); - if (scenario !== "revoked" && scenario !== "invalid-storage") - assert.deepStrictEqual(result.auth.host, Option.some("forgejo.local:3000")); - assert.deepStrictEqual( - result.auth.account, - scenario === "authenticated" || scenario === "missing" - ? Option.some("maria") - : Option.none(), - ); - assert.strictEqual( - commands.some((command) => command.startsWith("tea ")), - scenario === "missing", - ); - assert.include(commands, "fj version"); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.mock(ForgejoCli.ForgejoCli)({ - getAccount: (input) => { - assert.strictEqual(scenario, "authenticated"); - assert.deepStrictEqual(input, { - cwd: "/repo", - baseUrl: "http://forgejo.local:3000", - }); - return Effect.succeed("maria"); - }, - listLogins: (input) => { - assert.strictEqual( - input.remoteUrl, - "http://forgejo.local:3000/maria/project.git", - ); - if (scenario === "invalid-storage") - return Effect.fail( - new ForgejoCli.ForgejoCliError({ - command: "fj", - cwd: input.cwd, - reason: "authentication", - detail: "fj authentication storage is invalid.", - }), - ); - return Effect.succeed([ - { - name: "forgejo.local:3000", - url: "http://forgejo.local:3000", - user: "", - default: "false", - }, - ]); - }, - }), - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - commands.push(`${input.command} ${input.args.join(" ")}`); - if (input.command === "git") - return Effect.succeed( - processOutput("http://forgejo.local:3000/maria/project.git\n"), - ); - if (input.command === "fj") { - if (scenario === "missing") - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error("fj not found"), - }), - ); - if (input.args[0] === "version") - return Effect.succeed(processOutput("fj 0.10.0")); - if (scenario === "invalid-storage") { - assert.deepStrictEqual(input.args, ["auth", "list"]); - return Effect.succeed(processOutput("")); - } - assert.deepStrictEqual(input.args, [ - "--host", - "http://forgejo.local:3000", - "whoami", - ]); - return Effect.succeed( - processOutput("", { - exitCode: ChildProcessSpawner.ExitCode(scenario === "revoked" ? 1 : 0), - }), - ); - } - assert.strictEqual(input.command, "tea"); - return Effect.succeed( - input.args[0] === "--version" - ? processOutput("tea version 0.16.0") - : processOutput( - encodeJson([ - { - name: "work", - url: "http://forgejo.local:3000", - user: "maria", - default: "true", - valid: "true", - }, - ]), - ), - ); - }, - }), - ), - ), - ); - } - }), -); - it.effect( "checks out fj pull refs and preserves existing branches and dirty files until forced", () => @@ -1627,22 +385,26 @@ it.effect( args: ["rev-parse", "HEAD"], })).stdout.trim(); const fetched: string[] = []; + const host = yield* SourceControlHost.SourceControlHost; const provider = yield* ForgejoSourceControlProvider.make.pipe( Effect.provideService( - VcsProcess.VcsProcess, - VcsProcess.VcsProcess.of({ - run: (input) => { - if (input.args[0] !== "fetch") return git.run(input); - const url = input.args[2]; - assert.isDefined(url); - fetched.push(url!); - // Only SSH transport is substituted; both paths fetch the real pull ref. - return git.run({ - ...input, - args: input.args.map((arg) => - arg === "git@forgejo.test:reviewer/project.git" ? source : arg, - ), - }); + SourceControlHost.SourceControlHost, + SourceControlHost.SourceControlHost.of({ + settings: host.settings, + process: { + run: (input) => { + if (input.args[0] !== "fetch") return git.run(input); + const url = input.args[2]; + assert.isDefined(url); + fetched.push(url!); + // Only SSH transport is substituted; both paths fetch the real pull ref. + return git.run({ + ...input, + args: input.args.map((arg) => + arg === "git@forgejo.test:reviewer/project.git" ? source : arg, + ), + }); + }, }, }), ), @@ -1759,6 +521,12 @@ it.effect( assert.deepStrictEqual(fetched, [source, source, "git@forgejo.test:reviewer/project.git"]); }).pipe( Effect.scoped, - Effect.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), + Effect.provide( + ServerSourceControlHost.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ), + ), ), ); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 08a7259eb1e1..2e2969488d3d 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -19,7 +19,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitLabCli from "./GitLabCli.ts"; -import * as ForgejoCli from "./ForgejoCli.ts"; +import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 309f79b1509f..22516853b836 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -15,7 +15,7 @@ import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlPro import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; -import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; +import * as BuiltInDrivers from "./builtInDrivers.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { probeSourceControlProvider, @@ -308,11 +308,18 @@ export const make = Effect.gen(function* () { const github = yield* GitHubSourceControlProvider.make; const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery; 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; + const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => + driver.make.pipe( + Effect.map((instance): SourceControlProviderRegistration => ({ + kind: driver.kind, + provider: instance.sourceControl, + discovery: instance.discovery, + })), + ), + ); return yield* makeWithProviders([ { kind: "github", @@ -334,7 +341,7 @@ export const make = Effect.gen(function* () { provider: bitbucket, discovery: bitbucketDiscovery, }, - { kind: "forgejo", provider: forgejo, discovery: forgejoDiscovery }, + ...drivers, ]); }); diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts new file mode 100644 index 000000000000..c49319ba0a1f --- /dev/null +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -0,0 +1,20 @@ +/** + * The source control drivers this build ships with. Both registries, repository operations and + * pull requests, iterate this list; a host with no driver here shows up as unsupported. + * + * Adding a host means writing its `@t3tools/source-control-` package, adding its driver + * here, and providing its services' layers in `layer` below. + * + * @module sourceControl/builtInDrivers + */ +import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as Layer from "effect/Layer"; + +import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; + +/** Ordered as the hosts appear in discovery, after the ones still built in the server. */ +export const BUILT_IN_SOURCE_CONTROL_DRIVERS = [ForgejoDriver.driver] as const; + +/** The services the built-in drivers' packages own, plus the host port they all run against. */ +export const layer = ForgejoCli.layer.pipe(Layer.provideMerge(ServerSourceControlHost.layer)); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 666805e1d79e..d3e27ea0bc81 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -233,9 +233,8 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; -import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; +import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; -import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; @@ -3209,8 +3208,7 @@ export const layer = Layer.unwrap( BitbucketApi.layer, GitHubApi.layerWithDependencies, GitLabCli.layer, - ForgejoCli.layer, - ServerSourceControlHost.layer, + SourceControlBuiltInDrivers.layer, ), ), Layer.provideMerge(GitVcsDriver.layer), diff --git a/package.json b/package.json index 471e5361eef2..ed9f936dfbf9 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-forgejo --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/source-control-core/package.json b/packages/source-control-core/package.json index 58742f9f3589..ee1045b278cd 100644 --- a/packages/source-control-core/package.json +++ b/packages/source-control-core/package.json @@ -3,10 +3,18 @@ "private": true, "type": "module", "exports": { + "./server/diffFileRevisions": { + "types": "./src/server/diffFileRevisions.ts", + "import": "./src/server/diffFileRevisions.ts" + }, "./server/discovery": { "types": "./src/server/discovery.ts", "import": "./src/server/discovery.ts" }, + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, "./server/mergeMessage": { "types": "./src/server/mergeMessage.ts", "import": "./src/server/mergeMessage.ts" @@ -38,6 +46,7 @@ }, "dependencies": { "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts b/packages/source-control-core/src/server/diffFileRevisions.test.ts similarity index 99% rename from apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts rename to packages/source-control-core/src/server/diffFileRevisions.test.ts index 1426a67d29da..cd1790adf8bb 100644 --- a/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts +++ b/packages/source-control-core/src/server/diffFileRevisions.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; -import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; +import { parseDiffFileRevisions } from "./diffFileRevisions.ts"; function patchOf(...lines: ReadonlyArray): string { return `${lines.join("\n")}\n`; diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts b/packages/source-control-core/src/server/diffFileRevisions.ts similarity index 100% rename from apps/server/src/pullRequest/bitbucketDiffRevisions.ts rename to packages/source-control-core/src/server/diffFileRevisions.ts diff --git a/packages/source-control-core/src/server/driver.ts b/packages/source-control-core/src/server/driver.ts new file mode 100644 index 000000000000..3a227d0928d8 --- /dev/null +++ b/packages/source-control-core/src/server/driver.ts @@ -0,0 +1,36 @@ +/** + * SourceControlDriver — what a source control provider package hands the server. + * + * One driver per host kind. The server lists its drivers once and both registries (repository + * operations and pull requests) iterate that list, so adding a host means writing its package + * and adding its driver to the list. + * + * `make` yields what it needs from the environment: the `SourceControlHost` port, Effect + * platform services, and the package's own services, which the server provides from the + * package's layers. Its `R` channel is the full list. + * + * @module source-control-core/server/driver + */ +import type { SourceControlProviderKind } from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; + +import type { SourceControlProviderDiscoverySpec } from "./discovery.ts"; +import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; +import type * as SourceControlProvider from "./SourceControlProvider.ts"; + +export interface SourceControlDriverInstance { + readonly sourceControl: SourceControlProvider.SourceControlProvider["Service"]; + readonly discovery: SourceControlProviderDiscoverySpec; + /** Null for a host whose pull requests this build cannot read. */ + readonly pullRequests: PullRequestProviderApi | null; +} + +export interface SourceControlDriver { + readonly kind: SourceControlProviderKind; + readonly make: Effect.Effect; +} + +/** Declares a driver with its `R` inferred from `make`. */ +export const defineSourceControlDriver = ( + driver: SourceControlDriver, +): SourceControlDriver => driver; diff --git a/packages/source-control-forgejo/package.json b/packages/source-control-forgejo/package.json new file mode 100644 index 000000000000..4c6b534e2e81 --- /dev/null +++ b/packages/source-control-forgejo/package.json @@ -0,0 +1,48 @@ +{ + "name": "@t3tools/source-control-forgejo", + "private": true, + "type": "module", + "exports": { + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, + "./server/ForgejoCli": { + "types": "./src/server/ForgejoCli.ts", + "import": "./src/server/ForgejoCli.ts" + }, + "./server/forgejoPullRequestJson": { + "types": "./src/server/forgejoPullRequestJson.ts", + "import": "./src/server/forgejoPullRequestJson.ts" + }, + "./server/ForgejoPullRequestProvider": { + "types": "./src/server/ForgejoPullRequestProvider.ts", + "import": "./src/server/ForgejoPullRequestProvider.ts" + }, + "./server/forgejoPullRequests": { + "types": "./src/server/forgejoPullRequests.ts", + "import": "./src/server/forgejoPullRequests.ts" + }, + "./server/ForgejoSourceControlProvider": { + "types": "./src/server/ForgejoSourceControlProvider.ts", + "import": "./src/server/ForgejoSourceControlProvider.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@t3tools/source-control-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/apps/server/src/sourceControl/ForgejoCli.ts b/packages/source-control-forgejo/src/server/ForgejoCli.ts similarity index 98% rename from apps/server/src/sourceControl/ForgejoCli.ts rename to packages/source-control-forgejo/src/server/ForgejoCli.ts index 573c081e5a42..73b0098aa403 100644 --- a/apps/server/src/sourceControl/ForgejoCli.ts +++ b/packages/source-control-forgejo/src/server/ForgejoCli.ts @@ -13,7 +13,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import type { SourceControlProviderContext } from "@t3tools/source-control-core/server/SourceControlProvider"; @@ -133,7 +133,7 @@ export class ForgejoCli extends Context.Service< readonly stdin?: string; readonly timeoutMs?: number; readonly maxOutputBytes?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly listLogins?: (input: { readonly cwd: string; readonly command: "fj" | "tea"; @@ -148,9 +148,9 @@ export class ForgejoCli extends Context.Service< ) => Effect.Effect; readonly api: ( input: ForgejoApiInput, - ) => Effect.Effect; + ) => Effect.Effect; } ->()("t3/sourceControl/ForgejoCli") {} +>()("@t3tools/source-control-forgejo/server/ForgejoCli") {} export function parseForgejoRemote(value: string) { if (/^(?:https?|ssh):\/\//i.test(value)) { @@ -212,7 +212,7 @@ export function matchForgejoLogin( } export const make = Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; + const { process } = yield* SourceControlHost.SourceControlHost; const fileSystem = yield* FileSystem.FileSystem; const httpClient = yield* HttpClient.HttpClient; const authLock = yield* Semaphore.make(1); diff --git a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts similarity index 99% rename from apps/server/src/pullRequest/ForgejoPullRequestProvider.ts rename to packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index a9337fdbe8fd..4a2d8afba121 100644 --- a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -3,8 +3,8 @@ import * as Schema from "effect/Schema"; import * as Result from "effect/Result"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; -import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; +import * as ForgejoCli from "./ForgejoCli.ts"; +import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions"; import { PullRequestProviderError, type ProviderChangeRequestDetail, diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts new file mode 100644 index 000000000000..679a4e69028c --- /dev/null +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -0,0 +1,1283 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type * as Context from "effect/Context"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { VcsProcessSpawnError } from "@t3tools/contracts"; +import { firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as ForgejoCli from "./ForgejoCli.ts"; +import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; +import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; + +const processOutput = TestSourceControlHost.processOutput; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => { + const methods: string[] = []; + const fetchReview = async ( + ...[input, init]: Parameters> + ) => { + const request = new Request(input instanceof Request ? input.url : String(input), { + ...(init?.method === undefined ? {} : { method: init.method }), + ...(init?.headers === undefined ? {} : { headers: init.headers }), + ...(init?.body === undefined ? {} : { body: init.body }), + }); + methods.push(request.method); + if (request.method === "GET") { + assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42"); + return new Response( + encodeJson({ + number: 42, + title: "Review target", + body: "", + html_url: "https://forgejo.test/maria/project/pulls/42", + user: { login: "maria" }, + state: "open", + merged: false, + head: { ref: "feature", sha: "head", repo: null }, + base: { ref: "main", sha: "base", repo: null }, + created_at: "2026-09-13T00:00:00Z", + updated_at: "2026-09-13T00:00:00Z", + closed_at: null, + merged_at: null, + labels: [], + }), + ); + } + assert.strictEqual(request.method, "POST"); + assert.strictEqual( + request.url, + "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews", + ); + assert.deepStrictEqual(JSON.parse(await request.text()), { + event: "COMMENT", + body: "Review summary", + commit_id: "head", + comments: [], + }); + return new Response('{"id":1}', { status: 200 }); + }; + return Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const provider = yield* ForgejoPullRequestProvider.make.pipe( + Effect.provideService(ForgejoCli.ForgejoCli, cli), + ); + yield* provider.submitReview({ + cwd: "/repo", + repository: "maria/project", + host: "forgejo.test", + number: 42, + verdict: "comment", + body: "Review summary", + comments: [], + }); + assert.deepStrictEqual(methods, ["GET", "POST"]); + }).pipe( + Effect.provideService( + FetchHttpClient.Fetch, + Object.assign(fetchReview, { preconnect: () => undefined }), + ), + Effect.provide(FetchHttpClient.layer), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }), + ), + }), + ), + Effect.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => { + if (input.command === "git") { + assert.deepStrictEqual(input.args, ["remote", "-v"]); + return Effect.succeed( + processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"), + ); + } + assert.strictEqual(input.command, "fj"); + assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]); + return Effect.succeed(processOutput("")); + }, + }, + }), + ), + ); +}); + +it.effect("reads Forgejo checks without repository or viewer requests", () => { + const paths: string[] = []; + return Effect.gen(function* () { + const provider = yield* ForgejoPullRequestProvider.make; + const read = provider.getChangeRequestChecks; + if (read === undefined) return yield* Effect.die("checks read missing"); + const result = yield* read({ + cwd: "/repo", + repository: "acme/web", + host: "forgejo.test", + number: 1, + }); + assert.strictEqual(result.state, "open"); + assert.strictEqual(result.checks[0]?.status, "failure"); + assert.deepStrictEqual(paths, [ + "repos/acme/web/pulls/1", + "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=1", + "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=2", + ]); + }).pipe( + Effect.provide( + Layer.mock(ForgejoCli.ForgejoCli)({ + api: (input) => { + paths.push(input.path); + assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head)/); + return Effect.succeed( + processOutput( + input.path.endsWith("pulls/1") + ? `{"number":1,"title":"Checks","body":"","html_url":"https://forgejo.test/acme/web/pulls/1", "user":null,"state":"open","merged":false, + "head":{"ref":"feature","sha":"head","repo":null},"base":{"ref":"main","sha":"base","repo":null}, + "created_at":"2026-09-16T00:00:00Z","updated_at":"2026-09-16T00:00:00Z","closed_at":null,"merged_at":null,"labels":[]}` + : input.path.endsWith("page=1") + ? `[{"context":"build","status":"failure","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00Z"}]` + : "[]", + ), + ); + }, + }), + ), + ); +}); + +it.effect.each([ + ["Ready", false, true, false, "mergeable"], + ["Draft", true, false, true, "unknown"], + ["Blocked", false, false, false, "unknown"], + ["Unchecked", false, undefined, false, "unknown"], + ["WIP: Legacy draft", undefined, false, true, "unknown"], + ["[WIP] Legacy draft", undefined, false, true, "unknown"], + ["WIP: Explicitly ready", false, false, false, "unknown"], +] as const)( + "reads Forgejo mergeability for %s (draft=%s, mergeable=%s) across list, summary and detail", + ([title, draft, mergeable, isDraft, mergeability]) => { + const pr = { + number: 42, + title, + body: "", + html_url: "https://forgejo.test/maria/project/pulls/42", + user: { login: "maria" }, + state: "open", + merged: false, + ...(draft === undefined ? {} : { draft }), + ...(mergeable === undefined ? {} : { mergeable }), + head: { ref: "feature", sha: "head", repo: null }, + base: { ref: "main", sha: "base", repo: null }, + created_at: "2026-09-16T00:00:00Z", + updated_at: "2026-09-16T00:00:00Z", + closed_at: null, + merged_at: null, + labels: [], + }; + return Effect.gen(function* () { + const provider = yield* ForgejoPullRequestProvider.make; + const readSummary = provider.getChangeRequestSummary; + if (readSummary === undefined) return yield* Effect.die("summary read missing"); + const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 42 }; + const list = yield* provider.listChangeRequests({ + ...input, + state: "open", + involvement: "all", + viewer: "maria", + limit: 10, + }); + const summary = yield* readSummary(input); + const detail = yield* provider.getChangeRequest(input); + assert.strictEqual(list.items.length, 1); + for (const result of [list.items[0]!, summary, detail]) { + assert.strictEqual(result.mergeability, mergeability); + assert.strictEqual(result.isDraft, isDraft); + } + }).pipe( + Effect.provide( + Layer.mock(ForgejoCli.ForgejoCli)({ + api: (input) => { + const [path, query] = input.path.split("?"); + let response: unknown; + switch (path) { + case "repos/maria/project/pulls": + response = new URLSearchParams(query).get("page") === "1" ? [pr] : []; + break; + case "repos/maria/project/pulls/42": + response = pr; + break; + case "repos/maria/project": + response = { full_name: "maria/project", permissions: { push: true, admin: true } }; + break; + case "user": + response = pr.user; + break; + case "repos/maria/project/statuses/head": + response = []; + break; + default: + return Effect.die(`Unexpected Forgejo request: ${input.path}`); + } + return encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput)); + }, + }), + ), + ); + }, +); + +it.effect("loads Forgejo pull request references from files and commits views", () => + Effect.gen(function* () { + const provider = yield* ForgejoSourceControlProvider.make; + for (const reference of [ + "42", + "#42", + "https://forgejo.test/maria/project/pulls/42", + "https://forgejo.test/maria/project/pulls/42/", + "https://forgejo.test/maria/project/pulls/42/files?w=1#diff-1", + "http://forgejo.test:3000/git/maria/project/pulls/42/commits", + ]) { + const result = yield* provider.getChangeRequest({ cwd: "/repo", reference }); + assert.strictEqual(result.number, 42); + assert.strictEqual(result.title, "Forgejo view reference"); + } + const invalid = yield* provider + .getChangeRequest({ + cwd: "/repo", + reference: "https://forgejo.test/maria/project/pulls/42invalid/files", + }) + .pipe(Effect.result); + assert.strictEqual(invalid._tag, "Failure"); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})), + TestSourceControlHost.layer({ process: {} }), + Layer.mock(ForgejoCli.ForgejoCli)({ + resolveRepository: () => + Effect.succeed({ + login: "work", + repository: "maria/project", + baseUrl: "https://forgejo.test", + }), + api: (input) => { + assert.strictEqual(input.path, "repos/maria/project/pulls/42"); + return encodeJsonEffect({ + number: 42, + title: "Forgejo view reference", + html_url: "https://forgejo.test/maria/project/pulls/42", + state: "open", + merged: false, + base: { ref: "main", sha: "base", repo: null }, + head: { ref: "feature", sha: "head", repo: null }, + }).pipe(Effect.orDie, Effect.map(processOutput)); + }, + }), + ), + ), + ), +); + +it.effect( + "loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations", + () => { + const user = { login: "maria" }; + const review = { + id: 8, + body: "review body", + user, + state: "COMMENT", + submitted_at: "2026-09-12T00:00:00Z", + html_url: "https://forgejo.test/maria/project/pulls/2#issuecomment-37", + comments_count: 1, + }; + const comment = { id: 12, body: "ordinary", user, created_at: review.submitted_at }; + const responses: Record = { + user, + "repos/maria/project/issues/2/comments": [comment], + "repos/maria/project/pulls/2/reviews": [review], + "repos/maria/project/pulls/2/reviews/8": review, + "repos/maria/project/pulls/2/reviews/9": { ...review, id: 9, html_url: "" }, + "repos/maria/project/pulls/2/commits": [], + "repos/maria/project/issues/2/reactions": [], + "repos/maria/project/pulls/2/reviews/8/comments": [ + { + ...comment, + id: 38, + body: "inline", + path: "file.ts", + position: 1, + original_position: 1, + commit_id: "head", + original_commit_id: "head", + resolver: null, + }, + ], + "repos/maria/project/issues/comments/12/reactions": [{ content: "+1", user }], + "repos/maria/project/issues/comments/37/reactions": [{ content: "heart", user }], + "repos/maria/project/issues/comments/38/reactions": [ + { content: "rocket", user: { login: "reviewer" } }, + ], + }; + const writes: ForgejoCli.ForgejoApiInput[] = []; + let reactionReads = 0; + return Effect.gen(function* () { + const provider = yield* ForgejoPullRequestProvider.make; + const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 2 }; + const activity = yield* provider.getChangeRequestActivity(input); + assert.deepStrictEqual( + activity.comments.map((entry) => ({ + id: entry.id, + kind: entry.kind, + reactions: entry.reactions, + })), + [ + { + id: "12", + kind: "issue-comment", + reactions: [{ content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }], + }, + { + id: "review:8", + kind: "review", + reactions: [{ content: "heart", count: 1, actors: [], viewerHasReacted: true }], + }, + { + id: "38", + kind: "review-comment", + reactions: [ + { content: "rocket", count: 1, actors: ["reviewer"], viewerHasReacted: false }, + ], + }, + ], + ); + const inlineComment = activity.comments[2]; + assert.ok(inlineComment); + assert.deepStrictEqual(activity.reviewThreads[0]?.comments, [inlineComment]); + for (const reacted of [true, false]) { + yield* provider.setReaction({ ...input, subjectId: "review:8", content: "heart", reacted }); + yield* provider.setReaction({ ...input, subjectId: "38", content: "rocket", reacted }); + } + assert.deepStrictEqual( + writes.map(({ path, method, body }) => ({ path, method, body })), + [ + { + path: "repos/maria/project/issues/comments/37/reactions", + method: "POST", + body: { content: "heart" }, + }, + { + path: "repos/maria/project/issues/comments/38/reactions", + method: "POST", + body: { content: "rocket" }, + }, + { + path: "repos/maria/project/issues/comments/37/reactions", + method: "DELETE", + body: { content: "heart" }, + }, + { + path: "repos/maria/project/issues/comments/38/reactions", + method: "DELETE", + body: { content: "rocket" }, + }, + ], + ); + const missing = yield* provider + .setReaction({ ...input, subjectId: "review:9", content: "heart", reacted: true }) + .pipe(Effect.result); + assert.strictEqual(missing._tag, "Failure"); + if (missing._tag === "Failure") assert.include(missing.failure.detail, "comment ID"); + assert.strictEqual(writes.length, 4); + responses["repos/maria/project/pulls/2/reviews/8/comments"] = Array.from( + { length: 501 }, + (_, index) => ({ + ...comment, + id: 1000 + index, + path: "file.ts", + position: 1, + original_position: 1, + commit_id: "head", + original_commit_id: "head", + resolver: null, + }), + ); + for (let index = 0; index < 500; index++) { + responses[`repos/maria/project/issues/comments/${1000 + index}/reactions`] = []; + } + reactionReads = 0; + const bounded = yield* provider.getChangeRequestActivity(input); + assert.strictEqual(bounded.reviewThreads.length, 500); + assert.strictEqual( + bounded.comments.filter((entry) => entry.kind === "review-comment").length, + 500, + ); + assert.strictEqual(bounded.commentsTruncated, true); + assert.strictEqual(reactionReads, 502); + }).pipe( + Effect.provide( + Layer.mock(ForgejoCli.ForgejoCli)({ + api: (input) => { + if (input.method) { + writes.push(input); + return Effect.succeed(processOutput("{}")); + } + const path = input.path.split("?")[0]!; + if (/\/issues\/comments\/\d+\/reactions$/.test(path)) reactionReads++; + assert.ok(Object.hasOwn(responses, path), `Unexpected Forgejo request: ${path}`); + const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page")); + return encodeJsonEffect(page > 1 ? [] : responses[path]).pipe( + Effect.orDie, + Effect.map(processOutput), + ); + }, + }), + ), + ); + }, +); + +it.effect("discovers Forgejo accounts and retains the server port", () => + Effect.gen(function* () { + const auth = ForgejoSourceControlProvider.discovery.parseAuth( + processOutput( + yield* encodeJsonEffect([ + { + name: "work", + url: "http://forgejo.local:3000", + ssh_host: "git.forgejo.local", + user: "maria", + default: "true", + valid: "true", + }, + ]), + ), + ); + assert.deepStrictEqual( + firstNonEmptyLine("\u001b[1mtea version 0.16.0\u001b[0m\n"), + Option.some("tea version 0.16.0"), + ); + assert.strictEqual(auth.status, "authenticated"); + assert.deepStrictEqual(auth.account, Option.some("maria")); + assert.deepStrictEqual(auth.host, Option.some("forgejo.local:3000")); + const revoked = ForgejoSourceControlProvider.discovery.parseAuth( + processOutput( + encodeJson([ + { + name: "work", + url: "http://forgejo.local:3000", + user: "maria", + default: "true", + valid: "false", + }, + ]), + ), + ); + assert.strictEqual(revoked.status, "unauthenticated"); + const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ + cwd: "/repo", + context: { + provider: { + kind: "unknown", + name: "git.forgejo.local", + baseUrl: "https://git.forgejo.local", + }, + remoteName: "origin", + remoteUrl: "git@git.forgejo.local:maria/project.git", + }, + auth: processOutput( + yield* encodeJsonEffect([ + { + name: "work", + url: "http://forgejo.local:3000", + ssh_host: "git.forgejo.local", + user: "maria", + default: "true", + }, + ]), + ), + }); + assert.deepStrictEqual(refined, { + kind: "forgejo", + name: "Forgejo / Gitea", + baseUrl: "http://forgejo.local:3000", + }); + }), +); + +it.effect("does not choose a default Forgejo login across ambiguous SSH server ports", () => + Effect.gen(function* () { + const logins = ForgejoCli.parseForgejoLogins( + yield* encodeJsonEffect([ + { + name: "one", + url: "http://forgejo.local:3000", + ssh_host: "forgejo.local", + user: "maria", + default: "true", + }, + { + name: "two", + url: "http://forgejo.local:4000", + ssh_host: "forgejo.local", + user: "maria", + default: "false", + }, + ]), + ); + const remote = ForgejoCli.parseForgejoRemote("git@forgejo.local:maria/project.git"); + assert.isNotNull(remote); + assert.deepStrictEqual( + ForgejoCli.parseForgejoRemote("forgejo.local:maria/project.git"), + remote, + ); + assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!)); + assert.strictEqual( + ForgejoCli.matchForgejoLogin(logins, remote!, "forgejo.local:4000")?.name, + "two", + ); + assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!, "other.local:4000")); + const alias = ForgejoCli.parseForgejoRemote("git@ssh.forgejo.local:maria/project.git"); + assert.isNotNull(alias); + assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, alias!, "forgejo.local:4000")); + const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ + cwd: "/repo", + context: { + provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" }, + remoteName: "origin", + remoteUrl: "git@forgejo.local:maria/project.git", + requestedHost: "forgejo.local:4000", + }, + auth: processOutput(yield* encodeJsonEffect(logins)), + }); + assert.strictEqual(refined?.baseUrl, "http://forgejo.local:4000"); + const https = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000/maria/project.git"); + assert.isNotNull(https); + assert.strictEqual(ForgejoCli.matchForgejoLogin(logins, https!)?.name, "two"); + const hostOnly = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000"); + assert.strictEqual( + ForgejoCli.matchForgejoLogin(logins, hostOnly!, undefined, true)?.name, + "two", + ); + const mounted = logins.map((login) => ({ + ...login, + url: `http://forgejo.local:4000/${login.name}`, + })); + assert.isUndefined(ForgejoCli.matchForgejoLogin(mounted, hostOnly!, undefined, true)); + }), +); + +it.effect("rejects HTTP failures even when tea exits successfully", () => + Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const result = yield* cli + .api({ + cwd: "/repo", + repository: "http://forgejo.local:3000/maria/project", + path: "repos/maria/project/pulls/42", + method: "PATCH", + body: { state: "closed" }, + }) + .pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") + assert.strictEqual( + result.failure.detail, + "Forgejo repository or pull request was not found.", + ); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(false), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => { + throw new Error("tea must handle its own HTTP request"); + }), + ), + Effect.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => { + if (input.args[0] === "api") { + assert.strictEqual(input.stdin, '{"state":"closed"}'); + assert.include(input.args, "work"); + assert.include( + input.args, + "http://forgejo.local:3000/api/v1/repos/maria/project/pulls/42", + ); + } + return Effect.succeed( + input.args[0] === "login" + ? processOutput( + encodeJson([ + { + name: "work", + url: "http://forgejo.local:3000", + ssh_host: "forgejo.local", + user: "maria", + default: "true", + }, + ]), + ) + : processOutput('{"message":"not found"}', { stderr: "HTTP/1.1 404 Not Found\n" }), + ); + }, + }, + }), + ), + ), +); + +it.effect("routes mounted Forgejo repositories without repeating the mount in API paths", () => + Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const viewer = yield* cli.api({ cwd: "/upstream-only", host: "code.test", path: "user" }); + assert.strictEqual(viewer.stdout, "[]"); + const mountedRepository = yield* cli.resolveRepository({ + cwd: "/upstream-only", + host: "code.test", + repository: "maria/project", + }); + assert.strictEqual(mountedRepository.baseUrl, "https://code.test/forgejo"); + assert.strictEqual(mountedRepository.repository, "maria/project"); + for (const path of [ + "repos/forgejo/maria/project/pulls?state=open", + "repos/forgejo/maria/project", + "repos/reviewer/project/contents/file.ts", + ]) { + const result = yield* cli.api({ + cwd: "/repo", + repository: "forgejo/maria/project", + context: { + provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, + remoteName: "origin", + remoteUrl: "https://code.test/forgejo/maria/project.git", + }, + path, + }); + assert.strictEqual(result.stdout, "[]"); + } + const sameOwnerAsMount = yield* cli.resolveRepository({ + cwd: "/repo", + repository: "forgejo/project", + context: { + provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, + remoteName: "origin", + remoteUrl: "ssh://git@code.test/forgejo/project.git", + }, + }); + assert.strictEqual(sameOwnerAsMount.command, "tea"); + assert.strictEqual(sameOwnerAsMount.repository, "forgejo/project"); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + encodeJson({ + hosts: { "code.test/forgejo": { type: "Application", token: "test-token" } }, + }), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => { + throw new Error("tea must handle its own HTTP request"); + }), + ), + Effect.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => { + if (input.command === "git") + return Effect.succeed( + processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }), + ); + if (input.args[0] === "login") + return Effect.succeed( + processOutput( + encodeJson([ + { + name: "mounted", + url: "https://code.test/forgejo", + ssh_host: "code.test", + user: "maria", + default: "true", + }, + ]), + ), + ); + const supported = [ + "https://code.test/forgejo/api/v1/user", + "https://code.test/forgejo/api/v1/repos/maria/project/pulls?state=open", + "https://code.test/forgejo/api/v1/repos/maria/project", + "https://code.test/forgejo/api/v1/repos/reviewer/project/contents/file.ts", + ]; + if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo"); + assert.strictEqual(input.command, "tea"); + return Effect.succeed( + supported.includes(input.args.at(-1) ?? "") + ? processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }) + : processOutput("{}", { stderr: "HTTP/1.1 404 Not Found\n" }), + ); + }, + }, + }), + ), + ), +); + +it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => { + const commands: string[] = []; + const requests: string[] = []; + return Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + for (const remoteUrl of [ + "http://forgejo.local:3000/maria/project.git", + "ssh://git@ssh.forgejo.local:2222/maria/project.git", + "ssh://git@forgejo.local:2222/maria/project.git", + ]) { + const result = yield* cli.api({ + cwd: "/repo", + repository: "maria/project", + context: { + provider: { + kind: "forgejo", + name: "Forgejo", + baseUrl: "http://forgejo.local:3000", + }, + remoteName: "origin", + remoteUrl, + requestedHost: "forgejo.local:3000", + }, + path: "repos/maria/project/issues/42/comments", + method: "POST", + body: { body: "verified through fj" }, + }); + assert.strictEqual(result.stdout, '{"id":99}'); + } + assert.deepStrictEqual(commands, ["fj"]); + assert.deepStrictEqual(requests, [ + "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", + "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", + "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", + ]); + const viewer = yield* cli.api({ + cwd: "/no-remotes", + host: "forgejo.local:3000", + path: "user", + }); + assert.strictEqual(viewer.stdout, '{"login":"maria"}'); + assert.strictEqual(requests.at(-1), "https://forgejo.local:3000/api/v1/user"); + const upstreamViewer = yield* cli.api({ + cwd: "/upstream-only", + host: "forgejo.local:3000", + path: "user", + }); + assert.strictEqual(upstreamViewer.stdout, '{"login":"maria"}'); + assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user"); + const upstreamRepository = yield* cli.resolveRepository({ + cwd: "/upstream-only", + host: "forgejo.local:3000", + repository: "maria/project", + }); + assert.strictEqual(upstreamRepository.baseUrl, "http://forgejo.local:3000"); + assert.strictEqual(upstreamRepository.repository, "maria/project"); + const httpViewer = yield* cli.api({ cwd: "/repo", host: "forgejo.local:3000", path: "user" }); + assert.strictEqual(httpViewer.stdout, '{"login":"maria"}'); + assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user"); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + encodeJson({ + hosts: { + "forgejo.local:3000": { type: "Application", token: "test-token" }, + "forgejo.local:4000": { type: "Application", token: "other-token" }, + }, + aliases: { "ssh.forgejo.local:2222": "forgejo.local:3000" }, + }), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request.url); + if (request.url.endsWith("/user")) { + assert.strictEqual(request.method, "GET"); + assert.strictEqual(request.headers.authorization, "token test-token"); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response('{"login":"maria"}')), + ); + } + assert.strictEqual(request.method, "POST"); + assert.strictEqual(request.headers.authorization, "token test-token"); + assert.strictEqual(request.body._tag, "Uint8Array"); + if (request.body._tag === "Uint8Array") + assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), { + body: "verified through fj", + }); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })), + ); + }), + ), + Effect.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => { + commands.push(input.command); + if (input.command === "git") + return Effect.succeed( + input.cwd === "/no-remotes" + ? processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }) + : processOutput( + `${input.cwd === "/upstream-only" ? "upstream" : "origin"}\thttp://forgejo.local:3000/maria/project.git (fetch)\nother\thttp://forgejo.local:3000/maria/other.git (fetch)\nunrelated\thttp://other.local:3000/maria/project.git (fetch)`, + ), + ); + assert.strictEqual(input.command, "fj"); + assert.deepStrictEqual(input.args, [ + "--host", + input.cwd === "/no-remotes" + ? "https://forgejo.local:3000" + : "http://forgejo.local:3000", + "whoami", + ]); + return Effect.succeed(processOutput("")); + }, + }, + }), + ), + ); +}); + +it.effect("loads later fj review pages when the server caps pages below the requested size", () => { + const pages: number[] = []; + let issueCommentRequests = 0; + return Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const provider = yield* ForgejoPullRequestProvider.make.pipe( + Effect.provideService(ForgejoCli.ForgejoCli, cli), + ); + const activity = yield* provider.getChangeRequestActivity({ + cwd: "/repo", + repository: "maria/project", + host: "forgejo.test", + number: 42, + }); + assert.strictEqual(activity.commentCount, 42); + assert.strictEqual(activity.comments.at(-1)?.id, "review:41"); + assert.strictEqual(activity.commentsTruncated, false); + assert.deepStrictEqual(pages, [1, 2, 3]); + assert.strictEqual(issueCommentRequests, 1); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + const url = new URL(request.url); + if (url.pathname === "/api/v1/repos/maria/project/issues/42/comments") { + issueCommentRequests++; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + encodeJson([ + { + id: 100, + body: "Unpaginated issue comment", + user: { login: "maria" }, + created_at: "2026-09-13T00:00:00Z", + }, + ]), + ), + ), + ); + } + if (url.pathname.endsWith("/reviews")) { + const page = Number(url.searchParams.get("page")); + pages.push(page); + assert.ok(page >= 1 && page <= 3); + const reviews = Array.from({ length: page < 3 ? 20 : 1 }, (_, index) => ({ + id: (page - 1) * 20 + index + 1, + body: "Review from a capped page", + user: { login: "maria" }, + state: "COMMENT", + submitted_at: "2026-09-13T00:00:00Z", + comments_count: 0, + })); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson(reviews), { + headers: + page === 2 + ? {} + : { + Link: + page === 1 + ? `<${url.origin}${url.pathname}?limit=50&page=2>; rel="next"` + : `<${url.origin}${url.pathname}?limit=50&page=1>; rel="prev"`, + }, + }), + ), + ); + } + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson(url.pathname === "/api/v1/user" ? { login: "maria" } : [])), + ), + ); + }), + ), + Effect.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => { + if (input.command === "git") { + assert.deepStrictEqual(input.args, ["remote", "-v"]); + return Effect.succeed( + processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"), + ); + } + assert.strictEqual(input.command, "fj"); + assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]); + return Effect.succeed(processOutput("")); + }, + }, + }), + ), + ); +}); + +it.effect("falls back to tea when fj is missing or has no account for this server", () => + Effect.gen(function* () { + for (const scenario of ["missing-cli", "missing-account", "stale-invalid-storage"] as const) { + const commands: string[] = []; + yield* Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const result = yield* cli.api({ + cwd: "/repo", + repository: "https://forgejo.local:3000/maria/project", + path: "repos/maria/project/pulls", + }); + assert.strictEqual(result.stdout, "[]"); + assert.deepStrictEqual( + commands, + scenario === "missing-account" ? ["tea", "tea"] : ["fj", "tea", "tea"], + ); + const viewer = yield* cli.api({ + cwd: "/upstream-only", + host: "forgejo.local:3000", + path: "user", + }); + assert.strictEqual(viewer.stdout, "[]"); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + scenario === "stale-invalid-storage" + ? "invalid json" + : encodeJson({ + hosts: { + [scenario === "missing-cli" ? "forgejo.local:3000" : "other.local"]: { + type: "Application", + token: "test-token", + }, + }, + }), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => { + throw new Error("tea must handle its own HTTP request"); + }), + ), + Effect.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => { + commands.push(input.command); + if (input.command === "git") + return Effect.succeed( + processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }), + ); + if (input.command === "fj") + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error("fj not found"), + }), + ); + assert.strictEqual(input.command, "tea"); + if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo"); + return Effect.succeed( + input.args[0] === "login" + ? processOutput( + encodeJson([ + { + name: "work", + url: "https://forgejo.local:3000", + user: "maria", + default: "true", + valid: "true", + }, + ]), + ) + : processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }), + ); + }, + }, + }), + ), + ); + } + }), +); + +it.effect("handles fj mutation statuses without retrying failures or reading absent bodies", () => + Effect.gen(function* () { + for (const status of [204, 205, 302, 401, 403, 404, 429, 500]) { + let writes = 0; + yield* Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const result = yield* cli + .api({ + cwd: "/repo", + repository: "https://forgejo.local/maria/project", + path: "repos/maria/project/issues/42/comments", + method: "POST", + body: { body: "only once" }, + }) + .pipe(Effect.result); + assert.strictEqual(result._tag, status < 300 ? "Success" : "Failure"); + if (result._tag === "Success") assert.strictEqual(result.success.stdout, ""); + if (result._tag === "Failure") { + assert.strictEqual(result.failure.command, "fj"); + assert.strictEqual(result.failure.httpStatus, status); + } + assert.strictEqual(writes, 1); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + encodeJson({ + hosts: { + "forgejo.local": { type: "Application", token: "test-token" }, + }, + }), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + writes++; + assert.strictEqual( + request.url, + "https://forgejo.local/api/v1/repos/maria/project/issues/42/comments", + ); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(status < 300 ? null : "", { + status, + headers: { location: "https://other.local/" }, + }), + ), + ); + }), + ), + Effect.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => { + assert.strictEqual( + input.command, + "fj", + "a failed mutation must never switch accounts or CLI", + ); + return Effect.succeed(processOutput("")); + }, + }, + }), + ), + ); + } + }), +); + +it.effect( + "discovers fj first and retains configured authentication failures instead of switching accounts", + () => + Effect.gen(function* () { + for (const scenario of ["authenticated", "revoked", "missing", "invalid-storage"] as const) { + const commands: string[] = []; + yield* Effect.gen(function* () { + const spec = yield* ForgejoSourceControlProvider.makeDiscovery; + assert.strictEqual(spec.type, "managed-cli"); + if (spec.type !== "managed-cli") return; + const result = yield* spec.probe("/repo"); + assert.strictEqual(result.executable, scenario === "missing" ? "tea" : "fj"); + assert.strictEqual( + result.auth.status, + scenario === "revoked" + ? "unauthenticated" + : scenario === "invalid-storage" + ? "unknown" + : "authenticated", + ); + if (scenario !== "revoked" && scenario !== "invalid-storage") + assert.deepStrictEqual(result.auth.host, Option.some("forgejo.local:3000")); + assert.deepStrictEqual( + result.auth.account, + scenario === "authenticated" || scenario === "missing" + ? Option.some("maria") + : Option.none(), + ); + assert.strictEqual( + commands.some((command) => command.startsWith("tea ")), + scenario === "missing", + ); + assert.include(commands, "fj version"); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ForgejoCli.ForgejoCli)({ + getAccount: (input) => { + assert.strictEqual(scenario, "authenticated"); + assert.deepStrictEqual(input, { + cwd: "/repo", + baseUrl: "http://forgejo.local:3000", + }); + return Effect.succeed("maria"); + }, + listLogins: (input) => { + assert.strictEqual( + input.remoteUrl, + "http://forgejo.local:3000/maria/project.git", + ); + if (scenario === "invalid-storage") + return Effect.fail( + new ForgejoCli.ForgejoCliError({ + command: "fj", + cwd: input.cwd, + reason: "authentication", + detail: "fj authentication storage is invalid.", + }), + ); + return Effect.succeed([ + { + name: "forgejo.local:3000", + url: "http://forgejo.local:3000", + user: "", + default: "false", + }, + ]); + }, + }), + TestSourceControlHost.layer({ + process: { + run: (input) => { + commands.push(`${input.command} ${input.args.join(" ")}`); + if (input.command === "git") + return Effect.succeed( + processOutput("http://forgejo.local:3000/maria/project.git\n"), + ); + if (input.command === "fj") { + if (scenario === "missing") + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error("fj not found"), + }), + ); + if (input.args[0] === "version") + return Effect.succeed(processOutput("fj 0.10.0")); + if (scenario === "invalid-storage") { + assert.deepStrictEqual(input.args, ["auth", "list"]); + return Effect.succeed(processOutput("")); + } + assert.deepStrictEqual(input.args, [ + "--host", + "http://forgejo.local:3000", + "whoami", + ]); + return Effect.succeed( + processOutput("", { + exitCode: ChildProcessSpawner.ExitCode(scenario === "revoked" ? 1 : 0), + }), + ); + } + assert.strictEqual(input.command, "tea"); + return Effect.succeed( + input.args[0] === "--version" + ? processOutput("tea version 0.16.0") + : processOutput( + encodeJson([ + { + name: "work", + url: "http://forgejo.local:3000", + user: "maria", + default: "true", + valid: "true", + }, + ]), + ), + ); + }, + }, + }), + ), + ), + ); + } + }), +); diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts similarity index 98% rename from apps/server/src/sourceControl/ForgejoSourceControlProvider.ts rename to packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts index 79a1ca6f2aa0..cbcb3405f4ec 100644 --- a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts @@ -3,7 +3,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as Result from "effect/Result"; import { SourceControlProviderError } from "@t3tools/contracts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { @@ -55,7 +55,7 @@ export const discovery = { export const makeDiscovery = Effect.gen(function* () { const cli = yield* ForgejoCli.ForgejoCli; - const process = yield* VcsProcess.VcsProcess; + const { process } = yield* SourceControlHost.SourceControlHost; const listLogins = cli.listLogins; if (!listLogins) return discovery; return { @@ -175,7 +175,7 @@ const repositoryPath = (repository: string) => export const make = Effect.gen(function* () { const cli = yield* ForgejoCli.ForgejoCli; const fs = yield* FileSystem.FileSystem; - const process = yield* VcsProcess.VcsProcess; + const { process } = yield* SourceControlHost.SourceControlHost; const request = >( input: ForgejoCli.ForgejoApiInput, schema: S, diff --git a/packages/source-control-forgejo/src/server/driver.ts b/packages/source-control-forgejo/src/server/driver.ts new file mode 100644 index 000000000000..ae76d7c6409a --- /dev/null +++ b/packages/source-control-forgejo/src/server/driver.ts @@ -0,0 +1,20 @@ +/** + * The Forgejo / Gitea driver: repository operations, discovery, and pull requests through + * `fj` or `tea`. + * + * @module source-control-forgejo/server/driver + */ +import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; + +import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; +import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; + +export const driver = defineSourceControlDriver({ + kind: "forgejo", + make: Effect.all({ + sourceControl: ForgejoSourceControlProvider.make, + discovery: ForgejoSourceControlProvider.makeDiscovery, + pullRequests: ForgejoPullRequestProvider.make, + }), +}); diff --git a/apps/server/src/pullRequest/forgejoPullRequestJson.ts b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts similarity index 100% rename from apps/server/src/pullRequest/forgejoPullRequestJson.ts rename to packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts diff --git a/apps/server/src/sourceControl/forgejoPullRequests.ts b/packages/source-control-forgejo/src/server/forgejoPullRequests.ts similarity index 100% rename from apps/server/src/sourceControl/forgejoPullRequests.ts rename to packages/source-control-forgejo/src/server/forgejoPullRequests.ts diff --git a/packages/source-control-forgejo/tsconfig.json b/packages/source-control-forgejo/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/source-control-forgejo/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/source-control-testing/package.json b/packages/source-control-testing/package.json new file mode 100644 index 000000000000..66426fe03377 --- /dev/null +++ b/packages/source-control-testing/package.json @@ -0,0 +1,22 @@ +{ + "name": "@t3tools/source-control-testing", + "private": true, + "type": "module", + "exports": { + "./TestSourceControlHost": { + "types": "./src/TestSourceControlHost.ts", + "import": "./src/TestSourceControlHost.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/packages/source-control-testing/src/TestSourceControlHost.ts b/packages/source-control-testing/src/TestSourceControlHost.ts new file mode 100644 index 000000000000..293339d0867e --- /dev/null +++ b/packages/source-control-testing/src/TestSourceControlHost.ts @@ -0,0 +1,47 @@ +/** + * A `SourceControlHost.SourceControlHost` for provider tests. Settings are fixed unless the test + * supplies its own, and every process run goes to the test's `run`, which fails by default so an + * unexpected CLI call is visible. + * + * @module source-control-testing/TestSourceControlHost + */ +import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +export interface TestSourceControlHostOptions { + readonly settings?: ServerSettings; + readonly process?: Partial; +} + +export const layer = ( + options: TestSourceControlHostOptions = {}, +): Layer.Layer => + Layer.succeed( + SourceControlHost.SourceControlHost, + SourceControlHost.SourceControlHost.of({ + settings: { get: Effect.succeed(options.settings ?? DEFAULT_SERVER_SETTINGS) }, + process: { + run: + options.process?.run ?? + ((input) => Effect.die(`Unexpected ${input.command} run in ${input.operation}.`)), + }, + }), + ); + +/** A successful run's output, for tests that script CLI responses. */ +export const processOutput = ( + stdout: string, + options?: { + readonly stderr?: string; + readonly exitCode?: ChildProcessSpawner.ExitCode; + }, +): SourceControlHost.SourceControlProcessOutput => ({ + exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), + stdout, + stderr: options?.stderr ?? "", + stdoutTruncated: false, + stderrTruncated: false, +}); diff --git a/packages/source-control-testing/tsconfig.json b/packages/source-control-testing/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/source-control-testing/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 387677cd3d52..e91a9bb0555f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -651,6 +651,9 @@ importers: '@t3tools/source-control-core': specifier: workspace:* version: link:../../packages/source-control-core + '@t3tools/source-control-forgejo': + specifier: workspace:* + version: link:../../packages/source-control-forgejo diff: specifier: 8.0.3 version: 8.0.3 @@ -1518,6 +1521,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../contracts + '@t3tools/shared': + specifier: workspace:* + version: link:../shared effect: specifier: 4.0.2 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) @@ -1532,6 +1538,53 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-forgejo: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@effect/vitest': + specifier: 4.0.2 + version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/source-control-testing': + specifier: workspace:* + version: link:../source-control-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + + packages/source-control-testing: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + packages/ssh: dependencies: '@t3tools/contracts': From 6e8ae5b1b53cc6efb26a3d80d5da9b16844b5f73 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:31:23 -0700 Subject: [PATCH 2/2] fix(scripts): include the Forgejo and testing source control packages in the release smoke workspace Co-Authored-By: Claude Opus 5.5 --- scripts/release-smoke.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index f1c903bc8fe1..6482dd28f295 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -38,6 +38,8 @@ const workspaceFiles = [ "packages/provider-muse/package.json", "packages/provider-opencode/package.json", "packages/source-control-core/package.json", + "packages/source-control-forgejo/package.json", + "packages/source-control-testing/package.json", "packages/effect-acp/package.json", "packages/effect-codex-app-server/package.json", "scripts/package.json",