From 8c8a3c9768a91da2649adaf88a5979e7a8f528da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 18:22:01 -0700 Subject: [PATCH] Migrate server source control Effect services Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 24 +- apps/server/src/git/GitManager.ts | 139 ++-- .../server/src/git/GitWorkflowService.test.ts | 4 +- apps/server/src/git/GitWorkflowService.ts | 130 ++-- .../Layers/ProviderCommandReactor.test.ts | 6 +- .../src/sourceControl/AzureDevOpsCli.test.ts | 2 +- .../src/sourceControl/AzureDevOpsCli.ts | 140 ++-- .../AzureDevOpsSourceControlProvider.test.ts | 9 +- .../AzureDevOpsSourceControlProvider.ts | 26 +- .../src/sourceControl/BitbucketApi.test.ts | 32 +- apps/server/src/sourceControl/BitbucketApi.ts | 140 ++-- .../BitbucketSourceControlProvider.test.ts | 12 +- .../BitbucketSourceControlProvider.ts | 16 +- .../src/sourceControl/GitHubCli.test.ts | 2 +- apps/server/src/sourceControl/GitHubCli.ts | 121 ++-- .../GitHubSourceControlProvider.test.ts | 7 +- .../GitHubSourceControlProvider.ts | 40 +- .../src/sourceControl/GitLabCli.test.ts | 2 +- apps/server/src/sourceControl/GitLabCli.ts | 129 ++-- .../GitLabSourceControlProvider.test.ts | 9 +- .../GitLabSourceControlProvider.ts | 49 +- .../SourceControlDiscovery.test.ts | 24 +- .../sourceControl/SourceControlDiscovery.ts | 147 ++-- .../sourceControl/SourceControlProvider.ts | 94 ++- .../SourceControlProviderDiscovery.ts | 6 +- .../SourceControlProviderRegistry.test.ts | 18 +- .../SourceControlProviderRegistry.ts | 74 +- .../SourceControlRepositoryService.test.ts | 10 +- .../SourceControlRepositoryService.ts | 28 +- apps/server/src/vcs/GitVcsDriver.test.ts | 2 +- apps/server/src/vcs/GitVcsDriver.ts | 185 ++--- apps/server/src/vcs/GitVcsDriverCore.ts | 252 +++---- apps/server/src/vcs/VcsDriver.ts | 49 +- apps/server/src/vcs/VcsDriverRegistry.test.ts | 4 +- apps/server/src/vcs/VcsDriverRegistry.ts | 39 +- apps/server/src/vcs/VcsProcess.ts | 23 +- apps/server/src/vcs/VcsProjectConfig.ts | 23 +- .../src/vcs/VcsProvisioningService.test.ts | 2 +- apps/server/src/vcs/VcsProvisioningService.ts | 14 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 4 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 631 +++++++++--------- apps/server/src/ws.ts | 6 +- 42 files changed, 1336 insertions(+), 1338 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index cc7340f965a..165c351b36c 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -376,7 +376,7 @@ function createTextGeneration( } function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubCli.GitHubCliShape; + service: GitHubCli.GitHubCli["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -388,7 +388,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ); const ghCalls: string[] = []; - const execute: GitHubCli.GitHubCliShape["execute"] = (input) => { + const execute: GitHubCli.GitHubCli["Service"]["execute"] = (input) => { const args = [...input.args]; ghCalls.push(args.join(" ")); @@ -609,7 +609,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } function runStackedAction( - manager: GitManager.GitManagerShape, + manager: GitManager.GitManager["Service"], input: { cwd: string; action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; @@ -618,7 +618,7 @@ function runStackedAction( featureBranch?: boolean; filePaths?: readonly string[]; }, - options?: Parameters[1], + options?: Parameters[1], ) { return manager.runStackedAction( { @@ -630,14 +630,14 @@ function runStackedAction( } function resolvePullRequest( - manager: GitManager.GitManagerShape, + manager: GitManager.GitManager["Service"], input: { cwd: string; reference: string }, ) { return manager.resolvePullRequest(input); } function preparePullRequestThread( - manager: GitManager.GitManagerShape, + manager: GitManager.GitManager["Service"], input: GitPreparePullRequestThreadInput, ) { return manager.preparePullRequestThread(input); @@ -646,11 +646,11 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; - setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunnerShape; + setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); - const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-", }); @@ -663,7 +663,7 @@ function makeManager(input?: { ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - GitHubSourceControlProvider.make().pipe( + GitHubSourceControlProvider.make.pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ get: () => Effect.succeed(provider), @@ -688,7 +688,7 @@ function makeManager(input?: { serverSettingsLayer, ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); - return GitManager.makeGitManager().pipe( + return GitManager.make.pipe( Effect.provide(managerLayer), Effect.map((manager) => ({ manager, ghCalls })), ); @@ -697,9 +697,7 @@ function makeManager(input?: { const asThreadId = (threadId: string) => threadId as ThreadId; const GitManagerTestLayer = GitVcsDriver.layer.pipe( - Layer.provide( - ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" }), - ), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 94f3ee5b435..9938c40cffb 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -42,17 +42,13 @@ import { } from "@t3tools/shared/sourceControl"; import { GitManagerError } from "@t3tools/contracts"; -import { TextGeneration } from "../textGeneration/TextGeneration.ts"; -import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; -import { ServerSettingsService } from "../serverSettings.ts"; +import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; -import { - GitVcsDriver, - type GitRemoteStatusOptions, - type GitStatusDetails, -} from "../vcs/GitVcsDriver.ts"; -import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import type { ChangeRequest } from "@t3tools/contracts"; export interface GitActionProgressReporter { @@ -64,35 +60,34 @@ export interface GitRunStackedActionOptions { readonly progressReporter?: GitActionProgressReporter; } -export interface GitManagerShape { - readonly status: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly localStatus: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly remoteStatus: ( - input: VcsStatusInput, - options?: GitRemoteStatusOptions, - ) => Effect.Effect; - readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; - readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; - readonly invalidateStatus: (cwd: string) => Effect.Effect; - readonly resolvePullRequest: ( - input: GitPullRequestRefInput, - ) => Effect.Effect; - readonly preparePullRequestThread: ( - input: GitPreparePullRequestThreadInput, - ) => Effect.Effect; - readonly runStackedAction: ( - input: GitRunStackedActionInput, - options?: GitRunStackedActionOptions, - ) => Effect.Effect; -} - -export class GitManager extends Context.Service()( - "t3/git/GitManager", -) {} +export class GitManager extends Context.Service< + GitManager, + { + readonly status: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly localStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly remoteStatus: ( + input: VcsStatusInput, + options?: GitVcsDriver.GitRemoteStatusOptions, + ) => Effect.Effect; + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + readonly invalidateStatus: (cwd: string) => Effect.Effect; + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: GitRunStackedActionOptions, + ) => Effect.Effect; + } +>()("t3/git/GitManager") {} const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -531,15 +526,15 @@ function toPullRequestHeadRemoteInfo(pr: { }; } -export const makeGitManager = Effect.fn("makeGitManager")(function* () { - const gitCore = yield* GitVcsDriver; - const sourceControlProviders = yield* SourceControlProviderRegistry; - const textGeneration = yield* TextGeneration; - const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; +export const make = Effect.gen(function* () { + const gitCore = yield* GitVcsDriver.GitVcsDriver; + const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const textGeneration = yield* TextGeneration.TextGeneration; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const crypto = yield* Crypto.Crypto; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); - const serverSettingsService = yield* ServerSettingsService; + const serverSettingsService = yield* ServerSettings.ServerSettingsService; const randomUUIDv4 = crypto.randomUUIDv4.pipe( Effect.mapError((cause) => gitManagerError("randomUUIDv4", "Failed to generate Git operation identifier.", cause), @@ -721,7 +716,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { aheadCount: 0, behindCount: 0, aheadOfDefaultCount: 0, - } satisfies GitStatusDetails; + } satisfies GitVcsDriver.GitStatusDetails; const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { const details = yield* gitCore .statusDetailsLocal(cwd) @@ -752,7 +747,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( cwd: string, - options?: GitRemoteStatusOptions, + options?: GitVcsDriver.GitRemoteStatusOptions, ) { const details = yield* gitCore .statusDetailsRemote(cwd, options) @@ -1358,11 +1353,13 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; }); - const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { - const cacheKey = yield* normalizeStatusCacheKey(input.cwd); - return yield* Cache.get(localStatusResultCache, cacheKey); - }); - const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( + const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")( + function* (input) { + const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + return yield* Cache.get(localStatusResultCache, cacheKey); + }, + ); + const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")( function* (input, options) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); if (options?.refreshUpstream === false) { @@ -1371,43 +1368,43 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return yield* Cache.get(remoteStatusResultCache, cacheKey); }, ); - const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { + const status: GitManager["Service"]["status"] = Effect.fn("status")(function* (input) { const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)], { concurrency: "unbounded", }); return mergeGitStatusParts(local, remote); }); - const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( + const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); }); - const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( + const invalidateRemoteStatus: GitManager["Service"]["invalidateRemoteStatus"] = Effect.fn( "invalidateRemoteStatus", )(function* (cwd) { yield* invalidateRemoteStatusResultCache(cwd); }); - const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( + const invalidateStatus: GitManager["Service"]["invalidateStatus"] = Effect.fn("invalidateStatus")( function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); yield* invalidateRemoteStatusResultCache(cwd); }, ); - const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( - function* (input) { - const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) - .getChangeRequest({ - cwd: input.cwd, - reference: normalizePullRequestReference(input.reference), - }) - .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); + const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn( + "resolvePullRequest", + )(function* (input) { + const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) + .getChangeRequest({ + cwd: input.cwd, + reference: normalizePullRequestReference(input.reference), + }) + .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); - return { pullRequest }; - }, - ); + return { pullRequest }; + }); - const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( + const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { const maybeRunSetupScript = (worktreePath: string) => { @@ -1608,7 +1605,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; }); - const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")( + const runStackedAction: GitManager["Service"]["runStackedAction"] = Effect.fn("runStackedAction")( function* (input, options) { const progress = yield* createProgressEmitter(input, options); const currentPhase = yield* Ref.make>(Option.none()); @@ -1787,7 +1784,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }, ); - return { + return GitManager.of({ localStatus, remoteStatus, status, @@ -1797,7 +1794,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { resolvePullRequest, preparePullRequestThread, runStackedAction, - } satisfies GitManagerShape; + }); }); -export const layer = Layer.effect(GitManager, makeGitManager()); +export const layer = Layer.effect(GitManager, make); diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 9a34680496f..03cd624600d 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -7,7 +7,9 @@ import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistryShape["detect"] }) { +function makeLayer(input: { + readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; +}) { return GitWorkflowService.layer.pipe( Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 0af4847f4ac..f958b663006 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -28,68 +28,70 @@ import { type VcsStatusResult, } from "@t3tools/contracts"; -import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; -import { GitVcsDriver, type GitRemoteStatusOptions } from "../vcs/GitVcsDriver.ts"; -import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; - -export interface GitWorkflowServiceShape { - readonly status: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly localStatus: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly remoteStatus: ( - input: VcsStatusInput, - options?: GitRemoteStatusOptions, - ) => Effect.Effect; - readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; - readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; - readonly invalidateStatus: (cwd: string) => Effect.Effect; - readonly pullCurrentBranch: (cwd: string) => Effect.Effect; - readonly runStackedAction: ( - input: GitRunStackedActionInput, - options?: GitRunStackedActionOptions, - ) => Effect.Effect; - readonly resolvePullRequest: ( - input: GitPullRequestRefInput, - ) => Effect.Effect; - readonly preparePullRequestThread: ( - input: GitPreparePullRequestThreadInput, - ) => Effect.Effect; - readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; - readonly createWorktree: ( - input: VcsCreateWorktreeInput, - ) => Effect.Effect; - readonly fetchRemote: (input: { - readonly cwd: string; - readonly remoteName: string; - }) => Effect.Effect; - readonly resolveRemoteTrackingCommit: (input: { - readonly cwd: string; - readonly refName: string; - readonly fallbackRemoteName: string; - }) => Effect.Effect< - { readonly commitSha: string; readonly remoteRefName: string }, - GitCommandError - >; - readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; - readonly createRef: ( - input: VcsCreateRefInput, - ) => Effect.Effect; - readonly switchRef: ( - input: VcsSwitchRefInput, - ) => Effect.Effect; - readonly renameBranch: (input: { - readonly cwd: string; - readonly oldBranch: string; - readonly newBranch: string; - }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>; -} +import * as GitManager from "./GitManager.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; export class GitWorkflowService extends Context.Service< GitWorkflowService, - GitWorkflowServiceShape + { + readonly status: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly localStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly remoteStatus: ( + input: VcsStatusInput, + options?: GitVcsDriver.GitRemoteStatusOptions, + ) => Effect.Effect; + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + readonly invalidateStatus: (cwd: string) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: GitManager.GitRunStackedActionOptions, + ) => Effect.Effect; + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + readonly listRefs: ( + input: VcsListRefsInput, + ) => Effect.Effect; + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + ) => Effect.Effect; + readonly fetchRemote: (input: { + readonly cwd: string; + readonly remoteName: string; + }) => Effect.Effect; + readonly resolveRemoteTrackingCommit: (input: { + readonly cwd: string; + readonly refName: string; + readonly fallbackRemoteName: string; + }) => Effect.Effect< + { readonly commitSha: string; readonly remoteRefName: string }, + GitCommandError + >; + readonly removeWorktree: ( + input: VcsRemoveWorktreeInput, + ) => Effect.Effect; + readonly createRef: ( + input: VcsCreateRefInput, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; + readonly renameBranch: (input: { + readonly cwd: string; + readonly oldBranch: string; + readonly newBranch: string; + }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>; + } >()("t3/git/GitWorkflowService") {} const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) => @@ -142,10 +144,10 @@ function nonRepositoryListRefs(): VcsListRefsResult { }; } -export const make = Effect.fn("makeGitWorkflowService")(function* () { - const registry = yield* VcsDriverRegistry; - const git = yield* GitVcsDriver; - const gitManager = yield* GitManager; +export const make = Effect.gen(function* () { + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + const git = yield* GitVcsDriver.GitVcsDriver; + const gitManager = yield* GitManager.GitManager; const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( operation: string, @@ -334,4 +336,4 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { }); }); -export const layer = Layer.effect(GitWorkflowService, make()); +export const layer = Layer.effect(GitWorkflowService, make); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a08da26ba59..0e399f03ab8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -59,7 +59,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Clock from "effect/Clock"; import { ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; -import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitWorkflowService.ts"; +import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); @@ -348,9 +348,9 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)), Layer.provideMerge( - Layer.mock(GitWorkflowService)({ + Layer.mock(GitWorkflowService.GitWorkflowService)({ renameBranch, - } satisfies Partial), + } satisfies Partial), ), Layer.provideMerge( Layer.succeed(VcsStatusBroadcaster, { diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index f3078fcd06c..52aedd1d760 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -17,7 +17,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); -const mockRun = vi.fn(); +const mockRun = vi.fn(); const supportLayer = Layer.mergeAll( Layer.mock(VcsProcess.VcsProcess)({ diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index e39ce9f0100..442cae68934 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -11,7 +11,12 @@ import { } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts"; +import { + decodeAzureDevOpsPullRequestJson, + decodeAzureDevOpsPullRequestListJson, + formatAzureDevOpsJsonDecodeError, + type NormalizedAzureDevOpsPullRequestRecord, +} from "./azureDevOpsPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -35,67 +40,60 @@ export interface AzureDevOpsRepositoryCloneUrls { readonly sshUrl: string; } -export interface AzureDevOpsCliShape { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - readonly listPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect< - ReadonlyArray, - AzureDevOpsCliError - >; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect< - AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord, - AzureDevOpsCliError - >; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly remoteName?: string; - }) => Effect.Effect; -} - -export class AzureDevOpsCli extends Context.Service()( - "t3/sourceControl/AzureDevOpsCli", -) {} +export class AzureDevOpsCli extends Context.Service< + AzureDevOpsCli, + { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, AzureDevOpsCliError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly remoteName?: string; + }) => Effect.Effect; + } +>()("t3/sourceControl/AzureDevOpsCli") {} function errorText(error: VcsError | unknown): string { if (typeof error === "object" && error !== null) { @@ -239,10 +237,10 @@ function decodeAzureDevOpsJson( ); } -export const make = Effect.fn("makeAzureDevOpsCli")(function* () { +export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: AzureDevOpsCliShape["execute"] = (input) => + const execute: AzureDevOpsCli["Service"]["execute"] = (input) => process .run({ operation: "AzureDevOpsCli.execute", @@ -253,7 +251,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }) .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); - const executeJson = (input: Parameters[0]) => + const executeJson = (input: Parameters[0]) => execute({ ...input, args: [...input.args, "--only-show-errors", "--output", "json"], @@ -282,15 +280,13 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => - AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestListJson(raw), - ).pipe( + : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new AzureDevOpsCliError({ operation: "listPullRequests", - detail: `Azure DevOps CLI returned invalid PR list JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + detail: `Azure DevOps CLI returned invalid PR list JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -316,13 +312,13 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestJson(raw)).pipe( + Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new AzureDevOpsCliError({ operation: "getPullRequest", - detail: `Azure DevOps CLI returned invalid pull request JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + detail: `Azure DevOps CLI returned invalid pull request JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -434,4 +430,4 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }); }); -export const layer = Layer.effect(AzureDevOpsCli, make()); +export const layer = Layer.effect(AzureDevOpsCli, make); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 4ba3777159b..f007ecf7985 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -6,8 +6,8 @@ import * as Option from "effect/Option"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; -function makeProvider(azure: Partial) { - return AzureDevOpsSourceControlProvider.make().pipe( +function makeProvider(azure: Partial) { + return AzureDevOpsSourceControlProvider.make.pipe( Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), ); } @@ -48,8 +48,9 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" it.effect("creates Azure DevOps PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 8d8e081cb89..8cd5bd7522d 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -4,7 +4,13 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { + combinedAuthOutput, + firstSafeAuthLine, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; function providerError( operation: string, @@ -18,28 +24,26 @@ function providerError( }); } -function parseAzureAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { +function parseAzureAuth(input: SourceControlAuthProbeInput) { const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); if (input.exitCode !== 0) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unauthenticated", detail: - SourceControlProviderDiscovery.firstSafeAuthLine( - SourceControlProviderDiscovery.combinedAuthOutput(input), - ) ?? "Run `az login` to authenticate Azure CLI.", + firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", }); } if (account !== undefined && account.length > 0) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "authenticated", account, host: "dev.azure.com", }); } - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unknown", host: "dev.azure.com", detail: "Azure CLI account status could not be parsed.", @@ -56,7 +60,7 @@ export const discovery = { parseAuth: parseAzureAuth, installHint: "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", -} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; +} satisfies SourceControlCliDiscoverySpec; function toChangeRequest(summary: { readonly number: number; @@ -80,7 +84,7 @@ function toChangeRequest(summary: { }; } -export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () { +export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; return SourceControlProvider.SourceControlProvider.of({ @@ -142,4 +146,4 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index e93362b8423..5041fe6635b 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -53,41 +53,41 @@ const repositoryJson = { function makeLayer(input: { readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; - readonly git?: Partial; + readonly git?: Partial; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), ); const gitMock = { - readConfigValue: vi.fn(() => + readConfigValue: vi.fn(() => Effect.succeed("git@bitbucket.org:pingdotgg/t3code.git"), ), - resolvePrimaryRemoteName: vi.fn( - () => Effect.succeed("origin"), - ), - ensureRemote: vi.fn(() => + resolvePrimaryRemoteName: vi.fn< + GitVcsDriver.GitVcsDriver["Service"]["resolvePrimaryRemoteName"] + >(() => Effect.succeed("origin")), + ensureRemote: vi.fn(() => Effect.succeed("octocat"), ), - fetchRemoteBranch: vi.fn( - () => Effect.void, - ), - fetchRemoteTrackingBranch: vi.fn( + fetchRemoteBranch: vi.fn( () => Effect.void, ), - setBranchUpstream: vi.fn( + fetchRemoteTrackingBranch: vi.fn< + GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] + >(() => Effect.void), + setBranchUpstream: vi.fn( () => Effect.void, ), - switchRef: vi.fn((request) => + switchRef: vi.fn((request) => Effect.succeed({ refName: request.refName }), ), - listLocalBranchNames: vi.fn(() => + listLocalBranchNames: vi.fn(() => Effect.succeed([]), ), }; const git = { ...gitMock, ...input.git, - } satisfies Partial; + } satisfies Partial; const driver = { listRemotes: () => @@ -106,7 +106,7 @@ function makeLayer(input: { expiresAt: Option.none(), }, }), - } satisfies Partial; + } satisfies Partial; const layer = BitbucketApi.layer.pipe( Layer.provide( @@ -130,7 +130,7 @@ function makeLayer(input: { expiresAt: Option.none(), }, }, - driver: driver as unknown as VcsDriver.VcsDriverShape, + driver: driver as unknown as VcsDriver.VcsDriver["Service"], }), }), ), diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 632778eca24..43a1a705e67 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -15,7 +15,12 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { sanitizeBranchFragment } from "@t3tools/shared/git"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; +import { + BitbucketPullRequestListSchema, + BitbucketPullRequestSchema, + normalizeBitbucketPullRequestRecord, + type NormalizedBitbucketPullRequestRecord, +} from "./bitbucketPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -44,7 +49,7 @@ export class BitbucketApiError extends Schema.TaggedErrorClass; - readonly listPullRequests: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect< - ReadonlyArray, - BitbucketApiError - >; - readonly getPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly reference: string; - }) => Effect.Effect< - BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, - BitbucketApiError - >; - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly repository: string; - }) => Effect.Effect; - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - readonly createPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - }) => Effect.Effect; - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - -export class BitbucketApi extends Context.Service()( - "t3/sourceControl/BitbucketApi", -) {} +export class BitbucketApi extends Context.Service< + BitbucketApi, + { + readonly probeAuth: Effect.Effect; + readonly listPullRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, BitbucketApiError>; + readonly getPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + readonly createPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } +>()("t3/sourceControl/BitbucketApi") {} function nonEmpty(value: string | undefined): Option.Option { const trimmed = value?.trim(); @@ -299,9 +297,7 @@ function checkoutBranchName(input: { } function repositoryNameWithOwner( - repository: Schema.Schema.Type< - typeof BitbucketPullRequests.BitbucketPullRequestSchema - >["source"]["repository"], + repository: Schema.Schema.Type["source"]["repository"], ): string | null { const fullName = repository?.full_name?.trim() ?? ""; return fullName.length > 0 ? fullName : null; @@ -350,10 +346,6 @@ function requestError(operation: string, cause: unknown): BitbucketApiError { }); } -function isBitbucketApiError(cause: unknown): cause is BitbucketApiError { - return isBitbucketApiErrorValue(cause); -} - function responseError( operation: string, response: HttpClientResponse.HttpClientResponse, @@ -375,7 +367,7 @@ function responseError( ); } -export const make = Effect.fn("makeBitbucketApi")(function* () { +export const make = Effect.gen(function* () { const config = yield* BitbucketApiEnvConfig; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; @@ -511,7 +503,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, ), ), - BitbucketPullRequests.BitbucketPullRequestSchema, + BitbucketPullRequestSchema, ); const getRawPullRequest = (input: { @@ -599,17 +591,13 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { ), { urlParams: query }, ), - BitbucketPullRequests.BitbucketPullRequestListSchema, + BitbucketPullRequestListSchema, ); }), - Effect.map((list) => - list.values.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), - ), + Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)), ), getPullRequest: (input) => - getRawPullRequest(input).pipe( - Effect.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), - ), + getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)), getRepositoryCloneUrls: (input) => getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), createRepository: (input) => @@ -675,7 +663,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, ), ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), - BitbucketPullRequests.BitbucketPullRequestSchema, + BitbucketPullRequestSchema, ); }), getDefaultBranch: (input) => @@ -766,4 +754,4 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { }); }); -export const layer = Layer.effect(BitbucketApi, make()); +export const layer = Layer.effect(BitbucketApi, make); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index 07a3d386a35..8530e163dc6 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -6,8 +6,8 @@ import * as Option from "effect/Option"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; -function makeProvider(bitbucket: Partial) { - return BitbucketSourceControlProvider.make().pipe( +function makeProvider(bitbucket: Partial) { + return BitbucketSourceControlProvider.make.pipe( Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), ); } @@ -53,7 +53,8 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( it.effect("lists Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { - let listInput: Parameters[0] | null = null; + let listInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ listPullRequests: (input) => { listInput = input; @@ -79,8 +80,9 @@ it.effect("lists Bitbucket PRs through provider-neutral input names", () => it.effect("creates Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index f3fd502f7fb..6c1d67434bf 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -4,9 +4,9 @@ import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; import * as BitbucketApi from "./BitbucketApi.ts"; -import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; +import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import type * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts"; function providerError( operation: string, @@ -20,9 +20,7 @@ function providerError( }); } -function toChangeRequest( - summary: BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, -): ChangeRequest { +function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { return { provider: "bitbucket", number: summary.number, @@ -44,7 +42,7 @@ function toChangeRequest( }; } -export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () { +export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; return SourceControlProvider.SourceControlProvider.of({ @@ -112,9 +110,9 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); -export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscovery")(function* () { +export const makeDiscovery = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; return { @@ -124,5 +122,5 @@ export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscov installHint: "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request and repository scopes).", probeAuth: bitbucket.probeAuth, - } satisfies SourceControlProviderDiscovery.SourceControlApiDiscoverySpec; + } satisfies SourceControlApiDiscoverySpec; }); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index fb765b352c2..e0e781bd8b5 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -15,7 +15,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); -const mockRun = vi.fn(); +const mockRun = vi.fn(); const layer = GitHubCli.layer.pipe( Layer.provide( diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index d6c858c28bd..836c7e1eb74 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -12,7 +12,11 @@ import { } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubPullRequests from "./gitHubPullRequests.ts"; +import { + decodeGitHubPullRequestJson, + decodeGitHubPullRequestListJson, + formatGitHubJsonDecodeError, +} from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -44,57 +48,56 @@ export interface GitHubRepositoryCloneUrls { readonly sshUrl: string; } -export interface GitHubCliShape { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - readonly listOpenPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly limit?: number; - }) => Effect.Effect, GitHubCliError>; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - -export class GitHubCli extends Context.Service()( - "t3/sourceControl/GitHubCli", -) {} +export class GitHubCli extends Context.Service< + GitHubCli, + { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listOpenPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly limit?: number; + }) => Effect.Effect, GitHubCliError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } +>()("t3/sourceControl/GitHubCli") {} function errorText(error: VcsError | unknown): string { if (typeof error === "object" && error !== null) { @@ -226,10 +229,10 @@ function decodeGitHubJson( ); } -export const make = Effect.fn("makeGitHubCli")(function* () { +export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: GitHubCliShape["execute"] = (input) => + const execute: GitHubCli["Service"]["execute"] = (input) => process .run({ operation: "GitHubCli.execute", @@ -262,13 +265,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( + : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitHubCliError({ operation: "listOpenPullRequests", - detail: `GitHub CLI returned invalid PR list JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, + detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -294,13 +297,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestJson(raw)).pipe( + Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitHubCliError({ operation: "getPullRequest", - detail: `GitHub CLI returned invalid pull request JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, + detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -372,4 +375,4 @@ export const make = Effect.fn("makeGitHubCli")(function* () { }); }); -export const layer = Layer.effect(GitHubCli, make()); +export const layer = Layer.effect(GitHubCli, make); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 32fd1a91ce3..141672c91c5 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -24,8 +24,8 @@ const processResult = ( stderrTruncated: false, }); -function makeProvider(github: Partial) { - return GitHubSourceControlProvider.make().pipe( +function makeProvider(github: Partial) { + return GitHubSourceControlProvider.make.pipe( Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), ); } @@ -139,7 +139,8 @@ it.effect("treats empty non-open change request listing output as no results", ( it.effect("creates GitHub PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 41329b97f75..b84d2504f93 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -11,9 +11,15 @@ import { import * as GitHubCli from "./GitHubCli.ts"; import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; -import * as GitHubPullRequests from "./gitHubPullRequests.ts"; +import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { + combinedAuthOutput, + firstSafeAuthLine, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; const isSourceControlProviderError = Schema.is(SourceControlProviderError); function providerError( @@ -50,14 +56,14 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq }; } -function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { - const output = SourceControlProviderDiscovery.combinedAuthOutput(input); +function parseGitHubAuth(input: SourceControlAuthProbeInput) { + const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts); const host = authenticatedAccount?.host; if (authenticatedAccount) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "authenticated", account: authenticatedAccount.account, host, @@ -66,7 +72,7 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; if (authStatus.parsed) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unauthenticated", host: failedAccount?.host, detail: @@ -76,21 +82,17 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth } if (input.exitCode !== 0) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unauthenticated", host, - detail: - SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? - "Run `gh auth login` to authenticate GitHub CLI.", + detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", }); } - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unknown", host, - detail: - SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? - "GitHub CLI auth status could not be parsed.", + detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", }); } @@ -104,12 +106,12 @@ export const discovery = { parseAuth: parseGitHubAuth, installHint: "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", -} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; +} satisfies SourceControlCliDiscoverySpec; -export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { +export const make = Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; - const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] = + const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = (input) => { if (input.state === "open") { return github @@ -147,7 +149,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { if (raw.length === 0) { return Effect.succeed([]); } - return Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( + return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => Result.isSuccess(decoded) ? Effect.succeed( @@ -212,4 +214,4 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index c075027151a..f7c3b3e4bf0 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -8,7 +8,7 @@ import { VcsProcessExitError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitLabCli from "./GitLabCli.ts"; -const mockedRun = vi.fn(); +const mockedRun = vi.fn(); const layer = it.layer( GitLabCli.layer.pipe( Layer.provide( diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index bd430d9d01a..c5fd7ee52f0 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -10,7 +10,11 @@ import type * as DateTime from "effect/DateTime"; import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitLabMergeRequests from "./gitLabMergeRequests.ts"; +import { + decodeGitLabMergeRequestJson, + decodeGitLabMergeRequestListJson, + formatGitLabJsonDecodeError, +} from "./gitLabMergeRequests.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -44,61 +48,60 @@ export interface GitLabRepositoryCloneUrls { readonly sshUrl: string; } -export interface GitLabCliShape { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - readonly listMergeRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect, GitLabCliError>; - - readonly getMergeRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createMergeRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly checkoutMergeRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - -export class GitLabCli extends Context.Service()( - "t3/sourceControl/GitLabCli", -) {} +export class GitLabCli extends Context.Service< + GitLabCli, + { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listMergeRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, GitLabCliError>; + + readonly getMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createMergeRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } +>()("t3/sourceControl/GitLabCli") {} function isVcsProcessSpawnError(error: unknown): boolean { return ( @@ -259,10 +262,10 @@ function parseRepositoryPath(repository: string): { return { namespacePath, projectPath }; } -export const make = Effect.fn("makeGitLabCli")(function* () { +export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: GitLabCliShape["execute"] = (input) => + const execute: GitLabCli["Service"]["execute"] = (input) => process .run({ operation: "GitLabCli.execute", @@ -294,13 +297,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestListJson(raw)).pipe( + : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitLabCliError({ operation: "listMergeRequests", - detail: `GitLab CLI returned invalid MR list JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, + detail: `GitLab CLI returned invalid MR list JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -318,13 +321,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestJson(raw)).pipe( + Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitLabCliError({ operation: "getMergeRequest", - detail: `GitLab CLI returned invalid merge request JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, + detail: `GitLab CLI returned invalid merge request JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -449,4 +452,4 @@ export const make = Effect.fn("makeGitLabCli")(function* () { }); }); -export const layer = Layer.effect(GitLabCli, make()); +export const layer = Layer.effect(GitLabCli, make); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 842cf4a17cf..3dc61e132f3 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -8,8 +8,8 @@ import * as GitLabCli from "./GitLabCli.ts"; import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; -function makeProvider(gitlab: Partial) { - return GitLabSourceControlProvider.make().pipe( +function makeProvider(gitlab: Partial) { + return GitLabSourceControlProvider.make.pipe( Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), ); } @@ -54,7 +54,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = it.effect("lists GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { - let listInput: Parameters[0] | null = null; + let listInput: Parameters[0] | null = null; const provider = yield* makeProvider({ listMergeRequests: (input) => { listInput = input; @@ -80,7 +80,8 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => it.effect("creates GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ createMergeRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 77f41600e0f..d1aaf06309d 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -5,7 +5,16 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac import * as GitLabCli from "./GitLabCli.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { + combinedAuthOutput, + firstSafeAuthLine, + matchFirst, + parseCliHost, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, + type SourceControlUnknownRemoteRefinementInput, +} from "./SourceControlProviderDiscovery.ts"; import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; function providerError( @@ -42,48 +51,42 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe }; } -function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { - const output = SourceControlProviderDiscovery.combinedAuthOutput(input); +function parseGitLabAuth(input: SourceControlAuthProbeInput) { + const output = combinedAuthOutput(input); const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output)); const account = authenticatedHost?.account ?? - SourceControlProviderDiscovery.matchFirst(output, [ + matchFirst(output, [ /Logged in to .* as\s+([^\s(]+)/iu, /Logged in to .* account\s+([^\s(]+)/iu, /account:\s*([^\s(]+)/iu, ]); - const host = authenticatedHost?.host ?? SourceControlProviderDiscovery.parseCliHost(output); + const host = authenticatedHost?.host ?? parseCliHost(output); if (account) { - return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); + return providerAuth({ status: "authenticated", account, host }); } if (input.exitCode !== 0) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unauthenticated", host, - detail: - SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? - "Run `glab auth login` to authenticate GitLab CLI.", + detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", }); } - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unknown", host, - detail: - SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? - "GitLab CLI auth status could not be parsed.", + detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", }); } -function refineUnknownGitLabRemote( - input: SourceControlProviderDiscovery.SourceControlUnknownRemoteRefinementInput, -) { +function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementInput) { const host = input.context.provider.name.toLowerCase(); - const authenticated = parseGitLabAuthStatusHosts( - SourceControlProviderDiscovery.combinedAuthOutput(input.auth), - ).some((entry) => entry.account !== null && entry.host === host); + const authenticated = parseGitLabAuthStatusHosts(combinedAuthOutput(input.auth)).some( + (entry) => entry.account !== null && entry.host === host, + ); if (!authenticated) { return null; @@ -107,9 +110,9 @@ export const discovery = { refineUnknownRemote: refineUnknownGitLabRemote, installHint: "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).", -} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; +} satisfies SourceControlCliDiscoverySpec; -export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { +export const make = Effect.gen(function* () { const gitlab = yield* GitLabCli.GitLabCli; return SourceControlProvider.SourceControlProvider.of({ @@ -167,4 +170,4 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index f65710c4c9c..9e4702af04c 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessSpawnError } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -17,15 +17,15 @@ import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const sourceControlProviderRegistryTestLayer = (input: { - readonly bitbucket: Partial; - readonly process: Partial; + readonly bitbucket: Partial; + readonly process: Partial; }) => SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( - Layer.provide(NodeServices.layer), - ), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-registry-test-", + }).pipe(Layer.provide(NodeServices.layer)), Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), Layer.mock(GitHubCli.GitHubCli)({}), @@ -88,10 +88,12 @@ it.effect("reports implemented tools separately from locally available executabl }), ); }, - } satisfies Partial; + } satisfies Partial; const testLayer = SourceControlDiscovery.layer.pipe( Layer.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-discovery-", + }), ), Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), Layer.provide( @@ -215,10 +217,12 @@ Logged in to gitlab.com as gitlab-user }), ); }, - } satisfies Partial; + } satisfies Partial; const testLayer = SourceControlDiscovery.layer.pipe( Layer.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-auth-discovery-", + }), ), Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), Layer.provide( diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index eab46d23560..660f32283e0 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -10,7 +10,7 @@ import * as Option from "effect/Option"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { detailFromCause, firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; interface DiscoveryProbe { @@ -57,91 +57,86 @@ const VCS_PROBES: ReadonlyArray = [ }, ]; -export interface SourceControlDiscoveryShape { - readonly discover: Effect.Effect; -} - export class SourceControlDiscovery extends Context.Service< SourceControlDiscovery, - SourceControlDiscoveryShape + { + readonly discover: Effect.Effect; + } >()("t3/sourceControl/SourceControlDiscovery") {} -export const layer = Layer.effect( - SourceControlDiscovery, - Effect.gen(function* () { - const config = yield* ServerConfig; - const process = yield* VcsProcess.VcsProcess; - const sourceControlProviders = - yield* SourceControlProviderRegistry.SourceControlProviderRegistry; +export const make = Effect.gen(function* () { + const config = yield* ServerConfig; + const process = yield* VcsProcess.VcsProcess; + const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; - const probe = ( - input: DiscoveryProbe & { readonly kind: Kind }, - ): Effect.Effect> => { - const executable = input.executable; - const versionArgs = input.versionArgs; + const probe = ( + input: DiscoveryProbe & { readonly kind: Kind }, + ): Effect.Effect> => { + const executable = input.executable; + const versionArgs = input.versionArgs; - if (!executable || !versionArgs) { - return Effect.succeed({ - kind: input.kind, - label: input.label, - implemented: input.implemented, - status: "missing" as const, - version: Option.none(), - installHint: input.installHint, - detail: Option.some(input.installHint), - } satisfies DiscoveryProbeResult); - } + if (!executable || !versionArgs) { + return Effect.succeed({ + kind: input.kind, + label: input.label, + implemented: input.implemented, + status: "missing" as const, + version: Option.none(), + installHint: input.installHint, + detail: Option.some(input.installHint), + } satisfies DiscoveryProbeResult); + } - return process - .run({ - operation: "source-control.discovery.probe", - command: executable, - args: versionArgs, - cwd: config.cwd, - timeoutMs: 5_000, - maxOutputBytes: 8_000, - appendTruncationMarker: true, - }) - .pipe( - Effect.map( - (result) => - ({ - kind: input.kind, - label: input.label, - executable, - implemented: input.implemented, - status: "available" as const, - version: Option.orElse( - SourceControlProviderDiscovery.firstNonEmptyLine(result.stdout), - () => SourceControlProviderDiscovery.firstNonEmptyLine(result.stderr), - ), - installHint: input.installHint, - detail: Option.none(), - }) satisfies DiscoveryProbeResult, - ), - Effect.catch((cause) => - Effect.succeed({ + return process + .run({ + operation: "source-control.discovery.probe", + command: executable, + args: versionArgs, + cwd: config.cwd, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.map( + (result) => + ({ kind: input.kind, label: input.label, executable, implemented: input.implemented, - status: "missing" as const, - version: Option.none(), + status: "available" as const, + version: Option.orElse(firstNonEmptyLine(result.stdout), () => + firstNonEmptyLine(result.stderr), + ), installHint: input.installHint, - detail: SourceControlProviderDiscovery.detailFromCause(cause), - } satisfies DiscoveryProbeResult), - ), - ); - }; - - return SourceControlDiscovery.of({ - discover: Effect.all({ - versionControlSystems: Effect.all( - VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, - { concurrency: "unbounded" }, + detail: Option.none(), + }) satisfies DiscoveryProbeResult, + ), + Effect.catch((cause) => + Effect.succeed({ + kind: input.kind, + label: input.label, + executable, + implemented: input.implemented, + status: "missing" as const, + version: Option.none(), + installHint: input.installHint, + detail: detailFromCause(cause), + } satisfies DiscoveryProbeResult), ), - sourceControlProviders: sourceControlProviders.discover, - }), - }); - }), -); + ); + }; + + return SourceControlDiscovery.of({ + discover: Effect.all({ + versionControlSystems: Effect.all( + VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, + { concurrency: "unbounded" }, + ), + sourceControlProviders: sourceControlProviders.discover, + }), + }); +}); + +export const layer = Layer.effect(SourceControlDiscovery, make); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index f0602f03d14..c2959ef878e 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -49,54 +49,52 @@ export function sourceControlRefFromInput(input: { return input.source ?? parseSourceControlOwnerRef(input.headSelector); } -export interface SourceControlProviderShape { - readonly kind: SourceControlProviderKind; - readonly listChangeRequests: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly source?: SourceControlRefSelector; - readonly headSelector: string; - readonly state: ChangeRequestState | "all"; - readonly limit?: number; - }) => Effect.Effect, SourceControlProviderError>; - readonly getChangeRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly reference: string; - }) => Effect.Effect; - readonly createChangeRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly source?: SourceControlRefSelector; - readonly target?: SourceControlRefSelector; - readonly baseRefName: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly repository: string; - }) => Effect.Effect; - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - }) => Effect.Effect; - readonly checkoutChangeRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - export class SourceControlProvider extends Context.Service< SourceControlProvider, - SourceControlProviderShape + { + readonly kind: SourceControlProviderKind; + readonly listChangeRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly headSelector: string; + readonly state: ChangeRequestState | "all"; + readonly limit?: number; + }) => Effect.Effect, SourceControlProviderError>; + readonly getChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; + readonly createChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly target?: SourceControlRefSelector; + readonly baseRefName: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } >()("t3/sourceControl/SourceControlProvider") {} diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index 856d6948e09..e3a6bd1fb20 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -158,7 +158,7 @@ function isCliRemoteRefinementSpec( function probeCli(input: { readonly spec: SourceControlCliDiscoverySpec; - readonly process: VcsProcess.VcsProcessShape; + readonly process: VcsProcess.VcsProcess["Service"]; readonly cwd: string; }): Effect.Effect { return input.process @@ -202,7 +202,7 @@ function probeCli(input: { export function probeSourceControlProvider(input: { readonly spec: SourceControlProviderDiscoverySpec; - readonly process: VcsProcess.VcsProcessShape; + readonly process: VcsProcess.VcsProcess["Service"]; readonly cwd: string; }): Effect.Effect { if (input.spec.type === "api") { @@ -270,7 +270,7 @@ export function probeSourceControlProvider(input: { export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvider")( function* (input: { readonly specs: ReadonlyArray; - readonly process: VcsProcess.VcsProcessShape; + readonly process: VcsProcess.VcsProcess["Service"]; readonly cwd: string; readonly context: SourceControlProvider.SourceControlProviderContext | null; }): Effect.fn.Return { diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 833956ecc7e..6cea2d9a496 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -6,7 +6,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -37,7 +37,7 @@ function makeRegistry(input: { readonly name: string; readonly url: string; }>; - readonly process?: Partial; + readonly process?: Partial; }) { const driver = { listRemotes: () => @@ -53,10 +53,10 @@ function makeRegistry(input: { expiresAt: Option.none(), }, }), - } satisfies Partial; + } satisfies Partial; const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ - get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriverShape), + get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriver["Service"]), resolve: () => Effect.succeed({ kind: "git", @@ -70,7 +70,7 @@ function makeRegistry(input: { expiresAt: Option.none(), }, }, - driver: driver as unknown as VcsDriver.VcsDriverShape, + driver: driver as unknown as VcsDriver.VcsDriver["Service"], }), }); @@ -79,7 +79,7 @@ function makeRegistry(input: { ...input.process, }); - return SourceControlProviderRegistry.make().pipe( + return SourceControlProviderRegistry.make.pipe( Effect.provide( Layer.mergeAll( registryLayer, @@ -88,9 +88,9 @@ function makeRegistry(input: { Layer.mock(BitbucketApi.BitbucketApi)({}), Layer.mock(GitHubCli.GitHubCli)({}), Layer.mock(GitLabCli.GitLabCli)({}), - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( - Layer.provide(NodeServices.layer), - ), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-registry-test-", + }).pipe(Layer.provide(NodeServices.layer)), ), ), ); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 08f794d1f5c..b1f1ea7aae7 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -16,7 +16,11 @@ import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvide import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { + probeSourceControlProvider, + refineUnknownRemoteProvider, + type SourceControlProviderDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; import { ServerConfig } from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -26,36 +30,40 @@ const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); export interface SourceControlProviderRegistration { readonly kind: SourceControlProviderKind; - readonly provider: SourceControlProvider.SourceControlProviderShape; - readonly discovery: SourceControlProviderDiscovery.SourceControlProviderDiscoverySpec; + readonly provider: SourceControlProvider.SourceControlProvider["Service"]; + readonly discovery: SourceControlProviderDiscoverySpec; } export interface SourceControlProviderHandle { - readonly provider: SourceControlProvider.SourceControlProviderShape; + readonly provider: SourceControlProvider.SourceControlProvider["Service"]; readonly context: SourceControlProvider.SourceControlProviderContext | null; } -export interface SourceControlProviderRegistryShape { - readonly get: ( - kind: SourceControlProviderKind, - ) => Effect.Effect; - readonly resolveHandle: (input: { - readonly cwd: string; - }) => Effect.Effect; - readonly resolve: (input: { - readonly cwd: string; - }) => Effect.Effect; - readonly discover: Effect.Effect>; -} - export class SourceControlProviderRegistry extends Context.Service< SourceControlProviderRegistry, - SourceControlProviderRegistryShape + { + readonly get: ( + kind: SourceControlProviderKind, + ) => Effect.Effect< + SourceControlProvider.SourceControlProvider["Service"], + SourceControlProviderError + >; + readonly resolveHandle: (input: { + readonly cwd: string; + }) => Effect.Effect; + readonly resolve: (input: { + readonly cwd: string; + }) => Effect.Effect< + SourceControlProvider.SourceControlProvider["Service"], + SourceControlProviderError + >; + readonly discover: Effect.Effect>; + } >()("t3/sourceControl/SourceControlProviderRegistry") {} function unsupportedProvider( kind: SourceControlProviderKind, -): SourceControlProvider.SourceControlProviderShape { +): SourceControlProvider.SourceControlProvider["Service"] { const unsupported = (operation: string) => Effect.fail( new SourceControlProviderError({ @@ -113,9 +121,9 @@ function selectProviderContext( } function bindProviderContext( - provider: SourceControlProvider.SourceControlProviderShape, + provider: SourceControlProvider.SourceControlProvider["Service"], context: SourceControlProvider.SourceControlProviderContext | null, -): SourceControlProvider.SourceControlProviderShape { +): SourceControlProvider.SourceControlProvider["Service"] { if (context === null) { return provider; } @@ -163,11 +171,11 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const providers = new Map< SourceControlProviderKind, - SourceControlProvider.SourceControlProviderShape + SourceControlProvider.SourceControlProvider["Service"] >(registrations.map((registration) => [registration.kind, registration.provider])); const discoverySpecs = registrations.map((registration) => registration.discovery); - const get: SourceControlProviderRegistryShape["get"] = (kind) => + const get: SourceControlProviderRegistry["Service"]["get"] = (kind) => Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( @@ -180,7 +188,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); const context = selectProviderContext(remotes.remotes); - return yield* SourceControlProviderDiscovery.refineUnknownRemoteProvider({ + return yield* refineUnknownRemoteProvider({ specs: discoverySpecs, process, cwd, @@ -198,7 +206,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero), }); - const resolveHandle: SourceControlProviderRegistryShape["resolveHandle"] = (input) => + const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) => Cache.get(providerContextCache, input.cwd).pipe( Effect.map((context) => { const kind = context?.provider.kind ?? "unknown"; @@ -216,7 +224,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), discover: Effect.all( discoverySpecs.map((spec) => - SourceControlProviderDiscovery.probeSourceControlProvider({ + probeSourceControlProvider({ spec, process, cwd: config.cwd, @@ -228,12 +236,12 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }, ); -export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { - const github = yield* GitHubSourceControlProvider.make(); - const gitlab = yield* GitLabSourceControlProvider.make(); - const bitbucket = yield* BitbucketSourceControlProvider.make(); - const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery(); - const azureDevOps = yield* AzureDevOpsSourceControlProvider.make(); +export const make = Effect.gen(function* () { + const github = yield* GitHubSourceControlProvider.make; + const gitlab = yield* GitLabSourceControlProvider.make; + const bitbucket = yield* BitbucketSourceControlProvider.make; + const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; + const azureDevOps = yield* AzureDevOpsSourceControlProvider.make; return yield* makeWithProviders([ { kind: "github", @@ -258,4 +266,4 @@ export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () ]); }); -export const layer = Layer.effect(SourceControlProviderRegistry, make()); +export const layer = Layer.effect(SourceControlProviderRegistry, make); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 811b55c70a3..c792480b7fc 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -7,7 +7,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -20,8 +20,8 @@ const CLONE_URLS = { }; function makeProvider( - overrides: Partial = {}, -): SourceControlProvider.SourceControlProviderShape { + overrides: Partial = {}, +): SourceControlProvider.SourceControlProvider["Service"] { const unsupported = (operation: string) => Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect< never, @@ -52,8 +52,8 @@ function processOutput(): GitVcsDriver.ExecuteGitResult { } function makeLayer(input: { - readonly provider?: SourceControlProvider.SourceControlProviderShape; - readonly git?: Partial; + readonly provider?: SourceControlProvider.SourceControlProvider["Service"]; + readonly git?: Partial; }) { return SourceControlRepositoryService.layer.pipe( Layer.provide( diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 106d300ec2d..ff88a4c3146 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -24,21 +24,19 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); -export interface SourceControlRepositoryServiceShape { - readonly lookupRepository: ( - input: SourceControlRepositoryLookupInput, - ) => Effect.Effect; - readonly cloneRepository: ( - input: SourceControlCloneRepositoryInput, - ) => Effect.Effect; - readonly publishRepository: ( - input: SourceControlPublishRepositoryInput, - ) => Effect.Effect; -} - export class SourceControlRepositoryService extends Context.Service< SourceControlRepositoryService, - SourceControlRepositoryServiceShape + { + readonly lookupRepository: ( + input: SourceControlRepositoryLookupInput, + ) => Effect.Effect; + readonly cloneRepository: ( + input: SourceControlCloneRepositoryInput, + ) => Effect.Effect; + readonly publishRepository: ( + input: SourceControlPublishRepositoryInput, + ) => Effect.Effect; + } >()("t3/sourceControl/SourceControlRepositoryService") {} function detailFromUnknown(cause: unknown): string { @@ -116,7 +114,7 @@ function expandHomePath(input: string, path: Path.Path): string { return input; } -export const make = Effect.fn("makeSourceControlRepositoryService")(function* () { +export const make = Effect.gen(function* () { const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const git = yield* GitVcsDriver.GitVcsDriver; @@ -315,4 +313,4 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () }); }); -export const layer = Layer.effect(SourceControlRepositoryService, make()); +export const layer = Layer.effect(SourceControlRepositoryService, make); diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 70bb8655ea1..89f7c55d586 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -8,7 +8,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { assert, it } from "@effect/vitest"; import { GitCommandError } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts"; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index ff0d644901d..e0c19bd3428 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -28,7 +28,7 @@ import { type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; -import * as GitVcsDriverCore from "./GitVcsDriverCore.ts"; +import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -188,81 +188,84 @@ export interface GitRemoteStatusOptions { readonly refreshUpstream?: boolean; } -export interface GitVcsDriverShape { - readonly execute: (input: ExecuteGitInput) => Effect.Effect; - readonly status: (input: VcsStatusInput) => Effect.Effect; - readonly statusDetails: (cwd: string) => Effect.Effect; - readonly statusDetailsLocal: (cwd: string) => Effect.Effect; - readonly statusDetailsRemote: ( - cwd: string, - options?: GitRemoteStatusOptions, - ) => Effect.Effect; - readonly prepareCommitContext: ( - cwd: string, - filePaths?: readonly string[], - ) => Effect.Effect; - readonly commit: ( - cwd: string, - subject: string, - body: string, - options?: GitCommitOptions, - ) => Effect.Effect<{ commitSha: string }, GitCommandError>; - readonly pushCurrentBranch: ( - cwd: string, - fallbackBranch: string | null, - options?: { readonly remoteName?: string | null }, - ) => Effect.Effect; - readonly readRangeContext: ( - cwd: string, - baseRef: string, - ) => Effect.Effect; - readonly getReviewDiffPreview: ( - input: ReviewDiffPreviewInput, - ) => Effect.Effect; - readonly readConfigValue: ( - cwd: string, - key: string, - ) => Effect.Effect; - readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; - readonly pullCurrentBranch: (cwd: string) => Effect.Effect; - readonly createWorktree: ( - input: VcsCreateWorktreeInput, - ) => Effect.Effect; - readonly fetchPullRequestBranch: ( - input: GitFetchPullRequestBranchInput, - ) => Effect.Effect; - readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; - readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; - readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; - readonly resolveRemoteTrackingCommit: ( - input: GitResolveRemoteTrackingCommitInput, - ) => Effect.Effect; - readonly fetchRemoteBranch: ( - input: GitFetchRemoteBranchInput, - ) => Effect.Effect; - readonly fetchRemoteTrackingBranch: ( - input: GitFetchRemoteTrackingBranchInput, - ) => Effect.Effect; - readonly setBranchUpstream: ( - input: GitSetBranchUpstreamInput, - ) => Effect.Effect; - readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; - readonly renameBranch: ( - input: GitRenameBranchInput, - ) => Effect.Effect; - readonly createRef: ( - input: VcsCreateRefInput, - ) => Effect.Effect; - readonly switchRef: ( - input: VcsSwitchRefInput, - ) => Effect.Effect; - readonly initRepo: (input: VcsInitInput) => Effect.Effect; - readonly listLocalBranchNames: (cwd: string) => Effect.Effect; -} - -export class GitVcsDriver extends Context.Service()( - "t3/vcs/GitVcsDriver", -) {} +export class GitVcsDriver extends Context.Service< + GitVcsDriver, + { + readonly execute: (input: ExecuteGitInput) => Effect.Effect; + readonly status: (input: VcsStatusInput) => Effect.Effect; + readonly statusDetails: (cwd: string) => Effect.Effect; + readonly statusDetailsLocal: (cwd: string) => Effect.Effect; + readonly statusDetailsRemote: ( + cwd: string, + options?: GitRemoteStatusOptions, + ) => Effect.Effect; + readonly prepareCommitContext: ( + cwd: string, + filePaths?: readonly string[], + ) => Effect.Effect; + readonly commit: ( + cwd: string, + subject: string, + body: string, + options?: GitCommitOptions, + ) => Effect.Effect<{ commitSha: string }, GitCommandError>; + readonly pushCurrentBranch: ( + cwd: string, + fallbackBranch: string | null, + options?: { readonly remoteName?: string | null }, + ) => Effect.Effect; + readonly readRangeContext: ( + cwd: string, + baseRef: string, + ) => Effect.Effect; + readonly getReviewDiffPreview: ( + input: ReviewDiffPreviewInput, + ) => Effect.Effect; + readonly readConfigValue: ( + cwd: string, + key: string, + ) => Effect.Effect; + readonly listRefs: ( + input: VcsListRefsInput, + ) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + ) => Effect.Effect; + readonly fetchPullRequestBranch: ( + input: GitFetchPullRequestBranchInput, + ) => Effect.Effect; + readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; + readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; + readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; + readonly resolveRemoteTrackingCommit: ( + input: GitResolveRemoteTrackingCommitInput, + ) => Effect.Effect; + readonly fetchRemoteBranch: ( + input: GitFetchRemoteBranchInput, + ) => Effect.Effect; + readonly fetchRemoteTrackingBranch: ( + input: GitFetchRemoteTrackingBranchInput, + ) => Effect.Effect; + readonly setBranchUpstream: ( + input: GitSetBranchUpstreamInput, + ) => Effect.Effect; + readonly removeWorktree: ( + input: VcsRemoveWorktreeInput, + ) => Effect.Effect; + readonly renameBranch: ( + input: GitRenameBranchInput, + ) => Effect.Effect; + readonly createRef: ( + input: VcsCreateRefInput, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; + readonly initRepo: (input: VcsInitInput) => Effect.Effect; + readonly listLocalBranchNames: (cwd: string) => Effect.Effect; + } +>()("t3/vcs/GitVcsDriver") {} const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; @@ -357,7 +360,7 @@ function parseGitRemoteVerboseOutput( } const gitCommand = ( - process: VcsProcess.VcsProcessShape, + process: VcsProcess.VcsProcess["Service"], operation: string, cwd: string, args: ReadonlyArray, @@ -401,7 +404,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ignoreClassifier: "native" as const, }; - const isInsideWorkTree: VcsDriver.VcsDriverShape["isInsideWorkTree"] = (cwd) => + const isInsideWorkTree: VcsDriver.VcsDriver["Service"]["isInsideWorkTree"] = (cwd) => gitCommand( vcsProcess, "GitVcsDriver.isInsideWorkTree", @@ -414,7 +417,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }, ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); - const execute: VcsDriver.VcsDriverShape["execute"] = (input) => + const execute: VcsDriver.VcsDriver["Service"]["execute"] = (input) => gitCommand(vcsProcess, input.operation, input.cwd, input.args, { ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), ...(input.env !== undefined ? { env: input.env } : {}), @@ -426,7 +429,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( : {}), }); - const detectRepository: VcsDriver.VcsDriverShape["detectRepository"] = Effect.fn( + const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn( "detectRepository", )(function* (cwd) { if (!(yield* isInsideWorkTree(cwd))) { @@ -452,7 +455,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }; }); - const listWorkspaceFiles: VcsDriver.VcsDriverShape["listWorkspaceFiles"] = (cwd) => + const listWorkspaceFiles: VcsDriver.VcsDriver["Service"]["listWorkspaceFiles"] = (cwd) => gitCommand( vcsProcess, "GitVcsDriver.listWorkspaceFiles", @@ -494,7 +497,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), ); - const listRemotes: VcsDriver.VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")( + const listRemotes: VcsDriver.VcsDriver["Service"]["listRemotes"] = Effect.fn("listRemotes")( function* (cwd) { const result = yield* gitCommand( vcsProcess, @@ -540,7 +543,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }, ); - const filterIgnoredPaths: VcsDriver.VcsDriverShape["filterIgnoredPaths"] = Effect.fn( + const filterIgnoredPaths: VcsDriver.VcsDriver["Service"]["filterIgnoredPaths"] = Effect.fn( "filterIgnoredPaths", )(function* (cwd, relativePaths) { if (relativePaths.length === 0) { @@ -587,7 +590,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); }); - const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) => + const initRepository: VcsDriver.VcsDriver["Service"]["initRepository"] = (input) => gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], { timeoutMs: 10_000, maxOutputBytes: 64 * 1024, @@ -844,7 +847,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), }; - return VcsDriver.VcsDriver.of({ + return { capabilities, execute, checkpoints, @@ -854,18 +857,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( listRemotes, filterIgnoredPaths, initRepository, - }); + }; }); -export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { +export const makeVcsDriver = Effect.gen(function* () { const driver = yield* makeVcsDriverShape(); return VcsDriver.VcsDriver.of(driver); }); -export const make = Effect.fn("makeGitVcsDriverService")(function* () { - const git = yield* GitVcsDriverCore.makeGitVcsDriverCore(); +export const make = Effect.gen(function* () { + const git = yield* makeGitVcsDriverCore(); return GitVcsDriver.of(git); }); -export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver()); -export const layer = Layer.effect(GitVcsDriver, make()); +export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver); +export const layer = Layer.effect(GitVcsDriver, make); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index b78fba1030e..0e8f8df16e2 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -655,7 +655,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const { worktreesDir } = yield* ServerConfig; const crypto = yield* Crypto.Crypto; - const executeRaw: GitVcsDriver.GitVcsDriverShape["execute"] = Effect.fnUntraced( + const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( function* (input) { const commandInput = { ...input, @@ -756,7 +756,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const execute: GitVcsDriver.GitVcsDriverShape["execute"] = (input) => + const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) => executeRaw(input).pipe( withMetrics({ counter: gitCommandsTotal, @@ -1059,38 +1059,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); }); - const ensureRemote: GitVcsDriver.GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")( - function* (input) { - const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeRemoteUrl(input.url); - const remoteFetchUrls = yield* runGitStdout( - "GitVcsDriver.ensureRemote.listRemoteUrls", - input.cwd, - ["remote", "-v"], - ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); + const ensureRemote: GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"] = Effect.fn( + "ensureRemote", + )(function* (input) { + const preferredName = sanitizeRemoteName(input.preferredName); + const normalizedTargetUrl = normalizeRemoteUrl(input.url); + const remoteFetchUrls = yield* runGitStdout( + "GitVcsDriver.ensureRemote.listRemoteUrls", + input.cwd, + ["remote", "-v"], + ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); - for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { - return remoteName; - } + for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { + if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { + return remoteName; } + } - let remoteName = preferredName; - let suffix = 1; - while (remoteFetchUrls.has(remoteName)) { - remoteName = `${preferredName}-${suffix}`; - suffix += 1; - } + let remoteName = preferredName; + let suffix = 1; + while (remoteFetchUrls.has(remoteName)) { + remoteName = `${preferredName}-${suffix}`; + suffix += 1; + } - yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [ - "remote", - "add", - remoteName, - input.url, - ]); - return remoteName; - }, - ); + yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [ + "remote", + "add", + remoteName, + input.url, + ]); + return remoteName; + }); const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( cwd: string, @@ -1426,35 +1426,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const statusDetailsLocal: GitVcsDriver.GitVcsDriverShape["statusDetailsLocal"] = Effect.fn( + const statusDetailsLocal: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn( "statusDetailsLocal", )(function* (cwd) { return yield* readStatusDetailsLocal(cwd); }); - const statusDetails: GitVcsDriver.GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")( - function* (cwd) { - yield* refreshStatusUpstreamIfStale(cwd).pipe( - Effect.catchIf(isMissingGitCwdError, () => Effect.void), - Effect.ignoreCause({ log: true }), - ); - return yield* readStatusDetailsLocal(cwd); - }, - ); - - const statusDetailsRemote: GitVcsDriver.GitVcsDriverShape["statusDetailsRemote"] = Effect.fn( - "statusDetailsRemote", - )(function* (cwd, options) { - if (options?.refreshUpstream !== false) { - yield* refreshStatusUpstreamIfStale(cwd).pipe( - Effect.catchIf(isMissingGitCwdError, () => Effect.void), - Effect.ignoreCause({ log: true }), - ); - } - return yield* readStatusDetailsRemote(cwd); + const statusDetails: GitVcsDriver.GitVcsDriver["Service"]["statusDetails"] = Effect.fn( + "statusDetails", + )(function* (cwd) { + yield* refreshStatusUpstreamIfStale(cwd).pipe( + Effect.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.ignoreCause({ log: true }), + ); + return yield* readStatusDetailsLocal(cwd); }); - const status: GitVcsDriver.GitVcsDriverShape["status"] = (input) => + const statusDetailsRemote: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsRemote"] = + Effect.fn("statusDetailsRemote")(function* (cwd, options) { + if (options?.refreshUpstream !== false) { + yield* refreshStatusUpstreamIfStale(cwd).pipe( + Effect.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.ignoreCause({ log: true }), + ); + } + return yield* readStatusDetailsRemote(cwd); + }); + + const status: GitVcsDriver.GitVcsDriver["Service"]["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ isRepo: details.isRepo, @@ -1471,49 +1470,48 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* })), ); - const prepareCommitContext: GitVcsDriver.GitVcsDriverShape["prepareCommitContext"] = Effect.fn( - "prepareCommitContext", - )(function* (cwd, filePaths) { - if (filePaths && filePaths.length > 0) { - yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( - Effect.catch(() => Effect.void), - ); - yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ - "add", - "-A", - "--", - ...filePaths, - ]); - } else { - yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]); - } + const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = + Effect.fn("prepareCommitContext")(function* (cwd, filePaths) { + if (filePaths && filePaths.length > 0) { + yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( + Effect.catch(() => Effect.void), + ); + yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ + "add", + "-A", + "--", + ...filePaths, + ]); + } else { + yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]); + } - const stagedSummary = yield* runGitStdout( - "GitVcsDriver.prepareCommitContext.stagedSummary", - cwd, - ["diff", "--cached", "--name-status"], - ).pipe(Effect.map((stdout) => stdout.trim())); - if (stagedSummary.length === 0) { - return null; - } + const stagedSummary = yield* runGitStdout( + "GitVcsDriver.prepareCommitContext.stagedSummary", + cwd, + ["diff", "--cached", "--name-status"], + ).pipe(Effect.map((stdout) => stdout.trim())); + if (stagedSummary.length === 0) { + return null; + } - const stagedPatch = yield* runGitStdoutWithOptions( - "GitVcsDriver.prepareCommitContext.stagedPatch", - cwd, - ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], - { - maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, - appendTruncationMarker: true, - }, - ); + const stagedPatch = yield* runGitStdoutWithOptions( + "GitVcsDriver.prepareCommitContext.stagedPatch", + cwd, + ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], + { + maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); - return { - stagedSummary, - stagedPatch, - }; - }); + return { + stagedSummary, + stagedPatch, + }; + }); - const commit: GitVcsDriver.GitVcsDriverShape["commit"] = Effect.fn("commit")(function* ( + const commit: GitVcsDriver.GitVcsDriver["Service"]["commit"] = Effect.fn("commit")(function* ( cwd, subject, body, @@ -1546,7 +1544,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { commitSha }; }); - const pushCurrentBranch: GitVcsDriver.GitVcsDriverShape["pushCurrentBranch"] = Effect.fn( + const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn( "pushCurrentBranch", )(function* (cwd, fallbackBranch, options) { const details = yield* statusDetails(cwd); @@ -1664,7 +1662,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const pullCurrentBranch: GitVcsDriver.GitVcsDriverShape["pullCurrentBranch"] = Effect.fn( + const pullCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pullCurrentBranch"] = Effect.fn( "pullCurrentBranch", )(function* (cwd) { const details = yield* statusDetails(cwd); @@ -1710,7 +1708,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const readRangeContext: GitVcsDriver.GitVcsDriverShape["readRangeContext"] = Effect.fn( + const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( "readRangeContext", )(function* (cwd, baseRef) { const range = `${baseRef}..HEAD`; @@ -1921,13 +1919,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) => + const readConfigValue: GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"] = (cwd, key) => runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( Effect.map((stdout) => stdout.trim()), Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); - const listRefs: GitVcsDriver.GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")( + const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")( function* (input) { const branchRecencyPromise = readBranchRecency(input.cwd).pipe( Effect.orElseSucceed(() => new Map()), @@ -2165,7 +2163,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const createWorktree: GitVcsDriver.GitVcsDriverShape["createWorktree"] = Effect.fn( + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input) { const targetBranch = input.newRefName ?? input.refName; @@ -2188,7 +2186,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const fetchPullRequestBranch: GitVcsDriver.GitVcsDriverShape["fetchPullRequestBranch"] = + const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] = Effect.fn("fetchPullRequestBranch")(function* (input) { const remoteName = yield* resolvePrimaryRemoteName(input.cwd); yield* executeGit( @@ -2207,7 +2205,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); - const fetchRemote: GitVcsDriver.GitVcsDriverShape["fetchRemote"] = Effect.fn("fetchRemote")( + const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")( function* (input) { yield* executeGit( "GitVcsDriver.fetchRemote", @@ -2221,7 +2219,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriverShape["resolveRemoteTrackingCommit"] = + const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveRemoteTrackingCommit"] = Effect.fn("resolveRemoteTrackingCommit")(function* (input) { const remoteNames = yield* listRemoteNames(input.cwd); const parsedRemoteRef = parseRemoteRefWithRemoteNames( @@ -2239,7 +2237,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { commitSha, remoteRefName }; }); - const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn( + const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn( "fetchRemoteBranch", )(function* (input) { yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ @@ -2261,7 +2259,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); - const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"] = + const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] = Effect.fn("fetchRemoteTrackingBranch")(function* (input) { yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ "fetch", @@ -2272,7 +2270,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ]); }); - const setBranchUpstream: GitVcsDriver.GitVcsDriverShape["setBranchUpstream"] = (input) => + const setBranchUpstream: GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"] = (input) => runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ "branch", "--set-upstream-to", @@ -2280,7 +2278,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.branch, ]); - const removeWorktree: GitVcsDriver.GitVcsDriverShape["removeWorktree"] = Effect.fn( + const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn( "removeWorktree", )(function* (input) { const args = ["worktree", "remove"]; @@ -2304,28 +2302,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); - const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( - function* (input) { - if (input.oldBranch === input.newBranch) { - return { branch: input.newBranch }; - } - const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); + const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn( + "renameBranch", + )(function* (input) { + if (input.oldBranch === input.newBranch) { + return { branch: input.newBranch }; + } + const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); - yield* executeGit( - "GitVcsDriver.renameBranch", - input.cwd, - ["branch", "-m", "--", input.oldBranch, targetBranch], - { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch rename failed", - }, - ); + yield* executeGit( + "GitVcsDriver.renameBranch", + input.cwd, + ["branch", "-m", "--", input.oldBranch, targetBranch], + { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch rename failed", + }, + ); - return { branch: targetBranch }; - }, - ); + return { branch: targetBranch }; + }); - const switchRef: GitVcsDriver.GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")( + const switchRef: GitVcsDriver.GitVcsDriver["Service"]["switchRef"] = Effect.fn("switchRef")( function* (input) { const [localInputExists, remoteExists] = yield* Effect.all( [ @@ -2407,7 +2405,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const createRef: GitVcsDriver.GitVcsDriverShape["createRef"] = Effect.fn("createRef")( + const createRef: GitVcsDriver.GitVcsDriver["Service"]["createRef"] = Effect.fn("createRef")( function* (input) { yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { timeoutMs: 10_000, @@ -2421,13 +2419,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const initRepo: GitVcsDriver.GitVcsDriverShape["initRepo"] = (input) => + const initRepo: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = (input) => executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { timeoutMs: 10_000, fallbackErrorMessage: "git init failed", }).pipe(Effect.asVoid); - const listLocalBranchNames: GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"] = (cwd) => + const listLocalBranchNames: GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"] = ( + cwd, + ) => runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [ "branch", "--list", diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index 1885a49ce92..f2daf793502 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -52,26 +52,29 @@ export interface VcsCheckpointOps { ) => Effect.Effect; } -export interface VcsDriverShape { - readonly capabilities: VcsDriverCapabilities; - readonly execute: ( - input: Omit, - ) => Effect.Effect; - readonly checkpoints?: VcsCheckpointOps; - readonly detectRepository: (cwd: string) => Effect.Effect; - readonly isInsideWorkTree: (cwd: string) => Effect.Effect; - readonly listWorkspaceFiles: ( - cwd: string, - ) => Effect.Effect; - readonly listRemotes: (cwd: string) => Effect.Effect; - readonly filterIgnoredPaths: ( - cwd: string, - relativePaths: ReadonlyArray, - ) => Effect.Effect, VcsError>; - readonly initRepository: (input: VcsInitInput) => Effect.Effect; - readonly getDiffPreview?: ( - input: ReviewDiffPreviewInput, - ) => Effect.Effect; -} - -export class VcsDriver extends Context.Service()("t3/vcs/VcsDriver") {} +export class VcsDriver extends Context.Service< + VcsDriver, + { + readonly capabilities: VcsDriverCapabilities; + readonly execute: ( + input: Omit, + ) => Effect.Effect; + readonly checkpoints?: VcsCheckpointOps; + readonly detectRepository: ( + cwd: string, + ) => Effect.Effect; + readonly isInsideWorkTree: (cwd: string) => Effect.Effect; + readonly listWorkspaceFiles: ( + cwd: string, + ) => Effect.Effect; + readonly listRemotes: (cwd: string) => Effect.Effect; + readonly filterIgnoredPaths: ( + cwd: string, + relativePaths: ReadonlyArray, + ) => Effect.Effect, VcsError>; + readonly initRepository: (input: VcsInitInput) => Effect.Effect; + readonly getDiffPreview?: ( + input: ReviewDiffPreviewInput, + ) => Effect.Effect; + } +>()("t3/vcs/VcsDriver") {} diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 03c09c16be8..7a531a5adcc 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -21,7 +21,7 @@ const normalizeGitArgs = (args: ReadonlyArray): ReadonlyArray => describe("VcsDriverRegistry", () => { it.effect("routes directly by VCS driver kind for non-repository workflows", () => { - const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( Layer.provide(NodeServices.layer), Layer.provide( Layer.mock(VcsProjectConfig.VcsProjectConfig)({ @@ -45,7 +45,7 @@ describe("VcsDriverRegistry", () => { it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { const calls: VcsProcess.VcsProcessInput[] = []; - const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( Layer.provide(NodeServices.layer), Layer.provide( Layer.mock(VcsProjectConfig.VcsProjectConfig)({ diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index 22868855737..103cc9607c1 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -22,20 +22,19 @@ export interface VcsDriverResolveInput { export interface VcsDriverHandle { readonly kind: VcsDriverKind; readonly repository: VcsRepositoryIdentity; - readonly driver: VcsDriver.VcsDriverShape; + readonly driver: VcsDriver.VcsDriver["Service"]; } -export interface VcsDriverRegistryShape { - readonly get: (kind: VcsDriverKind) => Effect.Effect; - readonly detect: ( - input: VcsDriverResolveInput, - ) => Effect.Effect; - readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect; -} - -export class VcsDriverRegistry extends Context.Service()( - "t3/vcs/VcsDriverRegistry", -) {} +export class VcsDriverRegistry extends Context.Service< + VcsDriverRegistry, + { + readonly get: (kind: VcsDriverKind) => Effect.Effect; + readonly detect: ( + input: VcsDriverResolveInput, + ) => Effect.Effect; + readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect; + } +>()("t3/vcs/VcsDriverRegistry") {} const unsupported = (operation: string, kind: VcsDriverKind, detail: string) => new VcsUnsupportedOperationError({ @@ -68,14 +67,14 @@ function parseDetectionCacheKey(key: string): { }; } -export const make = Effect.fn("makeVcsDriverRegistry")(function* () { +export const make = Effect.gen(function* () { const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; - const git = yield* GitVcsDriver.makeVcsDriverShape(); - const drivers: Partial> = { + const git = yield* GitVcsDriver.makeVcsDriver; + const drivers: Partial> = { git, }; - const get: VcsDriverRegistryShape["get"] = (kind) => { + const get: VcsDriverRegistry["Service"]["get"] = (kind) => { const driver = drivers[kind]; if (!driver) { return Effect.fail( @@ -87,7 +86,7 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* ( kind: VcsDriverKind, - driver: VcsDriver.VcsDriverShape, + driver: VcsDriver.VcsDriver["Service"], cwd: string, ) { const repository = yield* driver.detectRepository(cwd); @@ -123,14 +122,14 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { }, ); - const detect: VcsDriverRegistryShape["detect"] = Effect.fn("VcsDriverRegistry.detect")( + const detect: VcsDriverRegistry["Service"]["detect"] = Effect.fn("VcsDriverRegistry.detect")( function* (input) { const requestedKind = yield* projectConfig.resolveKind(input); return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind })); }, ); - const resolve: VcsDriverRegistryShape["resolve"] = Effect.fn("VcsDriverRegistry.resolve")( + const resolve: VcsDriverRegistry["Service"]["resolve"] = Effect.fn("VcsDriverRegistry.resolve")( function* (input) { const detected = yield* detect(input); if (detected) { @@ -155,6 +154,6 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { }); }); -export const layer = Layer.effect(VcsDriverRegistry, make()).pipe( +export const layer = Layer.effect(VcsDriverRegistry, make).pipe( Layer.provide(VcsProjectConfig.layer), ); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index a4caf7d3230..4470a1bfc53 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Match from "effect/Match"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -10,8 +11,7 @@ import { VcsProcessSpawnError, VcsProcessTimeoutError, } from "@t3tools/contracts"; -import { ProcessRunner, layer as ProcessRunnerLive } from "../processRunner.ts"; -import * as Match from "effect/Match"; +import * as ProcessRunner from "../processRunner.ts"; export interface VcsProcessInput { readonly operation: string; @@ -35,13 +35,12 @@ export interface VcsProcessOutput { readonly stderrTruncated: boolean; } -export interface VcsProcessShape { - readonly run: (input: VcsProcessInput) => Effect.Effect; -} - -export class VcsProcess extends Context.Service()( - "t3/vcs/VcsProcess", -) {} +export class VcsProcess extends Context.Service< + VcsProcess, + { + readonly run: (input: VcsProcessInput) => Effect.Effect; + } +>()("t3/vcs/VcsProcess") {} const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; @@ -51,8 +50,8 @@ function commandLabel(command: string, args: ReadonlyArray): string { return [command, ...args].join(" "); } -export const make = Effect.fn("makeVcsProcess")(function* () { - const processRunner = yield* ProcessRunner; +export const make = Effect.gen(function* () { + const processRunner = yield* ProcessRunner.ProcessRunner; const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { const label = commandLabel(input.command, input.args); @@ -119,4 +118,4 @@ export const make = Effect.fn("makeVcsProcess")(function* () { return VcsProcess.of({ run }); }); -export const layer = Layer.effect(VcsProcess, make()).pipe(Layer.provide(ProcessRunnerLive)); +export const layer = Layer.effect(VcsProcess, make).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index 10ecfa7fd96..c3590f5dbb0 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -27,15 +27,14 @@ export interface VcsProjectConfigResolveInput { readonly requestedKind?: VcsDriverKindType | "auto"; } -export interface VcsProjectConfigShape { - readonly resolveKind: ( - input: VcsProjectConfigResolveInput, - ) => Effect.Effect; -} - -export class VcsProjectConfig extends Context.Service()( - "t3/vcs/VcsProjectConfig", -) {} +export class VcsProjectConfig extends Context.Service< + VcsProjectConfig, + { + readonly resolveKind: ( + input: VcsProjectConfigResolveInput, + ) => Effect.Effect; + } +>()("t3/vcs/VcsProjectConfig") {} function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto" { return config.vcs?.kind ?? config.vcsKind ?? "auto"; @@ -44,7 +43,7 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto const parseConfig = (raw: string): Option.Option => decodeProjectVcsConfigJson(raw); -export const make = Effect.fn("makeVcsProjectConfig")(function* () { +export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -91,7 +90,7 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { return configuredKind(parsed.value); }); - const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn( + const resolveKind: VcsProjectConfig["Service"]["resolveKind"] = Effect.fn( "VcsProjectConfig.resolveKind", )(function* (input) { if (input.requestedKind !== undefined && input.requestedKind !== "auto") { @@ -110,4 +109,4 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { }); }); -export const layer = Layer.effect(VcsProjectConfig, make()); +export const layer = Layer.effect(VcsProjectConfig, make); diff --git a/apps/server/src/vcs/VcsProvisioningService.test.ts b/apps/server/src/vcs/VcsProvisioningService.test.ts index ba919a5f435..0a28f9c9b2c 100644 --- a/apps/server/src/vcs/VcsProvisioningService.test.ts +++ b/apps/server/src/vcs/VcsProvisioningService.test.ts @@ -11,7 +11,7 @@ import * as VcsProvisioningService from "./VcsProvisioningService.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); -function makeDriver(calls: string[]): VcsDriver.VcsDriverShape { +function makeDriver(calls: string[]): VcsDriver.VcsDriver["Service"] { return { capabilities: { kind: "git", diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts index 38006b4b603..9febacf2256 100644 --- a/apps/server/src/vcs/VcsProvisioningService.ts +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -10,13 +10,11 @@ import { } from "@t3tools/contracts"; import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; -export interface VcsProvisioningServiceShape { - readonly initRepository: (input: VcsInitInput) => Effect.Effect; -} - export class VcsProvisioningService extends Context.Service< VcsProvisioningService, - VcsProvisioningServiceShape + { + readonly initRepository: (input: VcsInitInput) => Effect.Effect; + } >()("t3/vcs/VcsProvisioningService") {} function resolveRequestedKind( @@ -37,10 +35,10 @@ function resolveRequestedKind( return Effect.succeed(kind); } -export const make = Effect.fn("makeVcsProvisioningService")(function* () { +export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; - const initRepository: VcsProvisioningServiceShape["initRepository"] = Effect.fn( + const initRepository: VcsProvisioningService["Service"]["initRepository"] = Effect.fn( "VcsProvisioningService.initRepository", )(function* (input) { const kind = yield* resolveRequestedKind(input.kind); @@ -53,4 +51,4 @@ export const make = Effect.fn("makeVcsProvisioningService")(function* () { }); }); -export const layer = Layer.effect(VcsProvisioningService, make()); +export const layer = Layer.effect(VcsProvisioningService, make); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index d78999f88c1..c14115e7119 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -299,7 +299,7 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), - } satisfies Partial), + } satisfies Partial), ), ); @@ -617,7 +617,7 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), - } satisfies Partial), + } satisfies Partial), ), ); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index f0cacab2dcb..860fc8075b3 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -65,23 +65,21 @@ export function remoteRefreshFailureDelay( return Duration.max(configuredInterval, cappedBackoff); } -export interface VcsStatusBroadcasterShape { - readonly getStatus: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly refreshLocalStatus: ( - cwd: string, - ) => Effect.Effect; - readonly refreshStatus: (cwd: string) => Effect.Effect; - readonly streamStatus: ( - input: VcsStatusInput, - options?: StreamStatusOptions, - ) => Stream.Stream; -} - export class VcsStatusBroadcaster extends Context.Service< VcsStatusBroadcaster, - VcsStatusBroadcasterShape + { + readonly getStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly refreshLocalStatus: ( + cwd: string, + ) => Effect.Effect; + readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly streamStatus: ( + input: VcsStatusInput, + options?: StreamStatusOptions, + ) => Stream.Stream; + } >()("t3/vcs/VcsStatusBroadcaster") {} function fingerprintStatusPart(status: unknown): string { @@ -94,101 +92,57 @@ const normalizeCwd = (cwd: string) => Effect.orElseSucceed(() => cwd), ); -export const layer = Layer.effect( - VcsStatusBroadcaster, - Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - const fs = yield* FileSystem.FileSystem; - const changesPubSub = yield* Effect.acquireRelease( - PubSub.unbounded(), - (pubsub) => PubSub.shutdown(pubsub), - ); - const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => - Scope.close(scope, Exit.void), - ); - const cacheRef = yield* Ref.make(new Map()); - const pollersRef = yield* SynchronizedRef.make(new Map()); +export const make = Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const fs = yield* FileSystem.FileSystem; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + (pubsub) => PubSub.shutdown(pubsub), + ); + const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const cacheRef = yield* Ref.make(new Map()); + const pollersRef = yield* SynchronizedRef.make(new Map()); - const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( - cwd: string, - ) { - return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); - }); + const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( + cwd: string, + ) { + return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); + }); - const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( - function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { - const nextLocal = { - fingerprint: fingerprintStatusPart(local), - value: local, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - local: nextLocal, - }); - return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( + function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + local: nextLocal, }); + return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + }); - if (options?.publish && shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "localUpdated", - local, - }, - }); - } - - return local; - }, - ); - - const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( - function* ( - cwd: string, - remote: VcsStatusRemoteResult | null, - options?: { publish?: boolean }, - ) { - const nextRemote = { - fingerprint: fingerprintStatusPart(remote), - value: remote, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - remote: nextRemote, - }); - return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "localUpdated", + local, + }, }); + } - if (options?.publish && shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "remoteUpdated", - remote, - }, - }); - } - - return remote; - }, - ); + return local; + }, + ); - const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( - cwd: string, - local: VcsStatusLocalResult, - remote: VcsStatusRemoteResult | null, - options?: { publish?: boolean }, - ) { - const nextLocal = { - fingerprint: fingerprintStatusPart(local), - value: local, - } satisfies CachedValue; + const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( + function* (cwd: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) { const nextRemote = { fingerprint: fingerprintStatusPart(remote), value: remote, @@ -197,263 +151,302 @@ export const layer = Layer.effect( const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); nextCache.set(cwd, { - local: nextLocal, + ...previous, remote: nextRemote, }); - return [ - previous.local?.fingerprint !== nextLocal.fingerprint || - previous.remote?.fingerprint !== nextRemote.fingerprint, - nextCache, - ] as const; + return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; }); if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { cwd, event: { - _tag: "snapshot", - local, + _tag: "remoteUpdated", remote, }, }); } - return mergeGitStatusParts(local, remote); - }); + return remote; + }, + ); - const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( - cwd: string, - ) { - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local); + const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( + cwd: string, + local: VcsStatusLocalResult, + remote: VcsStatusRemoteResult | null, + options?: { publish?: boolean }, + ) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const nextRemote = { + fingerprint: fingerprintStatusPart(remote), + value: remote, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + local: nextLocal, + remote: nextRemote, + }); + return [ + previous.local?.fingerprint !== nextLocal.fingerprint || + previous.remote?.fingerprint !== nextRemote.fingerprint, + nextCache, + ] as const; }); - const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( - cwd: string, - ) { - const cached = yield* getCachedStatus(cwd); - if (cached?.local) { - return cached.local.value; - } - return yield* loadLocalStatus(cwd); - }); + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "snapshot", + local, + remote, + }, + }); + } - const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); + return mergeGitStatusParts(local, remote); + }); - const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn( - "VcsStatusBroadcaster.getStatus", - )(function* (input) { - const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); - const cached = yield* getCachedStatus(cwd); - if (cached?.local && cached.remote) { - return mergeGitStatusParts(cached.local.value, cached.remote.value); - } - const [local, remote] = yield* Effect.all( - [ - cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }), - cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }), - ], - { concurrency: "unbounded" }, - ); - return yield* updateCachedStatus(cwd, local, remote); - }); + const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( + cwd: string, + ) { + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local); + }); - const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( - function* (cwd: string) { - yield* workflow.invalidateLocalStatus(cwd); - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local, { publish: true }); - }, + const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( + cwd: string, + ) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + return cached.local.value; + } + return yield* loadLocalStatus(cwd); + }); + + const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); + + const getStatus: VcsStatusBroadcaster["Service"]["getStatus"] = Effect.fn( + "VcsStatusBroadcaster.getStatus", + )(function* (input) { + const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const cached = yield* getCachedStatus(cwd); + if (cached?.local && cached.remote) { + return mergeGitStatusParts(cached.local.value, cached.remote.value); + } + const [local, remote] = yield* Effect.all( + [ + cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }), + cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }), + ], + { concurrency: "unbounded" }, ); + return yield* updateCachedStatus(cwd, local, remote); + }); - const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( - "VcsStatusBroadcaster.refreshLocalStatus", - )(function* (rawCwd) { - const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - return yield* refreshLocalStatusCore(cwd); - }); + const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( + function* (cwd: string) { + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + }, + ); - const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( - cwd: string, - options?: { readonly refreshUpstream?: boolean }, - ) { - if (options?.refreshUpstream !== false) { - yield* workflow.invalidateRemoteStatus(cwd); - } - const remote = yield* workflow.remoteStatus({ cwd }, options); - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); - }); + const refreshLocalStatus: VcsStatusBroadcaster["Service"]["refreshLocalStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshLocalStatus", + )(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + return yield* refreshLocalStatusCore(cwd); + }); - const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn( - "VcsStatusBroadcaster.refreshStatus", - )(function* (rawCwd) { - const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - yield* Effect.all( - [workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], - { concurrency: "unbounded", discard: true }, - ); - const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], - { concurrency: "unbounded" }, - ); - return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( + cwd: string, + options?: { readonly refreshUpstream?: boolean }, + ) { + if (options?.refreshUpstream !== false) { + yield* workflow.invalidateRemoteStatus(cwd); + } + const remote = yield* workflow.remoteStatus({ cwd }, options); + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }); + + const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshStatus", + )(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + yield* Effect.all([workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], { + concurrency: "unbounded", + discard: true, }); + const [local, remote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], + { concurrency: "unbounded" }, + ); + return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + }); - const makeRemoteRefreshLoop = ( - cwd: string, - automaticRemoteRefreshInterval: Effect.Effect, - refreshImmediately: boolean, - ) => { - return Effect.gen(function* () { - const consecutiveFailuresRef = yield* Ref.make(0); - const needsInitialRefreshRef = yield* Ref.make(refreshImmediately); - const refreshRemoteStatusIfEnabled = Effect.gen(function* () { - const configuredInterval = yield* automaticRemoteRefreshInterval; - const activeInterval = Duration.isZero(configuredInterval) - ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL - : configuredInterval; - const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); - if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { - return activeInterval; - } - - const exit = yield* refreshRemoteStatus(cwd, { - refreshUpstream: !Duration.isZero(configuredInterval), - }).pipe(Effect.exit); - if (Exit.isSuccess(exit)) { - yield* Ref.set(needsInitialRefreshRef, false); - yield* Ref.set(consecutiveFailuresRef, 0); - return activeInterval; - } - - const consecutiveFailures = yield* Ref.updateAndGet( - consecutiveFailuresRef, - (count) => count + 1, - ); - const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); - yield* Effect.logWarning("VCS remote status refresh failed", { - cwd, - detail: exit.cause.toString(), - consecutiveFailures, - nextDelayMs: Duration.toMillis(nextDelay), - }); - return nextDelay; - }); + const makeRemoteRefreshLoop = ( + cwd: string, + automaticRemoteRefreshInterval: Effect.Effect, + refreshImmediately: boolean, + ) => { + return Effect.gen(function* () { + const consecutiveFailuresRef = yield* Ref.make(0); + const needsInitialRefreshRef = yield* Ref.make(refreshImmediately); + const refreshRemoteStatusIfEnabled = Effect.gen(function* () { + const configuredInterval = yield* automaticRemoteRefreshInterval; + const activeInterval = Duration.isZero(configuredInterval) + ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL + : configuredInterval; + const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); + if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { + return activeInterval; + } - if (!refreshImmediately) { - const configuredInterval = yield* automaticRemoteRefreshInterval; - yield* Effect.sleep( - Duration.isZero(configuredInterval) - ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL - : configuredInterval, - ); + const exit = yield* refreshRemoteStatus(cwd, { + refreshUpstream: !Duration.isZero(configuredInterval), + }).pipe(Effect.exit); + if (Exit.isSuccess(exit)) { + yield* Ref.set(needsInitialRefreshRef, false); + yield* Ref.set(consecutiveFailuresRef, 0); + return activeInterval; } - return yield* refreshRemoteStatusIfEnabled.pipe( - Effect.repeat( - Schedule.identity().pipe( - Schedule.addDelay((delay) => Effect.succeed(delay)), - ), - ), - Effect.asVoid, + const consecutiveFailures = yield* Ref.updateAndGet( + consecutiveFailuresRef, + (count) => count + 1, ); + const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); + yield* Effect.logWarning("VCS remote status refresh failed", { + cwd, + detail: exit.cause.toString(), + consecutiveFailures, + nextDelayMs: Duration.toMillis(nextDelay), + }); + return nextDelay; }); - }; - const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( - cwd: string, - automaticRemoteRefreshInterval: Effect.Effect, - refreshImmediately: boolean, - ) { - yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { - const existing = activePollers.get(cwd); - if (existing) { - const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { - ...existing, - subscriberCount: existing.subscriberCount + 1, - }); - return Effect.succeed([undefined, nextPollers] as const); - } - - return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe( - Effect.forkIn(broadcasterScope), - Effect.map((fiber) => { - const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { - fiber, - subscriberCount: 1, - }); - return [undefined, nextPollers] as const; - }), + if (!refreshImmediately) { + const configuredInterval = yield* automaticRemoteRefreshInterval; + yield* Effect.sleep( + Duration.isZero(configuredInterval) + ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL + : configuredInterval, ); - }); + } + + return yield* refreshRemoteStatusIfEnabled.pipe( + Effect.repeat( + Schedule.identity().pipe( + Schedule.addDelay((delay) => Effect.succeed(delay)), + ), + ), + Effect.asVoid, + ); }); + }; - const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( - cwd: string, - ) { - const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { - const existing = activePollers.get(cwd); - if (!existing) { - return [null, activePollers] as const; - } + const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( + cwd: string, + automaticRemoteRefreshInterval: Effect.Effect, + refreshImmediately: boolean, + ) { + yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (existing) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount + 1, + }); + return Effect.succeed([undefined, nextPollers] as const); + } - if (existing.subscriberCount > 1) { + return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { const nextPollers = new Map(activePollers); nextPollers.set(cwd, { - ...existing, - subscriberCount: existing.subscriberCount - 1, + fiber, + subscriberCount: 1, }); - return [null, nextPollers] as const; - } + return [undefined, nextPollers] as const; + }), + ); + }); + }); - const nextPollers = new Map(activePollers); - nextPollers.delete(cwd); - return [existing.fiber, nextPollers] as const; - }); + const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( + cwd: string, + ) { + const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (!existing) { + return [null, activePollers] as const; + } - if (pollerToInterrupt) { - yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + if (existing.subscriberCount > 1) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount - 1, + }); + return [null, nextPollers] as const; } + + const nextPollers = new Map(activePollers); + nextPollers.delete(cwd); + return [existing.fiber, nextPollers] as const; }); - const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input, options) => - Stream.unwrap( - Effect.gen(function* () { - const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); - const subscription = yield* PubSub.subscribe(changesPubSub); - const initialLocal = yield* getOrLoadLocalStatus(cwd); - const cachedStatus = yield* getCachedStatus(cwd); - const initialRemote = cachedStatus?.remote?.value ?? null; - yield* retainRemotePoller( - cwd, - options?.automaticRemoteRefreshInterval ?? - Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), - cachedStatus?.remote === null || cachedStatus?.remote === undefined, - ); - - const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); - - return Stream.concat( - Stream.make({ - _tag: "snapshot" as const, - local: initialLocal, - remote: initialRemote, - }), - Stream.fromSubscription(subscription).pipe( - Stream.filter((event) => event.cwd === cwd), - Stream.map((event) => event.event), - ), - ).pipe(Stream.ensuring(release)); - }), - ); + if (pollerToInterrupt) { + yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + } + }); + + const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) => + Stream.unwrap( + Effect.gen(function* () { + const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const subscription = yield* PubSub.subscribe(changesPubSub); + const initialLocal = yield* getOrLoadLocalStatus(cwd); + const cachedStatus = yield* getCachedStatus(cwd); + const initialRemote = cachedStatus?.remote?.value ?? null; + yield* retainRemotePoller( + cwd, + options?.automaticRemoteRefreshInterval ?? + Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), + cachedStatus?.remote === null || cachedStatus?.remote === undefined, + ); - return VcsStatusBroadcaster.of({ - getStatus, - refreshLocalStatus, - refreshStatus, - streamStatus, - }); - }), -); + const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); + + return Stream.concat( + Stream.make({ + _tag: "snapshot" as const, + local: initialLocal, + remote: initialRemote, + }), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.cwd === cwd), + Stream.map((event) => event.event), + ), + ).pipe(Stream.ensuring(release)); + }), + ); + + return VcsStatusBroadcaster.of({ + getStatus, + refreshLocalStatus, + refreshStatus, + streamStatus, + }); +}); + +export const layer = Layer.effect(VcsStatusBroadcaster, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 01337541cd1..9db10efcff9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -92,7 +92,7 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; -import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; +import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; @@ -278,7 +278,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; + const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; const automaticGitFetchInterval = serverSettings.getSettings.pipe( Effect.map((settings) => settings.automaticGitFetchInterval), Effect.catch((cause) => @@ -1669,7 +1669,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( - SourceControlDiscoveryLayer.layer.pipe( + SourceControlDiscovery.layer.pipe( Layer.provide( SourceControlProviderRegistry.layer.pipe( Layer.provide(