authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 22:13:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 22:13:36-07:00
logd9e95398622e158f78cffba5367a9d154ccc55e4
tree559ce3b1f3654c81072bd1fe07cabd1828633ea1
parent7118e43d16b157dd3079f354bd66ca5d772a9a93
signature Signed by PGP key B5690EEEBB952194

[codex] Migrate server source control Effect services (#3186)

Co-authored-by: codex <codex@users.noreply.github.com>

42 files changed, 1336 insertions(+), 1338 deletions(-)

apps/server/src/git/GitManager.test.ts+11-13
......@@ -376,7 +376,7 @@ function createTextGeneration(
376376}
377377
378378function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
379 service: GitHubCli.GitHubCliShape;
379 service: GitHubCli.GitHubCli["Service"];
380380 ghCalls: string[];
381381} {
382382 const prListQueue = [...(scenario.prListSequence ?? [])];
......@@ -388,7 +388,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
388388 );
389389 const ghCalls: string[] = [];
390390
391 const execute: GitHubCli.GitHubCliShape["execute"] = (input) => {
391 const execute: GitHubCli.GitHubCli["Service"]["execute"] = (input) => {
392392 const args = [...input.args];
393393 ghCalls.push(args.join(" "));
394394
......@@ -609,7 +609,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
609609}
610610
611611function runStackedAction(
612 manager: GitManager.GitManagerShape,
612 manager: GitManager.GitManager["Service"],
613613 input: {
614614 cwd: string;
615615 action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr";
......@@ -618,7 +618,7 @@ function runStackedAction(
618618 featureBranch?: boolean;
619619 filePaths?: readonly string[];
620620 },
621 options?: Parameters<GitManager.GitManagerShape["runStackedAction"]>[1],
621 options?: Parameters<GitManager.GitManager["Service"]["runStackedAction"]>[1],
622622) {
623623 return manager.runStackedAction(
624624 {
......@@ -630,14 +630,14 @@ function runStackedAction(
630630}
631631
632632function resolvePullRequest(
633 manager: GitManager.GitManagerShape,
633 manager: GitManager.GitManager["Service"],
634634 input: { cwd: string; reference: string },
635635) {
636636 return manager.resolvePullRequest(input);
637637}
638638
639639function preparePullRequestThread(
640 manager: GitManager.GitManagerShape,
640 manager: GitManager.GitManager["Service"],
641641 input: GitPreparePullRequestThreadInput,
642642) {
643643 return manager.preparePullRequestThread(input);
......@@ -646,11 +646,11 @@ function preparePullRequestThread(
646646function makeManager(input?: {
647647 ghScenario?: FakeGhScenario;
648648 textGeneration?: Partial<FakeGitTextGeneration>;
649 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunnerShape;
649 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
650650}) {
651651 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
652652 const textGeneration = createTextGeneration(input?.textGeneration);
653 const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
653 const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
654654 prefix: "t3-git-manager-test-",
655655 });
656656
......@@ -663,7 +663,7 @@ function makeManager(input?: {
663663 );
664664 const sourceControlRegistryLayer = Layer.effect(
665665 SourceControlProviderRegistry.SourceControlProviderRegistry,
666 GitHubSourceControlProvider.make().pipe(
666 GitHubSourceControlProvider.make.pipe(
667667 Effect.map((provider) =>
668668 SourceControlProviderRegistry.SourceControlProviderRegistry.of({
669669 get: () => Effect.succeed(provider),
......@@ -688,7 +688,7 @@ function makeManager(input?: {
688688 serverSettingsLayer,
689689 ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer));
690690
691 return GitManager.makeGitManager().pipe(
691 return GitManager.make.pipe(
692692 Effect.provide(managerLayer),
693693 Effect.map((manager) => ({ manager, ghCalls })),
694694 );
......@@ -697,9 +697,7 @@ function makeManager(input?: {
697697const asThreadId = (threadId: string) => threadId as ThreadId;
698698
699699const GitManagerTestLayer = GitVcsDriver.layer.pipe(
700 Layer.provide(
701 ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" }),
702 ),
700 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })),
703701 Layer.provideMerge(VcsProcess.layer),
704702 Layer.provideMerge(NodeServices.layer),
705703);
apps/server/src/git/GitManager.ts+68-71
......@@ -42,17 +42,13 @@ import {
4242} from "@t3tools/shared/sourceControl";
4343
4444import { GitManagerError } from "@t3tools/contracts";
45import { TextGeneration } from "../textGeneration/TextGeneration.ts";
46import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts";
45import * as TextGeneration from "../textGeneration/TextGeneration.ts";
46import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts";
4747import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";
48import { ServerSettingsService } from "../serverSettings.ts";
48import * as ServerSettings from "../serverSettings.ts";
4949import type { GitManagerServiceError } from "@t3tools/contracts";
50import {
51 GitVcsDriver,
52 type GitRemoteStatusOptions,
53 type GitStatusDetails,
54} from "../vcs/GitVcsDriver.ts";
55import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts";
50import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
51import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
5652import type { ChangeRequest } from "@t3tools/contracts";
5753
5854export interface GitActionProgressReporter {
......@@ -64,35 +60,34 @@ export interface GitRunStackedActionOptions {
6460 readonly progressReporter?: GitActionProgressReporter;
6561}
6662
67export interface GitManagerShape {
68 readonly status: (
69 input: VcsStatusInput,
70 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
71 readonly localStatus: (
72 input: VcsStatusInput,
73 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
74 readonly remoteStatus: (
75 input: VcsStatusInput,
76 options?: GitRemoteStatusOptions,
77 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
78 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
79 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
80 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
81 readonly resolvePullRequest: (
82 input: GitPullRequestRefInput,
83 ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>;
84 readonly preparePullRequestThread: (
85 input: GitPreparePullRequestThreadInput,
86 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;
87 readonly runStackedAction: (
88 input: GitRunStackedActionInput,
89 options?: GitRunStackedActionOptions,
90 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
91}
92
93export class GitManager extends Context.Service<GitManager, GitManagerShape>()(
94 "t3/git/GitManager",
95) {}
63export class GitManager extends Context.Service<
64 GitManager,
65 {
66 readonly status: (
67 input: VcsStatusInput,
68 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
69 readonly localStatus: (
70 input: VcsStatusInput,
71 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
72 readonly remoteStatus: (
73 input: VcsStatusInput,
74 options?: GitVcsDriver.GitRemoteStatusOptions,
75 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
76 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
77 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
78 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
79 readonly resolvePullRequest: (
80 input: GitPullRequestRefInput,
81 ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>;
82 readonly preparePullRequestThread: (
83 input: GitPreparePullRequestThreadInput,
84 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;
85 readonly runStackedAction: (
86 input: GitRunStackedActionInput,
87 options?: GitRunStackedActionOptions,
88 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
89 }
90>()("t3/git/GitManager") {}
9691
9792const COMMIT_TIMEOUT_MS = 10 * 60_000;
9893const MAX_PROGRESS_TEXT_LENGTH = 500;
......@@ -531,15 +526,15 @@ function toPullRequestHeadRemoteInfo(pr: {
531526 };
532527}
533528
534export const makeGitManager = Effect.fn("makeGitManager")(function* () {
535 const gitCore = yield* GitVcsDriver;
536 const sourceControlProviders = yield* SourceControlProviderRegistry;
537 const textGeneration = yield* TextGeneration;
538 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
529export const make = Effect.gen(function* () {
530 const gitCore = yield* GitVcsDriver.GitVcsDriver;
531 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
532 const textGeneration = yield* TextGeneration.TextGeneration;
533 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
539534 const crypto = yield* Crypto.Crypto;
540535
541536 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
542 const serverSettingsService = yield* ServerSettingsService;
537 const serverSettingsService = yield* ServerSettings.ServerSettingsService;
543538 const randomUUIDv4 = crypto.randomUUIDv4.pipe(
544539 Effect.mapError((cause) =>
545540 gitManagerError("randomUUIDv4", "Failed to generate Git operation identifier.", cause),
......@@ -721,7 +716,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
721716 aheadCount: 0,
722717 behindCount: 0,
723718 aheadOfDefaultCount: 0,
724 } satisfies GitStatusDetails;
719 } satisfies GitVcsDriver.GitStatusDetails;
725720 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
726721 const details = yield* gitCore
727722 .statusDetailsLocal(cwd)
......@@ -752,7 +747,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
752747 );
753748 const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (
754749 cwd: string,
755 options?: GitRemoteStatusOptions,
750 options?: GitVcsDriver.GitRemoteStatusOptions,
756751 ) {
757752 const details = yield* gitCore
758753 .statusDetailsRemote(cwd, options)
......@@ -1358,11 +1353,13 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
13581353 };
13591354 });
13601355
1361 const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) {
1362 const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
1363 return yield* Cache.get(localStatusResultCache, cacheKey);
1364 });
1365 const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")(
1356 const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")(
1357 function* (input) {
1358 const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
1359 return yield* Cache.get(localStatusResultCache, cacheKey);
1360 },
1361 );
1362 const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")(
13661363 function* (input, options) {
13671364 const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
13681365 if (options?.refreshUpstream === false) {
......@@ -1371,43 +1368,43 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
13711368 return yield* Cache.get(remoteStatusResultCache, cacheKey);
13721369 },
13731370 );
1374 const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {
1371 const status: GitManager["Service"]["status"] = Effect.fn("status")(function* (input) {
13751372 const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)], {
13761373 concurrency: "unbounded",
13771374 });
13781375 return mergeGitStatusParts(local, remote);
13791376 });
1380 const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn(
1377 const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(
13811378 "invalidateLocalStatus",
13821379 )(function* (cwd) {
13831380 yield* invalidateLocalStatusResultCache(cwd);
13841381 });
1385 const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn(
1382 const invalidateRemoteStatus: GitManager["Service"]["invalidateRemoteStatus"] = Effect.fn(
13861383 "invalidateRemoteStatus",
13871384 )(function* (cwd) {
13881385 yield* invalidateRemoteStatusResultCache(cwd);
13891386 });
1390 const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")(
1387 const invalidateStatus: GitManager["Service"]["invalidateStatus"] = Effect.fn("invalidateStatus")(
13911388 function* (cwd) {
13921389 yield* invalidateLocalStatusResultCache(cwd);
13931390 yield* invalidateRemoteStatusResultCache(cwd);
13941391 },
13951392 );
13961393
1397 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(
1398 function* (input) {
1399 const pullRequest = yield* (yield* sourceControlProvider(input.cwd))
1400 .getChangeRequest({
1401 cwd: input.cwd,
1402 reference: normalizePullRequestReference(input.reference),
1403 })
1404 .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved)));
1394 const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn(
1395 "resolvePullRequest",
1396 )(function* (input) {
1397 const pullRequest = yield* (yield* sourceControlProvider(input.cwd))
1398 .getChangeRequest({
1399 cwd: input.cwd,
1400 reference: normalizePullRequestReference(input.reference),
1401 })
1402 .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved)));
14051403
1406 return { pullRequest };
1407 },
1408 );
1404 return { pullRequest };
1405 });
14091406
1410 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn(
1407 const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn(
14111408 "preparePullRequestThread",
14121409 )(function* (input) {
14131410 const maybeRunSetupScript = (worktreePath: string) => {
......@@ -1608,7 +1605,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
16081605 };
16091606 });
16101607
1611 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")(
1608 const runStackedAction: GitManager["Service"]["runStackedAction"] = Effect.fn("runStackedAction")(
16121609 function* (input, options) {
16131610 const progress = yield* createProgressEmitter(input, options);
16141611 const currentPhase = yield* Ref.make<Option.Option<GitActionProgressPhase>>(Option.none());
......@@ -1787,7 +1784,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
17871784 },
17881785 );
17891786
1790 return {
1787 return GitManager.of({
17911788 localStatus,
17921789 remoteStatus,
17931790 status,
......@@ -1797,7 +1794,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
17971794 resolvePullRequest,
17981795 preparePullRequestThread,
17991796 runStackedAction,
1800 } satisfies GitManagerShape;
1797 });
18011798});
18021799
1803export const layer = Layer.effect(GitManager, makeGitManager());
1800export const layer = Layer.effect(GitManager, make);
apps/server/src/git/GitWorkflowService.test.ts+3-1
......@@ -7,7 +7,9 @@ import * as GitWorkflowService from "./GitWorkflowService.ts";
77import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
88import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
99
10function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistryShape["detect"] }) {
10function makeLayer(input: {
11 readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"];
12}) {
1113 return GitWorkflowService.layer.pipe(
1214 Layer.provide(
1315 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
apps/server/src/git/GitWorkflowService.ts+66-64
......@@ -28,68 +28,70 @@ import {
2828 type VcsStatusResult,
2929} from "@t3tools/contracts";
3030
31import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts";
32import { GitVcsDriver, type GitRemoteStatusOptions } from "../vcs/GitVcsDriver.ts";
33import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
34
35export interface GitWorkflowServiceShape {
36 readonly status: (
37 input: VcsStatusInput,
38 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
39 readonly localStatus: (
40 input: VcsStatusInput,
41 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
42 readonly remoteStatus: (
43 input: VcsStatusInput,
44 options?: GitRemoteStatusOptions,
45 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
46 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
47 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
48 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
49 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
50 readonly runStackedAction: (
51 input: GitRunStackedActionInput,
52 options?: GitRunStackedActionOptions,
53 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
54 readonly resolvePullRequest: (
55 input: GitPullRequestRefInput,
56 ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>;
57 readonly preparePullRequestThread: (
58 input: GitPreparePullRequestThreadInput,
59 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;
60 readonly listRefs: (input: VcsListRefsInput) => Effect.Effect<VcsListRefsResult, GitCommandError>;
61 readonly createWorktree: (
62 input: VcsCreateWorktreeInput,
63 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
64 readonly fetchRemote: (input: {
65 readonly cwd: string;
66 readonly remoteName: string;
67 }) => Effect.Effect<void, GitCommandError>;
68 readonly resolveRemoteTrackingCommit: (input: {
69 readonly cwd: string;
70 readonly refName: string;
71 readonly fallbackRemoteName: string;
72 }) => Effect.Effect<
73 { readonly commitSha: string; readonly remoteRefName: string },
74 GitCommandError
75 >;
76 readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;
77 readonly createRef: (
78 input: VcsCreateRefInput,
79 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
80 readonly switchRef: (
81 input: VcsSwitchRefInput,
82 ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
83 readonly renameBranch: (input: {
84 readonly cwd: string;
85 readonly oldBranch: string;
86 readonly newBranch: string;
87 }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>;
88}
31import * as GitManager from "./GitManager.ts";
32import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
33import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
8934
9035export class GitWorkflowService extends Context.Service<
9136 GitWorkflowService,
92 GitWorkflowServiceShape
37 {
38 readonly status: (
39 input: VcsStatusInput,
40 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
41 readonly localStatus: (
42 input: VcsStatusInput,
43 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
44 readonly remoteStatus: (
45 input: VcsStatusInput,
46 options?: GitVcsDriver.GitRemoteStatusOptions,
47 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
48 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
49 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
50 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
51 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
52 readonly runStackedAction: (
53 input: GitRunStackedActionInput,
54 options?: GitManager.GitRunStackedActionOptions,
55 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
56 readonly resolvePullRequest: (
57 input: GitPullRequestRefInput,
58 ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>;
59 readonly preparePullRequestThread: (
60 input: GitPreparePullRequestThreadInput,
61 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;
62 readonly listRefs: (
63 input: VcsListRefsInput,
64 ) => Effect.Effect<VcsListRefsResult, GitCommandError>;
65 readonly createWorktree: (
66 input: VcsCreateWorktreeInput,
67 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
68 readonly fetchRemote: (input: {
69 readonly cwd: string;
70 readonly remoteName: string;
71 }) => Effect.Effect<void, GitCommandError>;
72 readonly resolveRemoteTrackingCommit: (input: {
73 readonly cwd: string;
74 readonly refName: string;
75 readonly fallbackRemoteName: string;
76 }) => Effect.Effect<
77 { readonly commitSha: string; readonly remoteRefName: string },
78 GitCommandError
79 >;
80 readonly removeWorktree: (
81 input: VcsRemoveWorktreeInput,
82 ) => Effect.Effect<void, GitCommandError>;
83 readonly createRef: (
84 input: VcsCreateRefInput,
85 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
86 readonly switchRef: (
87 input: VcsSwitchRefInput,
88 ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
89 readonly renameBranch: (input: {
90 readonly cwd: string;
91 readonly oldBranch: string;
92 readonly newBranch: string;
93 }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>;
94 }
9395>()("t3/git/GitWorkflowService") {}
9496
9597const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) =>
......@@ -142,10 +144,10 @@ function nonRepositoryListRefs(): VcsListRefsResult {
142144 };
143145}
144146
145export const make = Effect.fn("makeGitWorkflowService")(function* () {
146 const registry = yield* VcsDriverRegistry;
147 const git = yield* GitVcsDriver;
148 const gitManager = yield* GitManager;
147export const make = Effect.gen(function* () {
148 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
149 const git = yield* GitVcsDriver.GitVcsDriver;
150 const gitManager = yield* GitManager.GitManager;
149151
150152 const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* (
151153 operation: string,
......@@ -334,4 +336,4 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () {
334336 });
335337});
336338
337export const layer = Layer.effect(GitWorkflowService, make());
339export const layer = Layer.effect(GitWorkflowService, make);
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+3-3
......@@ -59,7 +59,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
5959import * as Clock from "effect/Clock";
6060import { ServerSettingsService } from "../../serverSettings.ts";
6161import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
62import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitWorkflowService.ts";
62import * as GitWorkflowService from "../../git/GitWorkflowService.ts";
6363
6464const asProjectId = (value: string): ProjectId => ProjectId.make(value);
6565const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value);
......@@ -348,9 +348,9 @@ describe("ProviderCommandReactor", () => {
348348 Layer.provideMerge(Layer.succeed(ProviderService, service)),
349349 Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)),
350350 Layer.provideMerge(
351 Layer.mock(GitWorkflowService)({
351 Layer.mock(GitWorkflowService.GitWorkflowService)({
352352 renameBranch,
353 } satisfies Partial<GitWorkflowServiceShape>),
353 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
354354 ),
355355 Layer.provideMerge(
356356 Layer.succeed(VcsStatusBroadcaster, {
apps/server/src/sourceControl/AzureDevOpsCli.test.ts+1-1
......@@ -17,7 +17,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
1717 stderrTruncated: false,
1818});
1919
20const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();
20const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>();
2121
2222const supportLayer = Layer.mergeAll(
2323 Layer.mock(VcsProcess.VcsProcess)({
apps/server/src/sourceControl/AzureDevOpsCli.ts+68-72
......@@ -11,7 +11,12 @@ import {
1111} from "@t3tools/contracts";
1212
1313import * as VcsProcess from "../vcs/VcsProcess.ts";
14import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts";
14import {
15 decodeAzureDevOpsPullRequestJson,
16 decodeAzureDevOpsPullRequestListJson,
17 formatAzureDevOpsJsonDecodeError,
18 type NormalizedAzureDevOpsPullRequestRecord,
19} from "./azureDevOpsPullRequests.ts";
1520import * as SourceControlProvider from "./SourceControlProvider.ts";
1621
1722const DEFAULT_TIMEOUT_MS = 30_000;
......@@ -35,67 +40,60 @@ export interface AzureDevOpsRepositoryCloneUrls {
3540 readonly sshUrl: string;
3641}
3742
38export interface AzureDevOpsCliShape {
39 readonly execute: (input: {
40 readonly cwd: string;
41 readonly args: ReadonlyArray<string>;
42 readonly timeoutMs?: number;
43 }) => Effect.Effect<VcsProcess.VcsProcessOutput, AzureDevOpsCliError>;
44
45 readonly listPullRequests: (input: {
46 readonly cwd: string;
47 readonly headSelector: string;
48 readonly source?: SourceControlProvider.SourceControlRefSelector;
49 readonly state: "open" | "closed" | "merged" | "all";
50 readonly limit?: number;
51 }) => Effect.Effect<
52 ReadonlyArray<AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord>,
53 AzureDevOpsCliError
54 >;
55
56 readonly getPullRequest: (input: {
57 readonly cwd: string;
58 readonly reference: string;
59 }) => Effect.Effect<
60 AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord,
61 AzureDevOpsCliError
62 >;
63
64 readonly getRepositoryCloneUrls: (input: {
65 readonly cwd: string;
66 readonly repository: string;
67 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
68
69 readonly createRepository: (input: {
70 readonly cwd: string;
71 readonly repository: string;
72 readonly visibility: SourceControlRepositoryVisibility;
73 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
74
75 readonly createPullRequest: (input: {
76 readonly cwd: string;
77 readonly baseBranch: string;
78 readonly headSelector: string;
79 readonly source?: SourceControlProvider.SourceControlRefSelector;
80 readonly target?: SourceControlProvider.SourceControlRefSelector;
81 readonly title: string;
82 readonly bodyFile: string;
83 }) => Effect.Effect<void, AzureDevOpsCliError>;
84
85 readonly getDefaultBranch: (input: {
86 readonly cwd: string;
87 }) => Effect.Effect<string | null, AzureDevOpsCliError>;
88
89 readonly checkoutPullRequest: (input: {
90 readonly cwd: string;
91 readonly reference: string;
92 readonly remoteName?: string;
93 }) => Effect.Effect<void, AzureDevOpsCliError>;
94}
95
96export class AzureDevOpsCli extends Context.Service<AzureDevOpsCli, AzureDevOpsCliShape>()(
97 "t3/sourceControl/AzureDevOpsCli",
98) {}
43export class AzureDevOpsCli extends Context.Service<
44 AzureDevOpsCli,
45 {
46 readonly execute: (input: {
47 readonly cwd: string;
48 readonly args: ReadonlyArray<string>;
49 readonly timeoutMs?: number;
50 }) => Effect.Effect<VcsProcess.VcsProcessOutput, AzureDevOpsCliError>;
51
52 readonly listPullRequests: (input: {
53 readonly cwd: string;
54 readonly headSelector: string;
55 readonly source?: SourceControlProvider.SourceControlRefSelector;
56 readonly state: "open" | "closed" | "merged" | "all";
57 readonly limit?: number;
58 }) => Effect.Effect<ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, AzureDevOpsCliError>;
59
60 readonly getPullRequest: (input: {
61 readonly cwd: string;
62 readonly reference: string;
63 }) => Effect.Effect<NormalizedAzureDevOpsPullRequestRecord, AzureDevOpsCliError>;
64
65 readonly getRepositoryCloneUrls: (input: {
66 readonly cwd: string;
67 readonly repository: string;
68 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
69
70 readonly createRepository: (input: {
71 readonly cwd: string;
72 readonly repository: string;
73 readonly visibility: SourceControlRepositoryVisibility;
74 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
75
76 readonly createPullRequest: (input: {
77 readonly cwd: string;
78 readonly baseBranch: string;
79 readonly headSelector: string;
80 readonly source?: SourceControlProvider.SourceControlRefSelector;
81 readonly target?: SourceControlProvider.SourceControlRefSelector;
82 readonly title: string;
83 readonly bodyFile: string;
84 }) => Effect.Effect<void, AzureDevOpsCliError>;
85
86 readonly getDefaultBranch: (input: {
87 readonly cwd: string;
88 }) => Effect.Effect<string | null, AzureDevOpsCliError>;
89
90 readonly checkoutPullRequest: (input: {
91 readonly cwd: string;
92 readonly reference: string;
93 readonly remoteName?: string;
94 }) => Effect.Effect<void, AzureDevOpsCliError>;
95 }
96>()("t3/sourceControl/AzureDevOpsCli") {}
9997
10098function errorText(error: VcsError | unknown): string {
10199 if (typeof error === "object" && error !== null) {
......@@ -239,10 +237,10 @@ function decodeAzureDevOpsJson<S extends Schema.Top>(
239237 );
240238}
241239
242export const make = Effect.fn("makeAzureDevOpsCli")(function* () {
240export const make = Effect.gen(function* () {
243241 const process = yield* VcsProcess.VcsProcess;
244242
245 const execute: AzureDevOpsCliShape["execute"] = (input) =>
243 const execute: AzureDevOpsCli["Service"]["execute"] = (input) =>
246244 process
247245 .run({
248246 operation: "AzureDevOpsCli.execute",
......@@ -253,7 +251,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () {
253251 })
254252 .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error)));
255253
256 const executeJson = (input: Parameters<AzureDevOpsCliShape["execute"]>[0]) =>
254 const executeJson = (input: Parameters<AzureDevOpsCli["Service"]["execute"]>[0]) =>
257255 execute({
258256 ...input,
259257 args: [...input.args, "--only-show-errors", "--output", "json"],
......@@ -282,15 +280,13 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () {
282280 Effect.flatMap((raw) =>
283281 raw.length === 0
284282 ? Effect.succeed([])
285 : Effect.sync(() =>
286 AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestListJson(raw),
287 ).pipe(
283 : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe(
288284 Effect.flatMap((decoded) => {
289285 if (!Result.isSuccess(decoded)) {
290286 return Effect.fail(
291287 new AzureDevOpsCliError({
292288 operation: "listPullRequests",
293 detail: `Azure DevOps CLI returned invalid PR list JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`,
289 detail: `Azure DevOps CLI returned invalid PR list JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`,
294290 cause: decoded.failure,
295291 }),
296292 );
......@@ -316,13 +312,13 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () {
316312 }).pipe(
317313 Effect.map((result) => result.stdout.trim()),
318314 Effect.flatMap((raw) =>
319 Effect.sync(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestJson(raw)).pipe(
315 Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe(
320316 Effect.flatMap((decoded) => {
321317 if (!Result.isSuccess(decoded)) {
322318 return Effect.fail(
323319 new AzureDevOpsCliError({
324320 operation: "getPullRequest",
325 detail: `Azure DevOps CLI returned invalid pull request JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`,
321 detail: `Azure DevOps CLI returned invalid pull request JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`,
326322 cause: decoded.failure,
327323 }),
328324 );
......@@ -434,4 +430,4 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () {
434430 });
435431});
436432
437export const layer = Layer.effect(AzureDevOpsCli, make());
433export const layer = Layer.effect(AzureDevOpsCli, make);
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts+5-4
......@@ -6,8 +6,8 @@ import * as Option from "effect/Option";
66import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
77import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
88
9function makeProvider(azure: Partial<AzureDevOpsCli.AzureDevOpsCliShape>) {
10 return AzureDevOpsSourceControlProvider.make().pipe(
9function makeProvider(azure: Partial<AzureDevOpsCli.AzureDevOpsCli["Service"]>) {
10 return AzureDevOpsSourceControlProvider.make.pipe(
1111 Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)),
1212 );
1313}
......@@ -48,8 +48,9 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests"
4848
4949it.effect("creates Azure DevOps PRs through provider-neutral input names", () =>
5050 Effect.gen(function* () {
51 let createInput: Parameters<AzureDevOpsCli.AzureDevOpsCliShape["createPullRequest"]>[0] | null =
52 null;
51 let createInput:
52 | Parameters<AzureDevOpsCli.AzureDevOpsCli["Service"]["createPullRequest"]>[0]
53 | null = null;
5354 const provider = yield* makeProvider({
5455 createPullRequest: (input) => {
5556 createInput = input;
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts+15-11
......@@ -4,7 +4,13 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac
44
55import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
66import * as SourceControlProvider from "./SourceControlProvider.ts";
7import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
7import {
8 combinedAuthOutput,
9 firstSafeAuthLine,
10 providerAuth,
11 type SourceControlAuthProbeInput,
12 type SourceControlCliDiscoverySpec,
13} from "./SourceControlProviderDiscovery.ts";
814
915function providerError(
1016 operation: string,
......@@ -18,28 +24,26 @@ function providerError(
1824 });
1925}
2026
21function parseAzureAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {
27function parseAzureAuth(input: SourceControlAuthProbeInput) {
2228 const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
2329
2430 if (input.exitCode !== 0) {
25 return SourceControlProviderDiscovery.providerAuth({
31 return providerAuth({
2632 status: "unauthenticated",
2733 detail:
28 SourceControlProviderDiscovery.firstSafeAuthLine(
29 SourceControlProviderDiscovery.combinedAuthOutput(input),
30 ) ?? "Run `az login` to authenticate Azure CLI.",
34 firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.",
3135 });
3236 }
3337
3438 if (account !== undefined && account.length > 0) {
35 return SourceControlProviderDiscovery.providerAuth({
39 return providerAuth({
3640 status: "authenticated",
3741 account,
3842 host: "dev.azure.com",
3943 });
4044 }
4145
42 return SourceControlProviderDiscovery.providerAuth({
46 return providerAuth({
4347 status: "unknown",
4448 host: "dev.azure.com",
4549 detail: "Azure CLI account status could not be parsed.",
......@@ -56,7 +60,7 @@ export const discovery = {
5660 parseAuth: parseAzureAuth,
5761 installHint:
5862 "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.",
59} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec;
63} satisfies SourceControlCliDiscoverySpec;
6064
6165function toChangeRequest(summary: {
6266 readonly number: number;
......@@ -80,7 +84,7 @@ function toChangeRequest(summary: {
8084 };
8185}
8286
83export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () {
87export const make = Effect.gen(function* () {
8488 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
8589
8690 return SourceControlProvider.SourceControlProvider.of({
......@@ -142,4 +146,4 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function*
142146 });
143147});
144148
145export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make());
149export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make);
apps/server/src/sourceControl/BitbucketApi.test.ts+16-16
......@@ -53,41 +53,41 @@ const repositoryJson = {
5353
5454function makeLayer(input: {
5555 readonly response: (request: HttpClientRequest.HttpClientRequest) => Response;
56 readonly git?: Partial<GitVcsDriver.GitVcsDriverShape>;
56 readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>;
5757}) {
5858 const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) =>
5959 Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))),
6060 );
6161 const gitMock = {
62 readConfigValue: vi.fn<GitVcsDriver.GitVcsDriverShape["readConfigValue"]>(() =>
62 readConfigValue: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"]>(() =>
6363 Effect.succeed<string | null>("git@bitbucket.org:pingdotgg/t3code.git"),
6464 ),
65 resolvePrimaryRemoteName: vi.fn<GitVcsDriver.GitVcsDriverShape["resolvePrimaryRemoteName"]>(
66 () => Effect.succeed("origin"),
67 ),
68 ensureRemote: vi.fn<GitVcsDriver.GitVcsDriverShape["ensureRemote"]>(() =>
65 resolvePrimaryRemoteName: vi.fn<
66 GitVcsDriver.GitVcsDriver["Service"]["resolvePrimaryRemoteName"]
67 >(() => Effect.succeed("origin")),
68 ensureRemote: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"]>(() =>
6969 Effect.succeed("octocat"),
7070 ),
71 fetchRemoteBranch: vi.fn<GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"]>(
72 () => Effect.void,
73 ),
74 fetchRemoteTrackingBranch: vi.fn<GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"]>(
71 fetchRemoteBranch: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"]>(
7572 () => Effect.void,
7673 ),
77 setBranchUpstream: vi.fn<GitVcsDriver.GitVcsDriverShape["setBranchUpstream"]>(
74 fetchRemoteTrackingBranch: vi.fn<
75 GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"]
76 >(() => Effect.void),
77 setBranchUpstream: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"]>(
7878 () => Effect.void,
7979 ),
80 switchRef: vi.fn<GitVcsDriver.GitVcsDriverShape["switchRef"]>((request) =>
80 switchRef: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["switchRef"]>((request) =>
8181 Effect.succeed({ refName: request.refName }),
8282 ),
83 listLocalBranchNames: vi.fn<GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"]>(() =>
83 listLocalBranchNames: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"]>(() =>
8484 Effect.succeed([]),
8585 ),
8686 };
8787 const git = {
8888 ...gitMock,
8989 ...input.git,
90 } satisfies Partial<GitVcsDriver.GitVcsDriverShape>;
90 } satisfies Partial<GitVcsDriver.GitVcsDriver["Service"]>;
9191
9292 const driver = {
9393 listRemotes: () =>
......@@ -106,7 +106,7 @@ function makeLayer(input: {
106106 expiresAt: Option.none(),
107107 },
108108 }),
109 } satisfies Partial<VcsDriver.VcsDriverShape>;
109 } satisfies Partial<VcsDriver.VcsDriver["Service"]>;
110110
111111 const layer = BitbucketApi.layer.pipe(
112112 Layer.provide(
......@@ -130,7 +130,7 @@ function makeLayer(input: {
130130 expiresAt: Option.none(),
131131 },
132132 },
133 driver: driver as unknown as VcsDriver.VcsDriverShape,
133 driver: driver as unknown as VcsDriver.VcsDriver["Service"],
134134 }),
135135 }),
136136 ),
apps/server/src/sourceControl/BitbucketApi.ts+64-76
......@@ -15,7 +15,12 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
1515import { sanitizeBranchFragment } from "@t3tools/shared/git";
1616import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
1717
18import * as BitbucketPullRequests from "./bitbucketPullRequests.ts";
18import {
19 BitbucketPullRequestListSchema,
20 BitbucketPullRequestSchema,
21 normalizeBitbucketPullRequestRecord,
22 type NormalizedBitbucketPullRequestRecord,
23} from "./bitbucketPullRequests.ts";
1924import * as SourceControlProvider from "./SourceControlProvider.ts";
2025import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
2126import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
......@@ -44,7 +49,7 @@ export class BitbucketApiError extends Schema.TaggedErrorClass<BitbucketApiError
4449 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
4550 }
4651}
47const isBitbucketApiErrorValue = Schema.is(BitbucketApiError);
52const isBitbucketApiError = Schema.is(BitbucketApiError);
4853
4954const RawBitbucketRepositorySchema = Schema.Struct({
5055 full_name: TrimmedNonEmptyString,
......@@ -100,62 +105,55 @@ export interface BitbucketRepositoryLocator {
100105 readonly repoSlug: string;
101106}
102107
103export interface BitbucketApiShape {
104 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
105 readonly listPullRequests: (input: {
106 readonly cwd: string;
107 readonly context?: SourceControlProvider.SourceControlProviderContext;
108 readonly headSelector: string;
109 readonly source?: SourceControlProvider.SourceControlRefSelector;
110 readonly state: "open" | "closed" | "merged" | "all";
111 readonly limit?: number;
112 }) => Effect.Effect<
113 ReadonlyArray<BitbucketPullRequests.NormalizedBitbucketPullRequestRecord>,
114 BitbucketApiError
115 >;
116 readonly getPullRequest: (input: {
117 readonly cwd: string;
118 readonly context?: SourceControlProvider.SourceControlProviderContext;
119 readonly reference: string;
120 }) => Effect.Effect<
121 BitbucketPullRequests.NormalizedBitbucketPullRequestRecord,
122 BitbucketApiError
123 >;
124 readonly getRepositoryCloneUrls: (input: {
125 readonly cwd: string;
126 readonly context?: SourceControlProvider.SourceControlProviderContext;
127 readonly repository: string;
128 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
129 readonly createRepository: (input: {
130 readonly cwd: string;
131 readonly repository: string;
132 readonly visibility: SourceControlRepositoryVisibility;
133 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
134 readonly createPullRequest: (input: {
135 readonly cwd: string;
136 readonly context?: SourceControlProvider.SourceControlProviderContext;
137 readonly baseBranch: string;
138 readonly headSelector: string;
139 readonly source?: SourceControlProvider.SourceControlRefSelector;
140 readonly target?: SourceControlProvider.SourceControlRefSelector;
141 readonly title: string;
142 readonly bodyFile: string;
143 }) => Effect.Effect<void, BitbucketApiError>;
144 readonly getDefaultBranch: (input: {
145 readonly cwd: string;
146 readonly context?: SourceControlProvider.SourceControlProviderContext;
147 }) => Effect.Effect<string | null, BitbucketApiError>;
148 readonly checkoutPullRequest: (input: {
149 readonly cwd: string;
150 readonly context?: SourceControlProvider.SourceControlProviderContext;
151 readonly reference: string;
152 readonly force?: boolean;
153 }) => Effect.Effect<void, BitbucketApiError>;
154}
155
156export class BitbucketApi extends Context.Service<BitbucketApi, BitbucketApiShape>()(
157 "t3/sourceControl/BitbucketApi",
158) {}
108export class BitbucketApi extends Context.Service<
109 BitbucketApi,
110 {
111 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
112 readonly listPullRequests: (input: {
113 readonly cwd: string;
114 readonly context?: SourceControlProvider.SourceControlProviderContext;
115 readonly headSelector: string;
116 readonly source?: SourceControlProvider.SourceControlRefSelector;
117 readonly state: "open" | "closed" | "merged" | "all";
118 readonly limit?: number;
119 }) => Effect.Effect<ReadonlyArray<NormalizedBitbucketPullRequestRecord>, BitbucketApiError>;
120 readonly getPullRequest: (input: {
121 readonly cwd: string;
122 readonly context?: SourceControlProvider.SourceControlProviderContext;
123 readonly reference: string;
124 }) => Effect.Effect<NormalizedBitbucketPullRequestRecord, BitbucketApiError>;
125 readonly getRepositoryCloneUrls: (input: {
126 readonly cwd: string;
127 readonly context?: SourceControlProvider.SourceControlProviderContext;
128 readonly repository: string;
129 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
130 readonly createRepository: (input: {
131 readonly cwd: string;
132 readonly repository: string;
133 readonly visibility: SourceControlRepositoryVisibility;
134 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
135 readonly createPullRequest: (input: {
136 readonly cwd: string;
137 readonly context?: SourceControlProvider.SourceControlProviderContext;
138 readonly baseBranch: string;
139 readonly headSelector: string;
140 readonly source?: SourceControlProvider.SourceControlRefSelector;
141 readonly target?: SourceControlProvider.SourceControlRefSelector;
142 readonly title: string;
143 readonly bodyFile: string;
144 }) => Effect.Effect<void, BitbucketApiError>;
145 readonly getDefaultBranch: (input: {
146 readonly cwd: string;
147 readonly context?: SourceControlProvider.SourceControlProviderContext;
148 }) => Effect.Effect<string | null, BitbucketApiError>;
149 readonly checkoutPullRequest: (input: {
150 readonly cwd: string;
151 readonly context?: SourceControlProvider.SourceControlProviderContext;
152 readonly reference: string;
153 readonly force?: boolean;
154 }) => Effect.Effect<void, BitbucketApiError>;
155 }
156>()("t3/sourceControl/BitbucketApi") {}
159157
160158function nonEmpty(value: string | undefined): Option.Option<string> {
161159 const trimmed = value?.trim();
......@@ -299,9 +297,7 @@ function checkoutBranchName(input: {
299297}
300298
301299function repositoryNameWithOwner(
302 repository: Schema.Schema.Type<
303 typeof BitbucketPullRequests.BitbucketPullRequestSchema
304 >["source"]["repository"],
300 repository: Schema.Schema.Type<typeof BitbucketPullRequestSchema>["source"]["repository"],
305301): string | null {
306302 const fullName = repository?.full_name?.trim() ?? "";
307303 return fullName.length > 0 ? fullName : null;
......@@ -350,10 +346,6 @@ function requestError(operation: string, cause: unknown): BitbucketApiError {
350346 });
351347}
352348
353function isBitbucketApiError(cause: unknown): cause is BitbucketApiError {
354 return isBitbucketApiErrorValue(cause);
355}
356
357349function responseError(
358350 operation: string,
359351 response: HttpClientResponse.HttpClientResponse,
......@@ -375,7 +367,7 @@ function responseError(
375367 );
376368}
377369
378export const make = Effect.fn("makeBitbucketApi")(function* () {
370export const make = Effect.gen(function* () {
379371 const config = yield* BitbucketApiEnvConfig;
380372 const httpClient = yield* HttpClient.HttpClient;
381373 const fileSystem = yield* FileSystem.FileSystem;
......@@ -511,7 +503,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {
511503 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`,
512504 ),
513505 ),
514 BitbucketPullRequests.BitbucketPullRequestSchema,
506 BitbucketPullRequestSchema,
515507 );
516508
517509 const getRawPullRequest = (input: {
......@@ -599,17 +591,13 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {
599591 ),
600592 { urlParams: query },
601593 ),
602 BitbucketPullRequests.BitbucketPullRequestListSchema,
594 BitbucketPullRequestListSchema,
603595 );
604596 }),
605 Effect.map((list) =>
606 list.values.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord),
607 ),
597 Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)),
608598 ),
609599 getPullRequest: (input) =>
610 getRawPullRequest(input).pipe(
611 Effect.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord),
612 ),
600 getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)),
613601 getRepositoryCloneUrls: (input) =>
614602 getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)),
615603 createRepository: (input) =>
......@@ -675,7 +663,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {
675663 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
676664 ),
677665 ).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
678 BitbucketPullRequests.BitbucketPullRequestSchema,
666 BitbucketPullRequestSchema,
679667 );
680668 }),
681669 getDefaultBranch: (input) =>
......@@ -766,4 +754,4 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {
766754 });
767755});
768756
769export const layer = Layer.effect(BitbucketApi, make());
757export const layer = Layer.effect(BitbucketApi, make);
apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts+7-5
......@@ -6,8 +6,8 @@ import * as Option from "effect/Option";
66import * as BitbucketApi from "./BitbucketApi.ts";
77import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
88
9function makeProvider(bitbucket: Partial<BitbucketApi.BitbucketApiShape>) {
10 return BitbucketSourceControlProvider.make().pipe(
9function makeProvider(bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>) {
10 return BitbucketSourceControlProvider.make.pipe(
1111 Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)),
1212 );
1313}
......@@ -53,7 +53,8 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", (
5353
5454it.effect("lists Bitbucket PRs through provider-neutral input names", () =>
5555 Effect.gen(function* () {
56 let listInput: Parameters<BitbucketApi.BitbucketApiShape["listPullRequests"]>[0] | null = null;
56 let listInput: Parameters<BitbucketApi.BitbucketApi["Service"]["listPullRequests"]>[0] | null =
57 null;
5758 const provider = yield* makeProvider({
5859 listPullRequests: (input) => {
5960 listInput = input;
......@@ -79,8 +80,9 @@ it.effect("lists Bitbucket PRs through provider-neutral input names", () =>
7980
8081it.effect("creates Bitbucket PRs through provider-neutral input names", () =>
8182 Effect.gen(function* () {
82 let createInput: Parameters<BitbucketApi.BitbucketApiShape["createPullRequest"]>[0] | null =
83 null;
83 let createInput:
84 | Parameters<BitbucketApi.BitbucketApi["Service"]["createPullRequest"]>[0]
85 | null = null;
8486 const provider = yield* makeProvider({
8587 createPullRequest: (input) => {
8688 createInput = input;
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts+7-9
......@@ -4,9 +4,9 @@ import * as Option from "effect/Option";
44import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
55
66import * as BitbucketApi from "./BitbucketApi.ts";
7import * as BitbucketPullRequests from "./bitbucketPullRequests.ts";
7import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts";
88import * as SourceControlProvider from "./SourceControlProvider.ts";
9import type * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
9import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts";
1010
1111function providerError(
1212 operation: string,
......@@ -20,9 +20,7 @@ function providerError(
2020 });
2121}
2222
23function toChangeRequest(
24 summary: BitbucketPullRequests.NormalizedBitbucketPullRequestRecord,
25): ChangeRequest {
23function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest {
2624 return {
2725 provider: "bitbucket",
2826 number: summary.number,
......@@ -44,7 +42,7 @@ function toChangeRequest(
4442 };
4543}
4644
47export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () {
45export const make = Effect.gen(function* () {
4846 const bitbucket = yield* BitbucketApi.BitbucketApi;
4947
5048 return SourceControlProvider.SourceControlProvider.of({
......@@ -112,9 +110,9 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* ()
112110 });
113111});
114112
115export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make());
113export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make);
116114
117export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscovery")(function* () {
115export const makeDiscovery = Effect.gen(function* () {
118116 const bitbucket = yield* BitbucketApi.BitbucketApi;
119117
120118 return {
......@@ -124,5 +122,5 @@ export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscov
124122 installHint:
125123 "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request and repository scopes).",
126124 probeAuth: bitbucket.probeAuth,
127 } satisfies SourceControlProviderDiscovery.SourceControlApiDiscoverySpec;
125 } satisfies SourceControlApiDiscoverySpec;
128126});
apps/server/src/sourceControl/GitHubCli.test.ts+1-1
......@@ -15,7 +15,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
1515 stderrTruncated: false,
1616});
1717
18const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();
18const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>();
1919
2020const layer = GitHubCli.layer.pipe(
2121 Layer.provide(
apps/server/src/sourceControl/GitHubCli.ts+62-59
......@@ -12,7 +12,11 @@ import {
1212} from "@t3tools/contracts";
1313
1414import * as VcsProcess from "../vcs/VcsProcess.ts";
15import * as GitHubPullRequests from "./gitHubPullRequests.ts";
15import {
16 decodeGitHubPullRequestJson,
17 decodeGitHubPullRequestListJson,
18 formatGitHubJsonDecodeError,
19} from "./gitHubPullRequests.ts";
1620
1721const DEFAULT_TIMEOUT_MS = 30_000;
1822
......@@ -44,57 +48,56 @@ export interface GitHubRepositoryCloneUrls {
4448 readonly sshUrl: string;
4549}
4650
47export interface GitHubCliShape {
48 readonly execute: (input: {
49 readonly cwd: string;
50 readonly args: ReadonlyArray<string>;
51 readonly timeoutMs?: number;
52 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>;
53
54 readonly listOpenPullRequests: (input: {
55 readonly cwd: string;
56 readonly headSelector: string;
57 readonly limit?: number;
58 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;
59
60 readonly getPullRequest: (input: {
61 readonly cwd: string;
62 readonly reference: string;
63 }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>;
64
65 readonly getRepositoryCloneUrls: (input: {
66 readonly cwd: string;
67 readonly repository: string;
68 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
69
70 readonly createRepository: (input: {
71 readonly cwd: string;
72 readonly repository: string;
73 readonly visibility: SourceControlRepositoryVisibility;
74 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
75
76 readonly createPullRequest: (input: {
77 readonly cwd: string;
78 readonly baseBranch: string;
79 readonly headSelector: string;
80 readonly title: string;
81 readonly bodyFile: string;
82 }) => Effect.Effect<void, GitHubCliError>;
83
84 readonly getDefaultBranch: (input: {
85 readonly cwd: string;
86 }) => Effect.Effect<string | null, GitHubCliError>;
87
88 readonly checkoutPullRequest: (input: {
89 readonly cwd: string;
90 readonly reference: string;
91 readonly force?: boolean;
92 }) => Effect.Effect<void, GitHubCliError>;
93}
94
95export class GitHubCli extends Context.Service<GitHubCli, GitHubCliShape>()(
96 "t3/sourceControl/GitHubCli",
97) {}
51export class GitHubCli extends Context.Service<
52 GitHubCli,
53 {
54 readonly execute: (input: {
55 readonly cwd: string;
56 readonly args: ReadonlyArray<string>;
57 readonly timeoutMs?: number;
58 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>;
59
60 readonly listOpenPullRequests: (input: {
61 readonly cwd: string;
62 readonly headSelector: string;
63 readonly limit?: number;
64 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;
65
66 readonly getPullRequest: (input: {
67 readonly cwd: string;
68 readonly reference: string;
69 }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>;
70
71 readonly getRepositoryCloneUrls: (input: {
72 readonly cwd: string;
73 readonly repository: string;
74 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
75
76 readonly createRepository: (input: {
77 readonly cwd: string;
78 readonly repository: string;
79 readonly visibility: SourceControlRepositoryVisibility;
80 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
81
82 readonly createPullRequest: (input: {
83 readonly cwd: string;
84 readonly baseBranch: string;
85 readonly headSelector: string;
86 readonly title: string;
87 readonly bodyFile: string;
88 }) => Effect.Effect<void, GitHubCliError>;
89
90 readonly getDefaultBranch: (input: {
91 readonly cwd: string;
92 }) => Effect.Effect<string | null, GitHubCliError>;
93
94 readonly checkoutPullRequest: (input: {
95 readonly cwd: string;
96 readonly reference: string;
97 readonly force?: boolean;
98 }) => Effect.Effect<void, GitHubCliError>;
99 }
100>()("t3/sourceControl/GitHubCli") {}
98101
99102function errorText(error: VcsError | unknown): string {
100103 if (typeof error === "object" && error !== null) {
......@@ -226,10 +229,10 @@ function decodeGitHubJson<S extends Schema.Top>(
226229 );
227230}
228231
229export const make = Effect.fn("makeGitHubCli")(function* () {
232export const make = Effect.gen(function* () {
230233 const process = yield* VcsProcess.VcsProcess;
231234
232 const execute: GitHubCliShape["execute"] = (input) =>
235 const execute: GitHubCli["Service"]["execute"] = (input) =>
233236 process
234237 .run({
235238 operation: "GitHubCli.execute",
......@@ -262,13 +265,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () {
262265 Effect.flatMap((raw) =>
263266 raw.length === 0
264267 ? Effect.succeed([])
265 : Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe(
268 : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
266269 Effect.flatMap((decoded) => {
267270 if (!Result.isSuccess(decoded)) {
268271 return Effect.fail(
269272 new GitHubCliError({
270273 operation: "listOpenPullRequests",
271 detail: `GitHub CLI returned invalid PR list JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`,
274 detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
272275 cause: decoded.failure,
273276 }),
274277 );
......@@ -294,13 +297,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () {
294297 }).pipe(
295298 Effect.map((result) => result.stdout.trim()),
296299 Effect.flatMap((raw) =>
297 Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestJson(raw)).pipe(
300 Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe(
298301 Effect.flatMap((decoded) => {
299302 if (!Result.isSuccess(decoded)) {
300303 return Effect.fail(
301304 new GitHubCliError({
302305 operation: "getPullRequest",
303 detail: `GitHub CLI returned invalid pull request JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`,
306 detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
304307 cause: decoded.failure,
305308 }),
306309 );
......@@ -372,4 +375,4 @@ export const make = Effect.fn("makeGitHubCli")(function* () {
372375 });
373376});
374377
375export const layer = Layer.effect(GitHubCli, make());
378export const layer = Layer.effect(GitHubCli, make);
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+4-3
......@@ -24,8 +24,8 @@ const processResult = (
2424 stderrTruncated: false,
2525});
2626
27function makeProvider(github: Partial<GitHubCli.GitHubCliShape>) {
28 return GitHubSourceControlProvider.make().pipe(
27function makeProvider(github: Partial<GitHubCli.GitHubCli["Service"]>) {
28 return GitHubSourceControlProvider.make.pipe(
2929 Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)),
3030 );
3131}
......@@ -139,7 +139,8 @@ it.effect("treats empty non-open change request listing output as no results", (
139139
140140it.effect("creates GitHub PRs through provider-neutral input names", () =>
141141 Effect.gen(function* () {
142 let createInput: Parameters<GitHubCli.GitHubCliShape["createPullRequest"]>[0] | null = null;
142 let createInput: Parameters<GitHubCli.GitHubCli["Service"]["createPullRequest"]>[0] | null =
143 null;
143144 const provider = yield* makeProvider({
144145 createPullRequest: (input) => {
145146 createInput = input;
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+21-19
......@@ -11,9 +11,15 @@ import {
1111
1212import * as GitHubCli from "./GitHubCli.ts";
1313import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
14import * as GitHubPullRequests from "./gitHubPullRequests.ts";
14import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts";
1515import * as SourceControlProvider from "./SourceControlProvider.ts";
16import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
16import {
17 combinedAuthOutput,
18 firstSafeAuthLine,
19 providerAuth,
20 type SourceControlAuthProbeInput,
21 type SourceControlCliDiscoverySpec,
22} from "./SourceControlProviderDiscovery.ts";
1723const isSourceControlProviderError = Schema.is(SourceControlProviderError);
1824
1925function providerError(
......@@ -50,14 +56,14 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
5056 };
5157}
5258
53function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {
54 const output = SourceControlProviderDiscovery.combinedAuthOutput(input);
59function parseGitHubAuth(input: SourceControlAuthProbeInput) {
60 const output = combinedAuthOutput(input);
5561 const authStatus = parseGitHubAuthStatus(input.stdout);
5662 const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts);
5763 const host = authenticatedAccount?.host;
5864
5965 if (authenticatedAccount) {
60 return SourceControlProviderDiscovery.providerAuth({
66 return providerAuth({
6167 status: "authenticated",
6268 account: authenticatedAccount.account,
6369 host,
......@@ -66,7 +72,7 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth
6672
6773 const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0];
6874 if (authStatus.parsed) {
69 return SourceControlProviderDiscovery.providerAuth({
75 return providerAuth({
7076 status: "unauthenticated",
7177 host: failedAccount?.host,
7278 detail:
......@@ -76,21 +82,17 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth
7682 }
7783
7884 if (input.exitCode !== 0) {
79 return SourceControlProviderDiscovery.providerAuth({
85 return providerAuth({
8086 status: "unauthenticated",
8187 host,
82 detail:
83 SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
84 "Run `gh auth login` to authenticate GitHub CLI.",
88 detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.",
8589 });
8690 }
8791
88 return SourceControlProviderDiscovery.providerAuth({
92 return providerAuth({
8993 status: "unknown",
9094 host,
91 detail:
92 SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
93 "GitHub CLI auth status could not be parsed.",
95 detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.",
9496 });
9597}
9698
......@@ -104,12 +106,12 @@ export const discovery = {
104106 parseAuth: parseGitHubAuth,
105107 installHint:
106108 "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).",
107} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec;
109} satisfies SourceControlCliDiscoverySpec;
108110
109export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {
111export const make = Effect.gen(function* () {
110112 const github = yield* GitHubCli.GitHubCli;
111113
112 const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] =
114 const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] =
113115 (input) => {
114116 if (input.state === "open") {
115117 return github
......@@ -147,7 +149,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {
147149 if (raw.length === 0) {
148150 return Effect.succeed([]);
149151 }
150 return Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe(
152 return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
151153 Effect.flatMap((decoded) =>
152154 Result.isSuccess(decoded)
153155 ? Effect.succeed(
......@@ -212,4 +214,4 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {
212214 });
213215});
214216
215export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make());
217export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make);
apps/server/src/sourceControl/GitLabCli.test.ts+1-1
......@@ -8,7 +8,7 @@ import { VcsProcessExitError } from "@t3tools/contracts";
88import * as VcsProcess from "../vcs/VcsProcess.ts";
99import * as GitLabCli from "./GitLabCli.ts";
1010
11const mockedRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();
11const mockedRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>();
1212const layer = it.layer(
1313 GitLabCli.layer.pipe(
1414 Layer.provide(
apps/server/src/sourceControl/GitLabCli.ts+66-63
......@@ -10,7 +10,11 @@ import type * as DateTime from "effect/DateTime";
1010import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
1111
1212import * as VcsProcess from "../vcs/VcsProcess.ts";
13import * as GitLabMergeRequests from "./gitLabMergeRequests.ts";
13import {
14 decodeGitLabMergeRequestJson,
15 decodeGitLabMergeRequestListJson,
16 formatGitLabJsonDecodeError,
17} from "./gitLabMergeRequests.ts";
1418import type * as SourceControlProvider from "./SourceControlProvider.ts";
1519
1620const DEFAULT_TIMEOUT_MS = 30_000;
......@@ -44,61 +48,60 @@ export interface GitLabRepositoryCloneUrls {
4448 readonly sshUrl: string;
4549}
4650
47export interface GitLabCliShape {
48 readonly execute: (input: {
49 readonly cwd: string;
50 readonly args: ReadonlyArray<string>;
51 readonly timeoutMs?: number;
52 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitLabCliError>;
53
54 readonly listMergeRequests: (input: {
55 readonly cwd: string;
56 readonly headSelector: string;
57 readonly source?: SourceControlProvider.SourceControlRefSelector;
58 readonly state: "open" | "closed" | "merged" | "all";
59 readonly limit?: number;
60 }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>;
61
62 readonly getMergeRequest: (input: {
63 readonly cwd: string;
64 readonly reference: string;
65 }) => Effect.Effect<GitLabMergeRequestSummary, GitLabCliError>;
66
67 readonly getRepositoryCloneUrls: (input: {
68 readonly cwd: string;
69 readonly repository: string;
70 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
71
72 readonly createRepository: (input: {
73 readonly cwd: string;
74 readonly repository: string;
75 readonly visibility: SourceControlRepositoryVisibility;
76 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
77
78 readonly createMergeRequest: (input: {
79 readonly cwd: string;
80 readonly baseBranch: string;
81 readonly headSelector: string;
82 readonly source?: SourceControlProvider.SourceControlRefSelector;
83 readonly target?: SourceControlProvider.SourceControlRefSelector;
84 readonly title: string;
85 readonly bodyFile: string;
86 }) => Effect.Effect<void, GitLabCliError>;
87
88 readonly getDefaultBranch: (input: {
89 readonly cwd: string;
90 }) => Effect.Effect<string | null, GitLabCliError>;
91
92 readonly checkoutMergeRequest: (input: {
93 readonly cwd: string;
94 readonly reference: string;
95 readonly force?: boolean;
96 }) => Effect.Effect<void, GitLabCliError>;
97}
98
99export class GitLabCli extends Context.Service<GitLabCli, GitLabCliShape>()(
100 "t3/sourceControl/GitLabCli",
101) {}
51export class GitLabCli extends Context.Service<
52 GitLabCli,
53 {
54 readonly execute: (input: {
55 readonly cwd: string;
56 readonly args: ReadonlyArray<string>;
57 readonly timeoutMs?: number;
58 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitLabCliError>;
59
60 readonly listMergeRequests: (input: {
61 readonly cwd: string;
62 readonly headSelector: string;
63 readonly source?: SourceControlProvider.SourceControlRefSelector;
64 readonly state: "open" | "closed" | "merged" | "all";
65 readonly limit?: number;
66 }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>;
67
68 readonly getMergeRequest: (input: {
69 readonly cwd: string;
70 readonly reference: string;
71 }) => Effect.Effect<GitLabMergeRequestSummary, GitLabCliError>;
72
73 readonly getRepositoryCloneUrls: (input: {
74 readonly cwd: string;
75 readonly repository: string;
76 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
77
78 readonly createRepository: (input: {
79 readonly cwd: string;
80 readonly repository: string;
81 readonly visibility: SourceControlRepositoryVisibility;
82 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
83
84 readonly createMergeRequest: (input: {
85 readonly cwd: string;
86 readonly baseBranch: string;
87 readonly headSelector: string;
88 readonly source?: SourceControlProvider.SourceControlRefSelector;
89 readonly target?: SourceControlProvider.SourceControlRefSelector;
90 readonly title: string;
91 readonly bodyFile: string;
92 }) => Effect.Effect<void, GitLabCliError>;
93
94 readonly getDefaultBranch: (input: {
95 readonly cwd: string;
96 }) => Effect.Effect<string | null, GitLabCliError>;
97
98 readonly checkoutMergeRequest: (input: {
99 readonly cwd: string;
100 readonly reference: string;
101 readonly force?: boolean;
102 }) => Effect.Effect<void, GitLabCliError>;
103 }
104>()("t3/sourceControl/GitLabCli") {}
102105
103106function isVcsProcessSpawnError(error: unknown): boolean {
104107 return (
......@@ -259,10 +262,10 @@ function parseRepositoryPath(repository: string): {
259262 return { namespacePath, projectPath };
260263}
261264
262export const make = Effect.fn("makeGitLabCli")(function* () {
265export const make = Effect.gen(function* () {
263266 const process = yield* VcsProcess.VcsProcess;
264267
265 const execute: GitLabCliShape["execute"] = (input) =>
268 const execute: GitLabCli["Service"]["execute"] = (input) =>
266269 process
267270 .run({
268271 operation: "GitLabCli.execute",
......@@ -294,13 +297,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () {
294297 Effect.flatMap((raw) =>
295298 raw.length === 0
296299 ? Effect.succeed([])
297 : Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestListJson(raw)).pipe(
300 : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe(
298301 Effect.flatMap((decoded) => {
299302 if (!Result.isSuccess(decoded)) {
300303 return Effect.fail(
301304 new GitLabCliError({
302305 operation: "listMergeRequests",
303 detail: `GitLab CLI returned invalid MR list JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`,
306 detail: `GitLab CLI returned invalid MR list JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`,
304307 cause: decoded.failure,
305308 }),
306309 );
......@@ -318,13 +321,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () {
318321 }).pipe(
319322 Effect.map((result) => result.stdout.trim()),
320323 Effect.flatMap((raw) =>
321 Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestJson(raw)).pipe(
324 Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe(
322325 Effect.flatMap((decoded) => {
323326 if (!Result.isSuccess(decoded)) {
324327 return Effect.fail(
325328 new GitLabCliError({
326329 operation: "getMergeRequest",
327 detail: `GitLab CLI returned invalid merge request JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`,
330 detail: `GitLab CLI returned invalid merge request JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`,
328331 cause: decoded.failure,
329332 }),
330333 );
......@@ -449,4 +452,4 @@ export const make = Effect.fn("makeGitLabCli")(function* () {
449452 });
450453});
451454
452export const layer = Layer.effect(GitLabCli, make());
455export const layer = Layer.effect(GitLabCli, make);
apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts+5-4
......@@ -8,8 +8,8 @@ import * as GitLabCli from "./GitLabCli.ts";
88import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
99import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
1010
11function makeProvider(gitlab: Partial<GitLabCli.GitLabCliShape>) {
12 return GitLabSourceControlProvider.make().pipe(
11function makeProvider(gitlab: Partial<GitLabCli.GitLabCli["Service"]>) {
12 return GitLabSourceControlProvider.make.pipe(
1313 Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)),
1414 );
1515}
......@@ -54,7 +54,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () =
5454
5555it.effect("lists GitLab MRs through provider-neutral input names", () =>
5656 Effect.gen(function* () {
57 let listInput: Parameters<GitLabCli.GitLabCliShape["listMergeRequests"]>[0] | null = null;
57 let listInput: Parameters<GitLabCli.GitLabCli["Service"]["listMergeRequests"]>[0] | null = null;
5858 const provider = yield* makeProvider({
5959 listMergeRequests: (input) => {
6060 listInput = input;
......@@ -80,7 +80,8 @@ it.effect("lists GitLab MRs through provider-neutral input names", () =>
8080
8181it.effect("creates GitLab MRs through provider-neutral input names", () =>
8282 Effect.gen(function* () {
83 let createInput: Parameters<GitLabCli.GitLabCliShape["createMergeRequest"]>[0] | null = null;
83 let createInput: Parameters<GitLabCli.GitLabCli["Service"]["createMergeRequest"]>[0] | null =
84 null;
8485 const provider = yield* makeProvider({
8586 createMergeRequest: (input) => {
8687 createInput = input;
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+26-23
......@@ -5,7 +5,16 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac
55
66import * as GitLabCli from "./GitLabCli.ts";
77import * as SourceControlProvider from "./SourceControlProvider.ts";
8import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
8import {
9 combinedAuthOutput,
10 firstSafeAuthLine,
11 matchFirst,
12 parseCliHost,
13 providerAuth,
14 type SourceControlAuthProbeInput,
15 type SourceControlCliDiscoverySpec,
16 type SourceControlUnknownRemoteRefinementInput,
17} from "./SourceControlProviderDiscovery.ts";
918import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
1019
1120function providerError(
......@@ -42,48 +51,42 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe
4251 };
4352}
4453
45function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {
46 const output = SourceControlProviderDiscovery.combinedAuthOutput(input);
54function parseGitLabAuth(input: SourceControlAuthProbeInput) {
55 const output = combinedAuthOutput(input);
4756 const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output));
4857 const account =
4958 authenticatedHost?.account ??
50 SourceControlProviderDiscovery.matchFirst(output, [
59 matchFirst(output, [
5160 /Logged in to .* as\s+([^\s(]+)/iu,
5261 /Logged in to .* account\s+([^\s(]+)/iu,
5362 /account:\s*([^\s(]+)/iu,
5463 ]);
55 const host = authenticatedHost?.host ?? SourceControlProviderDiscovery.parseCliHost(output);
64 const host = authenticatedHost?.host ?? parseCliHost(output);
5665
5766 if (account) {
58 return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host });
67 return providerAuth({ status: "authenticated", account, host });
5968 }
6069
6170 if (input.exitCode !== 0) {
62 return SourceControlProviderDiscovery.providerAuth({
71 return providerAuth({
6372 status: "unauthenticated",
6473 host,
65 detail:
66 SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
67 "Run `glab auth login` to authenticate GitLab CLI.",
74 detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.",
6875 });
6976 }
7077
71 return SourceControlProviderDiscovery.providerAuth({
78 return providerAuth({
7279 status: "unknown",
7380 host,
74 detail:
75 SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
76 "GitLab CLI auth status could not be parsed.",
81 detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.",
7782 });
7883}
7984
80function refineUnknownGitLabRemote(
81 input: SourceControlProviderDiscovery.SourceControlUnknownRemoteRefinementInput,
82) {
85function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementInput) {
8386 const host = input.context.provider.name.toLowerCase();
84 const authenticated = parseGitLabAuthStatusHosts(
85 SourceControlProviderDiscovery.combinedAuthOutput(input.auth),
86 ).some((entry) => entry.account !== null && entry.host === host);
87 const authenticated = parseGitLabAuthStatusHosts(combinedAuthOutput(input.auth)).some(
88 (entry) => entry.account !== null && entry.host === host,
89 );
8790
8891 if (!authenticated) {
8992 return null;
......@@ -107,9 +110,9 @@ export const discovery = {
107110 refineUnknownRemote: refineUnknownGitLabRemote,
108111 installHint:
109112 "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).",
110} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec;
113} satisfies SourceControlCliDiscoverySpec;
111114
112export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () {
115export const make = Effect.gen(function* () {
113116 const gitlab = yield* GitLabCli.GitLabCli;
114117
115118 return SourceControlProvider.SourceControlProvider.of({
......@@ -167,4 +170,4 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () {
167170 });
168171});
169172
170export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make());
173export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make);
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+14-10
......@@ -6,7 +6,7 @@ import * as Option from "effect/Option";
66import { ChildProcessSpawner } from "effect/unstable/process";
77import { VcsProcessSpawnError } from "@t3tools/contracts";
88
9import { ServerConfig } from "../config.ts";
9import * as ServerConfig from "../config.ts";
1010import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
1111import * as VcsProcess from "../vcs/VcsProcess.ts";
1212import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
......@@ -17,15 +17,15 @@ import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
1717import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
1818
1919const sourceControlProviderRegistryTestLayer = (input: {
20 readonly bitbucket: Partial<BitbucketApi.BitbucketApiShape>;
21 readonly process: Partial<VcsProcess.VcsProcessShape>;
20 readonly bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>;
21 readonly process: Partial<VcsProcess.VcsProcess["Service"]>;
2222}) =>
2323 SourceControlProviderRegistry.layer.pipe(
2424 Layer.provide(
2525 Layer.mergeAll(
26 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe(
27 Layer.provide(NodeServices.layer),
28 ),
26 ServerConfig.layerTest(process.cwd(), {
27 prefix: "t3-source-control-registry-test-",
28 }).pipe(Layer.provide(NodeServices.layer)),
2929 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
3030 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
3131 Layer.mock(GitHubCli.GitHubCli)({}),
......@@ -88,10 +88,12 @@ it.effect("reports implemented tools separately from locally available executabl
8888 }),
8989 );
9090 },
91 } satisfies Partial<VcsProcess.VcsProcessShape>;
91 } satisfies Partial<VcsProcess.VcsProcess["Service"]>;
9292 const testLayer = SourceControlDiscovery.layer.pipe(
9393 Layer.provide(
94 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }),
94 ServerConfig.layerTest(process.cwd(), {
95 prefix: "t3-source-control-discovery-",
96 }),
9597 ),
9698 Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)),
9799 Layer.provide(
......@@ -215,10 +217,12 @@ Logged in to gitlab.com as gitlab-user
215217 }),
216218 );
217219 },
218 } satisfies Partial<VcsProcess.VcsProcessShape>;
220 } satisfies Partial<VcsProcess.VcsProcess["Service"]>;
219221 const testLayer = SourceControlDiscovery.layer.pipe(
220222 Layer.provide(
221 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }),
223 ServerConfig.layerTest(process.cwd(), {
224 prefix: "t3-source-control-auth-discovery-",
225 }),
222226 ),
223227 Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)),
224228 Layer.provide(
apps/server/src/sourceControl/SourceControlDiscovery.ts+71-76
......@@ -10,7 +10,7 @@ import * as Option from "effect/Option";
1010
1111import { ServerConfig } from "../config.ts";
1212import * as VcsProcess from "../vcs/VcsProcess.ts";
13import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
13import { detailFromCause, firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts";
1414import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
1515
1616interface DiscoveryProbe {
......@@ -57,91 +57,86 @@ const VCS_PROBES: ReadonlyArray<VcsProbe> = [
5757 },
5858];
5959
60export interface SourceControlDiscoveryShape {
61 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
62}
63
6460export class SourceControlDiscovery extends Context.Service<
6561 SourceControlDiscovery,
66 SourceControlDiscoveryShape
62 {
63 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
64 }
6765>()("t3/sourceControl/SourceControlDiscovery") {}
6866
69export const layer = Layer.effect(
70 SourceControlDiscovery,
71 Effect.gen(function* () {
72 const config = yield* ServerConfig;
73 const process = yield* VcsProcess.VcsProcess;
74 const sourceControlProviders =
75 yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
67export const make = Effect.gen(function* () {
68 const config = yield* ServerConfig;
69 const process = yield* VcsProcess.VcsProcess;
70 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
7671
77 const probe = <Kind extends VcsDriverKind>(
78 input: DiscoveryProbe & { readonly kind: Kind },
79 ): Effect.Effect<DiscoveryProbeResult<Kind>> => {
80 const executable = input.executable;
81 const versionArgs = input.versionArgs;
72 const probe = <Kind extends VcsDriverKind>(
73 input: DiscoveryProbe & { readonly kind: Kind },
74 ): Effect.Effect<DiscoveryProbeResult<Kind>> => {
75 const executable = input.executable;
76 const versionArgs = input.versionArgs;
8277
83 if (!executable || !versionArgs) {
84 return Effect.succeed({
85 kind: input.kind,
86 label: input.label,
87 implemented: input.implemented,
88 status: "missing" as const,
89 version: Option.none<string>(),
90 installHint: input.installHint,
91 detail: Option.some(input.installHint),
92 } satisfies DiscoveryProbeResult<Kind>);
93 }
78 if (!executable || !versionArgs) {
79 return Effect.succeed({
80 kind: input.kind,
81 label: input.label,
82 implemented: input.implemented,
83 status: "missing" as const,
84 version: Option.none<string>(),
85 installHint: input.installHint,
86 detail: Option.some(input.installHint),
87 } satisfies DiscoveryProbeResult<Kind>);
88 }
9489
95 return process
96 .run({
97 operation: "source-control.discovery.probe",
98 command: executable,
99 args: versionArgs,
100 cwd: config.cwd,
101 timeoutMs: 5_000,
102 maxOutputBytes: 8_000,
103 appendTruncationMarker: true,
104 })
105 .pipe(
106 Effect.map(
107 (result) =>
108 ({
109 kind: input.kind,
110 label: input.label,
111 executable,
112 implemented: input.implemented,
113 status: "available" as const,
114 version: Option.orElse(
115 SourceControlProviderDiscovery.firstNonEmptyLine(result.stdout),
116 () => SourceControlProviderDiscovery.firstNonEmptyLine(result.stderr),
117 ),
118 installHint: input.installHint,
119 detail: Option.none<string>(),
120 }) satisfies DiscoveryProbeResult<Kind>,
121 ),
122 Effect.catch((cause) =>
123 Effect.succeed({
90 return process
91 .run({
92 operation: "source-control.discovery.probe",
93 command: executable,
94 args: versionArgs,
95 cwd: config.cwd,
96 timeoutMs: 5_000,
97 maxOutputBytes: 8_000,
98 appendTruncationMarker: true,
99 })
100 .pipe(
101 Effect.map(
102 (result) =>
103 ({
124104 kind: input.kind,
125105 label: input.label,
126106 executable,
127107 implemented: input.implemented,
128 status: "missing" as const,
129 version: Option.none<string>(),
108 status: "available" as const,
109 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
110 firstNonEmptyLine(result.stderr),
111 ),
130112 installHint: input.installHint,
131 detail: SourceControlProviderDiscovery.detailFromCause(cause),
132 } satisfies DiscoveryProbeResult<Kind>),
133 ),
134 );
135 };
136
137 return SourceControlDiscovery.of({
138 discover: Effect.all({
139 versionControlSystems: Effect.all(
140 VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>,
141 { concurrency: "unbounded" },
113 detail: Option.none<string>(),
114 }) satisfies DiscoveryProbeResult<Kind>,
115 ),
116 Effect.catch((cause) =>
117 Effect.succeed({
118 kind: input.kind,
119 label: input.label,
120 executable,
121 implemented: input.implemented,
122 status: "missing" as const,
123 version: Option.none<string>(),
124 installHint: input.installHint,
125 detail: detailFromCause(cause),
126 } satisfies DiscoveryProbeResult<Kind>),
142127 ),
143 sourceControlProviders: sourceControlProviders.discover,
144 }),
145 });
146 }),
147);
128 );
129 };
130
131 return SourceControlDiscovery.of({
132 discover: Effect.all({
133 versionControlSystems: Effect.all(
134 VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>,
135 { concurrency: "unbounded" },
136 ),
137 sourceControlProviders: sourceControlProviders.discover,
138 }),
139 });
140});
141
142export const layer = Layer.effect(SourceControlDiscovery, make);
apps/server/src/sourceControl/SourceControlProvider.ts+46-48
......@@ -49,54 +49,52 @@ export function sourceControlRefFromInput(input: {
4949 return input.source ?? parseSourceControlOwnerRef(input.headSelector);
5050}
5151
52export interface SourceControlProviderShape {
53 readonly kind: SourceControlProviderKind;
54 readonly listChangeRequests: (input: {
55 readonly cwd: string;
56 readonly context?: SourceControlProviderContext;
57 readonly source?: SourceControlRefSelector;
58 readonly headSelector: string;
59 readonly state: ChangeRequestState | "all";
60 readonly limit?: number;
61 }) => Effect.Effect<ReadonlyArray<ChangeRequest>, SourceControlProviderError>;
62 readonly getChangeRequest: (input: {
63 readonly cwd: string;
64 readonly context?: SourceControlProviderContext;
65 readonly reference: string;
66 }) => Effect.Effect<ChangeRequest, SourceControlProviderError>;
67 readonly createChangeRequest: (input: {
68 readonly cwd: string;
69 readonly context?: SourceControlProviderContext;
70 readonly source?: SourceControlRefSelector;
71 readonly target?: SourceControlRefSelector;
72 readonly baseRefName: string;
73 readonly headSelector: string;
74 readonly title: string;
75 readonly bodyFile: string;
76 }) => Effect.Effect<void, SourceControlProviderError>;
77 readonly getRepositoryCloneUrls: (input: {
78 readonly cwd: string;
79 readonly context?: SourceControlProviderContext;
80 readonly repository: string;
81 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
82 readonly createRepository: (input: {
83 readonly cwd: string;
84 readonly repository: string;
85 readonly visibility: SourceControlRepositoryVisibility;
86 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
87 readonly getDefaultBranch: (input: {
88 readonly cwd: string;
89 readonly context?: SourceControlProviderContext;
90 }) => Effect.Effect<string | null, SourceControlProviderError>;
91 readonly checkoutChangeRequest: (input: {
92 readonly cwd: string;
93 readonly context?: SourceControlProviderContext;
94 readonly reference: string;
95 readonly force?: boolean;
96 }) => Effect.Effect<void, SourceControlProviderError>;
97}
98
9952export class SourceControlProvider extends Context.Service<
10053 SourceControlProvider,
101 SourceControlProviderShape
54 {
55 readonly kind: SourceControlProviderKind;
56 readonly listChangeRequests: (input: {
57 readonly cwd: string;
58 readonly context?: SourceControlProviderContext;
59 readonly source?: SourceControlRefSelector;
60 readonly headSelector: string;
61 readonly state: ChangeRequestState | "all";
62 readonly limit?: number;
63 }) => Effect.Effect<ReadonlyArray<ChangeRequest>, SourceControlProviderError>;
64 readonly getChangeRequest: (input: {
65 readonly cwd: string;
66 readonly context?: SourceControlProviderContext;
67 readonly reference: string;
68 }) => Effect.Effect<ChangeRequest, SourceControlProviderError>;
69 readonly createChangeRequest: (input: {
70 readonly cwd: string;
71 readonly context?: SourceControlProviderContext;
72 readonly source?: SourceControlRefSelector;
73 readonly target?: SourceControlRefSelector;
74 readonly baseRefName: string;
75 readonly headSelector: string;
76 readonly title: string;
77 readonly bodyFile: string;
78 }) => Effect.Effect<void, SourceControlProviderError>;
79 readonly getRepositoryCloneUrls: (input: {
80 readonly cwd: string;
81 readonly context?: SourceControlProviderContext;
82 readonly repository: string;
83 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
84 readonly createRepository: (input: {
85 readonly cwd: string;
86 readonly repository: string;
87 readonly visibility: SourceControlRepositoryVisibility;
88 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
89 readonly getDefaultBranch: (input: {
90 readonly cwd: string;
91 readonly context?: SourceControlProviderContext;
92 }) => Effect.Effect<string | null, SourceControlProviderError>;
93 readonly checkoutChangeRequest: (input: {
94 readonly cwd: string;
95 readonly context?: SourceControlProviderContext;
96 readonly reference: string;
97 readonly force?: boolean;
98 }) => Effect.Effect<void, SourceControlProviderError>;
99 }
102100>()("t3/sourceControl/SourceControlProvider") {}
apps/server/src/sourceControl/SourceControlProviderDiscovery.ts+3-3
......@@ -158,7 +158,7 @@ function isCliRemoteRefinementSpec(
158158
159159function probeCli(input: {
160160 readonly spec: SourceControlCliDiscoverySpec;
161 readonly process: VcsProcess.VcsProcessShape;
161 readonly process: VcsProcess.VcsProcess["Service"];
162162 readonly cwd: string;
163163}): Effect.Effect<DiscoveryProbeResult> {
164164 return input.process
......@@ -202,7 +202,7 @@ function probeCli(input: {
202202
203203export function probeSourceControlProvider(input: {
204204 readonly spec: SourceControlProviderDiscoverySpec;
205 readonly process: VcsProcess.VcsProcessShape;
205 readonly process: VcsProcess.VcsProcess["Service"];
206206 readonly cwd: string;
207207}): Effect.Effect<SourceControlProviderDiscoveryItem> {
208208 if (input.spec.type === "api") {
......@@ -270,7 +270,7 @@ export function probeSourceControlProvider(input: {
270270export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvider")(
271271 function* (input: {
272272 readonly specs: ReadonlyArray<SourceControlProviderDiscoverySpec>;
273 readonly process: VcsProcess.VcsProcessShape;
273 readonly process: VcsProcess.VcsProcess["Service"];
274274 readonly cwd: string;
275275 readonly context: SourceControlProvider.SourceControlProviderContext | null;
276276 }): Effect.fn.Return<SourceControlProvider.SourceControlProviderContext | null> {
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+9-9
......@@ -6,7 +6,7 @@ import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
77import { ChildProcessSpawner } from "effect/unstable/process";
88
9import { ServerConfig } from "../config.ts";
9import * as ServerConfig from "../config.ts";
1010import type * as VcsDriver from "../vcs/VcsDriver.ts";
1111import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
1212import * as VcsProcess from "../vcs/VcsProcess.ts";
......@@ -37,7 +37,7 @@ function makeRegistry(input: {
3737 readonly name: string;
3838 readonly url: string;
3939 }>;
40 readonly process?: Partial<VcsProcess.VcsProcessShape>;
40 readonly process?: Partial<VcsProcess.VcsProcess["Service"]>;
4141}) {
4242 const driver = {
4343 listRemotes: () =>
......@@ -53,10 +53,10 @@ function makeRegistry(input: {
5353 expiresAt: Option.none(),
5454 },
5555 }),
56 } satisfies Partial<VcsDriver.VcsDriverShape>;
56 } satisfies Partial<VcsDriver.VcsDriver["Service"]>;
5757
5858 const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
59 get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriverShape),
59 get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriver["Service"]),
6060 resolve: () =>
6161 Effect.succeed({
6262 kind: "git",
......@@ -70,7 +70,7 @@ function makeRegistry(input: {
7070 expiresAt: Option.none(),
7171 },
7272 },
73 driver: driver as unknown as VcsDriver.VcsDriverShape,
73 driver: driver as unknown as VcsDriver.VcsDriver["Service"],
7474 }),
7575 });
7676
......@@ -79,7 +79,7 @@ function makeRegistry(input: {
7979 ...input.process,
8080 });
8181
82 return SourceControlProviderRegistry.make().pipe(
82 return SourceControlProviderRegistry.make.pipe(
8383 Effect.provide(
8484 Layer.mergeAll(
8585 registryLayer,
......@@ -88,9 +88,9 @@ function makeRegistry(input: {
8888 Layer.mock(BitbucketApi.BitbucketApi)({}),
8989 Layer.mock(GitHubCli.GitHubCli)({}),
9090 Layer.mock(GitLabCli.GitLabCli)({}),
91 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe(
92 Layer.provide(NodeServices.layer),
93 ),
91 ServerConfig.layerTest(process.cwd(), {
92 prefix: "t3-source-control-registry-test-",
93 }).pipe(Layer.provide(NodeServices.layer)),
9494 ),
9595 ),
9696 );
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+41-33
......@@ -16,7 +16,11 @@ import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvide
1616import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1717import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
1818import * as SourceControlProvider from "./SourceControlProvider.ts";
19import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
19import {
20 probeSourceControlProvider,
21 refineUnknownRemoteProvider,
22 type SourceControlProviderDiscoverySpec,
23} from "./SourceControlProviderDiscovery.ts";
2024import { ServerConfig } from "../config.ts";
2125import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
2226import * as VcsProcess from "../vcs/VcsProcess.ts";
......@@ -26,36 +30,40 @@ const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5);
2630
2731export interface SourceControlProviderRegistration {
2832 readonly kind: SourceControlProviderKind;
29 readonly provider: SourceControlProvider.SourceControlProviderShape;
30 readonly discovery: SourceControlProviderDiscovery.SourceControlProviderDiscoverySpec;
33 readonly provider: SourceControlProvider.SourceControlProvider["Service"];
34 readonly discovery: SourceControlProviderDiscoverySpec;
3135}
3236
3337export interface SourceControlProviderHandle {
34 readonly provider: SourceControlProvider.SourceControlProviderShape;
38 readonly provider: SourceControlProvider.SourceControlProvider["Service"];
3539 readonly context: SourceControlProvider.SourceControlProviderContext | null;
3640}
3741
38export interface SourceControlProviderRegistryShape {
39 readonly get: (
40 kind: SourceControlProviderKind,
41 ) => Effect.Effect<SourceControlProvider.SourceControlProviderShape, SourceControlProviderError>;
42 readonly resolveHandle: (input: {
43 readonly cwd: string;
44 }) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>;
45 readonly resolve: (input: {
46 readonly cwd: string;
47 }) => Effect.Effect<SourceControlProvider.SourceControlProviderShape, SourceControlProviderError>;
48 readonly discover: Effect.Effect<ReadonlyArray<SourceControlProviderDiscoveryItem>>;
49}
50
5142export class SourceControlProviderRegistry extends Context.Service<
5243 SourceControlProviderRegistry,
53 SourceControlProviderRegistryShape
44 {
45 readonly get: (
46 kind: SourceControlProviderKind,
47 ) => Effect.Effect<
48 SourceControlProvider.SourceControlProvider["Service"],
49 SourceControlProviderError
50 >;
51 readonly resolveHandle: (input: {
52 readonly cwd: string;
53 }) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>;
54 readonly resolve: (input: {
55 readonly cwd: string;
56 }) => Effect.Effect<
57 SourceControlProvider.SourceControlProvider["Service"],
58 SourceControlProviderError
59 >;
60 readonly discover: Effect.Effect<ReadonlyArray<SourceControlProviderDiscoveryItem>>;
61 }
5462>()("t3/sourceControl/SourceControlProviderRegistry") {}
5563
5664function unsupportedProvider(
5765 kind: SourceControlProviderKind,
58): SourceControlProvider.SourceControlProviderShape {
66): SourceControlProvider.SourceControlProvider["Service"] {
5967 const unsupported = (operation: string) =>
6068 Effect.fail(
6169 new SourceControlProviderError({
......@@ -113,9 +121,9 @@ function selectProviderContext(
113121}
114122
115123function bindProviderContext(
116 provider: SourceControlProvider.SourceControlProviderShape,
124 provider: SourceControlProvider.SourceControlProvider["Service"],
117125 context: SourceControlProvider.SourceControlProviderContext | null,
118): SourceControlProvider.SourceControlProviderShape {
126): SourceControlProvider.SourceControlProvider["Service"] {
119127 if (context === null) {
120128 return provider;
121129 }
......@@ -163,11 +171,11 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
163171 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
164172 const providers = new Map<
165173 SourceControlProviderKind,
166 SourceControlProvider.SourceControlProviderShape
174 SourceControlProvider.SourceControlProvider["Service"]
167175 >(registrations.map((registration) => [registration.kind, registration.provider]));
168176 const discoverySpecs = registrations.map((registration) => registration.discovery);
169177
170 const get: SourceControlProviderRegistryShape["get"] = (kind) =>
178 const get: SourceControlProviderRegistry["Service"]["get"] = (kind) =>
171179 Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind));
172180
173181 const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")(
......@@ -180,7 +188,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
180188 .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)));
181189 const context = selectProviderContext(remotes.remotes);
182190
183 return yield* SourceControlProviderDiscovery.refineUnknownRemoteProvider({
191 return yield* refineUnknownRemoteProvider({
184192 specs: discoverySpecs,
185193 process,
186194 cwd,
......@@ -198,7 +206,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
198206 timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero),
199207 });
200208
201 const resolveHandle: SourceControlProviderRegistryShape["resolveHandle"] = (input) =>
209 const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) =>
202210 Cache.get(providerContextCache, input.cwd).pipe(
203211 Effect.map((context) => {
204212 const kind = context?.provider.kind ?? "unknown";
......@@ -216,7 +224,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
216224 resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)),
217225 discover: Effect.all(
218226 discoverySpecs.map((spec) =>
219 SourceControlProviderDiscovery.probeSourceControlProvider({
227 probeSourceControlProvider({
220228 spec,
221229 process,
222230 cwd: config.cwd,
......@@ -228,12 +236,12 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
228236 },
229237);
230238
231export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () {
232 const github = yield* GitHubSourceControlProvider.make();
233 const gitlab = yield* GitLabSourceControlProvider.make();
234 const bitbucket = yield* BitbucketSourceControlProvider.make();
235 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery();
236 const azureDevOps = yield* AzureDevOpsSourceControlProvider.make();
239export const make = Effect.gen(function* () {
240 const github = yield* GitHubSourceControlProvider.make;
241 const gitlab = yield* GitLabSourceControlProvider.make;
242 const bitbucket = yield* BitbucketSourceControlProvider.make;
243 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;
244 const azureDevOps = yield* AzureDevOpsSourceControlProvider.make;
237245 return yield* makeWithProviders([
238246 {
239247 kind: "github",
......@@ -258,4 +266,4 @@ export const make = Effect.fn("makeSourceControlProviderRegistry")(function* ()
258266 ]);
259267});
260268
261export const layer = Layer.effect(SourceControlProviderRegistry, make());
269export const layer = Layer.effect(SourceControlProviderRegistry, make);
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+5-5
......@@ -7,7 +7,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
77
88import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts";
99
10import { ServerConfig } from "../config.ts";
10import * as ServerConfig from "../config.ts";
1111import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
1212import type * as SourceControlProvider from "./SourceControlProvider.ts";
1313import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
......@@ -20,8 +20,8 @@ const CLONE_URLS = {
2020};
2121
2222function makeProvider(
23 overrides: Partial<SourceControlProvider.SourceControlProviderShape> = {},
24): SourceControlProvider.SourceControlProviderShape {
23 overrides: Partial<SourceControlProvider.SourceControlProvider["Service"]> = {},
24): SourceControlProvider.SourceControlProvider["Service"] {
2525 const unsupported = (operation: string) =>
2626 Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect<
2727 never,
......@@ -52,8 +52,8 @@ function processOutput(): GitVcsDriver.ExecuteGitResult {
5252}
5353
5454function makeLayer(input: {
55 readonly provider?: SourceControlProvider.SourceControlProviderShape;
56 readonly git?: Partial<GitVcsDriver.GitVcsDriverShape>;
55 readonly provider?: SourceControlProvider.SourceControlProvider["Service"];
56 readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>;
5757}) {
5858 return SourceControlRepositoryService.layer.pipe(
5959 Layer.provide(
apps/server/src/sourceControl/SourceControlRepositoryService.ts+13-15
......@@ -24,21 +24,19 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
2424import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
2525const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);
2626
27export interface SourceControlRepositoryServiceShape {
28 readonly lookupRepository: (
29 input: SourceControlRepositoryLookupInput,
30 ) => Effect.Effect<SourceControlRepositoryInfo, SourceControlRepositoryError>;
31 readonly cloneRepository: (
32 input: SourceControlCloneRepositoryInput,
33 ) => Effect.Effect<SourceControlCloneRepositoryResult, SourceControlRepositoryError>;
34 readonly publishRepository: (
35 input: SourceControlPublishRepositoryInput,
36 ) => Effect.Effect<SourceControlPublishRepositoryResult, SourceControlRepositoryError>;
37}
38
3927export class SourceControlRepositoryService extends Context.Service<
4028 SourceControlRepositoryService,
41 SourceControlRepositoryServiceShape
29 {
30 readonly lookupRepository: (
31 input: SourceControlRepositoryLookupInput,
32 ) => Effect.Effect<SourceControlRepositoryInfo, SourceControlRepositoryError>;
33 readonly cloneRepository: (
34 input: SourceControlCloneRepositoryInput,
35 ) => Effect.Effect<SourceControlCloneRepositoryResult, SourceControlRepositoryError>;
36 readonly publishRepository: (
37 input: SourceControlPublishRepositoryInput,
38 ) => Effect.Effect<SourceControlPublishRepositoryResult, SourceControlRepositoryError>;
39 }
4240>()("t3/sourceControl/SourceControlRepositoryService") {}
4341
4442function detailFromUnknown(cause: unknown): string {
......@@ -116,7 +114,7 @@ function expandHomePath(input: string, path: Path.Path): string {
116114 return input;
117115}
118116
119export const make = Effect.fn("makeSourceControlRepositoryService")(function* () {
117export const make = Effect.gen(function* () {
120118 const config = yield* ServerConfig;
121119 const fileSystem = yield* FileSystem.FileSystem;
122120 const git = yield* GitVcsDriver.GitVcsDriver;
......@@ -315,4 +313,4 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* ()
315313 });
316314});
317315
318export const layer = Layer.effect(SourceControlRepositoryService, make());
316export const layer = Layer.effect(SourceControlRepositoryService, make);
apps/server/src/vcs/GitVcsDriver.test.ts+1-1
......@@ -8,7 +8,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
88import { assert, it } from "@effect/vitest";
99
1010import { GitCommandError } from "@t3tools/contracts";
11import { ServerConfig } from "../config.ts";
11import * as ServerConfig from "../config.ts";
1212import * as GitVcsDriver from "./GitVcsDriver.ts";
1313import * as VcsProcess from "./VcsProcess.ts";
1414import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts";
apps/server/src/vcs/GitVcsDriver.ts+94-91
......@@ -28,7 +28,7 @@ import {
2828 type VcsStatusInput,
2929 type VcsStatusResult,
3030} from "@t3tools/contracts";
31import * as GitVcsDriverCore from "./GitVcsDriverCore.ts";
31import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts";
3232import * as VcsDriver from "./VcsDriver.ts";
3333import * as VcsProcess from "./VcsProcess.ts";
3434
......@@ -188,81 +188,84 @@ export interface GitRemoteStatusOptions {
188188 readonly refreshUpstream?: boolean;
189189}
190190
191export interface GitVcsDriverShape {
192 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;
193 readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>;
194 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
195 readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
196 readonly statusDetailsRemote: (
197 cwd: string,
198 options?: GitRemoteStatusOptions,
199 ) => Effect.Effect<GitRemoteStatusDetails, GitCommandError>;
200 readonly prepareCommitContext: (
201 cwd: string,
202 filePaths?: readonly string[],
203 ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>;
204 readonly commit: (
205 cwd: string,
206 subject: string,
207 body: string,
208 options?: GitCommitOptions,
209 ) => Effect.Effect<{ commitSha: string }, GitCommandError>;
210 readonly pushCurrentBranch: (
211 cwd: string,
212 fallbackBranch: string | null,
213 options?: { readonly remoteName?: string | null },
214 ) => Effect.Effect<GitPushResult, GitCommandError>;
215 readonly readRangeContext: (
216 cwd: string,
217 baseRef: string,
218 ) => Effect.Effect<GitRangeContext, GitCommandError>;
219 readonly getReviewDiffPreview: (
220 input: ReviewDiffPreviewInput,
221 ) => Effect.Effect<ReviewDiffPreviewResult, GitCommandError>;
222 readonly readConfigValue: (
223 cwd: string,
224 key: string,
225 ) => Effect.Effect<string | null, GitCommandError>;
226 readonly listRefs: (input: VcsListRefsInput) => Effect.Effect<VcsListRefsResult, GitCommandError>;
227 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
228 readonly createWorktree: (
229 input: VcsCreateWorktreeInput,
230 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
231 readonly fetchPullRequestBranch: (
232 input: GitFetchPullRequestBranchInput,
233 ) => Effect.Effect<void, GitCommandError>;
234 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;
235 readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>;
236 readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect<void, GitCommandError>;
237 readonly resolveRemoteTrackingCommit: (
238 input: GitResolveRemoteTrackingCommitInput,
239 ) => Effect.Effect<GitResolveRemoteTrackingCommitResult, GitCommandError>;
240 readonly fetchRemoteBranch: (
241 input: GitFetchRemoteBranchInput,
242 ) => Effect.Effect<void, GitCommandError>;
243 readonly fetchRemoteTrackingBranch: (
244 input: GitFetchRemoteTrackingBranchInput,
245 ) => Effect.Effect<void, GitCommandError>;
246 readonly setBranchUpstream: (
247 input: GitSetBranchUpstreamInput,
248 ) => Effect.Effect<void, GitCommandError>;
249 readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;
250 readonly renameBranch: (
251 input: GitRenameBranchInput,
252 ) => Effect.Effect<GitRenameBranchResult, GitCommandError>;
253 readonly createRef: (
254 input: VcsCreateRefInput,
255 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
256 readonly switchRef: (
257 input: VcsSwitchRefInput,
258 ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
259 readonly initRepo: (input: VcsInitInput) => Effect.Effect<void, GitCommandError>;
260 readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;
261}
262
263export class GitVcsDriver extends Context.Service<GitVcsDriver, GitVcsDriverShape>()(
264 "t3/vcs/GitVcsDriver",
265) {}
191export class GitVcsDriver extends Context.Service<
192 GitVcsDriver,
193 {
194 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;
195 readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>;
196 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
197 readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
198 readonly statusDetailsRemote: (
199 cwd: string,
200 options?: GitRemoteStatusOptions,
201 ) => Effect.Effect<GitRemoteStatusDetails, GitCommandError>;
202 readonly prepareCommitContext: (
203 cwd: string,
204 filePaths?: readonly string[],
205 ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>;
206 readonly commit: (
207 cwd: string,
208 subject: string,
209 body: string,
210 options?: GitCommitOptions,
211 ) => Effect.Effect<{ commitSha: string }, GitCommandError>;
212 readonly pushCurrentBranch: (
213 cwd: string,
214 fallbackBranch: string | null,
215 options?: { readonly remoteName?: string | null },
216 ) => Effect.Effect<GitPushResult, GitCommandError>;
217 readonly readRangeContext: (
218 cwd: string,
219 baseRef: string,
220 ) => Effect.Effect<GitRangeContext, GitCommandError>;
221 readonly getReviewDiffPreview: (
222 input: ReviewDiffPreviewInput,
223 ) => Effect.Effect<ReviewDiffPreviewResult, GitCommandError>;
224 readonly readConfigValue: (
225 cwd: string,
226 key: string,
227 ) => Effect.Effect<string | null, GitCommandError>;
228 readonly listRefs: (
229 input: VcsListRefsInput,
230 ) => Effect.Effect<VcsListRefsResult, GitCommandError>;
231 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
232 readonly createWorktree: (
233 input: VcsCreateWorktreeInput,
234 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
235 readonly fetchPullRequestBranch: (
236 input: GitFetchPullRequestBranchInput,
237 ) => Effect.Effect<void, GitCommandError>;
238 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;
239 readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>;
240 readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect<void, GitCommandError>;
241 readonly resolveRemoteTrackingCommit: (
242 input: GitResolveRemoteTrackingCommitInput,
243 ) => Effect.Effect<GitResolveRemoteTrackingCommitResult, GitCommandError>;
244 readonly fetchRemoteBranch: (
245 input: GitFetchRemoteBranchInput,
246 ) => Effect.Effect<void, GitCommandError>;
247 readonly fetchRemoteTrackingBranch: (
248 input: GitFetchRemoteTrackingBranchInput,
249 ) => Effect.Effect<void, GitCommandError>;
250 readonly setBranchUpstream: (
251 input: GitSetBranchUpstreamInput,
252 ) => Effect.Effect<void, GitCommandError>;
253 readonly removeWorktree: (
254 input: VcsRemoveWorktreeInput,
255 ) => Effect.Effect<void, GitCommandError>;
256 readonly renameBranch: (
257 input: GitRenameBranchInput,
258 ) => Effect.Effect<GitRenameBranchResult, GitCommandError>;
259 readonly createRef: (
260 input: VcsCreateRefInput,
261 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
262 readonly switchRef: (
263 input: VcsSwitchRefInput,
264 ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
265 readonly initRepo: (input: VcsInitInput) => Effect.Effect<void, GitCommandError>;
266 readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;
267 }
268>()("t3/vcs/GitVcsDriver") {}
266269
267270const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
268271const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
......@@ -357,7 +360,7 @@ function parseGitRemoteVerboseOutput(
357360}
358361
359362const gitCommand = (
360 process: VcsProcess.VcsProcessShape,
363 process: VcsProcess.VcsProcess["Service"],
361364 operation: string,
362365 cwd: string,
363366 args: ReadonlyArray<string>,
......@@ -401,7 +404,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
401404 ignoreClassifier: "native" as const,
402405 };
403406
404 const isInsideWorkTree: VcsDriver.VcsDriverShape["isInsideWorkTree"] = (cwd) =>
407 const isInsideWorkTree: VcsDriver.VcsDriver["Service"]["isInsideWorkTree"] = (cwd) =>
405408 gitCommand(
406409 vcsProcess,
407410 "GitVcsDriver.isInsideWorkTree",
......@@ -414,7 +417,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
414417 },
415418 ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"));
416419
417 const execute: VcsDriver.VcsDriverShape["execute"] = (input) =>
420 const execute: VcsDriver.VcsDriver["Service"]["execute"] = (input) =>
418421 gitCommand(vcsProcess, input.operation, input.cwd, input.args, {
419422 ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
420423 ...(input.env !== undefined ? { env: input.env } : {}),
......@@ -426,7 +429,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
426429 : {}),
427430 });
428431
429 const detectRepository: VcsDriver.VcsDriverShape["detectRepository"] = Effect.fn(
432 const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn(
430433 "detectRepository",
431434 )(function* (cwd) {
432435 if (!(yield* isInsideWorkTree(cwd))) {
......@@ -452,7 +455,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
452455 };
453456 });
454457
455 const listWorkspaceFiles: VcsDriver.VcsDriverShape["listWorkspaceFiles"] = (cwd) =>
458 const listWorkspaceFiles: VcsDriver.VcsDriver["Service"]["listWorkspaceFiles"] = (cwd) =>
456459 gitCommand(
457460 vcsProcess,
458461 "GitVcsDriver.listWorkspaceFiles",
......@@ -494,7 +497,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
494497 ),
495498 );
496499
497 const listRemotes: VcsDriver.VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")(
500 const listRemotes: VcsDriver.VcsDriver["Service"]["listRemotes"] = Effect.fn("listRemotes")(
498501 function* (cwd) {
499502 const result = yield* gitCommand(
500503 vcsProcess,
......@@ -540,7 +543,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
540543 },
541544 );
542545
543 const filterIgnoredPaths: VcsDriver.VcsDriverShape["filterIgnoredPaths"] = Effect.fn(
546 const filterIgnoredPaths: VcsDriver.VcsDriver["Service"]["filterIgnoredPaths"] = Effect.fn(
544547 "filterIgnoredPaths",
545548 )(function* (cwd, relativePaths) {
546549 if (relativePaths.length === 0) {
......@@ -587,7 +590,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
587590 return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));
588591 });
589592
590 const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) =>
593 const initRepository: VcsDriver.VcsDriver["Service"]["initRepository"] = (input) =>
591594 gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], {
592595 timeoutMs: 10_000,
593596 maxOutputBytes: 64 * 1024,
......@@ -844,7 +847,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
844847 ),
845848 };
846849
847 return VcsDriver.VcsDriver.of({
850 return {
848851 capabilities,
849852 execute,
850853 checkpoints,
......@@ -854,18 +857,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
854857 listRemotes,
855858 filterIgnoredPaths,
856859 initRepository,
857 });
860 };
858861});
859862
860export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () {
863export const makeVcsDriver = Effect.gen(function* () {
861864 const driver = yield* makeVcsDriverShape();
862865 return VcsDriver.VcsDriver.of(driver);
863866});
864867
865export const make = Effect.fn("makeGitVcsDriverService")(function* () {
866 const git = yield* GitVcsDriverCore.makeGitVcsDriverCore();
868export const make = Effect.gen(function* () {
869 const git = yield* makeGitVcsDriverCore();
867870 return GitVcsDriver.of(git);
868871});
869872
870export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver());
871export const layer = Layer.effect(GitVcsDriver, make());
873export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver);
874export const layer = Layer.effect(GitVcsDriver, make);
apps/server/src/vcs/GitVcsDriverCore.ts+126-126
......@@ -655,7 +655,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
655655 const { worktreesDir } = yield* ServerConfig;
656656 const crypto = yield* Crypto.Crypto;
657657
658 const executeRaw: GitVcsDriver.GitVcsDriverShape["execute"] = Effect.fnUntraced(
658 const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced(
659659 function* (input) {
660660 const commandInput = {
661661 ...input,
......@@ -756,7 +756,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
756756 },
757757 );
758758
759 const execute: GitVcsDriver.GitVcsDriverShape["execute"] = (input) =>
759 const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) =>
760760 executeRaw(input).pipe(
761761 withMetrics({
762762 counter: gitCommandsTotal,
......@@ -1059,38 +1059,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
10591059 return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null));
10601060 });
10611061
1062 const ensureRemote: GitVcsDriver.GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")(
1063 function* (input) {
1064 const preferredName = sanitizeRemoteName(input.preferredName);
1065 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
1066 const remoteFetchUrls = yield* runGitStdout(
1067 "GitVcsDriver.ensureRemote.listRemoteUrls",
1068 input.cwd,
1069 ["remote", "-v"],
1070 ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
1062 const ensureRemote: GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"] = Effect.fn(
1063 "ensureRemote",
1064 )(function* (input) {
1065 const preferredName = sanitizeRemoteName(input.preferredName);
1066 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
1067 const remoteFetchUrls = yield* runGitStdout(
1068 "GitVcsDriver.ensureRemote.listRemoteUrls",
1069 input.cwd,
1070 ["remote", "-v"],
1071 ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
10711072
1072 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
1073 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
1074 return remoteName;
1075 }
1073 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
1074 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
1075 return remoteName;
10761076 }
1077 }
10771078
1078 let remoteName = preferredName;
1079 let suffix = 1;
1080 while (remoteFetchUrls.has(remoteName)) {
1081 remoteName = `${preferredName}-${suffix}`;
1082 suffix += 1;
1083 }
1079 let remoteName = preferredName;
1080 let suffix = 1;
1081 while (remoteFetchUrls.has(remoteName)) {
1082 remoteName = `${preferredName}-${suffix}`;
1083 suffix += 1;
1084 }
10841085
1085 yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [
1086 "remote",
1087 "add",
1088 remoteName,
1089 input.url,
1090 ]);
1091 return remoteName;
1092 },
1093 );
1086 yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [
1087 "remote",
1088 "add",
1089 remoteName,
1090 input.url,
1091 ]);
1092 return remoteName;
1093 });
10941094
10951095 const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* (
10961096 cwd: string,
......@@ -1426,35 +1426,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
14261426 };
14271427 });
14281428
1429 const statusDetailsLocal: GitVcsDriver.GitVcsDriverShape["statusDetailsLocal"] = Effect.fn(
1429 const statusDetailsLocal: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn(
14301430 "statusDetailsLocal",
14311431 )(function* (cwd) {
14321432 return yield* readStatusDetailsLocal(cwd);
14331433 });
14341434
1435 const statusDetails: GitVcsDriver.GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")(
1436 function* (cwd) {
1437 yield* refreshStatusUpstreamIfStale(cwd).pipe(
1438 Effect.catchIf(isMissingGitCwdError, () => Effect.void),
1439 Effect.ignoreCause({ log: true }),
1440 );
1441 return yield* readStatusDetailsLocal(cwd);
1442 },
1443 );
1444
1445 const statusDetailsRemote: GitVcsDriver.GitVcsDriverShape["statusDetailsRemote"] = Effect.fn(
1446 "statusDetailsRemote",
1447 )(function* (cwd, options) {
1448 if (options?.refreshUpstream !== false) {
1449 yield* refreshStatusUpstreamIfStale(cwd).pipe(
1450 Effect.catchIf(isMissingGitCwdError, () => Effect.void),
1451 Effect.ignoreCause({ log: true }),
1452 );
1453 }
1454 return yield* readStatusDetailsRemote(cwd);
1435 const statusDetails: GitVcsDriver.GitVcsDriver["Service"]["statusDetails"] = Effect.fn(
1436 "statusDetails",
1437 )(function* (cwd) {
1438 yield* refreshStatusUpstreamIfStale(cwd).pipe(
1439 Effect.catchIf(isMissingGitCwdError, () => Effect.void),
1440 Effect.ignoreCause({ log: true }),
1441 );
1442 return yield* readStatusDetailsLocal(cwd);
14551443 });
14561444
1457 const status: GitVcsDriver.GitVcsDriverShape["status"] = (input) =>
1445 const statusDetailsRemote: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsRemote"] =
1446 Effect.fn("statusDetailsRemote")(function* (cwd, options) {
1447 if (options?.refreshUpstream !== false) {
1448 yield* refreshStatusUpstreamIfStale(cwd).pipe(
1449 Effect.catchIf(isMissingGitCwdError, () => Effect.void),
1450 Effect.ignoreCause({ log: true }),
1451 );
1452 }
1453 return yield* readStatusDetailsRemote(cwd);
1454 });
1455
1456 const status: GitVcsDriver.GitVcsDriver["Service"]["status"] = (input) =>
14581457 statusDetails(input.cwd).pipe(
14591458 Effect.map((details) => ({
14601459 isRepo: details.isRepo,
......@@ -1471,49 +1470,48 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
14711470 })),
14721471 );
14731472
1474 const prepareCommitContext: GitVcsDriver.GitVcsDriverShape["prepareCommitContext"] = Effect.fn(
1475 "prepareCommitContext",
1476 )(function* (cwd, filePaths) {
1477 if (filePaths && filePaths.length > 0) {
1478 yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe(
1479 Effect.catch(() => Effect.void),
1480 );
1481 yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [
1482 "add",
1483 "-A",
1484 "--",
1485 ...filePaths,
1486 ]);
1487 } else {
1488 yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]);
1489 }
1473 const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] =
1474 Effect.fn("prepareCommitContext")(function* (cwd, filePaths) {
1475 if (filePaths && filePaths.length > 0) {
1476 yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe(
1477 Effect.catch(() => Effect.void),
1478 );
1479 yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [
1480 "add",
1481 "-A",
1482 "--",
1483 ...filePaths,
1484 ]);
1485 } else {
1486 yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]);
1487 }
14901488
1491 const stagedSummary = yield* runGitStdout(
1492 "GitVcsDriver.prepareCommitContext.stagedSummary",
1493 cwd,
1494 ["diff", "--cached", "--name-status"],
1495 ).pipe(Effect.map((stdout) => stdout.trim()));
1496 if (stagedSummary.length === 0) {
1497 return null;
1498 }
1489 const stagedSummary = yield* runGitStdout(
1490 "GitVcsDriver.prepareCommitContext.stagedSummary",
1491 cwd,
1492 ["diff", "--cached", "--name-status"],
1493 ).pipe(Effect.map((stdout) => stdout.trim()));
1494 if (stagedSummary.length === 0) {
1495 return null;
1496 }
14991497
1500 const stagedPatch = yield* runGitStdoutWithOptions(
1501 "GitVcsDriver.prepareCommitContext.stagedPatch",
1502 cwd,
1503 ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"],
1504 {
1505 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,
1506 appendTruncationMarker: true,
1507 },
1508 );
1498 const stagedPatch = yield* runGitStdoutWithOptions(
1499 "GitVcsDriver.prepareCommitContext.stagedPatch",
1500 cwd,
1501 ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"],
1502 {
1503 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,
1504 appendTruncationMarker: true,
1505 },
1506 );
15091507
1510 return {
1511 stagedSummary,
1512 stagedPatch,
1513 };
1514 });
1508 return {
1509 stagedSummary,
1510 stagedPatch,
1511 };
1512 });
15151513
1516 const commit: GitVcsDriver.GitVcsDriverShape["commit"] = Effect.fn("commit")(function* (
1514 const commit: GitVcsDriver.GitVcsDriver["Service"]["commit"] = Effect.fn("commit")(function* (
15171515 cwd,
15181516 subject,
15191517 body,
......@@ -1546,7 +1544,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
15461544 return { commitSha };
15471545 });
15481546
1549 const pushCurrentBranch: GitVcsDriver.GitVcsDriverShape["pushCurrentBranch"] = Effect.fn(
1547 const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn(
15501548 "pushCurrentBranch",
15511549 )(function* (cwd, fallbackBranch, options) {
15521550 const details = yield* statusDetails(cwd);
......@@ -1664,7 +1662,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
16641662 };
16651663 });
16661664
1667 const pullCurrentBranch: GitVcsDriver.GitVcsDriverShape["pullCurrentBranch"] = Effect.fn(
1665 const pullCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pullCurrentBranch"] = Effect.fn(
16681666 "pullCurrentBranch",
16691667 )(function* (cwd) {
16701668 const details = yield* statusDetails(cwd);
......@@ -1710,7 +1708,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
17101708 };
17111709 });
17121710
1713 const readRangeContext: GitVcsDriver.GitVcsDriverShape["readRangeContext"] = Effect.fn(
1711 const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn(
17141712 "readRangeContext",
17151713 )(function* (cwd, baseRef) {
17161714 const range = `${baseRef}..HEAD`;
......@@ -1921,13 +1919,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
19211919 };
19221920 });
19231921
1924 const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) =>
1922 const readConfigValue: GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"] = (cwd, key) =>
19251923 runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe(
19261924 Effect.map((stdout) => stdout.trim()),
19271925 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
19281926 );
19291927
1930 const listRefs: GitVcsDriver.GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")(
1928 const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")(
19311929 function* (input) {
19321930 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
19331931 Effect.orElseSucceed(() => new Map<string, number>()),
......@@ -2165,7 +2163,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
21652163 },
21662164 );
21672165
2168 const createWorktree: GitVcsDriver.GitVcsDriverShape["createWorktree"] = Effect.fn(
2166 const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn(
21692167 "createWorktree",
21702168 )(function* (input) {
21712169 const targetBranch = input.newRefName ?? input.refName;
......@@ -2188,7 +2186,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
21882186 };
21892187 });
21902188
2191 const fetchPullRequestBranch: GitVcsDriver.GitVcsDriverShape["fetchPullRequestBranch"] =
2189 const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] =
21922190 Effect.fn("fetchPullRequestBranch")(function* (input) {
21932191 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);
21942192 yield* executeGit(
......@@ -2207,7 +2205,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
22072205 );
22082206 });
22092207
2210 const fetchRemote: GitVcsDriver.GitVcsDriverShape["fetchRemote"] = Effect.fn("fetchRemote")(
2208 const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")(
22112209 function* (input) {
22122210 yield* executeGit(
22132211 "GitVcsDriver.fetchRemote",
......@@ -2221,7 +2219,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
22212219 },
22222220 );
22232221
2224 const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriverShape["resolveRemoteTrackingCommit"] =
2222 const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveRemoteTrackingCommit"] =
22252223 Effect.fn("resolveRemoteTrackingCommit")(function* (input) {
22262224 const remoteNames = yield* listRemoteNames(input.cwd);
22272225 const parsedRemoteRef = parseRemoteRefWithRemoteNames(
......@@ -2239,7 +2237,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
22392237 return { commitSha, remoteRefName };
22402238 });
22412239
2242 const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn(
2240 const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn(
22432241 "fetchRemoteBranch",
22442242 )(function* (input) {
22452243 yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [
......@@ -2261,7 +2259,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
22612259 );
22622260 });
22632261
2264 const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"] =
2262 const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] =
22652263 Effect.fn("fetchRemoteTrackingBranch")(function* (input) {
22662264 yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [
22672265 "fetch",
......@@ -2272,7 +2270,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
22722270 ]);
22732271 });
22742272
2275 const setBranchUpstream: GitVcsDriver.GitVcsDriverShape["setBranchUpstream"] = (input) =>
2273 const setBranchUpstream: GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"] = (input) =>
22762274 runGit("GitVcsDriver.setBranchUpstream", input.cwd, [
22772275 "branch",
22782276 "--set-upstream-to",
......@@ -2280,7 +2278,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
22802278 input.branch,
22812279 ]);
22822280
2283 const removeWorktree: GitVcsDriver.GitVcsDriverShape["removeWorktree"] = Effect.fn(
2281 const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn(
22842282 "removeWorktree",
22852283 )(function* (input) {
22862284 const args = ["worktree", "remove"];
......@@ -2304,28 +2302,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
23042302 );
23052303 });
23062304
2307 const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")(
2308 function* (input) {
2309 if (input.oldBranch === input.newBranch) {
2310 return { branch: input.newBranch };
2311 }
2312 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
2305 const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn(
2306 "renameBranch",
2307 )(function* (input) {
2308 if (input.oldBranch === input.newBranch) {
2309 return { branch: input.newBranch };
2310 }
2311 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
23132312
2314 yield* executeGit(
2315 "GitVcsDriver.renameBranch",
2316 input.cwd,
2317 ["branch", "-m", "--", input.oldBranch, targetBranch],
2318 {
2319 timeoutMs: 10_000,
2320 fallbackErrorMessage: "git branch rename failed",
2321 },
2322 );
2313 yield* executeGit(
2314 "GitVcsDriver.renameBranch",
2315 input.cwd,
2316 ["branch", "-m", "--", input.oldBranch, targetBranch],
2317 {
2318 timeoutMs: 10_000,
2319 fallbackErrorMessage: "git branch rename failed",
2320 },
2321 );
23232322
2324 return { branch: targetBranch };
2325 },
2326 );
2323 return { branch: targetBranch };
2324 });
23272325
2328 const switchRef: GitVcsDriver.GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")(
2326 const switchRef: GitVcsDriver.GitVcsDriver["Service"]["switchRef"] = Effect.fn("switchRef")(
23292327 function* (input) {
23302328 const [localInputExists, remoteExists] = yield* Effect.all(
23312329 [
......@@ -2407,7 +2405,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
24072405 },
24082406 );
24092407
2410 const createRef: GitVcsDriver.GitVcsDriverShape["createRef"] = Effect.fn("createRef")(
2408 const createRef: GitVcsDriver.GitVcsDriver["Service"]["createRef"] = Effect.fn("createRef")(
24112409 function* (input) {
24122410 yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], {
24132411 timeoutMs: 10_000,
......@@ -2421,13 +2419,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
24212419 },
24222420 );
24232421
2424 const initRepo: GitVcsDriver.GitVcsDriverShape["initRepo"] = (input) =>
2422 const initRepo: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = (input) =>
24252423 executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], {
24262424 timeoutMs: 10_000,
24272425 fallbackErrorMessage: "git init failed",
24282426 }).pipe(Effect.asVoid);
24292427
2430 const listLocalBranchNames: GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"] = (cwd) =>
2428 const listLocalBranchNames: GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"] = (
2429 cwd,
2430 ) =>
24312431 runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [
24322432 "branch",
24332433 "--list",
apps/server/src/vcs/VcsDriver.ts+26-23
......@@ -52,26 +52,29 @@ export interface VcsCheckpointOps {
5252 ) => Effect.Effect<void, VcsError>;
5353}
5454
55export interface VcsDriverShape {
56 readonly capabilities: VcsDriverCapabilities;
57 readonly execute: (
58 input: Omit<VcsProcess.VcsProcessInput, "command">,
59 ) => Effect.Effect<VcsProcess.VcsProcessOutput, VcsError>;
60 readonly checkpoints?: VcsCheckpointOps;
61 readonly detectRepository: (cwd: string) => Effect.Effect<VcsRepositoryIdentity | null, VcsError>;
62 readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, VcsError>;
63 readonly listWorkspaceFiles: (
64 cwd: string,
65 ) => Effect.Effect<VcsListWorkspaceFilesResult, VcsError>;
66 readonly listRemotes: (cwd: string) => Effect.Effect<VcsListRemotesResult, VcsError>;
67 readonly filterIgnoredPaths: (
68 cwd: string,
69 relativePaths: ReadonlyArray<string>,
70 ) => Effect.Effect<ReadonlyArray<string>, VcsError>;
71 readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>;
72 readonly getDiffPreview?: (
73 input: ReviewDiffPreviewInput,
74 ) => Effect.Effect<ReviewDiffPreviewResult, VcsError>;
75}
76
77export class VcsDriver extends Context.Service<VcsDriver, VcsDriverShape>()("t3/vcs/VcsDriver") {}
55export class VcsDriver extends Context.Service<
56 VcsDriver,
57 {
58 readonly capabilities: VcsDriverCapabilities;
59 readonly execute: (
60 input: Omit<VcsProcess.VcsProcessInput, "command">,
61 ) => Effect.Effect<VcsProcess.VcsProcessOutput, VcsError>;
62 readonly checkpoints?: VcsCheckpointOps;
63 readonly detectRepository: (
64 cwd: string,
65 ) => Effect.Effect<VcsRepositoryIdentity | null, VcsError>;
66 readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, VcsError>;
67 readonly listWorkspaceFiles: (
68 cwd: string,
69 ) => Effect.Effect<VcsListWorkspaceFilesResult, VcsError>;
70 readonly listRemotes: (cwd: string) => Effect.Effect<VcsListRemotesResult, VcsError>;
71 readonly filterIgnoredPaths: (
72 cwd: string,
73 relativePaths: ReadonlyArray<string>,
74 ) => Effect.Effect<ReadonlyArray<string>, VcsError>;
75 readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>;
76 readonly getDiffPreview?: (
77 input: ReviewDiffPreviewInput,
78 ) => Effect.Effect<ReviewDiffPreviewResult, VcsError>;
79 }
80>()("t3/vcs/VcsDriver") {}
apps/server/src/vcs/VcsDriverRegistry.test.ts+2-2
......@@ -21,7 +21,7 @@ const normalizeGitArgs = (args: ReadonlyArray<string>): ReadonlyArray<string> =>
2121
2222describe("VcsDriverRegistry", () => {
2323 it.effect("routes directly by VCS driver kind for non-repository workflows", () => {
24 const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe(
24 const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe(
2525 Layer.provide(NodeServices.layer),
2626 Layer.provide(
2727 Layer.mock(VcsProjectConfig.VcsProjectConfig)({
......@@ -45,7 +45,7 @@ describe("VcsDriverRegistry", () => {
4545
4646 it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => {
4747 const calls: VcsProcess.VcsProcessInput[] = [];
48 const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe(
48 const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe(
4949 Layer.provide(NodeServices.layer),
5050 Layer.provide(
5151 Layer.mock(VcsProjectConfig.VcsProjectConfig)({
apps/server/src/vcs/VcsDriverRegistry.ts+19-20
......@@ -22,20 +22,19 @@ export interface VcsDriverResolveInput {
2222export interface VcsDriverHandle {
2323 readonly kind: VcsDriverKind;
2424 readonly repository: VcsRepositoryIdentity;
25 readonly driver: VcsDriver.VcsDriverShape;
25 readonly driver: VcsDriver.VcsDriver["Service"];
2626}
2727
28export interface VcsDriverRegistryShape {
29 readonly get: (kind: VcsDriverKind) => Effect.Effect<VcsDriver.VcsDriverShape, VcsError>;
30 readonly detect: (
31 input: VcsDriverResolveInput,
32 ) => Effect.Effect<VcsDriverHandle | null, VcsError>;
33 readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect<VcsDriverHandle, VcsError>;
34}
35
36export class VcsDriverRegistry extends Context.Service<VcsDriverRegistry, VcsDriverRegistryShape>()(
37 "t3/vcs/VcsDriverRegistry",
38) {}
28export class VcsDriverRegistry extends Context.Service<
29 VcsDriverRegistry,
30 {
31 readonly get: (kind: VcsDriverKind) => Effect.Effect<VcsDriver.VcsDriver["Service"], VcsError>;
32 readonly detect: (
33 input: VcsDriverResolveInput,
34 ) => Effect.Effect<VcsDriverHandle | null, VcsError>;
35 readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect<VcsDriverHandle, VcsError>;
36 }
37>()("t3/vcs/VcsDriverRegistry") {}
3938
4039const unsupported = (operation: string, kind: VcsDriverKind, detail: string) =>
4140 new VcsUnsupportedOperationError({
......@@ -68,14 +67,14 @@ function parseDetectionCacheKey(key: string): {
6867 };
6968}
7069
71export const make = Effect.fn("makeVcsDriverRegistry")(function* () {
70export const make = Effect.gen(function* () {
7271 const projectConfig = yield* VcsProjectConfig.VcsProjectConfig;
73 const git = yield* GitVcsDriver.makeVcsDriverShape();
74 const drivers: Partial<Record<VcsDriverKind, VcsDriver.VcsDriverShape>> = {
72 const git = yield* GitVcsDriver.makeVcsDriver;
73 const drivers: Partial<Record<VcsDriverKind, VcsDriver.VcsDriver["Service"]>> = {
7574 git,
7675 };
7776
78 const get: VcsDriverRegistryShape["get"] = (kind) => {
77 const get: VcsDriverRegistry["Service"]["get"] = (kind) => {
7978 const driver = drivers[kind];
8079 if (!driver) {
8180 return Effect.fail(
......@@ -87,7 +86,7 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () {
8786
8887 const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* (
8988 kind: VcsDriverKind,
90 driver: VcsDriver.VcsDriverShape,
89 driver: VcsDriver.VcsDriver["Service"],
9190 cwd: string,
9291 ) {
9392 const repository = yield* driver.detectRepository(cwd);
......@@ -123,14 +122,14 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () {
123122 },
124123 );
125124
126 const detect: VcsDriverRegistryShape["detect"] = Effect.fn("VcsDriverRegistry.detect")(
125 const detect: VcsDriverRegistry["Service"]["detect"] = Effect.fn("VcsDriverRegistry.detect")(
127126 function* (input) {
128127 const requestedKind = yield* projectConfig.resolveKind(input);
129128 return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind }));
130129 },
131130 );
132131
133 const resolve: VcsDriverRegistryShape["resolve"] = Effect.fn("VcsDriverRegistry.resolve")(
132 const resolve: VcsDriverRegistry["Service"]["resolve"] = Effect.fn("VcsDriverRegistry.resolve")(
134133 function* (input) {
135134 const detected = yield* detect(input);
136135 if (detected) {
......@@ -155,6 +154,6 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () {
155154 });
156155});
157156
158export const layer = Layer.effect(VcsDriverRegistry, make()).pipe(
157export const layer = Layer.effect(VcsDriverRegistry, make).pipe(
159158 Layer.provide(VcsProjectConfig.layer),
160159);
apps/server/src/vcs/VcsProcess.ts+11-12
......@@ -1,6 +1,7 @@
11import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
4import * as Match from "effect/Match";
45import { ChildProcessSpawner } from "effect/unstable/process";
56
67import {
......@@ -10,8 +11,7 @@ import {
1011 VcsProcessSpawnError,
1112 VcsProcessTimeoutError,
1213} from "@t3tools/contracts";
13import { ProcessRunner, layer as ProcessRunnerLive } from "../processRunner.ts";
14import * as Match from "effect/Match";
14import * as ProcessRunner from "../processRunner.ts";
1515
1616export interface VcsProcessInput {
1717 readonly operation: string;
......@@ -35,13 +35,12 @@ export interface VcsProcessOutput {
3535 readonly stderrTruncated: boolean;
3636}
3737
38export interface VcsProcessShape {
39 readonly run: (input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>;
40}
41
42export class VcsProcess extends Context.Service<VcsProcess, VcsProcessShape>()(
43 "t3/vcs/VcsProcess",
44) {}
38export class VcsProcess extends Context.Service<
39 VcsProcess,
40 {
41 readonly run: (input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>;
42 }
43>()("t3/vcs/VcsProcess") {}
4544
4645const DEFAULT_TIMEOUT_MS = 30_000;
4746const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
......@@ -51,8 +50,8 @@ function commandLabel(command: string, args: ReadonlyArray<string>): string {
5150 return [command, ...args].join(" ");
5251}
5352
54export const make = Effect.fn("makeVcsProcess")(function* () {
55 const processRunner = yield* ProcessRunner;
53export const make = Effect.gen(function* () {
54 const processRunner = yield* ProcessRunner.ProcessRunner;
5655
5756 const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) {
5857 const label = commandLabel(input.command, input.args);
......@@ -119,4 +118,4 @@ export const make = Effect.fn("makeVcsProcess")(function* () {
119118 return VcsProcess.of({ run });
120119});
121120
122export const layer = Layer.effect(VcsProcess, make()).pipe(Layer.provide(ProcessRunnerLive));
121export const layer = Layer.effect(VcsProcess, make).pipe(Layer.provide(ProcessRunner.layer));
apps/server/src/vcs/VcsProjectConfig.ts+11-12
......@@ -27,15 +27,14 @@ export interface VcsProjectConfigResolveInput {
2727 readonly requestedKind?: VcsDriverKindType | "auto";
2828}
2929
30export interface VcsProjectConfigShape {
31 readonly resolveKind: (
32 input: VcsProjectConfigResolveInput,
33 ) => Effect.Effect<VcsDriverKindType | "auto">;
34}
35
36export class VcsProjectConfig extends Context.Service<VcsProjectConfig, VcsProjectConfigShape>()(
37 "t3/vcs/VcsProjectConfig",
38) {}
30export class VcsProjectConfig extends Context.Service<
31 VcsProjectConfig,
32 {
33 readonly resolveKind: (
34 input: VcsProjectConfigResolveInput,
35 ) => Effect.Effect<VcsDriverKindType | "auto">;
36 }
37>()("t3/vcs/VcsProjectConfig") {}
3938
4039function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto" {
4140 return config.vcs?.kind ?? config.vcsKind ?? "auto";
......@@ -44,7 +43,7 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto
4443const parseConfig = (raw: string): Option.Option<ProjectVcsConfigFile> =>
4544 decodeProjectVcsConfigJson(raw);
4645
47export const make = Effect.fn("makeVcsProjectConfig")(function* () {
46export const make = Effect.gen(function* () {
4847 const fileSystem = yield* FileSystem.FileSystem;
4948 const path = yield* Path.Path;
5049
......@@ -91,7 +90,7 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
9190 return configuredKind(parsed.value);
9291 });
9392
94 const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn(
93 const resolveKind: VcsProjectConfig["Service"]["resolveKind"] = Effect.fn(
9594 "VcsProjectConfig.resolveKind",
9695 )(function* (input) {
9796 if (input.requestedKind !== undefined && input.requestedKind !== "auto") {
......@@ -110,4 +109,4 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
110109 });
111110});
112111
113export const layer = Layer.effect(VcsProjectConfig, make());
112export const layer = Layer.effect(VcsProjectConfig, make);
apps/server/src/vcs/VcsProvisioningService.test.ts+1-1
......@@ -11,7 +11,7 @@ import * as VcsProvisioningService from "./VcsProvisioningService.ts";
1111
1212const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
1313
14function makeDriver(calls: string[]): VcsDriver.VcsDriverShape {
14function makeDriver(calls: string[]): VcsDriver.VcsDriver["Service"] {
1515 return {
1616 capabilities: {
1717 kind: "git",
apps/server/src/vcs/VcsProvisioningService.ts+6-8
......@@ -10,13 +10,11 @@ import {
1010} from "@t3tools/contracts";
1111import * as VcsDriverRegistry from "./VcsDriverRegistry.ts";
1212
13export interface VcsProvisioningServiceShape {
14 readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>;
15}
16
1713export class VcsProvisioningService extends Context.Service<
1814 VcsProvisioningService,
19 VcsProvisioningServiceShape
15 {
16 readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>;
17 }
2018>()("t3/vcs/VcsProvisioningService") {}
2119
2220function resolveRequestedKind(
......@@ -37,10 +35,10 @@ function resolveRequestedKind(
3735 return Effect.succeed(kind);
3836}
3937
40export const make = Effect.fn("makeVcsProvisioningService")(function* () {
38export const make = Effect.gen(function* () {
4139 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
4240
43 const initRepository: VcsProvisioningServiceShape["initRepository"] = Effect.fn(
41 const initRepository: VcsProvisioningService["Service"]["initRepository"] = Effect.fn(
4442 "VcsProvisioningService.initRepository",
4543 )(function* (input) {
4644 const kind = yield* resolveRequestedKind(input.kind);
......@@ -53,4 +51,4 @@ export const make = Effect.fn("makeVcsProvisioningService")(function* () {
5351 });
5452});
5553
56export const layer = Layer.effect(VcsProvisioningService, make());
54export const layer = Layer.effect(VcsProvisioningService, make);
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+2-2
......@@ -299,7 +299,7 @@ describe("VcsStatusBroadcaster", () => {
299299 Effect.sync(() => {
300300 state.remoteInvalidationCalls += 1;
301301 }),
302 } satisfies Partial<GitWorkflowService.GitWorkflowServiceShape>),
302 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
303303 ),
304304 );
305305
......@@ -617,7 +617,7 @@ describe("VcsStatusBroadcaster", () => {
617617 Effect.sync(() => {
618618 state.remoteInvalidationCalls += 1;
619619 }),
620 } satisfies Partial<GitWorkflowService.GitWorkflowServiceShape>),
620 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
621621 ),
622622 );
623623
apps/server/src/vcs/VcsStatusBroadcaster.ts+312-319
......@@ -65,23 +65,21 @@ export function remoteRefreshFailureDelay(
6565 return Duration.max(configuredInterval, cappedBackoff);
6666}
6767
68export interface VcsStatusBroadcasterShape {
69 readonly getStatus: (
70 input: VcsStatusInput,
71 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
72 readonly refreshLocalStatus: (
73 cwd: string,
74 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
75 readonly refreshStatus: (cwd: string) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
76 readonly streamStatus: (
77 input: VcsStatusInput,
78 options?: StreamStatusOptions,
79 ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>;
80}
81
8268export class VcsStatusBroadcaster extends Context.Service<
8369 VcsStatusBroadcaster,
84 VcsStatusBroadcasterShape
70 {
71 readonly getStatus: (
72 input: VcsStatusInput,
73 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
74 readonly refreshLocalStatus: (
75 cwd: string,
76 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
77 readonly refreshStatus: (cwd: string) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
78 readonly streamStatus: (
79 input: VcsStatusInput,
80 options?: StreamStatusOptions,
81 ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>;
82 }
8583>()("t3/vcs/VcsStatusBroadcaster") {}
8684
8785function fingerprintStatusPart(status: unknown): string {
......@@ -94,101 +92,57 @@ const normalizeCwd = (cwd: string) =>
9492 Effect.orElseSucceed(() => cwd),
9593 );
9694
97export const layer = Layer.effect(
98 VcsStatusBroadcaster,
99 Effect.gen(function* () {
100 const workflow = yield* GitWorkflowService.GitWorkflowService;
101 const fs = yield* FileSystem.FileSystem;
102 const changesPubSub = yield* Effect.acquireRelease(
103 PubSub.unbounded<VcsStatusChange>(),
104 (pubsub) => PubSub.shutdown(pubsub),
105 );
106 const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
107 Scope.close(scope, Exit.void),
108 );
109 const cacheRef = yield* Ref.make(new Map<string, CachedVcsStatus>());
110 const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>());
95export const make = Effect.gen(function* () {
96 const workflow = yield* GitWorkflowService.GitWorkflowService;
97 const fs = yield* FileSystem.FileSystem;
98 const changesPubSub = yield* Effect.acquireRelease(
99 PubSub.unbounded<VcsStatusChange>(),
100 (pubsub) => PubSub.shutdown(pubsub),
101 );
102 const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
103 Scope.close(scope, Exit.void),
104 );
105 const cacheRef = yield* Ref.make(new Map<string, CachedVcsStatus>());
106 const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>());
111107
112 const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* (
113 cwd: string,
114 ) {
115 return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null));
116 });
108 const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* (
109 cwd: string,
110 ) {
111 return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null));
112 });
117113
118 const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")(
119 function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) {
120 const nextLocal = {
121 fingerprint: fingerprintStatusPart(local),
122 value: local,
123 } satisfies CachedValue<VcsStatusLocalResult>;
124 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
125 const previous = cache.get(cwd) ?? { local: null, remote: null };
126 const nextCache = new Map(cache);
127 nextCache.set(cwd, {
128 ...previous,
129 local: nextLocal,
130 });
131 return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const;
114 const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")(
115 function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) {
116 const nextLocal = {
117 fingerprint: fingerprintStatusPart(local),
118 value: local,
119 } satisfies CachedValue<VcsStatusLocalResult>;
120 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
121 const previous = cache.get(cwd) ?? { local: null, remote: null };
122 const nextCache = new Map(cache);
123 nextCache.set(cwd, {
124 ...previous,
125 local: nextLocal,
132126 });
127 return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const;
128 });
133129
134 if (options?.publish && shouldPublish) {
135 yield* PubSub.publish(changesPubSub, {
136 cwd,
137 event: {
138 _tag: "localUpdated",
139 local,
140 },
141 });
142 }
143
144 return local;
145 },
146 );
147
148 const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")(
149 function* (
150 cwd: string,
151 remote: VcsStatusRemoteResult | null,
152 options?: { publish?: boolean },
153 ) {
154 const nextRemote = {
155 fingerprint: fingerprintStatusPart(remote),
156 value: remote,
157 } satisfies CachedValue<VcsStatusRemoteResult | null>;
158 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
159 const previous = cache.get(cwd) ?? { local: null, remote: null };
160 const nextCache = new Map(cache);
161 nextCache.set(cwd, {
162 ...previous,
163 remote: nextRemote,
164 });
165 return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const;
130 if (options?.publish && shouldPublish) {
131 yield* PubSub.publish(changesPubSub, {
132 cwd,
133 event: {
134 _tag: "localUpdated",
135 local,
136 },
166137 });
138 }
167139
168 if (options?.publish && shouldPublish) {
169 yield* PubSub.publish(changesPubSub, {
170 cwd,
171 event: {
172 _tag: "remoteUpdated",
173 remote,
174 },
175 });
176 }
177
178 return remote;
179 },
180 );
140 return local;
141 },
142 );
181143
182 const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* (
183 cwd: string,
184 local: VcsStatusLocalResult,
185 remote: VcsStatusRemoteResult | null,
186 options?: { publish?: boolean },
187 ) {
188 const nextLocal = {
189 fingerprint: fingerprintStatusPart(local),
190 value: local,
191 } satisfies CachedValue<VcsStatusLocalResult>;
144 const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")(
145 function* (cwd: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) {
192146 const nextRemote = {
193147 fingerprint: fingerprintStatusPart(remote),
194148 value: remote,
......@@ -197,263 +151,302 @@ export const layer = Layer.effect(
197151 const previous = cache.get(cwd) ?? { local: null, remote: null };
198152 const nextCache = new Map(cache);
199153 nextCache.set(cwd, {
200 local: nextLocal,
154 ...previous,
201155 remote: nextRemote,
202156 });
203 return [
204 previous.local?.fingerprint !== nextLocal.fingerprint ||
205 previous.remote?.fingerprint !== nextRemote.fingerprint,
206 nextCache,
207 ] as const;
157 return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const;
208158 });
209159
210160 if (options?.publish && shouldPublish) {
211161 yield* PubSub.publish(changesPubSub, {
212162 cwd,
213163 event: {
214 _tag: "snapshot",
215 local,
164 _tag: "remoteUpdated",
216165 remote,
217166 },
218167 });
219168 }
220169
221 return mergeGitStatusParts(local, remote);
222 });
170 return remote;
171 },
172 );
223173
224 const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* (
225 cwd: string,
226 ) {
227 const local = yield* workflow.localStatus({ cwd });
228 return yield* updateCachedLocalStatus(cwd, local);
174 const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* (
175 cwd: string,
176 local: VcsStatusLocalResult,
177 remote: VcsStatusRemoteResult | null,
178 options?: { publish?: boolean },
179 ) {
180 const nextLocal = {
181 fingerprint: fingerprintStatusPart(local),
182 value: local,
183 } satisfies CachedValue<VcsStatusLocalResult>;
184 const nextRemote = {
185 fingerprint: fingerprintStatusPart(remote),
186 value: remote,
187 } satisfies CachedValue<VcsStatusRemoteResult | null>;
188 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
189 const previous = cache.get(cwd) ?? { local: null, remote: null };
190 const nextCache = new Map(cache);
191 nextCache.set(cwd, {
192 local: nextLocal,
193 remote: nextRemote,
194 });
195 return [
196 previous.local?.fingerprint !== nextLocal.fingerprint ||
197 previous.remote?.fingerprint !== nextRemote.fingerprint,
198 nextCache,
199 ] as const;
229200 });
230201
231 const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* (
232 cwd: string,
233 ) {
234 const cached = yield* getCachedStatus(cwd);
235 if (cached?.local) {
236 return cached.local.value;
237 }
238 return yield* loadLocalStatus(cwd);
239 });
202 if (options?.publish && shouldPublish) {
203 yield* PubSub.publish(changesPubSub, {
204 cwd,
205 event: {
206 _tag: "snapshot",
207 local,
208 remote,
209 },
210 });
211 }
240212
241 const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs);
213 return mergeGitStatusParts(local, remote);
214 });
242215
243 const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn(
244 "VcsStatusBroadcaster.getStatus",
245 )(function* (input) {
246 const cwd = yield* withFileSystem(normalizeCwd(input.cwd));
247 const cached = yield* getCachedStatus(cwd);
248 if (cached?.local && cached.remote) {
249 return mergeGitStatusParts(cached.local.value, cached.remote.value);
250 }
251 const [local, remote] = yield* Effect.all(
252 [
253 cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }),
254 cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }),
255 ],
256 { concurrency: "unbounded" },
257 );
258 return yield* updateCachedStatus(cwd, local, remote);
259 });
216 const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* (
217 cwd: string,
218 ) {
219 const local = yield* workflow.localStatus({ cwd });
220 return yield* updateCachedLocalStatus(cwd, local);
221 });
260222
261 const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")(
262 function* (cwd: string) {
263 yield* workflow.invalidateLocalStatus(cwd);
264 const local = yield* workflow.localStatus({ cwd });
265 return yield* updateCachedLocalStatus(cwd, local, { publish: true });
266 },
223 const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* (
224 cwd: string,
225 ) {
226 const cached = yield* getCachedStatus(cwd);
227 if (cached?.local) {
228 return cached.local.value;
229 }
230 return yield* loadLocalStatus(cwd);
231 });
232
233 const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs);
234
235 const getStatus: VcsStatusBroadcaster["Service"]["getStatus"] = Effect.fn(
236 "VcsStatusBroadcaster.getStatus",
237 )(function* (input) {
238 const cwd = yield* withFileSystem(normalizeCwd(input.cwd));
239 const cached = yield* getCachedStatus(cwd);
240 if (cached?.local && cached.remote) {
241 return mergeGitStatusParts(cached.local.value, cached.remote.value);
242 }
243 const [local, remote] = yield* Effect.all(
244 [
245 cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }),
246 cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }),
247 ],
248 { concurrency: "unbounded" },
267249 );
250 return yield* updateCachedStatus(cwd, local, remote);
251 });
268252
269 const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn(
270 "VcsStatusBroadcaster.refreshLocalStatus",
271 )(function* (rawCwd) {
272 const cwd = yield* withFileSystem(normalizeCwd(rawCwd));
273 return yield* refreshLocalStatusCore(cwd);
274 });
253 const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")(
254 function* (cwd: string) {
255 yield* workflow.invalidateLocalStatus(cwd);
256 const local = yield* workflow.localStatus({ cwd });
257 return yield* updateCachedLocalStatus(cwd, local, { publish: true });
258 },
259 );
275260
276 const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* (
277 cwd: string,
278 options?: { readonly refreshUpstream?: boolean },
279 ) {
280 if (options?.refreshUpstream !== false) {
281 yield* workflow.invalidateRemoteStatus(cwd);
282 }
283 const remote = yield* workflow.remoteStatus({ cwd }, options);
284 return yield* updateCachedRemoteStatus(cwd, remote, { publish: true });
285 });
261 const refreshLocalStatus: VcsStatusBroadcaster["Service"]["refreshLocalStatus"] = Effect.fn(
262 "VcsStatusBroadcaster.refreshLocalStatus",
263 )(function* (rawCwd) {
264 const cwd = yield* withFileSystem(normalizeCwd(rawCwd));
265 return yield* refreshLocalStatusCore(cwd);
266 });
286267
287 const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn(
288 "VcsStatusBroadcaster.refreshStatus",
289 )(function* (rawCwd) {
290 const cwd = yield* withFileSystem(normalizeCwd(rawCwd));
291 yield* Effect.all(
292 [workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)],
293 { concurrency: "unbounded", discard: true },
294 );
295 const [local, remote] = yield* Effect.all(
296 [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })],
297 { concurrency: "unbounded" },
298 );
299 return yield* updateCachedStatus(cwd, local, remote, { publish: true });
268 const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* (
269 cwd: string,
270 options?: { readonly refreshUpstream?: boolean },
271 ) {
272 if (options?.refreshUpstream !== false) {
273 yield* workflow.invalidateRemoteStatus(cwd);
274 }
275 const remote = yield* workflow.remoteStatus({ cwd }, options);
276 return yield* updateCachedRemoteStatus(cwd, remote, { publish: true });
277 });
278
279 const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn(
280 "VcsStatusBroadcaster.refreshStatus",
281 )(function* (rawCwd) {
282 const cwd = yield* withFileSystem(normalizeCwd(rawCwd));
283 yield* Effect.all([workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], {
284 concurrency: "unbounded",
285 discard: true,
300286 });
287 const [local, remote] = yield* Effect.all(
288 [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })],
289 { concurrency: "unbounded" },
290 );
291 return yield* updateCachedStatus(cwd, local, remote, { publish: true });
292 });
301293
302 const makeRemoteRefreshLoop = (
303 cwd: string,
304 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
305 refreshImmediately: boolean,
306 ) => {
307 return Effect.gen(function* () {
308 const consecutiveFailuresRef = yield* Ref.make(0);
309 const needsInitialRefreshRef = yield* Ref.make(refreshImmediately);
310 const refreshRemoteStatusIfEnabled = Effect.gen(function* () {
311 const configuredInterval = yield* automaticRemoteRefreshInterval;
312 const activeInterval = Duration.isZero(configuredInterval)
313 ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL
314 : configuredInterval;
315 const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef);
316 if (Duration.isZero(configuredInterval) && !needsInitialRefresh) {
317 return activeInterval;
318 }
319
320 const exit = yield* refreshRemoteStatus(cwd, {
321 refreshUpstream: !Duration.isZero(configuredInterval),
322 }).pipe(Effect.exit);
323 if (Exit.isSuccess(exit)) {
324 yield* Ref.set(needsInitialRefreshRef, false);
325 yield* Ref.set(consecutiveFailuresRef, 0);
326 return activeInterval;
327 }
328
329 const consecutiveFailures = yield* Ref.updateAndGet(
330 consecutiveFailuresRef,
331 (count) => count + 1,
332 );
333 const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval);
334 yield* Effect.logWarning("VCS remote status refresh failed", {
335 cwd,
336 detail: exit.cause.toString(),
337 consecutiveFailures,
338 nextDelayMs: Duration.toMillis(nextDelay),
339 });
340 return nextDelay;
341 });
294 const makeRemoteRefreshLoop = (
295 cwd: string,
296 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
297 refreshImmediately: boolean,
298 ) => {
299 return Effect.gen(function* () {
300 const consecutiveFailuresRef = yield* Ref.make(0);
301 const needsInitialRefreshRef = yield* Ref.make(refreshImmediately);
302 const refreshRemoteStatusIfEnabled = Effect.gen(function* () {
303 const configuredInterval = yield* automaticRemoteRefreshInterval;
304 const activeInterval = Duration.isZero(configuredInterval)
305 ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL
306 : configuredInterval;
307 const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef);
308 if (Duration.isZero(configuredInterval) && !needsInitialRefresh) {
309 return activeInterval;
310 }
342311
343 if (!refreshImmediately) {
344 const configuredInterval = yield* automaticRemoteRefreshInterval;
345 yield* Effect.sleep(
346 Duration.isZero(configuredInterval)
347 ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL
348 : configuredInterval,
349 );
312 const exit = yield* refreshRemoteStatus(cwd, {
313 refreshUpstream: !Duration.isZero(configuredInterval),
314 }).pipe(Effect.exit);
315 if (Exit.isSuccess(exit)) {
316 yield* Ref.set(needsInitialRefreshRef, false);
317 yield* Ref.set(consecutiveFailuresRef, 0);
318 return activeInterval;
350319 }
351320
352 return yield* refreshRemoteStatusIfEnabled.pipe(
353 Effect.repeat(
354 Schedule.identity<Duration.Duration>().pipe(
355 Schedule.addDelay((delay) => Effect.succeed(delay)),
356 ),
357 ),
358 Effect.asVoid,
321 const consecutiveFailures = yield* Ref.updateAndGet(
322 consecutiveFailuresRef,
323 (count) => count + 1,
359324 );
325 const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval);
326 yield* Effect.logWarning("VCS remote status refresh failed", {
327 cwd,
328 detail: exit.cause.toString(),
329 consecutiveFailures,
330 nextDelayMs: Duration.toMillis(nextDelay),
331 });
332 return nextDelay;
360333 });
361 };
362334
363 const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* (
364 cwd: string,
365 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
366 refreshImmediately: boolean,
367 ) {
368 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
369 const existing = activePollers.get(cwd);
370 if (existing) {
371 const nextPollers = new Map(activePollers);
372 nextPollers.set(cwd, {
373 ...existing,
374 subscriberCount: existing.subscriberCount + 1,
375 });
376 return Effect.succeed([undefined, nextPollers] as const);
377 }
378
379 return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe(
380 Effect.forkIn(broadcasterScope),
381 Effect.map((fiber) => {
382 const nextPollers = new Map(activePollers);
383 nextPollers.set(cwd, {
384 fiber,
385 subscriberCount: 1,
386 });
387 return [undefined, nextPollers] as const;
388 }),
335 if (!refreshImmediately) {
336 const configuredInterval = yield* automaticRemoteRefreshInterval;
337 yield* Effect.sleep(
338 Duration.isZero(configuredInterval)
339 ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL
340 : configuredInterval,
389341 );
390 });
342 }
343
344 return yield* refreshRemoteStatusIfEnabled.pipe(
345 Effect.repeat(
346 Schedule.identity<Duration.Duration>().pipe(
347 Schedule.addDelay((delay) => Effect.succeed(delay)),
348 ),
349 ),
350 Effect.asVoid,
351 );
391352 });
353 };
392354
393 const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* (
394 cwd: string,
395 ) {
396 const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => {
397 const existing = activePollers.get(cwd);
398 if (!existing) {
399 return [null, activePollers] as const;
400 }
355 const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* (
356 cwd: string,
357 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
358 refreshImmediately: boolean,
359 ) {
360 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
361 const existing = activePollers.get(cwd);
362 if (existing) {
363 const nextPollers = new Map(activePollers);
364 nextPollers.set(cwd, {
365 ...existing,
366 subscriberCount: existing.subscriberCount + 1,
367 });
368 return Effect.succeed([undefined, nextPollers] as const);
369 }
401370
402 if (existing.subscriberCount > 1) {
371 return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe(
372 Effect.forkIn(broadcasterScope),
373 Effect.map((fiber) => {
403374 const nextPollers = new Map(activePollers);
404375 nextPollers.set(cwd, {
405 ...existing,
406 subscriberCount: existing.subscriberCount - 1,
376 fiber,
377 subscriberCount: 1,
407378 });
408 return [null, nextPollers] as const;
409 }
379 return [undefined, nextPollers] as const;
380 }),
381 );
382 });
383 });
410384
411 const nextPollers = new Map(activePollers);
412 nextPollers.delete(cwd);
413 return [existing.fiber, nextPollers] as const;
414 });
385 const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* (
386 cwd: string,
387 ) {
388 const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => {
389 const existing = activePollers.get(cwd);
390 if (!existing) {
391 return [null, activePollers] as const;
392 }
415393
416 if (pollerToInterrupt) {
417 yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore);
394 if (existing.subscriberCount > 1) {
395 const nextPollers = new Map(activePollers);
396 nextPollers.set(cwd, {
397 ...existing,
398 subscriberCount: existing.subscriberCount - 1,
399 });
400 return [null, nextPollers] as const;
418401 }
402
403 const nextPollers = new Map(activePollers);
404 nextPollers.delete(cwd);
405 return [existing.fiber, nextPollers] as const;
419406 });
420407
421 const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input, options) =>
422 Stream.unwrap(
423 Effect.gen(function* () {
424 const cwd = yield* withFileSystem(normalizeCwd(input.cwd));
425 const subscription = yield* PubSub.subscribe(changesPubSub);
426 const initialLocal = yield* getOrLoadLocalStatus(cwd);
427 const cachedStatus = yield* getCachedStatus(cwd);
428 const initialRemote = cachedStatus?.remote?.value ?? null;
429 yield* retainRemotePoller(
430 cwd,
431 options?.automaticRemoteRefreshInterval ??
432 Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL),
433 cachedStatus?.remote === null || cachedStatus?.remote === undefined,
434 );
435
436 const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid);
437
438 return Stream.concat(
439 Stream.make({
440 _tag: "snapshot" as const,
441 local: initialLocal,
442 remote: initialRemote,
443 }),
444 Stream.fromSubscription(subscription).pipe(
445 Stream.filter((event) => event.cwd === cwd),
446 Stream.map((event) => event.event),
447 ),
448 ).pipe(Stream.ensuring(release));
449 }),
450 );
408 if (pollerToInterrupt) {
409 yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore);
410 }
411 });
412
413 const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) =>
414 Stream.unwrap(
415 Effect.gen(function* () {
416 const cwd = yield* withFileSystem(normalizeCwd(input.cwd));
417 const subscription = yield* PubSub.subscribe(changesPubSub);
418 const initialLocal = yield* getOrLoadLocalStatus(cwd);
419 const cachedStatus = yield* getCachedStatus(cwd);
420 const initialRemote = cachedStatus?.remote?.value ?? null;
421 yield* retainRemotePoller(
422 cwd,
423 options?.automaticRemoteRefreshInterval ??
424 Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL),
425 cachedStatus?.remote === null || cachedStatus?.remote === undefined,
426 );
451427
452 return VcsStatusBroadcaster.of({
453 getStatus,
454 refreshLocalStatus,
455 refreshStatus,
456 streamStatus,
457 });
458 }),
459);
428 const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid);
429
430 return Stream.concat(
431 Stream.make({
432 _tag: "snapshot" as const,
433 local: initialLocal,
434 remote: initialRemote,
435 }),
436 Stream.fromSubscription(subscription).pipe(
437 Stream.filter((event) => event.cwd === cwd),
438 Stream.map((event) => event.event),
439 ),
440 ).pipe(Stream.ensuring(release));
441 }),
442 );
443
444 return VcsStatusBroadcaster.of({
445 getStatus,
446 refreshLocalStatus,
447 refreshStatus,
448 streamStatus,
449 });
450});
451
452export const layer = Layer.effect(VcsStatusBroadcaster, make);
apps/server/src/ws.ts+3-3
......@@ -92,7 +92,7 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
9292import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
9393import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
9494import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
95import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts";
95import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";
9696import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
9797import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
9898import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
......@@ -278,7 +278,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
278278 yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
279279 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
280280 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
281 const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery;
281 const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery;
282282 const automaticGitFetchInterval = serverSettings.getSettings.pipe(
283283 Effect.map((settings) => settings.automaticGitFetchInterval),
284284 Effect.catch((cause) =>
......@@ -1669,7 +1669,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
16691669 Layer.provide(PreviewAutomationBroker.layer),
16701670 Layer.provide(ProviderMaintenanceRunner.layer),
16711671 Layer.provide(
1672 SourceControlDiscoveryLayer.layer.pipe(
1672 SourceControlDiscovery.layer.pipe(
16731673 Layer.provide(
16741674 SourceControlProviderRegistry.layer.pipe(
16751675 Layer.provide(