diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index aa7ccd9058d52412080cb695e6dff95fc8568e87..2f58bae6c4d0e698cc3f0ee793f25c6661333215 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { MaterialListRow } from "../../components/MaterialListRow"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; @@ -812,7 +813,7 @@ export function AddProjectRepositoryScreen(props: { const result = await lookupRepositoryQuery({ environmentId: environment.environmentId, input: { - provider, + provider: SourceControlProviderKind.make(provider), repository: repositoryInput.trim(), }, }); @@ -1079,7 +1080,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s environmentId: environment.environmentId, input: { cwd: workspaceRoot, - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot), visibility: "private", }, diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index 75ec6743ac59fa7f31f385ed2d6953429e9e09d4..a1f05736907d63580f92a847974a7425155017f1 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { ProjectId, type ThreadPullRequestLink, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -28,7 +29,7 @@ describe("presentThreadPr", () => { it("uses merge-request terminology for GitLab", () => { expect( presentThreadPr(pullRequest, { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), name: "GitLab", baseUrl: "https://gitlab.com", }), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 79053fbb65eb01e77ec77e06da627e7f140f2e92..cd176d8f938a46812321c36b368307a729a6c98c 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -410,7 +411,13 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { const ghCalls: string[] = []; const fail = (cwd: string, detail: string, cause?: unknown, operation = "fakeGh") => - new SourceControlProviderFailure({ provider: "github", operation, cwd, detail, cause }); + new SourceControlProviderFailure({ + provider: SourceControlProviderKind.make("github"), + operation, + cwd, + detail, + cause, + }); /** `operation` is the provider method the call answers, which a failure reports. */ const execute = (input: { @@ -534,7 +541,7 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { /** What GitHubSourceControlProvider makes of a pull request it read. */ const toChangeRequest = (summary: FakePullRequestSummary): ChangeRequest => ({ ...summary, - provider: "github", + provider: SourceControlProviderKind.make("github"), state: summary.state ?? "open", closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, @@ -546,7 +553,7 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { return { service: { - kind: "github", + kind: SourceControlProviderKind.make("github"), // The GitHub provider's own lookup rule and template convention, which GitManager reads // instead of the kind. headBranchProbe: ({ headSelectors }) => ({ @@ -602,7 +609,10 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { if (raw.length === 0) return []; const decoded = decodeGitHubPullRequestListJson(raw); return Result.isSuccess(decoded) - ? decoded.success.map((record) => ({ provider: "github" as const, ...record })) + ? decoded.success.map((record) => ({ + provider: SourceControlProviderKind.make("github"), + ...record, + })) : []; }), ), @@ -2841,7 +2851,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { operation: "lookupStatusPr", branch: "feature/status-rate-limited", errorTag: "SourceControlProviderError", - provider: "github", + provider: SourceControlProviderKind.make("github"), providerOperation: "listChangeRequests", errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.", }); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 0eee415f19f9e154dbd97c1bf5784c5c1d5b8a60..e2ae3109b2fe84da94296fed389b5aa67c4ef6fa 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -1787,7 +1788,9 @@ export const make = Effect.gen(function* () { ) { const terms = yield* sourceControlProvider(cwd).pipe( Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), - Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")), + Effect.orElseSucceed(() => + getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")), + ), ); const summary = summarizeGitActionResult(result, terms); let latestOpenPr: PullRequestInfo | null = null; @@ -2853,7 +2856,9 @@ export const make = Effect.gen(function* () { const changeRequestTerms = wantsPr ? yield* sourceControlProvider(input.cwd).pipe( Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), - Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")), + Effect.orElseSucceed(() => + getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")), + ), ) : null; diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 19e67f949b611104f5517c23fb8192c59dc2a4e4..25a643a9655ca98cab38e31bbc290202489767f4 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Stream from "effect/Stream"; import { EventId, @@ -148,7 +149,7 @@ function makeSummary( overrides: Partial = {}, ): PullRequestSummary { return { - provider: "github", + provider: SourceControlProviderKind.make("github"), projectId: input.projectId, repository: input.repository, number: input.number, @@ -1009,7 +1010,7 @@ describe("PullRequestSyncReactor", () => { Effect.gen(function* () { if (input.host === "github.com" && (yield* Clock.currentTimeMillis) < retryAt) { const paused = new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequestSummary", reason: "rate-limited", detail: "paused", @@ -1079,7 +1080,7 @@ describe("PullRequestSyncReactor", () => { operation: "stack", detail: "paused", cause: new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequestStack", reason: "rate-limited", detail: "paused", diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 00daff5a8c87b74675f349456fb9acbb80a90d88..ccf74f7d193e6dead28956571e9608ad4982070e 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import { @@ -472,7 +473,7 @@ function makePullRequestSummary(input: { readonly updatedAt?: string; }): PullRequestSummary { return { - provider: "github", + provider: SourceControlProviderKind.make("github"), projectId: input.projectId, repository: input.repository, number: input.number, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 42c47bba51c02ddd7c76b921ec12bca3fdc3f83f..4d3ff429f0c6669ac4aafe4f6f42efe04721b0c8 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -169,7 +170,7 @@ const watchedPullRequestDetail = (input: { readonly number: number; readonly at: string; }): PullRequestDetail => ({ - provider: "github", + provider: SourceControlProviderKind.make("github"), capabilities: { diff: true, comment: true, @@ -2572,7 +2573,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { operation: "getChangeRequest", detail: "github requests are paused until the rate limit resets", cause: new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequest", reason: "rate-limited", detail: "paused", @@ -2831,7 +2832,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { operation: "watchFingerprint", detail: "paused", cause: new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequestWatchFingerprint", reason: "rate-limited", detail: "paused", diff --git a/apps/server/src/project/ProjectCloneTracker.test.ts b/apps/server/src/project/ProjectCloneTracker.test.ts index d26a2ed712ac14797834339723d830c0b59801ba..d327ca4436f08df1f2839c5c01a5805a0a9a50d4 100644 --- a/apps/server/src/project/ProjectCloneTracker.test.ts +++ b/apps/server/src/project/ProjectCloneTracker.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import { OrchestrationDispatchCommandError, @@ -116,7 +117,7 @@ describe("ProjectCloneTracker", () => { ? Effect.fail( new SourceControlRepositoryError({ operation: "cloneRepository", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "fatal: repository not found", }), ) @@ -255,7 +256,7 @@ describe("ProjectCloneTracker", () => { Effect.fail( new SourceControlRepositoryError({ operation: "cloneRepository", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "Destination path already exists and is not empty.", }), ), diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts index 98c7b348d299fd25e90d47a3af4751ed7b4cb7c5..092a0376942be334939d4e9d0b251ab486ecc509 100644 --- a/apps/server/src/project/ProjectCloneTracker.ts +++ b/apps/server/src/project/ProjectCloneTracker.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import type { ProjectCloneSnapshot, ProjectCloneStage, @@ -301,7 +302,7 @@ export const make = Effect.gen(function* () { if (!claimed) { return yield* new SourceControlRepositoryError({ operation: "cloneRepository", - provider: input.provider ?? "unknown", + provider: input.provider ?? SourceControlProviderKind.make("unknown"), detail: "A clone into this destination is already in progress.", }); } diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index ef5eaf4389dd854e2b124151973beac318875f00..404b3c5b5b656c3e038da2be7ce659bdd514346a 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; // @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -71,7 +72,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { if (refinementFails) return Effect.fail( new SourceControlProviderError({ - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), operation: "detectProvider", cwd: rootPath, detail: "account unavailable", diff --git a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts index 8271c2f22dc07b0e1a89be4180d7fc9b944e24ca..38969188dcd2dc761e77289ff006b096353600fb 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; @@ -59,7 +60,7 @@ it("keeps GitHub's exact retry time", () => { assert.deepStrictEqual( gitHubProviderFailure( new SourceControlRateLimit.SourceControlRateLimitPausedError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), host: "github.com", retryAt: 1_786_802_400_000, }), diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 117fd3f69a3291d4d5fbaf5e7e59ffee72939dd6..cb544d4898acc4eb5043492977d19068ec275069 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; @@ -17,7 +18,6 @@ import type { ProjectId, PullRequestReviewCapabilities, PullRequestReviewerCapabilities, - SourceControlProviderKind, } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; import type { ServerSettings } from "@t3tools/contracts"; @@ -129,7 +129,7 @@ it.effect("caches narrow previews and invalidates them after refresh or mutation const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.die("preview must not read the viewer"), getChangeRequestPreview: () => Effect.sync(() => { @@ -175,13 +175,13 @@ it.effect("keeps cached previews available and pauses uncached previews until qu const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestPreview: ({ number }) => Effect.gen(function* () { reads++; if (reads === 2) return yield* new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequestPreview", reason: "rate-limited", detail: "GitHub requests are paused until the rate limit resets.", @@ -218,7 +218,7 @@ it.effect("reuses only unexpired detail for previews", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), getChangeRequestPreview: () => Effect.sync(() => { @@ -250,7 +250,7 @@ it.effect("shares detail and activity for a minute, and for ten once merged", () const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: ({ number }) => Effect.sync(() => { reads.push(`detail #${number}`); @@ -314,7 +314,7 @@ it.effect("does not wait for an in-flight detail read to display a preview", () const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Deferred.succeed(detailStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseDetail)), @@ -342,7 +342,7 @@ it.effect("keeps previews warm when another project finishes a turn", () => project({ id: "p2", title: "docs", workspaceRoot: "/d", repository: "acme/docs" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestPreview: (input) => Effect.sync(() => { reads.push(input.repository); @@ -374,11 +374,11 @@ it.effect("uses full detail for hosts without a narrow preview", () => title: "web", workspaceRoot: "/w", repository: "acme/web", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { getChangeRequest: () => Effect.sync(() => { reads += 1; @@ -405,7 +405,7 @@ function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | } const requestFailed = new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "listChangeRequests", reason: "failed", detail: "HTTP 404", @@ -530,12 +530,12 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos title: "enterprise", workspaceRoot: "/repo", repository: "team/project", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), host: "code.example.test", }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { /** Supplies a PR only after verifying discovery retained the Enterprise repository target. */ listChangeRequests: ({ host, repository }) => { assert.strictEqual(host, "code.example.test"); @@ -554,7 +554,11 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos return Effect.succeed({ context: { ...context, - provider: { ...context.provider, kind: "github", name: "GitHub Self-Hosted" }, + provider: { + ...context.provider, + kind: SourceControlProviderKind.make("github"), + name: "GitHub Self-Hosted", + }, }, provider: undefined as never, }); @@ -576,7 +580,7 @@ it.effect("refines unknown self-hosted GitLab projects before listing merge requ title: "self-hosted", workspaceRoot: "/gitlab", repository: "group/project", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), host: "code.example.test", }); const service = yield* makeService({ @@ -584,12 +588,15 @@ it.effect("refines unknown self-hosted GitLab projects before listing merge requ selfHosted, { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/gitlab-worktree" }, ], - providers: [fakeProvider("gitlab")], + providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], resolveHandle: ({ context }) => { refinementCalls += 1; assert.strictEqual(context?.remoteUrl, "https://code.example.test/group/project.git"); return Effect.succeed({ - context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } }, + context: { + ...context!, + provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") }, + }, provider: undefined as never, }); }, @@ -610,7 +617,7 @@ it.effect("derives a legacy repository host after refining its provider", () => title: "legacy self-hosted", workspaceRoot: "/gitlab", repository: "group/project", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), host: "code.example.test", }); const identity = current.repositoryIdentity!; @@ -625,10 +632,13 @@ it.effect("derives a legacy repository host after refining its provider", () => } as unknown as OrchestrationProjectShell; const service = yield* makeService({ projects: [legacy], - providers: [fakeProvider("gitlab")], + providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], resolveHandle: ({ context }) => Effect.succeed({ - context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } }, + context: { + ...context!, + provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") }, + }, provider: undefined as never, }), }); @@ -648,18 +658,21 @@ it.effect("tries another checkout when provider refinement remains unknown", () title: "self-hosted", workspaceRoot: "/gone", repository: "group/project", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), host: "code.example.test", }); const service = yield* makeService({ projects: [selfHosted, { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/healthy" }], - providers: [fakeProvider("gitlab")], + providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], resolveHandle: ({ cwd, context }) => { asked.push(cwd); return cwd === "/gone" ? Effect.succeed({ context: context!, provider: undefined as never }) : Effect.succeed({ - context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } }, + context: { + ...context!, + provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") }, + }, provider: undefined as never, }); }, @@ -689,11 +702,11 @@ it.effect("reads nothing from a host with no implementation, but reports it", () title: "on gitlab", workspaceRoot: "/c", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => { listed.push(input.repository); return Effect.succeed({ @@ -718,8 +731,8 @@ it.effect("reads nothing from a host with no implementation, but reports it", () projectCount: summary.projectCount, })), [ - { kind: "github", configured: true, projectCount: 1 }, - { kind: "gitlab", configured: false, projectCount: 1 }, + { kind: SourceControlProviderKind.make("github"), configured: true, projectCount: 1 }, + { kind: SourceControlProviderKind.make("gitlab"), configured: false, projectCount: 1 }, ], ); }), @@ -733,7 +746,7 @@ it.effect("asks for a whole page of a host, and for the reader's own size when g project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => { limits.push(input.limit); return Effect.succeed({ items: [], truncated: false, continues: true }); @@ -759,7 +772,7 @@ it.effect("says where each repository carries on, and from nothing it has run ou project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => Effect.succeed({ items: [changeRequest(1, "2026-07-02T00:00:00Z")], @@ -787,7 +800,7 @@ it.effect("offers no continuation for a host that cannot be carried on from", () project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(1, "2026-07-02T00:00:00Z")], @@ -815,12 +828,12 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" title: "web", workspaceRoot: "/a", repository: "acme/web", - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", }), ], providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(7, "2026-07-02T00:00:00Z")], @@ -851,7 +864,7 @@ it.effect("reads only the repositories it was asked to carry on with", () => project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => { listed.push(input.repository); cursors.push(input.cursor); @@ -882,7 +895,7 @@ it.effect("keeps a row already sent at the boundary instant from arriving twice" project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { // The boundary instant is asked for inclusively, so the host hands back the rows // already sent at it alongside the ones beside them — which a strictly-older read // would have lost instead. @@ -922,7 +935,7 @@ it.effect("keeps the earlier exclusions when a slice ends on the instant it bega project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -960,7 +973,9 @@ it.effect("refuses a continuation it did not issue, before asking any host anyth project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { listChangeRequests: () => Effect.die("should not be read") }), + fakeProvider(SourceControlProviderKind.make("github"), { + listChangeRequests: () => Effect.die("should not be read"), + }), ], }); @@ -983,11 +998,11 @@ it.effect("calls a transient viewer failure a failed operation, not a signed-out project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getViewer", reason: "failed", detail: "HTTP 500", @@ -1014,23 +1029,24 @@ it.effect("reports an unusable host over a merely failing one", () => title: "on gitlab", workspaceRoot: "/c", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getViewer", reason: "failed", detail: "HTTP 500", }), ), }), - fakeProvider("gitlab", { - getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")), + fakeProvider(SourceControlProviderKind.make("gitlab"), { + getViewer: () => + Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")), }), ], }); @@ -1052,11 +1068,11 @@ it.effect("lists every host that has an implementation", () => title: "on gitlab", workspaceRoot: "/b", repository: "group/sub/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(1, "2026-07-01T00:00:00Z")], @@ -1064,7 +1080,7 @@ it.effect("lists every host that has an implementation", () => continues: true, }), }), - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: (input) => // Nested groups need the full path, not the last two segments. input.repository === "group/sub/project" @@ -1100,12 +1116,14 @@ it.effect("narrows the listing to one host when asked", () => title: "on gitlab", workspaceRoot: "/b", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { listChangeRequests: () => Effect.die("should not be read") }), - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("github"), { + listChangeRequests: () => Effect.die("should not be read"), + }), + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(2, "2026-07-05T00:00:00Z")], @@ -1139,7 +1157,7 @@ it.effect("tells two hosts of one kind apart in the switcher and the filter", () }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ host }) => Effect.succeed({ items: host === "ghe.example.com" ? [changeRequest(2, "2026-07-05T00:00:00Z")] : [], @@ -1179,11 +1197,11 @@ it.effect("keeps one host listed when another is not set up", () => title: "on gitlab", workspaceRoot: "/b", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(1, "2026-07-01T00:00:00Z")], @@ -1191,8 +1209,9 @@ it.effect("keeps one host listed when another is not set up", () => continues: true, }), }), - fakeProvider("gitlab", { - getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")), + fakeProvider(SourceControlProviderKind.make("gitlab"), { + getViewer: () => + Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")), }), ], }); @@ -1220,8 +1239,9 @@ it.effect("fails as unavailable only when no host can be read", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { - getViewer: () => Effect.fail(unusable("github", "missing-tool")), + fakeProvider(SourceControlProviderKind.make("github"), { + getViewer: () => + Effect.fail(unusable(SourceControlProviderKind.make("github"), "missing-tool")), }), ], }); @@ -1250,7 +1270,7 @@ it.effect("reads a repository once when several worktrees share it", () => }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => { calls += 1; return Effect.succeed({ @@ -1278,7 +1298,7 @@ it.effect("keeps healthy repositories when one of them cannot be read", () => project({ id: "p2", title: "broken", workspaceRoot: "/b", repository: "pingdotgg/broken" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => input.repository === "pingdotgg/broken" ? Effect.fail(requestFailed) @@ -1309,11 +1329,11 @@ it.effect("tries another workspace on the same host for the viewer", () => project({ id: "p2", title: "healthy", workspaceRoot: "/healthy", repository: "acme/two" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: (input) => input.cwd === "/healthy" ? Effect.succeed("bilal") - : Effect.fail(unusable("github", "missing-tool")), + : Effect.fail(unusable(SourceControlProviderKind.make("github"), "missing-tool")), listChangeRequests: () => Effect.succeed({ items: [changeRequest(1, "2026-07-02T00:00:00Z")], @@ -1345,7 +1365,7 @@ it.effect("routing verifies the current account on the requested host without ca }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getRoutingIdentity: (input) => { assert.deepStrictEqual(input, { cwd: "/a", host: "github.example.test" }); return Effect.succeed({ @@ -1359,7 +1379,7 @@ it.effect("routing verifies the current account on the requested host without ca const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; assert.deepStrictEqual(yield* service.routing(ref), { host: "github.example.test", - provider: "github", + provider: SourceControlProviderKind.make("github"), viewer: "first-account", accountId: "123", projectTitle: "web", @@ -1382,7 +1402,7 @@ it.effect("refuses an action the host never claimed it could run", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -1428,12 +1448,12 @@ it.effect("publishes a merge for immediate settlement only after host confirmati project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => confirmationFails ? Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequestSummary", reason: "failed", detail: "HTTP 504", @@ -1486,7 +1506,7 @@ it.effect("refreshes every reader before a queued merge confirmation finishes", project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.gen(function* () { yield* Deferred.succeed(confirmationStarted, undefined); @@ -1528,7 +1548,7 @@ it.effect("refuses an action this viewer may not take, and says what access it t const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { // The host merges; this account only reads it, and opened the change request — which // is every contributor to a repository they do not own. getViewerPermissions: () => @@ -1565,7 +1585,7 @@ it.effect("gates arming a merge for later exactly as it gates merging now", () = const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -1620,7 +1640,7 @@ it.effect("hands the host the strategy an armed merge was asked for", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -1666,7 +1686,7 @@ it.effect("refuses an auto-merge the host never claimed, without asking it", () projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ // Bitbucket's shape: it merges, and has nothing that merges later on its own. - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { runAction: () => { ran = true; return Effect.void; @@ -1694,7 +1714,7 @@ it.effect("refuses to resolve a conversation this viewer may not, without asking const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewerPermissions: () => Effect.succeed({ actions: ["merge", "ready", "draft", "close", "reopen"], @@ -1729,7 +1749,7 @@ it.effect("asks nobody what the viewer may do when the host cannot do it at all" const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -1768,7 +1788,7 @@ it.effect("refuses a comment on a host that cannot post one", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: false, comment: false, @@ -1817,7 +1837,7 @@ it.effect("keeps two hosts of one provider kind as two accounts", () => }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"), listChangeRequests: () => Effect.succeed({ @@ -1858,11 +1878,11 @@ it.effect("reports repositories on a host that could not be read", () => }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: (input) => input.cwd === "/cloud" ? Effect.succeed("bilal") - : Effect.fail(unusable("github", "unauthenticated")), + : Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")), listChangeRequests: () => Effect.succeed({ items: [changeRequest(1, "2026-07-02T00:00:00Z")], @@ -1893,7 +1913,7 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.sync(() => { listCalls += 1; @@ -1901,7 +1921,7 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail Effect.andThen( Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "listChangeRequests", reason: "rate-limited", detail: "GitHub API rate limit exceeded.", @@ -1933,13 +1953,14 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail }), ); -for (const [provider, host] of [ +for (const [providerName, host] of [ ["github", "github.com"], ["gitlab", "gitlab.com"], ["forgejo", "code.example.test"], ["bitbucket", "bitbucket.org"], ["azure-devops", "dev.azure.com"], ] as const) { + const provider = SourceControlProviderKind.make(providerName); it.effect.each(["detail", "checks"] as const)( `shares fresh ${provider} %s reads and respects rate-limit resets`, (read) => @@ -2006,7 +2027,7 @@ it.effect("does not fall back to full detail when checks are unsupported", () => project({ id: "p1", title: "web", workspaceRoot: "/repo", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.die("Unexpected full detail read"), }), ], @@ -2025,7 +2046,7 @@ it.effect("uses a manual rate limit to pause later reads", () => project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.sync(() => { listCalls += 1; @@ -2034,7 +2055,7 @@ it.effect("uses a manual rate limit to pause later reads", () => runAction: () => Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "runAction", reason: "rate-limited", detail: "GitHub API rate limit exceeded.", @@ -2066,7 +2087,7 @@ it.effect("flags a review request for the viewer but not on their own change req project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -2099,7 +2120,7 @@ it.effect("refuses a repository that does not belong to the requested project", projects: [ project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], - providers: [fakeProvider("github")], + providers: [fakeProvider(SourceControlProviderKind.make("github"))], }); const error = yield* service @@ -2116,7 +2137,7 @@ it.effect("caches stack membership separately from action details", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestStack: (input) => Effect.sync(() => { reads.push(input.includeDetails === true); @@ -2157,7 +2178,7 @@ it.effect("reads a host-native stack through the provider and null where it has const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestStack: () => Effect.succeed({ id: "9", @@ -2185,7 +2206,7 @@ it.effect("reads a host-native stack through the provider and null where it has const withoutStacks = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [fakeProvider("github")], + providers: [fakeProvider(SourceControlProviderKind.make("github"))], }); assert.isNull( yield* withoutStacks.stack({ @@ -2215,7 +2236,7 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after }), ], providers: [ - fakeProvider("forgejo", { + fakeProvider(SourceControlProviderKind.make("forgejo"), { getViewer: (input) => { viewers.push(input.host); assert.strictEqual(input.host, "code.example:3000"); @@ -2244,7 +2265,11 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after return Effect.succeed({ context: { ...context, - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "http://code.example:3000" }, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo", + baseUrl: "http://code.example:3000", + }, }, provider: undefined as never, }); @@ -2294,12 +2319,12 @@ it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => title: "http", workspaceRoot: "/http", repository: "team/repo", - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), host: "code.example", remoteUrl: "http://code.example:4000/team/repo.git", }), ], - providers: [fakeProvider("forgejo")], + providers: [fakeProvider(SourceControlProviderKind.make("forgejo"))], }); const failure = yield* service .summary( @@ -2327,7 +2352,7 @@ it.effect("resolves a project's repository identity when its shell has none cach const service = yield* makeService({ projects: [{ ...resolved, repositoryIdentity: null }], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")), }), ], @@ -2351,7 +2376,7 @@ it.effect("routes a hosted reference to another repository through a project on project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: (input) => Effect.sync(() => { seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); @@ -2381,12 +2406,12 @@ it.effect("routes Azure reads and writes through the requested organization's ch title: organization, workspaceRoot: `/${organization}`, repository: `${organization}/project/_git/web`, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", }), ), providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { getChangeRequestSummary: (input) => Effect.sync(() => { seen.push(`read ${input.cwd} ${input.repository}`); @@ -2434,7 +2459,7 @@ it.effect.each([ id: "target", title: "target", workspaceRoot: "/target", - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), ...checkout, }); const service = yield* makeService({ @@ -2444,7 +2469,7 @@ it.effect.each([ id: repository, title: repository, workspaceRoot: `/${repository}`, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", repository, }), @@ -2452,7 +2477,7 @@ it.effect.each([ target, ], providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { getChangeRequestSummary: (input) => Effect.sync(() => { seen.push(`read ${input.cwd} ${input.repository}`); @@ -2486,12 +2511,12 @@ it.effect("refuses Azure cross-organization reads and writes without its checkou title: "org-a", workspaceRoot: "/org-a", repository: "org-a/project/_git/web", - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", }), ], providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { getChangeRequestSummary: () => Effect.die("must not read the wrong organization"), runAction: () => Effect.die("must not modify the wrong organization"), }), @@ -2518,7 +2543,7 @@ it.effect("refuses a hosted reference when nothing is checked out from that host projects: [ project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), ], - providers: [fakeProvider("github")], + providers: [fakeProvider(SourceControlProviderKind.make("github"))], }); const error = yield* service @@ -2546,11 +2571,11 @@ it.effect("refuses a diff on a host that cannot produce one", () => title: "on azure", workspaceRoot: "/a", repository: "org/project", - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), }), ], providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { capabilities: { diff: false, comment: true, @@ -2580,7 +2605,11 @@ it.effect("rejects an empty comment before reaching the host", () => projects: [ project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], - providers: [fakeProvider("github", { comment: () => Effect.die("must not be called") })], + providers: [ + fakeProvider(SourceControlProviderKind.make("github"), { + comment: () => Effect.die("must not be called"), + }), + ], }); const error = yield* service @@ -2606,11 +2635,11 @@ it.effect("refuses a verdict the host never claimed, without asking the provider title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -2658,7 +2687,7 @@ it.effect("refuses line comments on a host that takes only a summary", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -2704,7 +2733,7 @@ it.effect( }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { submitReview: () => { approved = true; return Effect.void; @@ -2736,7 +2765,7 @@ it.effect("refuses to resolve a conversation on a host that cannot", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -2777,7 +2806,7 @@ it.effect("refuses to react on a host with no reactions", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -2814,7 +2843,7 @@ it.effect("refuses to react on a host whose capabilities omit reactions entirely project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -2855,7 +2884,7 @@ it.effect("passes a reaction through with its subject id on a host that has them project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { setReaction: (input) => { received = { subjectId: input.subjectId, @@ -2888,7 +2917,7 @@ it.effect("invalidates the cached activity after reacting, like the other mutati const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestActivity: () => { activityCalls += 1; return Effect.succeed({ @@ -2926,7 +2955,9 @@ it.effect("refuses an empty reply before it reaches the host", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { replyToThread: () => Effect.die("must not be called") }), + fakeProvider(SourceControlProviderKind.make("github"), { + replyToThread: () => Effect.die("must not be called"), + }), ], }); @@ -2952,7 +2983,7 @@ it.effect("refuses a merge strategy the host does not offer", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -3005,7 +3036,7 @@ it.effect("hands the provider the host its repository lives on", () => }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => { hosts.push(input.host); return Effect.succeed({ items: [], truncated: false, continues: true }); @@ -3037,12 +3068,12 @@ it.effect("asks every host the reader's search, rather than filtering what came title: "on gitlab", workspaceRoot: "/b", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { listChangeRequests: listing }), - fakeProvider("gitlab", { listChangeRequests: listing }), + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: listing }), + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: listing }), ], }); @@ -3062,7 +3093,7 @@ it.effect("asks for no search when the reader has typed nothing", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => { asked.push(input.query); return Effect.succeed({ items: [], truncated: false, continues: true }); @@ -3098,13 +3129,13 @@ it.effect("asks another checkout who is signed in when the first one cannot answ }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: (input) => { asked.push(input.cwd); return input.cwd === "/gone" ? Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getViewer", reason: "failed", detail: "not a git repository", @@ -3138,7 +3169,7 @@ it.effect("refuses to ask for a review on a host that cannot, before any call is const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -3179,7 +3210,7 @@ it.effect("refuses the candidate list on a host that has no such list to give", const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: false, comment: false, @@ -3215,7 +3246,7 @@ it.effect("refuses a review request this viewer may not make, and says what acce const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { // The host asks for reviews; this account only reads the repository. getViewerPermissions: () => Effect.succeed({ @@ -3254,7 +3285,7 @@ it.effect("keeps the menu from a viewer who may not ask, which is all it is for" const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewerPermissions: () => Effect.succeed({ actions: [], @@ -3286,7 +3317,7 @@ it.effect("hands the host's own candidate list back, and asks for it with the ch const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listReviewerCandidates: (input) => { askedFor = input.number; return Effect.succeed({ @@ -3328,7 +3359,7 @@ it.effect("refuses a label change on a host that has not said it takes one", () const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { // The method is there; the capability that would let it be called is not. setLabels: () => { changed = true; @@ -3360,8 +3391,11 @@ it.effect("refuses a label change this viewer may not make, and says what access const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { - capabilities: { ...fakeProvider("github").capabilities, labels: true }, + fakeProvider(SourceControlProviderKind.make("github"), { + capabilities: { + ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + labels: true, + }, getViewerPermissions: () => Effect.succeed({ actions: [], @@ -3405,8 +3439,11 @@ it.effect("hands a label change to the host, and reads the labels back for the m const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { - capabilities: { ...fakeProvider("github").capabilities, labels: true }, + fakeProvider(SourceControlProviderKind.make("github"), { + capabilities: { + ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + labels: true, + }, listLabelCandidates: () => Effect.succeed({ candidates: [{ name: "bug", color: null, description: null, isApplied: false }], @@ -3447,7 +3484,7 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => { hostCalls += 1; return Effect.succeed({ @@ -3496,7 +3533,7 @@ it.effect.each(["list", "listStats"] as const)( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => lookup.pipe( Effect.as({ @@ -3545,7 +3582,7 @@ it.effect("shares one cold viewer lookup across distinct concurrent lists", () = const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.sync(() => { viewerCalls += 1; @@ -3583,7 +3620,7 @@ it.effect("uses five host reads for the normal indexed-repository page workflow" const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.sync(() => { viewerCalls += 1; @@ -3643,7 +3680,7 @@ it.effect("returns the refreshed listing on the first read after its cache expir const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => { hostCalls += 1; return Effect.succeed({ @@ -3682,7 +3719,7 @@ it.effect("a listing narrowed to some projects is its own cache entry", () => project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: (input) => { asked.push(input.repositories); return Effect.succeed({ @@ -3725,7 +3762,7 @@ it.effect( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ filters }) => Effect.gen(function* () { reads += 1; @@ -3782,7 +3819,7 @@ it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and proj project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: (input) => Effect.sync(() => { calls.push(`${input.repository}/${input.number}`); @@ -3827,7 +3864,7 @@ it.effect( }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: (input) => Effect.sync(() => { hosts.push(input.host); @@ -3858,7 +3895,7 @@ it.effect("does not revive old summaries when project epochs are evicted", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), title }), }), ], @@ -3882,7 +3919,7 @@ it.effect("explicit and turn invalidations make the next listing ask the host ag const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: () => { viewerCalls += 1; return Effect.succeed("bilal"); @@ -3924,7 +3961,7 @@ it.effect("close and reopen notify subscribed readers after invalidating their c project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }), runAction: (input) => @@ -3976,7 +4013,7 @@ it.effect("explicit invalidation refreshes origin readers after a routed host mu project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }), }), @@ -4021,7 +4058,7 @@ it.effect("does not cache a failed listing", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { // The viewer lookup is what fails the whole listing rather than one repository. getViewer: () => { hostCalls += 1; @@ -4052,11 +4089,11 @@ it.effect("reads a host's repositories in one search, and files the rows back un title: "on gitlab", workspaceRoot: "/c", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => { separately.push(repository); return Effect.succeed({ items: [], truncated: false, continues: true }); @@ -4073,7 +4110,7 @@ it.effect("reads a host's repositories in one search, and files the rows back un }, }), // A host with no search across repositories keeps being asked one at a time. - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: ({ repository }) => { separately.push(repository); return Effect.succeed({ @@ -4111,7 +4148,7 @@ it.effect("carries every repository of a slice on from the oldest row in it", () project({ id: "p3", title: "docs", workspaceRoot: "/c", repository: "acme/docs" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: () => Effect.succeed({ items: [ @@ -4144,7 +4181,7 @@ it.effect("carries a slice on without sending the rows it already sent", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: (input) => { cursors.push(input.cursor); return Effect.succeed({ @@ -4189,7 +4226,7 @@ it.effect("reads a workspace larger than one search in chunks, and merges them", }), ), providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: (input) => { asked.push(input.repositories.length); return Effect.succeed({ @@ -4218,7 +4255,7 @@ it.effect("asks on its own for a repository a search answered nothing for", () = project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => { separately.push(repository); return repository === "acme/docs" @@ -4262,7 +4299,7 @@ it.effect("reads the repositories one at a time when the search itself fails", ( project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => { separately.push(repository); return Effect.succeed({ @@ -4295,11 +4332,11 @@ it.effect("fills in the line counts for the rows it is given", () => title: "on gitlab", workspaceRoot: "/b", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestStats: (input) => { asked.push(input.changeRequests); return Effect.succeed([ @@ -4308,7 +4345,7 @@ it.effect("fills in the line counts for the rows it is given", () => }, }), // Its listing carries the counts already, so it has nothing to be asked. - fakeProvider("gitlab"), + fakeProvider(SourceControlProviderKind.make("gitlab")), ], }); @@ -4355,7 +4392,7 @@ it.effect( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestStats: (input) => { asked.push(input.changeRequests.map((ref) => ref.number)); return Effect.succeed( @@ -4404,7 +4441,7 @@ it.effect("reads the fresh diff when detail or summary discovers a changed revis const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.sync(() => ({ ...hostedChangeRequest("body"), updatedAt: revision })), getChangeRequestSummary: () => @@ -4453,7 +4490,9 @@ it.effect("keeps the rows when the line counts cannot be read", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { listChangeRequestStats: () => Effect.fail(requestFailed) }), + fakeProvider(SourceControlProviderKind.make("github"), { + listChangeRequestStats: () => Effect.fail(requestFailed), + }), ], }); @@ -4478,7 +4517,7 @@ it.effect( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestStats: () => { statsCalls += 1; return Effect.succeed([]); @@ -4556,7 +4595,7 @@ it.effect("shares linked summaries and reuses them for display without asking th const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.sync(() => { calls += 1; @@ -4567,7 +4606,7 @@ it.effect("shares linked summaries and reuses them for display without asking th shouldFail ? Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequestSummary", reason: "failed", detail: "HTTP 504", @@ -4622,8 +4661,11 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { - capabilities: { ...fakeProvider("github").capabilities, viewedFiles: "host" }, + fakeProvider(SourceControlProviderKind.make("github"), { + capabilities: { + ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + viewedFiles: "host", + }, getFilesViewed: () => read().pipe( Effect.as({ @@ -4685,8 +4727,11 @@ it.effect("isolates routed caches for two credentials belonging to the same acco project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { - capabilities: { ...fakeProvider("github").capabilities, viewedFiles: "host" }, + fakeProvider(SourceControlProviderKind.make("github"), { + capabilities: { + ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + viewedFiles: "host", + }, getFilesViewed: () => read().pipe( Effect.as({ @@ -4744,7 +4789,7 @@ it.effect("rejects mismatched routing credentials before use and preserves actio const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getRoutingIdentity: () => Effect.succeed({ accountId: "101", viewer: "octocat" }), withVerifiedCredential: (_, use) => use({ @@ -4798,7 +4843,7 @@ it.effect("answers a known pull request immediately while the host refreshes", ( const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.gen(function* () { calls += 1; @@ -4829,7 +4874,7 @@ it.effect("does not ask the host again for a linked summary it already holds", ( const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.sync(() => { calls += 1; @@ -4858,7 +4903,7 @@ it.effect( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed({ ...changeRequest(1, "2026-07-02T00:00:00Z"), @@ -4896,7 +4941,7 @@ it.effect("reuses an observed merged state for strict settlement reads", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("merged body", 4), @@ -4927,7 +4972,7 @@ it.effect("announces state a detail read sees first or newly", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }), getChangeRequestSummary: () => Effect.succeed({ @@ -4979,7 +5024,7 @@ it.effect("announces a reported state without trusting it", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }), }), ], @@ -5024,7 +5069,7 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.gen(function* () { detailCalls += 1; @@ -5078,7 +5123,7 @@ it.effect("does not let a still-cached detail overwrite a fresher linked summary const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed(hostedChangeRequest("old body", 4)), getChangeRequestSummary: () => Effect.succeed({ @@ -5114,11 +5159,11 @@ it.effect("tells the client when the host has no pull request under that number" const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequest", reason: "not-found", detail: "Pull request not found. Check the PR number or URL and try again.", @@ -5142,12 +5187,12 @@ it.effect("keeps recent detail on a transient refresh failure but not after inva const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => failing ? Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequest", reason: "failed", detail: "spawn gh EAGAIN", @@ -5197,7 +5242,7 @@ it.effect("carries an armed auto-merge through to the detail, and silence as sil project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...changeRequest(1, "2026-07-02T00:00:00Z"), @@ -5243,13 +5288,13 @@ it.effect("narrows the rows of a host that ignored the filters it was handed", ( title: "web", workspaceRoot: "/a", repository: "acme/web", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ // Only GitHub narrows a listing for itself; every other host answers unnarrowed, and // sending it a draft filter it quietly ignores used to put drafts on a filtered page. - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -5285,11 +5330,11 @@ it.effect("keeps a row of a host that ignored the filters if any name of a label title: "web", workspaceRoot: "/a", repository: "acme/web", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -5328,13 +5373,13 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho title: "web", workspaceRoot: "/a", repository: "acme/web", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ // Only GitHub narrows a listing for itself, so this fixture's "me" has to be resolved // locally too — the same helper both call sites lean on. - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -5395,7 +5440,7 @@ it.effect.each([false, true])( }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities, getViewerPermissions: () => Effect.succeed({ @@ -5463,7 +5508,7 @@ it.effect("refuses a way of updating a branch that the host or the viewer does n const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -5516,11 +5561,11 @@ it.effect("refuses to merge a target branch into a source branch on a host that title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -5575,12 +5620,12 @@ it.effect("judges the review filter only on a host that summarises its reviews", title: "on gitlab", workspaceRoot: "/b", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ // GitHub answers with the field on every row: null is "nobody has decided yet". - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -5595,7 +5640,7 @@ it.effect("judges the review filter only on a host that summarises its reviews", }), }), // GitLab never supplies the field, so its rows are not the filter's to judge. - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(3, "2026-07-02T00:00:00Z")], @@ -5621,7 +5666,7 @@ it.effect("sends only the words a rewrite carries", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { updateChangeRequest: (input) => { received.push({ title: input.title, body: input.body }); return Effect.void; @@ -5647,7 +5692,9 @@ it.effect("refuses a rewrite that changes nothing, before any call is made", () const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { updateChangeRequest: () => Effect.die("must not be called") }), + fakeProvider(SourceControlProviderKind.make("github"), { + updateChangeRequest: () => Effect.die("must not be called"), + }), ], }); @@ -5665,7 +5712,7 @@ it.effect("refuses to rewrite anything on a host that never claimed it", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -5704,7 +5751,7 @@ it.effect("passes a rewritten remark through with the id and kind it arrived und const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { updateComment: (input) => { received = { id: input.commentId, kind: input.kind, body: input.body }; return Effect.void; @@ -5735,7 +5782,9 @@ it.effect("refuses a remark rewritten into nothing but whitespace", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { updateComment: () => Effect.die("must not be called") }), + fakeProvider(SourceControlProviderKind.make("github"), { + updateComment: () => Effect.die("must not be called"), + }), ], }); @@ -5761,7 +5810,7 @@ it.effect("forgets the cached detail after a rewrite or terminal turn", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => { coreCalls += 1; return Effect.succeed({ @@ -5813,7 +5862,7 @@ it.effect("names the signed-in account in the detail, and says nothing where the number: 1, }); }); - const readable = fakeProvider("github", { + const readable = fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...changeRequest(1, "2026-07-02T00:00:00Z"), @@ -5837,7 +5886,8 @@ it.effect("names the signed-in account in the detail, and says nothing where the const named = yield* detailFrom(readable); const unnamed = yield* detailFrom({ ...readable, - getViewer: () => Effect.fail(unusable("github", "unauthenticated")), + getViewer: () => + Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")), }); assert.strictEqual(named.viewer, "bilal"); @@ -5855,7 +5905,7 @@ it.effect("keeps the diff cached across a file being ticked off", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -5912,7 +5962,7 @@ it.effect("returns large diff slices intact without retaining them in either cac const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getDiff: () => Effect.sync(() => { reads += 1; @@ -5950,7 +6000,7 @@ it.effect("caches a small replacement after releasing a large diff", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getDiff: () => Effect.sync(() => { reads += 1; @@ -5976,7 +6026,7 @@ const environmentViewedProvider = ( asked: Array>, unreadable: ReadonlySet = new Set(), ) => - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -6018,7 +6068,7 @@ const environmentViewedService = ( title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [environmentViewedProvider(revisions, asked, unreadable)], @@ -6083,7 +6133,7 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba title: "on forgejo", workspaceRoot: "/forgejo", repository: "reviewer/project", - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), host: "forge.example:3000", remoteUrl: "http://forge.example:3000/reviewer/project.git", }), @@ -6145,7 +6195,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi title: "first repository", workspaceRoot: "/first", repository: "reviewer/first", - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), host: "forge.example", remoteUrl: "https://forge.example:3000/reviewer/first.git", }), @@ -6153,13 +6203,20 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi const service = yield* makeService({ projects, providers: [ - { ...environmentViewedProvider(new Map([["same.ts", "blob"]]), []), kind: "forgejo" }, + { + ...environmentViewedProvider(new Map([["same.ts", "blob"]]), []), + kind: SourceControlProviderKind.make("forgejo"), + }, ], resolveHandle: ({ context }) => Effect.succeed({ context: { ...context!, - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://forge.example:3000" }, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo", + baseUrl: "https://forge.example:3000", + }, }, provider: undefined as never, }), @@ -6186,7 +6243,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi title: "second repository", workspaceRoot: "/second", repository: "reviewer/second", - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), host: "forge.example", remoteUrl: "https://forge.example:3000/reviewer/second.git", }), @@ -6201,7 +6258,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi title: "second repository over SSH", workspaceRoot: "/ssh", repository: "reviewer/second", - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), host: "ssh.forge.example", remoteUrl: "git@ssh.forge.example:reviewer/second.git", }), @@ -6269,7 +6326,7 @@ it.effect("holds what a whole-change answer carried, so the next tick reads noth title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ @@ -6643,11 +6700,11 @@ it.effect("still reports its own marks when the host will not say what the head title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -6699,11 +6756,11 @@ it.effect("finishes two presses on one file in the order they were made", () => title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -6755,7 +6812,7 @@ const azureViewedService = (projects: ReadonlyArray) makeService({ projects, providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { capabilities: { diff: true, comment: true, @@ -6781,7 +6838,7 @@ const AZURE_PAIR = [ title: "platform web", workspaceRoot: "/a", repository: "acme/platform/_git/web", - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", }), project({ @@ -6789,7 +6846,7 @@ const AZURE_PAIR = [ title: "other web", workspaceRoot: "/b", repository: "acme/other/_git/web", - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", }), ]; @@ -6880,7 +6937,7 @@ it.effect("keeps the marked paths when a whole-change answer is wider than the c title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ @@ -6949,7 +7006,7 @@ const environmentViewedServiceWithViewer = ( title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [{ ...environmentViewedProvider(revisions, []), getViewer }], @@ -6983,7 +7040,7 @@ it.effect("puts a listing and a press for one host on a single viewer lookup", ( title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ @@ -7048,7 +7105,7 @@ it.effect("records a press while the host is backing off", () => title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ @@ -7064,7 +7121,7 @@ it.effect("records a press while the host is backing off", () => getFileRevisions: () => Effect.fail( new PullRequestProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getFileRevisions", reason: "rate-limited", detail: "API rate limit exceeded.", @@ -7111,7 +7168,7 @@ it.effect("asks who is reading through a pause only for the press that is waitin title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ @@ -7129,7 +7186,7 @@ it.effect("asks who is reading through a pause only for the press that is waitin getFileRevisions: () => Effect.fail( new PullRequestProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getFileRevisions", reason: "rate-limited", detail: "API rate limit exceeded.", @@ -7174,7 +7231,7 @@ it.effect("does not ask a paused host who is reading again after the ask failed" title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ @@ -7185,7 +7242,7 @@ it.effect("does not ask a paused host who is reading again after the ask failed" viewerLookups += 1; return Effect.fail( new PullRequestProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getViewer", reason: "failed", detail: "glab exited with status 1", @@ -7197,7 +7254,7 @@ it.effect("does not ask a paused host who is reading again after the ask failed" runAction: () => Effect.fail( new PullRequestProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "runAction", reason: "rate-limited", detail: "API rate limit exceeded.", @@ -7231,7 +7288,7 @@ it.effect("refuses the marks when the host could not be asked who is reading", ( ? Effect.succeed("bilal") : Effect.fail( new PullRequestProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getViewer", reason: "failed", detail: "glab exited with status 1", @@ -7276,11 +7333,11 @@ it.effect("refuses to track viewed files on a host that does not", () => title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { getFilesViewed: () => Effect.die("must not be called"), setFilesViewed: () => Effect.die("must not be called"), }), @@ -7308,12 +7365,12 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s title: organization, workspaceRoot: `/${organization}`, repository: `${organization}/project/_git/web`, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", }), ), providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { listChangeRequests: (input) => Effect.sync(() => { seen.push(input.cwd); @@ -7363,9 +7420,9 @@ it.effect.each([ }, }, providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { - ...fakeProvider("github").capabilities, + ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, actions: ["merge", "enable-auto-merge"], }, getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 51377f1b33b193c35ed16fed893936a0caf3b6d4..e4d8178dbbe9d363c2c99f0fdc77dd0881a2259a 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -72,7 +72,7 @@ import { type PullRequestThreadCommentsResult, type PullRequestUpdateInput, type SourceControlProviderInfo, - type SourceControlProviderKind, + SourceControlProviderKind, type ThreadPullRequestKey, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; @@ -736,14 +736,15 @@ export const make = Effect.gen(function* () { sourceControlRepositorySelector(project.repositoryIdentity) === null ) continue; - const host = pullRequestHostOf(identity, "unknown"); + const host = pullRequestHostOf(identity, SourceControlProviderKind.make("unknown")); // A legacy identity has no canonical host until its provider is refined, so it must reach // the refinement before a host filter can decide whether it belongs in the result. if ( filter.host !== undefined && host !== "unknown" && host !== filter.host.toLowerCase() && - pullRequestHostOf(identity, "forgejo") !== filter.host.toLowerCase() && + pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo")) !== + filter.host.toLowerCase() && !isSshRemoteUrl(identity.locator.remoteUrl) ) { continue; @@ -768,7 +769,9 @@ export const make = Effect.gen(function* () { cwd: project.workspaceRoot, context: { provider: - provider.kind === "forgejo" ? { ...provider, kind: "unknown" } : provider, + provider.kind === "forgejo" + ? { ...provider, kind: SourceControlProviderKind.make("unknown") } + : provider, remoteName, remoteUrl, ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl) diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.ts b/apps/server/src/pullRequest/pullRequestSyncKey.ts index 334d48762f6974fdd239dedf398542c8e0f22ca2..635d90797fc601a71325d09f9ecdbb336da24cb4 100644 --- a/apps/server/src/pullRequest/pullRequestSyncKey.ts +++ b/apps/server/src/pullRequest/pullRequestSyncKey.ts @@ -2,7 +2,7 @@ import { pullRequestHostOf, type PullRequestRef, type RepositoryIdentity, - type SourceControlProviderKind, + SourceControlProviderKind, type ThreadPullRequestKey, } from "@t3tools/contracts"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; @@ -18,7 +18,8 @@ export function pullRequestSyncKey( reference.repository.toLowerCase() !== sourceControlRepositorySelector(identity)?.toLowerCase() || (reference.host !== undefined && - reference.host.toLowerCase() !== pullRequestHostOf(identity, "azure-devops")) + reference.host.toLowerCase() !== + pullRequestHostOf(identity, SourceControlProviderKind.make("azure-devops"))) ) return null; const [host, ...repository] = identity.canonicalKey.split("/"); diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 85e14a61bc62b1c1dd8f2f27be1a58cc662271d5..f879d57665bac04c0aed397d5211a2606464a38b 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it, vi } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -362,7 +363,7 @@ it.effect.each([false, true])( ? { context: { provider: { - kind: "bitbucket" as const, + kind: SourceControlProviderKind.make("bitbucket"), name: "Bitbucket", baseUrl: "https://bitbucket.org", }, @@ -396,7 +397,11 @@ it.effect("prefers an explicit repository and uses context when the repository i return Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; const context = { - provider: { kind: "bitbucket" as const, name: "Bitbucket", baseUrl: "https://bitbucket.org" }, + provider: { + kind: SourceControlProviderKind.make("bitbucket"), + name: "Bitbucket", + baseUrl: "https://bitbucket.org", + }, remoteName: "origin", remoteUrl: "git@bitbucket.org:another/context.git", }; @@ -838,7 +843,7 @@ it.effect("checks out same-repository pull requests with the existing Bitbucket cwd: "/repo", context: { provider: { - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), name: "Bitbucket", baseUrl: "https://bitbucket.org", }, diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 8fa9201b94ca8a89c1a5a5ed605d59d9468f9aeb..ff3933a98ed377d063137a9d720dc4e547d152c5 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -176,37 +177,37 @@ it.effect("reports implemented tools separately from locally available executabl })), [ { - kind: "github", + kind: SourceControlProviderKind.make("github"), status: "available", auth: "authenticated", account: Option.some("juliusmarminge"), }, { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), status: "missing", auth: "unknown", account: Option.none(), }, { - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), status: "missing", auth: "unknown", account: Option.none(), }, { - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), status: "available", auth: "unauthenticated", account: Option.none(), }, { - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), status: "missing", auth: "unknown", account: Option.none(), }, { - kind: "gitcafe", + kind: SourceControlProviderKind.make("gitcafe"), status: "missing", auth: "unknown", account: Option.none(), @@ -327,37 +328,37 @@ Logged in to gitlab.com as gitlab-user })), [ { - kind: "github", + kind: SourceControlProviderKind.make("github"), auth: "authenticated", account: Option.some("octocat"), detail: Option.none(), }, { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), auth: "authenticated", account: Option.some("gitlab-user"), detail: Option.none(), }, { - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), auth: "authenticated", account: Option.some("azure-user@example.com"), detail: Option.none(), }, { - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), auth: "authenticated", account: Option.some("bitbucket-user"), detail: Option.none(), }, { - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), auth: "authenticated", account: Option.some("forgejo-user"), detail: Option.none(), }, { - kind: "gitcafe", + kind: SourceControlProviderKind.make("gitcafe"), auth: "authenticated", account: Option.some("cafe-user"), detail: Option.none(), @@ -528,7 +529,11 @@ it.effect( reference: "42", force: true, context: { - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://forgejo.test" }, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo", + baseUrl: "https://forgejo.test", + }, remoteName: "origin", remoteUrl: "git@forgejo.test:maria/project.git", }, diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 1fab84a6acd0d439b0323c85eb14fdab5bf58f31..d174ed19eeab532e74a2db86a1a73a8055503547 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as DateTime from "effect/DateTime"; @@ -154,7 +155,7 @@ it.effect("routes directly by provider kind for remote-first workflows", () => remotes: [], }); - const provider = yield* registry.get("github"); + const provider = yield* registry.get(SourceControlProviderKind.make("github")); assert.strictEqual(provider.kind, "github"); }), @@ -163,7 +164,7 @@ it.effect("routes directly by provider kind for remote-first workflows", () => it.effect("includes the request cwd when an unregistered provider is used", () => Effect.gen(function* () { const registry = yield* makeRegistry({ remotes: [] }); - const provider = yield* registry.get("unknown"); + const provider = yield* registry.get(SourceControlProviderKind.make("unknown")); const error = yield* provider .getChangeRequest({ cwd: "/repo", reference: "#42" }) @@ -257,7 +258,7 @@ it.effect("refines the caller-selected remote instead of choosing another config cwd: "/repo", context: { provider: { - kind: "unknown", + kind: SourceControlProviderKind.make("unknown"), name: "self-hosted.example.test", baseUrl: "https://self-hosted.example.test", }, @@ -412,7 +413,7 @@ it.effect.each([ }, }); const handle = yield* registry.resolveHandle({ cwd: "/repo" }); - assert.strictEqual(handle.provider.kind, scenario.expected); + assert.strictEqual(handle.provider.kind, SourceControlProviderKind.make(scenario.expected)); assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test"); assert.deepStrictEqual(hosts, ["code.example.test"]); // Recognition goes through the credential layer; gh is only one of its sources. @@ -437,7 +438,7 @@ it.effect("skips GitHub discovery for the identity resolver's empty base URL", ( }, }); const context = { - provider: { kind: "unknown" as const, name: "Unknown", baseUrl: "" }, + provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" }, remoteName: "origin", remoteUrl, }; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 7af90663dc930cf1484b1f75468621c6a95dc1c8..821e24bf7bc3e0e172df6ce31d4b5b24774d4765 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -8,7 +9,7 @@ import { SourceControlProviderError, type SourceControlProviderDiscoveryItem, } from "@t3tools/contracts"; -import type { SourceControlProviderKind } from "@t3tools/contracts"; +import type {} from "@t3tools/contracts"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import * as BuiltInDrivers from "./builtInDrivers.ts"; @@ -214,7 +215,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit Effect.mapError( (error) => new SourceControlProviderError({ - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), operation: "detectProvider", cwd, detail: "Failed to detect source control provider.", @@ -226,7 +227,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit Effect.mapError( (error) => new SourceControlProviderError({ - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), operation: "detectProvider", cwd, detail: "Failed to detect source control provider.", @@ -265,7 +266,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }) ).pipe( Effect.map((context) => { - const kind = context?.provider.kind ?? "unknown"; + const kind = context?.provider.kind ?? SourceControlProviderKind.make("unknown"); const provider = providers.get(kind) ?? unsupportedProvider(kind); return { provider: bindProviderContext(provider, context), diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index c051ab47f42f5399dbefdecdf18cdc48b8b5955c..509fd0582eff1299a772316c7e588b5ba93d9cfe 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -37,7 +38,7 @@ function makeProvider( >; return { - kind: "github", + kind: SourceControlProviderKind.make("github"), listChangeRequests: () => unsupported("listChangeRequests"), getChangeRequest: () => unsupported("getChangeRequest"), createChangeRequest: () => unsupported("createChangeRequest"), @@ -114,19 +115,22 @@ it.effect("looks up repositories through the requested provider without search", return Effect.gen(function* () { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.lookupRepository({ - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "octocat/t3code", cwd: "/workspace", }); - assert.deepStrictEqual(result, { provider: "github", ...CLONE_URLS }); + assert.deepStrictEqual(result, { + provider: SourceControlProviderKind.make("github"), + ...CLONE_URLS, + }); assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]); }).pipe(Effect.provide(layer({ provider }))); }); it.effect("preserves provider failures without deriving the repository message from them", () => { const providerCause = new SourceControlProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getRepositoryCloneUrls", cwd: "/workspace", repository: "octocat/t3code", @@ -140,7 +144,7 @@ it.effect("preserves provider failures without deriving the repository message f const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const error = yield* Effect.flip( service.lookupRepository({ - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "octocat/t3code", cwd: "/workspace", }), @@ -176,7 +180,7 @@ it.effect( yield* Effect.gen(function* () { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const input = { - provider: "bitbucket" as const, + provider: SourceControlProviderKind.make("bitbucket"), repository: locatorCause.repository, cwd: "/workspace", }; @@ -204,11 +208,11 @@ it.effect( it.effect("keeps a plain Bitbucket locator lookalike out of the repository message", () => { const provider = makeProvider({ - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), getRepositoryCloneUrls: () => Effect.fail( new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "getRepositoryCloneUrls", cwd: "/workspace", detail: "credential token abc123 was rejected", @@ -224,7 +228,10 @@ it.effect("keeps a plain Bitbucket locator lookalike out of the repository messa return Effect.gen(function* () { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const error = yield* Effect.flip( - service.lookupRepository({ provider: "bitbucket", repository: "t3code" }), + service.lookupRepository({ + provider: SourceControlProviderKind.make("bitbucket"), + repository: "t3code", + }), ); assert.strictEqual(error.detail, "The source control operation could not be completed."); @@ -250,7 +257,7 @@ it.effect("keeps arbitrary Bitbucket request failures out of the repository mess const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const error = yield* Effect.flip( service.lookupRepository({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), repository: "pingdotgg/t3code", cwd: "/workspace", }), @@ -276,7 +283,7 @@ it.effect("clones a looked-up repository into the requested destination", () => yield* Effect.gen(function* () { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.cloneRepository({ - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "octocat/t3code", destinationPath, protocol: "https", @@ -285,7 +292,7 @@ it.effect("clones a looked-up repository into the requested destination", () => assert.deepStrictEqual(result, { cwd: destinationPath, remoteUrl: CLONE_URLS.url, - repository: { provider: "github", ...CLONE_URLS }, + repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS }, }); assert.deepStrictEqual(cloneCalls, [ { @@ -546,7 +553,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.publishRepository({ cwd: "/workspace", - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "octocat/t3code", visibility: "private", remoteName: "origin", @@ -554,7 +561,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up }); assert.deepStrictEqual(result, { - repository: { provider: "github", ...CLONE_URLS }, + repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS }, remoteName: "origin", remoteUrl: CLONE_URLS.sshUrl, branch: "feature/remote-v1", @@ -601,7 +608,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.publishRepository({ cwd: "/workspace", - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "octocat/t3code", visibility: "private", remoteName: "origin", @@ -637,7 +644,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.publishRepository({ cwd: "/workspace", - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "octocat/t3code", visibility: "private", remoteName: "origin", @@ -645,7 +652,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no }); assert.deepStrictEqual(result, { - repository: { provider: "github", ...CLONE_URLS }, + repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS }, remoteName: "origin", remoteUrl: CLONE_URLS.sshUrl, branch: "main", diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index d17f860050bb368bc0c1b4b68b5135964e720122..c273535bd69f9dd49d7a904fb79f55fb1c4d23d9 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -12,7 +12,7 @@ import { type SourceControlCloneRepositoryInput, type SourceControlCloneRepositoryResult, type SourceControlCloneProtocol, - type SourceControlProviderKind, + SourceControlProviderKind, type SourceControlPublishRepositoryInput, type SourceControlPublishRepositoryResult, type SourceControlRepositoryCloneUrls, @@ -209,7 +209,7 @@ export const make = Effect.gen(function* () { if (trimmed.length === 0) { return yield* new SourceControlRepositoryError({ operation: "cloneRepository", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "Choose a destination path before cloning.", }); } @@ -229,7 +229,7 @@ export const make = Effect.gen(function* () { (cause) => new SourceControlRepositoryError({ operation: "cloneRepository", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "Destination path already exists and is not a directory.", cause, }), @@ -238,7 +238,7 @@ export const make = Effect.gen(function* () { if (entries.length > 0) { return yield* new SourceControlRepositoryError({ operation: "cloneRepository", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "Destination path already exists and is not empty.", }); } @@ -260,7 +260,8 @@ export const make = Effect.gen(function* () { const preparedDestination = yield* prepareDestination(input.destinationPath); let repository: SourceControlRepositoryInfo | null = null; let remoteUrl = input.remoteUrl?.trim() ?? null; - let provider: SourceControlProviderKind = input.provider ?? "unknown"; + let provider: SourceControlProviderKind = + input.provider ?? SourceControlProviderKind.make("unknown"); if (input.provider && input.repository) { repository = yield* lookupRepository({ @@ -335,7 +336,7 @@ export const make = Effect.gen(function* () { (cause) => new SourceControlRepositoryError({ operation: "cloneRepository", - provider: input.provider ?? "unknown", + provider: input.provider ?? SourceControlProviderKind.make("unknown"), detail: stderrTail.length > 0 ? stderrTail.join(" ") @@ -370,7 +371,7 @@ export const make = Effect.gen(function* () { (cause) => new SourceControlRepositoryError({ operation: "discardClone", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "The clone destination could not be inspected.", cause, }), @@ -379,7 +380,7 @@ export const make = Effect.gen(function* () { if (entries.length > 0 && !entries.includes(".git")) { return yield* new SourceControlRepositoryError({ operation: "discardClone", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "Destination path contains files that are not from the clone.", }); } @@ -393,7 +394,7 @@ export const make = Effect.gen(function* () { (cause) => new SourceControlRepositoryError({ operation: "discardClone", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "The partial clone could not be removed.", cause, }), @@ -462,13 +463,23 @@ export const make = Effect.gen(function* () { lookupRepository: (input) => lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), prepareClone: (input) => - prepareClone(input).pipe(mapRepositoryError("cloneRepository", input.provider ?? "unknown")), + prepareClone(input).pipe( + mapRepositoryError( + "cloneRepository", + input.provider ?? SourceControlProviderKind.make("unknown"), + ), + ), cloneRepository: (input, options) => cloneRepository(input, options).pipe( - mapRepositoryError("cloneRepository", input.provider ?? "unknown"), + mapRepositoryError( + "cloneRepository", + input.provider ?? SourceControlProviderKind.make("unknown"), + ), ), discardClone: (destinationPath) => - discardClone(destinationPath).pipe(mapRepositoryError("discardClone", "unknown")), + discardClone(destinationPath).pipe( + mapRepositoryError("discardClone", SourceControlProviderKind.make("unknown")), + ), publishRepository: (input) => publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)), }); diff --git a/apps/server/src/textGeneration/ThreadTitleLinks.test.ts b/apps/server/src/textGeneration/ThreadTitleLinks.test.ts index f75a06cfb59dd83e0e86e058e8d2b7aa19ba72e3..bf4323a195e9d6c0221a46037087ea5378c85d0a 100644 --- a/apps/server/src/textGeneration/ThreadTitleLinks.test.ts +++ b/apps/server/src/textGeneration/ThreadTitleLinks.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -87,7 +88,7 @@ it.effect("keeps lookup failure out of generation and skips unlinked messages", resolveLink: () => Effect.fail( new SourceControlProviderError({ - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), operation: "resolveLink", cwd: "/tmp", detail: "Unavailable", diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 35d013433c499da22c2fd93601bfe39176e38cbd..9e9f2e4d2cca0c53d2645b094658afe59ff0c35c 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Cause from "effect/Cause"; @@ -34,7 +35,7 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); const baseLocalStatus: VcsStatusLocalResult = { isRepo: true, sourceControlProvider: { - kind: "github", + kind: SourceControlProviderKind.make("github"), name: "GitHub", baseUrl: "https://github.com", }, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 48c4b20a777f64dedae7d099552474ff534f64f6..b85b0ca2cdc37e4199c56d5a1f82853ebd1d5324 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -39,7 +39,7 @@ import { type FilesystemBrowseResult, type ProjectId, type SourceControlDiscoveryResult, - type SourceControlProviderKind, + SourceControlProviderKind, type SourceControlRepositoryInfo, PRIMARY_LOCAL_ENVIRONMENT_ID, resolveEnvironmentMachineKind, @@ -257,10 +257,13 @@ interface AddProjectEnvironmentOption { readonly status: string; } -type AddProjectRemoteProviderKind = Extract< - SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" ->; +type AddProjectRemoteProviderKind = + | "github" + | "gitlab" + | "forgejo" + | "bitbucket" + | "azure-devops" + | "gitcafe"; type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; type AddProjectCloneFlow = @@ -421,7 +424,7 @@ function buildAddProjectRemoteSourceReadiness( for (const source of REMOTE_PROJECT_SOURCES) { const kind = sourceProviderKind(source); if (!kind) continue; - const provider = providerByKind.get(kind); + const provider = providerByKind.get(SourceControlProviderKind.make(kind)); if (!provider) { readiness[source] = unavailable; continue; @@ -2628,7 +2631,7 @@ function OpenCommandPaletteDialog(props: { const lookupResult = await lookupRepository({ environmentId: addProjectCloneFlow.environmentId, input: { - provider, + provider: SourceControlProviderKind.make(provider), repository: rawRepository, }, }); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index b29a44be3702fd740beb27d034884b1711fb2690..12aee26c757ed118e811f6948b65160a43c4c6c6 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import type { VcsStatusResult } from "@t3tools/contracts"; import { assert, describe, it } from "vite-plus/test"; import { @@ -343,7 +344,7 @@ describe("when: source control provider uses merge requests", () => { const gitlabStatus = status({ aheadCount: 2, sourceControlProvider: { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), name: "GitLab", baseUrl: "https://gitlab.com", }, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2063767f315c1ef19b79bc6adb97e5cfa3d1ffca..703694ce1651e6f059a13210a5d5f61c6719e189 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -3,6 +3,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, + SourceControlProviderKind, type ScopedThreadRef, } from "@t3tools/contracts"; import { @@ -14,7 +15,6 @@ import type { GitStackedAction, SourceControlCloneProtocol, SourceControlProviderDiscoveryItem, - SourceControlProviderKind, SourceControlPublishRepositoryResult, SourceControlRepositoryVisibility, VcsStatusResult, @@ -158,10 +158,13 @@ interface PendingDefaultBranchAction { filePaths?: string[]; } -type PublishProviderKind = Extract< - SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" ->; +type PublishProviderKind = + | "github" + | "gitlab" + | "forgejo" + | "bitbucket" + | "azure-devops" + | "gitcafe"; type GitActionToastId = ReturnType; @@ -265,9 +268,7 @@ function publishProviderOption(provider: PublishProviderKind) { ); } -function isPublishProviderKind( - provider: SourceControlProviderKind, -): provider is PublishProviderKind { +function isPublishProviderKind(provider: string): provider is PublishProviderKind { return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider); } @@ -582,8 +583,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { gitcafe: null, }; for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { - if (isPublishProviderKind(provider.kind)) { - accounts[provider.kind] = Option.getOrNull(provider.auth.account); + const kind: string = provider.kind; + if (isPublishProviderKind(kind)) { + accounts[kind] = Option.getOrNull(provider.auth.account); } } return accounts; @@ -670,7 +672,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { void (async () => { const result = await publishRepositoryAction.run({ - provider: publishProvider, + provider: SourceControlProviderKind.make(publishProvider), repository: publishRepository.trim(), visibility: publishVisibility, remoteName: publishRemoteName.trim() || "origin", diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 75d0a16f5af71759cfa07168938295fb92ea2f32..976a220703c0ac9915d7343baa47928b6443afa3 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -93,7 +94,7 @@ function pullRequestSummary( updatedAt: string, ): PullRequestSummary { return { - provider: "github", + provider: SourceControlProviderKind.make("github"), projectId: ProjectId.make("project-1"), repository: "pingdotgg/t3code", number: 42, @@ -179,7 +180,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { url: "https://github.com/pingdotgg/t3code/pull/42", }; const provider = { - kind: "github" as const, + kind: SourceControlProviderKind.make("github"), name: "GitHub", baseUrl: "https://github.com", }; diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 8368dbb0eac9fce136b88da1b1529d3272b36bdf..146fac555c72aaa1e924c754b97861e79c23449f 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -13,6 +13,7 @@ import { type ThreadLinkedPullRequest, type ThreadPullRequestLink, type VcsStatusResult, + SourceControlProviderKind, } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { FolderGit2Icon, TerminalIcon } from "lucide-react"; @@ -154,7 +155,7 @@ export function linkedPullRequestSnapshotStatus( baseRef: snapshot.baseBranch, ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }), }, - sourceControlProvider: { kind, name: kind, baseUrl: "" }, + sourceControlProvider: { kind: SourceControlProviderKind.make(kind), name: kind, baseUrl: "" }, }; } diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx index daf9356003af2b3a886beb6d836bd479d5c10a34..3f192f745fa8a273ae263de3cda854a6506d6185 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { EnvironmentId, ProjectId, @@ -151,7 +152,7 @@ import { PullRequestDetailPanel } from "./PullRequestDetailPanel"; import { pullRequestPanelContext } from "./pullRequestDetail.logic"; const detail: PullRequestDetailView = { - provider: "github", + provider: SourceControlProviderKind.make("github"), projectId: ProjectId.make("project"), projectTitle: "Project", workspaceRoot: "/workspace", diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx index 27191a970b3e9d569eda3af305706d41009426c1..1362eb51d12f4d925795cc996f589b35f1dee286 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/contracts"; import { act, type ReactNode } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; @@ -26,7 +27,7 @@ vi.mock("../ui/tooltip", () => ({ import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; const detail: PullRequestDetailView = { - provider: "github", + provider: SourceControlProviderKind.make("github"), projectId: ProjectId.make("project"), projectTitle: "Project", workspaceRoot: "/workspace", diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 3d5676ecbf5565d79ab84563d6d83a3fe35b6247..7242eac8695a04b11bcf96f655058fe2f5cf818b 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { resolvePlanFollowUpSubmission } from "../../proposedPlan"; import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; import { @@ -95,12 +96,14 @@ describe("pull request checkout commands", () => { ], ["unknown", "feature", null, null], ] as const)("builds the %s command", (provider, branch, repository, expected) => { - expect(pullRequestCheckoutCommand(provider, 42, branch, repository)).toBe(expected); + expect( + pullRequestCheckoutCommand(SourceControlProviderKind.make(provider), 42, branch, repository), + ).toBe(expected); }); it("fetches Forgejo pull refs from the actual repository, including a mounted host and port", () => { expect( pullRequestCheckoutCommand( - "forgejo", + SourceControlProviderKind.make("forgejo"), 42, "feature", null, @@ -113,7 +116,7 @@ describe("pull request checkout commands", () => { it("quotes shell metacharacters in Forgejo repository URLs", () => { expect( pullRequestCheckoutCommand( - "forgejo", + SourceControlProviderKind.make("forgejo"), 42, "feature", null, @@ -1714,7 +1717,7 @@ describe("cached pull request detail", () => { const reference = { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 7 }; const detail = (overrides: Partial = {}): PullRequestDetail => ({ - provider: "github", + provider: SourceControlProviderKind.make("github"), capabilities: { diff: true, comment: true, @@ -1797,7 +1800,7 @@ describe("cached pull request detail", () => { remoteName: "origin", remoteUrl: "https://github.com/acme/web.git", }, - provider: "github", + provider: SourceControlProviderKind.make("github"), }); expect(readPullRequestDetailSnapshot(storage, "env-1", resolved)?.title).toBe( "Cache the title", @@ -1824,7 +1827,7 @@ describe("cached pull request detail", () => { remoteName: "origin", remoteUrl: "git@ssh.dev.azure.com:v3/org/project/web", }, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), }), ).toBe(reference); expect(resolvePullRequestReferenceHost(reference, undefined)).toBe(reference); @@ -1847,7 +1850,7 @@ describe("cached pull request detail", () => { it("keeps Forgejo ports isolated when recovering legacy snapshots", () => { const storage = makeStorage(); const cached = detail({ - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), url: "https://forge.example:8443/acme/web/pulls/7", }); writePullRequestDetailSnapshot(storage, "env-1", reference, cached); @@ -1869,7 +1872,8 @@ describe("cached pull request detail", () => { it.each(["github", "gitlab"] as const)( "retains portless %s snapshot identities for custom web ports", - (provider) => { + (providerName) => { + const provider = SourceControlProviderKind.make(providerName); const storage = makeStorage(); const host = `${provider}.example.com`; const hosted = { ...reference, host }; diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 083f2c8db22ab4fc8342e6c5383c50aa789ee1a5..0a367768a2de4763184d13a7c5d2060a1897494b 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -21,7 +21,7 @@ import { type PullRequestReviewThread, type PullRequestState, type PullRequestUpdateMethod, - type SourceControlProviderKind, + SourceControlProviderKind, type ThreadLinkedPullRequest, type ThreadPullRequestLink, type VcsRef, @@ -140,7 +140,7 @@ export function pullRequestCheckoutCommand( } return `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`; } - case "unknown": + default: return null; } } @@ -155,10 +155,11 @@ export function loadingPullRequestCheckoutCommand( identity?.provider ?? (host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null); if (provider !== "github" && provider !== "gitlab" && provider !== "azure-devops") return null; - if (identity?.provider !== undefined && host && pullRequestHostOf(identity, provider) !== host) { + const kind = SourceControlProviderKind.make(provider); + if (identity?.provider !== undefined && host && pullRequestHostOf(identity, kind) !== host) { return null; } - return pullRequestCheckoutCommand(provider, reference.number, ""); + return pullRequestCheckoutCommand(kind, reference.number, ""); } /** Activity changes only when the same host resource reports a newer revision. */ @@ -1304,7 +1305,10 @@ export function resolvePullRequestReferenceHost( ): PullRequestRef { // Other providers may resolve an SSH remote to a different web authority on the server. if (reference.host !== undefined || identity?.provider !== "github") return reference; - return { ...reference, host: pullRequestHostOf(identity, "github") }; + return { + ...reference, + host: pullRequestHostOf(identity, SourceControlProviderKind.make("github")), + }; } export interface PullRequestDetailSnapshotRef { diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 3bd486fa9e9c9a537e272d6897133ff3589f84d8..ebaa045a66a75dada399e06dfe2bfb89fb280b15 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import type { EnvironmentId, ProjectId, PullRequestListEntry } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -330,7 +331,7 @@ describe("pull request involvement filtering", () => { entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }), entry({ number: 2, - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), host: "gitlab.com", author: { login: "Bilal", name: null, avatarUrl: null }, }), @@ -1322,7 +1323,7 @@ describe("merging the environments' own listings", () => { providers: [ { host: "github.com", - kind: "github", + kind: SourceControlProviderKind.make("github"), searchesOnHost: false, projectCount: 2, configured: false, diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 68465b06d0fd584303f724ebb041c344e647ba3e..ec408ad97cdb10ed15b86348852920c4afd4a6c8 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -78,7 +78,7 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { sourceControlProviders: [], }; -const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { +const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { github: GitHubIcon, gitlab: GitLabIcon, forgejo: ForgejoIcon, diff --git a/apps/web/src/hooks/useNewProject.ts b/apps/web/src/hooks/useNewProject.ts index 175609cbfc4217cff52ceb7b7237e539264be5df..fc1ff81ce1afbac4bf37536e0f2aa59971390bbe 100644 --- a/apps/web/src/hooks/useNewProject.ts +++ b/apps/web/src/hooks/useNewProject.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { getNewProjectGitHubRepository } from "@t3tools/client-runtime/operations/projects"; import { @@ -45,7 +46,7 @@ export function useNewProject() { environmentId: input.environmentId, input: { cwd: input.workspaceRoot, - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: getNewProjectGitHubRepository(input, input.workspaceRoot), visibility: "private", }, diff --git a/apps/web/src/state/pullRequests.test.ts b/apps/web/src/state/pullRequests.test.ts index 48ee5b51b884993b81ce44c980c20d9513a25c64..42dc8c2fba6fb7d59147f338bb9228c7bda8047b 100644 --- a/apps/web/src/state/pullRequests.test.ts +++ b/apps/web/src/state/pullRequests.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { ProjectId, type PullRequestSummary } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -5,7 +6,7 @@ import { newestPullRequestObservation, newestPullRequestSummary } from "./pullRe function summary(overrides: Partial = {}): PullRequestSummary { return { - provider: "github", + provider: SourceControlProviderKind.make("github"), projectId: ProjectId.make("pull-request-cache-test"), repository: "acme/widget", number: 7, diff --git a/apps/web/src/state/sourceControlActions.test.ts b/apps/web/src/state/sourceControlActions.test.ts index 1747f79963453202f9ac948812db7bc4df91dbc0..1e04c3f340edefb6cddef93d49622fd3de54d675 100644 --- a/apps/web/src/state/sourceControlActions.test.ts +++ b/apps/web/src/state/sourceControlActions.test.ts @@ -3,6 +3,7 @@ import { AuthSourceControlWriteScope, EnvironmentId, ThreadId, + SourceControlProviderKind, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; @@ -98,7 +99,7 @@ const cases = [ isAllowed: action.isAllowed, run: () => action.run({ - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "owner/repo", visibility: "private", remoteName: "origin", diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index f760678136dc0e98a8571b208cd915d5fb25cd32..ec026c00f413401ecd46369376fa74937f7ed005 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -16,6 +16,7 @@ import { type EnvironmentId, type GitResolvePullRequestResult, type SourceControlCloneProtocol, + type SourceControlProviderKind, type SourceControlRepositoryVisibility, type ThreadId, } from "@t3tools/contracts"; @@ -275,7 +276,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi ); const action = useCallback( async (input: { - provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"; + provider: SourceControlProviderKind; repository: string; visibility: SourceControlRepositoryVisibility; remoteName: string; diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 9343e9842056d01bd555fbcc1523b8598f08b02b..7df9904158b5bd6f6fe7750054d396983f868004 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, @@ -71,14 +72,14 @@ describe("add project shared logic", () => { it("uses HTTPS for repositories selected through a provider", () => { expect( getDefaultCloneUrl({ - provider: "github", + provider: SourceControlProviderKind.make("github"), url: "https://github.com/imputnet/helium", sshUrl: "git@github.com:imputnet/helium.git", }), ).toBe("https://github.com/imputnet/helium"); expect( getDefaultCloneUrl({ - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), url: "https://forgejo.example.test:8443/owner/repo.git", sshUrl: "ssh://git@forgejo.example.test:2222/owner/repo.git", }), @@ -88,7 +89,7 @@ describe("add project shared logic", () => { it("preserves existing clone transport behavior for other providers", () => { expect( getDefaultCloneUrl({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), url: "https://gitlab.com/group/project.git", sshUrl: "git@gitlab.com:group/project.git", }), @@ -187,7 +188,7 @@ describe("add project shared logic", () => { versionControlSystems: [], sourceControlProviders: [ { - kind: "github", + kind: SourceControlProviderKind.make("github"), label: "GitHub", status: "available", installHint: "Install gh", @@ -201,7 +202,7 @@ describe("add project shared logic", () => { }, }, { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), label: "GitLab", status: "available", installHint: "Install glab", diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 8dd937d3a68dd4de3878b25557ac2cea61cc6dca..f3b7bee7ef22b33507ffe676dc02f01fad3b3278 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -6,9 +6,9 @@ import type { ProjectId, ServerConfig, SourceControlDiscoveryResult, - SourceControlProviderKind, SourceControlRepositoryInfo, } from "@t3tools/contracts"; +import { SourceControlProviderKind } from "@t3tools/contracts"; import { newProjectFolderName } from "@t3tools/shared/path"; import * as Arr from "effect/Array"; import * as Option from "effect/Option"; @@ -25,10 +25,12 @@ import { } from "../state/projects.ts"; import type { EnvironmentProject } from "../state/models.ts"; -export type AddProjectRemoteProviderKind = Extract< - SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" ->; +export type AddProjectRemoteProviderKind = + | "github" + | "gitlab" + | "forgejo" + | "bitbucket" + | "azure-devops"; export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; export function canCreateProjectInEnvironment( @@ -190,7 +192,7 @@ export function buildAddProjectRemoteSourceReadiness( for (const source of ADD_PROJECT_REMOTE_SOURCES) { const kind = addProjectRemoteSourceProvider(source); if (!kind) continue; - const provider = providerByKind.get(kind); + const provider = providerByKind.get(SourceControlProviderKind.make(kind)); if (!provider) { readiness[source] = unavailable; continue; diff --git a/packages/client-runtime/src/state/sourceControl.test.ts b/packages/client-runtime/src/state/sourceControl.test.ts index 24c258e2ae215bf384a8d3908ddd81c8c1cc6fbb..ad974ea6841a358fdbc51e822d9c072e2f74f233 100644 --- a/packages/client-runtime/src/state/sourceControl.test.ts +++ b/packages/client-runtime/src/state/sourceControl.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { vi } from "vite-plus/test"; import { EnvironmentId, @@ -36,7 +37,7 @@ const TARGET = new PrimaryConnectionTarget({ const PUBLISH_RESULT: SourceControlPublishRepositoryResult = { repository: { - provider: "github", + provider: SourceControlProviderKind.make("github"), nameWithOwner: "t3tools/t3code", url: "https://github.com/t3tools/t3code", sshUrl: "git@github.com:t3tools/t3code.git", @@ -140,7 +141,7 @@ describe("source control environment atoms", () => { environmentId: TARGET.environmentId, input: { cwd: "/repo", - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "t3tools/t3code", visibility: "private", }, @@ -156,7 +157,7 @@ describe("source control environment atoms", () => { environmentId: TARGET.environmentId, input: { cwd: "/repo", - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "t3tools/t3code", visibility: "private", }, diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 3d979e852abc58c88749a11386273d71622e6f81..2360d232895d4cab3f7f8e76af73efca80b350f4 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "./sourceControl.ts"; import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; @@ -25,7 +26,7 @@ const LIST_RESULT: PullRequestListResult = { providers: [ { host: "github.com", - kind: "github", + kind: SourceControlProviderKind.make("github"), searchesOnHost: true, projectCount: 1, configured: true, @@ -33,7 +34,7 @@ const LIST_RESULT: PullRequestListResult = { }, { host: "gitlab.com", - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), searchesOnHost: true, projectCount: 1, configured: false, @@ -42,7 +43,7 @@ const LIST_RESULT: PullRequestListResult = { ], entries: [ { - provider: "github", + provider: SourceControlProviderKind.make("github"), host: "github.com", projectId: "project-1" as PullRequestListResult["entries"][number]["projectId"], projectTitle: "t3code", @@ -75,12 +76,16 @@ describe("PullRequestListResult", () => { canonicalKey: "forge.example/team/repo", locator: { remoteUrl: "http://forge.example:3000/team/repo.git" }, }; - expect(pullRequestHostOf(identity, "forgejo")).toBe("forge.example:3000"); - expect(pullRequestHostOf(identity, "gitlab")).toBe("forge.example"); + expect(pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo"))).toBe( + "forge.example:3000", + ); + expect(pullRequestHostOf(identity, SourceControlProviderKind.make("gitlab"))).toBe( + "forge.example", + ); expect( pullRequestHostOf( { ...identity, locator: { remoteUrl: "ssh://git@forge.example:2222/team/repo.git" } }, - "forgejo", + SourceControlProviderKind.make("forgejo"), ), ).toBe("forge.example"); }); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 82e5dc1cd15f46ba697d6807c3baaf1d3dca0699..031439f9a2935441f79b771467ca8bcbc7202799 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -1301,38 +1301,54 @@ export type PullRequestUnavailableReason = typeof PullRequestUnavailableReason.T * symptom. The reason names keep their `cli-` prefix for wire compatibility; for GitHub and * Bitbucket they mean "no credential" and "a refused credential", not a missing tool. */ -const PROVIDER_REQUIREMENT: Partial< - Record -> = { - github: { - missing: - "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI (https://cli.github.com/) and run `gh auth login`.", - unauthenticated: - "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.", - }, - forgejo: { - missing: - "Install Forgejo CLI (`fj` 0.6 or later) from https://codeberg.org/forgejo-contrib/forgejo-cli or Gitea CLI (`tea` 0.16 or later) from https://gitea.com/gitea/tea to browse Forgejo pull requests.", - unauthenticated: - "Authenticate your Forgejo or Gitea server with `fj --host auth add-token` on the T3 Code server. If fj is missing or unconfigured for that server, use `tea login add`. A configured fj account must be repaired with fj.", - }, - gitlab: { - missing: - "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.", - unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", - }, - "azure-devops": { - missing: - "Azure CLI (`az`) with the Azure DevOps extension is required. Install `az`, then run `az extension add --name azure-devops`.", - unauthenticated: "Azure CLI is not signed in. Run `az login` and retry.", - }, - bitbucket: { - missing: - "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.", - unauthenticated: - "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.", - }, -}; +const PROVIDER_REQUIREMENT = new Map< + string, + { readonly missing: string; readonly unauthenticated: string } +>([ + [ + "github", + { + missing: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI (https://cli.github.com/) and run `gh auth login`.", + unauthenticated: + "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.", + }, + ], + [ + "forgejo", + { + missing: + "Install Forgejo CLI (`fj` 0.6 or later) from https://codeberg.org/forgejo-contrib/forgejo-cli or Gitea CLI (`tea` 0.16 or later) from https://gitea.com/gitea/tea to browse Forgejo pull requests.", + unauthenticated: + "Authenticate your Forgejo or Gitea server with `fj --host auth add-token` on the T3 Code server. If fj is missing or unconfigured for that server, use `tea login add`. A configured fj account must be repaired with fj.", + }, + ], + [ + "gitlab", + { + missing: + "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.", + unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", + }, + ], + [ + "azure-devops", + { + missing: + "Azure CLI (`az`) with the Azure DevOps extension is required. Install `az`, then run `az extension add --name azure-devops`.", + unauthenticated: "Azure CLI is not signed in. Run `az login` and retry.", + }, + ], + [ + "bitbucket", + { + missing: + "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.", + unauthenticated: + "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.", + }, + ], +]); /** * The host a project's repository is addressed below. `canonicalKey` is the normalized remote, @@ -1394,7 +1410,7 @@ export function pullRequestProviderRequirement( provider: SourceControlProviderKind, reason: PullRequestUnavailableReason, ): string | null { - const requirement = PROVIDER_REQUIREMENT[provider]; + const requirement = PROVIDER_REQUIREMENT.get(provider); if (requirement === undefined) return null; switch (reason) { case "cli-missing": @@ -1427,7 +1443,7 @@ export class PullRequestUnavailableError extends Schema.TaggedError { const current: VcsStatusResult = { isRepo: true, sourceControlProvider: { - kind: "github", + kind: SourceControlProviderKind.make("github"), name: "GitHub", baseUrl: "https://github.com", }, diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index b72f35793de5a2b80c00a30dac045eaa1effb71d..46d0f70fea5c072043a3bad80515d2770f5cdcec 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -10,30 +11,38 @@ import { describe("source control presentation", () => { it("uses merge request terminology for GitLab", () => { - expect(getChangeRequestTerminologyForKind("gitlab")).toEqual({ + expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("gitlab"))).toEqual({ shortLabel: "MR", singular: "merge request", }); }); it("uses pull request terminology for GitHub-compatible providers", () => { - expect(getChangeRequestTerminologyForKind("github")).toEqual({ + expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("github"))).toEqual({ shortLabel: "PR", singular: "pull request", }); - expect(getChangeRequestTerminologyForKind("azure-devops")).toEqual({ - shortLabel: "PR", - singular: "pull request", - }); - expect(getChangeRequestTerminologyForKind("bitbucket")).toEqual({ + expect( + getChangeRequestTerminologyForKind(SourceControlProviderKind.make("azure-devops")), + ).toEqual({ shortLabel: "PR", singular: "pull request", }); + expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("bitbucket"))).toEqual( + { + shortLabel: "PR", + singular: "pull request", + }, + ); }); it("falls back to generic change request copy for unknown providers", () => { expect( - resolveChangeRequestPresentation({ kind: "unknown", name: "forge", baseUrl: "" }), + resolveChangeRequestPresentation({ + kind: SourceControlProviderKind.make("unknown"), + name: "forge", + baseUrl: "", + }), ).toEqual( expect.objectContaining({ shortName: "change request", @@ -69,7 +78,7 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { }, ); } - expect(getChangeRequestTerminologyForKind("forgejo")).toEqual({ + expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("forgejo"))).toEqual({ shortLabel: "PR", singular: "pull request", }); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index be09c017767e76e20ebd97557c6f826789903644..f8c1f99c5d30f878f2281d65a3a36bd1bfb85300 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -1,8 +1,5 @@ -import type { - RepositoryIdentity, - SourceControlProviderInfo, - SourceControlProviderKind, -} from "@t3tools/contracts"; +import { SourceControlProviderKind } from "@t3tools/contracts"; +import type { RepositoryIdentity, SourceControlProviderInfo } from "@t3tools/contracts"; export interface ChangeRequestPresentation { readonly icon: @@ -124,7 +121,7 @@ export function resolveChangeRequestPresentation( return BITBUCKET_CHANGE_REQUEST_PRESENTATION; case "gitcafe": return GITCAFE_CHANGE_REQUEST_PRESENTATION; - case "unknown": + default: return GENERIC_CHANGE_REQUEST_PRESENTATION; } } @@ -244,7 +241,11 @@ export function detectSourceControlProviderFromRemoteUrl( const hostname = parseHostName(host); if (isGitCafeHost(hostname)) { - return { kind: "gitcafe", name: "GitCafe", baseUrl: toBaseUrl(hostname) }; + return { + kind: SourceControlProviderKind.make("gitcafe"), + name: "GitCafe", + baseUrl: toBaseUrl(hostname), + }; } if ( @@ -253,7 +254,7 @@ export function detectSourceControlProviderFromRemoteUrl( hasDnsLabel(hostname, "gitea") ) { return { - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), name: "Forgejo", baseUrl: /^https?:/iu.test(remoteUrl.trim()) ? new URL(remoteUrl.trim()).origin @@ -263,7 +264,7 @@ export function detectSourceControlProviderFromRemoteUrl( if (isGitHubHost(hostname)) { return { - kind: "github", + kind: SourceControlProviderKind.make("github"), name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted", baseUrl: toBaseUrl(host), }; @@ -271,7 +272,7 @@ export function detectSourceControlProviderFromRemoteUrl( if (isGitLabHost(hostname)) { return { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", baseUrl: toBaseUrl(host), }; @@ -279,7 +280,7 @@ export function detectSourceControlProviderFromRemoteUrl( if (isAzureDevOpsHost(hostname)) { return { - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), name: "Azure DevOps", baseUrl: toBaseUrl(host), }; @@ -287,14 +288,14 @@ export function detectSourceControlProviderFromRemoteUrl( if (isBitbucketHost(hostname)) { return { - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", baseUrl: toBaseUrl(host), }; } return { - kind: "unknown", + kind: SourceControlProviderKind.make("unknown"), name: host, baseUrl: toBaseUrl(host), }; diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts index b30356bba7b5bdb4ae79c77d8b91d2de939ca04d..965726c223b10f2f2471768933f8dd057aba08e9 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Semaphore from "effect/Semaphore"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; @@ -157,7 +158,7 @@ export const make = Effect.gen(function* () { const fail = (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) => new PullRequestProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation, ...azureDevOpsProviderFailure(error), detail: error.detail, @@ -168,7 +169,7 @@ export const make = Effect.gen(function* () { const unsupported = (operation: string) => Effect.fail( new PullRequestProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation, reason: "failed", detail: "Azure DevOps reviews cannot be written from here yet.", @@ -296,7 +297,7 @@ export const make = Effect.gen(function* () { }; const provider: PullRequestProviderApi = { - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), capabilities: CAPABILITIES, // The bare repository name repeats across an organization; the project path does not. repositoryKey: ({ canonicalKey }) => canonicalKey, @@ -612,7 +613,7 @@ export const make = Effect.gen(function* () { listReviewerCandidates: () => Effect.fail( new PullRequestProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "listReviewerCandidates", reason: "failed", detail: "Azure DevOps cannot say who may review a pull request.", diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts index f0362851596de784b4612ba1fc82c2a9b4af5f68..f4a8b2ec9c0e35d0d94195b00d86d5766cfee5ad 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -35,7 +36,7 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" }); assert.deepStrictEqual(changeRequest, { - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), number: 42, title: "Add Azure provider", url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts index f935fb808709f4223cefcd75cc4e4f2d0ab4b4fa..efecb2b3ec3a46ab633c00d0e08b776fe71c0f77 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; @@ -39,7 +40,7 @@ function parseAzureAuth(input: SourceControlAuthProbeInput) { export const discovery = { type: "cli", - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), label: "Azure DevOps", executable: "az", versionArgs: ["--version"], @@ -67,7 +68,7 @@ function toChangeRequest(summary: { readonly updatedAt: ChangeRequest["updatedAt"]; }): ChangeRequest { return { - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), number: summary.number, title: summary.title, url: summary.url, @@ -86,7 +87,7 @@ export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; return SourceControlProvider.SourceControlProvider.of({ - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), listChangeRequests: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); return azure @@ -102,7 +103,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "listChangeRequests", command: error.command, cwd: input.cwd, @@ -121,7 +122,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "getChangeRequest", command: error.command, cwd: input.cwd, @@ -149,7 +150,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "createChangeRequest", command: error.command, cwd: input.cwd, @@ -167,7 +168,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "getRepositoryCloneUrls", command: error.command, cwd: input.cwd, @@ -184,7 +185,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "createRepository", command: error.command, cwd: input.cwd, @@ -201,7 +202,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "getDefaultBranch", command: error.command, cwd: input.cwd, @@ -221,7 +222,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "checkoutChangeRequest", command: error.command, cwd: input.cwd, diff --git a/packages/source-control-azure-devops/src/server/driver.ts b/packages/source-control-azure-devops/src/server/driver.ts index 7a0c42c9f3ca4060a52673aa5b2989d695971845..5545ece49f8237025f803f76ce2ae85060c1de1e 100644 --- a/packages/source-control-azure-devops/src/server/driver.ts +++ b/packages/source-control-azure-devops/src/server/driver.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * The Azure DevOps driver: repository operations, discovery, and pull requests through `az`. * @@ -10,7 +11,7 @@ import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvide import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), make: Effect.all({ sourceControl: AzureDevOpsSourceControlProvider.make, discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery), diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts index 0abcb7d95e7e745350044265fea70c145aae5fba..d09ec748b3ef58d0818c0cf3dcffe8bd26e6f5a5 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; @@ -122,7 +123,7 @@ export const make = Effect.gen(function* () { const fail = (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) => new PullRequestProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation, ...bitbucketProviderFailure(error), // Every Bitbucket failure states its own fact; this names the operation around it, so @@ -157,7 +158,7 @@ export const make = Effect.gen(function* () { }; const provider: PullRequestProviderApi = { - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), capabilities: CAPABILITIES, // Bitbucket credentials come from the server's environment rather than a checkout, so the @@ -363,7 +364,7 @@ export const make = Effect.gen(function* () { setReaction: () => Effect.fail( new PullRequestProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "setReaction", reason: "failed", detail: "Bitbucket does not support reactions.", diff --git a/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts b/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts index 52a15547c57329cb2930887e7e88596576727e0f..e3e4b669c5e709f3a41201a4343362316959fce1 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -36,7 +37,7 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( }); assert.deepStrictEqual(changeRequest, { - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), number: 42, title: "Add Bitbucket provider", url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", diff --git a/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts index b9afd60bc389657b4d7c0c04decbab9747a69a0c..e4b4b1311ab4302e2621cc05badc79fa13cff39a 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; @@ -9,7 +10,7 @@ import type { SourceControlApiDiscoverySpec } from "@t3tools/source-control-core function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { return { - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), number: summary.number, title: summary.title, url: summary.url, @@ -34,7 +35,7 @@ export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; return SourceControlProvider.SourceControlProvider.of({ - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), listChangeRequests: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); return bitbucket @@ -51,7 +52,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "listChangeRequests", cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( @@ -69,7 +70,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "getChangeRequest", cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( @@ -97,7 +98,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "createChangeRequest", cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( @@ -114,7 +115,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "getRepositoryCloneUrls", cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( @@ -130,7 +131,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "createRepository", cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( @@ -151,7 +152,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "getDefaultBranch", cwd: input.cwd, detail: "Failed to get default branch.", @@ -171,7 +172,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "checkoutChangeRequest", cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( @@ -190,7 +191,7 @@ export const makeDiscovery = Effect.gen(function* () { return { type: "api", - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), label: "Bitbucket", installHint: "Add a Bitbucket token in Settings → Source Control.", probeAuth: bitbucket.probeAuth, diff --git a/packages/source-control-bitbucket/src/server/driver.ts b/packages/source-control-bitbucket/src/server/driver.ts index 06cd225ab4e4cdb21a12a81146c01939eaa6da45..871970423e97dd4bee10d80e84688eaf4cccd751 100644 --- a/packages/source-control-bitbucket/src/server/driver.ts +++ b/packages/source-control-bitbucket/src/server/driver.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * The Bitbucket Cloud driver: repository operations, discovery, and pull requests through the * Bitbucket REST API. @@ -11,7 +12,7 @@ import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), make: Effect.all({ sourceControl: BitbucketSourceControlProvider.make, discovery: BitbucketSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-core/src/server/SourceControlRateLimit.test.ts b/packages/source-control-core/src/server/SourceControlRateLimit.test.ts index e46005c73f0e73cd34783832037ed8a59504e190..be0451954f18a56d00102e87f14ecfedff25070a 100644 --- a/packages/source-control-core/src/server/SourceControlRateLimit.test.ts +++ b/packages/source-control-core/src/server/SourceControlRateLimit.test.ts @@ -1,10 +1,11 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as TestClock from "effect/testing/TestClock"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; -const github = { provider: "github" as const, host: "github.com" }; +const github = { provider: SourceControlProviderKind.make("github"), host: "github.com" }; it.effect("isolates cooldowns for verified credentials on the same host", () => Effect.gen(function* () { @@ -41,7 +42,7 @@ it.effect("backs off repeated rate limits until a successful request", () => const firstPause = yield* Effect.flip(limits.check(github)); assert.deepInclude(firstPause, { _tag: "SourceControlRateLimitPausedError", - provider: "github", + provider: SourceControlProviderKind.make("github"), host: "github.com", retryAt: 30_000, }); @@ -105,8 +106,20 @@ it.effect("keeps providers and hosts isolated", () => const lease = yield* limits.check(github); yield* limits.recordRateLimit({ ...github, lease }); - assert.equal(yield* limits.check({ provider: "gitlab", host: "github.com" }), 0); - assert.equal(yield* limits.check({ provider: "github", host: "github.example.com" }), 0); + assert.equal( + yield* limits.check({ + provider: SourceControlProviderKind.make("gitlab"), + host: "github.com", + }), + 0, + ); + assert.equal( + yield* limits.check({ + provider: SourceControlProviderKind.make("github"), + host: "github.example.com", + }), + 0, + ); }).pipe(Effect.provide(SourceControlRateLimit.layer)), ); diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index 4a2d8afba121e525bcccacf187ffc0bbd55ec109..51b3974a6bd26166d3773bd38ffc26d670351114 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Result from "effect/Result"; @@ -67,7 +68,7 @@ export const make = Effect.gen(function* () { const cli = yield* ForgejoCli.ForgejoCli; const failure = (operation: string, detail: string, cause?: unknown) => new PullRequestProviderError({ - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), operation, reason: "failed", detail, @@ -78,7 +79,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new PullRequestProviderError({ - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), operation: input.path, detail: error.detail, cause: error, @@ -203,7 +204,7 @@ export const make = Effect.gen(function* () { const unsupported = (operation: string) => Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`)); const provider: PullRequestProviderApi = { - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), capabilities: CAPABILITIES, getViewer, listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")( diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index e3dae3a6c529ade590a782cfe8b0f35817379701..468d1402eb1da9e0840321d624d4b4e7df79b9ba 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -258,7 +259,11 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the }); const served = (baseUrl: string) => () => Effect.succeed({ - provider: { kind: "forgejo" as const, name: "Forgejo / Gitea", baseUrl }, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo / Gitea", + baseUrl, + }, remoteName: "origin", remoteUrl: "", }); @@ -271,7 +276,7 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the }), { ...identity("http://forge.test:3000/git/team/repo.git"), - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), webUrl: "http://forge.test:3000/git/team/repo", }, ); @@ -557,7 +562,7 @@ it.effect("discovers Forgejo accounts and retains the server port", () => cwd: "/repo", context: { provider: { - kind: "unknown", + kind: SourceControlProviderKind.make("unknown"), name: "git.forgejo.local", baseUrl: "https://git.forgejo.local", }, @@ -577,7 +582,7 @@ it.effect("discovers Forgejo accounts and retains the server port", () => ), }); assert.deepStrictEqual(refined, { - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), name: "Forgejo / Gitea", baseUrl: "http://forgejo.local:3000", }); @@ -622,7 +627,11 @@ it.effect("does not choose a default Forgejo login across ambiguous SSH server p const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ cwd: "/repo", context: { - provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" }, + provider: { + kind: SourceControlProviderKind.make("unknown"), + name: "Forgejo", + baseUrl: "https://forgejo.local", + }, remoteName: "origin", remoteUrl: "git@forgejo.local:maria/project.git", requestedHost: "forgejo.local:4000", @@ -732,7 +741,11 @@ it.effect("routes mounted Forgejo repositories without repeating the mount in AP cwd: "/repo", repository: "forgejo/maria/project", context: { - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo", + baseUrl: "https://code.test/forgejo", + }, remoteName: "origin", remoteUrl: "https://code.test/forgejo/maria/project.git", }, @@ -744,7 +757,11 @@ it.effect("routes mounted Forgejo repositories without repeating the mount in AP cwd: "/repo", repository: "forgejo/project", context: { - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo", + baseUrl: "https://code.test/forgejo", + }, remoteName: "origin", remoteUrl: "ssh://git@code.test/forgejo/project.git", }, @@ -827,7 +844,7 @@ it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => { repository: "maria/project", context: { provider: { - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), name: "Forgejo", baseUrl: "http://forgejo.local:3000", }, diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts index ab20a027f7774ff3a5db94b3fd7e3ee0f8a1b00a..4ea8e16ed35ff2c5afdccd71c0e2987cf53c8feb 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; @@ -18,7 +19,7 @@ const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError); export const discovery = { type: "cli", - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), label: "Forgejo / Gitea", executable: "tea", versionArgs: ["--version"], @@ -47,7 +48,13 @@ export const discovery = { remote, input.context.requestedHost, ); - return login ? { kind: "forgejo", name: "Forgejo / Gitea", baseUrl: login.url } : null; + return login + ? { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo / Gitea", + baseUrl: login.url, + } + : null; }, installHint: "Install `fj` 0.6 or later from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj --host auth add-token`, or install `tea` 0.16 or later from https://gitea.com/gitea/tea and run `tea login add` for each Forgejo or Gitea server.", @@ -60,7 +67,7 @@ export const makeDiscovery = Effect.gen(function* () { if (!listLogins) return discovery; return { type: "managed-cli", - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), label: discovery.label, installHint: discovery.installHint, probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) { @@ -150,7 +157,12 @@ export const makeDiscovery = Effect.gen(function* () { remoteUrl: input.context.remoteUrl, }).pipe(Effect.orElseSucceed(() => [])); const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost); - if (login) return { kind: "forgejo" as const, name: discovery.label, baseUrl: login.url }; + if (login) + return { + kind: SourceControlProviderKind.make("forgejo"), + name: discovery.label, + baseUrl: login.url, + }; } return null; }, @@ -188,7 +200,7 @@ const refineRepositoryIdentity: NonNullable< const context = yield* resolveContext({ cwd: identity.rootPath, context: { - provider: { kind: "unknown", name: "Unknown", baseUrl: "" }, + provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" }, remoteName: identity.locator.remoteName, remoteUrl: identity.locator.remoteUrl, }, @@ -200,7 +212,11 @@ const refineRepositoryIdentity: NonNullable< !remote.ssh && basePath && remote.path.startsWith(`${basePath}/`) ? remote.path.slice(basePath.length + 1) : remote.path; - return { ...identity, provider: "forgejo", webUrl: `${baseUrl}/${path}` }; + return { + ...identity, + provider: SourceControlProviderKind.make("forgejo"), + webUrl: `${baseUrl}/${path}`, + }; }); const RepositorySchema = Schema.Struct({ @@ -245,7 +261,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause: unknown) => new SourceControlProviderError({ - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), operation, cwd, ...(isForgejoCliError(cause) ? { command: cause.command } : {}), @@ -272,7 +288,7 @@ export const make = Effect.gen(function* () { ); }); return SourceControlProvider.SourceControlProvider.of({ - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), repositoryNameFromRemoteUrl, refineRepositoryIdentity, listChangeRequests: (input) => diff --git a/packages/source-control-forgejo/src/server/driver.ts b/packages/source-control-forgejo/src/server/driver.ts index ae76d7c6409a682544348fa5efcf6926a501f935..d2fe9ee4e4c328fbd7019c66ff0102b469c5307c 100644 --- a/packages/source-control-forgejo/src/server/driver.ts +++ b/packages/source-control-forgejo/src/server/driver.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * The Forgejo / Gitea driver: repository operations, discovery, and pull requests through * `fj` or `tea`. @@ -11,7 +12,7 @@ import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), make: Effect.all({ sourceControl: ForgejoSourceControlProvider.make, discovery: ForgejoSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-forgejo/src/server/forgejoPullRequests.ts b/packages/source-control-forgejo/src/server/forgejoPullRequests.ts index affb9bae74c7de80ae0e07fd6a631ab0285b7d6a..dc084d9325327e2fba67c8b2ca9dbf0e1f2b66eb 100644 --- a/packages/source-control-forgejo/src/server/forgejoPullRequests.ts +++ b/packages/source-control-forgejo/src/server/forgejoPullRequests.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import type { ChangeRequest } from "@t3tools/contracts"; @@ -26,7 +27,7 @@ export const ForgejoPullRequestSchema = Schema.Struct({ }); export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type): ChangeRequest { return { - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), number: raw.number, title: raw.title, url: raw.html_url, diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index 7ea6a1e6eb87f011d5dc8f578e4a2ca19a585854..70d8c107ea0b250f3f8f0ba7b0055618843417ce 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -176,7 +177,7 @@ export const make = Effect.gen(function* () { const failure = (operation: string, detail: string, cause?: unknown) => new PullRequestProviderError({ - provider: "gitcafe", + provider: SourceControlProviderKind.make("gitcafe"), operation, reason: "failed", detail, @@ -207,7 +208,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new PullRequestProviderError({ - provider: "gitcafe", + provider: SourceControlProviderKind.make("gitcafe"), operation, reason: error.reason, detail: error.detail, @@ -475,7 +476,7 @@ export const make = Effect.gen(function* () { }); const provider: PullRequestProviderApi = { - kind: "gitcafe", + kind: SourceControlProviderKind.make("gitcafe"), capabilities: CAPABILITIES, getViewer: (input) => read( diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts index aaaf5d0e90ffd35fa1072b9bff3fc5af515dfed6..996925033eb3a62899277e6f242d098c7b5df760 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * Pins `./GitCafeSourceControlProvider.ts`, which pass 2 creates: * @@ -87,7 +88,11 @@ describe("GitCafeSourceControlProvider", () => { ); }); const context = { - provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: `https://${host}` }, + provider: { + kind: SourceControlProviderKind.make("gitcafe"), + name: "GitCafe", + baseUrl: `https://${host}`, + }, remoteName: "origin", remoteUrl, }; @@ -220,7 +225,11 @@ describe("GitCafeSourceControlProvider", () => { remoteUrl === undefined ? undefined : { - provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: "https://git.cafe" }, + provider: { + kind: SourceControlProviderKind.make("gitcafe"), + name: "GitCafe", + baseUrl: "https://git.cafe", + }, remoteName: "origin", remoteUrl, }; diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts index 5d55bd72bac3bd5b6cff91ab9d89eae53b67710a..5e271261f1e57e40edaee750bd681f5722ac4ab3 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; @@ -59,7 +60,7 @@ const cliFailure = (output: string) => export const discovery = { type: "cli", - kind: "gitcafe", + kind: SourceControlProviderKind.make("gitcafe"), label: "GitCafe", executable: "cafe", versionArgs: ["--version"], @@ -150,7 +151,7 @@ export const make = Effect.gen(function* () { const error = (operation: string, cwd: string, detail: string, cause?: unknown) => new SourceControlProviderError({ - provider: "gitcafe", + provider: SourceControlProviderKind.make("gitcafe"), operation, cwd, detail, @@ -269,7 +270,7 @@ export const make = Effect.gen(function* () { ); const owner = target.repository.split("/")[0] ?? null; return { - provider: "gitcafe", + provider: SourceControlProviderKind.make("gitcafe"), number: pull.number, title: pull.title, url: `https://${target.host}/${target.repository}/pulls/${pull.number}`, @@ -290,7 +291,7 @@ export const make = Effect.gen(function* () { }); return SourceControlProvider.SourceControlProvider.of({ - kind: "gitcafe", + kind: SourceControlProviderKind.make("gitcafe"), repositoryNameFromRemoteUrl: (url) => GitCafeHosts.parseGitCafeRemote(url)?.repository ?? null, getChangeRequest: getPull, listChangeRequests: Effect.fn("GitCafeSourceControlProvider.listChangeRequests")( diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts index 467614c2ec1c570c5d8a93ac43b5f46028bb0430..6fc49c393f21a72900e57013aaa57bc177c6f8e5 100644 --- a/packages/source-control-gitcafe/src/server/driver.ts +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * The GitCafe driver: repository operations and discovery through `cafe` and git, and pull * requests through GitCafe's REST API on git.cafe and staging.git.cafe. @@ -11,7 +12,7 @@ import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: "gitcafe", + kind: SourceControlProviderKind.make("gitcafe"), make: Effect.all({ sourceControl: GitCafeSourceControlProvider.make, discovery: Effect.succeed(GitCafeSourceControlProvider.discovery), diff --git a/packages/source-control-github/src/server/GitHubApi.ts b/packages/source-control-github/src/server/GitHubApi.ts index 111281a0484eefec53d6a19fdf8b49606768c644..29d8b2904b1ff6671f55debc70955d2e6ffd3bac 100644 --- a/packages/source-control-github/src/server/GitHubApi.ts +++ b/packages/source-control-github/src/server/GitHubApi.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; @@ -389,7 +390,7 @@ export const make = Effect.gen(function* () { // this reads today is `core`; a `search/` read would need its own resource here. A refusal // still pauses the whole host, the key PullRequestService records its own backoff under. const resource = input.graphql === true ? "graphql" : "core"; - const key = { provider: "github" as const, host }; + const key = { provider: SourceControlProviderKind.make("github"), host }; const run = Effect.gen(function* () { const lease = yield* quota .admit(host, resource, { allowReserve: input.allowReserve }) diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index 7b74bd368b818a39c0920788757251e4afeea8bb..b99972d58af036b9011f9a5ad0ad135e015ad6f5 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; import * as Effect from "effect/Effect"; import type { @@ -202,7 +203,7 @@ export const make = Effect.gen(function* () { const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) => new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation, ...gitHubProviderFailure(error), detail: error.message, @@ -261,7 +262,7 @@ export const make = Effect.gen(function* () { ); const provider: PullRequestProviderApi = { - kind: "github", + kind: SourceControlProviderKind.make("github"), capabilities: CAPABILITIES, mergeMessageRewrite: removeAgentCredits, getRoutingIdentity: (input) => diff --git a/packages/source-control-github/src/server/GitHubQuota.ts b/packages/source-control-github/src/server/GitHubQuota.ts index 5dcd5469b9edc29a2457d94cbab24960b9680fce..3bdffb80e6292e63a922b0a195f6222507a652f3 100644 --- a/packages/source-control-github/src/server/GitHubQuota.ts +++ b/packages/source-control-github/src/server/GitHubQuota.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -86,7 +87,7 @@ const make = Effect.gen(function* () { const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO; if (snapshot.remaining >= floor) return; return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), host: host.trim().toLowerCase(), retryAt: snapshot.resetAtMs, }); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index 7a902965b7aad38f911500db61bb4deff5506773..73475840ae22185ecf4cf5b52bd7f38a9618f55f 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -137,7 +138,11 @@ const makeProvider = (api: Partial) => GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer)); const githubContext = (host: string) => ({ - provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, + provider: { + kind: SourceControlProviderKind.make("github"), + name: "GitHub", + baseUrl: `https://${host}`, + }, remoteName: "origin", remoteUrl: `git@${host}:acme/web.git`, }); @@ -1067,7 +1072,7 @@ it.live.each([ cwd: "/repo", context: { provider: { - kind: "github" as const, + kind: SourceControlProviderKind.make("github"), name: "GitHub Self-Hosted", baseUrl: "https://code.example.test", }, @@ -1184,7 +1189,11 @@ it.effect.each([ const input = { cwd: "/repo", context: { - provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, + provider: { + kind: SourceControlProviderKind.make("github"), + name: "GitHub", + baseUrl: `https://${host}`, + }, remoteName: "origin", remoteUrl: `git@${host}:me/web.git`, }, diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 6cf169a1b7546843c4dcc56d05d001371aa177ec..66ac324aaed59fd47cf1d3533c80385e78710a22 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -62,7 +63,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( ); function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest { - return { provider: "github", ...record }; + return { provider: SourceControlProviderKind.make("github"), ...record }; } function authAccounts(accounts: ReadonlyArray) { @@ -164,7 +165,7 @@ export function parseGitHubAuth( export const discovery = { type: "cli", - kind: "github", + kind: SourceControlProviderKind.make("github"), label: "GitHub", executable: "gh", versionArgs: ["--version"], @@ -271,7 +272,7 @@ export const makeDiscovery = Effect.gen(function* () { Effect.map((known) => known ? ({ - kind: "github", + kind: SourceControlProviderKind.make("github"), name: "GitHub Self-Hosted", baseUrl: context.provider.baseUrl, } as const) @@ -878,7 +879,7 @@ export const make = Effect.gen(function* () { ) => (error: GitHubFailure) => new SourceControlProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation, cwd, ...(context?.reference === undefined @@ -915,7 +916,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new SourceControlProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "resolveLink", cwd: input.cwd, detail: "The linked subject could not be read.", @@ -927,7 +928,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new SourceControlProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "resolveLink.decode", cwd: input.cwd, detail: "The linked subject could not be read.", @@ -939,7 +940,7 @@ export const make = Effect.gen(function* () { }); return SourceControlProvider.SourceControlProvider.of({ - kind: "github", + kind: SourceControlProviderKind.make("github"), // `gh pr list --head` filters on the head ref name alone and accepts anything, so an // `owner:branch` or `remote:branch` selector silently lists zero pull requests while // spending a GraphQL call; the bare branch is always among the selectors. Without the owner, diff --git a/packages/source-control-github/src/server/driver.ts b/packages/source-control-github/src/server/driver.ts index d57ba07b2017016ac86b86cbc6f634d1990e8cdf..cc13f4f78fa37c3a7b6bb46be48126a3f6821fd5 100644 --- a/packages/source-control-github/src/server/driver.ts +++ b/packages/source-control-github/src/server/driver.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * The GitHub driver: repository operations and discovery through `gh` and git, and pull requests * through the GitHub REST and GraphQL APIs. @@ -11,7 +12,7 @@ import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: "github", + kind: SourceControlProviderKind.make("github"), make: Effect.all({ sourceControl: GitHubSourceControlProvider.make, discovery: GitHubSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index 1b5b987cd5acb3c25dc6fd8f080b703c6a07d6c9..db6548eb18f32b5b692f5472983ae5460c6bc315 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import type { PullRequestCapabilities, @@ -111,7 +112,7 @@ export const make = Effect.gen(function* () { const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) => new PullRequestProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation, ...gitLabProviderFailure(error), detail: error.detail, @@ -119,7 +120,7 @@ export const make = Effect.gen(function* () { }); const provider: PullRequestProviderApi = { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), capabilities: CAPABILITIES, getViewer: (input) => diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts index 513c2e2fc3b5070f14ed7b0ebefff489441f5517..ccfc49aff208e0b4ab71b6ca2c66c4f3d0f40e58 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -38,7 +39,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = }); assert.deepStrictEqual(changeRequest, { - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), number: 42, title: "Add GitLab provider", url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", @@ -189,7 +190,7 @@ it("refines unknown GitLab remotes with mixed-case provider hosts", () => { cwd: "/repo", context: { provider: { - kind: "unknown", + kind: SourceControlProviderKind.make("unknown"), name: "Self-Hosted.Example.Test", baseUrl: "https://Self-Hosted.Example.Test", }, diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index ef63f11cfdf24553ef43de44df9d89d29c0eb77b..6c15029d0e3f122bd6dd7e5b0723f3883d4f5641 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -25,7 +26,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { return { - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), number: summary.number, title: summary.title, url: summary.url, @@ -90,7 +91,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn } return { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), name: "GitLab Self-Hosted", baseUrl: input.context.provider.baseUrl, } as const; @@ -98,7 +99,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn export const discovery = { type: "cli", - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), label: "GitLab", executable: "glab", versionArgs: ["--version"], @@ -127,7 +128,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "resolveLink", cwd: input.cwd, detail: "The linked subject could not be read.", @@ -139,7 +140,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "resolveLink.decode", cwd: input.cwd, detail: "The linked subject could not be read.", @@ -151,7 +152,7 @@ export const make = Effect.gen(function* () { }); return SourceControlProvider.SourceControlProvider.of({ - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), resolveLink: (input) => { // Automatic enrichment must not send ambient CLI credentials to a host from message text. if (input.url.host !== "gitlab.com") return undefined; @@ -179,7 +180,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "listChangeRequests", command: error.command, cwd: input.cwd, @@ -198,7 +199,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getChangeRequest", command: error.command, cwd: input.cwd, @@ -226,7 +227,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "createChangeRequest", command: error.command, cwd: input.cwd, @@ -244,7 +245,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getRepositoryCloneUrls", command: error.command, cwd: input.cwd, @@ -261,7 +262,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "createRepository", command: error.command, cwd: input.cwd, @@ -278,7 +279,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getDefaultBranch", command: error.command, cwd: input.cwd, @@ -292,7 +293,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "checkoutChangeRequest", command: error.command, cwd: input.cwd, diff --git a/packages/source-control-gitlab/src/server/driver.ts b/packages/source-control-gitlab/src/server/driver.ts index 29327a35019b2b84da2dfb2370daff02436449b7..56d75ccc64de478fe728d2206ca52a81e36dac6a 100644 --- a/packages/source-control-gitlab/src/server/driver.ts +++ b/packages/source-control-gitlab/src/server/driver.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * The GitLab driver: repository operations, discovery, and merge requests through `glab`. * @@ -10,7 +11,7 @@ import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), make: Effect.all({ sourceControl: GitLabSourceControlProvider.make, discovery: Effect.succeed(GitLabSourceControlProvider.discovery),