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