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(...@@ -376,7 +376,7 @@ function createTextGeneration(
376}376}
377377
378function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {378function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
379 service: GitHubCli.GitHubCliShape;379 service: GitHubCli.GitHubCli["Service"];
380 ghCalls: string[];380 ghCalls: string[];
381} {381} {
382 const prListQueue = [...(scenario.prListSequence ?? [])];382 const prListQueue = [...(scenario.prListSequence ?? [])];
...@@ -388,7 +388,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -388,7 +388,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
388 );388 );
389 const ghCalls: string[] = [];389 const ghCalls: string[] = [];
390390
391 const execute: GitHubCli.GitHubCliShape["execute"] = (input) => {391 const execute: GitHubCli.GitHubCli["Service"]["execute"] = (input) => {
392 const args = [...input.args];392 const args = [...input.args];
393 ghCalls.push(args.join(" "));393 ghCalls.push(args.join(" "));
394394
...@@ -609,7 +609,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -609,7 +609,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
609}609}
610610
611function runStackedAction(611function runStackedAction(
612 manager: GitManager.GitManagerShape,612 manager: GitManager.GitManager["Service"],
613 input: {613 input: {
614 cwd: string;614 cwd: string;
615 action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr";615 action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr";
...@@ -618,7 +618,7 @@ function runStackedAction(...@@ -618,7 +618,7 @@ function runStackedAction(
618 featureBranch?: boolean;618 featureBranch?: boolean;
619 filePaths?: readonly string[];619 filePaths?: readonly string[];
620 },620 },
621 options?: Parameters<GitManager.GitManagerShape["runStackedAction"]>[1],621 options?: Parameters<GitManager.GitManager["Service"]["runStackedAction"]>[1],
622) {622) {
623 return manager.runStackedAction(623 return manager.runStackedAction(
624 {624 {
...@@ -630,14 +630,14 @@ function runStackedAction(...@@ -630,14 +630,14 @@ function runStackedAction(
630}630}
631631
632function resolvePullRequest(632function resolvePullRequest(
633 manager: GitManager.GitManagerShape,633 manager: GitManager.GitManager["Service"],
634 input: { cwd: string; reference: string },634 input: { cwd: string; reference: string },
635) {635) {
636 return manager.resolvePullRequest(input);636 return manager.resolvePullRequest(input);
637}637}
638638
639function preparePullRequestThread(639function preparePullRequestThread(
640 manager: GitManager.GitManagerShape,640 manager: GitManager.GitManager["Service"],
641 input: GitPreparePullRequestThreadInput,641 input: GitPreparePullRequestThreadInput,
642) {642) {
643 return manager.preparePullRequestThread(input);643 return manager.preparePullRequestThread(input);
...@@ -646,11 +646,11 @@ function preparePullRequestThread(...@@ -646,11 +646,11 @@ function preparePullRequestThread(
646function makeManager(input?: {646function makeManager(input?: {
647 ghScenario?: FakeGhScenario;647 ghScenario?: FakeGhScenario;
648 textGeneration?: Partial<FakeGitTextGeneration>;648 textGeneration?: Partial<FakeGitTextGeneration>;
649 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunnerShape;649 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
650}) {650}) {
651 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);651 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
652 const textGeneration = createTextGeneration(input?.textGeneration);652 const textGeneration = createTextGeneration(input?.textGeneration);
653 const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {653 const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
654 prefix: "t3-git-manager-test-",654 prefix: "t3-git-manager-test-",
655 });655 });
656656
...@@ -663,7 +663,7 @@ function makeManager(input?: {...@@ -663,7 +663,7 @@ function makeManager(input?: {
663 );663 );
664 const sourceControlRegistryLayer = Layer.effect(664 const sourceControlRegistryLayer = Layer.effect(
665 SourceControlProviderRegistry.SourceControlProviderRegistry,665 SourceControlProviderRegistry.SourceControlProviderRegistry,
666 GitHubSourceControlProvider.make().pipe(666 GitHubSourceControlProvider.make.pipe(
667 Effect.map((provider) =>667 Effect.map((provider) =>
668 SourceControlProviderRegistry.SourceControlProviderRegistry.of({668 SourceControlProviderRegistry.SourceControlProviderRegistry.of({
669 get: () => Effect.succeed(provider),669 get: () => Effect.succeed(provider),
...@@ -688,7 +688,7 @@ function makeManager(input?: {...@@ -688,7 +688,7 @@ function makeManager(input?: {
688 serverSettingsLayer,688 serverSettingsLayer,
689 ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer));689 ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer));
690690
691 return GitManager.makeGitManager().pipe(691 return GitManager.make.pipe(
692 Effect.provide(managerLayer),692 Effect.provide(managerLayer),
693 Effect.map((manager) => ({ manager, ghCalls })),693 Effect.map((manager) => ({ manager, ghCalls })),
694 );694 );
...@@ -697,9 +697,7 @@ function makeManager(input?: {...@@ -697,9 +697,7 @@ function makeManager(input?: {
697const asThreadId = (threadId: string) => threadId as ThreadId;697const asThreadId = (threadId: string) => threadId as ThreadId;
698698
699const GitManagerTestLayer = GitVcsDriver.layer.pipe(699const GitManagerTestLayer = GitVcsDriver.layer.pipe(
700 Layer.provide(700 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })),
701 ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" }),
702 ),
703 Layer.provideMerge(VcsProcess.layer),701 Layer.provideMerge(VcsProcess.layer),
704 Layer.provideMerge(NodeServices.layer),702 Layer.provideMerge(NodeServices.layer),
705);703);
apps/server/src/git/GitManager.ts+68-71
...@@ -42,17 +42,13 @@ import {...@@ -42,17 +42,13 @@ import {
42} from "@t3tools/shared/sourceControl";42} from "@t3tools/shared/sourceControl";
4343
44import { GitManagerError } from "@t3tools/contracts";44import { GitManagerError } from "@t3tools/contracts";
45import { TextGeneration } from "../textGeneration/TextGeneration.ts";45import * as TextGeneration from "../textGeneration/TextGeneration.ts";
46import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts";46import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts";
47import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";47import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";
48import { ServerSettingsService } from "../serverSettings.ts";48import * as ServerSettings from "../serverSettings.ts";
49import type { GitManagerServiceError } from "@t3tools/contracts";49import type { GitManagerServiceError } from "@t3tools/contracts";
50import {50import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
51 GitVcsDriver,51import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
52 type GitRemoteStatusOptions,
53 type GitStatusDetails,
54} from "../vcs/GitVcsDriver.ts";
55import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts";
56import type { ChangeRequest } from "@t3tools/contracts";52import type { ChangeRequest } from "@t3tools/contracts";
5753
58export interface GitActionProgressReporter {54export interface GitActionProgressReporter {
...@@ -64,35 +60,34 @@ export interface GitRunStackedActionOptions {...@@ -64,35 +60,34 @@ export interface GitRunStackedActionOptions {
64 readonly progressReporter?: GitActionProgressReporter;60 readonly progressReporter?: GitActionProgressReporter;
65}61}
6662
67export interface GitManagerShape {63export class GitManager extends Context.Service<
68 readonly status: (64 GitManager,
69 input: VcsStatusInput,65 {
70 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;66 readonly status: (
71 readonly localStatus: (67 input: VcsStatusInput,
72 input: VcsStatusInput,68 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
73 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;69 readonly localStatus: (
74 readonly remoteStatus: (70 input: VcsStatusInput,
75 input: VcsStatusInput,71 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
76 options?: GitRemoteStatusOptions,72 readonly remoteStatus: (
77 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;73 input: VcsStatusInput,
78 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;74 options?: GitVcsDriver.GitRemoteStatusOptions,
79 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;75 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
80 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;76 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
81 readonly resolvePullRequest: (77 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
82 input: GitPullRequestRefInput,78 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
83 ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>;79 readonly resolvePullRequest: (
84 readonly preparePullRequestThread: (80 input: GitPullRequestRefInput,
85 input: GitPreparePullRequestThreadInput,81 ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>;
86 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;82 readonly preparePullRequestThread: (
87 readonly runStackedAction: (83 input: GitPreparePullRequestThreadInput,
88 input: GitRunStackedActionInput,84 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;
89 options?: GitRunStackedActionOptions,85 readonly runStackedAction: (
90 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;86 input: GitRunStackedActionInput,
91}87 options?: GitRunStackedActionOptions,
9288 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
93export class GitManager extends Context.Service<GitManager, GitManagerShape>()(89 }
94 "t3/git/GitManager",90>()("t3/git/GitManager") {}
95) {}
9691
97const COMMIT_TIMEOUT_MS = 10 * 60_000;92const COMMIT_TIMEOUT_MS = 10 * 60_000;
98const MAX_PROGRESS_TEXT_LENGTH = 500;93const MAX_PROGRESS_TEXT_LENGTH = 500;
...@@ -531,15 +526,15 @@ function toPullRequestHeadRemoteInfo(pr: {...@@ -531,15 +526,15 @@ function toPullRequestHeadRemoteInfo(pr: {
531 };526 };
532}527}
533528
534export const makeGitManager = Effect.fn("makeGitManager")(function* () {529export const make = Effect.gen(function* () {
535 const gitCore = yield* GitVcsDriver;530 const gitCore = yield* GitVcsDriver.GitVcsDriver;
536 const sourceControlProviders = yield* SourceControlProviderRegistry;531 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
537 const textGeneration = yield* TextGeneration;532 const textGeneration = yield* TextGeneration.TextGeneration;
538 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;533 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
539 const crypto = yield* Crypto.Crypto;534 const crypto = yield* Crypto.Crypto;
540535
541 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });536 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
542 const serverSettingsService = yield* ServerSettingsService;537 const serverSettingsService = yield* ServerSettings.ServerSettingsService;
543 const randomUUIDv4 = crypto.randomUUIDv4.pipe(538 const randomUUIDv4 = crypto.randomUUIDv4.pipe(
544 Effect.mapError((cause) =>539 Effect.mapError((cause) =>
545 gitManagerError("randomUUIDv4", "Failed to generate Git operation identifier.", cause),540 gitManagerError("randomUUIDv4", "Failed to generate Git operation identifier.", cause),
...@@ -721,7 +716,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -721,7 +716,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
721 aheadCount: 0,716 aheadCount: 0,
722 behindCount: 0,717 behindCount: 0,
723 aheadOfDefaultCount: 0,718 aheadOfDefaultCount: 0,
724 } satisfies GitStatusDetails;719 } satisfies GitVcsDriver.GitStatusDetails;
725 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {720 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
726 const details = yield* gitCore721 const details = yield* gitCore
727 .statusDetailsLocal(cwd)722 .statusDetailsLocal(cwd)
...@@ -752,7 +747,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -752,7 +747,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
752 );747 );
753 const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (748 const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (
754 cwd: string,749 cwd: string,
755 options?: GitRemoteStatusOptions,750 options?: GitVcsDriver.GitRemoteStatusOptions,
756 ) {751 ) {
757 const details = yield* gitCore752 const details = yield* gitCore
758 .statusDetailsRemote(cwd, options)753 .statusDetailsRemote(cwd, options)
...@@ -1358,11 +1353,13 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1358,11 +1353,13 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1358 };1353 };
1359 });1354 });
13601355
1361 const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) {1356 const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")(
1362 const cacheKey = yield* normalizeStatusCacheKey(input.cwd);1357 function* (input) {
1363 return yield* Cache.get(localStatusResultCache, cacheKey);1358 const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
1364 });1359 return yield* Cache.get(localStatusResultCache, cacheKey);
1365 const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")(1360 },
1361 );
1362 const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")(
1366 function* (input, options) {1363 function* (input, options) {
1367 const cacheKey = yield* normalizeStatusCacheKey(input.cwd);1364 const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
1368 if (options?.refreshUpstream === false) {1365 if (options?.refreshUpstream === false) {
...@@ -1371,43 +1368,43 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1371,43 +1368,43 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1371 return yield* Cache.get(remoteStatusResultCache, cacheKey);1368 return yield* Cache.get(remoteStatusResultCache, cacheKey);
1372 },1369 },
1373 );1370 );
1374 const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {1371 const status: GitManager["Service"]["status"] = Effect.fn("status")(function* (input) {
1375 const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)], {1372 const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)], {
1376 concurrency: "unbounded",1373 concurrency: "unbounded",
1377 });1374 });
1378 return mergeGitStatusParts(local, remote);1375 return mergeGitStatusParts(local, remote);
1379 });1376 });
1380 const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn(1377 const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(
1381 "invalidateLocalStatus",1378 "invalidateLocalStatus",
1382 )(function* (cwd) {1379 )(function* (cwd) {
1383 yield* invalidateLocalStatusResultCache(cwd);1380 yield* invalidateLocalStatusResultCache(cwd);
1384 });1381 });
1385 const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn(1382 const invalidateRemoteStatus: GitManager["Service"]["invalidateRemoteStatus"] = Effect.fn(
1386 "invalidateRemoteStatus",1383 "invalidateRemoteStatus",
1387 )(function* (cwd) {1384 )(function* (cwd) {
1388 yield* invalidateRemoteStatusResultCache(cwd);1385 yield* invalidateRemoteStatusResultCache(cwd);
1389 });1386 });
1390 const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")(1387 const invalidateStatus: GitManager["Service"]["invalidateStatus"] = Effect.fn("invalidateStatus")(
1391 function* (cwd) {1388 function* (cwd) {
1392 yield* invalidateLocalStatusResultCache(cwd);1389 yield* invalidateLocalStatusResultCache(cwd);
1393 yield* invalidateRemoteStatusResultCache(cwd);1390 yield* invalidateRemoteStatusResultCache(cwd);
1394 },1391 },
1395 );1392 );
13961393
1397 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(1394 const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn(
1398 function* (input) {1395 "resolvePullRequest",
1399 const pullRequest = yield* (yield* sourceControlProvider(input.cwd))1396 )(function* (input) {
1400 .getChangeRequest({1397 const pullRequest = yield* (yield* sourceControlProvider(input.cwd))
1401 cwd: input.cwd,1398 .getChangeRequest({
1402 reference: normalizePullRequestReference(input.reference),1399 cwd: input.cwd,
1403 })1400 reference: normalizePullRequestReference(input.reference),
1404 .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved)));1401 })
1402 .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved)));
14051403
1406 return { pullRequest };1404 return { pullRequest };
1407 },1405 });
1408 );
14091406
1410 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn(1407 const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn(
1411 "preparePullRequestThread",1408 "preparePullRequestThread",
1412 )(function* (input) {1409 )(function* (input) {
1413 const maybeRunSetupScript = (worktreePath: string) => {1410 const maybeRunSetupScript = (worktreePath: string) => {
...@@ -1608,7 +1605,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1608,7 +1605,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1608 };1605 };
1609 });1606 });
16101607
1611 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")(1608 const runStackedAction: GitManager["Service"]["runStackedAction"] = Effect.fn("runStackedAction")(
1612 function* (input, options) {1609 function* (input, options) {
1613 const progress = yield* createProgressEmitter(input, options);1610 const progress = yield* createProgressEmitter(input, options);
1614 const currentPhase = yield* Ref.make<Option.Option<GitActionProgressPhase>>(Option.none());1611 const currentPhase = yield* Ref.make<Option.Option<GitActionProgressPhase>>(Option.none());
...@@ -1787,7 +1784,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1787,7 +1784,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1787 },1784 },
1788 );1785 );
17891786
1790 return {1787 return GitManager.of({
1791 localStatus,1788 localStatus,
1792 remoteStatus,1789 remoteStatus,
1793 status,1790 status,
...@@ -1797,7 +1794,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1797,7 +1794,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1797 resolvePullRequest,1794 resolvePullRequest,
1798 preparePullRequestThread,1795 preparePullRequestThread,
1799 runStackedAction,1796 runStackedAction,
1800 } satisfies GitManagerShape;1797 });
1801});1798});
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";...@@ -7,7 +7,9 @@ import * as GitWorkflowService from "./GitWorkflowService.ts";
7import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";7import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
8import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";8import * 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}) {
11 return GitWorkflowService.layer.pipe(13 return GitWorkflowService.layer.pipe(
12 Layer.provide(14 Layer.provide(
13 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({15 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
apps/server/src/git/GitWorkflowService.ts+66-64
...@@ -28,68 +28,70 @@ import {...@@ -28,68 +28,70 @@ import {
28 type VcsStatusResult,28 type VcsStatusResult,
29} from "@t3tools/contracts";29} from "@t3tools/contracts";
3030
31import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts";31import * as GitManager from "./GitManager.ts";
32import { GitVcsDriver, type GitRemoteStatusOptions } from "../vcs/GitVcsDriver.ts";32import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
33import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";33import * as 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}
8934
90export class GitWorkflowService extends Context.Service<35export class GitWorkflowService extends Context.Service<
91 GitWorkflowService,36 GitWorkflowService,
92 GitWorkflowServiceShape37 {
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 }
93>()("t3/git/GitWorkflowService") {}95>()("t3/git/GitWorkflowService") {}
9496
95const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) =>97const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) =>
...@@ -142,10 +144,10 @@ function nonRepositoryListRefs(): VcsListRefsResult {...@@ -142,10 +144,10 @@ function nonRepositoryListRefs(): VcsListRefsResult {
142 };144 };
143}145}
144146
145export const make = Effect.fn("makeGitWorkflowService")(function* () {147export const make = Effect.gen(function* () {
146 const registry = yield* VcsDriverRegistry;148 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
147 const git = yield* GitVcsDriver;149 const git = yield* GitVcsDriver.GitVcsDriver;
148 const gitManager = yield* GitManager;150 const gitManager = yield* GitManager.GitManager;
149151
150 const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* (152 const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* (
151 operation: string,153 operation: string,
...@@ -334,4 +336,4 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () {...@@ -334,4 +336,4 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () {
334 });336 });
335});337});
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";...@@ -59,7 +59,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
59import * as Clock from "effect/Clock";59import * as Clock from "effect/Clock";
60import { ServerSettingsService } from "../../serverSettings.ts";60import { ServerSettingsService } from "../../serverSettings.ts";
61import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";61import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
62import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitWorkflowService.ts";62import * as GitWorkflowService from "../../git/GitWorkflowService.ts";
6363
64const asProjectId = (value: string): ProjectId => ProjectId.make(value);64const asProjectId = (value: string): ProjectId => ProjectId.make(value);
65const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value);65const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value);
...@@ -348,9 +348,9 @@ describe("ProviderCommandReactor", () => {...@@ -348,9 +348,9 @@ describe("ProviderCommandReactor", () => {
348 Layer.provideMerge(Layer.succeed(ProviderService, service)),348 Layer.provideMerge(Layer.succeed(ProviderService, service)),
349 Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)),349 Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)),
350 Layer.provideMerge(350 Layer.provideMerge(
351 Layer.mock(GitWorkflowService)({351 Layer.mock(GitWorkflowService.GitWorkflowService)({
352 renameBranch,352 renameBranch,
353 } satisfies Partial<GitWorkflowServiceShape>),353 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
354 ),354 ),
355 Layer.provideMerge(355 Layer.provideMerge(
356 Layer.succeed(VcsStatusBroadcaster, {356 Layer.succeed(VcsStatusBroadcaster, {
apps/server/src/sourceControl/AzureDevOpsCli.test.ts+1-1
...@@ -17,7 +17,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({...@@ -17,7 +17,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
17 stderrTruncated: false,17 stderrTruncated: false,
18});18});
1919
20const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();20const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>();
2121
22const supportLayer = Layer.mergeAll(22const supportLayer = Layer.mergeAll(
23 Layer.mock(VcsProcess.VcsProcess)({23 Layer.mock(VcsProcess.VcsProcess)({
apps/server/src/sourceControl/AzureDevOpsCli.ts+68-72
...@@ -11,7 +11,12 @@ import {...@@ -11,7 +11,12 @@ import {
11} from "@t3tools/contracts";11} from "@t3tools/contracts";
1212
13import * as VcsProcess from "../vcs/VcsProcess.ts";13import * 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";
15import * as SourceControlProvider from "./SourceControlProvider.ts";20import * as SourceControlProvider from "./SourceControlProvider.ts";
1621
17const DEFAULT_TIMEOUT_MS = 30_000;22const DEFAULT_TIMEOUT_MS = 30_000;
...@@ -35,67 +40,60 @@ export interface AzureDevOpsRepositoryCloneUrls {...@@ -35,67 +40,60 @@ export interface AzureDevOpsRepositoryCloneUrls {
35 readonly sshUrl: string;40 readonly sshUrl: string;
36}41}
3742
38export interface AzureDevOpsCliShape {43export class AzureDevOpsCli extends Context.Service<
39 readonly execute: (input: {44 AzureDevOpsCli,
40 readonly cwd: string;45 {
41 readonly args: ReadonlyArray<string>;46 readonly execute: (input: {
42 readonly timeoutMs?: number;47 readonly cwd: string;
43 }) => Effect.Effect<VcsProcess.VcsProcessOutput, AzureDevOpsCliError>;48 readonly args: ReadonlyArray<string>;
4449 readonly timeoutMs?: number;
45 readonly listPullRequests: (input: {50 }) => Effect.Effect<VcsProcess.VcsProcessOutput, AzureDevOpsCliError>;
46 readonly cwd: string;51
47 readonly headSelector: string;52 readonly listPullRequests: (input: {
48 readonly source?: SourceControlProvider.SourceControlRefSelector;53 readonly cwd: string;
49 readonly state: "open" | "closed" | "merged" | "all";54 readonly headSelector: string;
50 readonly limit?: number;55 readonly source?: SourceControlProvider.SourceControlRefSelector;
51 }) => Effect.Effect<56 readonly state: "open" | "closed" | "merged" | "all";
52 ReadonlyArray<AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord>,57 readonly limit?: number;
53 AzureDevOpsCliError58 }) => Effect.Effect<ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, AzureDevOpsCliError>;
54 >;59
5560 readonly getPullRequest: (input: {
56 readonly getPullRequest: (input: {61 readonly cwd: string;
57 readonly cwd: string;62 readonly reference: string;
58 readonly reference: string;63 }) => Effect.Effect<NormalizedAzureDevOpsPullRequestRecord, AzureDevOpsCliError>;
59 }) => Effect.Effect<64
60 AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord,65 readonly getRepositoryCloneUrls: (input: {
61 AzureDevOpsCliError66 readonly cwd: string;
62 >;67 readonly repository: string;
6368 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
64 readonly getRepositoryCloneUrls: (input: {69
65 readonly cwd: string;70 readonly createRepository: (input: {
66 readonly repository: string;71 readonly cwd: string;
67 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;72 readonly repository: string;
6873 readonly visibility: SourceControlRepositoryVisibility;
69 readonly createRepository: (input: {74 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
70 readonly cwd: string;75
71 readonly repository: string;76 readonly createPullRequest: (input: {
72 readonly visibility: SourceControlRepositoryVisibility;77 readonly cwd: string;
73 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;78 readonly baseBranch: string;
7479 readonly headSelector: string;
75 readonly createPullRequest: (input: {80 readonly source?: SourceControlProvider.SourceControlRefSelector;
76 readonly cwd: string;81 readonly target?: SourceControlProvider.SourceControlRefSelector;
77 readonly baseBranch: string;82 readonly title: string;
78 readonly headSelector: string;83 readonly bodyFile: string;
79 readonly source?: SourceControlProvider.SourceControlRefSelector;84 }) => Effect.Effect<void, AzureDevOpsCliError>;
80 readonly target?: SourceControlProvider.SourceControlRefSelector;85
81 readonly title: string;86 readonly getDefaultBranch: (input: {
82 readonly bodyFile: string;87 readonly cwd: string;
83 }) => Effect.Effect<void, AzureDevOpsCliError>;88 }) => Effect.Effect<string | null, AzureDevOpsCliError>;
8489
85 readonly getDefaultBranch: (input: {90 readonly checkoutPullRequest: (input: {
86 readonly cwd: string;91 readonly cwd: string;
87 }) => Effect.Effect<string | null, AzureDevOpsCliError>;92 readonly reference: string;
8893 readonly remoteName?: string;
89 readonly checkoutPullRequest: (input: {94 }) => Effect.Effect<void, AzureDevOpsCliError>;
90 readonly cwd: string;95 }
91 readonly reference: string;96>()("t3/sourceControl/AzureDevOpsCli") {}
92 readonly remoteName?: string;
93 }) => Effect.Effect<void, AzureDevOpsCliError>;
94}
95
96export class AzureDevOpsCli extends Context.Service<AzureDevOpsCli, AzureDevOpsCliShape>()(
97 "t3/sourceControl/AzureDevOpsCli",
98) {}
9997
100function errorText(error: VcsError | unknown): string {98function errorText(error: VcsError | unknown): string {
101 if (typeof error === "object" && error !== null) {99 if (typeof error === "object" && error !== null) {
...@@ -239,10 +237,10 @@ function decodeAzureDevOpsJson<S extends Schema.Top>(...@@ -239,10 +237,10 @@ function decodeAzureDevOpsJson<S extends Schema.Top>(
239 );237 );
240}238}
241239
242export const make = Effect.fn("makeAzureDevOpsCli")(function* () {240export const make = Effect.gen(function* () {
243 const process = yield* VcsProcess.VcsProcess;241 const process = yield* VcsProcess.VcsProcess;
244242
245 const execute: AzureDevOpsCliShape["execute"] = (input) =>243 const execute: AzureDevOpsCli["Service"]["execute"] = (input) =>
246 process244 process
247 .run({245 .run({
248 operation: "AzureDevOpsCli.execute",246 operation: "AzureDevOpsCli.execute",
...@@ -253,7 +251,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () {...@@ -253,7 +251,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () {
253 })251 })
254 .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error)));252 .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]) =>
257 execute({255 execute({
258 ...input,256 ...input,
259 args: [...input.args, "--only-show-errors", "--output", "json"],257 args: [...input.args, "--only-show-errors", "--output", "json"],
...@@ -282,15 +280,13 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () {...@@ -282,15 +280,13 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () {
282 Effect.flatMap((raw) =>280 Effect.flatMap((raw) =>
283 raw.length === 0281 raw.length === 0
284 ? Effect.succeed([])282 ? Effect.succeed([])
285 : Effect.sync(() =>283 : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe(
286 AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestListJson(raw),
287 ).pipe(
288 Effect.flatMap((decoded) => {284 Effect.flatMap((decoded) => {
289 if (!Result.isSuccess(decoded)) {285 if (!Result.isSuccess(decoded)) {
290 return Effect.fail(286 return Effect.fail(
291 new AzureDevOpsCliError({287 new AzureDevOpsCliError({
292 operation: "listPullRequests",288 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)}`,
294 cause: decoded.failure,290 cause: decoded.failure,
295 }),291 }),
296 );292 );
...@@ -316,13 +312,13 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () {...@@ -316,13 +312,13 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () {
316 }).pipe(312 }).pipe(
317 Effect.map((result) => result.stdout.trim()),313 Effect.map((result) => result.stdout.trim()),
318 Effect.flatMap((raw) =>314 Effect.flatMap((raw) =>
319 Effect.sync(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestJson(raw)).pipe(315 Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe(
320 Effect.flatMap((decoded) => {316 Effect.flatMap((decoded) => {
321 if (!Result.isSuccess(decoded)) {317 if (!Result.isSuccess(decoded)) {
322 return Effect.fail(318 return Effect.fail(
323 new AzureDevOpsCliError({319 new AzureDevOpsCliError({
324 operation: "getPullRequest",320 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)}`,
326 cause: decoded.failure,322 cause: decoded.failure,
327 }),323 }),
328 );324 );
...@@ -434,4 +430,4 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () {...@@ -434,4 +430,4 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () {
434 });430 });
435});431});
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";...@@ -6,8 +6,8 @@ import * as Option from "effect/Option";
6import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";6import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
7import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";7import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
88
9function makeProvider(azure: Partial<AzureDevOpsCli.AzureDevOpsCliShape>) {9function makeProvider(azure: Partial<AzureDevOpsCli.AzureDevOpsCli["Service"]>) {
10 return AzureDevOpsSourceControlProvider.make().pipe(10 return AzureDevOpsSourceControlProvider.make.pipe(
11 Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)),11 Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)),
12 );12 );
13}13}
...@@ -48,8 +48,9 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests"...@@ -48,8 +48,9 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests"
4848
49it.effect("creates Azure DevOps PRs through provider-neutral input names", () =>49it.effect("creates Azure DevOps PRs through provider-neutral input names", () =>
50 Effect.gen(function* () {50 Effect.gen(function* () {
51 let createInput: Parameters<AzureDevOpsCli.AzureDevOpsCliShape["createPullRequest"]>[0] | null =51 let createInput:
52 null;52 | Parameters<AzureDevOpsCli.AzureDevOpsCli["Service"]["createPullRequest"]>[0]
53 | null = null;
53 const provider = yield* makeProvider({54 const provider = yield* makeProvider({
54 createPullRequest: (input) => {55 createPullRequest: (input) => {
55 createInput = input;56 createInput = input;
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts+15-11
...@@ -4,7 +4,13 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac...@@ -4,7 +4,13 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac
44
5import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";5import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
6import * as SourceControlProvider from "./SourceControlProvider.ts";6import * 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
9function providerError(15function providerError(
10 operation: string,16 operation: string,
...@@ -18,28 +24,26 @@ function providerError(...@@ -18,28 +24,26 @@ function providerError(
18 });24 });
19}25}
2026
21function parseAzureAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {27function parseAzureAuth(input: SourceControlAuthProbeInput) {
22 const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();28 const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
2329
24 if (input.exitCode !== 0) {30 if (input.exitCode !== 0) {
25 return SourceControlProviderDiscovery.providerAuth({31 return providerAuth({
26 status: "unauthenticated",32 status: "unauthenticated",
27 detail:33 detail:
28 SourceControlProviderDiscovery.firstSafeAuthLine(34 firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.",
29 SourceControlProviderDiscovery.combinedAuthOutput(input),
30 ) ?? "Run `az login` to authenticate Azure CLI.",
31 });35 });
32 }36 }
3337
34 if (account !== undefined && account.length > 0) {38 if (account !== undefined && account.length > 0) {
35 return SourceControlProviderDiscovery.providerAuth({39 return providerAuth({
36 status: "authenticated",40 status: "authenticated",
37 account,41 account,
38 host: "dev.azure.com",42 host: "dev.azure.com",
39 });43 });
40 }44 }
4145
42 return SourceControlProviderDiscovery.providerAuth({46 return providerAuth({
43 status: "unknown",47 status: "unknown",
44 host: "dev.azure.com",48 host: "dev.azure.com",
45 detail: "Azure CLI account status could not be parsed.",49 detail: "Azure CLI account status could not be parsed.",
...@@ -56,7 +60,7 @@ export const discovery = {...@@ -56,7 +60,7 @@ export const discovery = {
56 parseAuth: parseAzureAuth,60 parseAuth: parseAzureAuth,
57 installHint:61 installHint:
58 "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.",62 "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
61function toChangeRequest(summary: {65function toChangeRequest(summary: {
62 readonly number: number;66 readonly number: number;
...@@ -80,7 +84,7 @@ function toChangeRequest(summary: {...@@ -80,7 +84,7 @@ function toChangeRequest(summary: {
80 };84 };
81}85}
8286
83export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () {87export const make = Effect.gen(function* () {
84 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;88 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
8589
86 return SourceControlProvider.SourceControlProvider.of({90 return SourceControlProvider.SourceControlProvider.of({
...@@ -142,4 +146,4 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function*...@@ -142,4 +146,4 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function*
142 });146 });
143});147});
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 = {...@@ -53,41 +53,41 @@ const repositoryJson = {
5353
54function makeLayer(input: {54function makeLayer(input: {
55 readonly response: (request: HttpClientRequest.HttpClientRequest) => Response;55 readonly response: (request: HttpClientRequest.HttpClientRequest) => Response;
56 readonly git?: Partial<GitVcsDriver.GitVcsDriverShape>;56 readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>;
57}) {57}) {
58 const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) =>58 const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) =>
59 Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))),59 Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))),
60 );60 );
61 const gitMock = {61 const gitMock = {
62 readConfigValue: vi.fn<GitVcsDriver.GitVcsDriverShape["readConfigValue"]>(() =>62 readConfigValue: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"]>(() =>
63 Effect.succeed<string | null>("git@bitbucket.org:pingdotgg/t3code.git"),63 Effect.succeed<string | null>("git@bitbucket.org:pingdotgg/t3code.git"),
64 ),64 ),
65 resolvePrimaryRemoteName: vi.fn<GitVcsDriver.GitVcsDriverShape["resolvePrimaryRemoteName"]>(65 resolvePrimaryRemoteName: vi.fn<
66 () => Effect.succeed("origin"),66 GitVcsDriver.GitVcsDriver["Service"]["resolvePrimaryRemoteName"]
67 ),67 >(() => Effect.succeed("origin")),
68 ensureRemote: vi.fn<GitVcsDriver.GitVcsDriverShape["ensureRemote"]>(() =>68 ensureRemote: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"]>(() =>
69 Effect.succeed("octocat"),69 Effect.succeed("octocat"),
70 ),70 ),
71 fetchRemoteBranch: vi.fn<GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"]>(71 fetchRemoteBranch: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"]>(
72 () => Effect.void,
73 ),
74 fetchRemoteTrackingBranch: vi.fn<GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"]>(
75 () => Effect.void,72 () => Effect.void,
76 ),73 ),
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"]>(
78 () => Effect.void,78 () => Effect.void,
79 ),79 ),
80 switchRef: vi.fn<GitVcsDriver.GitVcsDriverShape["switchRef"]>((request) =>80 switchRef: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["switchRef"]>((request) =>
81 Effect.succeed({ refName: request.refName }),81 Effect.succeed({ refName: request.refName }),
82 ),82 ),
83 listLocalBranchNames: vi.fn<GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"]>(() =>83 listLocalBranchNames: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"]>(() =>
84 Effect.succeed([]),84 Effect.succeed([]),
85 ),85 ),
86 };86 };
87 const git = {87 const git = {
88 ...gitMock,88 ...gitMock,
89 ...input.git,89 ...input.git,
90 } satisfies Partial<GitVcsDriver.GitVcsDriverShape>;90 } satisfies Partial<GitVcsDriver.GitVcsDriver["Service"]>;
9191
92 const driver = {92 const driver = {
93 listRemotes: () =>93 listRemotes: () =>
...@@ -106,7 +106,7 @@ function makeLayer(input: {...@@ -106,7 +106,7 @@ function makeLayer(input: {
106 expiresAt: Option.none(),106 expiresAt: Option.none(),
107 },107 },
108 }),108 }),
109 } satisfies Partial<VcsDriver.VcsDriverShape>;109 } satisfies Partial<VcsDriver.VcsDriver["Service"]>;
110110
111 const layer = BitbucketApi.layer.pipe(111 const layer = BitbucketApi.layer.pipe(
112 Layer.provide(112 Layer.provide(
...@@ -130,7 +130,7 @@ function makeLayer(input: {...@@ -130,7 +130,7 @@ function makeLayer(input: {
130 expiresAt: Option.none(),130 expiresAt: Option.none(),
131 },131 },
132 },132 },
133 driver: driver as unknown as VcsDriver.VcsDriverShape,133 driver: driver as unknown as VcsDriver.VcsDriver["Service"],
134 }),134 }),
135 }),135 }),
136 ),136 ),
apps/server/src/sourceControl/BitbucketApi.ts+64-76
...@@ -15,7 +15,12 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab...@@ -15,7 +15,12 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
15import { sanitizeBranchFragment } from "@t3tools/shared/git";15import { sanitizeBranchFragment } from "@t3tools/shared/git";
16import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";16import { 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";
19import * as SourceControlProvider from "./SourceControlProvider.ts";24import * as SourceControlProvider from "./SourceControlProvider.ts";
20import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";25import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
21import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";26import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
...@@ -44,7 +49,7 @@ export class BitbucketApiError extends Schema.TaggedErrorClass<BitbucketApiError...@@ -44,7 +49,7 @@ export class BitbucketApiError extends Schema.TaggedErrorClass<BitbucketApiError
44 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;49 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
45 }50 }
46}51}
47const isBitbucketApiErrorValue = Schema.is(BitbucketApiError);52const isBitbucketApiError = Schema.is(BitbucketApiError);
4853
49const RawBitbucketRepositorySchema = Schema.Struct({54const RawBitbucketRepositorySchema = Schema.Struct({
50 full_name: TrimmedNonEmptyString,55 full_name: TrimmedNonEmptyString,
...@@ -100,62 +105,55 @@ export interface BitbucketRepositoryLocator {...@@ -100,62 +105,55 @@ export interface BitbucketRepositoryLocator {
100 readonly repoSlug: string;105 readonly repoSlug: string;
101}106}
102107
103export interface BitbucketApiShape {108export class BitbucketApi extends Context.Service<
104 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;109 BitbucketApi,
105 readonly listPullRequests: (input: {110 {
106 readonly cwd: string;111 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
107 readonly context?: SourceControlProvider.SourceControlProviderContext;112 readonly listPullRequests: (input: {
108 readonly headSelector: string;113 readonly cwd: string;
109 readonly source?: SourceControlProvider.SourceControlRefSelector;114 readonly context?: SourceControlProvider.SourceControlProviderContext;
110 readonly state: "open" | "closed" | "merged" | "all";115 readonly headSelector: string;
111 readonly limit?: number;116 readonly source?: SourceControlProvider.SourceControlRefSelector;
112 }) => Effect.Effect<117 readonly state: "open" | "closed" | "merged" | "all";
113 ReadonlyArray<BitbucketPullRequests.NormalizedBitbucketPullRequestRecord>,118 readonly limit?: number;
114 BitbucketApiError119 }) => Effect.Effect<ReadonlyArray<NormalizedBitbucketPullRequestRecord>, BitbucketApiError>;
115 >;120 readonly getPullRequest: (input: {
116 readonly getPullRequest: (input: {121 readonly cwd: string;
117 readonly cwd: string;122 readonly context?: SourceControlProvider.SourceControlProviderContext;
118 readonly context?: SourceControlProvider.SourceControlProviderContext;123 readonly reference: string;
119 readonly reference: string;124 }) => Effect.Effect<NormalizedBitbucketPullRequestRecord, BitbucketApiError>;
120 }) => Effect.Effect<125 readonly getRepositoryCloneUrls: (input: {
121 BitbucketPullRequests.NormalizedBitbucketPullRequestRecord,126 readonly cwd: string;
122 BitbucketApiError127 readonly context?: SourceControlProvider.SourceControlProviderContext;
123 >;128 readonly repository: string;
124 readonly getRepositoryCloneUrls: (input: {129 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
125 readonly cwd: string;130 readonly createRepository: (input: {
126 readonly context?: SourceControlProvider.SourceControlProviderContext;131 readonly cwd: string;
127 readonly repository: string;132 readonly repository: string;
128 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;133 readonly visibility: SourceControlRepositoryVisibility;
129 readonly createRepository: (input: {134 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
130 readonly cwd: string;135 readonly createPullRequest: (input: {
131 readonly repository: string;136 readonly cwd: string;
132 readonly visibility: SourceControlRepositoryVisibility;137 readonly context?: SourceControlProvider.SourceControlProviderContext;
133 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;138 readonly baseBranch: string;
134 readonly createPullRequest: (input: {139 readonly headSelector: string;
135 readonly cwd: string;140 readonly source?: SourceControlProvider.SourceControlRefSelector;
136 readonly context?: SourceControlProvider.SourceControlProviderContext;141 readonly target?: SourceControlProvider.SourceControlRefSelector;
137 readonly baseBranch: string;142 readonly title: string;
138 readonly headSelector: string;143 readonly bodyFile: string;
139 readonly source?: SourceControlProvider.SourceControlRefSelector;144 }) => Effect.Effect<void, BitbucketApiError>;
140 readonly target?: SourceControlProvider.SourceControlRefSelector;145 readonly getDefaultBranch: (input: {
141 readonly title: string;146 readonly cwd: string;
142 readonly bodyFile: string;147 readonly context?: SourceControlProvider.SourceControlProviderContext;
143 }) => Effect.Effect<void, BitbucketApiError>;148 }) => Effect.Effect<string | null, BitbucketApiError>;
144 readonly getDefaultBranch: (input: {149 readonly checkoutPullRequest: (input: {
145 readonly cwd: string;150 readonly cwd: string;
146 readonly context?: SourceControlProvider.SourceControlProviderContext;151 readonly context?: SourceControlProvider.SourceControlProviderContext;
147 }) => Effect.Effect<string | null, BitbucketApiError>;152 readonly reference: string;
148 readonly checkoutPullRequest: (input: {153 readonly force?: boolean;
149 readonly cwd: string;154 }) => Effect.Effect<void, BitbucketApiError>;
150 readonly context?: SourceControlProvider.SourceControlProviderContext;155 }
151 readonly reference: string;156>()("t3/sourceControl/BitbucketApi") {}
152 readonly force?: boolean;
153 }) => Effect.Effect<void, BitbucketApiError>;
154}
155
156export class BitbucketApi extends Context.Service<BitbucketApi, BitbucketApiShape>()(
157 "t3/sourceControl/BitbucketApi",
158) {}
159157
160function nonEmpty(value: string | undefined): Option.Option<string> {158function nonEmpty(value: string | undefined): Option.Option<string> {
161 const trimmed = value?.trim();159 const trimmed = value?.trim();
...@@ -299,9 +297,7 @@ function checkoutBranchName(input: {...@@ -299,9 +297,7 @@ function checkoutBranchName(input: {
299}297}
300298
301function repositoryNameWithOwner(299function repositoryNameWithOwner(
302 repository: Schema.Schema.Type<300 repository: Schema.Schema.Type<typeof BitbucketPullRequestSchema>["source"]["repository"],
303 typeof BitbucketPullRequests.BitbucketPullRequestSchema
304 >["source"]["repository"],
305): string | null {301): string | null {
306 const fullName = repository?.full_name?.trim() ?? "";302 const fullName = repository?.full_name?.trim() ?? "";
307 return fullName.length > 0 ? fullName : null;303 return fullName.length > 0 ? fullName : null;
...@@ -350,10 +346,6 @@ function requestError(operation: string, cause: unknown): BitbucketApiError {...@@ -350,10 +346,6 @@ function requestError(operation: string, cause: unknown): BitbucketApiError {
350 });346 });
351}347}
352348
353function isBitbucketApiError(cause: unknown): cause is BitbucketApiError {
354 return isBitbucketApiErrorValue(cause);
355}
356
357function responseError(349function responseError(
358 operation: string,350 operation: string,
359 response: HttpClientResponse.HttpClientResponse,351 response: HttpClientResponse.HttpClientResponse,
...@@ -375,7 +367,7 @@ function responseError(...@@ -375,7 +367,7 @@ function responseError(
375 );367 );
376}368}
377369
378export const make = Effect.fn("makeBitbucketApi")(function* () {370export const make = Effect.gen(function* () {
379 const config = yield* BitbucketApiEnvConfig;371 const config = yield* BitbucketApiEnvConfig;
380 const httpClient = yield* HttpClient.HttpClient;372 const httpClient = yield* HttpClient.HttpClient;
381 const fileSystem = yield* FileSystem.FileSystem;373 const fileSystem = yield* FileSystem.FileSystem;
...@@ -511,7 +503,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {...@@ -511,7 +503,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {
511 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`,503 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`,
512 ),504 ),
513 ),505 ),
514 BitbucketPullRequests.BitbucketPullRequestSchema,506 BitbucketPullRequestSchema,
515 );507 );
516508
517 const getRawPullRequest = (input: {509 const getRawPullRequest = (input: {
...@@ -599,17 +591,13 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {...@@ -599,17 +591,13 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {
599 ),591 ),
600 { urlParams: query },592 { urlParams: query },
601 ),593 ),
602 BitbucketPullRequests.BitbucketPullRequestListSchema,594 BitbucketPullRequestListSchema,
603 );595 );
604 }),596 }),
605 Effect.map((list) =>597 Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)),
606 list.values.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord),
607 ),
608 ),598 ),
609 getPullRequest: (input) =>599 getPullRequest: (input) =>
610 getRawPullRequest(input).pipe(600 getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)),
611 Effect.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord),
612 ),
613 getRepositoryCloneUrls: (input) =>601 getRepositoryCloneUrls: (input) =>
614 getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)),602 getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)),
615 createRepository: (input) =>603 createRepository: (input) =>
...@@ -675,7 +663,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {...@@ -675,7 +663,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {
675 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,663 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
676 ),664 ),
677 ).pipe(HttpClientRequest.bodyJsonUnsafe(body)),665 ).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
678 BitbucketPullRequests.BitbucketPullRequestSchema,666 BitbucketPullRequestSchema,
679 );667 );
680 }),668 }),
681 getDefaultBranch: (input) =>669 getDefaultBranch: (input) =>
...@@ -766,4 +754,4 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {...@@ -766,4 +754,4 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {
766 });754 });
767});755});
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";...@@ -6,8 +6,8 @@ import * as Option from "effect/Option";
6import * as BitbucketApi from "./BitbucketApi.ts";6import * as BitbucketApi from "./BitbucketApi.ts";
7import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";7import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
88
9function makeProvider(bitbucket: Partial<BitbucketApi.BitbucketApiShape>) {9function makeProvider(bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>) {
10 return BitbucketSourceControlProvider.make().pipe(10 return BitbucketSourceControlProvider.make.pipe(
11 Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)),11 Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)),
12 );12 );
13}13}
...@@ -53,7 +53,8 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", (...@@ -53,7 +53,8 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", (
5353
54it.effect("lists Bitbucket PRs through provider-neutral input names", () =>54it.effect("lists Bitbucket PRs through provider-neutral input names", () =>
55 Effect.gen(function* () {55 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;
57 const provider = yield* makeProvider({58 const provider = yield* makeProvider({
58 listPullRequests: (input) => {59 listPullRequests: (input) => {
59 listInput = input;60 listInput = input;
...@@ -79,8 +80,9 @@ it.effect("lists Bitbucket PRs through provider-neutral input names", () =>...@@ -79,8 +80,9 @@ it.effect("lists Bitbucket PRs through provider-neutral input names", () =>
7980
80it.effect("creates Bitbucket PRs through provider-neutral input names", () =>81it.effect("creates Bitbucket PRs through provider-neutral input names", () =>
81 Effect.gen(function* () {82 Effect.gen(function* () {
82 let createInput: Parameters<BitbucketApi.BitbucketApiShape["createPullRequest"]>[0] | null =83 let createInput:
83 null;84 | Parameters<BitbucketApi.BitbucketApi["Service"]["createPullRequest"]>[0]
85 | null = null;
84 const provider = yield* makeProvider({86 const provider = yield* makeProvider({
85 createPullRequest: (input) => {87 createPullRequest: (input) => {
86 createInput = input;88 createInput = input;
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts+7-9
...@@ -4,9 +4,9 @@ import * as Option from "effect/Option";...@@ -4,9 +4,9 @@ import * as Option from "effect/Option";
4import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";4import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
55
6import * as BitbucketApi from "./BitbucketApi.ts";6import * as BitbucketApi from "./BitbucketApi.ts";
7import * as BitbucketPullRequests from "./bitbucketPullRequests.ts";7import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts";
8import * as SourceControlProvider from "./SourceControlProvider.ts";8import * as SourceControlProvider from "./SourceControlProvider.ts";
9import type * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";9import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts";
1010
11function providerError(11function providerError(
12 operation: string,12 operation: string,
...@@ -20,9 +20,7 @@ function providerError(...@@ -20,9 +20,7 @@ function providerError(
20 });20 });
21}21}
2222
23function toChangeRequest(23function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest {
24 summary: BitbucketPullRequests.NormalizedBitbucketPullRequestRecord,
25): ChangeRequest {
26 return {24 return {
27 provider: "bitbucket",25 provider: "bitbucket",
28 number: summary.number,26 number: summary.number,
...@@ -44,7 +42,7 @@ function toChangeRequest(...@@ -44,7 +42,7 @@ function toChangeRequest(
44 };42 };
45}43}
4644
47export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () {45export const make = Effect.gen(function* () {
48 const bitbucket = yield* BitbucketApi.BitbucketApi;46 const bitbucket = yield* BitbucketApi.BitbucketApi;
4947
50 return SourceControlProvider.SourceControlProvider.of({48 return SourceControlProvider.SourceControlProvider.of({
...@@ -112,9 +110,9 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* ()...@@ -112,9 +110,9 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* ()
112 });110 });
113});111});
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* () {
118 const bitbucket = yield* BitbucketApi.BitbucketApi;116 const bitbucket = yield* BitbucketApi.BitbucketApi;
119117
120 return {118 return {
...@@ -124,5 +122,5 @@ export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscov...@@ -124,5 +122,5 @@ export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscov
124 installHint:122 installHint:
125 "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request and repository scopes).",123 "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request and repository scopes).",
126 probeAuth: bitbucket.probeAuth,124 probeAuth: bitbucket.probeAuth,
127 } satisfies SourceControlProviderDiscovery.SourceControlApiDiscoverySpec;125 } satisfies SourceControlApiDiscoverySpec;
128});126});
apps/server/src/sourceControl/GitHubCli.test.ts+1-1
...@@ -15,7 +15,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({...@@ -15,7 +15,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
15 stderrTruncated: false,15 stderrTruncated: false,
16});16});
1717
18const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();18const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>();
1919
20const layer = GitHubCli.layer.pipe(20const layer = GitHubCli.layer.pipe(
21 Layer.provide(21 Layer.provide(
apps/server/src/sourceControl/GitHubCli.ts+62-59
...@@ -12,7 +12,11 @@ import {...@@ -12,7 +12,11 @@ import {
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
1313
14import * as VcsProcess from "../vcs/VcsProcess.ts";14import * as VcsProcess from "../vcs/VcsProcess.ts";
15import * as GitHubPullRequests from "./gitHubPullRequests.ts";15import {
16 decodeGitHubPullRequestJson,
17 decodeGitHubPullRequestListJson,
18 formatGitHubJsonDecodeError,
19} from "./gitHubPullRequests.ts";
1620
17const DEFAULT_TIMEOUT_MS = 30_000;21const DEFAULT_TIMEOUT_MS = 30_000;
1822
...@@ -44,57 +48,56 @@ export interface GitHubRepositoryCloneUrls {...@@ -44,57 +48,56 @@ export interface GitHubRepositoryCloneUrls {
44 readonly sshUrl: string;48 readonly sshUrl: string;
45}49}
4650
47export interface GitHubCliShape {51export class GitHubCli extends Context.Service<
48 readonly execute: (input: {52 GitHubCli,
49 readonly cwd: string;53 {
50 readonly args: ReadonlyArray<string>;54 readonly execute: (input: {
51 readonly timeoutMs?: number;55 readonly cwd: string;
52 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>;56 readonly args: ReadonlyArray<string>;
5357 readonly timeoutMs?: number;
54 readonly listOpenPullRequests: (input: {58 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>;
55 readonly cwd: string;59
56 readonly headSelector: string;60 readonly listOpenPullRequests: (input: {
57 readonly limit?: number;61 readonly cwd: string;
58 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;62 readonly headSelector: string;
5963 readonly limit?: number;
60 readonly getPullRequest: (input: {64 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;
61 readonly cwd: string;65
62 readonly reference: string;66 readonly getPullRequest: (input: {
63 }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>;67 readonly cwd: string;
6468 readonly reference: string;
65 readonly getRepositoryCloneUrls: (input: {69 }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>;
66 readonly cwd: string;70
67 readonly repository: string;71 readonly getRepositoryCloneUrls: (input: {
68 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;72 readonly cwd: string;
6973 readonly repository: string;
70 readonly createRepository: (input: {74 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
71 readonly cwd: string;75
72 readonly repository: string;76 readonly createRepository: (input: {
73 readonly visibility: SourceControlRepositoryVisibility;77 readonly cwd: string;
74 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;78 readonly repository: string;
7579 readonly visibility: SourceControlRepositoryVisibility;
76 readonly createPullRequest: (input: {80 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
77 readonly cwd: string;81
78 readonly baseBranch: string;82 readonly createPullRequest: (input: {
79 readonly headSelector: string;83 readonly cwd: string;
80 readonly title: string;84 readonly baseBranch: string;
81 readonly bodyFile: string;85 readonly headSelector: string;
82 }) => Effect.Effect<void, GitHubCliError>;86 readonly title: string;
8387 readonly bodyFile: string;
84 readonly getDefaultBranch: (input: {88 }) => Effect.Effect<void, GitHubCliError>;
85 readonly cwd: string;89
86 }) => Effect.Effect<string | null, GitHubCliError>;90 readonly getDefaultBranch: (input: {
8791 readonly cwd: string;
88 readonly checkoutPullRequest: (input: {92 }) => Effect.Effect<string | null, GitHubCliError>;
89 readonly cwd: string;93
90 readonly reference: string;94 readonly checkoutPullRequest: (input: {
91 readonly force?: boolean;95 readonly cwd: string;
92 }) => Effect.Effect<void, GitHubCliError>;96 readonly reference: string;
93}97 readonly force?: boolean;
9498 }) => Effect.Effect<void, GitHubCliError>;
95export class GitHubCli extends Context.Service<GitHubCli, GitHubCliShape>()(99 }
96 "t3/sourceControl/GitHubCli",100>()("t3/sourceControl/GitHubCli") {}
97) {}
98101
99function errorText(error: VcsError | unknown): string {102function errorText(error: VcsError | unknown): string {
100 if (typeof error === "object" && error !== null) {103 if (typeof error === "object" && error !== null) {
...@@ -226,10 +229,10 @@ function decodeGitHubJson<S extends Schema.Top>(...@@ -226,10 +229,10 @@ function decodeGitHubJson<S extends Schema.Top>(
226 );229 );
227}230}
228231
229export const make = Effect.fn("makeGitHubCli")(function* () {232export const make = Effect.gen(function* () {
230 const process = yield* VcsProcess.VcsProcess;233 const process = yield* VcsProcess.VcsProcess;
231234
232 const execute: GitHubCliShape["execute"] = (input) =>235 const execute: GitHubCli["Service"]["execute"] = (input) =>
233 process236 process
234 .run({237 .run({
235 operation: "GitHubCli.execute",238 operation: "GitHubCli.execute",
...@@ -262,13 +265,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () {...@@ -262,13 +265,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () {
262 Effect.flatMap((raw) =>265 Effect.flatMap((raw) =>
263 raw.length === 0266 raw.length === 0
264 ? Effect.succeed([])267 ? Effect.succeed([])
265 : Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe(268 : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
266 Effect.flatMap((decoded) => {269 Effect.flatMap((decoded) => {
267 if (!Result.isSuccess(decoded)) {270 if (!Result.isSuccess(decoded)) {
268 return Effect.fail(271 return Effect.fail(
269 new GitHubCliError({272 new GitHubCliError({
270 operation: "listOpenPullRequests",273 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)}`,
272 cause: decoded.failure,275 cause: decoded.failure,
273 }),276 }),
274 );277 );
...@@ -294,13 +297,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () {...@@ -294,13 +297,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () {
294 }).pipe(297 }).pipe(
295 Effect.map((result) => result.stdout.trim()),298 Effect.map((result) => result.stdout.trim()),
296 Effect.flatMap((raw) =>299 Effect.flatMap((raw) =>
297 Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestJson(raw)).pipe(300 Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe(
298 Effect.flatMap((decoded) => {301 Effect.flatMap((decoded) => {
299 if (!Result.isSuccess(decoded)) {302 if (!Result.isSuccess(decoded)) {
300 return Effect.fail(303 return Effect.fail(
301 new GitHubCliError({304 new GitHubCliError({
302 operation: "getPullRequest",305 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)}`,
304 cause: decoded.failure,307 cause: decoded.failure,
305 }),308 }),
306 );309 );
...@@ -372,4 +375,4 @@ export const make = Effect.fn("makeGitHubCli")(function* () {...@@ -372,4 +375,4 @@ export const make = Effect.fn("makeGitHubCli")(function* () {
372 });375 });
373});376});
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 = (...@@ -24,8 +24,8 @@ const processResult = (
24 stderrTruncated: false,24 stderrTruncated: false,
25});25});
2626
27function makeProvider(github: Partial<GitHubCli.GitHubCliShape>) {27function makeProvider(github: Partial<GitHubCli.GitHubCli["Service"]>) {
28 return GitHubSourceControlProvider.make().pipe(28 return GitHubSourceControlProvider.make.pipe(
29 Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)),29 Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)),
30 );30 );
31}31}
...@@ -139,7 +139,8 @@ it.effect("treats empty non-open change request listing output as no results", (...@@ -139,7 +139,8 @@ it.effect("treats empty non-open change request listing output as no results", (
139139
140it.effect("creates GitHub PRs through provider-neutral input names", () =>140it.effect("creates GitHub PRs through provider-neutral input names", () =>
141 Effect.gen(function* () {141 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;
143 const provider = yield* makeProvider({144 const provider = yield* makeProvider({
144 createPullRequest: (input) => {145 createPullRequest: (input) => {
145 createInput = input;146 createInput = input;
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+21-19
...@@ -11,9 +11,15 @@ import {...@@ -11,9 +11,15 @@ import {
1111
12import * as GitHubCli from "./GitHubCli.ts";12import * as GitHubCli from "./GitHubCli.ts";
13import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";13import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
14import * as GitHubPullRequests from "./gitHubPullRequests.ts";14import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts";
15import * as SourceControlProvider from "./SourceControlProvider.ts";15import * 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";
17const isSourceControlProviderError = Schema.is(SourceControlProviderError);23const isSourceControlProviderError = Schema.is(SourceControlProviderError);
1824
19function providerError(25function providerError(
...@@ -50,14 +56,14 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq...@@ -50,14 +56,14 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
50 };56 };
51}57}
5258
53function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {59function parseGitHubAuth(input: SourceControlAuthProbeInput) {
54 const output = SourceControlProviderDiscovery.combinedAuthOutput(input);60 const output = combinedAuthOutput(input);
55 const authStatus = parseGitHubAuthStatus(input.stdout);61 const authStatus = parseGitHubAuthStatus(input.stdout);
56 const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts);62 const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts);
57 const host = authenticatedAccount?.host;63 const host = authenticatedAccount?.host;
5864
59 if (authenticatedAccount) {65 if (authenticatedAccount) {
60 return SourceControlProviderDiscovery.providerAuth({66 return providerAuth({
61 status: "authenticated",67 status: "authenticated",
62 account: authenticatedAccount.account,68 account: authenticatedAccount.account,
63 host,69 host,
...@@ -66,7 +72,7 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth...@@ -66,7 +72,7 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth
6672
67 const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0];73 const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0];
68 if (authStatus.parsed) {74 if (authStatus.parsed) {
69 return SourceControlProviderDiscovery.providerAuth({75 return providerAuth({
70 status: "unauthenticated",76 status: "unauthenticated",
71 host: failedAccount?.host,77 host: failedAccount?.host,
72 detail:78 detail:
...@@ -76,21 +82,17 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth...@@ -76,21 +82,17 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth
76 }82 }
7783
78 if (input.exitCode !== 0) {84 if (input.exitCode !== 0) {
79 return SourceControlProviderDiscovery.providerAuth({85 return providerAuth({
80 status: "unauthenticated",86 status: "unauthenticated",
81 host,87 host,
82 detail:88 detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.",
83 SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
84 "Run `gh auth login` to authenticate GitHub CLI.",
85 });89 });
86 }90 }
8791
88 return SourceControlProviderDiscovery.providerAuth({92 return providerAuth({
89 status: "unknown",93 status: "unknown",
90 host,94 host,
91 detail:95 detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.",
92 SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
93 "GitHub CLI auth status could not be parsed.",
94 });96 });
95}97}
9698
...@@ -104,12 +106,12 @@ export const discovery = {...@@ -104,12 +106,12 @@ export const discovery = {
104 parseAuth: parseGitHubAuth,106 parseAuth: parseGitHubAuth,
105 installHint:107 installHint:
106 "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).",108 "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* () {
110 const github = yield* GitHubCli.GitHubCli;112 const github = yield* GitHubCli.GitHubCli;
111113
112 const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] =114 const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] =
113 (input) => {115 (input) => {
114 if (input.state === "open") {116 if (input.state === "open") {
115 return github117 return github
...@@ -147,7 +149,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {...@@ -147,7 +149,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {
147 if (raw.length === 0) {149 if (raw.length === 0) {
148 return Effect.succeed([]);150 return Effect.succeed([]);
149 }151 }
150 return Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe(152 return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
151 Effect.flatMap((decoded) =>153 Effect.flatMap((decoded) =>
152 Result.isSuccess(decoded)154 Result.isSuccess(decoded)
153 ? Effect.succeed(155 ? Effect.succeed(
...@@ -212,4 +214,4 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {...@@ -212,4 +214,4 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {
212 });214 });
213});215});
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";...@@ -8,7 +8,7 @@ import { VcsProcessExitError } from "@t3tools/contracts";
8import * as VcsProcess from "../vcs/VcsProcess.ts";8import * as VcsProcess from "../vcs/VcsProcess.ts";
9import * as GitLabCli from "./GitLabCli.ts";9import * as GitLabCli from "./GitLabCli.ts";
1010
11const mockedRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();11const mockedRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>();
12const layer = it.layer(12const layer = it.layer(
13 GitLabCli.layer.pipe(13 GitLabCli.layer.pipe(
14 Layer.provide(14 Layer.provide(
apps/server/src/sourceControl/GitLabCli.ts+66-63
...@@ -10,7 +10,11 @@ import type * as DateTime from "effect/DateTime";...@@ -10,7 +10,11 @@ import type * as DateTime from "effect/DateTime";
10import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";10import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
1111
12import * as VcsProcess from "../vcs/VcsProcess.ts";12import * as VcsProcess from "../vcs/VcsProcess.ts";
13import * as GitLabMergeRequests from "./gitLabMergeRequests.ts";13import {
14 decodeGitLabMergeRequestJson,
15 decodeGitLabMergeRequestListJson,
16 formatGitLabJsonDecodeError,
17} from "./gitLabMergeRequests.ts";
14import type * as SourceControlProvider from "./SourceControlProvider.ts";18import type * as SourceControlProvider from "./SourceControlProvider.ts";
1519
16const DEFAULT_TIMEOUT_MS = 30_000;20const DEFAULT_TIMEOUT_MS = 30_000;
...@@ -44,61 +48,60 @@ export interface GitLabRepositoryCloneUrls {...@@ -44,61 +48,60 @@ export interface GitLabRepositoryCloneUrls {
44 readonly sshUrl: string;48 readonly sshUrl: string;
45}49}
4650
47export interface GitLabCliShape {51export class GitLabCli extends Context.Service<
48 readonly execute: (input: {52 GitLabCli,
49 readonly cwd: string;53 {
50 readonly args: ReadonlyArray<string>;54 readonly execute: (input: {
51 readonly timeoutMs?: number;55 readonly cwd: string;
52 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitLabCliError>;56 readonly args: ReadonlyArray<string>;
5357 readonly timeoutMs?: number;
54 readonly listMergeRequests: (input: {58 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitLabCliError>;
55 readonly cwd: string;59
56 readonly headSelector: string;60 readonly listMergeRequests: (input: {
57 readonly source?: SourceControlProvider.SourceControlRefSelector;61 readonly cwd: string;
58 readonly state: "open" | "closed" | "merged" | "all";62 readonly headSelector: string;
59 readonly limit?: number;63 readonly source?: SourceControlProvider.SourceControlRefSelector;
60 }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>;64 readonly state: "open" | "closed" | "merged" | "all";
6165 readonly limit?: number;
62 readonly getMergeRequest: (input: {66 }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>;
63 readonly cwd: string;67
64 readonly reference: string;68 readonly getMergeRequest: (input: {
65 }) => Effect.Effect<GitLabMergeRequestSummary, GitLabCliError>;69 readonly cwd: string;
6670 readonly reference: string;
67 readonly getRepositoryCloneUrls: (input: {71 }) => Effect.Effect<GitLabMergeRequestSummary, GitLabCliError>;
68 readonly cwd: string;72
69 readonly repository: string;73 readonly getRepositoryCloneUrls: (input: {
70 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;74 readonly cwd: string;
7175 readonly repository: string;
72 readonly createRepository: (input: {76 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
73 readonly cwd: string;77
74 readonly repository: string;78 readonly createRepository: (input: {
75 readonly visibility: SourceControlRepositoryVisibility;79 readonly cwd: string;
76 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;80 readonly repository: string;
7781 readonly visibility: SourceControlRepositoryVisibility;
78 readonly createMergeRequest: (input: {82 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
79 readonly cwd: string;83
80 readonly baseBranch: string;84 readonly createMergeRequest: (input: {
81 readonly headSelector: string;85 readonly cwd: string;
82 readonly source?: SourceControlProvider.SourceControlRefSelector;86 readonly baseBranch: string;
83 readonly target?: SourceControlProvider.SourceControlRefSelector;87 readonly headSelector: string;
84 readonly title: string;88 readonly source?: SourceControlProvider.SourceControlRefSelector;
85 readonly bodyFile: string;89 readonly target?: SourceControlProvider.SourceControlRefSelector;
86 }) => Effect.Effect<void, GitLabCliError>;90 readonly title: string;
8791 readonly bodyFile: string;
88 readonly getDefaultBranch: (input: {92 }) => Effect.Effect<void, GitLabCliError>;
89 readonly cwd: string;93
90 }) => Effect.Effect<string | null, GitLabCliError>;94 readonly getDefaultBranch: (input: {
9195 readonly cwd: string;
92 readonly checkoutMergeRequest: (input: {96 }) => Effect.Effect<string | null, GitLabCliError>;
93 readonly cwd: string;97
94 readonly reference: string;98 readonly checkoutMergeRequest: (input: {
95 readonly force?: boolean;99 readonly cwd: string;
96 }) => Effect.Effect<void, GitLabCliError>;100 readonly reference: string;
97}101 readonly force?: boolean;
98102 }) => Effect.Effect<void, GitLabCliError>;
99export class GitLabCli extends Context.Service<GitLabCli, GitLabCliShape>()(103 }
100 "t3/sourceControl/GitLabCli",104>()("t3/sourceControl/GitLabCli") {}
101) {}
102105
103function isVcsProcessSpawnError(error: unknown): boolean {106function isVcsProcessSpawnError(error: unknown): boolean {
104 return (107 return (
...@@ -259,10 +262,10 @@ function parseRepositoryPath(repository: string): {...@@ -259,10 +262,10 @@ function parseRepositoryPath(repository: string): {
259 return { namespacePath, projectPath };262 return { namespacePath, projectPath };
260}263}
261264
262export const make = Effect.fn("makeGitLabCli")(function* () {265export const make = Effect.gen(function* () {
263 const process = yield* VcsProcess.VcsProcess;266 const process = yield* VcsProcess.VcsProcess;
264267
265 const execute: GitLabCliShape["execute"] = (input) =>268 const execute: GitLabCli["Service"]["execute"] = (input) =>
266 process269 process
267 .run({270 .run({
268 operation: "GitLabCli.execute",271 operation: "GitLabCli.execute",
...@@ -294,13 +297,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () {...@@ -294,13 +297,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () {
294 Effect.flatMap((raw) =>297 Effect.flatMap((raw) =>
295 raw.length === 0298 raw.length === 0
296 ? Effect.succeed([])299 ? Effect.succeed([])
297 : Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestListJson(raw)).pipe(300 : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe(
298 Effect.flatMap((decoded) => {301 Effect.flatMap((decoded) => {
299 if (!Result.isSuccess(decoded)) {302 if (!Result.isSuccess(decoded)) {
300 return Effect.fail(303 return Effect.fail(
301 new GitLabCliError({304 new GitLabCliError({
302 operation: "listMergeRequests",305 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)}`,
304 cause: decoded.failure,307 cause: decoded.failure,
305 }),308 }),
306 );309 );
...@@ -318,13 +321,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () {...@@ -318,13 +321,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () {
318 }).pipe(321 }).pipe(
319 Effect.map((result) => result.stdout.trim()),322 Effect.map((result) => result.stdout.trim()),
320 Effect.flatMap((raw) =>323 Effect.flatMap((raw) =>
321 Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestJson(raw)).pipe(324 Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe(
322 Effect.flatMap((decoded) => {325 Effect.flatMap((decoded) => {
323 if (!Result.isSuccess(decoded)) {326 if (!Result.isSuccess(decoded)) {
324 return Effect.fail(327 return Effect.fail(
325 new GitLabCliError({328 new GitLabCliError({
326 operation: "getMergeRequest",329 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)}`,
328 cause: decoded.failure,331 cause: decoded.failure,
329 }),332 }),
330 );333 );
...@@ -449,4 +452,4 @@ export const make = Effect.fn("makeGitLabCli")(function* () {...@@ -449,4 +452,4 @@ export const make = Effect.fn("makeGitLabCli")(function* () {
449 });452 });
450});453});
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";...@@ -8,8 +8,8 @@ import * as GitLabCli from "./GitLabCli.ts";
8import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";8import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
9import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";9import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
1010
11function makeProvider(gitlab: Partial<GitLabCli.GitLabCliShape>) {11function makeProvider(gitlab: Partial<GitLabCli.GitLabCli["Service"]>) {
12 return GitLabSourceControlProvider.make().pipe(12 return GitLabSourceControlProvider.make.pipe(
13 Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)),13 Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)),
14 );14 );
15}15}
...@@ -54,7 +54,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () =...@@ -54,7 +54,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () =
5454
55it.effect("lists GitLab MRs through provider-neutral input names", () =>55it.effect("lists GitLab MRs through provider-neutral input names", () =>
56 Effect.gen(function* () {56 Effect.gen(function* () {
57 let listInput: Parameters<GitLabCli.GitLabCliShape["listMergeRequests"]>[0] | null = null;57 let listInput: Parameters<GitLabCli.GitLabCli["Service"]["listMergeRequests"]>[0] | null = null;
58 const provider = yield* makeProvider({58 const provider = yield* makeProvider({
59 listMergeRequests: (input) => {59 listMergeRequests: (input) => {
60 listInput = input;60 listInput = input;
...@@ -80,7 +80,8 @@ it.effect("lists GitLab MRs through provider-neutral input names", () =>...@@ -80,7 +80,8 @@ it.effect("lists GitLab MRs through provider-neutral input names", () =>
8080
81it.effect("creates GitLab MRs through provider-neutral input names", () =>81it.effect("creates GitLab MRs through provider-neutral input names", () =>
82 Effect.gen(function* () {82 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;
84 const provider = yield* makeProvider({85 const provider = yield* makeProvider({
85 createMergeRequest: (input) => {86 createMergeRequest: (input) => {
86 createInput = input;87 createInput = input;
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+26-23
...@@ -5,7 +5,16 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac...@@ -5,7 +5,16 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac
55
6import * as GitLabCli from "./GitLabCli.ts";6import * as GitLabCli from "./GitLabCli.ts";
7import * as SourceControlProvider from "./SourceControlProvider.ts";7import * 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";
9import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";18import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
1019
11function providerError(20function providerError(
...@@ -42,48 +51,42 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe...@@ -42,48 +51,42 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe
42 };51 };
43}52}
4453
45function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {54function parseGitLabAuth(input: SourceControlAuthProbeInput) {
46 const output = SourceControlProviderDiscovery.combinedAuthOutput(input);55 const output = combinedAuthOutput(input);
47 const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output));56 const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output));
48 const account =57 const account =
49 authenticatedHost?.account ??58 authenticatedHost?.account ??
50 SourceControlProviderDiscovery.matchFirst(output, [59 matchFirst(output, [
51 /Logged in to .* as\s+([^\s(]+)/iu,60 /Logged in to .* as\s+([^\s(]+)/iu,
52 /Logged in to .* account\s+([^\s(]+)/iu,61 /Logged in to .* account\s+([^\s(]+)/iu,
53 /account:\s*([^\s(]+)/iu,62 /account:\s*([^\s(]+)/iu,
54 ]);63 ]);
55 const host = authenticatedHost?.host ?? SourceControlProviderDiscovery.parseCliHost(output);64 const host = authenticatedHost?.host ?? parseCliHost(output);
5665
57 if (account) {66 if (account) {
58 return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host });67 return providerAuth({ status: "authenticated", account, host });
59 }68 }
6069
61 if (input.exitCode !== 0) {70 if (input.exitCode !== 0) {
62 return SourceControlProviderDiscovery.providerAuth({71 return providerAuth({
63 status: "unauthenticated",72 status: "unauthenticated",
64 host,73 host,
65 detail:74 detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.",
66 SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
67 "Run `glab auth login` to authenticate GitLab CLI.",
68 });75 });
69 }76 }
7077
71 return SourceControlProviderDiscovery.providerAuth({78 return providerAuth({
72 status: "unknown",79 status: "unknown",
73 host,80 host,
74 detail:81 detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.",
75 SourceControlProviderDiscovery.firstSafeAuthLine(output) ??
76 "GitLab CLI auth status could not be parsed.",
77 });82 });
78}83}
7984
80function refineUnknownGitLabRemote(85function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementInput) {
81 input: SourceControlProviderDiscovery.SourceControlUnknownRemoteRefinementInput,
82) {
83 const host = input.context.provider.name.toLowerCase();86 const host = input.context.provider.name.toLowerCase();
84 const authenticated = parseGitLabAuthStatusHosts(87 const authenticated = parseGitLabAuthStatusHosts(combinedAuthOutput(input.auth)).some(
85 SourceControlProviderDiscovery.combinedAuthOutput(input.auth),88 (entry) => entry.account !== null && entry.host === host,
86 ).some((entry) => entry.account !== null && entry.host === host);89 );
8790
88 if (!authenticated) {91 if (!authenticated) {
89 return null;92 return null;
...@@ -107,9 +110,9 @@ export const discovery = {...@@ -107,9 +110,9 @@ export const discovery = {
107 refineUnknownRemote: refineUnknownGitLabRemote,110 refineUnknownRemote: refineUnknownGitLabRemote,
108 installHint:111 installHint:
109 "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).",112 "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* () {
113 const gitlab = yield* GitLabCli.GitLabCli;116 const gitlab = yield* GitLabCli.GitLabCli;
114117
115 return SourceControlProvider.SourceControlProvider.of({118 return SourceControlProvider.SourceControlProvider.of({
...@@ -167,4 +170,4 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () {...@@ -167,4 +170,4 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () {
167 });170 });
168});171});
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";...@@ -6,7 +6,7 @@ import * as Option from "effect/Option";
6import { ChildProcessSpawner } from "effect/unstable/process";6import { ChildProcessSpawner } from "effect/unstable/process";
7import { VcsProcessSpawnError } from "@t3tools/contracts";7import { VcsProcessSpawnError } from "@t3tools/contracts";
88
9import { ServerConfig } from "../config.ts";9import * as ServerConfig from "../config.ts";
10import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";10import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
11import * as VcsProcess from "../vcs/VcsProcess.ts";11import * as VcsProcess from "../vcs/VcsProcess.ts";
12import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";12import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
...@@ -17,15 +17,15 @@ import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";...@@ -17,15 +17,15 @@ import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
17import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";17import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
1818
19const sourceControlProviderRegistryTestLayer = (input: {19const sourceControlProviderRegistryTestLayer = (input: {
20 readonly bitbucket: Partial<BitbucketApi.BitbucketApiShape>;20 readonly bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>;
21 readonly process: Partial<VcsProcess.VcsProcessShape>;21 readonly process: Partial<VcsProcess.VcsProcess["Service"]>;
22}) =>22}) =>
23 SourceControlProviderRegistry.layer.pipe(23 SourceControlProviderRegistry.layer.pipe(
24 Layer.provide(24 Layer.provide(
25 Layer.mergeAll(25 Layer.mergeAll(
26 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe(26 ServerConfig.layerTest(process.cwd(), {
27 Layer.provide(NodeServices.layer),27 prefix: "t3-source-control-registry-test-",
28 ),28 }).pipe(Layer.provide(NodeServices.layer)),
29 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),29 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
30 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),30 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
31 Layer.mock(GitHubCli.GitHubCli)({}),31 Layer.mock(GitHubCli.GitHubCli)({}),
...@@ -88,10 +88,12 @@ it.effect("reports implemented tools separately from locally available executabl...@@ -88,10 +88,12 @@ it.effect("reports implemented tools separately from locally available executabl
88 }),88 }),
89 );89 );
90 },90 },
91 } satisfies Partial<VcsProcess.VcsProcessShape>;91 } satisfies Partial<VcsProcess.VcsProcess["Service"]>;
92 const testLayer = SourceControlDiscovery.layer.pipe(92 const testLayer = SourceControlDiscovery.layer.pipe(
93 Layer.provide(93 Layer.provide(
94 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }),94 ServerConfig.layerTest(process.cwd(), {
95 prefix: "t3-source-control-discovery-",
96 }),
95 ),97 ),
96 Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)),98 Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)),
97 Layer.provide(99 Layer.provide(
...@@ -215,10 +217,12 @@ Logged in to gitlab.com as gitlab-user...@@ -215,10 +217,12 @@ Logged in to gitlab.com as gitlab-user
215 }),217 }),
216 );218 );
217 },219 },
218 } satisfies Partial<VcsProcess.VcsProcessShape>;220 } satisfies Partial<VcsProcess.VcsProcess["Service"]>;
219 const testLayer = SourceControlDiscovery.layer.pipe(221 const testLayer = SourceControlDiscovery.layer.pipe(
220 Layer.provide(222 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 }),
222 ),226 ),
223 Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)),227 Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)),
224 Layer.provide(228 Layer.provide(
apps/server/src/sourceControl/SourceControlDiscovery.ts+71-76
...@@ -10,7 +10,7 @@ import * as Option from "effect/Option";...@@ -10,7 +10,7 @@ import * as Option from "effect/Option";
1010
11import { ServerConfig } from "../config.ts";11import { ServerConfig } from "../config.ts";
12import * as VcsProcess from "../vcs/VcsProcess.ts";12import * as VcsProcess from "../vcs/VcsProcess.ts";
13import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";13import { detailFromCause, firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts";
14import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";14import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
1515
16interface DiscoveryProbe {16interface DiscoveryProbe {
...@@ -57,91 +57,86 @@ const VCS_PROBES: ReadonlyArray<VcsProbe> = [...@@ -57,91 +57,86 @@ const VCS_PROBES: ReadonlyArray<VcsProbe> = [
57 },57 },
58];58];
5959
60export interface SourceControlDiscoveryShape {
61 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
62}
63
64export class SourceControlDiscovery extends Context.Service<60export class SourceControlDiscovery extends Context.Service<
65 SourceControlDiscovery,61 SourceControlDiscovery,
66 SourceControlDiscoveryShape62 {
63 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
64 }
67>()("t3/sourceControl/SourceControlDiscovery") {}65>()("t3/sourceControl/SourceControlDiscovery") {}
6866
69export const layer = Layer.effect(67export const make = Effect.gen(function* () {
70 SourceControlDiscovery,68 const config = yield* ServerConfig;
71 Effect.gen(function* () {69 const process = yield* VcsProcess.VcsProcess;
72 const config = yield* ServerConfig;70 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
73 const process = yield* VcsProcess.VcsProcess;
74 const sourceControlProviders =
75 yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
7671
77 const probe = <Kind extends VcsDriverKind>(72 const probe = <Kind extends VcsDriverKind>(
78 input: DiscoveryProbe & { readonly kind: Kind },73 input: DiscoveryProbe & { readonly kind: Kind },
79 ): Effect.Effect<DiscoveryProbeResult<Kind>> => {74 ): Effect.Effect<DiscoveryProbeResult<Kind>> => {
80 const executable = input.executable;75 const executable = input.executable;
81 const versionArgs = input.versionArgs;76 const versionArgs = input.versionArgs;
8277
83 if (!executable || !versionArgs) {78 if (!executable || !versionArgs) {
84 return Effect.succeed({79 return Effect.succeed({
85 kind: input.kind,80 kind: input.kind,
86 label: input.label,81 label: input.label,
87 implemented: input.implemented,82 implemented: input.implemented,
88 status: "missing" as const,83 status: "missing" as const,
89 version: Option.none<string>(),84 version: Option.none<string>(),
90 installHint: input.installHint,85 installHint: input.installHint,
91 detail: Option.some(input.installHint),86 detail: Option.some(input.installHint),
92 } satisfies DiscoveryProbeResult<Kind>);87 } satisfies DiscoveryProbeResult<Kind>);
93 }88 }
9489
95 return process90 return process
96 .run({91 .run({
97 operation: "source-control.discovery.probe",92 operation: "source-control.discovery.probe",
98 command: executable,93 command: executable,
99 args: versionArgs,94 args: versionArgs,
100 cwd: config.cwd,95 cwd: config.cwd,
101 timeoutMs: 5_000,96 timeoutMs: 5_000,
102 maxOutputBytes: 8_000,97 maxOutputBytes: 8_000,
103 appendTruncationMarker: true,98 appendTruncationMarker: true,
104 })99 })
105 .pipe(100 .pipe(
106 Effect.map(101 Effect.map(
107 (result) =>102 (result) =>
108 ({103 ({
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({
124 kind: input.kind,104 kind: input.kind,
125 label: input.label,105 label: input.label,
126 executable,106 executable,
127 implemented: input.implemented,107 implemented: input.implemented,
128 status: "missing" as const,108 status: "available" as const,
129 version: Option.none<string>(),109 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
110 firstNonEmptyLine(result.stderr),
111 ),
130 installHint: input.installHint,112 installHint: input.installHint,
131 detail: SourceControlProviderDiscovery.detailFromCause(cause),113 detail: Option.none<string>(),
132 } satisfies DiscoveryProbeResult<Kind>),114 }) satisfies DiscoveryProbeResult<Kind>,
133 ),115 ),
134 );116 Effect.catch((cause) =>
135 };117 Effect.succeed({
136118 kind: input.kind,
137 return SourceControlDiscovery.of({119 label: input.label,
138 discover: Effect.all({120 executable,
139 versionControlSystems: Effect.all(121 implemented: input.implemented,
140 VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>,122 status: "missing" as const,
141 { concurrency: "unbounded" },123 version: Option.none<string>(),
124 installHint: input.installHint,
125 detail: detailFromCause(cause),
126 } satisfies DiscoveryProbeResult<Kind>),
142 ),127 ),
143 sourceControlProviders: sourceControlProviders.discover,128 );
144 }),129 };
145 });130
146 }),131 return SourceControlDiscovery.of({
147);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: {...@@ -49,54 +49,52 @@ export function sourceControlRefFromInput(input: {
49 return input.source ?? parseSourceControlOwnerRef(input.headSelector);49 return input.source ?? parseSourceControlOwnerRef(input.headSelector);
50}50}
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
99export class SourceControlProvider extends Context.Service<52export class SourceControlProvider extends Context.Service<
100 SourceControlProvider,53 SourceControlProvider,
101 SourceControlProviderShape54 {
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 }
102>()("t3/sourceControl/SourceControlProvider") {}100>()("t3/sourceControl/SourceControlProvider") {}
apps/server/src/sourceControl/SourceControlProviderDiscovery.ts+3-3
...@@ -158,7 +158,7 @@ function isCliRemoteRefinementSpec(...@@ -158,7 +158,7 @@ function isCliRemoteRefinementSpec(
158158
159function probeCli(input: {159function probeCli(input: {
160 readonly spec: SourceControlCliDiscoverySpec;160 readonly spec: SourceControlCliDiscoverySpec;
161 readonly process: VcsProcess.VcsProcessShape;161 readonly process: VcsProcess.VcsProcess["Service"];
162 readonly cwd: string;162 readonly cwd: string;
163}): Effect.Effect<DiscoveryProbeResult> {163}): Effect.Effect<DiscoveryProbeResult> {
164 return input.process164 return input.process
...@@ -202,7 +202,7 @@ function probeCli(input: {...@@ -202,7 +202,7 @@ function probeCli(input: {
202202
203export function probeSourceControlProvider(input: {203export function probeSourceControlProvider(input: {
204 readonly spec: SourceControlProviderDiscoverySpec;204 readonly spec: SourceControlProviderDiscoverySpec;
205 readonly process: VcsProcess.VcsProcessShape;205 readonly process: VcsProcess.VcsProcess["Service"];
206 readonly cwd: string;206 readonly cwd: string;
207}): Effect.Effect<SourceControlProviderDiscoveryItem> {207}): Effect.Effect<SourceControlProviderDiscoveryItem> {
208 if (input.spec.type === "api") {208 if (input.spec.type === "api") {
...@@ -270,7 +270,7 @@ export function probeSourceControlProvider(input: {...@@ -270,7 +270,7 @@ export function probeSourceControlProvider(input: {
270export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvider")(270export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvider")(
271 function* (input: {271 function* (input: {
272 readonly specs: ReadonlyArray<SourceControlProviderDiscoverySpec>;272 readonly specs: ReadonlyArray<SourceControlProviderDiscoverySpec>;
273 readonly process: VcsProcess.VcsProcessShape;273 readonly process: VcsProcess.VcsProcess["Service"];
274 readonly cwd: string;274 readonly cwd: string;
275 readonly context: SourceControlProvider.SourceControlProviderContext | null;275 readonly context: SourceControlProvider.SourceControlProviderContext | null;
276 }): Effect.fn.Return<SourceControlProvider.SourceControlProviderContext | null> {276 }): 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";...@@ -6,7 +6,7 @@ import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import { ChildProcessSpawner } from "effect/unstable/process";7import { ChildProcessSpawner } from "effect/unstable/process";
88
9import { ServerConfig } from "../config.ts";9import * as ServerConfig from "../config.ts";
10import type * as VcsDriver from "../vcs/VcsDriver.ts";10import type * as VcsDriver from "../vcs/VcsDriver.ts";
11import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";11import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
12import * as VcsProcess from "../vcs/VcsProcess.ts";12import * as VcsProcess from "../vcs/VcsProcess.ts";
...@@ -37,7 +37,7 @@ function makeRegistry(input: {...@@ -37,7 +37,7 @@ function makeRegistry(input: {
37 readonly name: string;37 readonly name: string;
38 readonly url: string;38 readonly url: string;
39 }>;39 }>;
40 readonly process?: Partial<VcsProcess.VcsProcessShape>;40 readonly process?: Partial<VcsProcess.VcsProcess["Service"]>;
41}) {41}) {
42 const driver = {42 const driver = {
43 listRemotes: () =>43 listRemotes: () =>
...@@ -53,10 +53,10 @@ function makeRegistry(input: {...@@ -53,10 +53,10 @@ function makeRegistry(input: {
53 expiresAt: Option.none(),53 expiresAt: Option.none(),
54 },54 },
55 }),55 }),
56 } satisfies Partial<VcsDriver.VcsDriverShape>;56 } satisfies Partial<VcsDriver.VcsDriver["Service"]>;
5757
58 const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({58 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"]),
60 resolve: () =>60 resolve: () =>
61 Effect.succeed({61 Effect.succeed({
62 kind: "git",62 kind: "git",
...@@ -70,7 +70,7 @@ function makeRegistry(input: {...@@ -70,7 +70,7 @@ function makeRegistry(input: {
70 expiresAt: Option.none(),70 expiresAt: Option.none(),
71 },71 },
72 },72 },
73 driver: driver as unknown as VcsDriver.VcsDriverShape,73 driver: driver as unknown as VcsDriver.VcsDriver["Service"],
74 }),74 }),
75 });75 });
7676
...@@ -79,7 +79,7 @@ function makeRegistry(input: {...@@ -79,7 +79,7 @@ function makeRegistry(input: {
79 ...input.process,79 ...input.process,
80 });80 });
8181
82 return SourceControlProviderRegistry.make().pipe(82 return SourceControlProviderRegistry.make.pipe(
83 Effect.provide(83 Effect.provide(
84 Layer.mergeAll(84 Layer.mergeAll(
85 registryLayer,85 registryLayer,
...@@ -88,9 +88,9 @@ function makeRegistry(input: {...@@ -88,9 +88,9 @@ function makeRegistry(input: {
88 Layer.mock(BitbucketApi.BitbucketApi)({}),88 Layer.mock(BitbucketApi.BitbucketApi)({}),
89 Layer.mock(GitHubCli.GitHubCli)({}),89 Layer.mock(GitHubCli.GitHubCli)({}),
90 Layer.mock(GitLabCli.GitLabCli)({}),90 Layer.mock(GitLabCli.GitLabCli)({}),
91 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe(91 ServerConfig.layerTest(process.cwd(), {
92 Layer.provide(NodeServices.layer),92 prefix: "t3-source-control-registry-test-",
93 ),93 }).pipe(Layer.provide(NodeServices.layer)),
94 ),94 ),
95 ),95 ),
96 );96 );
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+41-33
...@@ -16,7 +16,11 @@ import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvide...@@ -16,7 +16,11 @@ import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvide
16import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";16import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
17import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";17import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
18import * as SourceControlProvider from "./SourceControlProvider.ts";18import * as SourceControlProvider from "./SourceControlProvider.ts";
19import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";19import {
20 probeSourceControlProvider,
21 refineUnknownRemoteProvider,
22 type SourceControlProviderDiscoverySpec,
23} from "./SourceControlProviderDiscovery.ts";
20import { ServerConfig } from "../config.ts";24import { ServerConfig } from "../config.ts";
21import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";25import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
22import * as VcsProcess from "../vcs/VcsProcess.ts";26import * as VcsProcess from "../vcs/VcsProcess.ts";
...@@ -26,36 +30,40 @@ const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5);...@@ -26,36 +30,40 @@ const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5);
2630
27export interface SourceControlProviderRegistration {31export interface SourceControlProviderRegistration {
28 readonly kind: SourceControlProviderKind;32 readonly kind: SourceControlProviderKind;
29 readonly provider: SourceControlProvider.SourceControlProviderShape;33 readonly provider: SourceControlProvider.SourceControlProvider["Service"];
30 readonly discovery: SourceControlProviderDiscovery.SourceControlProviderDiscoverySpec;34 readonly discovery: SourceControlProviderDiscoverySpec;
31}35}
3236
33export interface SourceControlProviderHandle {37export interface SourceControlProviderHandle {
34 readonly provider: SourceControlProvider.SourceControlProviderShape;38 readonly provider: SourceControlProvider.SourceControlProvider["Service"];
35 readonly context: SourceControlProvider.SourceControlProviderContext | null;39 readonly context: SourceControlProvider.SourceControlProviderContext | null;
36}40}
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
51export class SourceControlProviderRegistry extends Context.Service<42export class SourceControlProviderRegistry extends Context.Service<
52 SourceControlProviderRegistry,43 SourceControlProviderRegistry,
53 SourceControlProviderRegistryShape44 {
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 }
54>()("t3/sourceControl/SourceControlProviderRegistry") {}62>()("t3/sourceControl/SourceControlProviderRegistry") {}
5563
56function unsupportedProvider(64function unsupportedProvider(
57 kind: SourceControlProviderKind,65 kind: SourceControlProviderKind,
58): SourceControlProvider.SourceControlProviderShape {66): SourceControlProvider.SourceControlProvider["Service"] {
59 const unsupported = (operation: string) =>67 const unsupported = (operation: string) =>
60 Effect.fail(68 Effect.fail(
61 new SourceControlProviderError({69 new SourceControlProviderError({
...@@ -113,9 +121,9 @@ function selectProviderContext(...@@ -113,9 +121,9 @@ function selectProviderContext(
113}121}
114122
115function bindProviderContext(123function bindProviderContext(
116 provider: SourceControlProvider.SourceControlProviderShape,124 provider: SourceControlProvider.SourceControlProvider["Service"],
117 context: SourceControlProvider.SourceControlProviderContext | null,125 context: SourceControlProvider.SourceControlProviderContext | null,
118): SourceControlProvider.SourceControlProviderShape {126): SourceControlProvider.SourceControlProvider["Service"] {
119 if (context === null) {127 if (context === null) {
120 return provider;128 return provider;
121 }129 }
...@@ -163,11 +171,11 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -163,11 +171,11 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
163 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;171 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
164 const providers = new Map<172 const providers = new Map<
165 SourceControlProviderKind,173 SourceControlProviderKind,
166 SourceControlProvider.SourceControlProviderShape174 SourceControlProvider.SourceControlProvider["Service"]
167 >(registrations.map((registration) => [registration.kind, registration.provider]));175 >(registrations.map((registration) => [registration.kind, registration.provider]));
168 const discoverySpecs = registrations.map((registration) => registration.discovery);176 const discoverySpecs = registrations.map((registration) => registration.discovery);
169177
170 const get: SourceControlProviderRegistryShape["get"] = (kind) =>178 const get: SourceControlProviderRegistry["Service"]["get"] = (kind) =>
171 Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind));179 Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind));
172180
173 const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")(181 const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")(
...@@ -180,7 +188,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -180,7 +188,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
180 .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)));188 .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)));
181 const context = selectProviderContext(remotes.remotes);189 const context = selectProviderContext(remotes.remotes);
182190
183 return yield* SourceControlProviderDiscovery.refineUnknownRemoteProvider({191 return yield* refineUnknownRemoteProvider({
184 specs: discoverySpecs,192 specs: discoverySpecs,
185 process,193 process,
186 cwd,194 cwd,
...@@ -198,7 +206,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -198,7 +206,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
198 timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero),206 timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero),
199 });207 });
200208
201 const resolveHandle: SourceControlProviderRegistryShape["resolveHandle"] = (input) =>209 const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) =>
202 Cache.get(providerContextCache, input.cwd).pipe(210 Cache.get(providerContextCache, input.cwd).pipe(
203 Effect.map((context) => {211 Effect.map((context) => {
204 const kind = context?.provider.kind ?? "unknown";212 const kind = context?.provider.kind ?? "unknown";
...@@ -216,7 +224,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -216,7 +224,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
216 resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)),224 resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)),
217 discover: Effect.all(225 discover: Effect.all(
218 discoverySpecs.map((spec) =>226 discoverySpecs.map((spec) =>
219 SourceControlProviderDiscovery.probeSourceControlProvider({227 probeSourceControlProvider({
220 spec,228 spec,
221 process,229 process,
222 cwd: config.cwd,230 cwd: config.cwd,
...@@ -228,12 +236,12 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -228,12 +236,12 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
228 },236 },
229);237);
230238
231export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () {239export const make = Effect.gen(function* () {
232 const github = yield* GitHubSourceControlProvider.make();240 const github = yield* GitHubSourceControlProvider.make;
233 const gitlab = yield* GitLabSourceControlProvider.make();241 const gitlab = yield* GitLabSourceControlProvider.make;
234 const bitbucket = yield* BitbucketSourceControlProvider.make();242 const bitbucket = yield* BitbucketSourceControlProvider.make;
235 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery();243 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;
236 const azureDevOps = yield* AzureDevOpsSourceControlProvider.make();244 const azureDevOps = yield* AzureDevOpsSourceControlProvider.make;
237 return yield* makeWithProviders([245 return yield* makeWithProviders([
238 {246 {
239 kind: "github",247 kind: "github",
...@@ -258,4 +266,4 @@ export const make = Effect.fn("makeSourceControlProviderRegistry")(function* ()...@@ -258,4 +266,4 @@ export const make = Effect.fn("makeSourceControlProviderRegistry")(function* ()
258 ]);266 ]);
259});267});
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";...@@ -7,7 +7,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
77
8import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts";8import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts";
99
10import { ServerConfig } from "../config.ts";10import * as ServerConfig from "../config.ts";
11import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";11import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
12import type * as SourceControlProvider from "./SourceControlProvider.ts";12import type * as SourceControlProvider from "./SourceControlProvider.ts";
13import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";13import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
...@@ -20,8 +20,8 @@ const CLONE_URLS = {...@@ -20,8 +20,8 @@ const CLONE_URLS = {
20};20};
2121
22function makeProvider(22function makeProvider(
23 overrides: Partial<SourceControlProvider.SourceControlProviderShape> = {},23 overrides: Partial<SourceControlProvider.SourceControlProvider["Service"]> = {},
24): SourceControlProvider.SourceControlProviderShape {24): SourceControlProvider.SourceControlProvider["Service"] {
25 const unsupported = (operation: string) =>25 const unsupported = (operation: string) =>
26 Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect<26 Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect<
27 never,27 never,
...@@ -52,8 +52,8 @@ function processOutput(): GitVcsDriver.ExecuteGitResult {...@@ -52,8 +52,8 @@ function processOutput(): GitVcsDriver.ExecuteGitResult {
52}52}
5353
54function makeLayer(input: {54function makeLayer(input: {
55 readonly provider?: SourceControlProvider.SourceControlProviderShape;55 readonly provider?: SourceControlProvider.SourceControlProvider["Service"];
56 readonly git?: Partial<GitVcsDriver.GitVcsDriverShape>;56 readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>;
57}) {57}) {
58 return SourceControlRepositoryService.layer.pipe(58 return SourceControlRepositoryService.layer.pipe(
59 Layer.provide(59 Layer.provide(
apps/server/src/sourceControl/SourceControlRepositoryService.ts+13-15
...@@ -24,21 +24,19 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";...@@ -24,21 +24,19 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
24import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";24import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
25const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);25const 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
39export class SourceControlRepositoryService extends Context.Service<27export class SourceControlRepositoryService extends Context.Service<
40 SourceControlRepositoryService,28 SourceControlRepositoryService,
41 SourceControlRepositoryServiceShape29 {
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 }
42>()("t3/sourceControl/SourceControlRepositoryService") {}40>()("t3/sourceControl/SourceControlRepositoryService") {}
4341
44function detailFromUnknown(cause: unknown): string {42function detailFromUnknown(cause: unknown): string {
...@@ -116,7 +114,7 @@ function expandHomePath(input: string, path: Path.Path): string {...@@ -116,7 +114,7 @@ function expandHomePath(input: string, path: Path.Path): string {
116 return input;114 return input;
117}115}
118116
119export const make = Effect.fn("makeSourceControlRepositoryService")(function* () {117export const make = Effect.gen(function* () {
120 const config = yield* ServerConfig;118 const config = yield* ServerConfig;
121 const fileSystem = yield* FileSystem.FileSystem;119 const fileSystem = yield* FileSystem.FileSystem;
122 const git = yield* GitVcsDriver.GitVcsDriver;120 const git = yield* GitVcsDriver.GitVcsDriver;
...@@ -315,4 +313,4 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* ()...@@ -315,4 +313,4 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* ()
315 });313 });
316});314});
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";...@@ -8,7 +8,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
8import { assert, it } from "@effect/vitest";8import { assert, it } from "@effect/vitest";
99
10import { GitCommandError } from "@t3tools/contracts";10import { GitCommandError } from "@t3tools/contracts";
11import { ServerConfig } from "../config.ts";11import * as ServerConfig from "../config.ts";
12import * as GitVcsDriver from "./GitVcsDriver.ts";12import * as GitVcsDriver from "./GitVcsDriver.ts";
13import * as VcsProcess from "./VcsProcess.ts";13import * as VcsProcess from "./VcsProcess.ts";
14import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts";14import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts";
apps/server/src/vcs/GitVcsDriver.ts+94-91
...@@ -28,7 +28,7 @@ import {...@@ -28,7 +28,7 @@ import {
28 type VcsStatusInput,28 type VcsStatusInput,
29 type VcsStatusResult,29 type VcsStatusResult,
30} from "@t3tools/contracts";30} from "@t3tools/contracts";
31import * as GitVcsDriverCore from "./GitVcsDriverCore.ts";31import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts";
32import * as VcsDriver from "./VcsDriver.ts";32import * as VcsDriver from "./VcsDriver.ts";
33import * as VcsProcess from "./VcsProcess.ts";33import * as VcsProcess from "./VcsProcess.ts";
3434
...@@ -188,81 +188,84 @@ export interface GitRemoteStatusOptions {...@@ -188,81 +188,84 @@ export interface GitRemoteStatusOptions {
188 readonly refreshUpstream?: boolean;188 readonly refreshUpstream?: boolean;
189}189}
190190
191export interface GitVcsDriverShape {191export class GitVcsDriver extends Context.Service<
192 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;192 GitVcsDriver,
193 readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>;193 {
194 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;194 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;
195 readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;195 readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>;
196 readonly statusDetailsRemote: (196 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
197 cwd: string,197 readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
198 options?: GitRemoteStatusOptions,198 readonly statusDetailsRemote: (
199 ) => Effect.Effect<GitRemoteStatusDetails, GitCommandError>;199 cwd: string,
200 readonly prepareCommitContext: (200 options?: GitRemoteStatusOptions,
201 cwd: string,201 ) => Effect.Effect<GitRemoteStatusDetails, GitCommandError>;
202 filePaths?: readonly string[],202 readonly prepareCommitContext: (
203 ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>;203 cwd: string,
204 readonly commit: (204 filePaths?: readonly string[],
205 cwd: string,205 ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>;
206 subject: string,206 readonly commit: (
207 body: string,207 cwd: string,
208 options?: GitCommitOptions,208 subject: string,
209 ) => Effect.Effect<{ commitSha: string }, GitCommandError>;209 body: string,
210 readonly pushCurrentBranch: (210 options?: GitCommitOptions,
211 cwd: string,211 ) => Effect.Effect<{ commitSha: string }, GitCommandError>;
212 fallbackBranch: string | null,212 readonly pushCurrentBranch: (
213 options?: { readonly remoteName?: string | null },213 cwd: string,
214 ) => Effect.Effect<GitPushResult, GitCommandError>;214 fallbackBranch: string | null,
215 readonly readRangeContext: (215 options?: { readonly remoteName?: string | null },
216 cwd: string,216 ) => Effect.Effect<GitPushResult, GitCommandError>;
217 baseRef: string,217 readonly readRangeContext: (
218 ) => Effect.Effect<GitRangeContext, GitCommandError>;218 cwd: string,
219 readonly getReviewDiffPreview: (219 baseRef: string,
220 input: ReviewDiffPreviewInput,220 ) => Effect.Effect<GitRangeContext, GitCommandError>;
221 ) => Effect.Effect<ReviewDiffPreviewResult, GitCommandError>;221 readonly getReviewDiffPreview: (
222 readonly readConfigValue: (222 input: ReviewDiffPreviewInput,
223 cwd: string,223 ) => Effect.Effect<ReviewDiffPreviewResult, GitCommandError>;
224 key: string,224 readonly readConfigValue: (
225 ) => Effect.Effect<string | null, GitCommandError>;225 cwd: string,
226 readonly listRefs: (input: VcsListRefsInput) => Effect.Effect<VcsListRefsResult, GitCommandError>;226 key: string,
227 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;227 ) => Effect.Effect<string | null, GitCommandError>;
228 readonly createWorktree: (228 readonly listRefs: (
229 input: VcsCreateWorktreeInput,229 input: VcsListRefsInput,
230 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;230 ) => Effect.Effect<VcsListRefsResult, GitCommandError>;
231 readonly fetchPullRequestBranch: (231 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
232 input: GitFetchPullRequestBranchInput,232 readonly createWorktree: (
233 ) => Effect.Effect<void, GitCommandError>;233 input: VcsCreateWorktreeInput,
234 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;234 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
235 readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>;235 readonly fetchPullRequestBranch: (
236 readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect<void, GitCommandError>;236 input: GitFetchPullRequestBranchInput,
237 readonly resolveRemoteTrackingCommit: (237 ) => Effect.Effect<void, GitCommandError>;
238 input: GitResolveRemoteTrackingCommitInput,238 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;
239 ) => Effect.Effect<GitResolveRemoteTrackingCommitResult, GitCommandError>;239 readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>;
240 readonly fetchRemoteBranch: (240 readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect<void, GitCommandError>;
241 input: GitFetchRemoteBranchInput,241 readonly resolveRemoteTrackingCommit: (
242 ) => Effect.Effect<void, GitCommandError>;242 input: GitResolveRemoteTrackingCommitInput,
243 readonly fetchRemoteTrackingBranch: (243 ) => Effect.Effect<GitResolveRemoteTrackingCommitResult, GitCommandError>;
244 input: GitFetchRemoteTrackingBranchInput,244 readonly fetchRemoteBranch: (
245 ) => Effect.Effect<void, GitCommandError>;245 input: GitFetchRemoteBranchInput,
246 readonly setBranchUpstream: (246 ) => Effect.Effect<void, GitCommandError>;
247 input: GitSetBranchUpstreamInput,247 readonly fetchRemoteTrackingBranch: (
248 ) => Effect.Effect<void, GitCommandError>;248 input: GitFetchRemoteTrackingBranchInput,
249 readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;249 ) => Effect.Effect<void, GitCommandError>;
250 readonly renameBranch: (250 readonly setBranchUpstream: (
251 input: GitRenameBranchInput,251 input: GitSetBranchUpstreamInput,
252 ) => Effect.Effect<GitRenameBranchResult, GitCommandError>;252 ) => Effect.Effect<void, GitCommandError>;
253 readonly createRef: (253 readonly removeWorktree: (
254 input: VcsCreateRefInput,254 input: VcsRemoveWorktreeInput,
255 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;255 ) => Effect.Effect<void, GitCommandError>;
256 readonly switchRef: (256 readonly renameBranch: (
257 input: VcsSwitchRefInput,257 input: GitRenameBranchInput,
258 ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;258 ) => Effect.Effect<GitRenameBranchResult, GitCommandError>;
259 readonly initRepo: (input: VcsInitInput) => Effect.Effect<void, GitCommandError>;259 readonly createRef: (
260 readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;260 input: VcsCreateRefInput,
261}261 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
262262 readonly switchRef: (
263export class GitVcsDriver extends Context.Service<GitVcsDriver, GitVcsDriverShape>()(263 input: VcsSwitchRefInput,
264 "t3/vcs/GitVcsDriver",264 ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
265) {}265 readonly initRepo: (input: VcsInitInput) => Effect.Effect<void, GitCommandError>;
266 readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;
267 }
268>()("t3/vcs/GitVcsDriver") {}
266269
267const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;270const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
268const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;271const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
...@@ -357,7 +360,7 @@ function parseGitRemoteVerboseOutput(...@@ -357,7 +360,7 @@ function parseGitRemoteVerboseOutput(
357}360}
358361
359const gitCommand = (362const gitCommand = (
360 process: VcsProcess.VcsProcessShape,363 process: VcsProcess.VcsProcess["Service"],
361 operation: string,364 operation: string,
362 cwd: string,365 cwd: string,
363 args: ReadonlyArray<string>,366 args: ReadonlyArray<string>,
...@@ -401,7 +404,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -401,7 +404,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
401 ignoreClassifier: "native" as const,404 ignoreClassifier: "native" as const,
402 };405 };
403406
404 const isInsideWorkTree: VcsDriver.VcsDriverShape["isInsideWorkTree"] = (cwd) =>407 const isInsideWorkTree: VcsDriver.VcsDriver["Service"]["isInsideWorkTree"] = (cwd) =>
405 gitCommand(408 gitCommand(
406 vcsProcess,409 vcsProcess,
407 "GitVcsDriver.isInsideWorkTree",410 "GitVcsDriver.isInsideWorkTree",
...@@ -414,7 +417,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -414,7 +417,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
414 },417 },
415 ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"));418 ).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) =>
418 gitCommand(vcsProcess, input.operation, input.cwd, input.args, {421 gitCommand(vcsProcess, input.operation, input.cwd, input.args, {
419 ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),422 ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
420 ...(input.env !== undefined ? { env: input.env } : {}),423 ...(input.env !== undefined ? { env: input.env } : {}),
...@@ -426,7 +429,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -426,7 +429,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
426 : {}),429 : {}),
427 });430 });
428431
429 const detectRepository: VcsDriver.VcsDriverShape["detectRepository"] = Effect.fn(432 const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn(
430 "detectRepository",433 "detectRepository",
431 )(function* (cwd) {434 )(function* (cwd) {
432 if (!(yield* isInsideWorkTree(cwd))) {435 if (!(yield* isInsideWorkTree(cwd))) {
...@@ -452,7 +455,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -452,7 +455,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
452 };455 };
453 });456 });
454457
455 const listWorkspaceFiles: VcsDriver.VcsDriverShape["listWorkspaceFiles"] = (cwd) =>458 const listWorkspaceFiles: VcsDriver.VcsDriver["Service"]["listWorkspaceFiles"] = (cwd) =>
456 gitCommand(459 gitCommand(
457 vcsProcess,460 vcsProcess,
458 "GitVcsDriver.listWorkspaceFiles",461 "GitVcsDriver.listWorkspaceFiles",
...@@ -494,7 +497,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -494,7 +497,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
494 ),497 ),
495 );498 );
496499
497 const listRemotes: VcsDriver.VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")(500 const listRemotes: VcsDriver.VcsDriver["Service"]["listRemotes"] = Effect.fn("listRemotes")(
498 function* (cwd) {501 function* (cwd) {
499 const result = yield* gitCommand(502 const result = yield* gitCommand(
500 vcsProcess,503 vcsProcess,
...@@ -540,7 +543,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -540,7 +543,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
540 },543 },
541 );544 );
542545
543 const filterIgnoredPaths: VcsDriver.VcsDriverShape["filterIgnoredPaths"] = Effect.fn(546 const filterIgnoredPaths: VcsDriver.VcsDriver["Service"]["filterIgnoredPaths"] = Effect.fn(
544 "filterIgnoredPaths",547 "filterIgnoredPaths",
545 )(function* (cwd, relativePaths) {548 )(function* (cwd, relativePaths) {
546 if (relativePaths.length === 0) {549 if (relativePaths.length === 0) {
...@@ -587,7 +590,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -587,7 +590,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
587 return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));590 return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));
588 });591 });
589592
590 const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) =>593 const initRepository: VcsDriver.VcsDriver["Service"]["initRepository"] = (input) =>
591 gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], {594 gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], {
592 timeoutMs: 10_000,595 timeoutMs: 10_000,
593 maxOutputBytes: 64 * 1024,596 maxOutputBytes: 64 * 1024,
...@@ -844,7 +847,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -844,7 +847,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
844 ),847 ),
845 };848 };
846849
847 return VcsDriver.VcsDriver.of({850 return {
848 capabilities,851 capabilities,
849 execute,852 execute,
850 checkpoints,853 checkpoints,
...@@ -854,18 +857,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -854,18 +857,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
854 listRemotes,857 listRemotes,
855 filterIgnoredPaths,858 filterIgnoredPaths,
856 initRepository,859 initRepository,
857 });860 };
858});861});
859862
860export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () {863export const makeVcsDriver = Effect.gen(function* () {
861 const driver = yield* makeVcsDriverShape();864 const driver = yield* makeVcsDriverShape();
862 return VcsDriver.VcsDriver.of(driver);865 return VcsDriver.VcsDriver.of(driver);
863});866});
864867
865export const make = Effect.fn("makeGitVcsDriverService")(function* () {868export const make = Effect.gen(function* () {
866 const git = yield* GitVcsDriverCore.makeGitVcsDriverCore();869 const git = yield* makeGitVcsDriverCore();
867 return GitVcsDriver.of(git);870 return GitVcsDriver.of(git);
868});871});
869872
870export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver());873export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver);
871export const layer = Layer.effect(GitVcsDriver, make());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*...@@ -655,7 +655,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
655 const { worktreesDir } = yield* ServerConfig;655 const { worktreesDir } = yield* ServerConfig;
656 const crypto = yield* Crypto.Crypto;656 const crypto = yield* Crypto.Crypto;
657657
658 const executeRaw: GitVcsDriver.GitVcsDriverShape["execute"] = Effect.fnUntraced(658 const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced(
659 function* (input) {659 function* (input) {
660 const commandInput = {660 const commandInput = {
661 ...input,661 ...input,
...@@ -756,7 +756,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -756,7 +756,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
756 },756 },
757 );757 );
758758
759 const execute: GitVcsDriver.GitVcsDriverShape["execute"] = (input) =>759 const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) =>
760 executeRaw(input).pipe(760 executeRaw(input).pipe(
761 withMetrics({761 withMetrics({
762 counter: gitCommandsTotal,762 counter: gitCommandsTotal,
...@@ -1059,38 +1059,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1059,38 +1059,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1059 return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null));1059 return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null));
1060 });1060 });
10611061
1062 const ensureRemote: GitVcsDriver.GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")(1062 const ensureRemote: GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"] = Effect.fn(
1063 function* (input) {1063 "ensureRemote",
1064 const preferredName = sanitizeRemoteName(input.preferredName);1064 )(function* (input) {
1065 const normalizedTargetUrl = normalizeRemoteUrl(input.url);1065 const preferredName = sanitizeRemoteName(input.preferredName);
1066 const remoteFetchUrls = yield* runGitStdout(1066 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
1067 "GitVcsDriver.ensureRemote.listRemoteUrls",1067 const remoteFetchUrls = yield* runGitStdout(
1068 input.cwd,1068 "GitVcsDriver.ensureRemote.listRemoteUrls",
1069 ["remote", "-v"],1069 input.cwd,
1070 ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));1070 ["remote", "-v"],
1071 ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
10711072
1072 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {1073 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
1073 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {1074 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
1074 return remoteName;1075 return remoteName;
1075 }
1076 }1076 }
1077 }
10771078
1078 let remoteName = preferredName;1079 let remoteName = preferredName;
1079 let suffix = 1;1080 let suffix = 1;
1080 while (remoteFetchUrls.has(remoteName)) {1081 while (remoteFetchUrls.has(remoteName)) {
1081 remoteName = `${preferredName}-${suffix}`;1082 remoteName = `${preferredName}-${suffix}`;
1082 suffix += 1;1083 suffix += 1;
1083 }1084 }
10841085
1085 yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [1086 yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [
1086 "remote",1087 "remote",
1087 "add",1088 "add",
1088 remoteName,1089 remoteName,
1089 input.url,1090 input.url,
1090 ]);1091 ]);
1091 return remoteName;1092 return remoteName;
1092 },1093 });
1093 );
10941094
1095 const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* (1095 const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* (
1096 cwd: string,1096 cwd: string,
...@@ -1426,35 +1426,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1426,35 +1426,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1426 };1426 };
1427 });1427 });
14281428
1429 const statusDetailsLocal: GitVcsDriver.GitVcsDriverShape["statusDetailsLocal"] = Effect.fn(1429 const statusDetailsLocal: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn(
1430 "statusDetailsLocal",1430 "statusDetailsLocal",
1431 )(function* (cwd) {1431 )(function* (cwd) {
1432 return yield* readStatusDetailsLocal(cwd);1432 return yield* readStatusDetailsLocal(cwd);
1433 });1433 });
14341434
1435 const statusDetails: GitVcsDriver.GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")(1435 const statusDetails: GitVcsDriver.GitVcsDriver["Service"]["statusDetails"] = Effect.fn(
1436 function* (cwd) {1436 "statusDetails",
1437 yield* refreshStatusUpstreamIfStale(cwd).pipe(1437 )(function* (cwd) {
1438 Effect.catchIf(isMissingGitCwdError, () => Effect.void),1438 yield* refreshStatusUpstreamIfStale(cwd).pipe(
1439 Effect.ignoreCause({ log: true }),1439 Effect.catchIf(isMissingGitCwdError, () => Effect.void),
1440 );1440 Effect.ignoreCause({ log: true }),
1441 return yield* readStatusDetailsLocal(cwd);1441 );
1442 },1442 return yield* readStatusDetailsLocal(cwd);
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);
1455 });1443 });
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) =>
1458 statusDetails(input.cwd).pipe(1457 statusDetails(input.cwd).pipe(
1459 Effect.map((details) => ({1458 Effect.map((details) => ({
1460 isRepo: details.isRepo,1459 isRepo: details.isRepo,
...@@ -1471,49 +1470,48 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1471,49 +1470,48 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1471 })),1470 })),
1472 );1471 );
14731472
1474 const prepareCommitContext: GitVcsDriver.GitVcsDriverShape["prepareCommitContext"] = Effect.fn(1473 const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] =
1475 "prepareCommitContext",1474 Effect.fn("prepareCommitContext")(function* (cwd, filePaths) {
1476 )(function* (cwd, filePaths) {1475 if (filePaths && filePaths.length > 0) {
1477 if (filePaths && filePaths.length > 0) {1476 yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe(
1478 yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe(1477 Effect.catch(() => Effect.void),
1479 Effect.catch(() => Effect.void),1478 );
1480 );1479 yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [
1481 yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [1480 "add",
1482 "add",1481 "-A",
1483 "-A",1482 "--",
1484 "--",1483 ...filePaths,
1485 ...filePaths,1484 ]);
1486 ]);1485 } else {
1487 } else {1486 yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]);
1488 yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]);1487 }
1489 }
14901488
1491 const stagedSummary = yield* runGitStdout(1489 const stagedSummary = yield* runGitStdout(
1492 "GitVcsDriver.prepareCommitContext.stagedSummary",1490 "GitVcsDriver.prepareCommitContext.stagedSummary",
1493 cwd,1491 cwd,
1494 ["diff", "--cached", "--name-status"],1492 ["diff", "--cached", "--name-status"],
1495 ).pipe(Effect.map((stdout) => stdout.trim()));1493 ).pipe(Effect.map((stdout) => stdout.trim()));
1496 if (stagedSummary.length === 0) {1494 if (stagedSummary.length === 0) {
1497 return null;1495 return null;
1498 }1496 }
14991497
1500 const stagedPatch = yield* runGitStdoutWithOptions(1498 const stagedPatch = yield* runGitStdoutWithOptions(
1501 "GitVcsDriver.prepareCommitContext.stagedPatch",1499 "GitVcsDriver.prepareCommitContext.stagedPatch",
1502 cwd,1500 cwd,
1503 ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"],1501 ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"],
1504 {1502 {
1505 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,1503 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,
1506 appendTruncationMarker: true,1504 appendTruncationMarker: true,
1507 },1505 },
1508 );1506 );
15091507
1510 return {1508 return {
1511 stagedSummary,1509 stagedSummary,
1512 stagedPatch,1510 stagedPatch,
1513 };1511 };
1514 });1512 });
15151513
1516 const commit: GitVcsDriver.GitVcsDriverShape["commit"] = Effect.fn("commit")(function* (1514 const commit: GitVcsDriver.GitVcsDriver["Service"]["commit"] = Effect.fn("commit")(function* (
1517 cwd,1515 cwd,
1518 subject,1516 subject,
1519 body,1517 body,
...@@ -1546,7 +1544,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1546,7 +1544,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1546 return { commitSha };1544 return { commitSha };
1547 });1545 });
15481546
1549 const pushCurrentBranch: GitVcsDriver.GitVcsDriverShape["pushCurrentBranch"] = Effect.fn(1547 const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn(
1550 "pushCurrentBranch",1548 "pushCurrentBranch",
1551 )(function* (cwd, fallbackBranch, options) {1549 )(function* (cwd, fallbackBranch, options) {
1552 const details = yield* statusDetails(cwd);1550 const details = yield* statusDetails(cwd);
...@@ -1664,7 +1662,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1664,7 +1662,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1664 };1662 };
1665 });1663 });
16661664
1667 const pullCurrentBranch: GitVcsDriver.GitVcsDriverShape["pullCurrentBranch"] = Effect.fn(1665 const pullCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pullCurrentBranch"] = Effect.fn(
1668 "pullCurrentBranch",1666 "pullCurrentBranch",
1669 )(function* (cwd) {1667 )(function* (cwd) {
1670 const details = yield* statusDetails(cwd);1668 const details = yield* statusDetails(cwd);
...@@ -1710,7 +1708,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1710,7 +1708,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1710 };1708 };
1711 });1709 });
17121710
1713 const readRangeContext: GitVcsDriver.GitVcsDriverShape["readRangeContext"] = Effect.fn(1711 const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn(
1714 "readRangeContext",1712 "readRangeContext",
1715 )(function* (cwd, baseRef) {1713 )(function* (cwd, baseRef) {
1716 const range = `${baseRef}..HEAD`;1714 const range = `${baseRef}..HEAD`;
...@@ -1921,13 +1919,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1921,13 +1919,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1921 };1919 };
1922 });1920 });
19231921
1924 const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) =>1922 const readConfigValue: GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"] = (cwd, key) =>
1925 runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe(1923 runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe(
1926 Effect.map((stdout) => stdout.trim()),1924 Effect.map((stdout) => stdout.trim()),
1927 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),1925 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
1928 );1926 );
19291927
1930 const listRefs: GitVcsDriver.GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")(1928 const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")(
1931 function* (input) {1929 function* (input) {
1932 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(1930 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
1933 Effect.orElseSucceed(() => new Map<string, number>()),1931 Effect.orElseSucceed(() => new Map<string, number>()),
...@@ -2165,7 +2163,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2165,7 +2163,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2165 },2163 },
2166 );2164 );
21672165
2168 const createWorktree: GitVcsDriver.GitVcsDriverShape["createWorktree"] = Effect.fn(2166 const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn(
2169 "createWorktree",2167 "createWorktree",
2170 )(function* (input) {2168 )(function* (input) {
2171 const targetBranch = input.newRefName ?? input.refName;2169 const targetBranch = input.newRefName ?? input.refName;
...@@ -2188,7 +2186,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2188,7 +2186,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2188 };2186 };
2189 });2187 });
21902188
2191 const fetchPullRequestBranch: GitVcsDriver.GitVcsDriverShape["fetchPullRequestBranch"] =2189 const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] =
2192 Effect.fn("fetchPullRequestBranch")(function* (input) {2190 Effect.fn("fetchPullRequestBranch")(function* (input) {
2193 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);2191 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);
2194 yield* executeGit(2192 yield* executeGit(
...@@ -2207,7 +2205,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2207,7 +2205,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2207 );2205 );
2208 });2206 });
22092207
2210 const fetchRemote: GitVcsDriver.GitVcsDriverShape["fetchRemote"] = Effect.fn("fetchRemote")(2208 const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")(
2211 function* (input) {2209 function* (input) {
2212 yield* executeGit(2210 yield* executeGit(
2213 "GitVcsDriver.fetchRemote",2211 "GitVcsDriver.fetchRemote",
...@@ -2221,7 +2219,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2221,7 +2219,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2221 },2219 },
2222 );2220 );
22232221
2224 const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriverShape["resolveRemoteTrackingCommit"] =2222 const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveRemoteTrackingCommit"] =
2225 Effect.fn("resolveRemoteTrackingCommit")(function* (input) {2223 Effect.fn("resolveRemoteTrackingCommit")(function* (input) {
2226 const remoteNames = yield* listRemoteNames(input.cwd);2224 const remoteNames = yield* listRemoteNames(input.cwd);
2227 const parsedRemoteRef = parseRemoteRefWithRemoteNames(2225 const parsedRemoteRef = parseRemoteRefWithRemoteNames(
...@@ -2239,7 +2237,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2239,7 +2237,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2239 return { commitSha, remoteRefName };2237 return { commitSha, remoteRefName };
2240 });2238 });
22412239
2242 const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn(2240 const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn(
2243 "fetchRemoteBranch",2241 "fetchRemoteBranch",
2244 )(function* (input) {2242 )(function* (input) {
2245 yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [2243 yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [
...@@ -2261,7 +2259,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2261,7 +2259,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2261 );2259 );
2262 });2260 });
22632261
2264 const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"] =2262 const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] =
2265 Effect.fn("fetchRemoteTrackingBranch")(function* (input) {2263 Effect.fn("fetchRemoteTrackingBranch")(function* (input) {
2266 yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [2264 yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [
2267 "fetch",2265 "fetch",
...@@ -2272,7 +2270,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2272,7 +2270,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2272 ]);2270 ]);
2273 });2271 });
22742272
2275 const setBranchUpstream: GitVcsDriver.GitVcsDriverShape["setBranchUpstream"] = (input) =>2273 const setBranchUpstream: GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"] = (input) =>
2276 runGit("GitVcsDriver.setBranchUpstream", input.cwd, [2274 runGit("GitVcsDriver.setBranchUpstream", input.cwd, [
2277 "branch",2275 "branch",
2278 "--set-upstream-to",2276 "--set-upstream-to",
...@@ -2280,7 +2278,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2280,7 +2278,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2280 input.branch,2278 input.branch,
2281 ]);2279 ]);
22822280
2283 const removeWorktree: GitVcsDriver.GitVcsDriverShape["removeWorktree"] = Effect.fn(2281 const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn(
2284 "removeWorktree",2282 "removeWorktree",
2285 )(function* (input) {2283 )(function* (input) {
2286 const args = ["worktree", "remove"];2284 const args = ["worktree", "remove"];
...@@ -2304,28 +2302,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2304,28 +2302,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2304 );2302 );
2305 });2303 });
23062304
2307 const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")(2305 const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn(
2308 function* (input) {2306 "renameBranch",
2309 if (input.oldBranch === input.newBranch) {2307 )(function* (input) {
2310 return { branch: input.newBranch };2308 if (input.oldBranch === input.newBranch) {
2311 }2309 return { branch: input.newBranch };
2312 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);2310 }
2311 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
23132312
2314 yield* executeGit(2313 yield* executeGit(
2315 "GitVcsDriver.renameBranch",2314 "GitVcsDriver.renameBranch",
2316 input.cwd,2315 input.cwd,
2317 ["branch", "-m", "--", input.oldBranch, targetBranch],2316 ["branch", "-m", "--", input.oldBranch, targetBranch],
2318 {2317 {
2319 timeoutMs: 10_000,2318 timeoutMs: 10_000,
2320 fallbackErrorMessage: "git branch rename failed",2319 fallbackErrorMessage: "git branch rename failed",
2321 },2320 },
2322 );2321 );
23232322
2324 return { branch: targetBranch };2323 return { branch: targetBranch };
2325 },2324 });
2326 );
23272325
2328 const switchRef: GitVcsDriver.GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")(2326 const switchRef: GitVcsDriver.GitVcsDriver["Service"]["switchRef"] = Effect.fn("switchRef")(
2329 function* (input) {2327 function* (input) {
2330 const [localInputExists, remoteExists] = yield* Effect.all(2328 const [localInputExists, remoteExists] = yield* Effect.all(
2331 [2329 [
...@@ -2407,7 +2405,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2407,7 +2405,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2407 },2405 },
2408 );2406 );
24092407
2410 const createRef: GitVcsDriver.GitVcsDriverShape["createRef"] = Effect.fn("createRef")(2408 const createRef: GitVcsDriver.GitVcsDriver["Service"]["createRef"] = Effect.fn("createRef")(
2411 function* (input) {2409 function* (input) {
2412 yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], {2410 yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], {
2413 timeoutMs: 10_000,2411 timeoutMs: 10_000,
...@@ -2421,13 +2419,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2421,13 +2419,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2421 },2419 },
2422 );2420 );
24232421
2424 const initRepo: GitVcsDriver.GitVcsDriverShape["initRepo"] = (input) =>2422 const initRepo: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = (input) =>
2425 executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], {2423 executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], {
2426 timeoutMs: 10_000,2424 timeoutMs: 10_000,
2427 fallbackErrorMessage: "git init failed",2425 fallbackErrorMessage: "git init failed",
2428 }).pipe(Effect.asVoid);2426 }).pipe(Effect.asVoid);
24292427
2430 const listLocalBranchNames: GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"] = (cwd) =>2428 const listLocalBranchNames: GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"] = (
2429 cwd,
2430 ) =>
2431 runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [2431 runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [
2432 "branch",2432 "branch",
2433 "--list",2433 "--list",
apps/server/src/vcs/VcsDriver.ts+26-23
...@@ -52,26 +52,29 @@ export interface VcsCheckpointOps {...@@ -52,26 +52,29 @@ export interface VcsCheckpointOps {
52 ) => Effect.Effect<void, VcsError>;52 ) => Effect.Effect<void, VcsError>;
53}53}
5454
55export interface VcsDriverShape {55export class VcsDriver extends Context.Service<
56 readonly capabilities: VcsDriverCapabilities;56 VcsDriver,
57 readonly execute: (57 {
58 input: Omit<VcsProcess.VcsProcessInput, "command">,58 readonly capabilities: VcsDriverCapabilities;
59 ) => Effect.Effect<VcsProcess.VcsProcessOutput, VcsError>;59 readonly execute: (
60 readonly checkpoints?: VcsCheckpointOps;60 input: Omit<VcsProcess.VcsProcessInput, "command">,
61 readonly detectRepository: (cwd: string) => Effect.Effect<VcsRepositoryIdentity | null, VcsError>;61 ) => Effect.Effect<VcsProcess.VcsProcessOutput, VcsError>;
62 readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, VcsError>;62 readonly checkpoints?: VcsCheckpointOps;
63 readonly listWorkspaceFiles: (63 readonly detectRepository: (
64 cwd: string,64 cwd: string,
65 ) => Effect.Effect<VcsListWorkspaceFilesResult, VcsError>;65 ) => Effect.Effect<VcsRepositoryIdentity | null, VcsError>;
66 readonly listRemotes: (cwd: string) => Effect.Effect<VcsListRemotesResult, VcsError>;66 readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, VcsError>;
67 readonly filterIgnoredPaths: (67 readonly listWorkspaceFiles: (
68 cwd: string,68 cwd: string,
69 relativePaths: ReadonlyArray<string>,69 ) => Effect.Effect<VcsListWorkspaceFilesResult, VcsError>;
70 ) => Effect.Effect<ReadonlyArray<string>, VcsError>;70 readonly listRemotes: (cwd: string) => Effect.Effect<VcsListRemotesResult, VcsError>;
71 readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>;71 readonly filterIgnoredPaths: (
72 readonly getDiffPreview?: (72 cwd: string,
73 input: ReviewDiffPreviewInput,73 relativePaths: ReadonlyArray<string>,
74 ) => Effect.Effect<ReviewDiffPreviewResult, VcsError>;74 ) => Effect.Effect<ReadonlyArray<string>, VcsError>;
75}75 readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>;
7676 readonly getDiffPreview?: (
77export class VcsDriver extends Context.Service<VcsDriver, VcsDriverShape>()("t3/vcs/VcsDriver") {}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> =>...@@ -21,7 +21,7 @@ const normalizeGitArgs = (args: ReadonlyArray<string>): ReadonlyArray<string> =>
2121
22describe("VcsDriverRegistry", () => {22describe("VcsDriverRegistry", () => {
23 it.effect("routes directly by VCS driver kind for non-repository workflows", () => {23 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(
25 Layer.provide(NodeServices.layer),25 Layer.provide(NodeServices.layer),
26 Layer.provide(26 Layer.provide(
27 Layer.mock(VcsProjectConfig.VcsProjectConfig)({27 Layer.mock(VcsProjectConfig.VcsProjectConfig)({
...@@ -45,7 +45,7 @@ describe("VcsDriverRegistry", () => {...@@ -45,7 +45,7 @@ describe("VcsDriverRegistry", () => {
4545
46 it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => {46 it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => {
47 const calls: VcsProcess.VcsProcessInput[] = [];47 const calls: VcsProcess.VcsProcessInput[] = [];
48 const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe(48 const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe(
49 Layer.provide(NodeServices.layer),49 Layer.provide(NodeServices.layer),
50 Layer.provide(50 Layer.provide(
51 Layer.mock(VcsProjectConfig.VcsProjectConfig)({51 Layer.mock(VcsProjectConfig.VcsProjectConfig)({
apps/server/src/vcs/VcsDriverRegistry.ts+19-20
...@@ -22,20 +22,19 @@ export interface VcsDriverResolveInput {...@@ -22,20 +22,19 @@ export interface VcsDriverResolveInput {
22export interface VcsDriverHandle {22export interface VcsDriverHandle {
23 readonly kind: VcsDriverKind;23 readonly kind: VcsDriverKind;
24 readonly repository: VcsRepositoryIdentity;24 readonly repository: VcsRepositoryIdentity;
25 readonly driver: VcsDriver.VcsDriverShape;25 readonly driver: VcsDriver.VcsDriver["Service"];
26}26}
2727
28export interface VcsDriverRegistryShape {28export class VcsDriverRegistry extends Context.Service<
29 readonly get: (kind: VcsDriverKind) => Effect.Effect<VcsDriver.VcsDriverShape, VcsError>;29 VcsDriverRegistry,
30 readonly detect: (30 {
31 input: VcsDriverResolveInput,31 readonly get: (kind: VcsDriverKind) => Effect.Effect<VcsDriver.VcsDriver["Service"], VcsError>;
32 ) => Effect.Effect<VcsDriverHandle | null, VcsError>;32 readonly detect: (
33 readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect<VcsDriverHandle, VcsError>;33 input: VcsDriverResolveInput,
34}34 ) => Effect.Effect<VcsDriverHandle | null, VcsError>;
3535 readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect<VcsDriverHandle, VcsError>;
36export class VcsDriverRegistry extends Context.Service<VcsDriverRegistry, VcsDriverRegistryShape>()(36 }
37 "t3/vcs/VcsDriverRegistry",37>()("t3/vcs/VcsDriverRegistry") {}
38) {}
3938
40const unsupported = (operation: string, kind: VcsDriverKind, detail: string) =>39const unsupported = (operation: string, kind: VcsDriverKind, detail: string) =>
41 new VcsUnsupportedOperationError({40 new VcsUnsupportedOperationError({
...@@ -68,14 +67,14 @@ function parseDetectionCacheKey(key: string): {...@@ -68,14 +67,14 @@ function parseDetectionCacheKey(key: string): {
68 };67 };
69}68}
7069
71export const make = Effect.fn("makeVcsDriverRegistry")(function* () {70export const make = Effect.gen(function* () {
72 const projectConfig = yield* VcsProjectConfig.VcsProjectConfig;71 const projectConfig = yield* VcsProjectConfig.VcsProjectConfig;
73 const git = yield* GitVcsDriver.makeVcsDriverShape();72 const git = yield* GitVcsDriver.makeVcsDriver;
74 const drivers: Partial<Record<VcsDriverKind, VcsDriver.VcsDriverShape>> = {73 const drivers: Partial<Record<VcsDriverKind, VcsDriver.VcsDriver["Service"]>> = {
75 git,74 git,
76 };75 };
7776
78 const get: VcsDriverRegistryShape["get"] = (kind) => {77 const get: VcsDriverRegistry["Service"]["get"] = (kind) => {
79 const driver = drivers[kind];78 const driver = drivers[kind];
80 if (!driver) {79 if (!driver) {
81 return Effect.fail(80 return Effect.fail(
...@@ -87,7 +86,7 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () {...@@ -87,7 +86,7 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () {
8786
88 const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* (87 const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* (
89 kind: VcsDriverKind,88 kind: VcsDriverKind,
90 driver: VcsDriver.VcsDriverShape,89 driver: VcsDriver.VcsDriver["Service"],
91 cwd: string,90 cwd: string,
92 ) {91 ) {
93 const repository = yield* driver.detectRepository(cwd);92 const repository = yield* driver.detectRepository(cwd);
...@@ -123,14 +122,14 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () {...@@ -123,14 +122,14 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () {
123 },122 },
124 );123 );
125124
126 const detect: VcsDriverRegistryShape["detect"] = Effect.fn("VcsDriverRegistry.detect")(125 const detect: VcsDriverRegistry["Service"]["detect"] = Effect.fn("VcsDriverRegistry.detect")(
127 function* (input) {126 function* (input) {
128 const requestedKind = yield* projectConfig.resolveKind(input);127 const requestedKind = yield* projectConfig.resolveKind(input);
129 return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind }));128 return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind }));
130 },129 },
131 );130 );
132131
133 const resolve: VcsDriverRegistryShape["resolve"] = Effect.fn("VcsDriverRegistry.resolve")(132 const resolve: VcsDriverRegistry["Service"]["resolve"] = Effect.fn("VcsDriverRegistry.resolve")(
134 function* (input) {133 function* (input) {
135 const detected = yield* detect(input);134 const detected = yield* detect(input);
136 if (detected) {135 if (detected) {
...@@ -155,6 +154,6 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () {...@@ -155,6 +154,6 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () {
155 });154 });
156});155});
157156
158export const layer = Layer.effect(VcsDriverRegistry, make()).pipe(157export const layer = Layer.effect(VcsDriverRegistry, make).pipe(
159 Layer.provide(VcsProjectConfig.layer),158 Layer.provide(VcsProjectConfig.layer),
160);159);
apps/server/src/vcs/VcsProcess.ts+11-12
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Match from "effect/Match";
4import { ChildProcessSpawner } from "effect/unstable/process";5import { ChildProcessSpawner } from "effect/unstable/process";
56
6import {7import {
...@@ -10,8 +11,7 @@ import {...@@ -10,8 +11,7 @@ import {
10 VcsProcessSpawnError,11 VcsProcessSpawnError,
11 VcsProcessTimeoutError,12 VcsProcessTimeoutError,
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
13import { ProcessRunner, layer as ProcessRunnerLive } from "../processRunner.ts";14import * as ProcessRunner from "../processRunner.ts";
14import * as Match from "effect/Match";
1515
16export interface VcsProcessInput {16export interface VcsProcessInput {
17 readonly operation: string;17 readonly operation: string;
...@@ -35,13 +35,12 @@ export interface VcsProcessOutput {...@@ -35,13 +35,12 @@ export interface VcsProcessOutput {
35 readonly stderrTruncated: boolean;35 readonly stderrTruncated: boolean;
36}36}
3737
38export interface VcsProcessShape {38export class VcsProcess extends Context.Service<
39 readonly run: (input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>;39 VcsProcess,
40}40 {
4141 readonly run: (input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>;
42export class VcsProcess extends Context.Service<VcsProcess, VcsProcessShape>()(42 }
43 "t3/vcs/VcsProcess",43>()("t3/vcs/VcsProcess") {}
44) {}
4544
46const DEFAULT_TIMEOUT_MS = 30_000;45const DEFAULT_TIMEOUT_MS = 30_000;
47const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;46const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
...@@ -51,8 +50,8 @@ function commandLabel(command: string, args: ReadonlyArray<string>): string {...@@ -51,8 +50,8 @@ function commandLabel(command: string, args: ReadonlyArray<string>): string {
51 return [command, ...args].join(" ");50 return [command, ...args].join(" ");
52}51}
5352
54export const make = Effect.fn("makeVcsProcess")(function* () {53export const make = Effect.gen(function* () {
55 const processRunner = yield* ProcessRunner;54 const processRunner = yield* ProcessRunner.ProcessRunner;
5655
57 const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) {56 const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) {
58 const label = commandLabel(input.command, input.args);57 const label = commandLabel(input.command, input.args);
...@@ -119,4 +118,4 @@ export const make = Effect.fn("makeVcsProcess")(function* () {...@@ -119,4 +118,4 @@ export const make = Effect.fn("makeVcsProcess")(function* () {
119 return VcsProcess.of({ run });118 return VcsProcess.of({ run });
120});119});
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 {...@@ -27,15 +27,14 @@ export interface VcsProjectConfigResolveInput {
27 readonly requestedKind?: VcsDriverKindType | "auto";27 readonly requestedKind?: VcsDriverKindType | "auto";
28}28}
2929
30export interface VcsProjectConfigShape {30export class VcsProjectConfig extends Context.Service<
31 readonly resolveKind: (31 VcsProjectConfig,
32 input: VcsProjectConfigResolveInput,32 {
33 ) => Effect.Effect<VcsDriverKindType | "auto">;33 readonly resolveKind: (
34}34 input: VcsProjectConfigResolveInput,
3535 ) => Effect.Effect<VcsDriverKindType | "auto">;
36export class VcsProjectConfig extends Context.Service<VcsProjectConfig, VcsProjectConfigShape>()(36 }
37 "t3/vcs/VcsProjectConfig",37>()("t3/vcs/VcsProjectConfig") {}
38) {}
3938
40function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto" {39function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto" {
41 return config.vcs?.kind ?? config.vcsKind ?? "auto";40 return config.vcs?.kind ?? config.vcsKind ?? "auto";
...@@ -44,7 +43,7 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto...@@ -44,7 +43,7 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto
44const parseConfig = (raw: string): Option.Option<ProjectVcsConfigFile> =>43const parseConfig = (raw: string): Option.Option<ProjectVcsConfigFile> =>
45 decodeProjectVcsConfigJson(raw);44 decodeProjectVcsConfigJson(raw);
4645
47export const make = Effect.fn("makeVcsProjectConfig")(function* () {46export const make = Effect.gen(function* () {
48 const fileSystem = yield* FileSystem.FileSystem;47 const fileSystem = yield* FileSystem.FileSystem;
49 const path = yield* Path.Path;48 const path = yield* Path.Path;
5049
...@@ -91,7 +90,7 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {...@@ -91,7 +90,7 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
91 return configuredKind(parsed.value);90 return configuredKind(parsed.value);
92 });91 });
9392
94 const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn(93 const resolveKind: VcsProjectConfig["Service"]["resolveKind"] = Effect.fn(
95 "VcsProjectConfig.resolveKind",94 "VcsProjectConfig.resolveKind",
96 )(function* (input) {95 )(function* (input) {
97 if (input.requestedKind !== undefined && input.requestedKind !== "auto") {96 if (input.requestedKind !== undefined && input.requestedKind !== "auto") {
...@@ -110,4 +109,4 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {...@@ -110,4 +109,4 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
110 });109 });
111});110});
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";...@@ -11,7 +11,7 @@ import * as VcsProvisioningService from "./VcsProvisioningService.ts";
1111
12const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");12const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
1313
14function makeDriver(calls: string[]): VcsDriver.VcsDriverShape {14function makeDriver(calls: string[]): VcsDriver.VcsDriver["Service"] {
15 return {15 return {
16 capabilities: {16 capabilities: {
17 kind: "git",17 kind: "git",
apps/server/src/vcs/VcsProvisioningService.ts+6-8
...@@ -10,13 +10,11 @@ import {...@@ -10,13 +10,11 @@ import {
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import * as VcsDriverRegistry from "./VcsDriverRegistry.ts";11import * as VcsDriverRegistry from "./VcsDriverRegistry.ts";
1212
13export interface VcsProvisioningServiceShape {
14 readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>;
15}
16
17export class VcsProvisioningService extends Context.Service<13export class VcsProvisioningService extends Context.Service<
18 VcsProvisioningService,14 VcsProvisioningService,
19 VcsProvisioningServiceShape15 {
16 readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>;
17 }
20>()("t3/vcs/VcsProvisioningService") {}18>()("t3/vcs/VcsProvisioningService") {}
2119
22function resolveRequestedKind(20function resolveRequestedKind(
...@@ -37,10 +35,10 @@ function resolveRequestedKind(...@@ -37,10 +35,10 @@ function resolveRequestedKind(
37 return Effect.succeed(kind);35 return Effect.succeed(kind);
38}36}
3937
40export const make = Effect.fn("makeVcsProvisioningService")(function* () {38export const make = Effect.gen(function* () {
41 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;39 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
4240
43 const initRepository: VcsProvisioningServiceShape["initRepository"] = Effect.fn(41 const initRepository: VcsProvisioningService["Service"]["initRepository"] = Effect.fn(
44 "VcsProvisioningService.initRepository",42 "VcsProvisioningService.initRepository",
45 )(function* (input) {43 )(function* (input) {
46 const kind = yield* resolveRequestedKind(input.kind);44 const kind = yield* resolveRequestedKind(input.kind);
...@@ -53,4 +51,4 @@ export const make = Effect.fn("makeVcsProvisioningService")(function* () {...@@ -53,4 +51,4 @@ export const make = Effect.fn("makeVcsProvisioningService")(function* () {
53 });51 });
54});52});
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", () => {...@@ -299,7 +299,7 @@ describe("VcsStatusBroadcaster", () => {
299 Effect.sync(() => {299 Effect.sync(() => {
300 state.remoteInvalidationCalls += 1;300 state.remoteInvalidationCalls += 1;
301 }),301 }),
302 } satisfies Partial<GitWorkflowService.GitWorkflowServiceShape>),302 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
303 ),303 ),
304 );304 );
305305
...@@ -617,7 +617,7 @@ describe("VcsStatusBroadcaster", () => {...@@ -617,7 +617,7 @@ describe("VcsStatusBroadcaster", () => {
617 Effect.sync(() => {617 Effect.sync(() => {
618 state.remoteInvalidationCalls += 1;618 state.remoteInvalidationCalls += 1;
619 }),619 }),
620 } satisfies Partial<GitWorkflowService.GitWorkflowServiceShape>),620 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
621 ),621 ),
622 );622 );
623623
apps/server/src/vcs/VcsStatusBroadcaster.ts+312-319
...@@ -65,23 +65,21 @@ export function remoteRefreshFailureDelay(...@@ -65,23 +65,21 @@ export function remoteRefreshFailureDelay(
65 return Duration.max(configuredInterval, cappedBackoff);65 return Duration.max(configuredInterval, cappedBackoff);
66}66}
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
82export class VcsStatusBroadcaster extends Context.Service<68export class VcsStatusBroadcaster extends Context.Service<
83 VcsStatusBroadcaster,69 VcsStatusBroadcaster,
84 VcsStatusBroadcasterShape70 {
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 }
85>()("t3/vcs/VcsStatusBroadcaster") {}83>()("t3/vcs/VcsStatusBroadcaster") {}
8684
87function fingerprintStatusPart(status: unknown): string {85function fingerprintStatusPart(status: unknown): string {
...@@ -94,101 +92,57 @@ const normalizeCwd = (cwd: string) =>...@@ -94,101 +92,57 @@ const normalizeCwd = (cwd: string) =>
94 Effect.orElseSucceed(() => cwd),92 Effect.orElseSucceed(() => cwd),
95 );93 );
9694
97export const layer = Layer.effect(95export const make = Effect.gen(function* () {
98 VcsStatusBroadcaster,96 const workflow = yield* GitWorkflowService.GitWorkflowService;
99 Effect.gen(function* () {97 const fs = yield* FileSystem.FileSystem;
100 const workflow = yield* GitWorkflowService.GitWorkflowService;98 const changesPubSub = yield* Effect.acquireRelease(
101 const fs = yield* FileSystem.FileSystem;99 PubSub.unbounded<VcsStatusChange>(),
102 const changesPubSub = yield* Effect.acquireRelease(100 (pubsub) => PubSub.shutdown(pubsub),
103 PubSub.unbounded<VcsStatusChange>(),101 );
104 (pubsub) => PubSub.shutdown(pubsub),102 const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
105 );103 Scope.close(scope, Exit.void),
106 const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>104 );
107 Scope.close(scope, Exit.void),105 const cacheRef = yield* Ref.make(new Map<string, CachedVcsStatus>());
108 );106 const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>());
109 const cacheRef = yield* Ref.make(new Map<string, CachedVcsStatus>());
110 const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>());
111107
112 const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* (108 const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* (
113 cwd: string,109 cwd: string,
114 ) {110 ) {
115 return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null));111 return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null));
116 });112 });
117113
118 const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")(114 const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")(
119 function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) {115 function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) {
120 const nextLocal = {116 const nextLocal = {
121 fingerprint: fingerprintStatusPart(local),117 fingerprint: fingerprintStatusPart(local),
122 value: local,118 value: local,
123 } satisfies CachedValue<VcsStatusLocalResult>;119 } satisfies CachedValue<VcsStatusLocalResult>;
124 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {120 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
125 const previous = cache.get(cwd) ?? { local: null, remote: null };121 const previous = cache.get(cwd) ?? { local: null, remote: null };
126 const nextCache = new Map(cache);122 const nextCache = new Map(cache);
127 nextCache.set(cwd, {123 nextCache.set(cwd, {
128 ...previous,124 ...previous,
129 local: nextLocal,125 local: nextLocal,
130 });
131 return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const;
132 });126 });
127 return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const;
128 });
133129
134 if (options?.publish && shouldPublish) {130 if (options?.publish && shouldPublish) {
135 yield* PubSub.publish(changesPubSub, {131 yield* PubSub.publish(changesPubSub, {
136 cwd,132 cwd,
137 event: {133 event: {
138 _tag: "localUpdated",134 _tag: "localUpdated",
139 local,135 local,
140 },136 },
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;
166 });137 });
138 }
167139
168 if (options?.publish && shouldPublish) {140 return local;
169 yield* PubSub.publish(changesPubSub, {141 },
170 cwd,142 );
171 event: {
172 _tag: "remoteUpdated",
173 remote,
174 },
175 });
176 }
177
178 return remote;
179 },
180 );
181143
182 const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* (144 const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")(
183 cwd: string,145 function* (cwd: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) {
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>;
192 const nextRemote = {146 const nextRemote = {
193 fingerprint: fingerprintStatusPart(remote),147 fingerprint: fingerprintStatusPart(remote),
194 value: remote,148 value: remote,
...@@ -197,263 +151,302 @@ export const layer = Layer.effect(...@@ -197,263 +151,302 @@ export const layer = Layer.effect(
197 const previous = cache.get(cwd) ?? { local: null, remote: null };151 const previous = cache.get(cwd) ?? { local: null, remote: null };
198 const nextCache = new Map(cache);152 const nextCache = new Map(cache);
199 nextCache.set(cwd, {153 nextCache.set(cwd, {
200 local: nextLocal,154 ...previous,
201 remote: nextRemote,155 remote: nextRemote,
202 });156 });
203 return [157 return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const;
204 previous.local?.fingerprint !== nextLocal.fingerprint ||
205 previous.remote?.fingerprint !== nextRemote.fingerprint,
206 nextCache,
207 ] as const;
208 });158 });
209159
210 if (options?.publish && shouldPublish) {160 if (options?.publish && shouldPublish) {
211 yield* PubSub.publish(changesPubSub, {161 yield* PubSub.publish(changesPubSub, {
212 cwd,162 cwd,
213 event: {163 event: {
214 _tag: "snapshot",164 _tag: "remoteUpdated",
215 local,
216 remote,165 remote,
217 },166 },
218 });167 });
219 }168 }
220169
221 return mergeGitStatusParts(local, remote);170 return remote;
222 });171 },
172 );
223173
224 const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* (174 const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* (
225 cwd: string,175 cwd: string,
226 ) {176 local: VcsStatusLocalResult,
227 const local = yield* workflow.localStatus({ cwd });177 remote: VcsStatusRemoteResult | null,
228 return yield* updateCachedLocalStatus(cwd, local);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;
229 });200 });
230201
231 const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* (202 if (options?.publish && shouldPublish) {
232 cwd: string,203 yield* PubSub.publish(changesPubSub, {
233 ) {204 cwd,
234 const cached = yield* getCachedStatus(cwd);205 event: {
235 if (cached?.local) {206 _tag: "snapshot",
236 return cached.local.value;207 local,
237 }208 remote,
238 return yield* loadLocalStatus(cwd);209 },
239 });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(216 const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* (
244 "VcsStatusBroadcaster.getStatus",217 cwd: string,
245 )(function* (input) {218 ) {
246 const cwd = yield* withFileSystem(normalizeCwd(input.cwd));219 const local = yield* workflow.localStatus({ cwd });
247 const cached = yield* getCachedStatus(cwd);220 return yield* updateCachedLocalStatus(cwd, local);
248 if (cached?.local && cached.remote) {221 });
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 });
260222
261 const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")(223 const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* (
262 function* (cwd: string) {224 cwd: string,
263 yield* workflow.invalidateLocalStatus(cwd);225 ) {
264 const local = yield* workflow.localStatus({ cwd });226 const cached = yield* getCachedStatus(cwd);
265 return yield* updateCachedLocalStatus(cwd, local, { publish: true });227 if (cached?.local) {
266 },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" },
267 );249 );
250 return yield* updateCachedStatus(cwd, local, remote);
251 });
268252
269 const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn(253 const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")(
270 "VcsStatusBroadcaster.refreshLocalStatus",254 function* (cwd: string) {
271 )(function* (rawCwd) {255 yield* workflow.invalidateLocalStatus(cwd);
272 const cwd = yield* withFileSystem(normalizeCwd(rawCwd));256 const local = yield* workflow.localStatus({ cwd });
273 return yield* refreshLocalStatusCore(cwd);257 return yield* updateCachedLocalStatus(cwd, local, { publish: true });
274 });258 },
259 );
275260
276 const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* (261 const refreshLocalStatus: VcsStatusBroadcaster["Service"]["refreshLocalStatus"] = Effect.fn(
277 cwd: string,262 "VcsStatusBroadcaster.refreshLocalStatus",
278 options?: { readonly refreshUpstream?: boolean },263 )(function* (rawCwd) {
279 ) {264 const cwd = yield* withFileSystem(normalizeCwd(rawCwd));
280 if (options?.refreshUpstream !== false) {265 return yield* refreshLocalStatusCore(cwd);
281 yield* workflow.invalidateRemoteStatus(cwd);266 });
282 }
283 const remote = yield* workflow.remoteStatus({ cwd }, options);
284 return yield* updateCachedRemoteStatus(cwd, remote, { publish: true });
285 });
286267
287 const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn(268 const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* (
288 "VcsStatusBroadcaster.refreshStatus",269 cwd: string,
289 )(function* (rawCwd) {270 options?: { readonly refreshUpstream?: boolean },
290 const cwd = yield* withFileSystem(normalizeCwd(rawCwd));271 ) {
291 yield* Effect.all(272 if (options?.refreshUpstream !== false) {
292 [workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)],273 yield* workflow.invalidateRemoteStatus(cwd);
293 { concurrency: "unbounded", discard: true },274 }
294 );275 const remote = yield* workflow.remoteStatus({ cwd }, options);
295 const [local, remote] = yield* Effect.all(276 return yield* updateCachedRemoteStatus(cwd, remote, { publish: true });
296 [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })],277 });
297 { concurrency: "unbounded" },278
298 );279 const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn(
299 return yield* updateCachedStatus(cwd, local, remote, { publish: true });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,
300 });286 });
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 = (294 const makeRemoteRefreshLoop = (
303 cwd: string,295 cwd: string,
304 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,296 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
305 refreshImmediately: boolean,297 refreshImmediately: boolean,
306 ) => {298 ) => {
307 return Effect.gen(function* () {299 return Effect.gen(function* () {
308 const consecutiveFailuresRef = yield* Ref.make(0);300 const consecutiveFailuresRef = yield* Ref.make(0);
309 const needsInitialRefreshRef = yield* Ref.make(refreshImmediately);301 const needsInitialRefreshRef = yield* Ref.make(refreshImmediately);
310 const refreshRemoteStatusIfEnabled = Effect.gen(function* () {302 const refreshRemoteStatusIfEnabled = Effect.gen(function* () {
311 const configuredInterval = yield* automaticRemoteRefreshInterval;303 const configuredInterval = yield* automaticRemoteRefreshInterval;
312 const activeInterval = Duration.isZero(configuredInterval)304 const activeInterval = Duration.isZero(configuredInterval)
313 ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL305 ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL
314 : configuredInterval;306 : configuredInterval;
315 const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef);307 const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef);
316 if (Duration.isZero(configuredInterval) && !needsInitialRefresh) {308 if (Duration.isZero(configuredInterval) && !needsInitialRefresh) {
317 return activeInterval;309 return activeInterval;
318 }310 }
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 });
342311
343 if (!refreshImmediately) {312 const exit = yield* refreshRemoteStatus(cwd, {
344 const configuredInterval = yield* automaticRemoteRefreshInterval;313 refreshUpstream: !Duration.isZero(configuredInterval),
345 yield* Effect.sleep(314 }).pipe(Effect.exit);
346 Duration.isZero(configuredInterval)315 if (Exit.isSuccess(exit)) {
347 ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL316 yield* Ref.set(needsInitialRefreshRef, false);
348 : configuredInterval,317 yield* Ref.set(consecutiveFailuresRef, 0);
349 );318 return activeInterval;
350 }319 }
351320
352 return yield* refreshRemoteStatusIfEnabled.pipe(321 const consecutiveFailures = yield* Ref.updateAndGet(
353 Effect.repeat(322 consecutiveFailuresRef,
354 Schedule.identity<Duration.Duration>().pipe(323 (count) => count + 1,
355 Schedule.addDelay((delay) => Effect.succeed(delay)),
356 ),
357 ),
358 Effect.asVoid,
359 );324 );
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;
360 });333 });
361 };
362334
363 const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* (335 if (!refreshImmediately) {
364 cwd: string,336 const configuredInterval = yield* automaticRemoteRefreshInterval;
365 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,337 yield* Effect.sleep(
366 refreshImmediately: boolean,338 Duration.isZero(configuredInterval)
367 ) {339 ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL
368 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {340 : configuredInterval,
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 }),
389 );341 );
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 );
391 });352 });
353 };
392354
393 const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* (355 const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* (
394 cwd: string,356 cwd: string,
395 ) {357 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
396 const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => {358 refreshImmediately: boolean,
397 const existing = activePollers.get(cwd);359 ) {
398 if (!existing) {360 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
399 return [null, activePollers] as const;361 const existing = activePollers.get(cwd);
400 }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) => {
403 const nextPollers = new Map(activePollers);374 const nextPollers = new Map(activePollers);
404 nextPollers.set(cwd, {375 nextPollers.set(cwd, {
405 ...existing,376 fiber,
406 subscriberCount: existing.subscriberCount - 1,377 subscriberCount: 1,
407 });378 });
408 return [null, nextPollers] as const;379 return [undefined, nextPollers] as const;
409 }380 }),
381 );
382 });
383 });
410384
411 const nextPollers = new Map(activePollers);385 const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* (
412 nextPollers.delete(cwd);386 cwd: string,
413 return [existing.fiber, nextPollers] as const;387 ) {
414 });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) {394 if (existing.subscriberCount > 1) {
417 yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore);395 const nextPollers = new Map(activePollers);
396 nextPollers.set(cwd, {
397 ...existing,
398 subscriberCount: existing.subscriberCount - 1,
399 });
400 return [null, nextPollers] as const;
418 }401 }
402
403 const nextPollers = new Map(activePollers);
404 nextPollers.delete(cwd);
405 return [existing.fiber, nextPollers] as const;
419 });406 });
420407
421 const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input, options) =>408 if (pollerToInterrupt) {
422 Stream.unwrap(409 yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore);
423 Effect.gen(function* () {410 }
424 const cwd = yield* withFileSystem(normalizeCwd(input.cwd));411 });
425 const subscription = yield* PubSub.subscribe(changesPubSub);412
426 const initialLocal = yield* getOrLoadLocalStatus(cwd);413 const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) =>
427 const cachedStatus = yield* getCachedStatus(cwd);414 Stream.unwrap(
428 const initialRemote = cachedStatus?.remote?.value ?? null;415 Effect.gen(function* () {
429 yield* retainRemotePoller(416 const cwd = yield* withFileSystem(normalizeCwd(input.cwd));
430 cwd,417 const subscription = yield* PubSub.subscribe(changesPubSub);
431 options?.automaticRemoteRefreshInterval ??418 const initialLocal = yield* getOrLoadLocalStatus(cwd);
432 Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL),419 const cachedStatus = yield* getCachedStatus(cwd);
433 cachedStatus?.remote === null || cachedStatus?.remote === undefined,420 const initialRemote = cachedStatus?.remote?.value ?? null;
434 );421 yield* retainRemotePoller(
435422 cwd,
436 const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid);423 options?.automaticRemoteRefreshInterval ??
437424 Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL),
438 return Stream.concat(425 cachedStatus?.remote === null || cachedStatus?.remote === undefined,
439 Stream.make({426 );
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 );
451427
452 return VcsStatusBroadcaster.of({428 const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid);
453 getStatus,429
454 refreshLocalStatus,430 return Stream.concat(
455 refreshStatus,431 Stream.make({
456 streamStatus,432 _tag: "snapshot" as const,
457 });433 local: initialLocal,
458 }),434 remote: initialRemote,
459);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";...@@ -92,7 +92,7 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
92import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";92import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
93import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";93import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
94import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";94import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
95import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts";95import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";
96import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";96import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
97import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";97import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
98import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";98import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
...@@ -278,7 +278,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -278,7 +278,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
278 yield* RepositoryIdentityResolver.RepositoryIdentityResolver;278 yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
279 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;279 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
280 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;280 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
281 const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery;281 const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery;
282 const automaticGitFetchInterval = serverSettings.getSettings.pipe(282 const automaticGitFetchInterval = serverSettings.getSettings.pipe(
283 Effect.map((settings) => settings.automaticGitFetchInterval),283 Effect.map((settings) => settings.automaticGitFetchInterval),
284 Effect.catch((cause) =>284 Effect.catch((cause) =>
...@@ -1669,7 +1669,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -1669,7 +1669,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
1669 Layer.provide(PreviewAutomationBroker.layer),1669 Layer.provide(PreviewAutomationBroker.layer),
1670 Layer.provide(ProviderMaintenanceRunner.layer),1670 Layer.provide(ProviderMaintenanceRunner.layer),
1671 Layer.provide(1671 Layer.provide(
1672 SourceControlDiscoveryLayer.layer.pipe(1672 SourceControlDiscovery.layer.pipe(
1673 Layer.provide(1673 Layer.provide(
1674 SourceControlProviderRegistry.layer.pipe(1674 SourceControlProviderRegistry.layer.pipe(
1675 Layer.provide(1675 Layer.provide(