authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 12:06:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 12:06:59-07:00
logfd3549c8ffa5eac8230a8b5e1a87e59d28ba488e
tree5177c0522b7004cfd5bc7ec8a139624b222d3599
parentdacd2cb649eea06a00244bd181eb400735e923d5
signature Signed by PGP key B5690EEEBB952194

refactor(contracts): source control provider kind is an open branded slug (#17739)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

74 files changed, 816 insertions(+), 551 deletions(-)

apps/mobile/src/features/projects/AddProjectScreen.tsx+3-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { MaterialListRow } from "../../components/MaterialListRow";2import { MaterialListRow } from "../../components/MaterialListRow";
2import { SettingsScreen } from "../settings/components/SettingsScreen";3import { SettingsScreen } from "../settings/components/SettingsScreen";
3import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";4import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
...@@ -812,7 +813,7 @@ export function AddProjectRepositoryScreen(props: {...@@ -812,7 +813,7 @@ export function AddProjectRepositoryScreen(props: {
812 const result = await lookupRepositoryQuery({813 const result = await lookupRepositoryQuery({
813 environmentId: environment.environmentId,814 environmentId: environment.environmentId,
814 input: {815 input: {
815 provider,816 provider: SourceControlProviderKind.make(provider),
816 repository: repositoryInput.trim(),817 repository: repositoryInput.trim(),
817 },818 },
818 });819 });
...@@ -1079,7 +1080,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s...@@ -1079,7 +1080,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
1079 environmentId: environment.environmentId,1080 environmentId: environment.environmentId,
1080 input: {1081 input: {
1081 cwd: workspaceRoot,1082 cwd: workspaceRoot,
1082 provider: "github",1083 provider: SourceControlProviderKind.make("github"),
1083 repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot),1084 repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot),
1084 visibility: "private",1085 visibility: "private",
1085 },1086 },
apps/mobile/src/state/use-thread-pr.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { ProjectId, type ThreadPullRequestLink, type VcsStatusResult } from "@t3tools/contracts";2import { ProjectId, type ThreadPullRequestLink, type VcsStatusResult } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
34
...@@ -28,7 +29,7 @@ describe("presentThreadPr", () => {...@@ -28,7 +29,7 @@ describe("presentThreadPr", () => {
28 it("uses merge-request terminology for GitLab", () => {29 it("uses merge-request terminology for GitLab", () => {
29 expect(30 expect(
30 presentThreadPr(pullRequest, {31 presentThreadPr(pullRequest, {
31 kind: "gitlab",32 kind: SourceControlProviderKind.make("gitlab"),
32 name: "GitLab",33 name: "GitLab",
33 baseUrl: "https://gitlab.com",34 baseUrl: "https://gitlab.com",
34 }),35 }),
apps/server/src/git/GitManager.test.ts+15-5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1// @effect-diagnostics nodeBuiltinImport:off2// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";3import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";4import * as NodePath from "node:path";
...@@ -410,7 +411,13 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -410,7 +411,13 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
410 const ghCalls: string[] = [];411 const ghCalls: string[] = [];
411412
412 const fail = (cwd: string, detail: string, cause?: unknown, operation = "fakeGh") =>413 const fail = (cwd: string, detail: string, cause?: unknown, operation = "fakeGh") =>
413 new SourceControlProviderFailure({ provider: "github", operation, cwd, detail, cause });414 new SourceControlProviderFailure({
415 provider: SourceControlProviderKind.make("github"),
416 operation,
417 cwd,
418 detail,
419 cause,
420 });
414421
415 /** `operation` is the provider method the call answers, which a failure reports. */422 /** `operation` is the provider method the call answers, which a failure reports. */
416 const execute = (input: {423 const execute = (input: {
...@@ -534,7 +541,7 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -534,7 +541,7 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
534 /** What GitHubSourceControlProvider makes of a pull request it read. */541 /** What GitHubSourceControlProvider makes of a pull request it read. */
535 const toChangeRequest = (summary: FakePullRequestSummary): ChangeRequest => ({542 const toChangeRequest = (summary: FakePullRequestSummary): ChangeRequest => ({
536 ...summary,543 ...summary,
537 provider: "github",544 provider: SourceControlProviderKind.make("github"),
538 state: summary.state ?? "open",545 state: summary.state ?? "open",
539 closedAt: summary.closedAt ?? null,546 closedAt: summary.closedAt ?? null,
540 mergedAt: summary.mergedAt ?? null,547 mergedAt: summary.mergedAt ?? null,
...@@ -546,7 +553,7 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -546,7 +553,7 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
546553
547 return {554 return {
548 service: {555 service: {
549 kind: "github",556 kind: SourceControlProviderKind.make("github"),
550 // The GitHub provider's own lookup rule and template convention, which GitManager reads557 // The GitHub provider's own lookup rule and template convention, which GitManager reads
551 // instead of the kind.558 // instead of the kind.
552 headBranchProbe: ({ headSelectors }) => ({559 headBranchProbe: ({ headSelectors }) => ({
...@@ -602,7 +609,10 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -602,7 +609,10 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
602 if (raw.length === 0) return [];609 if (raw.length === 0) return [];
603 const decoded = decodeGitHubPullRequestListJson(raw);610 const decoded = decodeGitHubPullRequestListJson(raw);
604 return Result.isSuccess(decoded)611 return Result.isSuccess(decoded)
605 ? decoded.success.map((record) => ({ provider: "github" as const, ...record }))612 ? decoded.success.map((record) => ({
613 provider: SourceControlProviderKind.make("github"),
614 ...record,
615 }))
606 : [];616 : [];
607 }),617 }),
608 ),618 ),
...@@ -2841,7 +2851,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2841,7 +2851,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2841 operation: "lookupStatusPr",2851 operation: "lookupStatusPr",
2842 branch: "feature/status-rate-limited",2852 branch: "feature/status-rate-limited",
2843 errorTag: "SourceControlProviderError",2853 errorTag: "SourceControlProviderError",
2844 provider: "github",2854 provider: SourceControlProviderKind.make("github"),
2845 providerOperation: "listChangeRequests",2855 providerOperation: "listChangeRequests",
2846 errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.",2856 errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.",
2847 });2857 });
apps/server/src/git/GitManager.ts+7-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Arr from "effect/Array";2import * as Arr from "effect/Array";
2import * as Cache from "effect/Cache";3import * as Cache from "effect/Cache";
3import * as Context from "effect/Context";4import * as Context from "effect/Context";
...@@ -1787,7 +1788,9 @@ export const make = Effect.gen(function* () {...@@ -1787,7 +1788,9 @@ export const make = Effect.gen(function* () {
1787 ) {1788 ) {
1788 const terms = yield* sourceControlProvider(cwd).pipe(1789 const terms = yield* sourceControlProvider(cwd).pipe(
1789 Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)),1790 Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)),
1790 Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")),1791 Effect.orElseSucceed(() =>
1792 getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")),
1793 ),
1791 );1794 );
1792 const summary = summarizeGitActionResult(result, terms);1795 const summary = summarizeGitActionResult(result, terms);
1793 let latestOpenPr: PullRequestInfo | null = null;1796 let latestOpenPr: PullRequestInfo | null = null;
...@@ -2853,7 +2856,9 @@ export const make = Effect.gen(function* () {...@@ -2853,7 +2856,9 @@ export const make = Effect.gen(function* () {
2853 const changeRequestTerms = wantsPr2856 const changeRequestTerms = wantsPr
2854 ? yield* sourceControlProvider(input.cwd).pipe(2857 ? yield* sourceControlProvider(input.cwd).pipe(
2855 Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)),2858 Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)),
2856 Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")),2859 Effect.orElseSucceed(() =>
2860 getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")),
2861 ),
2857 )2862 )
2858 : null;2863 : null;
28592864
apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts+4-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Stream from "effect/Stream";2import * as Stream from "effect/Stream";
2import {3import {
3 EventId,4 EventId,
...@@ -148,7 +149,7 @@ function makeSummary(...@@ -148,7 +149,7 @@ function makeSummary(
148 overrides: Partial<PullRequestSummary> = {},149 overrides: Partial<PullRequestSummary> = {},
149): PullRequestSummary {150): PullRequestSummary {
150 return {151 return {
151 provider: "github",152 provider: SourceControlProviderKind.make("github"),
152 projectId: input.projectId,153 projectId: input.projectId,
153 repository: input.repository,154 repository: input.repository,
154 number: input.number,155 number: input.number,
...@@ -1009,7 +1010,7 @@ describe("PullRequestSyncReactor", () => {...@@ -1009,7 +1010,7 @@ describe("PullRequestSyncReactor", () => {
1009 Effect.gen(function* () {1010 Effect.gen(function* () {
1010 if (input.host === "github.com" && (yield* Clock.currentTimeMillis) < retryAt) {1011 if (input.host === "github.com" && (yield* Clock.currentTimeMillis) < retryAt) {
1011 const paused = new PullRequestProviderError({1012 const paused = new PullRequestProviderError({
1012 provider: "github",1013 provider: SourceControlProviderKind.make("github"),
1013 operation: "getChangeRequestSummary",1014 operation: "getChangeRequestSummary",
1014 reason: "rate-limited",1015 reason: "rate-limited",
1015 detail: "paused",1016 detail: "paused",
...@@ -1079,7 +1080,7 @@ describe("PullRequestSyncReactor", () => {...@@ -1079,7 +1080,7 @@ describe("PullRequestSyncReactor", () => {
1079 operation: "stack",1080 operation: "stack",
1080 detail: "paused",1081 detail: "paused",
1081 cause: new PullRequestProviderError({1082 cause: new PullRequestProviderError({
1082 provider: "github",1083 provider: SourceControlProviderKind.make("github"),
1083 operation: "getChangeRequestStack",1084 operation: "getChangeRequestStack",
1084 reason: "rate-limited",1085 reason: "rate-limited",
1085 detail: "paused",1086 detail: "paused",
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { assert, describe, expect, it } from "@effect/vitest";2import { assert, describe, expect, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";3import * as DateTime from "effect/DateTime";
3import {4import {
...@@ -472,7 +473,7 @@ function makePullRequestSummary(input: {...@@ -472,7 +473,7 @@ function makePullRequestSummary(input: {
472 readonly updatedAt?: string;473 readonly updatedAt?: string;
473}): PullRequestSummary {474}): PullRequestSummary {
474 return {475 return {
475 provider: "github",476 provider: SourceControlProviderKind.make("github"),
476 projectId: input.projectId,477 projectId: input.projectId,
477 repository: input.repository,478 repository: input.repository,
478 number: input.number,479 number: input.number,
apps/server/src/orchestration-v2/runtimeLayer.test.ts+4-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts";2import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts";
2import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";3import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
3import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";4import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
...@@ -169,7 +170,7 @@ const watchedPullRequestDetail = (input: {...@@ -169,7 +170,7 @@ const watchedPullRequestDetail = (input: {
169 readonly number: number;170 readonly number: number;
170 readonly at: string;171 readonly at: string;
171}): PullRequestDetail => ({172}): PullRequestDetail => ({
172 provider: "github",173 provider: SourceControlProviderKind.make("github"),
173 capabilities: {174 capabilities: {
174 diff: true,175 diff: true,
175 comment: true,176 comment: true,
...@@ -2572,7 +2573,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {...@@ -2572,7 +2573,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
2572 operation: "getChangeRequest",2573 operation: "getChangeRequest",
2573 detail: "github requests are paused until the rate limit resets",2574 detail: "github requests are paused until the rate limit resets",
2574 cause: new PullRequestProviderError({2575 cause: new PullRequestProviderError({
2575 provider: "github",2576 provider: SourceControlProviderKind.make("github"),
2576 operation: "getChangeRequest",2577 operation: "getChangeRequest",
2577 reason: "rate-limited",2578 reason: "rate-limited",
2578 detail: "paused",2579 detail: "paused",
...@@ -2831,7 +2832,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {...@@ -2831,7 +2832,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
2831 operation: "watchFingerprint",2832 operation: "watchFingerprint",
2832 detail: "paused",2833 detail: "paused",
2833 cause: new PullRequestProviderError({2834 cause: new PullRequestProviderError({
2834 provider: "github",2835 provider: SourceControlProviderKind.make("github"),
2835 operation: "getChangeRequestWatchFingerprint",2836 operation: "getChangeRequestWatchFingerprint",
2836 reason: "rate-limited",2837 reason: "rate-limited",
2837 detail: "paused",2838 detail: "paused",
apps/server/src/project/ProjectCloneTracker.test.ts+3-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
2import {3import {
3 OrchestrationDispatchCommandError,4 OrchestrationDispatchCommandError,
...@@ -116,7 +117,7 @@ describe("ProjectCloneTracker", () => {...@@ -116,7 +117,7 @@ describe("ProjectCloneTracker", () => {
116 ? Effect.fail(117 ? Effect.fail(
117 new SourceControlRepositoryError({118 new SourceControlRepositoryError({
118 operation: "cloneRepository",119 operation: "cloneRepository",
119 provider: "unknown",120 provider: SourceControlProviderKind.make("unknown"),
120 detail: "fatal: repository not found",121 detail: "fatal: repository not found",
121 }),122 }),
122 )123 )
...@@ -255,7 +256,7 @@ describe("ProjectCloneTracker", () => {...@@ -255,7 +256,7 @@ describe("ProjectCloneTracker", () => {
255 Effect.fail(256 Effect.fail(
256 new SourceControlRepositoryError({257 new SourceControlRepositoryError({
257 operation: "cloneRepository",258 operation: "cloneRepository",
258 provider: "unknown",259 provider: SourceControlProviderKind.make("unknown"),
259 detail: "Destination path already exists and is not empty.",260 detail: "Destination path already exists and is not empty.",
260 }),261 }),
261 ),262 ),
apps/server/src/project/ProjectCloneTracker.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import type {2import type {
2 ProjectCloneSnapshot,3 ProjectCloneSnapshot,
3 ProjectCloneStage,4 ProjectCloneStage,
...@@ -301,7 +302,7 @@ export const make = Effect.gen(function* () {...@@ -301,7 +302,7 @@ export const make = Effect.gen(function* () {
301 if (!claimed) {302 if (!claimed) {
302 return yield* new SourceControlRepositoryError({303 return yield* new SourceControlRepositoryError({
303 operation: "cloneRepository",304 operation: "cloneRepository",
304 provider: input.provider ?? "unknown",305 provider: input.provider ?? SourceControlProviderKind.make("unknown"),
305 detail: "A clone into this destination is already in progress.",306 detail: "A clone into this destination is already in progress.",
306 });307 });
307 }308 }
apps/server/src/project/RepositoryIdentityResolver.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not.2// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not.
2import * as NodeFS from "node:fs";3import * as NodeFS from "node:fs";
3import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -71,7 +72,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {...@@ -71,7 +72,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
71 if (refinementFails)72 if (refinementFails)
72 return Effect.fail(73 return Effect.fail(
73 new SourceControlProviderError({74 new SourceControlProviderError({
74 provider: "forgejo",75 provider: SourceControlProviderKind.make("forgejo"),
75 operation: "detectProvider",76 operation: "detectProvider",
76 cwd: rootPath,77 cwd: rootPath,
77 detail: "account unavailable",78 detail: "account unavailable",
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
23
3import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";4import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";
...@@ -59,7 +60,7 @@ it("keeps GitHub's exact retry time", () => {...@@ -59,7 +60,7 @@ it("keeps GitHub's exact retry time", () => {
59 assert.deepStrictEqual(60 assert.deepStrictEqual(
60 gitHubProviderFailure(61 gitHubProviderFailure(
61 new SourceControlRateLimit.SourceControlRateLimitPausedError({62 new SourceControlRateLimit.SourceControlRateLimitPausedError({
62 provider: "github",63 provider: SourceControlProviderKind.make("github"),
63 host: "github.com",64 host: "github.com",
64 retryAt: 1_786_802_400_000,65 retryAt: 1_786_802_400_000,
65 }),66 }),
apps/server/src/pullRequest/PullRequestService.test.ts+295-238
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";3import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
3import * as KeyValueStore from "effect/persistence/KeyValueStore";4import * as KeyValueStore from "effect/persistence/KeyValueStore";
...@@ -17,7 +18,6 @@ import type {...@@ -17,7 +18,6 @@ import type {
17 ProjectId,18 ProjectId,
18 PullRequestReviewCapabilities,19 PullRequestReviewCapabilities,
19 PullRequestReviewerCapabilities,20 PullRequestReviewerCapabilities,
20 SourceControlProviderKind,
21} from "@t3tools/contracts";21} from "@t3tools/contracts";
22import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings";22import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings";
23import type { ServerSettings } from "@t3tools/contracts";23import type { ServerSettings } from "@t3tools/contracts";
...@@ -129,7 +129,7 @@ it.effect("caches narrow previews and invalidates them after refresh or mutation...@@ -129,7 +129,7 @@ it.effect("caches narrow previews and invalidates them after refresh or mutation
129 const service = yield* makeService({129 const service = yield* makeService({
130 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],130 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
131 providers: [131 providers: [
132 fakeProvider("github", {132 fakeProvider(SourceControlProviderKind.make("github"), {
133 getViewer: () => Effect.die("preview must not read the viewer"),133 getViewer: () => Effect.die("preview must not read the viewer"),
134 getChangeRequestPreview: () =>134 getChangeRequestPreview: () =>
135 Effect.sync(() => {135 Effect.sync(() => {
...@@ -175,13 +175,13 @@ it.effect("keeps cached previews available and pauses uncached previews until qu...@@ -175,13 +175,13 @@ it.effect("keeps cached previews available and pauses uncached previews until qu
175 const service = yield* makeService({175 const service = yield* makeService({
176 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],176 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
177 providers: [177 providers: [
178 fakeProvider("github", {178 fakeProvider(SourceControlProviderKind.make("github"), {
179 getChangeRequestPreview: ({ number }) =>179 getChangeRequestPreview: ({ number }) =>
180 Effect.gen(function* () {180 Effect.gen(function* () {
181 reads++;181 reads++;
182 if (reads === 2)182 if (reads === 2)
183 return yield* new PullRequestProviderError({183 return yield* new PullRequestProviderError({
184 provider: "github",184 provider: SourceControlProviderKind.make("github"),
185 operation: "getChangeRequestPreview",185 operation: "getChangeRequestPreview",
186 reason: "rate-limited",186 reason: "rate-limited",
187 detail: "GitHub requests are paused until the rate limit resets.",187 detail: "GitHub requests are paused until the rate limit resets.",
...@@ -218,7 +218,7 @@ it.effect("reuses only unexpired detail for previews", () =>...@@ -218,7 +218,7 @@ it.effect("reuses only unexpired detail for previews", () =>
218 const service = yield* makeService({218 const service = yield* makeService({
219 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],219 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
220 providers: [220 providers: [
221 fakeProvider("github", {221 fakeProvider(SourceControlProviderKind.make("github"), {
222 getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")),222 getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")),
223 getChangeRequestPreview: () =>223 getChangeRequestPreview: () =>
224 Effect.sync(() => {224 Effect.sync(() => {
...@@ -250,7 +250,7 @@ it.effect("shares detail and activity for a minute, and for ten once merged", ()...@@ -250,7 +250,7 @@ it.effect("shares detail and activity for a minute, and for ten once merged", ()
250 const service = yield* makeService({250 const service = yield* makeService({
251 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],251 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
252 providers: [252 providers: [
253 fakeProvider("github", {253 fakeProvider(SourceControlProviderKind.make("github"), {
254 getChangeRequest: ({ number }) =>254 getChangeRequest: ({ number }) =>
255 Effect.sync(() => {255 Effect.sync(() => {
256 reads.push(`detail #${number}`);256 reads.push(`detail #${number}`);
...@@ -314,7 +314,7 @@ it.effect("does not wait for an in-flight detail read to display a preview", ()...@@ -314,7 +314,7 @@ it.effect("does not wait for an in-flight detail read to display a preview", ()
314 const service = yield* makeService({314 const service = yield* makeService({
315 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],315 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
316 providers: [316 providers: [
317 fakeProvider("github", {317 fakeProvider(SourceControlProviderKind.make("github"), {
318 getChangeRequest: () =>318 getChangeRequest: () =>
319 Deferred.succeed(detailStarted, undefined).pipe(319 Deferred.succeed(detailStarted, undefined).pipe(
320 Effect.andThen(Deferred.await(releaseDetail)),320 Effect.andThen(Deferred.await(releaseDetail)),
...@@ -342,7 +342,7 @@ it.effect("keeps previews warm when another project finishes a turn", () =>...@@ -342,7 +342,7 @@ it.effect("keeps previews warm when another project finishes a turn", () =>
342 project({ id: "p2", title: "docs", workspaceRoot: "/d", repository: "acme/docs" }),342 project({ id: "p2", title: "docs", workspaceRoot: "/d", repository: "acme/docs" }),
343 ],343 ],
344 providers: [344 providers: [
345 fakeProvider("github", {345 fakeProvider(SourceControlProviderKind.make("github"), {
346 getChangeRequestPreview: (input) =>346 getChangeRequestPreview: (input) =>
347 Effect.sync(() => {347 Effect.sync(() => {
348 reads.push(input.repository);348 reads.push(input.repository);
...@@ -374,11 +374,11 @@ it.effect("uses full detail for hosts without a narrow preview", () =>...@@ -374,11 +374,11 @@ it.effect("uses full detail for hosts without a narrow preview", () =>
374 title: "web",374 title: "web",
375 workspaceRoot: "/w",375 workspaceRoot: "/w",
376 repository: "acme/web",376 repository: "acme/web",
377 provider: "gitlab",377 provider: SourceControlProviderKind.make("gitlab"),
378 }),378 }),
379 ],379 ],
380 providers: [380 providers: [
381 fakeProvider("gitlab", {381 fakeProvider(SourceControlProviderKind.make("gitlab"), {
382 getChangeRequest: () =>382 getChangeRequest: () =>
383 Effect.sync(() => {383 Effect.sync(() => {
384 reads += 1;384 reads += 1;
...@@ -405,7 +405,7 @@ function unusable(provider: SourceControlProviderKind, reason: "missing-tool" |...@@ -405,7 +405,7 @@ function unusable(provider: SourceControlProviderKind, reason: "missing-tool" |
405}405}
406406
407const requestFailed = new PullRequestProviderError({407const requestFailed = new PullRequestProviderError({
408 provider: "github",408 provider: SourceControlProviderKind.make("github"),
409 operation: "listChangeRequests",409 operation: "listChangeRequests",
410 reason: "failed",410 reason: "failed",
411 detail: "HTTP 404",411 detail: "HTTP 404",
...@@ -530,12 +530,12 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos...@@ -530,12 +530,12 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos
530 title: "enterprise",530 title: "enterprise",
531 workspaceRoot: "/repo",531 workspaceRoot: "/repo",
532 repository: "team/project",532 repository: "team/project",
533 provider: "unknown",533 provider: SourceControlProviderKind.make("unknown"),
534 host: "code.example.test",534 host: "code.example.test",
535 }),535 }),
536 ],536 ],
537 providers: [537 providers: [
538 fakeProvider("github", {538 fakeProvider(SourceControlProviderKind.make("github"), {
539 /** Supplies a PR only after verifying discovery retained the Enterprise repository target. */539 /** Supplies a PR only after verifying discovery retained the Enterprise repository target. */
540 listChangeRequests: ({ host, repository }) => {540 listChangeRequests: ({ host, repository }) => {
541 assert.strictEqual(host, "code.example.test");541 assert.strictEqual(host, "code.example.test");
...@@ -554,7 +554,11 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos...@@ -554,7 +554,11 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos
554 return Effect.succeed({554 return Effect.succeed({
555 context: {555 context: {
556 ...context,556 ...context,
557 provider: { ...context.provider, kind: "github", name: "GitHub Self-Hosted" },557 provider: {
558 ...context.provider,
559 kind: SourceControlProviderKind.make("github"),
560 name: "GitHub Self-Hosted",
561 },
558 },562 },
559 provider: undefined as never,563 provider: undefined as never,
560 });564 });
...@@ -576,7 +580,7 @@ it.effect("refines unknown self-hosted GitLab projects before listing merge requ...@@ -576,7 +580,7 @@ it.effect("refines unknown self-hosted GitLab projects before listing merge requ
576 title: "self-hosted",580 title: "self-hosted",
577 workspaceRoot: "/gitlab",581 workspaceRoot: "/gitlab",
578 repository: "group/project",582 repository: "group/project",
579 provider: "unknown",583 provider: SourceControlProviderKind.make("unknown"),
580 host: "code.example.test",584 host: "code.example.test",
581 });585 });
582 const service = yield* makeService({586 const service = yield* makeService({
...@@ -584,12 +588,15 @@ it.effect("refines unknown self-hosted GitLab projects before listing merge requ...@@ -584,12 +588,15 @@ it.effect("refines unknown self-hosted GitLab projects before listing merge requ
584 selfHosted,588 selfHosted,
585 { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/gitlab-worktree" },589 { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/gitlab-worktree" },
586 ],590 ],
587 providers: [fakeProvider("gitlab")],591 providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))],
588 resolveHandle: ({ context }) => {592 resolveHandle: ({ context }) => {
589 refinementCalls += 1;593 refinementCalls += 1;
590 assert.strictEqual(context?.remoteUrl, "https://code.example.test/group/project.git");594 assert.strictEqual(context?.remoteUrl, "https://code.example.test/group/project.git");
591 return Effect.succeed({595 return Effect.succeed({
592 context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } },596 context: {
597 ...context!,
598 provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") },
599 },
593 provider: undefined as never,600 provider: undefined as never,
594 });601 });
595 },602 },
...@@ -610,7 +617,7 @@ it.effect("derives a legacy repository host after refining its provider", () =>...@@ -610,7 +617,7 @@ it.effect("derives a legacy repository host after refining its provider", () =>
610 title: "legacy self-hosted",617 title: "legacy self-hosted",
611 workspaceRoot: "/gitlab",618 workspaceRoot: "/gitlab",
612 repository: "group/project",619 repository: "group/project",
613 provider: "unknown",620 provider: SourceControlProviderKind.make("unknown"),
614 host: "code.example.test",621 host: "code.example.test",
615 });622 });
616 const identity = current.repositoryIdentity!;623 const identity = current.repositoryIdentity!;
...@@ -625,10 +632,13 @@ it.effect("derives a legacy repository host after refining its provider", () =>...@@ -625,10 +632,13 @@ it.effect("derives a legacy repository host after refining its provider", () =>
625 } as unknown as OrchestrationProjectShell;632 } as unknown as OrchestrationProjectShell;
626 const service = yield* makeService({633 const service = yield* makeService({
627 projects: [legacy],634 projects: [legacy],
628 providers: [fakeProvider("gitlab")],635 providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))],
629 resolveHandle: ({ context }) =>636 resolveHandle: ({ context }) =>
630 Effect.succeed({637 Effect.succeed({
631 context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } },638 context: {
639 ...context!,
640 provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") },
641 },
632 provider: undefined as never,642 provider: undefined as never,
633 }),643 }),
634 });644 });
...@@ -648,18 +658,21 @@ it.effect("tries another checkout when provider refinement remains unknown", ()...@@ -648,18 +658,21 @@ it.effect("tries another checkout when provider refinement remains unknown", ()
648 title: "self-hosted",658 title: "self-hosted",
649 workspaceRoot: "/gone",659 workspaceRoot: "/gone",
650 repository: "group/project",660 repository: "group/project",
651 provider: "unknown",661 provider: SourceControlProviderKind.make("unknown"),
652 host: "code.example.test",662 host: "code.example.test",
653 });663 });
654 const service = yield* makeService({664 const service = yield* makeService({
655 projects: [selfHosted, { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/healthy" }],665 projects: [selfHosted, { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/healthy" }],
656 providers: [fakeProvider("gitlab")],666 providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))],
657 resolveHandle: ({ cwd, context }) => {667 resolveHandle: ({ cwd, context }) => {
658 asked.push(cwd);668 asked.push(cwd);
659 return cwd === "/gone"669 return cwd === "/gone"
660 ? Effect.succeed({ context: context!, provider: undefined as never })670 ? Effect.succeed({ context: context!, provider: undefined as never })
661 : Effect.succeed({671 : Effect.succeed({
662 context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } },672 context: {
673 ...context!,
674 provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") },
675 },
663 provider: undefined as never,676 provider: undefined as never,
664 });677 });
665 },678 },
...@@ -689,11 +702,11 @@ it.effect("reads nothing from a host with no implementation, but reports it", ()...@@ -689,11 +702,11 @@ it.effect("reads nothing from a host with no implementation, but reports it", ()
689 title: "on gitlab",702 title: "on gitlab",
690 workspaceRoot: "/c",703 workspaceRoot: "/c",
691 repository: "group/project",704 repository: "group/project",
692 provider: "gitlab",705 provider: SourceControlProviderKind.make("gitlab"),
693 }),706 }),
694 ],707 ],
695 providers: [708 providers: [
696 fakeProvider("github", {709 fakeProvider(SourceControlProviderKind.make("github"), {
697 listChangeRequests: (input) => {710 listChangeRequests: (input) => {
698 listed.push(input.repository);711 listed.push(input.repository);
699 return Effect.succeed({712 return Effect.succeed({
...@@ -718,8 +731,8 @@ it.effect("reads nothing from a host with no implementation, but reports it", ()...@@ -718,8 +731,8 @@ it.effect("reads nothing from a host with no implementation, but reports it", ()
718 projectCount: summary.projectCount,731 projectCount: summary.projectCount,
719 })),732 })),
720 [733 [
721 { kind: "github", configured: true, projectCount: 1 },734 { kind: SourceControlProviderKind.make("github"), configured: true, projectCount: 1 },
722 { kind: "gitlab", configured: false, projectCount: 1 },735 { kind: SourceControlProviderKind.make("gitlab"), configured: false, projectCount: 1 },
723 ],736 ],
724 );737 );
725 }),738 }),
...@@ -733,7 +746,7 @@ it.effect("asks for a whole page of a host, and for the reader's own size when g...@@ -733,7 +746,7 @@ it.effect("asks for a whole page of a host, and for the reader's own size when g
733 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),746 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
734 ],747 ],
735 providers: [748 providers: [
736 fakeProvider("github", {749 fakeProvider(SourceControlProviderKind.make("github"), {
737 listChangeRequests: (input) => {750 listChangeRequests: (input) => {
738 limits.push(input.limit);751 limits.push(input.limit);
739 return Effect.succeed({ items: [], truncated: false, continues: true });752 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...@@ -759,7 +772,7 @@ it.effect("says where each repository carries on, and from nothing it has run ou
759 project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }),772 project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }),
760 ],773 ],
761 providers: [774 providers: [
762 fakeProvider("github", {775 fakeProvider(SourceControlProviderKind.make("github"), {
763 listChangeRequests: ({ repository }) =>776 listChangeRequests: ({ repository }) =>
764 Effect.succeed({777 Effect.succeed({
765 items: [changeRequest(1, "2026-07-02T00:00:00Z")],778 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", ()...@@ -787,7 +800,7 @@ it.effect("offers no continuation for a host that cannot be carried on from", ()
787 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),800 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
788 ],801 ],
789 providers: [802 providers: [
790 fakeProvider("github", {803 fakeProvider(SourceControlProviderKind.make("github"), {
791 listChangeRequests: () =>804 listChangeRequests: () =>
792 Effect.succeed({805 Effect.succeed({
793 items: [changeRequest(1, "2026-07-02T00:00:00Z")],806 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"...@@ -815,12 +828,12 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows"
815 title: "web",828 title: "web",
816 workspaceRoot: "/a",829 workspaceRoot: "/a",
817 repository: "acme/web",830 repository: "acme/web",
818 provider: "azure-devops",831 provider: SourceControlProviderKind.make("azure-devops"),
819 host: "dev.azure.com",832 host: "dev.azure.com",
820 }),833 }),
821 ],834 ],
822 providers: [835 providers: [
823 fakeProvider("azure-devops", {836 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
824 listChangeRequests: () =>837 listChangeRequests: () =>
825 Effect.succeed({838 Effect.succeed({
826 items: [changeRequest(7, "2026-07-02T00:00:00Z")],839 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", () =>...@@ -851,7 +864,7 @@ it.effect("reads only the repositories it was asked to carry on with", () =>
851 project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }),864 project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }),
852 ],865 ],
853 providers: [866 providers: [
854 fakeProvider("github", {867 fakeProvider(SourceControlProviderKind.make("github"), {
855 listChangeRequests: (input) => {868 listChangeRequests: (input) => {
856 listed.push(input.repository);869 listed.push(input.repository);
857 cursors.push(input.cursor);870 cursors.push(input.cursor);
...@@ -882,7 +895,7 @@ it.effect("keeps a row already sent at the boundary instant from arriving twice"...@@ -882,7 +895,7 @@ it.effect("keeps a row already sent at the boundary instant from arriving twice"
882 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),895 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
883 ],896 ],
884 providers: [897 providers: [
885 fakeProvider("github", {898 fakeProvider(SourceControlProviderKind.make("github"), {
886 // The boundary instant is asked for inclusively, so the host hands back the rows899 // The boundary instant is asked for inclusively, so the host hands back the rows
887 // already sent at it alongside the ones beside them — which a strictly-older read900 // already sent at it alongside the ones beside them — which a strictly-older read
888 // would have lost instead.901 // would have lost instead.
...@@ -922,7 +935,7 @@ it.effect("keeps the earlier exclusions when a slice ends on the instant it bega...@@ -922,7 +935,7 @@ it.effect("keeps the earlier exclusions when a slice ends on the instant it bega
922 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),935 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
923 ],936 ],
924 providers: [937 providers: [
925 fakeProvider("github", {938 fakeProvider(SourceControlProviderKind.make("github"), {
926 listChangeRequests: () =>939 listChangeRequests: () =>
927 Effect.succeed({940 Effect.succeed({
928 items: [941 items: [
...@@ -960,7 +973,9 @@ it.effect("refuses a continuation it did not issue, before asking any host anyth...@@ -960,7 +973,9 @@ it.effect("refuses a continuation it did not issue, before asking any host anyth
960 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),973 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
961 ],974 ],
962 providers: [975 providers: [
963 fakeProvider("github", { listChangeRequests: () => Effect.die("should not be read") }),976 fakeProvider(SourceControlProviderKind.make("github"), {
977 listChangeRequests: () => Effect.die("should not be read"),
978 }),
964 ],979 ],
965 });980 });
966981
...@@ -983,11 +998,11 @@ it.effect("calls a transient viewer failure a failed operation, not a signed-out...@@ -983,11 +998,11 @@ it.effect("calls a transient viewer failure a failed operation, not a signed-out
983 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),998 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
984 ],999 ],
985 providers: [1000 providers: [
986 fakeProvider("github", {1001 fakeProvider(SourceControlProviderKind.make("github"), {
987 getViewer: () =>1002 getViewer: () =>
988 Effect.fail(1003 Effect.fail(
989 new PullRequestProviderError({1004 new PullRequestProviderError({
990 provider: "github",1005 provider: SourceControlProviderKind.make("github"),
991 operation: "getViewer",1006 operation: "getViewer",
992 reason: "failed",1007 reason: "failed",
993 detail: "HTTP 500",1008 detail: "HTTP 500",
...@@ -1014,23 +1029,24 @@ it.effect("reports an unusable host over a merely failing one", () =>...@@ -1014,23 +1029,24 @@ it.effect("reports an unusable host over a merely failing one", () =>
1014 title: "on gitlab",1029 title: "on gitlab",
1015 workspaceRoot: "/c",1030 workspaceRoot: "/c",
1016 repository: "group/project",1031 repository: "group/project",
1017 provider: "gitlab",1032 provider: SourceControlProviderKind.make("gitlab"),
1018 }),1033 }),
1019 ],1034 ],
1020 providers: [1035 providers: [
1021 fakeProvider("github", {1036 fakeProvider(SourceControlProviderKind.make("github"), {
1022 getViewer: () =>1037 getViewer: () =>
1023 Effect.fail(1038 Effect.fail(
1024 new PullRequestProviderError({1039 new PullRequestProviderError({
1025 provider: "github",1040 provider: SourceControlProviderKind.make("github"),
1026 operation: "getViewer",1041 operation: "getViewer",
1027 reason: "failed",1042 reason: "failed",
1028 detail: "HTTP 500",1043 detail: "HTTP 500",
1029 }),1044 }),
1030 ),1045 ),
1031 }),1046 }),
1032 fakeProvider("gitlab", {1047 fakeProvider(SourceControlProviderKind.make("gitlab"), {
1033 getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")),1048 getViewer: () =>
1049 Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")),
1034 }),1050 }),
1035 ],1051 ],
1036 });1052 });
...@@ -1052,11 +1068,11 @@ it.effect("lists every host that has an implementation", () =>...@@ -1052,11 +1068,11 @@ it.effect("lists every host that has an implementation", () =>
1052 title: "on gitlab",1068 title: "on gitlab",
1053 workspaceRoot: "/b",1069 workspaceRoot: "/b",
1054 repository: "group/sub/project",1070 repository: "group/sub/project",
1055 provider: "gitlab",1071 provider: SourceControlProviderKind.make("gitlab"),
1056 }),1072 }),
1057 ],1073 ],
1058 providers: [1074 providers: [
1059 fakeProvider("github", {1075 fakeProvider(SourceControlProviderKind.make("github"), {
1060 listChangeRequests: () =>1076 listChangeRequests: () =>
1061 Effect.succeed({1077 Effect.succeed({
1062 items: [changeRequest(1, "2026-07-01T00:00:00Z")],1078 items: [changeRequest(1, "2026-07-01T00:00:00Z")],
...@@ -1064,7 +1080,7 @@ it.effect("lists every host that has an implementation", () =>...@@ -1064,7 +1080,7 @@ it.effect("lists every host that has an implementation", () =>
1064 continues: true,1080 continues: true,
1065 }),1081 }),
1066 }),1082 }),
1067 fakeProvider("gitlab", {1083 fakeProvider(SourceControlProviderKind.make("gitlab"), {
1068 listChangeRequests: (input) =>1084 listChangeRequests: (input) =>
1069 // Nested groups need the full path, not the last two segments.1085 // Nested groups need the full path, not the last two segments.
1070 input.repository === "group/sub/project"1086 input.repository === "group/sub/project"
...@@ -1100,12 +1116,14 @@ it.effect("narrows the listing to one host when asked", () =>...@@ -1100,12 +1116,14 @@ it.effect("narrows the listing to one host when asked", () =>
1100 title: "on gitlab",1116 title: "on gitlab",
1101 workspaceRoot: "/b",1117 workspaceRoot: "/b",
1102 repository: "group/project",1118 repository: "group/project",
1103 provider: "gitlab",1119 provider: SourceControlProviderKind.make("gitlab"),
1104 }),1120 }),
1105 ],1121 ],
1106 providers: [1122 providers: [
1107 fakeProvider("github", { listChangeRequests: () => Effect.die("should not be read") }),1123 fakeProvider(SourceControlProviderKind.make("github"), {
1108 fakeProvider("gitlab", {1124 listChangeRequests: () => Effect.die("should not be read"),
1125 }),
1126 fakeProvider(SourceControlProviderKind.make("gitlab"), {
1109 listChangeRequests: () =>1127 listChangeRequests: () =>
1110 Effect.succeed({1128 Effect.succeed({
1111 items: [changeRequest(2, "2026-07-05T00:00:00Z")],1129 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", ()...@@ -1139,7 +1157,7 @@ it.effect("tells two hosts of one kind apart in the switcher and the filter", ()
1139 }),1157 }),
1140 ],1158 ],
1141 providers: [1159 providers: [
1142 fakeProvider("github", {1160 fakeProvider(SourceControlProviderKind.make("github"), {
1143 listChangeRequests: ({ host }) =>1161 listChangeRequests: ({ host }) =>
1144 Effect.succeed({1162 Effect.succeed({
1145 items: host === "ghe.example.com" ? [changeRequest(2, "2026-07-05T00:00:00Z")] : [],1163 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", () =>...@@ -1179,11 +1197,11 @@ it.effect("keeps one host listed when another is not set up", () =>
1179 title: "on gitlab",1197 title: "on gitlab",
1180 workspaceRoot: "/b",1198 workspaceRoot: "/b",
1181 repository: "group/project",1199 repository: "group/project",
1182 provider: "gitlab",1200 provider: SourceControlProviderKind.make("gitlab"),
1183 }),1201 }),
1184 ],1202 ],
1185 providers: [1203 providers: [
1186 fakeProvider("github", {1204 fakeProvider(SourceControlProviderKind.make("github"), {
1187 listChangeRequests: () =>1205 listChangeRequests: () =>
1188 Effect.succeed({1206 Effect.succeed({
1189 items: [changeRequest(1, "2026-07-01T00:00:00Z")],1207 items: [changeRequest(1, "2026-07-01T00:00:00Z")],
...@@ -1191,8 +1209,9 @@ it.effect("keeps one host listed when another is not set up", () =>...@@ -1191,8 +1209,9 @@ it.effect("keeps one host listed when another is not set up", () =>
1191 continues: true,1209 continues: true,
1192 }),1210 }),
1193 }),1211 }),
1194 fakeProvider("gitlab", {1212 fakeProvider(SourceControlProviderKind.make("gitlab"), {
1195 getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")),1213 getViewer: () =>
1214 Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")),
1196 }),1215 }),
1197 ],1216 ],
1198 });1217 });
...@@ -1220,8 +1239,9 @@ it.effect("fails as unavailable only when no host can be read", () =>...@@ -1220,8 +1239,9 @@ it.effect("fails as unavailable only when no host can be read", () =>
1220 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),1239 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1221 ],1240 ],
1222 providers: [1241 providers: [
1223 fakeProvider("github", {1242 fakeProvider(SourceControlProviderKind.make("github"), {
1224 getViewer: () => Effect.fail(unusable("github", "missing-tool")),1243 getViewer: () =>
1244 Effect.fail(unusable(SourceControlProviderKind.make("github"), "missing-tool")),
1225 }),1245 }),
1226 ],1246 ],
1227 });1247 });
...@@ -1250,7 +1270,7 @@ it.effect("reads a repository once when several worktrees share it", () =>...@@ -1250,7 +1270,7 @@ it.effect("reads a repository once when several worktrees share it", () =>
1250 }),1270 }),
1251 ],1271 ],
1252 providers: [1272 providers: [
1253 fakeProvider("github", {1273 fakeProvider(SourceControlProviderKind.make("github"), {
1254 listChangeRequests: () => {1274 listChangeRequests: () => {
1255 calls += 1;1275 calls += 1;
1256 return Effect.succeed({1276 return Effect.succeed({
...@@ -1278,7 +1298,7 @@ it.effect("keeps healthy repositories when one of them cannot be read", () =>...@@ -1278,7 +1298,7 @@ it.effect("keeps healthy repositories when one of them cannot be read", () =>
1278 project({ id: "p2", title: "broken", workspaceRoot: "/b", repository: "pingdotgg/broken" }),1298 project({ id: "p2", title: "broken", workspaceRoot: "/b", repository: "pingdotgg/broken" }),
1279 ],1299 ],
1280 providers: [1300 providers: [
1281 fakeProvider("github", {1301 fakeProvider(SourceControlProviderKind.make("github"), {
1282 listChangeRequests: (input) =>1302 listChangeRequests: (input) =>
1283 input.repository === "pingdotgg/broken"1303 input.repository === "pingdotgg/broken"
1284 ? Effect.fail(requestFailed)1304 ? Effect.fail(requestFailed)
...@@ -1309,11 +1329,11 @@ it.effect("tries another workspace on the same host for the viewer", () =>...@@ -1309,11 +1329,11 @@ it.effect("tries another workspace on the same host for the viewer", () =>
1309 project({ id: "p2", title: "healthy", workspaceRoot: "/healthy", repository: "acme/two" }),1329 project({ id: "p2", title: "healthy", workspaceRoot: "/healthy", repository: "acme/two" }),
1310 ],1330 ],
1311 providers: [1331 providers: [
1312 fakeProvider("github", {1332 fakeProvider(SourceControlProviderKind.make("github"), {
1313 getViewer: (input) =>1333 getViewer: (input) =>
1314 input.cwd === "/healthy"1334 input.cwd === "/healthy"
1315 ? Effect.succeed("bilal")1335 ? Effect.succeed("bilal")
1316 : Effect.fail(unusable("github", "missing-tool")),1336 : Effect.fail(unusable(SourceControlProviderKind.make("github"), "missing-tool")),
1317 listChangeRequests: () =>1337 listChangeRequests: () =>
1318 Effect.succeed({1338 Effect.succeed({
1319 items: [changeRequest(1, "2026-07-02T00:00:00Z")],1339 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...@@ -1345,7 +1365,7 @@ it.effect("routing verifies the current account on the requested host without ca
1345 }),1365 }),
1346 ],1366 ],
1347 providers: [1367 providers: [
1348 fakeProvider("github", {1368 fakeProvider(SourceControlProviderKind.make("github"), {
1349 getRoutingIdentity: (input) => {1369 getRoutingIdentity: (input) => {
1350 assert.deepStrictEqual(input, { cwd: "/a", host: "github.example.test" });1370 assert.deepStrictEqual(input, { cwd: "/a", host: "github.example.test" });
1351 return Effect.succeed({1371 return Effect.succeed({
...@@ -1359,7 +1379,7 @@ it.effect("routing verifies the current account on the requested host without ca...@@ -1359,7 +1379,7 @@ it.effect("routing verifies the current account on the requested host without ca
1359 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };1379 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
1360 assert.deepStrictEqual(yield* service.routing(ref), {1380 assert.deepStrictEqual(yield* service.routing(ref), {
1361 host: "github.example.test",1381 host: "github.example.test",
1362 provider: "github",1382 provider: SourceControlProviderKind.make("github"),
1363 viewer: "first-account",1383 viewer: "first-account",
1364 accountId: "123",1384 accountId: "123",
1365 projectTitle: "web",1385 projectTitle: "web",
...@@ -1382,7 +1402,7 @@ it.effect("refuses an action the host never claimed it could run", () =>...@@ -1382,7 +1402,7 @@ it.effect("refuses an action the host never claimed it could run", () =>
1382 const service = yield* makeService({1402 const service = yield* makeService({
1383 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],1403 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1384 providers: [1404 providers: [
1385 fakeProvider("github", {1405 fakeProvider(SourceControlProviderKind.make("github"), {
1386 capabilities: {1406 capabilities: {
1387 diff: true,1407 diff: true,
1388 comment: true,1408 comment: true,
...@@ -1428,12 +1448,12 @@ it.effect("publishes a merge for immediate settlement only after host confirmati...@@ -1428,12 +1448,12 @@ it.effect("publishes a merge for immediate settlement only after host confirmati
1428 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),1448 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
1429 ],1449 ],
1430 providers: [1450 providers: [
1431 fakeProvider("github", {1451 fakeProvider(SourceControlProviderKind.make("github"), {
1432 getChangeRequestSummary: () =>1452 getChangeRequestSummary: () =>
1433 confirmationFails1453 confirmationFails
1434 ? Effect.fail(1454 ? Effect.fail(
1435 new PullRequestProviderError({1455 new PullRequestProviderError({
1436 provider: "github",1456 provider: SourceControlProviderKind.make("github"),
1437 operation: "getChangeRequestSummary",1457 operation: "getChangeRequestSummary",
1438 reason: "failed",1458 reason: "failed",
1439 detail: "HTTP 504",1459 detail: "HTTP 504",
...@@ -1486,7 +1506,7 @@ it.effect("refreshes every reader before a queued merge confirmation finishes",...@@ -1486,7 +1506,7 @@ it.effect("refreshes every reader before a queued merge confirmation finishes",
1486 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),1506 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
1487 ],1507 ],
1488 providers: [1508 providers: [
1489 fakeProvider("github", {1509 fakeProvider(SourceControlProviderKind.make("github"), {
1490 getChangeRequestSummary: () =>1510 getChangeRequestSummary: () =>
1491 Effect.gen(function* () {1511 Effect.gen(function* () {
1492 yield* Deferred.succeed(confirmationStarted, undefined);1512 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...@@ -1528,7 +1548,7 @@ it.effect("refuses an action this viewer may not take, and says what access it t
1528 const service = yield* makeService({1548 const service = yield* makeService({
1529 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],1549 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1530 providers: [1550 providers: [
1531 fakeProvider("github", {1551 fakeProvider(SourceControlProviderKind.make("github"), {
1532 // The host merges; this account only reads it, and opened the change request — which1552 // The host merges; this account only reads it, and opened the change request — which
1533 // is every contributor to a repository they do not own.1553 // is every contributor to a repository they do not own.
1534 getViewerPermissions: () =>1554 getViewerPermissions: () =>
...@@ -1565,7 +1585,7 @@ it.effect("gates arming a merge for later exactly as it gates merging now", () =...@@ -1565,7 +1585,7 @@ it.effect("gates arming a merge for later exactly as it gates merging now", () =
1565 const service = yield* makeService({1585 const service = yield* makeService({
1566 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],1586 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1567 providers: [1587 providers: [
1568 fakeProvider("github", {1588 fakeProvider(SourceControlProviderKind.make("github"), {
1569 capabilities: {1589 capabilities: {
1570 diff: true,1590 diff: true,
1571 comment: true,1591 comment: true,
...@@ -1620,7 +1640,7 @@ it.effect("hands the host the strategy an armed merge was asked for", () =>...@@ -1620,7 +1640,7 @@ it.effect("hands the host the strategy an armed merge was asked for", () =>
1620 const service = yield* makeService({1640 const service = yield* makeService({
1621 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],1641 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1622 providers: [1642 providers: [
1623 fakeProvider("github", {1643 fakeProvider(SourceControlProviderKind.make("github"), {
1624 capabilities: {1644 capabilities: {
1625 diff: true,1645 diff: true,
1626 comment: true,1646 comment: true,
...@@ -1666,7 +1686,7 @@ it.effect("refuses an auto-merge the host never claimed, without asking it", ()...@@ -1666,7 +1686,7 @@ it.effect("refuses an auto-merge the host never claimed, without asking it", ()
1666 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],1686 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1667 providers: [1687 providers: [
1668 // Bitbucket's shape: it merges, and has nothing that merges later on its own.1688 // Bitbucket's shape: it merges, and has nothing that merges later on its own.
1669 fakeProvider("github", {1689 fakeProvider(SourceControlProviderKind.make("github"), {
1670 runAction: () => {1690 runAction: () => {
1671 ran = true;1691 ran = true;
1672 return Effect.void;1692 return Effect.void;
...@@ -1694,7 +1714,7 @@ it.effect("refuses to resolve a conversation this viewer may not, without asking...@@ -1694,7 +1714,7 @@ it.effect("refuses to resolve a conversation this viewer may not, without asking
1694 const service = yield* makeService({1714 const service = yield* makeService({
1695 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],1715 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1696 providers: [1716 providers: [
1697 fakeProvider("github", {1717 fakeProvider(SourceControlProviderKind.make("github"), {
1698 getViewerPermissions: () =>1718 getViewerPermissions: () =>
1699 Effect.succeed({1719 Effect.succeed({
1700 actions: ["merge", "ready", "draft", "close", "reopen"],1720 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"...@@ -1729,7 +1749,7 @@ it.effect("asks nobody what the viewer may do when the host cannot do it at all"
1729 const service = yield* makeService({1749 const service = yield* makeService({
1730 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],1750 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1731 providers: [1751 providers: [
1732 fakeProvider("github", {1752 fakeProvider(SourceControlProviderKind.make("github"), {
1733 capabilities: {1753 capabilities: {
1734 diff: true,1754 diff: true,
1735 comment: true,1755 comment: true,
...@@ -1768,7 +1788,7 @@ it.effect("refuses a comment on a host that cannot post one", () =>...@@ -1768,7 +1788,7 @@ it.effect("refuses a comment on a host that cannot post one", () =>
1768 const service = yield* makeService({1788 const service = yield* makeService({
1769 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],1789 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1770 providers: [1790 providers: [
1771 fakeProvider("github", {1791 fakeProvider(SourceControlProviderKind.make("github"), {
1772 capabilities: {1792 capabilities: {
1773 diff: false,1793 diff: false,
1774 comment: false,1794 comment: false,
...@@ -1817,7 +1837,7 @@ it.effect("keeps two hosts of one provider kind as two accounts", () =>...@@ -1817,7 +1837,7 @@ it.effect("keeps two hosts of one provider kind as two accounts", () =>
1817 }),1837 }),
1818 ],1838 ],
1819 providers: [1839 providers: [
1820 fakeProvider("github", {1840 fakeProvider(SourceControlProviderKind.make("github"), {
1821 getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"),1841 getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"),
1822 listChangeRequests: () =>1842 listChangeRequests: () =>
1823 Effect.succeed({1843 Effect.succeed({
...@@ -1858,11 +1878,11 @@ it.effect("reports repositories on a host that could not be read", () =>...@@ -1858,11 +1878,11 @@ it.effect("reports repositories on a host that could not be read", () =>
1858 }),1878 }),
1859 ],1879 ],
1860 providers: [1880 providers: [
1861 fakeProvider("github", {1881 fakeProvider(SourceControlProviderKind.make("github"), {
1862 getViewer: (input) =>1882 getViewer: (input) =>
1863 input.cwd === "/cloud"1883 input.cwd === "/cloud"
1864 ? Effect.succeed("bilal")1884 ? Effect.succeed("bilal")
1865 : Effect.fail(unusable("github", "unauthenticated")),1885 : Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")),
1866 listChangeRequests: () =>1886 listChangeRequests: () =>
1867 Effect.succeed({1887 Effect.succeed({
1868 items: [changeRequest(1, "2026-07-02T00:00:00Z")],1888 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...@@ -1893,7 +1913,7 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail
1893 project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }),1913 project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }),
1894 ],1914 ],
1895 providers: [1915 providers: [
1896 fakeProvider("github", {1916 fakeProvider(SourceControlProviderKind.make("github"), {
1897 listChangeRequests: () =>1917 listChangeRequests: () =>
1898 Effect.sync(() => {1918 Effect.sync(() => {
1899 listCalls += 1;1919 listCalls += 1;
...@@ -1901,7 +1921,7 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail...@@ -1901,7 +1921,7 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail
1901 Effect.andThen(1921 Effect.andThen(
1902 Effect.fail(1922 Effect.fail(
1903 new PullRequestProviderError({1923 new PullRequestProviderError({
1904 provider: "github",1924 provider: SourceControlProviderKind.make("github"),
1905 operation: "listChangeRequests",1925 operation: "listChangeRequests",
1906 reason: "rate-limited",1926 reason: "rate-limited",
1907 detail: "GitHub API rate limit exceeded.",1927 detail: "GitHub API rate limit exceeded.",
...@@ -1933,13 +1953,14 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail...@@ -1933,13 +1953,14 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail
1933 }),1953 }),
1934);1954);
19351955
1936for (const [provider, host] of [1956for (const [providerName, host] of [
1937 ["github", "github.com"],1957 ["github", "github.com"],
1938 ["gitlab", "gitlab.com"],1958 ["gitlab", "gitlab.com"],
1939 ["forgejo", "code.example.test"],1959 ["forgejo", "code.example.test"],
1940 ["bitbucket", "bitbucket.org"],1960 ["bitbucket", "bitbucket.org"],
1941 ["azure-devops", "dev.azure.com"],1961 ["azure-devops", "dev.azure.com"],
1942] as const) {1962] as const) {
1963 const provider = SourceControlProviderKind.make(providerName);
1943 it.effect.each(["detail", "checks"] as const)(1964 it.effect.each(["detail", "checks"] as const)(
1944 `shares fresh ${provider} %s reads and respects rate-limit resets`,1965 `shares fresh ${provider} %s reads and respects rate-limit resets`,
1945 (read) =>1966 (read) =>
...@@ -2006,7 +2027,7 @@ it.effect("does not fall back to full detail when checks are unsupported", () =>...@@ -2006,7 +2027,7 @@ it.effect("does not fall back to full detail when checks are unsupported", () =>
2006 project({ id: "p1", title: "web", workspaceRoot: "/repo", repository: "acme/web" }),2027 project({ id: "p1", title: "web", workspaceRoot: "/repo", repository: "acme/web" }),
2007 ],2028 ],
2008 providers: [2029 providers: [
2009 fakeProvider("github", {2030 fakeProvider(SourceControlProviderKind.make("github"), {
2010 getChangeRequest: () => Effect.die("Unexpected full detail read"),2031 getChangeRequest: () => Effect.die("Unexpected full detail read"),
2011 }),2032 }),
2012 ],2033 ],
...@@ -2025,7 +2046,7 @@ it.effect("uses a manual rate limit to pause later reads", () =>...@@ -2025,7 +2046,7 @@ it.effect("uses a manual rate limit to pause later reads", () =>
2025 project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }),2046 project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }),
2026 ],2047 ],
2027 providers: [2048 providers: [
2028 fakeProvider("github", {2049 fakeProvider(SourceControlProviderKind.make("github"), {
2029 listChangeRequests: () =>2050 listChangeRequests: () =>
2030 Effect.sync(() => {2051 Effect.sync(() => {
2031 listCalls += 1;2052 listCalls += 1;
...@@ -2034,7 +2055,7 @@ it.effect("uses a manual rate limit to pause later reads", () =>...@@ -2034,7 +2055,7 @@ it.effect("uses a manual rate limit to pause later reads", () =>
2034 runAction: () =>2055 runAction: () =>
2035 Effect.fail(2056 Effect.fail(
2036 new PullRequestProviderError({2057 new PullRequestProviderError({
2037 provider: "github",2058 provider: SourceControlProviderKind.make("github"),
2038 operation: "runAction",2059 operation: "runAction",
2039 reason: "rate-limited",2060 reason: "rate-limited",
2040 detail: "GitHub API rate limit exceeded.",2061 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...@@ -2066,7 +2087,7 @@ it.effect("flags a review request for the viewer but not on their own change req
2066 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),2087 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
2067 ],2088 ],
2068 providers: [2089 providers: [
2069 fakeProvider("github", {2090 fakeProvider(SourceControlProviderKind.make("github"), {
2070 listChangeRequests: () =>2091 listChangeRequests: () =>
2071 Effect.succeed({2092 Effect.succeed({
2072 items: [2093 items: [
...@@ -2099,7 +2120,7 @@ it.effect("refuses a repository that does not belong to the requested project",...@@ -2099,7 +2120,7 @@ it.effect("refuses a repository that does not belong to the requested project",
2099 projects: [2120 projects: [
2100 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),2121 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
2101 ],2122 ],
2102 providers: [fakeProvider("github")],2123 providers: [fakeProvider(SourceControlProviderKind.make("github"))],
2103 });2124 });
21042125
2105 const error = yield* service2126 const error = yield* service
...@@ -2116,7 +2137,7 @@ it.effect("caches stack membership separately from action details", () =>...@@ -2116,7 +2137,7 @@ it.effect("caches stack membership separately from action details", () =>
2116 const service = yield* makeService({2137 const service = yield* makeService({
2117 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],2138 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2118 providers: [2139 providers: [
2119 fakeProvider("github", {2140 fakeProvider(SourceControlProviderKind.make("github"), {
2120 getChangeRequestStack: (input) =>2141 getChangeRequestStack: (input) =>
2121 Effect.sync(() => {2142 Effect.sync(() => {
2122 reads.push(input.includeDetails === true);2143 reads.push(input.includeDetails === true);
...@@ -2157,7 +2178,7 @@ it.effect("reads a host-native stack through the provider and null where it has...@@ -2157,7 +2178,7 @@ it.effect("reads a host-native stack through the provider and null where it has
2157 const service = yield* makeService({2178 const service = yield* makeService({
2158 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],2179 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2159 providers: [2180 providers: [
2160 fakeProvider("github", {2181 fakeProvider(SourceControlProviderKind.make("github"), {
2161 getChangeRequestStack: () =>2182 getChangeRequestStack: () =>
2162 Effect.succeed({2183 Effect.succeed({
2163 id: "9",2184 id: "9",
...@@ -2185,7 +2206,7 @@ it.effect("reads a host-native stack through the provider and null where it has...@@ -2185,7 +2206,7 @@ it.effect("reads a host-native stack through the provider and null where it has
21852206
2186 const withoutStacks = yield* makeService({2207 const withoutStacks = yield* makeService({
2187 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],2208 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2188 providers: [fakeProvider("github")],2209 providers: [fakeProvider(SourceControlProviderKind.make("github"))],
2189 });2210 });
2190 assert.isNull(2211 assert.isNull(
2191 yield* withoutStacks.stack({2212 yield* withoutStacks.stack({
...@@ -2215,7 +2236,7 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after...@@ -2215,7 +2236,7 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after
2215 }),2236 }),
2216 ],2237 ],
2217 providers: [2238 providers: [
2218 fakeProvider("forgejo", {2239 fakeProvider(SourceControlProviderKind.make("forgejo"), {
2219 getViewer: (input) => {2240 getViewer: (input) => {
2220 viewers.push(input.host);2241 viewers.push(input.host);
2221 assert.strictEqual(input.host, "code.example:3000");2242 assert.strictEqual(input.host, "code.example:3000");
...@@ -2244,7 +2265,11 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after...@@ -2244,7 +2265,11 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after
2244 return Effect.succeed({2265 return Effect.succeed({
2245 context: {2266 context: {
2246 ...context,2267 ...context,
2247 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "http://code.example:3000" },2268 provider: {
2269 kind: SourceControlProviderKind.make("forgejo"),
2270 name: "Forgejo",
2271 baseUrl: "http://code.example:3000",
2272 },
2248 },2273 },
2249 provider: undefined as never,2274 provider: undefined as never,
2250 });2275 });
...@@ -2294,12 +2319,12 @@ it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () =>...@@ -2294,12 +2319,12 @@ it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () =>
2294 title: "http",2319 title: "http",
2295 workspaceRoot: "/http",2320 workspaceRoot: "/http",
2296 repository: "team/repo",2321 repository: "team/repo",
2297 provider: "forgejo",2322 provider: SourceControlProviderKind.make("forgejo"),
2298 host: "code.example",2323 host: "code.example",
2299 remoteUrl: "http://code.example:4000/team/repo.git",2324 remoteUrl: "http://code.example:4000/team/repo.git",
2300 }),2325 }),
2301 ],2326 ],
2302 providers: [fakeProvider("forgejo")],2327 providers: [fakeProvider(SourceControlProviderKind.make("forgejo"))],
2303 });2328 });
2304 const failure = yield* service2329 const failure = yield* service
2305 .summary(2330 .summary(
...@@ -2327,7 +2352,7 @@ it.effect("resolves a project's repository identity when its shell has none cach...@@ -2327,7 +2352,7 @@ it.effect("resolves a project's repository identity when its shell has none cach
2327 const service = yield* makeService({2352 const service = yield* makeService({
2328 projects: [{ ...resolved, repositoryIdentity: null }],2353 projects: [{ ...resolved, repositoryIdentity: null }],
2329 providers: [2354 providers: [
2330 fakeProvider("github", {2355 fakeProvider(SourceControlProviderKind.make("github"), {
2331 getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")),2356 getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")),
2332 }),2357 }),
2333 ],2358 ],
...@@ -2351,7 +2376,7 @@ it.effect("routes a hosted reference to another repository through a project on...@@ -2351,7 +2376,7 @@ it.effect("routes a hosted reference to another repository through a project on
2351 project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),2376 project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),
2352 ],2377 ],
2353 providers: [2378 providers: [
2354 fakeProvider("github", {2379 fakeProvider(SourceControlProviderKind.make("github"), {
2355 getChangeRequestSummary: (input) =>2380 getChangeRequestSummary: (input) =>
2356 Effect.sync(() => {2381 Effect.sync(() => {
2357 seen.push({ cwd: input.cwd, repository: input.repository, host: input.host });2382 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...@@ -2381,12 +2406,12 @@ it.effect("routes Azure reads and writes through the requested organization's ch
2381 title: organization,2406 title: organization,
2382 workspaceRoot: `/${organization}`,2407 workspaceRoot: `/${organization}`,
2383 repository: `${organization}/project/_git/web`,2408 repository: `${organization}/project/_git/web`,
2384 provider: "azure-devops",2409 provider: SourceControlProviderKind.make("azure-devops"),
2385 host: "dev.azure.com",2410 host: "dev.azure.com",
2386 }),2411 }),
2387 ),2412 ),
2388 providers: [2413 providers: [
2389 fakeProvider("azure-devops", {2414 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
2390 getChangeRequestSummary: (input) =>2415 getChangeRequestSummary: (input) =>
2391 Effect.sync(() => {2416 Effect.sync(() => {
2392 seen.push(`read ${input.cwd} ${input.repository}`);2417 seen.push(`read ${input.cwd} ${input.repository}`);
...@@ -2434,7 +2459,7 @@ it.effect.each([...@@ -2434,7 +2459,7 @@ it.effect.each([
2434 id: "target",2459 id: "target",
2435 title: "target",2460 title: "target",
2436 workspaceRoot: "/target",2461 workspaceRoot: "/target",
2437 provider: "azure-devops",2462 provider: SourceControlProviderKind.make("azure-devops"),
2438 ...checkout,2463 ...checkout,
2439 });2464 });
2440 const service = yield* makeService({2465 const service = yield* makeService({
...@@ -2444,7 +2469,7 @@ it.effect.each([...@@ -2444,7 +2469,7 @@ it.effect.each([
2444 id: repository,2469 id: repository,
2445 title: repository,2470 title: repository,
2446 workspaceRoot: `/${repository}`,2471 workspaceRoot: `/${repository}`,
2447 provider: "azure-devops",2472 provider: SourceControlProviderKind.make("azure-devops"),
2448 host: "dev.azure.com",2473 host: "dev.azure.com",
2449 repository,2474 repository,
2450 }),2475 }),
...@@ -2452,7 +2477,7 @@ it.effect.each([...@@ -2452,7 +2477,7 @@ it.effect.each([
2452 target,2477 target,
2453 ],2478 ],
2454 providers: [2479 providers: [
2455 fakeProvider("azure-devops", {2480 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
2456 getChangeRequestSummary: (input) =>2481 getChangeRequestSummary: (input) =>
2457 Effect.sync(() => {2482 Effect.sync(() => {
2458 seen.push(`read ${input.cwd} ${input.repository}`);2483 seen.push(`read ${input.cwd} ${input.repository}`);
...@@ -2486,12 +2511,12 @@ it.effect("refuses Azure cross-organization reads and writes without its checkou...@@ -2486,12 +2511,12 @@ it.effect("refuses Azure cross-organization reads and writes without its checkou
2486 title: "org-a",2511 title: "org-a",
2487 workspaceRoot: "/org-a",2512 workspaceRoot: "/org-a",
2488 repository: "org-a/project/_git/web",2513 repository: "org-a/project/_git/web",
2489 provider: "azure-devops",2514 provider: SourceControlProviderKind.make("azure-devops"),
2490 host: "dev.azure.com",2515 host: "dev.azure.com",
2491 }),2516 }),
2492 ],2517 ],
2493 providers: [2518 providers: [
2494 fakeProvider("azure-devops", {2519 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
2495 getChangeRequestSummary: () => Effect.die("must not read the wrong organization"),2520 getChangeRequestSummary: () => Effect.die("must not read the wrong organization"),
2496 runAction: () => Effect.die("must not modify the wrong organization"),2521 runAction: () => Effect.die("must not modify the wrong organization"),
2497 }),2522 }),
...@@ -2518,7 +2543,7 @@ it.effect("refuses a hosted reference when nothing is checked out from that host...@@ -2518,7 +2543,7 @@ it.effect("refuses a hosted reference when nothing is checked out from that host
2518 projects: [2543 projects: [
2519 project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),2544 project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),
2520 ],2545 ],
2521 providers: [fakeProvider("github")],2546 providers: [fakeProvider(SourceControlProviderKind.make("github"))],
2522 });2547 });
25232548
2524 const error = yield* service2549 const error = yield* service
...@@ -2546,11 +2571,11 @@ it.effect("refuses a diff on a host that cannot produce one", () =>...@@ -2546,11 +2571,11 @@ it.effect("refuses a diff on a host that cannot produce one", () =>
2546 title: "on azure",2571 title: "on azure",
2547 workspaceRoot: "/a",2572 workspaceRoot: "/a",
2548 repository: "org/project",2573 repository: "org/project",
2549 provider: "azure-devops",2574 provider: SourceControlProviderKind.make("azure-devops"),
2550 }),2575 }),
2551 ],2576 ],
2552 providers: [2577 providers: [
2553 fakeProvider("azure-devops", {2578 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
2554 capabilities: {2579 capabilities: {
2555 diff: false,2580 diff: false,
2556 comment: true,2581 comment: true,
...@@ -2580,7 +2605,11 @@ it.effect("rejects an empty comment before reaching the host", () =>...@@ -2580,7 +2605,11 @@ it.effect("rejects an empty comment before reaching the host", () =>
2580 projects: [2605 projects: [
2581 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),2606 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
2582 ],2607 ],
2583 providers: [fakeProvider("github", { comment: () => Effect.die("must not be called") })],2608 providers: [
2609 fakeProvider(SourceControlProviderKind.make("github"), {
2610 comment: () => Effect.die("must not be called"),
2611 }),
2612 ],
2584 });2613 });
25852614
2586 const error = yield* service2615 const error = yield* service
...@@ -2606,11 +2635,11 @@ it.effect("refuses a verdict the host never claimed, without asking the provider...@@ -2606,11 +2635,11 @@ it.effect("refuses a verdict the host never claimed, without asking the provider
2606 title: "on gitlab",2635 title: "on gitlab",
2607 workspaceRoot: "/a",2636 workspaceRoot: "/a",
2608 repository: "group/project",2637 repository: "group/project",
2609 provider: "gitlab",2638 provider: SourceControlProviderKind.make("gitlab"),
2610 }),2639 }),
2611 ],2640 ],
2612 providers: [2641 providers: [
2613 fakeProvider("gitlab", {2642 fakeProvider(SourceControlProviderKind.make("gitlab"), {
2614 capabilities: {2643 capabilities: {
2615 diff: true,2644 diff: true,
2616 comment: true,2645 comment: true,
...@@ -2658,7 +2687,7 @@ it.effect("refuses line comments on a host that takes only a summary", () =>...@@ -2658,7 +2687,7 @@ it.effect("refuses line comments on a host that takes only a summary", () =>
2658 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),2687 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
2659 ],2688 ],
2660 providers: [2689 providers: [
2661 fakeProvider("github", {2690 fakeProvider(SourceControlProviderKind.make("github"), {
2662 capabilities: {2691 capabilities: {
2663 diff: true,2692 diff: true,
2664 comment: true,2693 comment: true,
...@@ -2704,7 +2733,7 @@ it.effect(...@@ -2704,7 +2733,7 @@ it.effect(
2704 }),2733 }),
2705 ],2734 ],
2706 providers: [2735 providers: [
2707 fakeProvider("github", {2736 fakeProvider(SourceControlProviderKind.make("github"), {
2708 submitReview: () => {2737 submitReview: () => {
2709 approved = true;2738 approved = true;
2710 return Effect.void;2739 return Effect.void;
...@@ -2736,7 +2765,7 @@ it.effect("refuses to resolve a conversation on a host that cannot", () =>...@@ -2736,7 +2765,7 @@ it.effect("refuses to resolve a conversation on a host that cannot", () =>
2736 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),2765 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
2737 ],2766 ],
2738 providers: [2767 providers: [
2739 fakeProvider("github", {2768 fakeProvider(SourceControlProviderKind.make("github"), {
2740 capabilities: {2769 capabilities: {
2741 diff: true,2770 diff: true,
2742 comment: true,2771 comment: true,
...@@ -2777,7 +2806,7 @@ it.effect("refuses to react on a host with no reactions", () =>...@@ -2777,7 +2806,7 @@ it.effect("refuses to react on a host with no reactions", () =>
2777 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),2806 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
2778 ],2807 ],
2779 providers: [2808 providers: [
2780 fakeProvider("github", {2809 fakeProvider(SourceControlProviderKind.make("github"), {
2781 capabilities: {2810 capabilities: {
2782 diff: true,2811 diff: true,
2783 comment: true,2812 comment: true,
...@@ -2814,7 +2843,7 @@ it.effect("refuses to react on a host whose capabilities omit reactions entirely...@@ -2814,7 +2843,7 @@ it.effect("refuses to react on a host whose capabilities omit reactions entirely
2814 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),2843 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
2815 ],2844 ],
2816 providers: [2845 providers: [
2817 fakeProvider("github", {2846 fakeProvider(SourceControlProviderKind.make("github"), {
2818 capabilities: {2847 capabilities: {
2819 diff: true,2848 diff: true,
2820 comment: true,2849 comment: true,
...@@ -2855,7 +2884,7 @@ it.effect("passes a reaction through with its subject id on a host that has them...@@ -2855,7 +2884,7 @@ it.effect("passes a reaction through with its subject id on a host that has them
2855 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),2884 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
2856 ],2885 ],
2857 providers: [2886 providers: [
2858 fakeProvider("github", {2887 fakeProvider(SourceControlProviderKind.make("github"), {
2859 setReaction: (input) => {2888 setReaction: (input) => {
2860 received = {2889 received = {
2861 subjectId: input.subjectId,2890 subjectId: input.subjectId,
...@@ -2888,7 +2917,7 @@ it.effect("invalidates the cached activity after reacting, like the other mutati...@@ -2888,7 +2917,7 @@ it.effect("invalidates the cached activity after reacting, like the other mutati
2888 const service = yield* makeService({2917 const service = yield* makeService({
2889 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],2918 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2890 providers: [2919 providers: [
2891 fakeProvider("github", {2920 fakeProvider(SourceControlProviderKind.make("github"), {
2892 getChangeRequestActivity: () => {2921 getChangeRequestActivity: () => {
2893 activityCalls += 1;2922 activityCalls += 1;
2894 return Effect.succeed({2923 return Effect.succeed({
...@@ -2926,7 +2955,9 @@ it.effect("refuses an empty reply before it reaches the host", () =>...@@ -2926,7 +2955,9 @@ it.effect("refuses an empty reply before it reaches the host", () =>
2926 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),2955 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
2927 ],2956 ],
2928 providers: [2957 providers: [
2929 fakeProvider("github", { replyToThread: () => Effect.die("must not be called") }),2958 fakeProvider(SourceControlProviderKind.make("github"), {
2959 replyToThread: () => Effect.die("must not be called"),
2960 }),
2930 ],2961 ],
2931 });2962 });
29322963
...@@ -2952,7 +2983,7 @@ it.effect("refuses a merge strategy the host does not offer", () =>...@@ -2952,7 +2983,7 @@ it.effect("refuses a merge strategy the host does not offer", () =>
2952 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),2983 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
2953 ],2984 ],
2954 providers: [2985 providers: [
2955 fakeProvider("github", {2986 fakeProvider(SourceControlProviderKind.make("github"), {
2956 capabilities: {2987 capabilities: {
2957 diff: true,2988 diff: true,
2958 comment: true,2989 comment: true,
...@@ -3005,7 +3036,7 @@ it.effect("hands the provider the host its repository lives on", () =>...@@ -3005,7 +3036,7 @@ it.effect("hands the provider the host its repository lives on", () =>
3005 }),3036 }),
3006 ],3037 ],
3007 providers: [3038 providers: [
3008 fakeProvider("github", {3039 fakeProvider(SourceControlProviderKind.make("github"), {
3009 listChangeRequests: (input) => {3040 listChangeRequests: (input) => {
3010 hosts.push(input.host);3041 hosts.push(input.host);
3011 return Effect.succeed({ items: [], truncated: false, continues: true });3042 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...@@ -3037,12 +3068,12 @@ it.effect("asks every host the reader's search, rather than filtering what came
3037 title: "on gitlab",3068 title: "on gitlab",
3038 workspaceRoot: "/b",3069 workspaceRoot: "/b",
3039 repository: "group/project",3070 repository: "group/project",
3040 provider: "gitlab",3071 provider: SourceControlProviderKind.make("gitlab"),
3041 }),3072 }),
3042 ],3073 ],
3043 providers: [3074 providers: [
3044 fakeProvider("github", { listChangeRequests: listing }),3075 fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: listing }),
3045 fakeProvider("gitlab", { listChangeRequests: listing }),3076 fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: listing }),
3046 ],3077 ],
3047 });3078 });
30483079
...@@ -3062,7 +3093,7 @@ it.effect("asks for no search when the reader has typed nothing", () =>...@@ -3062,7 +3093,7 @@ it.effect("asks for no search when the reader has typed nothing", () =>
3062 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),3093 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
3063 ],3094 ],
3064 providers: [3095 providers: [
3065 fakeProvider("github", {3096 fakeProvider(SourceControlProviderKind.make("github"), {
3066 listChangeRequests: (input) => {3097 listChangeRequests: (input) => {
3067 asked.push(input.query);3098 asked.push(input.query);
3068 return Effect.succeed({ items: [], truncated: false, continues: true });3099 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...@@ -3098,13 +3129,13 @@ it.effect("asks another checkout who is signed in when the first one cannot answ
3098 }),3129 }),
3099 ],3130 ],
3100 providers: [3131 providers: [
3101 fakeProvider("github", {3132 fakeProvider(SourceControlProviderKind.make("github"), {
3102 getViewer: (input) => {3133 getViewer: (input) => {
3103 asked.push(input.cwd);3134 asked.push(input.cwd);
3104 return input.cwd === "/gone"3135 return input.cwd === "/gone"
3105 ? Effect.fail(3136 ? Effect.fail(
3106 new PullRequestProviderError({3137 new PullRequestProviderError({
3107 provider: "github",3138 provider: SourceControlProviderKind.make("github"),
3108 operation: "getViewer",3139 operation: "getViewer",
3109 reason: "failed",3140 reason: "failed",
3110 detail: "not a git repository",3141 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...@@ -3138,7 +3169,7 @@ it.effect("refuses to ask for a review on a host that cannot, before any call is
3138 const service = yield* makeService({3169 const service = yield* makeService({
3139 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3170 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3140 providers: [3171 providers: [
3141 fakeProvider("github", {3172 fakeProvider(SourceControlProviderKind.make("github"), {
3142 capabilities: {3173 capabilities: {
3143 diff: true,3174 diff: true,
3144 comment: true,3175 comment: true,
...@@ -3179,7 +3210,7 @@ it.effect("refuses the candidate list on a host that has no such list to give",...@@ -3179,7 +3210,7 @@ it.effect("refuses the candidate list on a host that has no such list to give",
3179 const service = yield* makeService({3210 const service = yield* makeService({
3180 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3211 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3181 providers: [3212 providers: [
3182 fakeProvider("github", {3213 fakeProvider(SourceControlProviderKind.make("github"), {
3183 capabilities: {3214 capabilities: {
3184 diff: false,3215 diff: false,
3185 comment: false,3216 comment: false,
...@@ -3215,7 +3246,7 @@ it.effect("refuses a review request this viewer may not make, and says what acce...@@ -3215,7 +3246,7 @@ it.effect("refuses a review request this viewer may not make, and says what acce
3215 const service = yield* makeService({3246 const service = yield* makeService({
3216 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3247 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3217 providers: [3248 providers: [
3218 fakeProvider("github", {3249 fakeProvider(SourceControlProviderKind.make("github"), {
3219 // The host asks for reviews; this account only reads the repository.3250 // The host asks for reviews; this account only reads the repository.
3220 getViewerPermissions: () =>3251 getViewerPermissions: () =>
3221 Effect.succeed({3252 Effect.succeed({
...@@ -3254,7 +3285,7 @@ it.effect("keeps the menu from a viewer who may not ask, which is all it is for"...@@ -3254,7 +3285,7 @@ it.effect("keeps the menu from a viewer who may not ask, which is all it is for"
3254 const service = yield* makeService({3285 const service = yield* makeService({
3255 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3286 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3256 providers: [3287 providers: [
3257 fakeProvider("github", {3288 fakeProvider(SourceControlProviderKind.make("github"), {
3258 getViewerPermissions: () =>3289 getViewerPermissions: () =>
3259 Effect.succeed({3290 Effect.succeed({
3260 actions: [],3291 actions: [],
...@@ -3286,7 +3317,7 @@ it.effect("hands the host's own candidate list back, and asks for it with the ch...@@ -3286,7 +3317,7 @@ it.effect("hands the host's own candidate list back, and asks for it with the ch
3286 const service = yield* makeService({3317 const service = yield* makeService({
3287 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3318 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3288 providers: [3319 providers: [
3289 fakeProvider("github", {3320 fakeProvider(SourceControlProviderKind.make("github"), {
3290 listReviewerCandidates: (input) => {3321 listReviewerCandidates: (input) => {
3291 askedFor = input.number;3322 askedFor = input.number;
3292 return Effect.succeed({3323 return Effect.succeed({
...@@ -3328,7 +3359,7 @@ it.effect("refuses a label change on a host that has not said it takes one", ()...@@ -3328,7 +3359,7 @@ it.effect("refuses a label change on a host that has not said it takes one", ()
3328 const service = yield* makeService({3359 const service = yield* makeService({
3329 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3360 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3330 providers: [3361 providers: [
3331 fakeProvider("github", {3362 fakeProvider(SourceControlProviderKind.make("github"), {
3332 // The method is there; the capability that would let it be called is not.3363 // The method is there; the capability that would let it be called is not.
3333 setLabels: () => {3364 setLabels: () => {
3334 changed = true;3365 changed = true;
...@@ -3360,8 +3391,11 @@ it.effect("refuses a label change this viewer may not make, and says what access...@@ -3360,8 +3391,11 @@ it.effect("refuses a label change this viewer may not make, and says what access
3360 const service = yield* makeService({3391 const service = yield* makeService({
3361 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3392 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3362 providers: [3393 providers: [
3363 fakeProvider("github", {3394 fakeProvider(SourceControlProviderKind.make("github"), {
3364 capabilities: { ...fakeProvider("github").capabilities, labels: true },3395 capabilities: {
3396 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
3397 labels: true,
3398 },
3365 getViewerPermissions: () =>3399 getViewerPermissions: () =>
3366 Effect.succeed({3400 Effect.succeed({
3367 actions: [],3401 actions: [],
...@@ -3405,8 +3439,11 @@ it.effect("hands a label change to the host, and reads the labels back for the m...@@ -3405,8 +3439,11 @@ it.effect("hands a label change to the host, and reads the labels back for the m
3405 const service = yield* makeService({3439 const service = yield* makeService({
3406 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3440 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3407 providers: [3441 providers: [
3408 fakeProvider("github", {3442 fakeProvider(SourceControlProviderKind.make("github"), {
3409 capabilities: { ...fakeProvider("github").capabilities, labels: true },3443 capabilities: {
3444 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
3445 labels: true,
3446 },
3410 listLabelCandidates: () =>3447 listLabelCandidates: () =>
3411 Effect.succeed({3448 Effect.succeed({
3412 candidates: [{ name: "bug", color: null, description: null, isApplied: false }],3449 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...@@ -3447,7 +3484,7 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o
3447 const service = yield* makeService({3484 const service = yield* makeService({
3448 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3485 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3449 providers: [3486 providers: [
3450 fakeProvider("github", {3487 fakeProvider(SourceControlProviderKind.make("github"), {
3451 listChangeRequests: () => {3488 listChangeRequests: () => {
3452 hostCalls += 1;3489 hostCalls += 1;
3453 return Effect.succeed({3490 return Effect.succeed({
...@@ -3496,7 +3533,7 @@ it.effect.each(["list", "listStats"] as const)(...@@ -3496,7 +3533,7 @@ it.effect.each(["list", "listStats"] as const)(
3496 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),3533 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
3497 ],3534 ],
3498 providers: [3535 providers: [
3499 fakeProvider("github", {3536 fakeProvider(SourceControlProviderKind.make("github"), {
3500 listChangeRequests: () =>3537 listChangeRequests: () =>
3501 lookup.pipe(3538 lookup.pipe(
3502 Effect.as({3539 Effect.as({
...@@ -3545,7 +3582,7 @@ it.effect("shares one cold viewer lookup across distinct concurrent lists", () =...@@ -3545,7 +3582,7 @@ it.effect("shares one cold viewer lookup across distinct concurrent lists", () =
3545 const service = yield* makeService({3582 const service = yield* makeService({
3546 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3583 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3547 providers: [3584 providers: [
3548 fakeProvider("github", {3585 fakeProvider(SourceControlProviderKind.make("github"), {
3549 getViewer: () =>3586 getViewer: () =>
3550 Effect.sync(() => {3587 Effect.sync(() => {
3551 viewerCalls += 1;3588 viewerCalls += 1;
...@@ -3583,7 +3620,7 @@ it.effect("uses five host reads for the normal indexed-repository page workflow"...@@ -3583,7 +3620,7 @@ it.effect("uses five host reads for the normal indexed-repository page workflow"
3583 const service = yield* makeService({3620 const service = yield* makeService({
3584 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3621 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3585 providers: [3622 providers: [
3586 fakeProvider("github", {3623 fakeProvider(SourceControlProviderKind.make("github"), {
3587 getViewer: () =>3624 getViewer: () =>
3588 Effect.sync(() => {3625 Effect.sync(() => {
3589 viewerCalls += 1;3626 viewerCalls += 1;
...@@ -3643,7 +3680,7 @@ it.effect("returns the refreshed listing on the first read after its cache expir...@@ -3643,7 +3680,7 @@ it.effect("returns the refreshed listing on the first read after its cache expir
3643 const service = yield* makeService({3680 const service = yield* makeService({
3644 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3681 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3645 providers: [3682 providers: [
3646 fakeProvider("github", {3683 fakeProvider(SourceControlProviderKind.make("github"), {
3647 listChangeRequests: () => {3684 listChangeRequests: () => {
3648 hostCalls += 1;3685 hostCalls += 1;
3649 return Effect.succeed({3686 return Effect.succeed({
...@@ -3682,7 +3719,7 @@ it.effect("a listing narrowed to some projects is its own cache entry", () =>...@@ -3682,7 +3719,7 @@ it.effect("a listing narrowed to some projects is its own cache entry", () =>
3682 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),3719 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
3683 ],3720 ],
3684 providers: [3721 providers: [
3685 fakeProvider("github", {3722 fakeProvider(SourceControlProviderKind.make("github"), {
3686 listChangeRequestsAcross: (input) => {3723 listChangeRequestsAcross: (input) => {
3687 asked.push(input.repositories);3724 asked.push(input.repositories);
3688 return Effect.succeed({3725 return Effect.succeed({
...@@ -3725,7 +3762,7 @@ it.effect(...@@ -3725,7 +3762,7 @@ it.effect(
3725 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),3762 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
3726 ],3763 ],
3727 providers: [3764 providers: [
3728 fakeProvider("github", {3765 fakeProvider(SourceControlProviderKind.make("github"), {
3729 listChangeRequests: ({ filters }) =>3766 listChangeRequests: ({ filters }) =>
3730 Effect.gen(function* () {3767 Effect.gen(function* () {
3731 reads += 1;3768 reads += 1;
...@@ -3782,7 +3819,7 @@ it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and proj...@@ -3782,7 +3819,7 @@ it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and proj
3782 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),3819 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
3783 ],3820 ],
3784 providers: [3821 providers: [
3785 fakeProvider("github", {3822 fakeProvider(SourceControlProviderKind.make("github"), {
3786 getChangeRequest: (input) =>3823 getChangeRequest: (input) =>
3787 Effect.sync(() => {3824 Effect.sync(() => {
3788 calls.push(`${input.repository}/${input.number}`);3825 calls.push(`${input.repository}/${input.number}`);
...@@ -3827,7 +3864,7 @@ it.effect(...@@ -3827,7 +3864,7 @@ it.effect(
3827 }),3864 }),
3828 ],3865 ],
3829 providers: [3866 providers: [
3830 fakeProvider("github", {3867 fakeProvider(SourceControlProviderKind.make("github"), {
3831 getChangeRequest: (input) =>3868 getChangeRequest: (input) =>
3832 Effect.sync(() => {3869 Effect.sync(() => {
3833 hosts.push(input.host);3870 hosts.push(input.host);
...@@ -3858,7 +3895,7 @@ it.effect("does not revive old summaries when project epochs are evicted", () =>...@@ -3858,7 +3895,7 @@ it.effect("does not revive old summaries when project epochs are evicted", () =>
3858 const service = yield* makeService({3895 const service = yield* makeService({
3859 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3896 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3860 providers: [3897 providers: [
3861 fakeProvider("github", {3898 fakeProvider(SourceControlProviderKind.make("github"), {
3862 getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), title }),3899 getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), title }),
3863 }),3900 }),
3864 ],3901 ],
...@@ -3882,7 +3919,7 @@ it.effect("explicit and turn invalidations make the next listing ask the host ag...@@ -3882,7 +3919,7 @@ it.effect("explicit and turn invalidations make the next listing ask the host ag
3882 const service = yield* makeService({3919 const service = yield* makeService({
3883 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],3920 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3884 providers: [3921 providers: [
3885 fakeProvider("github", {3922 fakeProvider(SourceControlProviderKind.make("github"), {
3886 getViewer: () => {3923 getViewer: () => {
3887 viewerCalls += 1;3924 viewerCalls += 1;
3888 return Effect.succeed("bilal");3925 return Effect.succeed("bilal");
...@@ -3924,7 +3961,7 @@ it.effect("close and reopen notify subscribed readers after invalidating their c...@@ -3924,7 +3961,7 @@ it.effect("close and reopen notify subscribed readers after invalidating their c
3924 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),3961 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
3925 ],3962 ],
3926 providers: [3963 providers: [
3927 fakeProvider("github", {3964 fakeProvider(SourceControlProviderKind.make("github"), {
3928 getChangeRequestSummary: () =>3965 getChangeRequestSummary: () =>
3929 Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }),3966 Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }),
3930 runAction: (input) =>3967 runAction: (input) =>
...@@ -3976,7 +4013,7 @@ it.effect("explicit invalidation refreshes origin readers after a routed host mu...@@ -3976,7 +4013,7 @@ it.effect("explicit invalidation refreshes origin readers after a routed host mu
3976 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),4013 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
3977 ],4014 ],
3978 providers: [4015 providers: [
3979 fakeProvider("github", {4016 fakeProvider(SourceControlProviderKind.make("github"), {
3980 getChangeRequestSummary: () =>4017 getChangeRequestSummary: () =>
3981 Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }),4018 Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }),
3982 }),4019 }),
...@@ -4021,7 +4058,7 @@ it.effect("does not cache a failed listing", () =>...@@ -4021,7 +4058,7 @@ it.effect("does not cache a failed listing", () =>
4021 const service = yield* makeService({4058 const service = yield* makeService({
4022 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],4059 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4023 providers: [4060 providers: [
4024 fakeProvider("github", {4061 fakeProvider(SourceControlProviderKind.make("github"), {
4025 // The viewer lookup is what fails the whole listing rather than one repository.4062 // The viewer lookup is what fails the whole listing rather than one repository.
4026 getViewer: () => {4063 getViewer: () => {
4027 hostCalls += 1;4064 hostCalls += 1;
...@@ -4052,11 +4089,11 @@ it.effect("reads a host's repositories in one search, and files the rows back un...@@ -4052,11 +4089,11 @@ it.effect("reads a host's repositories in one search, and files the rows back un
4052 title: "on gitlab",4089 title: "on gitlab",
4053 workspaceRoot: "/c",4090 workspaceRoot: "/c",
4054 repository: "group/project",4091 repository: "group/project",
4055 provider: "gitlab",4092 provider: SourceControlProviderKind.make("gitlab"),
4056 }),4093 }),
4057 ],4094 ],
4058 providers: [4095 providers: [
4059 fakeProvider("github", {4096 fakeProvider(SourceControlProviderKind.make("github"), {
4060 listChangeRequests: ({ repository }) => {4097 listChangeRequests: ({ repository }) => {
4061 separately.push(repository);4098 separately.push(repository);
4062 return Effect.succeed({ items: [], truncated: false, continues: true });4099 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...@@ -4073,7 +4110,7 @@ it.effect("reads a host's repositories in one search, and files the rows back un
4073 },4110 },
4074 }),4111 }),
4075 // A host with no search across repositories keeps being asked one at a time.4112 // A host with no search across repositories keeps being asked one at a time.
4076 fakeProvider("gitlab", {4113 fakeProvider(SourceControlProviderKind.make("gitlab"), {
4077 listChangeRequests: ({ repository }) => {4114 listChangeRequests: ({ repository }) => {
4078 separately.push(repository);4115 separately.push(repository);
4079 return Effect.succeed({4116 return Effect.succeed({
...@@ -4111,7 +4148,7 @@ it.effect("carries every repository of a slice on from the oldest row in it", ()...@@ -4111,7 +4148,7 @@ it.effect("carries every repository of a slice on from the oldest row in it", ()
4111 project({ id: "p3", title: "docs", workspaceRoot: "/c", repository: "acme/docs" }),4148 project({ id: "p3", title: "docs", workspaceRoot: "/c", repository: "acme/docs" }),
4112 ],4149 ],
4113 providers: [4150 providers: [
4114 fakeProvider("github", {4151 fakeProvider(SourceControlProviderKind.make("github"), {
4115 listChangeRequestsAcross: () =>4152 listChangeRequestsAcross: () =>
4116 Effect.succeed({4153 Effect.succeed({
4117 items: [4154 items: [
...@@ -4144,7 +4181,7 @@ it.effect("carries a slice on without sending the rows it already sent", () =>...@@ -4144,7 +4181,7 @@ it.effect("carries a slice on without sending the rows it already sent", () =>
4144 const service = yield* makeService({4181 const service = yield* makeService({
4145 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],4182 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4146 providers: [4183 providers: [
4147 fakeProvider("github", {4184 fakeProvider(SourceControlProviderKind.make("github"), {
4148 listChangeRequestsAcross: (input) => {4185 listChangeRequestsAcross: (input) => {
4149 cursors.push(input.cursor);4186 cursors.push(input.cursor);
4150 return Effect.succeed({4187 return Effect.succeed({
...@@ -4189,7 +4226,7 @@ it.effect("reads a workspace larger than one search in chunks, and merges them",...@@ -4189,7 +4226,7 @@ it.effect("reads a workspace larger than one search in chunks, and merges them",
4189 }),4226 }),
4190 ),4227 ),
4191 providers: [4228 providers: [
4192 fakeProvider("github", {4229 fakeProvider(SourceControlProviderKind.make("github"), {
4193 listChangeRequestsAcross: (input) => {4230 listChangeRequestsAcross: (input) => {
4194 asked.push(input.repositories.length);4231 asked.push(input.repositories.length);
4195 return Effect.succeed({4232 return Effect.succeed({
...@@ -4218,7 +4255,7 @@ it.effect("asks on its own for a repository a search answered nothing for", () =...@@ -4218,7 +4255,7 @@ it.effect("asks on its own for a repository a search answered nothing for", () =
4218 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),4255 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
4219 ],4256 ],
4220 providers: [4257 providers: [
4221 fakeProvider("github", {4258 fakeProvider(SourceControlProviderKind.make("github"), {
4222 listChangeRequests: ({ repository }) => {4259 listChangeRequests: ({ repository }) => {
4223 separately.push(repository);4260 separately.push(repository);
4224 return repository === "acme/docs"4261 return repository === "acme/docs"
...@@ -4262,7 +4299,7 @@ it.effect("reads the repositories one at a time when the search itself fails", (...@@ -4262,7 +4299,7 @@ it.effect("reads the repositories one at a time when the search itself fails", (
4262 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),4299 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
4263 ],4300 ],
4264 providers: [4301 providers: [
4265 fakeProvider("github", {4302 fakeProvider(SourceControlProviderKind.make("github"), {
4266 listChangeRequests: ({ repository }) => {4303 listChangeRequests: ({ repository }) => {
4267 separately.push(repository);4304 separately.push(repository);
4268 return Effect.succeed({4305 return Effect.succeed({
...@@ -4295,11 +4332,11 @@ it.effect("fills in the line counts for the rows it is given", () =>...@@ -4295,11 +4332,11 @@ it.effect("fills in the line counts for the rows it is given", () =>
4295 title: "on gitlab",4332 title: "on gitlab",
4296 workspaceRoot: "/b",4333 workspaceRoot: "/b",
4297 repository: "group/project",4334 repository: "group/project",
4298 provider: "gitlab",4335 provider: SourceControlProviderKind.make("gitlab"),
4299 }),4336 }),
4300 ],4337 ],
4301 providers: [4338 providers: [
4302 fakeProvider("github", {4339 fakeProvider(SourceControlProviderKind.make("github"), {
4303 listChangeRequestStats: (input) => {4340 listChangeRequestStats: (input) => {
4304 asked.push(input.changeRequests);4341 asked.push(input.changeRequests);
4305 return Effect.succeed([4342 return Effect.succeed([
...@@ -4308,7 +4345,7 @@ it.effect("fills in the line counts for the rows it is given", () =>...@@ -4308,7 +4345,7 @@ it.effect("fills in the line counts for the rows it is given", () =>
4308 },4345 },
4309 }),4346 }),
4310 // Its listing carries the counts already, so it has nothing to be asked.4347 // Its listing carries the counts already, so it has nothing to be asked.
4311 fakeProvider("gitlab"),4348 fakeProvider(SourceControlProviderKind.make("gitlab")),
4312 ],4349 ],
4313 });4350 });
43144351
...@@ -4355,7 +4392,7 @@ it.effect(...@@ -4355,7 +4392,7 @@ it.effect(
4355 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),4392 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4356 ],4393 ],
4357 providers: [4394 providers: [
4358 fakeProvider("github", {4395 fakeProvider(SourceControlProviderKind.make("github"), {
4359 listChangeRequestStats: (input) => {4396 listChangeRequestStats: (input) => {
4360 asked.push(input.changeRequests.map((ref) => ref.number));4397 asked.push(input.changeRequests.map((ref) => ref.number));
4361 return Effect.succeed(4398 return Effect.succeed(
...@@ -4404,7 +4441,7 @@ it.effect("reads the fresh diff when detail or summary discovers a changed revis...@@ -4404,7 +4441,7 @@ it.effect("reads the fresh diff when detail or summary discovers a changed revis
4404 const service = yield* makeService({4441 const service = yield* makeService({
4405 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],4442 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4406 providers: [4443 providers: [
4407 fakeProvider("github", {4444 fakeProvider(SourceControlProviderKind.make("github"), {
4408 getChangeRequest: () =>4445 getChangeRequest: () =>
4409 Effect.sync(() => ({ ...hostedChangeRequest("body"), updatedAt: revision })),4446 Effect.sync(() => ({ ...hostedChangeRequest("body"), updatedAt: revision })),
4410 getChangeRequestSummary: () =>4447 getChangeRequestSummary: () =>
...@@ -4453,7 +4490,9 @@ it.effect("keeps the rows when the line counts cannot be read", () =>...@@ -4453,7 +4490,9 @@ it.effect("keeps the rows when the line counts cannot be read", () =>
4453 const service = yield* makeService({4490 const service = yield* makeService({
4454 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],4491 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4455 providers: [4492 providers: [
4456 fakeProvider("github", { listChangeRequestStats: () => Effect.fail(requestFailed) }),4493 fakeProvider(SourceControlProviderKind.make("github"), {
4494 listChangeRequestStats: () => Effect.fail(requestFailed),
4495 }),
4457 ],4496 ],
4458 });4497 });
44594498
...@@ -4478,7 +4517,7 @@ it.effect(...@@ -4478,7 +4517,7 @@ it.effect(
4478 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),4517 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4479 ],4518 ],
4480 providers: [4519 providers: [
4481 fakeProvider("github", {4520 fakeProvider(SourceControlProviderKind.make("github"), {
4482 listChangeRequestStats: () => {4521 listChangeRequestStats: () => {
4483 statsCalls += 1;4522 statsCalls += 1;
4484 return Effect.succeed([]);4523 return Effect.succeed([]);
...@@ -4556,7 +4595,7 @@ it.effect("shares linked summaries and reuses them for display without asking th...@@ -4556,7 +4595,7 @@ it.effect("shares linked summaries and reuses them for display without asking th
4556 const service = yield* makeService({4595 const service = yield* makeService({
4557 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],4596 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4558 providers: [4597 providers: [
4559 fakeProvider("github", {4598 fakeProvider(SourceControlProviderKind.make("github"), {
4560 getChangeRequestSummary: () =>4599 getChangeRequestSummary: () =>
4561 Effect.sync(() => {4600 Effect.sync(() => {
4562 calls += 1;4601 calls += 1;
...@@ -4567,7 +4606,7 @@ it.effect("shares linked summaries and reuses them for display without asking th...@@ -4567,7 +4606,7 @@ it.effect("shares linked summaries and reuses them for display without asking th
4567 shouldFail4606 shouldFail
4568 ? Effect.fail(4607 ? Effect.fail(
4569 new PullRequestProviderError({4608 new PullRequestProviderError({
4570 provider: "github",4609 provider: SourceControlProviderKind.make("github"),
4571 operation: "getChangeRequestSummary",4610 operation: "getChangeRequestSummary",
4572 reason: "failed",4611 reason: "failed",
4573 detail: "HTTP 504",4612 detail: "HTTP 504",
...@@ -4622,8 +4661,11 @@ it.effect("keeps routed reads separate when the GitHub account changes", () =>...@@ -4622,8 +4661,11 @@ it.effect("keeps routed reads separate when the GitHub account changes", () =>
4622 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),4661 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4623 ],4662 ],
4624 providers: [4663 providers: [
4625 fakeProvider("github", {4664 fakeProvider(SourceControlProviderKind.make("github"), {
4626 capabilities: { ...fakeProvider("github").capabilities, viewedFiles: "host" },4665 capabilities: {
4666 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
4667 viewedFiles: "host",
4668 },
4627 getFilesViewed: () =>4669 getFilesViewed: () =>
4628 read().pipe(4670 read().pipe(
4629 Effect.as({4671 Effect.as({
...@@ -4685,8 +4727,11 @@ it.effect("isolates routed caches for two credentials belonging to the same acco...@@ -4685,8 +4727,11 @@ it.effect("isolates routed caches for two credentials belonging to the same acco
4685 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),4727 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4686 ],4728 ],
4687 providers: [4729 providers: [
4688 fakeProvider("github", {4730 fakeProvider(SourceControlProviderKind.make("github"), {
4689 capabilities: { ...fakeProvider("github").capabilities, viewedFiles: "host" },4731 capabilities: {
4732 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
4733 viewedFiles: "host",
4734 },
4690 getFilesViewed: () =>4735 getFilesViewed: () =>
4691 read().pipe(4736 read().pipe(
4692 Effect.as({4737 Effect.as({
...@@ -4744,7 +4789,7 @@ it.effect("rejects mismatched routing credentials before use and preserves actio...@@ -4744,7 +4789,7 @@ it.effect("rejects mismatched routing credentials before use and preserves actio
4744 const service = yield* makeService({4789 const service = yield* makeService({
4745 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],4790 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4746 providers: [4791 providers: [
4747 fakeProvider("github", {4792 fakeProvider(SourceControlProviderKind.make("github"), {
4748 getRoutingIdentity: () => Effect.succeed({ accountId: "101", viewer: "octocat" }),4793 getRoutingIdentity: () => Effect.succeed({ accountId: "101", viewer: "octocat" }),
4749 withVerifiedCredential: (_, use) =>4794 withVerifiedCredential: (_, use) =>
4750 use({4795 use({
...@@ -4798,7 +4843,7 @@ it.effect("answers a known pull request immediately while the host refreshes", (...@@ -4798,7 +4843,7 @@ it.effect("answers a known pull request immediately while the host refreshes", (
4798 const service = yield* makeService({4843 const service = yield* makeService({
4799 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],4844 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4800 providers: [4845 providers: [
4801 fakeProvider("github", {4846 fakeProvider(SourceControlProviderKind.make("github"), {
4802 getChangeRequest: () =>4847 getChangeRequest: () =>
4803 Effect.gen(function* () {4848 Effect.gen(function* () {
4804 calls += 1;4849 calls += 1;
...@@ -4829,7 +4874,7 @@ it.effect("does not ask the host again for a linked summary it already holds", (...@@ -4829,7 +4874,7 @@ it.effect("does not ask the host again for a linked summary it already holds", (
4829 const service = yield* makeService({4874 const service = yield* makeService({
4830 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],4875 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4831 providers: [4876 providers: [
4832 fakeProvider("github", {4877 fakeProvider(SourceControlProviderKind.make("github"), {
4833 getChangeRequestSummary: () =>4878 getChangeRequestSummary: () =>
4834 Effect.sync(() => {4879 Effect.sync(() => {
4835 calls += 1;4880 calls += 1;
...@@ -4858,7 +4903,7 @@ it.effect(...@@ -4858,7 +4903,7 @@ it.effect(
4858 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),4903 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4859 ],4904 ],
4860 providers: [4905 providers: [
4861 fakeProvider("github", {4906 fakeProvider(SourceControlProviderKind.make("github"), {
4862 getChangeRequestSummary: () =>4907 getChangeRequestSummary: () =>
4863 Effect.succeed({4908 Effect.succeed({
4864 ...changeRequest(1, "2026-07-02T00:00:00Z"),4909 ...changeRequest(1, "2026-07-02T00:00:00Z"),
...@@ -4896,7 +4941,7 @@ it.effect("reuses an observed merged state for strict settlement reads", () =>...@@ -4896,7 +4941,7 @@ it.effect("reuses an observed merged state for strict settlement reads", () =>
4896 const service = yield* makeService({4941 const service = yield* makeService({
4897 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],4942 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4898 providers: [4943 providers: [
4899 fakeProvider("github", {4944 fakeProvider(SourceControlProviderKind.make("github"), {
4900 getChangeRequest: () =>4945 getChangeRequest: () =>
4901 Effect.succeed({4946 Effect.succeed({
4902 ...hostedChangeRequest("merged body", 4),4947 ...hostedChangeRequest("merged body", 4),
...@@ -4927,7 +4972,7 @@ it.effect("announces state a detail read sees first or newly", () =>...@@ -4927,7 +4972,7 @@ it.effect("announces state a detail read sees first or newly", () =>
4927 const service = yield* makeService({4972 const service = yield* makeService({
4928 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],4973 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4929 providers: [4974 providers: [
4930 fakeProvider("github", {4975 fakeProvider(SourceControlProviderKind.make("github"), {
4931 getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }),4976 getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }),
4932 getChangeRequestSummary: () =>4977 getChangeRequestSummary: () =>
4933 Effect.succeed({4978 Effect.succeed({
...@@ -4979,7 +5024,7 @@ it.effect("announces a reported state without trusting it", () =>...@@ -4979,7 +5024,7 @@ it.effect("announces a reported state without trusting it", () =>
4979 const service = yield* makeService({5024 const service = yield* makeService({
4980 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],5025 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4981 providers: [5026 providers: [
4982 fakeProvider("github", {5027 fakeProvider(SourceControlProviderKind.make("github"), {
4983 getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }),5028 getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }),
4984 }),5029 }),
4985 ],5030 ],
...@@ -5024,7 +5069,7 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary...@@ -5024,7 +5069,7 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary
5024 const service = yield* makeService({5069 const service = yield* makeService({
5025 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],5070 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5026 providers: [5071 providers: [
5027 fakeProvider("github", {5072 fakeProvider(SourceControlProviderKind.make("github"), {
5028 getChangeRequest: () =>5073 getChangeRequest: () =>
5029 Effect.gen(function* () {5074 Effect.gen(function* () {
5030 detailCalls += 1;5075 detailCalls += 1;
...@@ -5078,7 +5123,7 @@ it.effect("does not let a still-cached detail overwrite a fresher linked summary...@@ -5078,7 +5123,7 @@ it.effect("does not let a still-cached detail overwrite a fresher linked summary
5078 const service = yield* makeService({5123 const service = yield* makeService({
5079 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],5124 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5080 providers: [5125 providers: [
5081 fakeProvider("github", {5126 fakeProvider(SourceControlProviderKind.make("github"), {
5082 getChangeRequest: () => Effect.succeed(hostedChangeRequest("old body", 4)),5127 getChangeRequest: () => Effect.succeed(hostedChangeRequest("old body", 4)),
5083 getChangeRequestSummary: () =>5128 getChangeRequestSummary: () =>
5084 Effect.succeed({5129 Effect.succeed({
...@@ -5114,11 +5159,11 @@ it.effect("tells the client when the host has no pull request under that number"...@@ -5114,11 +5159,11 @@ it.effect("tells the client when the host has no pull request under that number"
5114 const service = yield* makeService({5159 const service = yield* makeService({
5115 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],5160 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5116 providers: [5161 providers: [
5117 fakeProvider("github", {5162 fakeProvider(SourceControlProviderKind.make("github"), {
5118 getChangeRequest: () =>5163 getChangeRequest: () =>
5119 Effect.fail(5164 Effect.fail(
5120 new PullRequestProviderError({5165 new PullRequestProviderError({
5121 provider: "github",5166 provider: SourceControlProviderKind.make("github"),
5122 operation: "getChangeRequest",5167 operation: "getChangeRequest",
5123 reason: "not-found",5168 reason: "not-found",
5124 detail: "Pull request not found. Check the PR number or URL and try again.",5169 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...@@ -5142,12 +5187,12 @@ it.effect("keeps recent detail on a transient refresh failure but not after inva
5142 const service = yield* makeService({5187 const service = yield* makeService({
5143 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],5188 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5144 providers: [5189 providers: [
5145 fakeProvider("github", {5190 fakeProvider(SourceControlProviderKind.make("github"), {
5146 getChangeRequest: () =>5191 getChangeRequest: () =>
5147 failing5192 failing
5148 ? Effect.fail(5193 ? Effect.fail(
5149 new PullRequestProviderError({5194 new PullRequestProviderError({
5150 provider: "github",5195 provider: SourceControlProviderKind.make("github"),
5151 operation: "getChangeRequest",5196 operation: "getChangeRequest",
5152 reason: "failed",5197 reason: "failed",
5153 detail: "spawn gh EAGAIN",5198 detail: "spawn gh EAGAIN",
...@@ -5197,7 +5242,7 @@ it.effect("carries an armed auto-merge through to the detail, and silence as sil...@@ -5197,7 +5242,7 @@ it.effect("carries an armed auto-merge through to the detail, and silence as sil
5197 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),5242 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
5198 ],5243 ],
5199 providers: [5244 providers: [
5200 fakeProvider("github", {5245 fakeProvider(SourceControlProviderKind.make("github"), {
5201 getChangeRequest: () =>5246 getChangeRequest: () =>
5202 Effect.succeed({5247 Effect.succeed({
5203 ...changeRequest(1, "2026-07-02T00:00:00Z"),5248 ...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", (...@@ -5243,13 +5288,13 @@ it.effect("narrows the rows of a host that ignored the filters it was handed", (
5243 title: "web",5288 title: "web",
5244 workspaceRoot: "/a",5289 workspaceRoot: "/a",
5245 repository: "acme/web",5290 repository: "acme/web",
5246 provider: "gitlab",5291 provider: SourceControlProviderKind.make("gitlab"),
5247 }),5292 }),
5248 ],5293 ],
5249 providers: [5294 providers: [
5250 // Only GitHub narrows a listing for itself; every other host answers unnarrowed, and5295 // Only GitHub narrows a listing for itself; every other host answers unnarrowed, and
5251 // sending it a draft filter it quietly ignores used to put drafts on a filtered page.5296 // sending it a draft filter it quietly ignores used to put drafts on a filtered page.
5252 fakeProvider("gitlab", {5297 fakeProvider(SourceControlProviderKind.make("gitlab"), {
5253 listChangeRequests: () =>5298 listChangeRequests: () =>
5254 Effect.succeed({5299 Effect.succeed({
5255 items: [5300 items: [
...@@ -5285,11 +5330,11 @@ it.effect("keeps a row of a host that ignored the filters if any name of a label...@@ -5285,11 +5330,11 @@ it.effect("keeps a row of a host that ignored the filters if any name of a label
5285 title: "web",5330 title: "web",
5286 workspaceRoot: "/a",5331 workspaceRoot: "/a",
5287 repository: "acme/web",5332 repository: "acme/web",
5288 provider: "gitlab",5333 provider: SourceControlProviderKind.make("gitlab"),
5289 }),5334 }),
5290 ],5335 ],
5291 providers: [5336 providers: [
5292 fakeProvider("gitlab", {5337 fakeProvider(SourceControlProviderKind.make("gitlab"), {
5293 listChangeRequests: () =>5338 listChangeRequests: () =>
5294 Effect.succeed({5339 Effect.succeed({
5295 items: [5340 items: [
...@@ -5328,13 +5373,13 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho...@@ -5328,13 +5373,13 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho
5328 title: "web",5373 title: "web",
5329 workspaceRoot: "/a",5374 workspaceRoot: "/a",
5330 repository: "acme/web",5375 repository: "acme/web",
5331 provider: "gitlab",5376 provider: SourceControlProviderKind.make("gitlab"),
5332 }),5377 }),
5333 ],5378 ],
5334 providers: [5379 providers: [
5335 // Only GitHub narrows a listing for itself, so this fixture's "me" has to be resolved5380 // Only GitHub narrows a listing for itself, so this fixture's "me" has to be resolved
5336 // locally too — the same helper both call sites lean on.5381 // locally too — the same helper both call sites lean on.
5337 fakeProvider("gitlab", {5382 fakeProvider(SourceControlProviderKind.make("gitlab"), {
5338 listChangeRequests: () =>5383 listChangeRequests: () =>
5339 Effect.succeed({5384 Effect.succeed({
5340 items: [5385 items: [
...@@ -5395,7 +5440,7 @@ it.effect.each([false, true])(...@@ -5395,7 +5440,7 @@ it.effect.each([false, true])(
5395 }),5440 }),
5396 ],5441 ],
5397 providers: [5442 providers: [
5398 fakeProvider("github", {5443 fakeProvider(SourceControlProviderKind.make("github"), {
5399 capabilities,5444 capabilities,
5400 getViewerPermissions: () =>5445 getViewerPermissions: () =>
5401 Effect.succeed({5446 Effect.succeed({
...@@ -5463,7 +5508,7 @@ it.effect("refuses a way of updating a branch that the host or the viewer does n...@@ -5463,7 +5508,7 @@ it.effect("refuses a way of updating a branch that the host or the viewer does n
5463 const service = yield* makeService({5508 const service = yield* makeService({
5464 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],5509 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5465 providers: [5510 providers: [
5466 fakeProvider("github", {5511 fakeProvider(SourceControlProviderKind.make("github"), {
5467 capabilities: {5512 capabilities: {
5468 diff: true,5513 diff: true,
5469 comment: true,5514 comment: true,
...@@ -5516,11 +5561,11 @@ it.effect("refuses to merge a target branch into a source branch on a host that...@@ -5516,11 +5561,11 @@ it.effect("refuses to merge a target branch into a source branch on a host that
5516 title: "on gitlab",5561 title: "on gitlab",
5517 workspaceRoot: "/a",5562 workspaceRoot: "/a",
5518 repository: "group/project",5563 repository: "group/project",
5519 provider: "gitlab",5564 provider: SourceControlProviderKind.make("gitlab"),
5520 }),5565 }),
5521 ],5566 ],
5522 providers: [5567 providers: [
5523 fakeProvider("gitlab", {5568 fakeProvider(SourceControlProviderKind.make("gitlab"), {
5524 capabilities: {5569 capabilities: {
5525 diff: true,5570 diff: true,
5526 comment: true,5571 comment: true,
...@@ -5575,12 +5620,12 @@ it.effect("judges the review filter only on a host that summarises its reviews",...@@ -5575,12 +5620,12 @@ it.effect("judges the review filter only on a host that summarises its reviews",
5575 title: "on gitlab",5620 title: "on gitlab",
5576 workspaceRoot: "/b",5621 workspaceRoot: "/b",
5577 repository: "group/project",5622 repository: "group/project",
5578 provider: "gitlab",5623 provider: SourceControlProviderKind.make("gitlab"),
5579 }),5624 }),
5580 ],5625 ],
5581 providers: [5626 providers: [
5582 // GitHub answers with the field on every row: null is "nobody has decided yet".5627 // GitHub answers with the field on every row: null is "nobody has decided yet".
5583 fakeProvider("github", {5628 fakeProvider(SourceControlProviderKind.make("github"), {
5584 listChangeRequests: () =>5629 listChangeRequests: () =>
5585 Effect.succeed({5630 Effect.succeed({
5586 items: [5631 items: [
...@@ -5595,7 +5640,7 @@ it.effect("judges the review filter only on a host that summarises its reviews",...@@ -5595,7 +5640,7 @@ it.effect("judges the review filter only on a host that summarises its reviews",
5595 }),5640 }),
5596 }),5641 }),
5597 // GitLab never supplies the field, so its rows are not the filter's to judge.5642 // GitLab never supplies the field, so its rows are not the filter's to judge.
5598 fakeProvider("gitlab", {5643 fakeProvider(SourceControlProviderKind.make("gitlab"), {
5599 listChangeRequests: () =>5644 listChangeRequests: () =>
5600 Effect.succeed({5645 Effect.succeed({
5601 items: [changeRequest(3, "2026-07-02T00:00:00Z")],5646 items: [changeRequest(3, "2026-07-02T00:00:00Z")],
...@@ -5621,7 +5666,7 @@ it.effect("sends only the words a rewrite carries", () =>...@@ -5621,7 +5666,7 @@ it.effect("sends only the words a rewrite carries", () =>
5621 const service = yield* makeService({5666 const service = yield* makeService({
5622 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],5667 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5623 providers: [5668 providers: [
5624 fakeProvider("github", {5669 fakeProvider(SourceControlProviderKind.make("github"), {
5625 updateChangeRequest: (input) => {5670 updateChangeRequest: (input) => {
5626 received.push({ title: input.title, body: input.body });5671 received.push({ title: input.title, body: input.body });
5627 return Effect.void;5672 return Effect.void;
...@@ -5647,7 +5692,9 @@ it.effect("refuses a rewrite that changes nothing, before any call is made", ()...@@ -5647,7 +5692,9 @@ it.effect("refuses a rewrite that changes nothing, before any call is made", ()
5647 const service = yield* makeService({5692 const service = yield* makeService({
5648 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],5693 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5649 providers: [5694 providers: [
5650 fakeProvider("github", { updateChangeRequest: () => Effect.die("must not be called") }),5695 fakeProvider(SourceControlProviderKind.make("github"), {
5696 updateChangeRequest: () => Effect.die("must not be called"),
5697 }),
5651 ],5698 ],
5652 });5699 });
56535700
...@@ -5665,7 +5712,7 @@ it.effect("refuses to rewrite anything on a host that never claimed it", () =>...@@ -5665,7 +5712,7 @@ it.effect("refuses to rewrite anything on a host that never claimed it", () =>
5665 const service = yield* makeService({5712 const service = yield* makeService({
5666 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],5713 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5667 providers: [5714 providers: [
5668 fakeProvider("github", {5715 fakeProvider(SourceControlProviderKind.make("github"), {
5669 capabilities: {5716 capabilities: {
5670 diff: true,5717 diff: true,
5671 comment: true,5718 comment: true,
...@@ -5704,7 +5751,7 @@ it.effect("passes a rewritten remark through with the id and kind it arrived und...@@ -5704,7 +5751,7 @@ it.effect("passes a rewritten remark through with the id and kind it arrived und
5704 const service = yield* makeService({5751 const service = yield* makeService({
5705 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],5752 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5706 providers: [5753 providers: [
5707 fakeProvider("github", {5754 fakeProvider(SourceControlProviderKind.make("github"), {
5708 updateComment: (input) => {5755 updateComment: (input) => {
5709 received = { id: input.commentId, kind: input.kind, body: input.body };5756 received = { id: input.commentId, kind: input.kind, body: input.body };
5710 return Effect.void;5757 return Effect.void;
...@@ -5735,7 +5782,9 @@ it.effect("refuses a remark rewritten into nothing but whitespace", () =>...@@ -5735,7 +5782,9 @@ it.effect("refuses a remark rewritten into nothing but whitespace", () =>
5735 const service = yield* makeService({5782 const service = yield* makeService({
5736 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],5783 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5737 providers: [5784 providers: [
5738 fakeProvider("github", { updateComment: () => Effect.die("must not be called") }),5785 fakeProvider(SourceControlProviderKind.make("github"), {
5786 updateComment: () => Effect.die("must not be called"),
5787 }),
5739 ],5788 ],
5740 });5789 });
57415790
...@@ -5761,7 +5810,7 @@ it.effect("forgets the cached detail after a rewrite or terminal turn", () =>...@@ -5761,7 +5810,7 @@ it.effect("forgets the cached detail after a rewrite or terminal turn", () =>
5761 const service = yield* makeService({5810 const service = yield* makeService({
5762 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],5811 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5763 providers: [5812 providers: [
5764 fakeProvider("github", {5813 fakeProvider(SourceControlProviderKind.make("github"), {
5765 getChangeRequest: () => {5814 getChangeRequest: () => {
5766 coreCalls += 1;5815 coreCalls += 1;
5767 return Effect.succeed({5816 return Effect.succeed({
...@@ -5813,7 +5862,7 @@ it.effect("names the signed-in account in the detail, and says nothing where the...@@ -5813,7 +5862,7 @@ it.effect("names the signed-in account in the detail, and says nothing where the
5813 number: 1,5862 number: 1,
5814 });5863 });
5815 });5864 });
5816 const readable = fakeProvider("github", {5865 const readable = fakeProvider(SourceControlProviderKind.make("github"), {
5817 getChangeRequest: () =>5866 getChangeRequest: () =>
5818 Effect.succeed({5867 Effect.succeed({
5819 ...changeRequest(1, "2026-07-02T00:00:00Z"),5868 ...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...@@ -5837,7 +5886,8 @@ it.effect("names the signed-in account in the detail, and says nothing where the
5837 const named = yield* detailFrom(readable);5886 const named = yield* detailFrom(readable);
5838 const unnamed = yield* detailFrom({5887 const unnamed = yield* detailFrom({
5839 ...readable,5888 ...readable,
5840 getViewer: () => Effect.fail(unusable("github", "unauthenticated")),5889 getViewer: () =>
5890 Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")),
5841 });5891 });
58425892
5843 assert.strictEqual(named.viewer, "bilal");5893 assert.strictEqual(named.viewer, "bilal");
...@@ -5855,7 +5905,7 @@ it.effect("keeps the diff cached across a file being ticked off", () =>...@@ -5855,7 +5905,7 @@ it.effect("keeps the diff cached across a file being ticked off", () =>
5855 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),5905 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
5856 ],5906 ],
5857 providers: [5907 providers: [
5858 fakeProvider("github", {5908 fakeProvider(SourceControlProviderKind.make("github"), {
5859 capabilities: {5909 capabilities: {
5860 diff: true,5910 diff: true,
5861 comment: true,5911 comment: true,
...@@ -5912,7 +5962,7 @@ it.effect("returns large diff slices intact without retaining them in either cac...@@ -5912,7 +5962,7 @@ it.effect("returns large diff slices intact without retaining them in either cac
5912 const service = yield* makeService({5962 const service = yield* makeService({
5913 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],5963 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5914 providers: [5964 providers: [
5915 fakeProvider("github", {5965 fakeProvider(SourceControlProviderKind.make("github"), {
5916 getDiff: () =>5966 getDiff: () =>
5917 Effect.sync(() => {5967 Effect.sync(() => {
5918 reads += 1;5968 reads += 1;
...@@ -5950,7 +6000,7 @@ it.effect("caches a small replacement after releasing a large diff", () =>...@@ -5950,7 +6000,7 @@ it.effect("caches a small replacement after releasing a large diff", () =>
5950 const service = yield* makeService({6000 const service = yield* makeService({
5951 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],6001 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5952 providers: [6002 providers: [
5953 fakeProvider("github", {6003 fakeProvider(SourceControlProviderKind.make("github"), {
5954 getDiff: () =>6004 getDiff: () =>
5955 Effect.sync(() => {6005 Effect.sync(() => {
5956 reads += 1;6006 reads += 1;
...@@ -5976,7 +6026,7 @@ const environmentViewedProvider = (...@@ -5976,7 +6026,7 @@ const environmentViewedProvider = (
5976 asked: Array<ReadonlyArray<string>>,6026 asked: Array<ReadonlyArray<string>>,
5977 unreadable: ReadonlySet<string> = new Set(),6027 unreadable: ReadonlySet<string> = new Set(),
5978) =>6028) =>
5979 fakeProvider("gitlab", {6029 fakeProvider(SourceControlProviderKind.make("gitlab"), {
5980 capabilities: {6030 capabilities: {
5981 diff: true,6031 diff: true,
5982 comment: true,6032 comment: true,
...@@ -6018,7 +6068,7 @@ const environmentViewedService = (...@@ -6018,7 +6068,7 @@ const environmentViewedService = (
6018 title: "on gitlab",6068 title: "on gitlab",
6019 workspaceRoot: "/a",6069 workspaceRoot: "/a",
6020 repository: "group/project",6070 repository: "group/project",
6021 provider: "gitlab",6071 provider: SourceControlProviderKind.make("gitlab"),
6022 }),6072 }),
6023 ],6073 ],
6024 providers: [environmentViewedProvider(revisions, asked, unreadable)],6074 providers: [environmentViewedProvider(revisions, asked, unreadable)],
...@@ -6083,7 +6133,7 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba...@@ -6083,7 +6133,7 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba
6083 title: "on forgejo",6133 title: "on forgejo",
6084 workspaceRoot: "/forgejo",6134 workspaceRoot: "/forgejo",
6085 repository: "reviewer/project",6135 repository: "reviewer/project",
6086 provider: "forgejo",6136 provider: SourceControlProviderKind.make("forgejo"),
6087 host: "forge.example:3000",6137 host: "forge.example:3000",
6088 remoteUrl: "http://forge.example:3000/reviewer/project.git",6138 remoteUrl: "http://forge.example:3000/reviewer/project.git",
6089 }),6139 }),
...@@ -6145,7 +6195,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi...@@ -6145,7 +6195,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi
6145 title: "first repository",6195 title: "first repository",
6146 workspaceRoot: "/first",6196 workspaceRoot: "/first",
6147 repository: "reviewer/first",6197 repository: "reviewer/first",
6148 provider: "forgejo",6198 provider: SourceControlProviderKind.make("forgejo"),
6149 host: "forge.example",6199 host: "forge.example",
6150 remoteUrl: "https://forge.example:3000/reviewer/first.git",6200 remoteUrl: "https://forge.example:3000/reviewer/first.git",
6151 }),6201 }),
...@@ -6153,13 +6203,20 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi...@@ -6153,13 +6203,20 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi
6153 const service = yield* makeService({6203 const service = yield* makeService({
6154 projects,6204 projects,
6155 providers: [6205 providers: [
6156 { ...environmentViewedProvider(new Map([["same.ts", "blob"]]), []), kind: "forgejo" },6206 {
6207 ...environmentViewedProvider(new Map([["same.ts", "blob"]]), []),
6208 kind: SourceControlProviderKind.make("forgejo"),
6209 },
6157 ],6210 ],
6158 resolveHandle: ({ context }) =>6211 resolveHandle: ({ context }) =>
6159 Effect.succeed({6212 Effect.succeed({
6160 context: {6213 context: {
6161 ...context!,6214 ...context!,
6162 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://forge.example:3000" },6215 provider: {
6216 kind: SourceControlProviderKind.make("forgejo"),
6217 name: "Forgejo",
6218 baseUrl: "https://forge.example:3000",
6219 },
6163 },6220 },
6164 provider: undefined as never,6221 provider: undefined as never,
6165 }),6222 }),
...@@ -6186,7 +6243,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi...@@ -6186,7 +6243,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi
6186 title: "second repository",6243 title: "second repository",
6187 workspaceRoot: "/second",6244 workspaceRoot: "/second",
6188 repository: "reviewer/second",6245 repository: "reviewer/second",
6189 provider: "forgejo",6246 provider: SourceControlProviderKind.make("forgejo"),
6190 host: "forge.example",6247 host: "forge.example",
6191 remoteUrl: "https://forge.example:3000/reviewer/second.git",6248 remoteUrl: "https://forge.example:3000/reviewer/second.git",
6192 }),6249 }),
...@@ -6201,7 +6258,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi...@@ -6201,7 +6258,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi
6201 title: "second repository over SSH",6258 title: "second repository over SSH",
6202 workspaceRoot: "/ssh",6259 workspaceRoot: "/ssh",
6203 repository: "reviewer/second",6260 repository: "reviewer/second",
6204 provider: "forgejo",6261 provider: SourceControlProviderKind.make("forgejo"),
6205 host: "ssh.forge.example",6262 host: "ssh.forge.example",
6206 remoteUrl: "git@ssh.forge.example:reviewer/second.git",6263 remoteUrl: "git@ssh.forge.example:reviewer/second.git",
6207 }),6264 }),
...@@ -6269,7 +6326,7 @@ it.effect("holds what a whole-change answer carried, so the next tick reads noth...@@ -6269,7 +6326,7 @@ it.effect("holds what a whole-change answer carried, so the next tick reads noth
6269 title: "on gitlab",6326 title: "on gitlab",
6270 workspaceRoot: "/a",6327 workspaceRoot: "/a",
6271 repository: "group/project",6328 repository: "group/project",
6272 provider: "gitlab",6329 provider: SourceControlProviderKind.make("gitlab"),
6273 }),6330 }),
6274 ],6331 ],
6275 providers: [6332 providers: [
...@@ -6643,11 +6700,11 @@ it.effect("still reports its own marks when the host will not say what the head...@@ -6643,11 +6700,11 @@ it.effect("still reports its own marks when the host will not say what the head
6643 title: "on gitlab",6700 title: "on gitlab",
6644 workspaceRoot: "/a",6701 workspaceRoot: "/a",
6645 repository: "group/project",6702 repository: "group/project",
6646 provider: "gitlab",6703 provider: SourceControlProviderKind.make("gitlab"),
6647 }),6704 }),
6648 ],6705 ],
6649 providers: [6706 providers: [
6650 fakeProvider("gitlab", {6707 fakeProvider(SourceControlProviderKind.make("gitlab"), {
6651 capabilities: {6708 capabilities: {
6652 diff: true,6709 diff: true,
6653 comment: true,6710 comment: true,
...@@ -6699,11 +6756,11 @@ it.effect("finishes two presses on one file in the order they were made", () =>...@@ -6699,11 +6756,11 @@ it.effect("finishes two presses on one file in the order they were made", () =>
6699 title: "on gitlab",6756 title: "on gitlab",
6700 workspaceRoot: "/a",6757 workspaceRoot: "/a",
6701 repository: "group/project",6758 repository: "group/project",
6702 provider: "gitlab",6759 provider: SourceControlProviderKind.make("gitlab"),
6703 }),6760 }),
6704 ],6761 ],
6705 providers: [6762 providers: [
6706 fakeProvider("gitlab", {6763 fakeProvider(SourceControlProviderKind.make("gitlab"), {
6707 capabilities: {6764 capabilities: {
6708 diff: true,6765 diff: true,
6709 comment: true,6766 comment: true,
...@@ -6755,7 +6812,7 @@ const azureViewedService = (projects: ReadonlyArray<OrchestrationProjectShell>)...@@ -6755,7 +6812,7 @@ const azureViewedService = (projects: ReadonlyArray<OrchestrationProjectShell>)
6755 makeService({6812 makeService({
6756 projects,6813 projects,
6757 providers: [6814 providers: [
6758 fakeProvider("azure-devops", {6815 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
6759 capabilities: {6816 capabilities: {
6760 diff: true,6817 diff: true,
6761 comment: true,6818 comment: true,
...@@ -6781,7 +6838,7 @@ const AZURE_PAIR = [...@@ -6781,7 +6838,7 @@ const AZURE_PAIR = [
6781 title: "platform web",6838 title: "platform web",
6782 workspaceRoot: "/a",6839 workspaceRoot: "/a",
6783 repository: "acme/platform/_git/web",6840 repository: "acme/platform/_git/web",
6784 provider: "azure-devops",6841 provider: SourceControlProviderKind.make("azure-devops"),
6785 host: "dev.azure.com",6842 host: "dev.azure.com",
6786 }),6843 }),
6787 project({6844 project({
...@@ -6789,7 +6846,7 @@ const AZURE_PAIR = [...@@ -6789,7 +6846,7 @@ const AZURE_PAIR = [
6789 title: "other web",6846 title: "other web",
6790 workspaceRoot: "/b",6847 workspaceRoot: "/b",
6791 repository: "acme/other/_git/web",6848 repository: "acme/other/_git/web",
6792 provider: "azure-devops",6849 provider: SourceControlProviderKind.make("azure-devops"),
6793 host: "dev.azure.com",6850 host: "dev.azure.com",
6794 }),6851 }),
6795];6852];
...@@ -6880,7 +6937,7 @@ it.effect("keeps the marked paths when a whole-change answer is wider than the c...@@ -6880,7 +6937,7 @@ it.effect("keeps the marked paths when a whole-change answer is wider than the c
6880 title: "on gitlab",6937 title: "on gitlab",
6881 workspaceRoot: "/a",6938 workspaceRoot: "/a",
6882 repository: "group/project",6939 repository: "group/project",
6883 provider: "gitlab",6940 provider: SourceControlProviderKind.make("gitlab"),
6884 }),6941 }),
6885 ],6942 ],
6886 providers: [6943 providers: [
...@@ -6949,7 +7006,7 @@ const environmentViewedServiceWithViewer = (...@@ -6949,7 +7006,7 @@ const environmentViewedServiceWithViewer = (
6949 title: "on gitlab",7006 title: "on gitlab",
6950 workspaceRoot: "/a",7007 workspaceRoot: "/a",
6951 repository: "group/project",7008 repository: "group/project",
6952 provider: "gitlab",7009 provider: SourceControlProviderKind.make("gitlab"),
6953 }),7010 }),
6954 ],7011 ],
6955 providers: [{ ...environmentViewedProvider(revisions, []), getViewer }],7012 providers: [{ ...environmentViewedProvider(revisions, []), getViewer }],
...@@ -6983,7 +7040,7 @@ it.effect("puts a listing and a press for one host on a single viewer lookup", (...@@ -6983,7 +7040,7 @@ it.effect("puts a listing and a press for one host on a single viewer lookup", (
6983 title: "on gitlab",7040 title: "on gitlab",
6984 workspaceRoot: "/a",7041 workspaceRoot: "/a",
6985 repository: "group/project",7042 repository: "group/project",
6986 provider: "gitlab",7043 provider: SourceControlProviderKind.make("gitlab"),
6987 }),7044 }),
6988 ],7045 ],
6989 providers: [7046 providers: [
...@@ -7048,7 +7105,7 @@ it.effect("records a press while the host is backing off", () =>...@@ -7048,7 +7105,7 @@ it.effect("records a press while the host is backing off", () =>
7048 title: "on gitlab",7105 title: "on gitlab",
7049 workspaceRoot: "/a",7106 workspaceRoot: "/a",
7050 repository: "group/project",7107 repository: "group/project",
7051 provider: "gitlab",7108 provider: SourceControlProviderKind.make("gitlab"),
7052 }),7109 }),
7053 ],7110 ],
7054 providers: [7111 providers: [
...@@ -7064,7 +7121,7 @@ it.effect("records a press while the host is backing off", () =>...@@ -7064,7 +7121,7 @@ it.effect("records a press while the host is backing off", () =>
7064 getFileRevisions: () =>7121 getFileRevisions: () =>
7065 Effect.fail(7122 Effect.fail(
7066 new PullRequestProviderError({7123 new PullRequestProviderError({
7067 provider: "gitlab",7124 provider: SourceControlProviderKind.make("gitlab"),
7068 operation: "getFileRevisions",7125 operation: "getFileRevisions",
7069 reason: "rate-limited",7126 reason: "rate-limited",
7070 detail: "API rate limit exceeded.",7127 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...@@ -7111,7 +7168,7 @@ it.effect("asks who is reading through a pause only for the press that is waitin
7111 title: "on gitlab",7168 title: "on gitlab",
7112 workspaceRoot: "/a",7169 workspaceRoot: "/a",
7113 repository: "group/project",7170 repository: "group/project",
7114 provider: "gitlab",7171 provider: SourceControlProviderKind.make("gitlab"),
7115 }),7172 }),
7116 ],7173 ],
7117 providers: [7174 providers: [
...@@ -7129,7 +7186,7 @@ it.effect("asks who is reading through a pause only for the press that is waitin...@@ -7129,7 +7186,7 @@ it.effect("asks who is reading through a pause only for the press that is waitin
7129 getFileRevisions: () =>7186 getFileRevisions: () =>
7130 Effect.fail(7187 Effect.fail(
7131 new PullRequestProviderError({7188 new PullRequestProviderError({
7132 provider: "gitlab",7189 provider: SourceControlProviderKind.make("gitlab"),
7133 operation: "getFileRevisions",7190 operation: "getFileRevisions",
7134 reason: "rate-limited",7191 reason: "rate-limited",
7135 detail: "API rate limit exceeded.",7192 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"...@@ -7174,7 +7231,7 @@ it.effect("does not ask a paused host who is reading again after the ask failed"
7174 title: "on gitlab",7231 title: "on gitlab",
7175 workspaceRoot: "/a",7232 workspaceRoot: "/a",
7176 repository: "group/project",7233 repository: "group/project",
7177 provider: "gitlab",7234 provider: SourceControlProviderKind.make("gitlab"),
7178 }),7235 }),
7179 ],7236 ],
7180 providers: [7237 providers: [
...@@ -7185,7 +7242,7 @@ it.effect("does not ask a paused host who is reading again after the ask failed"...@@ -7185,7 +7242,7 @@ it.effect("does not ask a paused host who is reading again after the ask failed"
7185 viewerLookups += 1;7242 viewerLookups += 1;
7186 return Effect.fail(7243 return Effect.fail(
7187 new PullRequestProviderError({7244 new PullRequestProviderError({
7188 provider: "gitlab",7245 provider: SourceControlProviderKind.make("gitlab"),
7189 operation: "getViewer",7246 operation: "getViewer",
7190 reason: "failed",7247 reason: "failed",
7191 detail: "glab exited with status 1",7248 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"...@@ -7197,7 +7254,7 @@ it.effect("does not ask a paused host who is reading again after the ask failed"
7197 runAction: () =>7254 runAction: () =>
7198 Effect.fail(7255 Effect.fail(
7199 new PullRequestProviderError({7256 new PullRequestProviderError({
7200 provider: "gitlab",7257 provider: SourceControlProviderKind.make("gitlab"),
7201 operation: "runAction",7258 operation: "runAction",
7202 reason: "rate-limited",7259 reason: "rate-limited",
7203 detail: "API rate limit exceeded.",7260 detail: "API rate limit exceeded.",
...@@ -7231,7 +7288,7 @@ it.effect("refuses the marks when the host could not be asked who is reading", (...@@ -7231,7 +7288,7 @@ it.effect("refuses the marks when the host could not be asked who is reading", (
7231 ? Effect.succeed("bilal")7288 ? Effect.succeed("bilal")
7232 : Effect.fail(7289 : Effect.fail(
7233 new PullRequestProviderError({7290 new PullRequestProviderError({
7234 provider: "gitlab",7291 provider: SourceControlProviderKind.make("gitlab"),
7235 operation: "getViewer",7292 operation: "getViewer",
7236 reason: "failed",7293 reason: "failed",
7237 detail: "glab exited with status 1",7294 detail: "glab exited with status 1",
...@@ -7276,11 +7333,11 @@ it.effect("refuses to track viewed files on a host that does not", () =>...@@ -7276,11 +7333,11 @@ it.effect("refuses to track viewed files on a host that does not", () =>
7276 title: "on gitlab",7333 title: "on gitlab",
7277 workspaceRoot: "/a",7334 workspaceRoot: "/a",
7278 repository: "group/project",7335 repository: "group/project",
7279 provider: "gitlab",7336 provider: SourceControlProviderKind.make("gitlab"),
7280 }),7337 }),
7281 ],7338 ],
7282 providers: [7339 providers: [
7283 fakeProvider("gitlab", {7340 fakeProvider(SourceControlProviderKind.make("gitlab"), {
7284 getFilesViewed: () => Effect.die("must not be called"),7341 getFilesViewed: () => Effect.die("must not be called"),
7285 setFilesViewed: () => Effect.die("must not be called"),7342 setFilesViewed: () => Effect.die("must not be called"),
7286 }),7343 }),
...@@ -7308,12 +7365,12 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s...@@ -7308,12 +7365,12 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s
7308 title: organization,7365 title: organization,
7309 workspaceRoot: `/${organization}`,7366 workspaceRoot: `/${organization}`,
7310 repository: `${organization}/project/_git/web`,7367 repository: `${organization}/project/_git/web`,
7311 provider: "azure-devops",7368 provider: SourceControlProviderKind.make("azure-devops"),
7312 host: "dev.azure.com",7369 host: "dev.azure.com",
7313 }),7370 }),
7314 ),7371 ),
7315 providers: [7372 providers: [
7316 fakeProvider("azure-devops", {7373 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
7317 listChangeRequests: (input) =>7374 listChangeRequests: (input) =>
7318 Effect.sync(() => {7375 Effect.sync(() => {
7319 seen.push(input.cwd);7376 seen.push(input.cwd);
...@@ -7363,9 +7420,9 @@ it.effect.each([...@@ -7363,9 +7420,9 @@ it.effect.each([
7363 },7420 },
7364 },7421 },
7365 providers: [7422 providers: [
7366 fakeProvider("github", {7423 fakeProvider(SourceControlProviderKind.make("github"), {
7367 capabilities: {7424 capabilities: {
7368 ...fakeProvider("github").capabilities,7425 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
7369 actions: ["merge", "enable-auto-merge"],7426 actions: ["merge", "enable-auto-merge"],
7370 },7427 },
7371 getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),7428 getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
apps/server/src/pullRequest/PullRequestService.ts+7-4
...@@ -72,7 +72,7 @@ import {...@@ -72,7 +72,7 @@ import {
72 type PullRequestThreadCommentsResult,72 type PullRequestThreadCommentsResult,
73 type PullRequestUpdateInput,73 type PullRequestUpdateInput,
74 type SourceControlProviderInfo,74 type SourceControlProviderInfo,
75 type SourceControlProviderKind,75 SourceControlProviderKind,
76 type ThreadPullRequestKey,76 type ThreadPullRequestKey,
77} from "@t3tools/contracts";77} from "@t3tools/contracts";
78import { resolveProjectSettings } from "@t3tools/shared/projectSettings";78import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
...@@ -736,14 +736,15 @@ export const make = Effect.gen(function* () {...@@ -736,14 +736,15 @@ export const make = Effect.gen(function* () {
736 sourceControlRepositorySelector(project.repositoryIdentity) === null736 sourceControlRepositorySelector(project.repositoryIdentity) === null
737 )737 )
738 continue;738 continue;
739 const host = pullRequestHostOf(identity, "unknown");739 const host = pullRequestHostOf(identity, SourceControlProviderKind.make("unknown"));
740 // A legacy identity has no canonical host until its provider is refined, so it must reach740 // A legacy identity has no canonical host until its provider is refined, so it must reach
741 // the refinement before a host filter can decide whether it belongs in the result.741 // the refinement before a host filter can decide whether it belongs in the result.
742 if (742 if (
743 filter.host !== undefined &&743 filter.host !== undefined &&
744 host !== "unknown" &&744 host !== "unknown" &&
745 host !== filter.host.toLowerCase() &&745 host !== filter.host.toLowerCase() &&
746 pullRequestHostOf(identity, "forgejo") !== filter.host.toLowerCase() &&746 pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo")) !==
747 filter.host.toLowerCase() &&
747 !isSshRemoteUrl(identity.locator.remoteUrl)748 !isSshRemoteUrl(identity.locator.remoteUrl)
748 ) {749 ) {
749 continue;750 continue;
...@@ -768,7 +769,9 @@ export const make = Effect.gen(function* () {...@@ -768,7 +769,9 @@ export const make = Effect.gen(function* () {
768 cwd: project.workspaceRoot,769 cwd: project.workspaceRoot,
769 context: {770 context: {
770 provider:771 provider:
771 provider.kind === "forgejo" ? { ...provider, kind: "unknown" } : provider,772 provider.kind === "forgejo"
773 ? { ...provider, kind: SourceControlProviderKind.make("unknown") }
774 : provider,
772 remoteName,775 remoteName,
773 remoteUrl,776 remoteUrl,
774 ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl)777 ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl)
apps/server/src/pullRequest/pullRequestSyncKey.ts+3-2
...@@ -2,7 +2,7 @@ import {...@@ -2,7 +2,7 @@ import {
2 pullRequestHostOf,2 pullRequestHostOf,
3 type PullRequestRef,3 type PullRequestRef,
4 type RepositoryIdentity,4 type RepositoryIdentity,
5 type SourceControlProviderKind,5 SourceControlProviderKind,
6 type ThreadPullRequestKey,6 type ThreadPullRequestKey,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";8import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
...@@ -18,7 +18,8 @@ export function pullRequestSyncKey(...@@ -18,7 +18,8 @@ export function pullRequestSyncKey(
18 reference.repository.toLowerCase() !==18 reference.repository.toLowerCase() !==
19 sourceControlRepositorySelector(identity)?.toLowerCase() ||19 sourceControlRepositorySelector(identity)?.toLowerCase() ||
20 (reference.host !== undefined &&20 (reference.host !== undefined &&
21 reference.host.toLowerCase() !== pullRequestHostOf(identity, "azure-devops"))21 reference.host.toLowerCase() !==
22 pullRequestHostOf(identity, SourceControlProviderKind.make("azure-devops")))
22 )23 )
23 return null;24 return null;
24 const [host, ...repository] = identity.canonicalKey.split("/");25 const [host, ...repository] = identity.canonicalKey.split("/");
apps/server/src/sourceControl/BitbucketApi.test.ts+8-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { assert, it, vi } from "@effect/vitest";2import { assert, it, vi } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as ConfigProvider from "effect/ConfigProvider";4import * as ConfigProvider from "effect/ConfigProvider";
...@@ -362,7 +363,7 @@ it.effect.each([false, true])(...@@ -362,7 +363,7 @@ it.effect.each([false, true])(
362 ? {363 ? {
363 context: {364 context: {
364 provider: {365 provider: {
365 kind: "bitbucket" as const,366 kind: SourceControlProviderKind.make("bitbucket"),
366 name: "Bitbucket",367 name: "Bitbucket",
367 baseUrl: "https://bitbucket.org",368 baseUrl: "https://bitbucket.org",
368 },369 },
...@@ -396,7 +397,11 @@ it.effect("prefers an explicit repository and uses context when the repository i...@@ -396,7 +397,11 @@ it.effect("prefers an explicit repository and uses context when the repository i
396 return Effect.gen(function* () {397 return Effect.gen(function* () {
397 const bitbucket = yield* BitbucketApi.BitbucketApi;398 const bitbucket = yield* BitbucketApi.BitbucketApi;
398 const context = {399 const context = {
399 provider: { kind: "bitbucket" as const, name: "Bitbucket", baseUrl: "https://bitbucket.org" },400 provider: {
401 kind: SourceControlProviderKind.make("bitbucket"),
402 name: "Bitbucket",
403 baseUrl: "https://bitbucket.org",
404 },
400 remoteName: "origin",405 remoteName: "origin",
401 remoteUrl: "git@bitbucket.org:another/context.git",406 remoteUrl: "git@bitbucket.org:another/context.git",
402 };407 };
...@@ -838,7 +843,7 @@ it.effect("checks out same-repository pull requests with the existing Bitbucket...@@ -838,7 +843,7 @@ it.effect("checks out same-repository pull requests with the existing Bitbucket
838 cwd: "/repo",843 cwd: "/repo",
839 context: {844 context: {
840 provider: {845 provider: {
841 kind: "bitbucket",846 kind: SourceControlProviderKind.make("bitbucket"),
842 name: "Bitbucket",847 name: "Bitbucket",
843 baseUrl: "https://bitbucket.org",848 baseUrl: "https://bitbucket.org",
844 },849 },
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+18-13
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -176,37 +177,37 @@ it.effect("reports implemented tools separately from locally available executabl...@@ -176,37 +177,37 @@ it.effect("reports implemented tools separately from locally available executabl
176 })),177 })),
177 [178 [
178 {179 {
179 kind: "github",180 kind: SourceControlProviderKind.make("github"),
180 status: "available",181 status: "available",
181 auth: "authenticated",182 auth: "authenticated",
182 account: Option.some("juliusmarminge"),183 account: Option.some("juliusmarminge"),
183 },184 },
184 {185 {
185 kind: "gitlab",186 kind: SourceControlProviderKind.make("gitlab"),
186 status: "missing",187 status: "missing",
187 auth: "unknown",188 auth: "unknown",
188 account: Option.none(),189 account: Option.none(),
189 },190 },
190 {191 {
191 kind: "azure-devops",192 kind: SourceControlProviderKind.make("azure-devops"),
192 status: "missing",193 status: "missing",
193 auth: "unknown",194 auth: "unknown",
194 account: Option.none(),195 account: Option.none(),
195 },196 },
196 {197 {
197 kind: "bitbucket",198 kind: SourceControlProviderKind.make("bitbucket"),
198 status: "available",199 status: "available",
199 auth: "unauthenticated",200 auth: "unauthenticated",
200 account: Option.none(),201 account: Option.none(),
201 },202 },
202 {203 {
203 kind: "forgejo",204 kind: SourceControlProviderKind.make("forgejo"),
204 status: "missing",205 status: "missing",
205 auth: "unknown",206 auth: "unknown",
206 account: Option.none(),207 account: Option.none(),
207 },208 },
208 {209 {
209 kind: "gitcafe",210 kind: SourceControlProviderKind.make("gitcafe"),
210 status: "missing",211 status: "missing",
211 auth: "unknown",212 auth: "unknown",
212 account: Option.none(),213 account: Option.none(),
...@@ -327,37 +328,37 @@ Logged in to gitlab.com as gitlab-user...@@ -327,37 +328,37 @@ Logged in to gitlab.com as gitlab-user
327 })),328 })),
328 [329 [
329 {330 {
330 kind: "github",331 kind: SourceControlProviderKind.make("github"),
331 auth: "authenticated",332 auth: "authenticated",
332 account: Option.some("octocat"),333 account: Option.some("octocat"),
333 detail: Option.none(),334 detail: Option.none(),
334 },335 },
335 {336 {
336 kind: "gitlab",337 kind: SourceControlProviderKind.make("gitlab"),
337 auth: "authenticated",338 auth: "authenticated",
338 account: Option.some("gitlab-user"),339 account: Option.some("gitlab-user"),
339 detail: Option.none(),340 detail: Option.none(),
340 },341 },
341 {342 {
342 kind: "azure-devops",343 kind: SourceControlProviderKind.make("azure-devops"),
343 auth: "authenticated",344 auth: "authenticated",
344 account: Option.some("azure-user@example.com"),345 account: Option.some("azure-user@example.com"),
345 detail: Option.none(),346 detail: Option.none(),
346 },347 },
347 {348 {
348 kind: "bitbucket",349 kind: SourceControlProviderKind.make("bitbucket"),
349 auth: "authenticated",350 auth: "authenticated",
350 account: Option.some("bitbucket-user"),351 account: Option.some("bitbucket-user"),
351 detail: Option.none(),352 detail: Option.none(),
352 },353 },
353 {354 {
354 kind: "forgejo",355 kind: SourceControlProviderKind.make("forgejo"),
355 auth: "authenticated",356 auth: "authenticated",
356 account: Option.some("forgejo-user"),357 account: Option.some("forgejo-user"),
357 detail: Option.none(),358 detail: Option.none(),
358 },359 },
359 {360 {
360 kind: "gitcafe",361 kind: SourceControlProviderKind.make("gitcafe"),
361 auth: "authenticated",362 auth: "authenticated",
362 account: Option.some("cafe-user"),363 account: Option.some("cafe-user"),
363 detail: Option.none(),364 detail: Option.none(),
...@@ -528,7 +529,11 @@ it.effect(...@@ -528,7 +529,11 @@ it.effect(
528 reference: "42",529 reference: "42",
529 force: true,530 force: true,
530 context: {531 context: {
531 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://forgejo.test" },532 provider: {
533 kind: SourceControlProviderKind.make("forgejo"),
534 name: "Forgejo",
535 baseUrl: "https://forgejo.test",
536 },
532 remoteName: "origin",537 remoteName: "origin",
533 remoteUrl: "git@forgejo.test:maria/project.git",538 remoteUrl: "git@forgejo.test:maria/project.git",
534 },539 },
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+6-5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as DateTime from "effect/DateTime";4import * as DateTime from "effect/DateTime";
...@@ -154,7 +155,7 @@ it.effect("routes directly by provider kind for remote-first workflows", () =>...@@ -154,7 +155,7 @@ it.effect("routes directly by provider kind for remote-first workflows", () =>
154 remotes: [],155 remotes: [],
155 });156 });
156157
157 const provider = yield* registry.get("github");158 const provider = yield* registry.get(SourceControlProviderKind.make("github"));
158159
159 assert.strictEqual(provider.kind, "github");160 assert.strictEqual(provider.kind, "github");
160 }),161 }),
...@@ -163,7 +164,7 @@ it.effect("routes directly by provider kind for remote-first workflows", () =>...@@ -163,7 +164,7 @@ it.effect("routes directly by provider kind for remote-first workflows", () =>
163it.effect("includes the request cwd when an unregistered provider is used", () =>164it.effect("includes the request cwd when an unregistered provider is used", () =>
164 Effect.gen(function* () {165 Effect.gen(function* () {
165 const registry = yield* makeRegistry({ remotes: [] });166 const registry = yield* makeRegistry({ remotes: [] });
166 const provider = yield* registry.get("unknown");167 const provider = yield* registry.get(SourceControlProviderKind.make("unknown"));
167168
168 const error = yield* provider169 const error = yield* provider
169 .getChangeRequest({ cwd: "/repo", reference: "#42" })170 .getChangeRequest({ cwd: "/repo", reference: "#42" })
...@@ -257,7 +258,7 @@ it.effect("refines the caller-selected remote instead of choosing another config...@@ -257,7 +258,7 @@ it.effect("refines the caller-selected remote instead of choosing another config
257 cwd: "/repo",258 cwd: "/repo",
258 context: {259 context: {
259 provider: {260 provider: {
260 kind: "unknown",261 kind: SourceControlProviderKind.make("unknown"),
261 name: "self-hosted.example.test",262 name: "self-hosted.example.test",
262 baseUrl: "https://self-hosted.example.test",263 baseUrl: "https://self-hosted.example.test",
263 },264 },
...@@ -412,7 +413,7 @@ it.effect.each([...@@ -412,7 +413,7 @@ it.effect.each([
412 },413 },
413 });414 });
414 const handle = yield* registry.resolveHandle({ cwd: "/repo" });415 const handle = yield* registry.resolveHandle({ cwd: "/repo" });
415 assert.strictEqual(handle.provider.kind, scenario.expected);416 assert.strictEqual(handle.provider.kind, SourceControlProviderKind.make(scenario.expected));
416 assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test");417 assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test");
417 assert.deepStrictEqual(hosts, ["code.example.test"]);418 assert.deepStrictEqual(hosts, ["code.example.test"]);
418 // Recognition goes through the credential layer; gh is only one of its sources.419 // 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", (...@@ -437,7 +438,7 @@ it.effect("skips GitHub discovery for the identity resolver's empty base URL", (
437 },438 },
438 });439 });
439 const context = {440 const context = {
440 provider: { kind: "unknown" as const, name: "Unknown", baseUrl: "" },441 provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" },
441 remoteName: "origin",442 remoteName: "origin",
442 remoteUrl,443 remoteUrl,
443 };444 };
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+5-4
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Cache from "effect/Cache";2import * as Cache from "effect/Cache";
2import * as Context from "effect/Context";3import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";4import * as Duration from "effect/Duration";
...@@ -8,7 +9,7 @@ import {...@@ -8,7 +9,7 @@ import {
8 SourceControlProviderError,9 SourceControlProviderError,
9 type SourceControlProviderDiscoveryItem,10 type SourceControlProviderDiscoveryItem,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
11import type { SourceControlProviderKind } from "@t3tools/contracts";12import type {} from "@t3tools/contracts";
12import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";13import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
1314
14import * as BuiltInDrivers from "./builtInDrivers.ts";15import * as BuiltInDrivers from "./builtInDrivers.ts";
...@@ -214,7 +215,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -214,7 +215,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
214 Effect.mapError(215 Effect.mapError(
215 (error) =>216 (error) =>
216 new SourceControlProviderError({217 new SourceControlProviderError({
217 provider: "unknown",218 provider: SourceControlProviderKind.make("unknown"),
218 operation: "detectProvider",219 operation: "detectProvider",
219 cwd,220 cwd,
220 detail: "Failed to detect source control provider.",221 detail: "Failed to detect source control provider.",
...@@ -226,7 +227,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -226,7 +227,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
226 Effect.mapError(227 Effect.mapError(
227 (error) =>228 (error) =>
228 new SourceControlProviderError({229 new SourceControlProviderError({
229 provider: "unknown",230 provider: SourceControlProviderKind.make("unknown"),
230 operation: "detectProvider",231 operation: "detectProvider",
231 cwd,232 cwd,
232 detail: "Failed to detect source control provider.",233 detail: "Failed to detect source control provider.",
...@@ -265,7 +266,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -265,7 +266,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
265 })266 })
266 ).pipe(267 ).pipe(
267 Effect.map((context) => {268 Effect.map((context) => {
268 const kind = context?.provider.kind ?? "unknown";269 const kind = context?.provider.kind ?? SourceControlProviderKind.make("unknown");
269 const provider = providers.get(kind) ?? unsupportedProvider(kind);270 const provider = providers.get(kind) ?? unsupportedProvider(kind);
270 return {271 return {
271 provider: bindProviderContext(provider, context),272 provider: bindProviderContext(provider, context),
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+24-17
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as NodePath from "@effect/platform-node/NodePath";2import * as NodePath from "@effect/platform-node/NodePath";
2import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";4import { assert, it } from "@effect/vitest";
...@@ -37,7 +38,7 @@ function makeProvider(...@@ -37,7 +38,7 @@ function makeProvider(
37 >;38 >;
3839
39 return {40 return {
40 kind: "github",41 kind: SourceControlProviderKind.make("github"),
41 listChangeRequests: () => unsupported("listChangeRequests"),42 listChangeRequests: () => unsupported("listChangeRequests"),
42 getChangeRequest: () => unsupported("getChangeRequest"),43 getChangeRequest: () => unsupported("getChangeRequest"),
43 createChangeRequest: () => unsupported("createChangeRequest"),44 createChangeRequest: () => unsupported("createChangeRequest"),
...@@ -114,19 +115,22 @@ it.effect("looks up repositories through the requested provider without search",...@@ -114,19 +115,22 @@ it.effect("looks up repositories through the requested provider without search",
114 return Effect.gen(function* () {115 return Effect.gen(function* () {
115 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;116 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
116 const result = yield* service.lookupRepository({117 const result = yield* service.lookupRepository({
117 provider: "github",118 provider: SourceControlProviderKind.make("github"),
118 repository: "octocat/t3code",119 repository: "octocat/t3code",
119 cwd: "/workspace",120 cwd: "/workspace",
120 });121 });
121122
122 assert.deepStrictEqual(result, { provider: "github", ...CLONE_URLS });123 assert.deepStrictEqual(result, {
124 provider: SourceControlProviderKind.make("github"),
125 ...CLONE_URLS,
126 });
123 assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]);127 assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]);
124 }).pipe(Effect.provide(layer({ provider })));128 }).pipe(Effect.provide(layer({ provider })));
125});129});
126130
127it.effect("preserves provider failures without deriving the repository message from them", () => {131it.effect("preserves provider failures without deriving the repository message from them", () => {
128 const providerCause = new SourceControlProviderError({132 const providerCause = new SourceControlProviderError({
129 provider: "github",133 provider: SourceControlProviderKind.make("github"),
130 operation: "getRepositoryCloneUrls",134 operation: "getRepositoryCloneUrls",
131 cwd: "/workspace",135 cwd: "/workspace",
132 repository: "octocat/t3code",136 repository: "octocat/t3code",
...@@ -140,7 +144,7 @@ it.effect("preserves provider failures without deriving the repository message f...@@ -140,7 +144,7 @@ it.effect("preserves provider failures without deriving the repository message f
140 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;144 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
141 const error = yield* Effect.flip(145 const error = yield* Effect.flip(
142 service.lookupRepository({146 service.lookupRepository({
143 provider: "github",147 provider: SourceControlProviderKind.make("github"),
144 repository: "octocat/t3code",148 repository: "octocat/t3code",
145 cwd: "/workspace",149 cwd: "/workspace",
146 }),150 }),
...@@ -176,7 +180,7 @@ it.effect(...@@ -176,7 +180,7 @@ it.effect(
176 yield* Effect.gen(function* () {180 yield* Effect.gen(function* () {
177 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;181 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
178 const input = {182 const input = {
179 provider: "bitbucket" as const,183 provider: SourceControlProviderKind.make("bitbucket"),
180 repository: locatorCause.repository,184 repository: locatorCause.repository,
181 cwd: "/workspace",185 cwd: "/workspace",
182 };186 };
...@@ -204,11 +208,11 @@ it.effect(...@@ -204,11 +208,11 @@ it.effect(
204208
205it.effect("keeps a plain Bitbucket locator lookalike out of the repository message", () => {209it.effect("keeps a plain Bitbucket locator lookalike out of the repository message", () => {
206 const provider = makeProvider({210 const provider = makeProvider({
207 kind: "bitbucket",211 kind: SourceControlProviderKind.make("bitbucket"),
208 getRepositoryCloneUrls: () =>212 getRepositoryCloneUrls: () =>
209 Effect.fail(213 Effect.fail(
210 new SourceControlProviderError({214 new SourceControlProviderError({
211 provider: "bitbucket",215 provider: SourceControlProviderKind.make("bitbucket"),
212 operation: "getRepositoryCloneUrls",216 operation: "getRepositoryCloneUrls",
213 cwd: "/workspace",217 cwd: "/workspace",
214 detail: "credential token abc123 was rejected",218 detail: "credential token abc123 was rejected",
...@@ -224,7 +228,10 @@ it.effect("keeps a plain Bitbucket locator lookalike out of the repository messa...@@ -224,7 +228,10 @@ it.effect("keeps a plain Bitbucket locator lookalike out of the repository messa
224 return Effect.gen(function* () {228 return Effect.gen(function* () {
225 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;229 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
226 const error = yield* Effect.flip(230 const error = yield* Effect.flip(
227 service.lookupRepository({ provider: "bitbucket", repository: "t3code" }),231 service.lookupRepository({
232 provider: SourceControlProviderKind.make("bitbucket"),
233 repository: "t3code",
234 }),
228 );235 );
229236
230 assert.strictEqual(error.detail, "The source control operation could not be completed.");237 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...@@ -250,7 +257,7 @@ it.effect("keeps arbitrary Bitbucket request failures out of the repository mess
250 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;257 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
251 const error = yield* Effect.flip(258 const error = yield* Effect.flip(
252 service.lookupRepository({259 service.lookupRepository({
253 provider: "bitbucket",260 provider: SourceControlProviderKind.make("bitbucket"),
254 repository: "pingdotgg/t3code",261 repository: "pingdotgg/t3code",
255 cwd: "/workspace",262 cwd: "/workspace",
256 }),263 }),
...@@ -276,7 +283,7 @@ it.effect("clones a looked-up repository into the requested destination", () =>...@@ -276,7 +283,7 @@ it.effect("clones a looked-up repository into the requested destination", () =>
276 yield* Effect.gen(function* () {283 yield* Effect.gen(function* () {
277 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;284 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
278 const result = yield* service.cloneRepository({285 const result = yield* service.cloneRepository({
279 provider: "github",286 provider: SourceControlProviderKind.make("github"),
280 repository: "octocat/t3code",287 repository: "octocat/t3code",
281 destinationPath,288 destinationPath,
282 protocol: "https",289 protocol: "https",
...@@ -285,7 +292,7 @@ it.effect("clones a looked-up repository into the requested destination", () =>...@@ -285,7 +292,7 @@ it.effect("clones a looked-up repository into the requested destination", () =>
285 assert.deepStrictEqual(result, {292 assert.deepStrictEqual(result, {
286 cwd: destinationPath,293 cwd: destinationPath,
287 remoteUrl: CLONE_URLS.url,294 remoteUrl: CLONE_URLS.url,
288 repository: { provider: "github", ...CLONE_URLS },295 repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS },
289 });296 });
290 assert.deepStrictEqual(cloneCalls, [297 assert.deepStrictEqual(cloneCalls, [
291 {298 {
...@@ -546,7 +553,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up...@@ -546,7 +553,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up
546 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;553 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
547 const result = yield* service.publishRepository({554 const result = yield* service.publishRepository({
548 cwd: "/workspace",555 cwd: "/workspace",
549 provider: "github",556 provider: SourceControlProviderKind.make("github"),
550 repository: "octocat/t3code",557 repository: "octocat/t3code",
551 visibility: "private",558 visibility: "private",
552 remoteName: "origin",559 remoteName: "origin",
...@@ -554,7 +561,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up...@@ -554,7 +561,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up
554 });561 });
555562
556 assert.deepStrictEqual(result, {563 assert.deepStrictEqual(result, {
557 repository: { provider: "github", ...CLONE_URLS },564 repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS },
558 remoteName: "origin",565 remoteName: "origin",
559 remoteUrl: CLONE_URLS.sshUrl,566 remoteUrl: CLONE_URLS.sshUrl,
560 branch: "feature/remote-v1",567 branch: "feature/remote-v1",
...@@ -601,7 +608,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => {...@@ -601,7 +608,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => {
601 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;608 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
602 const result = yield* service.publishRepository({609 const result = yield* service.publishRepository({
603 cwd: "/workspace",610 cwd: "/workspace",
604 provider: "github",611 provider: SourceControlProviderKind.make("github"),
605 repository: "octocat/t3code",612 repository: "octocat/t3code",
606 visibility: "private",613 visibility: "private",
607 remoteName: "origin",614 remoteName: "origin",
...@@ -637,7 +644,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no...@@ -637,7 +644,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no
637 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;644 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
638 const result = yield* service.publishRepository({645 const result = yield* service.publishRepository({
639 cwd: "/workspace",646 cwd: "/workspace",
640 provider: "github",647 provider: SourceControlProviderKind.make("github"),
641 repository: "octocat/t3code",648 repository: "octocat/t3code",
642 visibility: "private",649 visibility: "private",
643 remoteName: "origin",650 remoteName: "origin",
...@@ -645,7 +652,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no...@@ -645,7 +652,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no
645 });652 });
646653
647 assert.deepStrictEqual(result, {654 assert.deepStrictEqual(result, {
648 repository: { provider: "github", ...CLONE_URLS },655 repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS },
649 remoteName: "origin",656 remoteName: "origin",
650 remoteUrl: CLONE_URLS.sshUrl,657 remoteUrl: CLONE_URLS.sshUrl,
651 branch: "main",658 branch: "main",
apps/server/src/sourceControl/SourceControlRepositoryService.ts+23-12
...@@ -12,7 +12,7 @@ import {...@@ -12,7 +12,7 @@ import {
12 type SourceControlCloneRepositoryInput,12 type SourceControlCloneRepositoryInput,
13 type SourceControlCloneRepositoryResult,13 type SourceControlCloneRepositoryResult,
14 type SourceControlCloneProtocol,14 type SourceControlCloneProtocol,
15 type SourceControlProviderKind,15 SourceControlProviderKind,
16 type SourceControlPublishRepositoryInput,16 type SourceControlPublishRepositoryInput,
17 type SourceControlPublishRepositoryResult,17 type SourceControlPublishRepositoryResult,
18 type SourceControlRepositoryCloneUrls,18 type SourceControlRepositoryCloneUrls,
...@@ -209,7 +209,7 @@ export const make = Effect.gen(function* () {...@@ -209,7 +209,7 @@ export const make = Effect.gen(function* () {
209 if (trimmed.length === 0) {209 if (trimmed.length === 0) {
210 return yield* new SourceControlRepositoryError({210 return yield* new SourceControlRepositoryError({
211 operation: "cloneRepository",211 operation: "cloneRepository",
212 provider: "unknown",212 provider: SourceControlProviderKind.make("unknown"),
213 detail: "Choose a destination path before cloning.",213 detail: "Choose a destination path before cloning.",
214 });214 });
215 }215 }
...@@ -229,7 +229,7 @@ export const make = Effect.gen(function* () {...@@ -229,7 +229,7 @@ export const make = Effect.gen(function* () {
229 (cause) =>229 (cause) =>
230 new SourceControlRepositoryError({230 new SourceControlRepositoryError({
231 operation: "cloneRepository",231 operation: "cloneRepository",
232 provider: "unknown",232 provider: SourceControlProviderKind.make("unknown"),
233 detail: "Destination path already exists and is not a directory.",233 detail: "Destination path already exists and is not a directory.",
234 cause,234 cause,
235 }),235 }),
...@@ -238,7 +238,7 @@ export const make = Effect.gen(function* () {...@@ -238,7 +238,7 @@ export const make = Effect.gen(function* () {
238 if (entries.length > 0) {238 if (entries.length > 0) {
239 return yield* new SourceControlRepositoryError({239 return yield* new SourceControlRepositoryError({
240 operation: "cloneRepository",240 operation: "cloneRepository",
241 provider: "unknown",241 provider: SourceControlProviderKind.make("unknown"),
242 detail: "Destination path already exists and is not empty.",242 detail: "Destination path already exists and is not empty.",
243 });243 });
244 }244 }
...@@ -260,7 +260,8 @@ export const make = Effect.gen(function* () {...@@ -260,7 +260,8 @@ export const make = Effect.gen(function* () {
260 const preparedDestination = yield* prepareDestination(input.destinationPath);260 const preparedDestination = yield* prepareDestination(input.destinationPath);
261 let repository: SourceControlRepositoryInfo | null = null;261 let repository: SourceControlRepositoryInfo | null = null;
262 let remoteUrl = input.remoteUrl?.trim() ?? null;262 let remoteUrl = input.remoteUrl?.trim() ?? null;
263 let provider: SourceControlProviderKind = input.provider ?? "unknown";263 let provider: SourceControlProviderKind =
264 input.provider ?? SourceControlProviderKind.make("unknown");
264265
265 if (input.provider && input.repository) {266 if (input.provider && input.repository) {
266 repository = yield* lookupRepository({267 repository = yield* lookupRepository({
...@@ -335,7 +336,7 @@ export const make = Effect.gen(function* () {...@@ -335,7 +336,7 @@ export const make = Effect.gen(function* () {
335 (cause) =>336 (cause) =>
336 new SourceControlRepositoryError({337 new SourceControlRepositoryError({
337 operation: "cloneRepository",338 operation: "cloneRepository",
338 provider: input.provider ?? "unknown",339 provider: input.provider ?? SourceControlProviderKind.make("unknown"),
339 detail:340 detail:
340 stderrTail.length > 0341 stderrTail.length > 0
341 ? stderrTail.join(" ")342 ? stderrTail.join(" ")
...@@ -370,7 +371,7 @@ export const make = Effect.gen(function* () {...@@ -370,7 +371,7 @@ export const make = Effect.gen(function* () {
370 (cause) =>371 (cause) =>
371 new SourceControlRepositoryError({372 new SourceControlRepositoryError({
372 operation: "discardClone",373 operation: "discardClone",
373 provider: "unknown",374 provider: SourceControlProviderKind.make("unknown"),
374 detail: "The clone destination could not be inspected.",375 detail: "The clone destination could not be inspected.",
375 cause,376 cause,
376 }),377 }),
...@@ -379,7 +380,7 @@ export const make = Effect.gen(function* () {...@@ -379,7 +380,7 @@ export const make = Effect.gen(function* () {
379 if (entries.length > 0 && !entries.includes(".git")) {380 if (entries.length > 0 && !entries.includes(".git")) {
380 return yield* new SourceControlRepositoryError({381 return yield* new SourceControlRepositoryError({
381 operation: "discardClone",382 operation: "discardClone",
382 provider: "unknown",383 provider: SourceControlProviderKind.make("unknown"),
383 detail: "Destination path contains files that are not from the clone.",384 detail: "Destination path contains files that are not from the clone.",
384 });385 });
385 }386 }
...@@ -393,7 +394,7 @@ export const make = Effect.gen(function* () {...@@ -393,7 +394,7 @@ export const make = Effect.gen(function* () {
393 (cause) =>394 (cause) =>
394 new SourceControlRepositoryError({395 new SourceControlRepositoryError({
395 operation: "discardClone",396 operation: "discardClone",
396 provider: "unknown",397 provider: SourceControlProviderKind.make("unknown"),
397 detail: "The partial clone could not be removed.",398 detail: "The partial clone could not be removed.",
398 cause,399 cause,
399 }),400 }),
...@@ -462,13 +463,23 @@ export const make = Effect.gen(function* () {...@@ -462,13 +463,23 @@ export const make = Effect.gen(function* () {
462 lookupRepository: (input) =>463 lookupRepository: (input) =>
463 lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)),464 lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)),
464 prepareClone: (input) =>465 prepareClone: (input) =>
465 prepareClone(input).pipe(mapRepositoryError("cloneRepository", input.provider ?? "unknown")),466 prepareClone(input).pipe(
467 mapRepositoryError(
468 "cloneRepository",
469 input.provider ?? SourceControlProviderKind.make("unknown"),
470 ),
471 ),
466 cloneRepository: (input, options) =>472 cloneRepository: (input, options) =>
467 cloneRepository(input, options).pipe(473 cloneRepository(input, options).pipe(
468 mapRepositoryError("cloneRepository", input.provider ?? "unknown"),474 mapRepositoryError(
475 "cloneRepository",
476 input.provider ?? SourceControlProviderKind.make("unknown"),
477 ),
469 ),478 ),
470 discardClone: (destinationPath) =>479 discardClone: (destinationPath) =>
471 discardClone(destinationPath).pipe(mapRepositoryError("discardClone", "unknown")),480 discardClone(destinationPath).pipe(
481 mapRepositoryError("discardClone", SourceControlProviderKind.make("unknown")),
482 ),
472 publishRepository: (input) =>483 publishRepository: (input) =>
473 publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)),484 publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)),
474 });485 });
apps/server/src/textGeneration/ThreadTitleLinks.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { expect, it } from "@effect/vitest";2import { expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
...@@ -87,7 +88,7 @@ it.effect("keeps lookup failure out of generation and skips unlinked messages",...@@ -87,7 +88,7 @@ it.effect("keeps lookup failure out of generation and skips unlinked messages",
87 resolveLink: () =>88 resolveLink: () =>
88 Effect.fail(89 Effect.fail(
89 new SourceControlProviderError({90 new SourceControlProviderError({
90 provider: "unknown",91 provider: SourceControlProviderKind.make("unknown"),
91 operation: "resolveLink",92 operation: "resolveLink",
92 cwd: "/tmp",93 cwd: "/tmp",
93 detail: "Unavailable",94 detail: "Unavailable",
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { assert, it, describe } from "@effect/vitest";2import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Cause from "effect/Cause";4import * as Cause from "effect/Cause";
...@@ -34,7 +35,7 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");...@@ -34,7 +35,7 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
34const baseLocalStatus: VcsStatusLocalResult = {35const baseLocalStatus: VcsStatusLocalResult = {
35 isRepo: true,36 isRepo: true,
36 sourceControlProvider: {37 sourceControlProvider: {
37 kind: "github",38 kind: SourceControlProviderKind.make("github"),
38 name: "GitHub",39 name: "GitHub",
39 baseUrl: "https://github.com",40 baseUrl: "https://github.com",
40 },41 },
apps/web/src/components/CommandPalette.tsx+10-7
...@@ -39,7 +39,7 @@ import {...@@ -39,7 +39,7 @@ import {
39 type FilesystemBrowseResult,39 type FilesystemBrowseResult,
40 type ProjectId,40 type ProjectId,
41 type SourceControlDiscoveryResult,41 type SourceControlDiscoveryResult,
42 type SourceControlProviderKind,42 SourceControlProviderKind,
43 type SourceControlRepositoryInfo,43 type SourceControlRepositoryInfo,
44 PRIMARY_LOCAL_ENVIRONMENT_ID,44 PRIMARY_LOCAL_ENVIRONMENT_ID,
45 resolveEnvironmentMachineKind,45 resolveEnvironmentMachineKind,
...@@ -257,10 +257,13 @@ interface AddProjectEnvironmentOption {...@@ -257,10 +257,13 @@ interface AddProjectEnvironmentOption {
257 readonly status: string;257 readonly status: string;
258}258}
259259
260type AddProjectRemoteProviderKind = Extract<260type AddProjectRemoteProviderKind =
261 SourceControlProviderKind,261 | "github"
262 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"262 | "gitlab"
263>;263 | "forgejo"
264 | "bitbucket"
265 | "azure-devops"
266 | "gitcafe";
264type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";267type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
265268
266type AddProjectCloneFlow =269type AddProjectCloneFlow =
...@@ -421,7 +424,7 @@ function buildAddProjectRemoteSourceReadiness(...@@ -421,7 +424,7 @@ function buildAddProjectRemoteSourceReadiness(
421 for (const source of REMOTE_PROJECT_SOURCES) {424 for (const source of REMOTE_PROJECT_SOURCES) {
422 const kind = sourceProviderKind(source);425 const kind = sourceProviderKind(source);
423 if (!kind) continue;426 if (!kind) continue;
424 const provider = providerByKind.get(kind);427 const provider = providerByKind.get(SourceControlProviderKind.make(kind));
425 if (!provider) {428 if (!provider) {
426 readiness[source] = unavailable;429 readiness[source] = unavailable;
427 continue;430 continue;
...@@ -2628,7 +2631,7 @@ function OpenCommandPaletteDialog(props: {...@@ -2628,7 +2631,7 @@ function OpenCommandPaletteDialog(props: {
2628 const lookupResult = await lookupRepository({2631 const lookupResult = await lookupRepository({
2629 environmentId: addProjectCloneFlow.environmentId,2632 environmentId: addProjectCloneFlow.environmentId,
2630 input: {2633 input: {
2631 provider,2634 provider: SourceControlProviderKind.make(provider),
2632 repository: rawRepository,2635 repository: rawRepository,
2633 },2636 },
2634 });2637 });
apps/web/src/components/GitActionsControl.logic.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import type { VcsStatusResult } from "@t3tools/contracts";2import type { VcsStatusResult } from "@t3tools/contracts";
2import { assert, describe, it } from "vite-plus/test";3import { assert, describe, it } from "vite-plus/test";
3import {4import {
...@@ -343,7 +344,7 @@ describe("when: source control provider uses merge requests", () => {...@@ -343,7 +344,7 @@ describe("when: source control provider uses merge requests", () => {
343 const gitlabStatus = status({344 const gitlabStatus = status({
344 aheadCount: 2,345 aheadCount: 2,
345 sourceControlProvider: {346 sourceControlProvider: {
346 kind: "gitlab",347 kind: SourceControlProviderKind.make("gitlab"),
347 name: "GitLab",348 name: "GitLab",
348 baseUrl: "https://gitlab.com",349 baseUrl: "https://gitlab.com",
349 },350 },
apps/web/src/components/GitActionsControl.tsx+13-11
...@@ -3,6 +3,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -3,6 +3,7 @@ import { useAtomValue } from "@effect/atom-react";
3import {3import {
4 AuthOrchestrationOperateScope,4 AuthOrchestrationOperateScope,
5 AuthSourceControlWriteScope,5 AuthSourceControlWriteScope,
6 SourceControlProviderKind,
6 type ScopedThreadRef,7 type ScopedThreadRef,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import {9import {
...@@ -14,7 +15,6 @@ import type {...@@ -14,7 +15,6 @@ import type {
14 GitStackedAction,15 GitStackedAction,
15 SourceControlCloneProtocol,16 SourceControlCloneProtocol,
16 SourceControlProviderDiscoveryItem,17 SourceControlProviderDiscoveryItem,
17 SourceControlProviderKind,
18 SourceControlPublishRepositoryResult,18 SourceControlPublishRepositoryResult,
19 SourceControlRepositoryVisibility,19 SourceControlRepositoryVisibility,
20 VcsStatusResult,20 VcsStatusResult,
...@@ -158,10 +158,13 @@ interface PendingDefaultBranchAction {...@@ -158,10 +158,13 @@ interface PendingDefaultBranchAction {
158 filePaths?: string[];158 filePaths?: string[];
159}159}
160160
161type PublishProviderKind = Extract<161type PublishProviderKind =
162 SourceControlProviderKind,162 | "github"
163 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"163 | "gitlab"
164>;164 | "forgejo"
165 | "bitbucket"
166 | "azure-devops"
167 | "gitcafe";
165168
166type GitActionToastId = ReturnType<typeof toastManager.add>;169type GitActionToastId = ReturnType<typeof toastManager.add>;
167170
...@@ -265,9 +268,7 @@ function publishProviderOption(provider: PublishProviderKind) {...@@ -265,9 +268,7 @@ function publishProviderOption(provider: PublishProviderKind) {
265 );268 );
266}269}
267270
268function isPublishProviderKind(271function isPublishProviderKind(provider: string): provider is PublishProviderKind {
269 provider: SourceControlProviderKind,
270): provider is PublishProviderKind {
271 return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider);272 return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider);
272}273}
273274
...@@ -582,8 +583,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -582,8 +583,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
582 gitcafe: null,583 gitcafe: null,
583 };584 };
584 for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) {585 for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) {
585 if (isPublishProviderKind(provider.kind)) {586 const kind: string = provider.kind;
586 accounts[provider.kind] = Option.getOrNull(provider.auth.account);587 if (isPublishProviderKind(kind)) {
588 accounts[kind] = Option.getOrNull(provider.auth.account);
587 }589 }
588 }590 }
589 return accounts;591 return accounts;
...@@ -670,7 +672,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -670,7 +672,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
670672
671 void (async () => {673 void (async () => {
672 const result = await publishRepositoryAction.run({674 const result = await publishRepositoryAction.run({
673 provider: publishProvider,675 provider: SourceControlProviderKind.make(publishProvider),
674 repository: publishRepository.trim(),676 repository: publishRepository.trim(),
675 visibility: publishVisibility,677 visibility: publishVisibility,
676 remoteName: publishRemoteName.trim() || "origin",678 remoteName: publishRemoteName.trim() || "origin",
apps/web/src/components/ThreadStatusIndicators.test.ts+3-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";2import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";3import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -93,7 +94,7 @@ function pullRequestSummary(...@@ -93,7 +94,7 @@ function pullRequestSummary(
93 updatedAt: string,94 updatedAt: string,
94): PullRequestSummary {95): PullRequestSummary {
95 return {96 return {
96 provider: "github",97 provider: SourceControlProviderKind.make("github"),
97 projectId: ProjectId.make("project-1"),98 projectId: ProjectId.make("project-1"),
98 repository: "pingdotgg/t3code",99 repository: "pingdotgg/t3code",
99 number: 42,100 number: 42,
...@@ -179,7 +180,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {...@@ -179,7 +180,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
179 url: "https://github.com/pingdotgg/t3code/pull/42",180 url: "https://github.com/pingdotgg/t3code/pull/42",
180 };181 };
181 const provider = {182 const provider = {
182 kind: "github" as const,183 kind: SourceControlProviderKind.make("github"),
183 name: "GitHub",184 name: "GitHub",
184 baseUrl: "https://github.com",185 baseUrl: "https://github.com",
185 };186 };
apps/web/src/components/ThreadStatusIndicators.tsx+2-1
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 type ThreadLinkedPullRequest,13 type ThreadLinkedPullRequest,
14 type ThreadPullRequestLink,14 type ThreadPullRequestLink,
15 type VcsStatusResult,15 type VcsStatusResult,
16 SourceControlProviderKind,
16} from "@t3tools/contracts";17} from "@t3tools/contracts";
17import { Atom } from "effect/reactivity";18import { Atom } from "effect/reactivity";
18import { FolderGit2Icon, TerminalIcon } from "lucide-react";19import { FolderGit2Icon, TerminalIcon } from "lucide-react";
...@@ -154,7 +155,7 @@ export function linkedPullRequestSnapshotStatus(...@@ -154,7 +155,7 @@ export function linkedPullRequestSnapshotStatus(
154 baseRef: snapshot.baseBranch,155 baseRef: snapshot.baseBranch,
155 ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }),156 ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }),
156 },157 },
157 sourceControlProvider: { kind, name: kind, baseUrl: "" },158 sourceControlProvider: { kind: SourceControlProviderKind.make(kind), name: kind, baseUrl: "" },
158 };159 };
159}160}
160161
apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import {2import {
2 EnvironmentId,3 EnvironmentId,
3 ProjectId,4 ProjectId,
...@@ -151,7 +152,7 @@ import { PullRequestDetailPanel } from "./PullRequestDetailPanel";...@@ -151,7 +152,7 @@ import { PullRequestDetailPanel } from "./PullRequestDetailPanel";
151import { pullRequestPanelContext } from "./pullRequestDetail.logic";152import { pullRequestPanelContext } from "./pullRequestDetail.logic";
152153
153const detail: PullRequestDetailView = {154const detail: PullRequestDetailView = {
154 provider: "github",155 provider: SourceControlProviderKind.make("github"),
155 projectId: ProjectId.make("project"),156 projectId: ProjectId.make("project"),
156 projectTitle: "Project",157 projectTitle: "Project",
157 workspaceRoot: "/workspace",158 workspaceRoot: "/workspace",
apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/contracts";2import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/contracts";
2import { act, type ReactNode } from "react";3import { act, type ReactNode } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";4import { create, type ReactTestRenderer } from "react-test-renderer";
...@@ -26,7 +27,7 @@ vi.mock("../ui/tooltip", () => ({...@@ -26,7 +27,7 @@ vi.mock("../ui/tooltip", () => ({
26import { PullRequestSummaryTab } from "./PullRequestSummaryTab";27import { PullRequestSummaryTab } from "./PullRequestSummaryTab";
2728
28const detail: PullRequestDetailView = {29const detail: PullRequestDetailView = {
29 provider: "github",30 provider: SourceControlProviderKind.make("github"),
30 projectId: ProjectId.make("project"),31 projectId: ProjectId.make("project"),
31 projectTitle: "Project",32 projectTitle: "Project",
32 workspaceRoot: "/workspace",33 workspaceRoot: "/workspace",
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+12-8
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { resolvePlanFollowUpSubmission } from "../../proposedPlan";2import { resolvePlanFollowUpSubmission } from "../../proposedPlan";
2import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend";3import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend";
3import {4import {
...@@ -95,12 +96,14 @@ describe("pull request checkout commands", () => {...@@ -95,12 +96,14 @@ describe("pull request checkout commands", () => {
95 ],96 ],
96 ["unknown", "feature", null, null],97 ["unknown", "feature", null, null],
97 ] as const)("builds the %s command", (provider, branch, repository, expected) => {98 ] as const)("builds the %s command", (provider, branch, repository, expected) => {
98 expect(pullRequestCheckoutCommand(provider, 42, branch, repository)).toBe(expected);99 expect(
100 pullRequestCheckoutCommand(SourceControlProviderKind.make(provider), 42, branch, repository),
101 ).toBe(expected);
99 });102 });
100 it("fetches Forgejo pull refs from the actual repository, including a mounted host and port", () => {103 it("fetches Forgejo pull refs from the actual repository, including a mounted host and port", () => {
101 expect(104 expect(
102 pullRequestCheckoutCommand(105 pullRequestCheckoutCommand(
103 "forgejo",106 SourceControlProviderKind.make("forgejo"),
104 42,107 42,
105 "feature",108 "feature",
106 null,109 null,
...@@ -113,7 +116,7 @@ describe("pull request checkout commands", () => {...@@ -113,7 +116,7 @@ describe("pull request checkout commands", () => {
113 it("quotes shell metacharacters in Forgejo repository URLs", () => {116 it("quotes shell metacharacters in Forgejo repository URLs", () => {
114 expect(117 expect(
115 pullRequestCheckoutCommand(118 pullRequestCheckoutCommand(
116 "forgejo",119 SourceControlProviderKind.make("forgejo"),
117 42,120 42,
118 "feature",121 "feature",
119 null,122 null,
...@@ -1714,7 +1717,7 @@ describe("cached pull request detail", () => {...@@ -1714,7 +1717,7 @@ describe("cached pull request detail", () => {
1714 const reference = { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 7 };1717 const reference = { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 7 };
1715 const detail = (overrides: Partial<PullRequestDetail> = {}): PullRequestDetail =>1718 const detail = (overrides: Partial<PullRequestDetail> = {}): PullRequestDetail =>
1716 ({1719 ({
1717 provider: "github",1720 provider: SourceControlProviderKind.make("github"),
1718 capabilities: {1721 capabilities: {
1719 diff: true,1722 diff: true,
1720 comment: true,1723 comment: true,
...@@ -1797,7 +1800,7 @@ describe("cached pull request detail", () => {...@@ -1797,7 +1800,7 @@ describe("cached pull request detail", () => {
1797 remoteName: "origin",1800 remoteName: "origin",
1798 remoteUrl: "https://github.com/acme/web.git",1801 remoteUrl: "https://github.com/acme/web.git",
1799 },1802 },
1800 provider: "github",1803 provider: SourceControlProviderKind.make("github"),
1801 });1804 });
1802 expect(readPullRequestDetailSnapshot(storage, "env-1", resolved)?.title).toBe(1805 expect(readPullRequestDetailSnapshot(storage, "env-1", resolved)?.title).toBe(
1803 "Cache the title",1806 "Cache the title",
...@@ -1824,7 +1827,7 @@ describe("cached pull request detail", () => {...@@ -1824,7 +1827,7 @@ describe("cached pull request detail", () => {
1824 remoteName: "origin",1827 remoteName: "origin",
1825 remoteUrl: "git@ssh.dev.azure.com:v3/org/project/web",1828 remoteUrl: "git@ssh.dev.azure.com:v3/org/project/web",
1826 },1829 },
1827 provider: "azure-devops",1830 provider: SourceControlProviderKind.make("azure-devops"),
1828 }),1831 }),
1829 ).toBe(reference);1832 ).toBe(reference);
1830 expect(resolvePullRequestReferenceHost(reference, undefined)).toBe(reference);1833 expect(resolvePullRequestReferenceHost(reference, undefined)).toBe(reference);
...@@ -1847,7 +1850,7 @@ describe("cached pull request detail", () => {...@@ -1847,7 +1850,7 @@ describe("cached pull request detail", () => {
1847 it("keeps Forgejo ports isolated when recovering legacy snapshots", () => {1850 it("keeps Forgejo ports isolated when recovering legacy snapshots", () => {
1848 const storage = makeStorage();1851 const storage = makeStorage();
1849 const cached = detail({1852 const cached = detail({
1850 provider: "forgejo",1853 provider: SourceControlProviderKind.make("forgejo"),
1851 url: "https://forge.example:8443/acme/web/pulls/7",1854 url: "https://forge.example:8443/acme/web/pulls/7",
1852 });1855 });
1853 writePullRequestDetailSnapshot(storage, "env-1", reference, cached);1856 writePullRequestDetailSnapshot(storage, "env-1", reference, cached);
...@@ -1869,7 +1872,8 @@ describe("cached pull request detail", () => {...@@ -1869,7 +1872,8 @@ describe("cached pull request detail", () => {
18691872
1870 it.each(["github", "gitlab"] as const)(1873 it.each(["github", "gitlab"] as const)(
1871 "retains portless %s snapshot identities for custom web ports",1874 "retains portless %s snapshot identities for custom web ports",
1872 (provider) => {1875 (providerName) => {
1876 const provider = SourceControlProviderKind.make(providerName);
1873 const storage = makeStorage();1877 const storage = makeStorage();
1874 const host = `${provider}.example.com`;1878 const host = `${provider}.example.com`;
1875 const hosted = { ...reference, host };1879 const hosted = { ...reference, host };
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+9-5
...@@ -21,7 +21,7 @@ import {...@@ -21,7 +21,7 @@ import {
21 type PullRequestReviewThread,21 type PullRequestReviewThread,
22 type PullRequestState,22 type PullRequestState,
23 type PullRequestUpdateMethod,23 type PullRequestUpdateMethod,
24 type SourceControlProviderKind,24 SourceControlProviderKind,
25 type ThreadLinkedPullRequest,25 type ThreadLinkedPullRequest,
26 type ThreadPullRequestLink,26 type ThreadPullRequestLink,
27 type VcsRef,27 type VcsRef,
...@@ -140,7 +140,7 @@ export function pullRequestCheckoutCommand(...@@ -140,7 +140,7 @@ export function pullRequestCheckoutCommand(
140 }140 }
141 return `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`;141 return `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`;
142 }142 }
143 case "unknown":143 default:
144 return null;144 return null;
145 }145 }
146}146}
...@@ -155,10 +155,11 @@ export function loadingPullRequestCheckoutCommand(...@@ -155,10 +155,11 @@ export function loadingPullRequestCheckoutCommand(
155 identity?.provider ??155 identity?.provider ??
156 (host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null);156 (host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null);
157 if (provider !== "github" && provider !== "gitlab" && provider !== "azure-devops") return null;157 if (provider !== "github" && provider !== "gitlab" && provider !== "azure-devops") return null;
158 if (identity?.provider !== undefined && host && pullRequestHostOf(identity, provider) !== host) {158 const kind = SourceControlProviderKind.make(provider);
159 if (identity?.provider !== undefined && host && pullRequestHostOf(identity, kind) !== host) {
159 return null;160 return null;
160 }161 }
161 return pullRequestCheckoutCommand(provider, reference.number, "");162 return pullRequestCheckoutCommand(kind, reference.number, "");
162}163}
163164
164/** Activity changes only when the same host resource reports a newer revision. */165/** Activity changes only when the same host resource reports a newer revision. */
...@@ -1304,7 +1305,10 @@ export function resolvePullRequestReferenceHost(...@@ -1304,7 +1305,10 @@ export function resolvePullRequestReferenceHost(
1304): PullRequestRef {1305): PullRequestRef {
1305 // Other providers may resolve an SSH remote to a different web authority on the server.1306 // Other providers may resolve an SSH remote to a different web authority on the server.
1306 if (reference.host !== undefined || identity?.provider !== "github") return reference;1307 if (reference.host !== undefined || identity?.provider !== "github") return reference;
1307 return { ...reference, host: pullRequestHostOf(identity, "github") };1308 return {
1309 ...reference,
1310 host: pullRequestHostOf(identity, SourceControlProviderKind.make("github")),
1311 };
1308}1312}
13091313
1310export interface PullRequestDetailSnapshotRef {1314export interface PullRequestDetailSnapshotRef {
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+3-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import type { EnvironmentId, ProjectId, PullRequestListEntry } from "@t3tools/contracts";2import type { EnvironmentId, ProjectId, PullRequestListEntry } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
34
...@@ -330,7 +331,7 @@ describe("pull request involvement filtering", () => {...@@ -330,7 +331,7 @@ describe("pull request involvement filtering", () => {
330 entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }),331 entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }),
331 entry({332 entry({
332 number: 2,333 number: 2,
333 provider: "gitlab",334 provider: SourceControlProviderKind.make("gitlab"),
334 host: "gitlab.com",335 host: "gitlab.com",
335 author: { login: "Bilal", name: null, avatarUrl: null },336 author: { login: "Bilal", name: null, avatarUrl: null },
336 }),337 }),
...@@ -1322,7 +1323,7 @@ describe("merging the environments' own listings", () => {...@@ -1322,7 +1323,7 @@ describe("merging the environments' own listings", () => {
1322 providers: [1323 providers: [
1323 {1324 {
1324 host: "github.com",1325 host: "github.com",
1325 kind: "github",1326 kind: SourceControlProviderKind.make("github"),
1326 searchesOnHost: false,1327 searchesOnHost: false,
1327 projectCount: 2,1328 projectCount: 2,
1328 configured: false,1329 configured: false,
apps/web/src/components/settings/SourceControlSettings.tsx+1-1
...@@ -78,7 +78,7 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {...@@ -78,7 +78,7 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
78 sourceControlProviders: [],78 sourceControlProviders: [],
79};79};
8080
81const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = {81const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<string, Icon>> = {
82 github: GitHubIcon,82 github: GitHubIcon,
83 gitlab: GitLabIcon,83 gitlab: GitLabIcon,
84 forgejo: ForgejoIcon,84 forgejo: ForgejoIcon,
apps/web/src/hooks/useNewProject.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";2import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { getNewProjectGitHubRepository } from "@t3tools/client-runtime/operations/projects";3import { getNewProjectGitHubRepository } from "@t3tools/client-runtime/operations/projects";
3import {4import {
...@@ -45,7 +46,7 @@ export function useNewProject() {...@@ -45,7 +46,7 @@ export function useNewProject() {
45 environmentId: input.environmentId,46 environmentId: input.environmentId,
46 input: {47 input: {
47 cwd: input.workspaceRoot,48 cwd: input.workspaceRoot,
48 provider: "github",49 provider: SourceControlProviderKind.make("github"),
49 repository: getNewProjectGitHubRepository(input, input.workspaceRoot),50 repository: getNewProjectGitHubRepository(input, input.workspaceRoot),
50 visibility: "private",51 visibility: "private",
51 },52 },
apps/web/src/state/pullRequests.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { ProjectId, type PullRequestSummary } from "@t3tools/contracts";2import { ProjectId, type PullRequestSummary } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
34
...@@ -5,7 +6,7 @@ import { newestPullRequestObservation, newestPullRequestSummary } from "./pullRe...@@ -5,7 +6,7 @@ import { newestPullRequestObservation, newestPullRequestSummary } from "./pullRe
56
6function summary(overrides: Partial<PullRequestSummary> = {}): PullRequestSummary {7function summary(overrides: Partial<PullRequestSummary> = {}): PullRequestSummary {
7 return {8 return {
8 provider: "github",9 provider: SourceControlProviderKind.make("github"),
9 projectId: ProjectId.make("pull-request-cache-test"),10 projectId: ProjectId.make("pull-request-cache-test"),
10 repository: "acme/widget",11 repository: "acme/widget",
11 number: 7,12 number: 7,
apps/web/src/state/sourceControlActions.test.ts+2-1
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 AuthSourceControlWriteScope,3 AuthSourceControlWriteScope,
4 EnvironmentId,4 EnvironmentId,
5 ThreadId,5 ThreadId,
6 SourceControlProviderKind,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";8import * as Cause from "effect/Cause";
8import { AsyncResult } from "effect/reactivity";9import { AsyncResult } from "effect/reactivity";
...@@ -98,7 +99,7 @@ const cases = [...@@ -98,7 +99,7 @@ const cases = [
98 isAllowed: action.isAllowed,99 isAllowed: action.isAllowed,
99 run: () =>100 run: () =>
100 action.run({101 action.run({
101 provider: "github",102 provider: SourceControlProviderKind.make("github"),
102 repository: "owner/repo",103 repository: "owner/repo",
103 visibility: "private",104 visibility: "private",
104 remoteName: "origin",105 remoteName: "origin",
apps/web/src/state/sourceControlActions.ts+2-1
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 type EnvironmentId,16 type EnvironmentId,
17 type GitResolvePullRequestResult,17 type GitResolvePullRequestResult,
18 type SourceControlCloneProtocol,18 type SourceControlCloneProtocol,
19 type SourceControlProviderKind,
19 type SourceControlRepositoryVisibility,20 type SourceControlRepositoryVisibility,
20 type ThreadId,21 type ThreadId,
21} from "@t3tools/contracts";22} from "@t3tools/contracts";
...@@ -275,7 +276,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi...@@ -275,7 +276,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi
275 );276 );
276 const action = useCallback(277 const action = useCallback(
277 async (input: {278 async (input: {
278 provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe";279 provider: SourceControlProviderKind;
279 repository: string;280 repository: string;
280 visibility: SourceControlRepositoryVisibility;281 visibility: SourceControlRepositoryVisibility;
281 remoteName: string;282 remoteName: string;
packages/client-runtime/src/operations/projects.test.ts+6-5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
2import {3import {
3 EnvironmentId,4 EnvironmentId,
...@@ -71,14 +72,14 @@ describe("add project shared logic", () => {...@@ -71,14 +72,14 @@ describe("add project shared logic", () => {
71 it("uses HTTPS for repositories selected through a provider", () => {72 it("uses HTTPS for repositories selected through a provider", () => {
72 expect(73 expect(
73 getDefaultCloneUrl({74 getDefaultCloneUrl({
74 provider: "github",75 provider: SourceControlProviderKind.make("github"),
75 url: "https://github.com/imputnet/helium",76 url: "https://github.com/imputnet/helium",
76 sshUrl: "git@github.com:imputnet/helium.git",77 sshUrl: "git@github.com:imputnet/helium.git",
77 }),78 }),
78 ).toBe("https://github.com/imputnet/helium");79 ).toBe("https://github.com/imputnet/helium");
79 expect(80 expect(
80 getDefaultCloneUrl({81 getDefaultCloneUrl({
81 provider: "forgejo",82 provider: SourceControlProviderKind.make("forgejo"),
82 url: "https://forgejo.example.test:8443/owner/repo.git",83 url: "https://forgejo.example.test:8443/owner/repo.git",
83 sshUrl: "ssh://git@forgejo.example.test:2222/owner/repo.git",84 sshUrl: "ssh://git@forgejo.example.test:2222/owner/repo.git",
84 }),85 }),
...@@ -88,7 +89,7 @@ describe("add project shared logic", () => {...@@ -88,7 +89,7 @@ describe("add project shared logic", () => {
88 it("preserves existing clone transport behavior for other providers", () => {89 it("preserves existing clone transport behavior for other providers", () => {
89 expect(90 expect(
90 getDefaultCloneUrl({91 getDefaultCloneUrl({
91 provider: "gitlab",92 provider: SourceControlProviderKind.make("gitlab"),
92 url: "https://gitlab.com/group/project.git",93 url: "https://gitlab.com/group/project.git",
93 sshUrl: "git@gitlab.com:group/project.git",94 sshUrl: "git@gitlab.com:group/project.git",
94 }),95 }),
...@@ -187,7 +188,7 @@ describe("add project shared logic", () => {...@@ -187,7 +188,7 @@ describe("add project shared logic", () => {
187 versionControlSystems: [],188 versionControlSystems: [],
188 sourceControlProviders: [189 sourceControlProviders: [
189 {190 {
190 kind: "github",191 kind: SourceControlProviderKind.make("github"),
191 label: "GitHub",192 label: "GitHub",
192 status: "available",193 status: "available",
193 installHint: "Install gh",194 installHint: "Install gh",
...@@ -201,7 +202,7 @@ describe("add project shared logic", () => {...@@ -201,7 +202,7 @@ describe("add project shared logic", () => {
201 },202 },
202 },203 },
203 {204 {
204 kind: "gitlab",205 kind: SourceControlProviderKind.make("gitlab"),
205 label: "GitLab",206 label: "GitLab",
206 status: "available",207 status: "available",
207 installHint: "Install glab",208 installHint: "Install glab",
packages/client-runtime/src/operations/projects.ts+8-6
...@@ -6,9 +6,9 @@ import type {...@@ -6,9 +6,9 @@ import type {
6 ProjectId,6 ProjectId,
7 ServerConfig,7 ServerConfig,
8 SourceControlDiscoveryResult,8 SourceControlDiscoveryResult,
9 SourceControlProviderKind,
10 SourceControlRepositoryInfo,9 SourceControlRepositoryInfo,
11} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { SourceControlProviderKind } from "@t3tools/contracts";
12import { newProjectFolderName } from "@t3tools/shared/path";12import { newProjectFolderName } from "@t3tools/shared/path";
13import * as Arr from "effect/Array";13import * as Arr from "effect/Array";
14import * as Option from "effect/Option";14import * as Option from "effect/Option";
...@@ -25,10 +25,12 @@ import {...@@ -25,10 +25,12 @@ import {
25} from "../state/projects.ts";25} from "../state/projects.ts";
26import type { EnvironmentProject } from "../state/models.ts";26import type { EnvironmentProject } from "../state/models.ts";
2727
28export type AddProjectRemoteProviderKind = Extract<28export type AddProjectRemoteProviderKind =
29 SourceControlProviderKind,29 | "github"
30 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"30 | "gitlab"
31>;31 | "forgejo"
32 | "bitbucket"
33 | "azure-devops";
32export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";34export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
3335
34export function canCreateProjectInEnvironment(36export function canCreateProjectInEnvironment(
...@@ -190,7 +192,7 @@ export function buildAddProjectRemoteSourceReadiness(...@@ -190,7 +192,7 @@ export function buildAddProjectRemoteSourceReadiness(
190 for (const source of ADD_PROJECT_REMOTE_SOURCES) {192 for (const source of ADD_PROJECT_REMOTE_SOURCES) {
191 const kind = addProjectRemoteSourceProvider(source);193 const kind = addProjectRemoteSourceProvider(source);
192 if (!kind) continue;194 if (!kind) continue;
193 const provider = providerByKind.get(kind);195 const provider = providerByKind.get(SourceControlProviderKind.make(kind));
194 if (!provider) {196 if (!provider) {
195 readiness[source] = unavailable;197 readiness[source] = unavailable;
196 continue;198 continue;
packages/client-runtime/src/state/sourceControl.test.ts+4-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { vi } from "vite-plus/test";2import { vi } from "vite-plus/test";
2import {3import {
3 EnvironmentId,4 EnvironmentId,
...@@ -36,7 +37,7 @@ const TARGET = new PrimaryConnectionTarget({...@@ -36,7 +37,7 @@ const TARGET = new PrimaryConnectionTarget({
3637
37const PUBLISH_RESULT: SourceControlPublishRepositoryResult = {38const PUBLISH_RESULT: SourceControlPublishRepositoryResult = {
38 repository: {39 repository: {
39 provider: "github",40 provider: SourceControlProviderKind.make("github"),
40 nameWithOwner: "t3tools/t3code",41 nameWithOwner: "t3tools/t3code",
41 url: "https://github.com/t3tools/t3code",42 url: "https://github.com/t3tools/t3code",
42 sshUrl: "git@github.com:t3tools/t3code.git",43 sshUrl: "git@github.com:t3tools/t3code.git",
...@@ -140,7 +141,7 @@ describe("source control environment atoms", () => {...@@ -140,7 +141,7 @@ describe("source control environment atoms", () => {
140 environmentId: TARGET.environmentId,141 environmentId: TARGET.environmentId,
141 input: {142 input: {
142 cwd: "/repo",143 cwd: "/repo",
143 provider: "github",144 provider: SourceControlProviderKind.make("github"),
144 repository: "t3tools/t3code",145 repository: "t3tools/t3code",
145 visibility: "private",146 visibility: "private",
146 },147 },
...@@ -156,7 +157,7 @@ describe("source control environment atoms", () => {...@@ -156,7 +157,7 @@ describe("source control environment atoms", () => {
156 environmentId: TARGET.environmentId,157 environmentId: TARGET.environmentId,
157 input: {158 input: {
158 cwd: "/repo",159 cwd: "/repo",
159 provider: "github",160 provider: SourceControlProviderKind.make("github"),
160 repository: "t3tools/t3code",161 repository: "t3tools/t3code",
161 visibility: "private",162 visibility: "private",
162 },163 },
packages/contracts/src/pullRequest.test.ts+11-6
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "./sourceControl.ts";
1import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
34
...@@ -25,7 +26,7 @@ const LIST_RESULT: PullRequestListResult = {...@@ -25,7 +26,7 @@ const LIST_RESULT: PullRequestListResult = {
25 providers: [26 providers: [
26 {27 {
27 host: "github.com",28 host: "github.com",
28 kind: "github",29 kind: SourceControlProviderKind.make("github"),
29 searchesOnHost: true,30 searchesOnHost: true,
30 projectCount: 1,31 projectCount: 1,
31 configured: true,32 configured: true,
...@@ -33,7 +34,7 @@ const LIST_RESULT: PullRequestListResult = {...@@ -33,7 +34,7 @@ const LIST_RESULT: PullRequestListResult = {
33 },34 },
34 {35 {
35 host: "gitlab.com",36 host: "gitlab.com",
36 kind: "gitlab",37 kind: SourceControlProviderKind.make("gitlab"),
37 searchesOnHost: true,38 searchesOnHost: true,
38 projectCount: 1,39 projectCount: 1,
39 configured: false,40 configured: false,
...@@ -42,7 +43,7 @@ const LIST_RESULT: PullRequestListResult = {...@@ -42,7 +43,7 @@ const LIST_RESULT: PullRequestListResult = {
42 ],43 ],
43 entries: [44 entries: [
44 {45 {
45 provider: "github",46 provider: SourceControlProviderKind.make("github"),
46 host: "github.com",47 host: "github.com",
47 projectId: "project-1" as PullRequestListResult["entries"][number]["projectId"],48 projectId: "project-1" as PullRequestListResult["entries"][number]["projectId"],
48 projectTitle: "t3code",49 projectTitle: "t3code",
...@@ -75,12 +76,16 @@ describe("PullRequestListResult", () => {...@@ -75,12 +76,16 @@ describe("PullRequestListResult", () => {
75 canonicalKey: "forge.example/team/repo",76 canonicalKey: "forge.example/team/repo",
76 locator: { remoteUrl: "http://forge.example:3000/team/repo.git" },77 locator: { remoteUrl: "http://forge.example:3000/team/repo.git" },
77 };78 };
78 expect(pullRequestHostOf(identity, "forgejo")).toBe("forge.example:3000");79 expect(pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo"))).toBe(
79 expect(pullRequestHostOf(identity, "gitlab")).toBe("forge.example");80 "forge.example:3000",
81 );
82 expect(pullRequestHostOf(identity, SourceControlProviderKind.make("gitlab"))).toBe(
83 "forge.example",
84 );
80 expect(85 expect(
81 pullRequestHostOf(86 pullRequestHostOf(
82 { ...identity, locator: { remoteUrl: "ssh://git@forge.example:2222/team/repo.git" } },87 { ...identity, locator: { remoteUrl: "ssh://git@forge.example:2222/team/repo.git" } },
83 "forgejo",88 SourceControlProviderKind.make("forgejo"),
84 ),89 ),
85 ).toBe("forge.example");90 ).toBe("forge.example");
86 });91 });
packages/contracts/src/pullRequest.ts+50-34
...@@ -1301,38 +1301,54 @@ export type PullRequestUnavailableReason = typeof PullRequestUnavailableReason.T...@@ -1301,38 +1301,54 @@ export type PullRequestUnavailableReason = typeof PullRequestUnavailableReason.T
1301 * symptom. The reason names keep their `cli-` prefix for wire compatibility; for GitHub and1301 * symptom. The reason names keep their `cli-` prefix for wire compatibility; for GitHub and
1302 * Bitbucket they mean "no credential" and "a refused credential", not a missing tool.1302 * Bitbucket they mean "no credential" and "a refused credential", not a missing tool.
1303 */1303 */
1304const PROVIDER_REQUIREMENT: Partial<1304const PROVIDER_REQUIREMENT = new Map<
1305 Record<SourceControlProviderKind, { readonly missing: string; readonly unauthenticated: string }>1305 string,
1306> = {1306 { readonly missing: string; readonly unauthenticated: string }
1307 github: {1307>([
1308 missing:1308 [
1309 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI (https://cli.github.com/) and run `gh auth login`.",1309 "github",
1310 unauthenticated:1310 {
1311 "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.",1311 missing:
1312 },1312 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI (https://cli.github.com/) and run `gh auth login`.",
1313 forgejo: {1313 unauthenticated:
1314 missing:1314 "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.",
1315 "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.",1315 },
1316 unauthenticated:1316 ],
1317 "Authenticate your Forgejo or Gitea server with `fj --host <server-url> 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.",1317 [
1318 },1318 "forgejo",
1319 gitlab: {1319 {
1320 missing:1320 missing:
1321 "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.",1321 "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.",
1322 unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.",1322 unauthenticated:
1323 },1323 "Authenticate your Forgejo or Gitea server with `fj --host <server-url> 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.",
1324 "azure-devops": {1324 },
1325 missing:1325 ],
1326 "Azure CLI (`az`) with the Azure DevOps extension is required. Install `az`, then run `az extension add --name azure-devops`.",1326 [
1327 unauthenticated: "Azure CLI is not signed in. Run `az login` and retry.",1327 "gitlab",
1328 },1328 {
1329 bitbucket: {1329 missing:
1330 missing:1330 "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.",
1331 "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.",1331 unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.",
1332 unauthenticated:1332 },
1333 "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.",1333 ],
1334 },1334 [
1335};1335 "azure-devops",
1336 {
1337 missing:
1338 "Azure CLI (`az`) with the Azure DevOps extension is required. Install `az`, then run `az extension add --name azure-devops`.",
1339 unauthenticated: "Azure CLI is not signed in. Run `az login` and retry.",
1340 },
1341 ],
1342 [
1343 "bitbucket",
1344 {
1345 missing:
1346 "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.",
1347 unauthenticated:
1348 "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.",
1349 },
1350 ],
1351]);
13361352
1337/**1353/**
1338 * The host a project's repository is addressed below. `canonicalKey` is the normalized remote,1354 * The host a project's repository is addressed below. `canonicalKey` is the normalized remote,
...@@ -1394,7 +1410,7 @@ export function pullRequestProviderRequirement(...@@ -1394,7 +1410,7 @@ export function pullRequestProviderRequirement(
1394 provider: SourceControlProviderKind,1410 provider: SourceControlProviderKind,
1395 reason: PullRequestUnavailableReason,1411 reason: PullRequestUnavailableReason,
1396): string | null {1412): string | null {
1397 const requirement = PROVIDER_REQUIREMENT[provider];1413 const requirement = PROVIDER_REQUIREMENT.get(provider);
1398 if (requirement === undefined) return null;1414 if (requirement === undefined) return null;
1399 switch (reason) {1415 switch (reason) {
1400 case "cli-missing":1416 case "cli-missing":
...@@ -1427,7 +1443,7 @@ export class PullRequestUnavailableError extends Schema.TaggedError<PullRequestU...@@ -1427,7 +1443,7 @@ export class PullRequestUnavailableError extends Schema.TaggedError<PullRequestU
14271443
1428 override get message(): string {1444 override get message(): string {
1429 const requirement =1445 const requirement =
1430 this.provider === undefined ? undefined : PROVIDER_REQUIREMENT[this.provider];1446 this.provider === undefined ? undefined : PROVIDER_REQUIREMENT.get(this.provider);
1431 switch (this.reason) {1447 switch (this.reason) {
1432 case "cli-missing":1448 case "cli-missing":
1433 return (1449 return (
packages/contracts/src/sourceControl.ts+11-9
...@@ -2,15 +2,17 @@ import * as Schema from "effect/Schema";...@@ -2,15 +2,17 @@ import * as Schema from "effect/Schema";
2import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";2import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { VcsDriverKind } from "./vcs.ts";3import { VcsDriverKind } from "./vcs.ts";
44
5export const SourceControlProviderKind = Schema.Literals([5/**
6 "github",6 * `SourceControlProviderKind` — open branded slug naming a source control host, such as `github`.
7 "gitlab",7 *
8 "forgejo",8 * Open on purpose, like `ProviderDriverKind`: a server can report a host this client does not
9 "azure-devops",9 * ship a definition for, and clients fall back to a generic presentation instead of failing to
10 "bitbucket",10 * decode. `unknown` is what the server reports for a remote it cannot attribute to any host.
11 "gitcafe",11 */
12 "unknown",12export const SourceControlProviderKind = TrimmedNonEmptyString.check(
13]);13 Schema.isMaxLength(64),
14 Schema.isPattern(/^[a-z][a-z0-9-]*$/),
15).pipe(Schema.brand("SourceControlProviderKind"));
14export type SourceControlProviderKind = typeof SourceControlProviderKind.Type;16export type SourceControlProviderKind = typeof SourceControlProviderKind.Type;
1517
16export const SourceControlProviderInfo = Schema.Struct({18export const SourceControlProviderInfo = Schema.Struct({
packages/shared/src/git.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import type { VcsStatusRemoteResult, VcsStatusResult } from "@t3tools/contracts";2import type { VcsStatusRemoteResult, VcsStatusResult } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
34
...@@ -257,7 +258,7 @@ describe("applyGitStatusStreamEvent", () => {...@@ -257,7 +258,7 @@ describe("applyGitStatusStreamEvent", () => {
257 const current: VcsStatusResult = {258 const current: VcsStatusResult = {
258 isRepo: true,259 isRepo: true,
259 sourceControlProvider: {260 sourceControlProvider: {
260 kind: "github",261 kind: SourceControlProviderKind.make("github"),
261 name: "GitHub",262 name: "GitHub",
262 baseUrl: "https://github.com",263 baseUrl: "https://github.com",
263 },264 },
packages/shared/src/sourceControl.test.ts+18-9
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
23
3import {4import {
...@@ -10,30 +11,38 @@ import {...@@ -10,30 +11,38 @@ import {
1011
11describe("source control presentation", () => {12describe("source control presentation", () => {
12 it("uses merge request terminology for GitLab", () => {13 it("uses merge request terminology for GitLab", () => {
13 expect(getChangeRequestTerminologyForKind("gitlab")).toEqual({14 expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("gitlab"))).toEqual({
14 shortLabel: "MR",15 shortLabel: "MR",
15 singular: "merge request",16 singular: "merge request",
16 });17 });
17 });18 });
1819
19 it("uses pull request terminology for GitHub-compatible providers", () => {20 it("uses pull request terminology for GitHub-compatible providers", () => {
20 expect(getChangeRequestTerminologyForKind("github")).toEqual({21 expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("github"))).toEqual({
21 shortLabel: "PR",22 shortLabel: "PR",
22 singular: "pull request",23 singular: "pull request",
23 });24 });
24 expect(getChangeRequestTerminologyForKind("azure-devops")).toEqual({25 expect(
25 shortLabel: "PR",26 getChangeRequestTerminologyForKind(SourceControlProviderKind.make("azure-devops")),
26 singular: "pull request",27 ).toEqual({
27 });
28 expect(getChangeRequestTerminologyForKind("bitbucket")).toEqual({
29 shortLabel: "PR",28 shortLabel: "PR",
30 singular: "pull request",29 singular: "pull request",
31 });30 });
31 expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("bitbucket"))).toEqual(
32 {
33 shortLabel: "PR",
34 singular: "pull request",
35 },
36 );
32 });37 });
3338
34 it("falls back to generic change request copy for unknown providers", () => {39 it("falls back to generic change request copy for unknown providers", () => {
35 expect(40 expect(
36 resolveChangeRequestPresentation({ kind: "unknown", name: "forge", baseUrl: "" }),41 resolveChangeRequestPresentation({
42 kind: SourceControlProviderKind.make("unknown"),
43 name: "forge",
44 baseUrl: "",
45 }),
37 ).toEqual(46 ).toEqual(
38 expect.objectContaining({47 expect.objectContaining({
39 shortName: "change request",48 shortName: "change request",
...@@ -69,7 +78,7 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {...@@ -69,7 +78,7 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {
69 },78 },
70 );79 );
71 }80 }
72 expect(getChangeRequestTerminologyForKind("forgejo")).toEqual({81 expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("forgejo"))).toEqual({
73 shortLabel: "PR",82 shortLabel: "PR",
74 singular: "pull request",83 singular: "pull request",
75 });84 });
packages/shared/src/sourceControl.ts+14-13
...@@ -1,8 +1,5 @@...@@ -1,8 +1,5 @@
1import type {1import { SourceControlProviderKind } from "@t3tools/contracts";
2 RepositoryIdentity,2import type { RepositoryIdentity, SourceControlProviderInfo } from "@t3tools/contracts";
3 SourceControlProviderInfo,
4 SourceControlProviderKind,
5} from "@t3tools/contracts";
63
7export interface ChangeRequestPresentation {4export interface ChangeRequestPresentation {
8 readonly icon:5 readonly icon:
...@@ -124,7 +121,7 @@ export function resolveChangeRequestPresentation(...@@ -124,7 +121,7 @@ export function resolveChangeRequestPresentation(
124 return BITBUCKET_CHANGE_REQUEST_PRESENTATION;121 return BITBUCKET_CHANGE_REQUEST_PRESENTATION;
125 case "gitcafe":122 case "gitcafe":
126 return GITCAFE_CHANGE_REQUEST_PRESENTATION;123 return GITCAFE_CHANGE_REQUEST_PRESENTATION;
127 case "unknown":124 default:
128 return GENERIC_CHANGE_REQUEST_PRESENTATION;125 return GENERIC_CHANGE_REQUEST_PRESENTATION;
129 }126 }
130}127}
...@@ -244,7 +241,11 @@ export function detectSourceControlProviderFromRemoteUrl(...@@ -244,7 +241,11 @@ export function detectSourceControlProviderFromRemoteUrl(
244 const hostname = parseHostName(host);241 const hostname = parseHostName(host);
245242
246 if (isGitCafeHost(hostname)) {243 if (isGitCafeHost(hostname)) {
247 return { kind: "gitcafe", name: "GitCafe", baseUrl: toBaseUrl(hostname) };244 return {
245 kind: SourceControlProviderKind.make("gitcafe"),
246 name: "GitCafe",
247 baseUrl: toBaseUrl(hostname),
248 };
248 }249 }
249250
250 if (251 if (
...@@ -253,7 +254,7 @@ export function detectSourceControlProviderFromRemoteUrl(...@@ -253,7 +254,7 @@ export function detectSourceControlProviderFromRemoteUrl(
253 hasDnsLabel(hostname, "gitea")254 hasDnsLabel(hostname, "gitea")
254 ) {255 ) {
255 return {256 return {
256 kind: "forgejo",257 kind: SourceControlProviderKind.make("forgejo"),
257 name: "Forgejo",258 name: "Forgejo",
258 baseUrl: /^https?:/iu.test(remoteUrl.trim())259 baseUrl: /^https?:/iu.test(remoteUrl.trim())
259 ? new URL(remoteUrl.trim()).origin260 ? new URL(remoteUrl.trim()).origin
...@@ -263,7 +264,7 @@ export function detectSourceControlProviderFromRemoteUrl(...@@ -263,7 +264,7 @@ export function detectSourceControlProviderFromRemoteUrl(
263264
264 if (isGitHubHost(hostname)) {265 if (isGitHubHost(hostname)) {
265 return {266 return {
266 kind: "github",267 kind: SourceControlProviderKind.make("github"),
267 name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted",268 name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted",
268 baseUrl: toBaseUrl(host),269 baseUrl: toBaseUrl(host),
269 };270 };
...@@ -271,7 +272,7 @@ export function detectSourceControlProviderFromRemoteUrl(...@@ -271,7 +272,7 @@ export function detectSourceControlProviderFromRemoteUrl(
271272
272 if (isGitLabHost(hostname)) {273 if (isGitLabHost(hostname)) {
273 return {274 return {
274 kind: "gitlab",275 kind: SourceControlProviderKind.make("gitlab"),
275 name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted",276 name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted",
276 baseUrl: toBaseUrl(host),277 baseUrl: toBaseUrl(host),
277 };278 };
...@@ -279,7 +280,7 @@ export function detectSourceControlProviderFromRemoteUrl(...@@ -279,7 +280,7 @@ export function detectSourceControlProviderFromRemoteUrl(
279280
280 if (isAzureDevOpsHost(hostname)) {281 if (isAzureDevOpsHost(hostname)) {
281 return {282 return {
282 kind: "azure-devops",283 kind: SourceControlProviderKind.make("azure-devops"),
283 name: "Azure DevOps",284 name: "Azure DevOps",
284 baseUrl: toBaseUrl(host),285 baseUrl: toBaseUrl(host),
285 };286 };
...@@ -287,14 +288,14 @@ export function detectSourceControlProviderFromRemoteUrl(...@@ -287,14 +288,14 @@ export function detectSourceControlProviderFromRemoteUrl(
287288
288 if (isBitbucketHost(hostname)) {289 if (isBitbucketHost(hostname)) {
289 return {290 return {
290 kind: "bitbucket",291 kind: SourceControlProviderKind.make("bitbucket"),
291 name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted",292 name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted",
292 baseUrl: toBaseUrl(host),293 baseUrl: toBaseUrl(host),
293 };294 };
294 }295 }
295296
296 return {297 return {
297 kind: "unknown",298 kind: SourceControlProviderKind.make("unknown"),
298 name: host,299 name: host,
299 baseUrl: toBaseUrl(host),300 baseUrl: toBaseUrl(host),
300 };301 };
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts+5-4
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import * as Semaphore from "effect/Semaphore";3import * as Semaphore from "effect/Semaphore";
3import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";4import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
...@@ -157,7 +158,7 @@ export const make = Effect.gen(function* () {...@@ -157,7 +158,7 @@ export const make = Effect.gen(function* () {
157 const fail =158 const fail =
158 (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) =>159 (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) =>
159 new PullRequestProviderError({160 new PullRequestProviderError({
160 provider: "azure-devops",161 provider: SourceControlProviderKind.make("azure-devops"),
161 operation,162 operation,
162 ...azureDevOpsProviderFailure(error),163 ...azureDevOpsProviderFailure(error),
163 detail: error.detail,164 detail: error.detail,
...@@ -168,7 +169,7 @@ export const make = Effect.gen(function* () {...@@ -168,7 +169,7 @@ export const make = Effect.gen(function* () {
168 const unsupported = (operation: string) =>169 const unsupported = (operation: string) =>
169 Effect.fail(170 Effect.fail(
170 new PullRequestProviderError({171 new PullRequestProviderError({
171 provider: "azure-devops",172 provider: SourceControlProviderKind.make("azure-devops"),
172 operation,173 operation,
173 reason: "failed",174 reason: "failed",
174 detail: "Azure DevOps reviews cannot be written from here yet.",175 detail: "Azure DevOps reviews cannot be written from here yet.",
...@@ -296,7 +297,7 @@ export const make = Effect.gen(function* () {...@@ -296,7 +297,7 @@ export const make = Effect.gen(function* () {
296 };297 };
297298
298 const provider: PullRequestProviderApi = {299 const provider: PullRequestProviderApi = {
299 kind: "azure-devops",300 kind: SourceControlProviderKind.make("azure-devops"),
300 capabilities: CAPABILITIES,301 capabilities: CAPABILITIES,
301 // The bare repository name repeats across an organization; the project path does not.302 // The bare repository name repeats across an organization; the project path does not.
302 repositoryKey: ({ canonicalKey }) => canonicalKey,303 repositoryKey: ({ canonicalKey }) => canonicalKey,
...@@ -612,7 +613,7 @@ export const make = Effect.gen(function* () {...@@ -612,7 +613,7 @@ export const make = Effect.gen(function* () {
612 listReviewerCandidates: () =>613 listReviewerCandidates: () =>
613 Effect.fail(614 Effect.fail(
614 new PullRequestProviderError({615 new PullRequestProviderError({
615 provider: "azure-devops",616 provider: SourceControlProviderKind.make("azure-devops"),
616 operation: "listReviewerCandidates",617 operation: "listReviewerCandidates",
617 reason: "failed",618 reason: "failed",
618 detail: "Azure DevOps cannot say who may review a pull request.",619 detail: "Azure DevOps cannot say who may review a pull request.",
packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -35,7 +36,7 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests"...@@ -35,7 +36,7 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests"
35 });36 });
3637
37 assert.deepStrictEqual(changeRequest, {38 assert.deepStrictEqual(changeRequest, {
38 provider: "azure-devops",39 provider: SourceControlProviderKind.make("azure-devops"),
39 number: 42,40 number: 42,
40 title: "Add Azure provider",41 title: "Add Azure provider",
41 url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",42 url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts+11-10
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";3import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
34
...@@ -39,7 +40,7 @@ function parseAzureAuth(input: SourceControlAuthProbeInput) {...@@ -39,7 +40,7 @@ function parseAzureAuth(input: SourceControlAuthProbeInput) {
3940
40export const discovery = {41export const discovery = {
41 type: "cli",42 type: "cli",
42 kind: "azure-devops",43 kind: SourceControlProviderKind.make("azure-devops"),
43 label: "Azure DevOps",44 label: "Azure DevOps",
44 executable: "az",45 executable: "az",
45 versionArgs: ["--version"],46 versionArgs: ["--version"],
...@@ -67,7 +68,7 @@ function toChangeRequest(summary: {...@@ -67,7 +68,7 @@ function toChangeRequest(summary: {
67 readonly updatedAt: ChangeRequest["updatedAt"];68 readonly updatedAt: ChangeRequest["updatedAt"];
68}): ChangeRequest {69}): ChangeRequest {
69 return {70 return {
70 provider: "azure-devops",71 provider: SourceControlProviderKind.make("azure-devops"),
71 number: summary.number,72 number: summary.number,
72 title: summary.title,73 title: summary.title,
73 url: summary.url,74 url: summary.url,
...@@ -86,7 +87,7 @@ export const make = Effect.gen(function* () {...@@ -86,7 +87,7 @@ export const make = Effect.gen(function* () {
86 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;87 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
8788
88 return SourceControlProvider.SourceControlProvider.of({89 return SourceControlProvider.SourceControlProvider.of({
89 kind: "azure-devops",90 kind: SourceControlProviderKind.make("azure-devops"),
90 listChangeRequests: (input) => {91 listChangeRequests: (input) => {
91 const source = SourceControlProvider.sourceControlRefFromInput(input);92 const source = SourceControlProvider.sourceControlRefFromInput(input);
92 return azure93 return azure
...@@ -102,7 +103,7 @@ export const make = Effect.gen(function* () {...@@ -102,7 +103,7 @@ export const make = Effect.gen(function* () {
102 Effect.mapError(103 Effect.mapError(
103 (error) =>104 (error) =>
104 new SourceControlProviderError({105 new SourceControlProviderError({
105 provider: "azure-devops",106 provider: SourceControlProviderKind.make("azure-devops"),
106 operation: "listChangeRequests",107 operation: "listChangeRequests",
107 command: error.command,108 command: error.command,
108 cwd: input.cwd,109 cwd: input.cwd,
...@@ -121,7 +122,7 @@ export const make = Effect.gen(function* () {...@@ -121,7 +122,7 @@ export const make = Effect.gen(function* () {
121 Effect.mapError(122 Effect.mapError(
122 (error) =>123 (error) =>
123 new SourceControlProviderError({124 new SourceControlProviderError({
124 provider: "azure-devops",125 provider: SourceControlProviderKind.make("azure-devops"),
125 operation: "getChangeRequest",126 operation: "getChangeRequest",
126 command: error.command,127 command: error.command,
127 cwd: input.cwd,128 cwd: input.cwd,
...@@ -149,7 +150,7 @@ export const make = Effect.gen(function* () {...@@ -149,7 +150,7 @@ export const make = Effect.gen(function* () {
149 Effect.mapError(150 Effect.mapError(
150 (error) =>151 (error) =>
151 new SourceControlProviderError({152 new SourceControlProviderError({
152 provider: "azure-devops",153 provider: SourceControlProviderKind.make("azure-devops"),
153 operation: "createChangeRequest",154 operation: "createChangeRequest",
154 command: error.command,155 command: error.command,
155 cwd: input.cwd,156 cwd: input.cwd,
...@@ -167,7 +168,7 @@ export const make = Effect.gen(function* () {...@@ -167,7 +168,7 @@ export const make = Effect.gen(function* () {
167 Effect.mapError(168 Effect.mapError(
168 (error) =>169 (error) =>
169 new SourceControlProviderError({170 new SourceControlProviderError({
170 provider: "azure-devops",171 provider: SourceControlProviderKind.make("azure-devops"),
171 operation: "getRepositoryCloneUrls",172 operation: "getRepositoryCloneUrls",
172 command: error.command,173 command: error.command,
173 cwd: input.cwd,174 cwd: input.cwd,
...@@ -184,7 +185,7 @@ export const make = Effect.gen(function* () {...@@ -184,7 +185,7 @@ export const make = Effect.gen(function* () {
184 Effect.mapError(185 Effect.mapError(
185 (error) =>186 (error) =>
186 new SourceControlProviderError({187 new SourceControlProviderError({
187 provider: "azure-devops",188 provider: SourceControlProviderKind.make("azure-devops"),
188 operation: "createRepository",189 operation: "createRepository",
189 command: error.command,190 command: error.command,
190 cwd: input.cwd,191 cwd: input.cwd,
...@@ -201,7 +202,7 @@ export const make = Effect.gen(function* () {...@@ -201,7 +202,7 @@ export const make = Effect.gen(function* () {
201 Effect.mapError(202 Effect.mapError(
202 (error) =>203 (error) =>
203 new SourceControlProviderError({204 new SourceControlProviderError({
204 provider: "azure-devops",205 provider: SourceControlProviderKind.make("azure-devops"),
205 operation: "getDefaultBranch",206 operation: "getDefaultBranch",
206 command: error.command,207 command: error.command,
207 cwd: input.cwd,208 cwd: input.cwd,
...@@ -221,7 +222,7 @@ export const make = Effect.gen(function* () {...@@ -221,7 +222,7 @@ export const make = Effect.gen(function* () {
221 Effect.mapError(222 Effect.mapError(
222 (error) =>223 (error) =>
223 new SourceControlProviderError({224 new SourceControlProviderError({
224 provider: "azure-devops",225 provider: SourceControlProviderKind.make("azure-devops"),
225 operation: "checkoutChangeRequest",226 operation: "checkoutChangeRequest",
226 command: error.command,227 command: error.command,
227 cwd: input.cwd,228 cwd: input.cwd,
packages/source-control-azure-devops/src/server/driver.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1/**2/**
2 * The Azure DevOps driver: repository operations, discovery, and pull requests through `az`.3 * The Azure DevOps driver: repository operations, discovery, and pull requests through `az`.
3 *4 *
...@@ -10,7 +11,7 @@ import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvide...@@ -10,7 +11,7 @@ import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvide
10import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";11import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
1112
12export const driver = defineSourceControlDriver({13export const driver = defineSourceControlDriver({
13 kind: "azure-devops",14 kind: SourceControlProviderKind.make("azure-devops"),
14 make: Effect.all({15 make: Effect.all({
15 sourceControl: AzureDevOpsSourceControlProvider.make,16 sourceControl: AzureDevOpsSourceControlProvider.make,
16 discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery),17 discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery),
packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts+4-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";3import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
34
...@@ -122,7 +123,7 @@ export const make = Effect.gen(function* () {...@@ -122,7 +123,7 @@ export const make = Effect.gen(function* () {
122 const fail =123 const fail =
123 (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) =>124 (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) =>
124 new PullRequestProviderError({125 new PullRequestProviderError({
125 provider: "bitbucket",126 provider: SourceControlProviderKind.make("bitbucket"),
126 operation,127 operation,
127 ...bitbucketProviderFailure(error),128 ...bitbucketProviderFailure(error),
128 // Every Bitbucket failure states its own fact; this names the operation around it, so129 // Every Bitbucket failure states its own fact; this names the operation around it, so
...@@ -157,7 +158,7 @@ export const make = Effect.gen(function* () {...@@ -157,7 +158,7 @@ export const make = Effect.gen(function* () {
157 };158 };
158159
159 const provider: PullRequestProviderApi = {160 const provider: PullRequestProviderApi = {
160 kind: "bitbucket",161 kind: SourceControlProviderKind.make("bitbucket"),
161 capabilities: CAPABILITIES,162 capabilities: CAPABILITIES,
162163
163 // Bitbucket credentials come from the server's environment rather than a checkout, so the164 // Bitbucket credentials come from the server's environment rather than a checkout, so the
...@@ -363,7 +364,7 @@ export const make = Effect.gen(function* () {...@@ -363,7 +364,7 @@ export const make = Effect.gen(function* () {
363 setReaction: () =>364 setReaction: () =>
364 Effect.fail(365 Effect.fail(
365 new PullRequestProviderError({366 new PullRequestProviderError({
366 provider: "bitbucket",367 provider: SourceControlProviderKind.make("bitbucket"),
367 operation: "setReaction",368 operation: "setReaction",
368 reason: "failed",369 reason: "failed",
369 detail: "Bitbucket does not support reactions.",370 detail: "Bitbucket does not support reactions.",
packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -36,7 +37,7 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", (...@@ -36,7 +37,7 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", (
36 });37 });
3738
38 assert.deepStrictEqual(changeRequest, {39 assert.deepStrictEqual(changeRequest, {
39 provider: "bitbucket",40 provider: SourceControlProviderKind.make("bitbucket"),
40 number: 42,41 number: 42,
41 title: "Add Bitbucket provider",42 title: "Add Bitbucket provider",
42 url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",43 url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts+11-10
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";3import * as Option from "effect/Option";
3import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";4import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
...@@ -9,7 +10,7 @@ import type { SourceControlApiDiscoverySpec } from "@t3tools/source-control-core...@@ -9,7 +10,7 @@ import type { SourceControlApiDiscoverySpec } from "@t3tools/source-control-core
910
10function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest {11function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest {
11 return {12 return {
12 provider: "bitbucket",13 provider: SourceControlProviderKind.make("bitbucket"),
13 number: summary.number,14 number: summary.number,
14 title: summary.title,15 title: summary.title,
15 url: summary.url,16 url: summary.url,
...@@ -34,7 +35,7 @@ export const make = Effect.gen(function* () {...@@ -34,7 +35,7 @@ export const make = Effect.gen(function* () {
34 const bitbucket = yield* BitbucketApi.BitbucketApi;35 const bitbucket = yield* BitbucketApi.BitbucketApi;
3536
36 return SourceControlProvider.SourceControlProvider.of({37 return SourceControlProvider.SourceControlProvider.of({
37 kind: "bitbucket",38 kind: SourceControlProviderKind.make("bitbucket"),
38 listChangeRequests: (input) => {39 listChangeRequests: (input) => {
39 const source = SourceControlProvider.sourceControlRefFromInput(input);40 const source = SourceControlProvider.sourceControlRefFromInput(input);
40 return bitbucket41 return bitbucket
...@@ -51,7 +52,7 @@ export const make = Effect.gen(function* () {...@@ -51,7 +52,7 @@ export const make = Effect.gen(function* () {
51 Effect.mapError(52 Effect.mapError(
52 (error) =>53 (error) =>
53 new SourceControlProviderError({54 new SourceControlProviderError({
54 provider: "bitbucket",55 provider: SourceControlProviderKind.make("bitbucket"),
55 operation: "listChangeRequests",56 operation: "listChangeRequests",
56 cwd: input.cwd,57 cwd: input.cwd,
57 reference: SourceControlProvider.transportSafeSourceControlErrorValue(58 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
...@@ -69,7 +70,7 @@ export const make = Effect.gen(function* () {...@@ -69,7 +70,7 @@ export const make = Effect.gen(function* () {
69 Effect.mapError(70 Effect.mapError(
70 (error) =>71 (error) =>
71 new SourceControlProviderError({72 new SourceControlProviderError({
72 provider: "bitbucket",73 provider: SourceControlProviderKind.make("bitbucket"),
73 operation: "getChangeRequest",74 operation: "getChangeRequest",
74 cwd: input.cwd,75 cwd: input.cwd,
75 reference: SourceControlProvider.transportSafeSourceControlErrorValue(76 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
...@@ -97,7 +98,7 @@ export const make = Effect.gen(function* () {...@@ -97,7 +98,7 @@ export const make = Effect.gen(function* () {
97 Effect.mapError(98 Effect.mapError(
98 (error) =>99 (error) =>
99 new SourceControlProviderError({100 new SourceControlProviderError({
100 provider: "bitbucket",101 provider: SourceControlProviderKind.make("bitbucket"),
101 operation: "createChangeRequest",102 operation: "createChangeRequest",
102 cwd: input.cwd,103 cwd: input.cwd,
103 reference: SourceControlProvider.transportSafeSourceControlErrorValue(104 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
...@@ -114,7 +115,7 @@ export const make = Effect.gen(function* () {...@@ -114,7 +115,7 @@ export const make = Effect.gen(function* () {
114 Effect.mapError(115 Effect.mapError(
115 (error) =>116 (error) =>
116 new SourceControlProviderError({117 new SourceControlProviderError({
117 provider: "bitbucket",118 provider: SourceControlProviderKind.make("bitbucket"),
118 operation: "getRepositoryCloneUrls",119 operation: "getRepositoryCloneUrls",
119 cwd: input.cwd,120 cwd: input.cwd,
120 repository: SourceControlProvider.transportSafeSourceControlErrorValue(121 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
...@@ -130,7 +131,7 @@ export const make = Effect.gen(function* () {...@@ -130,7 +131,7 @@ export const make = Effect.gen(function* () {
130 Effect.mapError(131 Effect.mapError(
131 (error) =>132 (error) =>
132 new SourceControlProviderError({133 new SourceControlProviderError({
133 provider: "bitbucket",134 provider: SourceControlProviderKind.make("bitbucket"),
134 operation: "createRepository",135 operation: "createRepository",
135 cwd: input.cwd,136 cwd: input.cwd,
136 repository: SourceControlProvider.transportSafeSourceControlErrorValue(137 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
...@@ -151,7 +152,7 @@ export const make = Effect.gen(function* () {...@@ -151,7 +152,7 @@ export const make = Effect.gen(function* () {
151 Effect.mapError(152 Effect.mapError(
152 (error) =>153 (error) =>
153 new SourceControlProviderError({154 new SourceControlProviderError({
154 provider: "bitbucket",155 provider: SourceControlProviderKind.make("bitbucket"),
155 operation: "getDefaultBranch",156 operation: "getDefaultBranch",
156 cwd: input.cwd,157 cwd: input.cwd,
157 detail: "Failed to get default branch.",158 detail: "Failed to get default branch.",
...@@ -171,7 +172,7 @@ export const make = Effect.gen(function* () {...@@ -171,7 +172,7 @@ export const make = Effect.gen(function* () {
171 Effect.mapError(172 Effect.mapError(
172 (error) =>173 (error) =>
173 new SourceControlProviderError({174 new SourceControlProviderError({
174 provider: "bitbucket",175 provider: SourceControlProviderKind.make("bitbucket"),
175 operation: "checkoutChangeRequest",176 operation: "checkoutChangeRequest",
176 cwd: input.cwd,177 cwd: input.cwd,
177 reference: SourceControlProvider.transportSafeSourceControlErrorValue(178 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
...@@ -190,7 +191,7 @@ export const makeDiscovery = Effect.gen(function* () {...@@ -190,7 +191,7 @@ export const makeDiscovery = Effect.gen(function* () {
190191
191 return {192 return {
192 type: "api",193 type: "api",
193 kind: "bitbucket",194 kind: SourceControlProviderKind.make("bitbucket"),
194 label: "Bitbucket",195 label: "Bitbucket",
195 installHint: "Add a Bitbucket token in Settings → Source Control.",196 installHint: "Add a Bitbucket token in Settings → Source Control.",
196 probeAuth: bitbucket.probeAuth,197 probeAuth: bitbucket.probeAuth,
packages/source-control-bitbucket/src/server/driver.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1/**2/**
2 * The Bitbucket Cloud driver: repository operations, discovery, and pull requests through the3 * The Bitbucket Cloud driver: repository operations, discovery, and pull requests through the
3 * Bitbucket REST API.4 * Bitbucket REST API.
...@@ -11,7 +12,7 @@ import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts...@@ -11,7 +12,7 @@ import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts
11import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";12import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
1213
13export const driver = defineSourceControlDriver({14export const driver = defineSourceControlDriver({
14 kind: "bitbucket",15 kind: SourceControlProviderKind.make("bitbucket"),
15 make: Effect.all({16 make: Effect.all({
16 sourceControl: BitbucketSourceControlProvider.make,17 sourceControl: BitbucketSourceControlProvider.make,
17 discovery: BitbucketSourceControlProvider.makeDiscovery,18 discovery: BitbucketSourceControlProvider.makeDiscovery,
packages/source-control-core/src/server/SourceControlRateLimit.test.ts+17-4
...@@ -1,10 +1,11 @@...@@ -1,10 +1,11 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as TestClock from "effect/testing/TestClock";4import * as TestClock from "effect/testing/TestClock";
45
5import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";6import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
67
7const github = { provider: "github" as const, host: "github.com" };8const github = { provider: SourceControlProviderKind.make("github"), host: "github.com" };
89
9it.effect("isolates cooldowns for verified credentials on the same host", () =>10it.effect("isolates cooldowns for verified credentials on the same host", () =>
10 Effect.gen(function* () {11 Effect.gen(function* () {
...@@ -41,7 +42,7 @@ it.effect("backs off repeated rate limits until a successful request", () =>...@@ -41,7 +42,7 @@ it.effect("backs off repeated rate limits until a successful request", () =>
41 const firstPause = yield* Effect.flip(limits.check(github));42 const firstPause = yield* Effect.flip(limits.check(github));
42 assert.deepInclude(firstPause, {43 assert.deepInclude(firstPause, {
43 _tag: "SourceControlRateLimitPausedError",44 _tag: "SourceControlRateLimitPausedError",
44 provider: "github",45 provider: SourceControlProviderKind.make("github"),
45 host: "github.com",46 host: "github.com",
46 retryAt: 30_000,47 retryAt: 30_000,
47 });48 });
...@@ -105,8 +106,20 @@ it.effect("keeps providers and hosts isolated", () =>...@@ -105,8 +106,20 @@ it.effect("keeps providers and hosts isolated", () =>
105 const lease = yield* limits.check(github);106 const lease = yield* limits.check(github);
106 yield* limits.recordRateLimit({ ...github, lease });107 yield* limits.recordRateLimit({ ...github, lease });
107108
108 assert.equal(yield* limits.check({ provider: "gitlab", host: "github.com" }), 0);109 assert.equal(
109 assert.equal(yield* limits.check({ provider: "github", host: "github.example.com" }), 0);110 yield* limits.check({
111 provider: SourceControlProviderKind.make("gitlab"),
112 host: "github.com",
113 }),
114 0,
115 );
116 assert.equal(
117 yield* limits.check({
118 provider: SourceControlProviderKind.make("github"),
119 host: "github.example.com",
120 }),
121 0,
122 );
110 }).pipe(Effect.provide(SourceControlRateLimit.layer)),123 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
111);124);
112125
packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts+4-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
3import * as Result from "effect/Result";4import * as Result from "effect/Result";
...@@ -67,7 +68,7 @@ export const make = Effect.gen(function* () {...@@ -67,7 +68,7 @@ export const make = Effect.gen(function* () {
67 const cli = yield* ForgejoCli.ForgejoCli;68 const cli = yield* ForgejoCli.ForgejoCli;
68 const failure = (operation: string, detail: string, cause?: unknown) =>69 const failure = (operation: string, detail: string, cause?: unknown) =>
69 new PullRequestProviderError({70 new PullRequestProviderError({
70 provider: "forgejo",71 provider: SourceControlProviderKind.make("forgejo"),
71 operation,72 operation,
72 reason: "failed",73 reason: "failed",
73 detail,74 detail,
...@@ -78,7 +79,7 @@ export const make = Effect.gen(function* () {...@@ -78,7 +79,7 @@ export const make = Effect.gen(function* () {
78 Effect.mapError(79 Effect.mapError(
79 (error) =>80 (error) =>
80 new PullRequestProviderError({81 new PullRequestProviderError({
81 provider: "forgejo",82 provider: SourceControlProviderKind.make("forgejo"),
82 operation: input.path,83 operation: input.path,
83 detail: error.detail,84 detail: error.detail,
84 cause: error,85 cause: error,
...@@ -203,7 +204,7 @@ export const make = Effect.gen(function* () {...@@ -203,7 +204,7 @@ export const make = Effect.gen(function* () {
203 const unsupported = (operation: string) =>204 const unsupported = (operation: string) =>
204 Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`));205 Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`));
205 const provider: PullRequestProviderApi = {206 const provider: PullRequestProviderApi = {
206 kind: "forgejo",207 kind: SourceControlProviderKind.make("forgejo"),
207 capabilities: CAPABILITIES,208 capabilities: CAPABILITIES,
208 getViewer,209 getViewer,
209 listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")(210 listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")(
packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts+25-8
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
...@@ -258,7 +259,11 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the...@@ -258,7 +259,11 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the
258 });259 });
259 const served = (baseUrl: string) => () =>260 const served = (baseUrl: string) => () =>
260 Effect.succeed({261 Effect.succeed({
261 provider: { kind: "forgejo" as const, name: "Forgejo / Gitea", baseUrl },262 provider: {
263 kind: SourceControlProviderKind.make("forgejo"),
264 name: "Forgejo / Gitea",
265 baseUrl,
266 },
262 remoteName: "origin",267 remoteName: "origin",
263 remoteUrl: "",268 remoteUrl: "",
264 });269 });
...@@ -271,7 +276,7 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the...@@ -271,7 +276,7 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the
271 }),276 }),
272 {277 {
273 ...identity("http://forge.test:3000/git/team/repo.git"),278 ...identity("http://forge.test:3000/git/team/repo.git"),
274 provider: "forgejo",279 provider: SourceControlProviderKind.make("forgejo"),
275 webUrl: "http://forge.test:3000/git/team/repo",280 webUrl: "http://forge.test:3000/git/team/repo",
276 },281 },
277 );282 );
...@@ -557,7 +562,7 @@ it.effect("discovers Forgejo accounts and retains the server port", () =>...@@ -557,7 +562,7 @@ it.effect("discovers Forgejo accounts and retains the server port", () =>
557 cwd: "/repo",562 cwd: "/repo",
558 context: {563 context: {
559 provider: {564 provider: {
560 kind: "unknown",565 kind: SourceControlProviderKind.make("unknown"),
561 name: "git.forgejo.local",566 name: "git.forgejo.local",
562 baseUrl: "https://git.forgejo.local",567 baseUrl: "https://git.forgejo.local",
563 },568 },
...@@ -577,7 +582,7 @@ it.effect("discovers Forgejo accounts and retains the server port", () =>...@@ -577,7 +582,7 @@ it.effect("discovers Forgejo accounts and retains the server port", () =>
577 ),582 ),
578 });583 });
579 assert.deepStrictEqual(refined, {584 assert.deepStrictEqual(refined, {
580 kind: "forgejo",585 kind: SourceControlProviderKind.make("forgejo"),
581 name: "Forgejo / Gitea",586 name: "Forgejo / Gitea",
582 baseUrl: "http://forgejo.local:3000",587 baseUrl: "http://forgejo.local:3000",
583 });588 });
...@@ -622,7 +627,11 @@ it.effect("does not choose a default Forgejo login across ambiguous SSH server p...@@ -622,7 +627,11 @@ it.effect("does not choose a default Forgejo login across ambiguous SSH server p
622 const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({627 const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
623 cwd: "/repo",628 cwd: "/repo",
624 context: {629 context: {
625 provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" },630 provider: {
631 kind: SourceControlProviderKind.make("unknown"),
632 name: "Forgejo",
633 baseUrl: "https://forgejo.local",
634 },
626 remoteName: "origin",635 remoteName: "origin",
627 remoteUrl: "git@forgejo.local:maria/project.git",636 remoteUrl: "git@forgejo.local:maria/project.git",
628 requestedHost: "forgejo.local:4000",637 requestedHost: "forgejo.local:4000",
...@@ -732,7 +741,11 @@ it.effect("routes mounted Forgejo repositories without repeating the mount in AP...@@ -732,7 +741,11 @@ it.effect("routes mounted Forgejo repositories without repeating the mount in AP
732 cwd: "/repo",741 cwd: "/repo",
733 repository: "forgejo/maria/project",742 repository: "forgejo/maria/project",
734 context: {743 context: {
735 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" },744 provider: {
745 kind: SourceControlProviderKind.make("forgejo"),
746 name: "Forgejo",
747 baseUrl: "https://code.test/forgejo",
748 },
736 remoteName: "origin",749 remoteName: "origin",
737 remoteUrl: "https://code.test/forgejo/maria/project.git",750 remoteUrl: "https://code.test/forgejo/maria/project.git",
738 },751 },
...@@ -744,7 +757,11 @@ it.effect("routes mounted Forgejo repositories without repeating the mount in AP...@@ -744,7 +757,11 @@ it.effect("routes mounted Forgejo repositories without repeating the mount in AP
744 cwd: "/repo",757 cwd: "/repo",
745 repository: "forgejo/project",758 repository: "forgejo/project",
746 context: {759 context: {
747 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" },760 provider: {
761 kind: SourceControlProviderKind.make("forgejo"),
762 name: "Forgejo",
763 baseUrl: "https://code.test/forgejo",
764 },
748 remoteName: "origin",765 remoteName: "origin",
749 remoteUrl: "ssh://git@code.test/forgejo/project.git",766 remoteUrl: "ssh://git@code.test/forgejo/project.git",
750 },767 },
...@@ -827,7 +844,7 @@ it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => {...@@ -827,7 +844,7 @@ it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => {
827 repository: "maria/project",844 repository: "maria/project",
828 context: {845 context: {
829 provider: {846 provider: {
830 kind: "forgejo",847 kind: SourceControlProviderKind.make("forgejo"),
831 name: "Forgejo",848 name: "Forgejo",
832 baseUrl: "http://forgejo.local:3000",849 baseUrl: "http://forgejo.local:3000",
833 },850 },
packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts+24-8
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
3import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
...@@ -18,7 +19,7 @@ const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError);...@@ -18,7 +19,7 @@ const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError);
1819
19export const discovery = {20export const discovery = {
20 type: "cli",21 type: "cli",
21 kind: "forgejo",22 kind: SourceControlProviderKind.make("forgejo"),
22 label: "Forgejo / Gitea",23 label: "Forgejo / Gitea",
23 executable: "tea",24 executable: "tea",
24 versionArgs: ["--version"],25 versionArgs: ["--version"],
...@@ -47,7 +48,13 @@ export const discovery = {...@@ -47,7 +48,13 @@ export const discovery = {
47 remote,48 remote,
48 input.context.requestedHost,49 input.context.requestedHost,
49 );50 );
50 return login ? { kind: "forgejo", name: "Forgejo / Gitea", baseUrl: login.url } : null;51 return login
52 ? {
53 kind: SourceControlProviderKind.make("forgejo"),
54 name: "Forgejo / Gitea",
55 baseUrl: login.url,
56 }
57 : null;
51 },58 },
52 installHint:59 installHint:
53 "Install `fj` 0.6 or later from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj --host <server-url> 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 "Install `fj` 0.6 or later from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj --host <server-url> 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* () {...@@ -60,7 +67,7 @@ export const makeDiscovery = Effect.gen(function* () {
60 if (!listLogins) return discovery;67 if (!listLogins) return discovery;
61 return {68 return {
62 type: "managed-cli",69 type: "managed-cli",
63 kind: "forgejo",70 kind: SourceControlProviderKind.make("forgejo"),
64 label: discovery.label,71 label: discovery.label,
65 installHint: discovery.installHint,72 installHint: discovery.installHint,
66 probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) {73 probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) {
...@@ -150,7 +157,12 @@ export const makeDiscovery = Effect.gen(function* () {...@@ -150,7 +157,12 @@ export const makeDiscovery = Effect.gen(function* () {
150 remoteUrl: input.context.remoteUrl,157 remoteUrl: input.context.remoteUrl,
151 }).pipe(Effect.orElseSucceed(() => []));158 }).pipe(Effect.orElseSucceed(() => []));
152 const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost);159 const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost);
153 if (login) return { kind: "forgejo" as const, name: discovery.label, baseUrl: login.url };160 if (login)
161 return {
162 kind: SourceControlProviderKind.make("forgejo"),
163 name: discovery.label,
164 baseUrl: login.url,
165 };
154 }166 }
155 return null;167 return null;
156 },168 },
...@@ -188,7 +200,7 @@ const refineRepositoryIdentity: NonNullable<...@@ -188,7 +200,7 @@ const refineRepositoryIdentity: NonNullable<
188 const context = yield* resolveContext({200 const context = yield* resolveContext({
189 cwd: identity.rootPath,201 cwd: identity.rootPath,
190 context: {202 context: {
191 provider: { kind: "unknown", name: "Unknown", baseUrl: "" },203 provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" },
192 remoteName: identity.locator.remoteName,204 remoteName: identity.locator.remoteName,
193 remoteUrl: identity.locator.remoteUrl,205 remoteUrl: identity.locator.remoteUrl,
194 },206 },
...@@ -200,7 +212,11 @@ const refineRepositoryIdentity: NonNullable<...@@ -200,7 +212,11 @@ const refineRepositoryIdentity: NonNullable<
200 !remote.ssh && basePath && remote.path.startsWith(`${basePath}/`)212 !remote.ssh && basePath && remote.path.startsWith(`${basePath}/`)
201 ? remote.path.slice(basePath.length + 1)213 ? remote.path.slice(basePath.length + 1)
202 : remote.path;214 : remote.path;
203 return { ...identity, provider: "forgejo", webUrl: `${baseUrl}/${path}` };215 return {
216 ...identity,
217 provider: SourceControlProviderKind.make("forgejo"),
218 webUrl: `${baseUrl}/${path}`,
219 };
204});220});
205221
206const RepositorySchema = Schema.Struct({222const RepositorySchema = Schema.Struct({
...@@ -245,7 +261,7 @@ export const make = Effect.gen(function* () {...@@ -245,7 +261,7 @@ export const make = Effect.gen(function* () {
245 Effect.mapError(261 Effect.mapError(
246 (cause: unknown) =>262 (cause: unknown) =>
247 new SourceControlProviderError({263 new SourceControlProviderError({
248 provider: "forgejo",264 provider: SourceControlProviderKind.make("forgejo"),
249 operation,265 operation,
250 cwd,266 cwd,
251 ...(isForgejoCliError(cause) ? { command: cause.command } : {}),267 ...(isForgejoCliError(cause) ? { command: cause.command } : {}),
...@@ -272,7 +288,7 @@ export const make = Effect.gen(function* () {...@@ -272,7 +288,7 @@ export const make = Effect.gen(function* () {
272 );288 );
273 });289 });
274 return SourceControlProvider.SourceControlProvider.of({290 return SourceControlProvider.SourceControlProvider.of({
275 kind: "forgejo",291 kind: SourceControlProviderKind.make("forgejo"),
276 repositoryNameFromRemoteUrl,292 repositoryNameFromRemoteUrl,
277 refineRepositoryIdentity,293 refineRepositoryIdentity,
278 listChangeRequests: (input) =>294 listChangeRequests: (input) =>
packages/source-control-forgejo/src/server/driver.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1/**2/**
2 * The Forgejo / Gitea driver: repository operations, discovery, and pull requests through3 * The Forgejo / Gitea driver: repository operations, discovery, and pull requests through
3 * `fj` or `tea`.4 * `fj` or `tea`.
...@@ -11,7 +12,7 @@ import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";...@@ -11,7 +12,7 @@ import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
11import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";12import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
1213
13export const driver = defineSourceControlDriver({14export const driver = defineSourceControlDriver({
14 kind: "forgejo",15 kind: SourceControlProviderKind.make("forgejo"),
15 make: Effect.all({16 make: Effect.all({
16 sourceControl: ForgejoSourceControlProvider.make,17 sourceControl: ForgejoSourceControlProvider.make,
17 discovery: ForgejoSourceControlProvider.makeDiscovery,18 discovery: ForgejoSourceControlProvider.makeDiscovery,
packages/source-control-forgejo/src/server/forgejoPullRequests.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
2import * as Option from "effect/Option";3import * as Option from "effect/Option";
3import type { ChangeRequest } from "@t3tools/contracts";4import type { ChangeRequest } from "@t3tools/contracts";
...@@ -26,7 +27,7 @@ export const ForgejoPullRequestSchema = Schema.Struct({...@@ -26,7 +27,7 @@ export const ForgejoPullRequestSchema = Schema.Struct({
26});27});
27export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type): ChangeRequest {28export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type): ChangeRequest {
28 return {29 return {
29 provider: "forgejo",30 provider: SourceControlProviderKind.make("forgejo"),
30 number: raw.number,31 number: raw.number,
31 title: raw.title,32 title: raw.title,
32 url: raw.html_url,33 url: raw.html_url,
packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts+4-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";3import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
...@@ -176,7 +177,7 @@ export const make = Effect.gen(function* () {...@@ -176,7 +177,7 @@ export const make = Effect.gen(function* () {
176177
177 const failure = (operation: string, detail: string, cause?: unknown) =>178 const failure = (operation: string, detail: string, cause?: unknown) =>
178 new PullRequestProviderError({179 new PullRequestProviderError({
179 provider: "gitcafe",180 provider: SourceControlProviderKind.make("gitcafe"),
180 operation,181 operation,
181 reason: "failed",182 reason: "failed",
182 detail,183 detail,
...@@ -207,7 +208,7 @@ export const make = Effect.gen(function* () {...@@ -207,7 +208,7 @@ export const make = Effect.gen(function* () {
207 Effect.mapError(208 Effect.mapError(
208 (error) =>209 (error) =>
209 new PullRequestProviderError({210 new PullRequestProviderError({
210 provider: "gitcafe",211 provider: SourceControlProviderKind.make("gitcafe"),
211 operation,212 operation,
212 reason: error.reason,213 reason: error.reason,
213 detail: error.detail,214 detail: error.detail,
...@@ -475,7 +476,7 @@ export const make = Effect.gen(function* () {...@@ -475,7 +476,7 @@ export const make = Effect.gen(function* () {
475 });476 });
476477
477 const provider: PullRequestProviderApi = {478 const provider: PullRequestProviderApi = {
478 kind: "gitcafe",479 kind: SourceControlProviderKind.make("gitcafe"),
479 capabilities: CAPABILITIES,480 capabilities: CAPABILITIES,
480 getViewer: (input) =>481 getViewer: (input) =>
481 read(482 read(
packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts+11-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1/**2/**
2 * Pins `./GitCafeSourceControlProvider.ts`, which pass 2 creates:3 * Pins `./GitCafeSourceControlProvider.ts`, which pass 2 creates:
3 *4 *
...@@ -87,7 +88,11 @@ describe("GitCafeSourceControlProvider", () => {...@@ -87,7 +88,11 @@ describe("GitCafeSourceControlProvider", () => {
87 );88 );
88 });89 });
89 const context = {90 const context = {
90 provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: `https://${host}` },91 provider: {
92 kind: SourceControlProviderKind.make("gitcafe"),
93 name: "GitCafe",
94 baseUrl: `https://${host}`,
95 },
91 remoteName: "origin",96 remoteName: "origin",
92 remoteUrl,97 remoteUrl,
93 };98 };
...@@ -220,7 +225,11 @@ describe("GitCafeSourceControlProvider", () => {...@@ -220,7 +225,11 @@ describe("GitCafeSourceControlProvider", () => {
220 remoteUrl === undefined225 remoteUrl === undefined
221 ? undefined226 ? undefined
222 : {227 : {
223 provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: "https://git.cafe" },228 provider: {
229 kind: SourceControlProviderKind.make("gitcafe"),
230 name: "GitCafe",
231 baseUrl: "https://git.cafe",
232 },
224 remoteName: "origin",233 remoteName: "origin",
225 remoteUrl,234 remoteUrl,
226 };235 };
packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts+5-4
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";3import * as Option from "effect/Option";
3import * as Result from "effect/Result";4import * as Result from "effect/Result";
...@@ -59,7 +60,7 @@ const cliFailure = (output: string) =>...@@ -59,7 +60,7 @@ const cliFailure = (output: string) =>
5960
60export const discovery = {61export const discovery = {
61 type: "cli",62 type: "cli",
62 kind: "gitcafe",63 kind: SourceControlProviderKind.make("gitcafe"),
63 label: "GitCafe",64 label: "GitCafe",
64 executable: "cafe",65 executable: "cafe",
65 versionArgs: ["--version"],66 versionArgs: ["--version"],
...@@ -150,7 +151,7 @@ export const make = Effect.gen(function* () {...@@ -150,7 +151,7 @@ export const make = Effect.gen(function* () {
150151
151 const error = (operation: string, cwd: string, detail: string, cause?: unknown) =>152 const error = (operation: string, cwd: string, detail: string, cause?: unknown) =>
152 new SourceControlProviderError({153 new SourceControlProviderError({
153 provider: "gitcafe",154 provider: SourceControlProviderKind.make("gitcafe"),
154 operation,155 operation,
155 cwd,156 cwd,
156 detail,157 detail,
...@@ -269,7 +270,7 @@ export const make = Effect.gen(function* () {...@@ -269,7 +270,7 @@ export const make = Effect.gen(function* () {
269 );270 );
270 const owner = target.repository.split("/")[0] ?? null;271 const owner = target.repository.split("/")[0] ?? null;
271 return {272 return {
272 provider: "gitcafe",273 provider: SourceControlProviderKind.make("gitcafe"),
273 number: pull.number,274 number: pull.number,
274 title: pull.title,275 title: pull.title,
275 url: `https://${target.host}/${target.repository}/pulls/${pull.number}`,276 url: `https://${target.host}/${target.repository}/pulls/${pull.number}`,
...@@ -290,7 +291,7 @@ export const make = Effect.gen(function* () {...@@ -290,7 +291,7 @@ export const make = Effect.gen(function* () {
290 });291 });
291292
292 return SourceControlProvider.SourceControlProvider.of({293 return SourceControlProvider.SourceControlProvider.of({
293 kind: "gitcafe",294 kind: SourceControlProviderKind.make("gitcafe"),
294 repositoryNameFromRemoteUrl: (url) => GitCafeHosts.parseGitCafeRemote(url)?.repository ?? null,295 repositoryNameFromRemoteUrl: (url) => GitCafeHosts.parseGitCafeRemote(url)?.repository ?? null,
295 getChangeRequest: getPull,296 getChangeRequest: getPull,
296 listChangeRequests: Effect.fn("GitCafeSourceControlProvider.listChangeRequests")(297 listChangeRequests: Effect.fn("GitCafeSourceControlProvider.listChangeRequests")(
packages/source-control-gitcafe/src/server/driver.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1/**2/**
2 * The GitCafe driver: repository operations and discovery through `cafe` and git, and pull3 * The GitCafe driver: repository operations and discovery through `cafe` and git, and pull
3 * requests through GitCafe's REST API on git.cafe and staging.git.cafe.4 * requests through GitCafe's REST API on git.cafe and staging.git.cafe.
...@@ -11,7 +12,7 @@ import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts";...@@ -11,7 +12,7 @@ import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts";
11import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts";12import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts";
1213
13export const driver = defineSourceControlDriver({14export const driver = defineSourceControlDriver({
14 kind: "gitcafe",15 kind: SourceControlProviderKind.make("gitcafe"),
15 make: Effect.all({16 make: Effect.all({
16 sourceControl: GitCafeSourceControlProvider.make,17 sourceControl: GitCafeSourceControlProvider.make,
17 discovery: Effect.succeed(GitCafeSourceControlProvider.discovery),18 discovery: Effect.succeed(GitCafeSourceControlProvider.discovery),
packages/source-control-github/src/server/GitHubApi.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Clock from "effect/Clock";2import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";3import * as Context from "effect/Context";
3import * as Data from "effect/Data";4import * as Data from "effect/Data";
...@@ -389,7 +390,7 @@ export const make = Effect.gen(function* () {...@@ -389,7 +390,7 @@ export const make = Effect.gen(function* () {
389 // this reads today is `core`; a `search/` read would need its own resource here. A refusal390 // this reads today is `core`; a `search/` read would need its own resource here. A refusal
390 // still pauses the whole host, the key PullRequestService records its own backoff under.391 // still pauses the whole host, the key PullRequestService records its own backoff under.
391 const resource = input.graphql === true ? "graphql" : "core";392 const resource = input.graphql === true ? "graphql" : "core";
392 const key = { provider: "github" as const, host };393 const key = { provider: SourceControlProviderKind.make("github"), host };
393 const run = Effect.gen(function* () {394 const run = Effect.gen(function* () {
394 const lease = yield* quota395 const lease = yield* quota
395 .admit(host, resource, { allowReserve: input.allowReserve })396 .admit(host, resource, { allowReserve: input.allowReserve })
packages/source-control-github/src/server/GitHubPullRequestProvider.ts+3-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage";2import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import type {4import type {
...@@ -202,7 +203,7 @@ export const make = Effect.gen(function* () {...@@ -202,7 +203,7 @@ export const make = Effect.gen(function* () {
202203
203 const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) =>204 const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) =>
204 new PullRequestProviderError({205 new PullRequestProviderError({
205 provider: "github",206 provider: SourceControlProviderKind.make("github"),
206 operation,207 operation,
207 ...gitHubProviderFailure(error),208 ...gitHubProviderFailure(error),
208 detail: error.message,209 detail: error.message,
...@@ -261,7 +262,7 @@ export const make = Effect.gen(function* () {...@@ -261,7 +262,7 @@ export const make = Effect.gen(function* () {
261 );262 );
262263
263 const provider: PullRequestProviderApi = {264 const provider: PullRequestProviderApi = {
264 kind: "github",265 kind: SourceControlProviderKind.make("github"),
265 capabilities: CAPABILITIES,266 capabilities: CAPABILITIES,
266 mergeMessageRewrite: removeAgentCredits,267 mergeMessageRewrite: removeAgentCredits,
267 getRoutingIdentity: (input) =>268 getRoutingIdentity: (input) =>
packages/source-control-github/src/server/GitHubQuota.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Clock from "effect/Clock";2import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";3import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -86,7 +87,7 @@ const make = Effect.gen(function* () {...@@ -86,7 +87,7 @@ const make = Effect.gen(function* () {
86 const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO;87 const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO;
87 if (snapshot.remaining >= floor) return;88 if (snapshot.remaining >= floor) return;
88 return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({89 return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({
89 provider: "github",90 provider: SourceControlProviderKind.make("github"),
90 host: host.trim().toLowerCase(),91 host: host.trim().toLowerCase(),
91 retryAt: snapshot.resetAtMs,92 retryAt: snapshot.resetAtMs,
92 });93 });
packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts+12-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as HostProcess from "@t3tools/shared/HostProcess";2import * as HostProcess from "@t3tools/shared/HostProcess";
2import { assert, describe, it } from "@effect/vitest";3import { assert, describe, it } from "@effect/vitest";
3import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -137,7 +138,11 @@ const makeProvider = (api: Partial<GitHubApi.GitHubApi["Service"]>) =>...@@ -137,7 +138,11 @@ const makeProvider = (api: Partial<GitHubApi.GitHubApi["Service"]>) =>
137 GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer));138 GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer));
138139
139const githubContext = (host: string) => ({140const githubContext = (host: string) => ({
140 provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` },141 provider: {
142 kind: SourceControlProviderKind.make("github"),
143 name: "GitHub",
144 baseUrl: `https://${host}`,
145 },
141 remoteName: "origin",146 remoteName: "origin",
142 remoteUrl: `git@${host}:acme/web.git`,147 remoteUrl: `git@${host}:acme/web.git`,
143});148});
...@@ -1067,7 +1072,7 @@ it.live.each([...@@ -1067,7 +1072,7 @@ it.live.each([
1067 cwd: "/repo",1072 cwd: "/repo",
1068 context: {1073 context: {
1069 provider: {1074 provider: {
1070 kind: "github" as const,1075 kind: SourceControlProviderKind.make("github"),
1071 name: "GitHub Self-Hosted",1076 name: "GitHub Self-Hosted",
1072 baseUrl: "https://code.example.test",1077 baseUrl: "https://code.example.test",
1073 },1078 },
...@@ -1184,7 +1189,11 @@ it.effect.each([...@@ -1184,7 +1189,11 @@ it.effect.each([
1184 const input = {1189 const input = {
1185 cwd: "/repo",1190 cwd: "/repo",
1186 context: {1191 context: {
1187 provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` },1192 provider: {
1193 kind: SourceControlProviderKind.make("github"),
1194 name: "GitHub",
1195 baseUrl: `https://${host}`,
1196 },
1188 remoteName: "origin",1197 remoteName: "origin",
1189 remoteUrl: `git@${host}:me/web.git`,1198 remoteUrl: `git@${host}:me/web.git`,
1190 },1199 },
packages/source-control-github/src/server/GitHubSourceControlProvider.ts+8-7
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Context from "effect/Context";2import * as Context from "effect/Context";
2import * as Data from "effect/Data";3import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -62,7 +63,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect(...@@ -62,7 +63,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect(
62);63);
6364
64function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest {65function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest {
65 return { provider: "github", ...record };66 return { provider: SourceControlProviderKind.make("github"), ...record };
66}67}
6768
68function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) {69function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) {
...@@ -164,7 +165,7 @@ export function parseGitHubAuth(...@@ -164,7 +165,7 @@ export function parseGitHubAuth(
164165
165export const discovery = {166export const discovery = {
166 type: "cli",167 type: "cli",
167 kind: "github",168 kind: SourceControlProviderKind.make("github"),
168 label: "GitHub",169 label: "GitHub",
169 executable: "gh",170 executable: "gh",
170 versionArgs: ["--version"],171 versionArgs: ["--version"],
...@@ -271,7 +272,7 @@ export const makeDiscovery = Effect.gen(function* () {...@@ -271,7 +272,7 @@ export const makeDiscovery = Effect.gen(function* () {
271 Effect.map((known) =>272 Effect.map((known) =>
272 known273 known
273 ? ({274 ? ({
274 kind: "github",275 kind: SourceControlProviderKind.make("github"),
275 name: "GitHub Self-Hosted",276 name: "GitHub Self-Hosted",
276 baseUrl: context.provider.baseUrl,277 baseUrl: context.provider.baseUrl,
277 } as const)278 } as const)
...@@ -878,7 +879,7 @@ export const make = Effect.gen(function* () {...@@ -878,7 +879,7 @@ export const make = Effect.gen(function* () {
878 ) =>879 ) =>
879 (error: GitHubFailure) =>880 (error: GitHubFailure) =>
880 new SourceControlProviderError({881 new SourceControlProviderError({
881 provider: "github",882 provider: SourceControlProviderKind.make("github"),
882 operation,883 operation,
883 cwd,884 cwd,
884 ...(context?.reference === undefined885 ...(context?.reference === undefined
...@@ -915,7 +916,7 @@ export const make = Effect.gen(function* () {...@@ -915,7 +916,7 @@ export const make = Effect.gen(function* () {
915 Effect.mapError(916 Effect.mapError(
916 (cause) =>917 (cause) =>
917 new SourceControlProviderError({918 new SourceControlProviderError({
918 provider: "github",919 provider: SourceControlProviderKind.make("github"),
919 operation: "resolveLink",920 operation: "resolveLink",
920 cwd: input.cwd,921 cwd: input.cwd,
921 detail: "The linked subject could not be read.",922 detail: "The linked subject could not be read.",
...@@ -927,7 +928,7 @@ export const make = Effect.gen(function* () {...@@ -927,7 +928,7 @@ export const make = Effect.gen(function* () {
927 Effect.mapError(928 Effect.mapError(
928 (cause) =>929 (cause) =>
929 new SourceControlProviderError({930 new SourceControlProviderError({
930 provider: "github",931 provider: SourceControlProviderKind.make("github"),
931 operation: "resolveLink.decode",932 operation: "resolveLink.decode",
932 cwd: input.cwd,933 cwd: input.cwd,
933 detail: "The linked subject could not be read.",934 detail: "The linked subject could not be read.",
...@@ -939,7 +940,7 @@ export const make = Effect.gen(function* () {...@@ -939,7 +940,7 @@ export const make = Effect.gen(function* () {
939 });940 });
940941
941 return SourceControlProvider.SourceControlProvider.of({942 return SourceControlProvider.SourceControlProvider.of({
942 kind: "github",943 kind: SourceControlProviderKind.make("github"),
943 // `gh pr list --head` filters on the head ref name alone and accepts anything, so an944 // `gh pr list --head` filters on the head ref name alone and accepts anything, so an
944 // `owner:branch` or `remote:branch` selector silently lists zero pull requests while945 // `owner:branch` or `remote:branch` selector silently lists zero pull requests while
945 // spending a GraphQL call; the bare branch is always among the selectors. Without the owner,946 // spending a GraphQL call; the bare branch is always among the selectors. Without the owner,
packages/source-control-github/src/server/driver.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1/**2/**
2 * The GitHub driver: repository operations and discovery through `gh` and git, and pull requests3 * The GitHub driver: repository operations and discovery through `gh` and git, and pull requests
3 * through the GitHub REST and GraphQL APIs.4 * through the GitHub REST and GraphQL APIs.
...@@ -11,7 +12,7 @@ import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";...@@ -11,7 +12,7 @@ import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
11import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";12import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1213
13export const driver = defineSourceControlDriver({14export const driver = defineSourceControlDriver({
14 kind: "github",15 kind: SourceControlProviderKind.make("github"),
15 make: Effect.all({16 make: Effect.all({
16 sourceControl: GitHubSourceControlProvider.make,17 sourceControl: GitHubSourceControlProvider.make,
17 discovery: GitHubSourceControlProvider.makeDiscovery,18 discovery: GitHubSourceControlProvider.makeDiscovery,
packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts+3-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import type {3import type {
3 PullRequestCapabilities,4 PullRequestCapabilities,
...@@ -111,7 +112,7 @@ export const make = Effect.gen(function* () {...@@ -111,7 +112,7 @@ export const make = Effect.gen(function* () {
111112
112 const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) =>113 const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) =>
113 new PullRequestProviderError({114 new PullRequestProviderError({
114 provider: "gitlab",115 provider: SourceControlProviderKind.make("gitlab"),
115 operation,116 operation,
116 ...gitLabProviderFailure(error),117 ...gitLabProviderFailure(error),
117 detail: error.detail,118 detail: error.detail,
...@@ -119,7 +120,7 @@ export const make = Effect.gen(function* () {...@@ -119,7 +120,7 @@ export const make = Effect.gen(function* () {
119 });120 });
120121
121 const provider: PullRequestProviderApi = {122 const provider: PullRequestProviderApi = {
122 kind: "gitlab",123 kind: SourceControlProviderKind.make("gitlab"),
123 capabilities: CAPABILITIES,124 capabilities: CAPABILITIES,
124125
125 getViewer: (input) =>126 getViewer: (input) =>
packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts+3-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -38,7 +39,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () =...@@ -38,7 +39,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () =
38 });39 });
3940
40 assert.deepStrictEqual(changeRequest, {41 assert.deepStrictEqual(changeRequest, {
41 provider: "gitlab",42 provider: SourceControlProviderKind.make("gitlab"),
42 number: 42,43 number: 42,
43 title: "Add GitLab provider",44 title: "Add GitLab provider",
44 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",45 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
...@@ -189,7 +190,7 @@ it("refines unknown GitLab remotes with mixed-case provider hosts", () => {...@@ -189,7 +190,7 @@ it("refines unknown GitLab remotes with mixed-case provider hosts", () => {
189 cwd: "/repo",190 cwd: "/repo",
190 context: {191 context: {
191 provider: {192 provider: {
192 kind: "unknown",193 kind: SourceControlProviderKind.make("unknown"),
193 name: "Self-Hosted.Example.Test",194 name: "Self-Hosted.Example.Test",
194 baseUrl: "https://Self-Hosted.Example.Test",195 baseUrl: "https://Self-Hosted.Example.Test",
195 },196 },
packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts+14-13
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";4import * as Option from "effect/Option";
...@@ -25,7 +26,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect(...@@ -25,7 +26,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect(
2526
26function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest {27function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest {
27 return {28 return {
28 provider: "gitlab",29 provider: SourceControlProviderKind.make("gitlab"),
29 number: summary.number,30 number: summary.number,
30 title: summary.title,31 title: summary.title,
31 url: summary.url,32 url: summary.url,
...@@ -90,7 +91,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn...@@ -90,7 +91,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn
90 }91 }
9192
92 return {93 return {
93 kind: "gitlab",94 kind: SourceControlProviderKind.make("gitlab"),
94 name: "GitLab Self-Hosted",95 name: "GitLab Self-Hosted",
95 baseUrl: input.context.provider.baseUrl,96 baseUrl: input.context.provider.baseUrl,
96 } as const;97 } as const;
...@@ -98,7 +99,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn...@@ -98,7 +99,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn
9899
99export const discovery = {100export const discovery = {
100 type: "cli",101 type: "cli",
101 kind: "gitlab",102 kind: SourceControlProviderKind.make("gitlab"),
102 label: "GitLab",103 label: "GitLab",
103 executable: "glab",104 executable: "glab",
104 versionArgs: ["--version"],105 versionArgs: ["--version"],
...@@ -127,7 +128,7 @@ export const make = Effect.gen(function* () {...@@ -127,7 +128,7 @@ export const make = Effect.gen(function* () {
127 Effect.mapError(128 Effect.mapError(
128 (cause) =>129 (cause) =>
129 new SourceControlProviderError({130 new SourceControlProviderError({
130 provider: "gitlab",131 provider: SourceControlProviderKind.make("gitlab"),
131 operation: "resolveLink",132 operation: "resolveLink",
132 cwd: input.cwd,133 cwd: input.cwd,
133 detail: "The linked subject could not be read.",134 detail: "The linked subject could not be read.",
...@@ -139,7 +140,7 @@ export const make = Effect.gen(function* () {...@@ -139,7 +140,7 @@ export const make = Effect.gen(function* () {
139 Effect.mapError(140 Effect.mapError(
140 (cause) =>141 (cause) =>
141 new SourceControlProviderError({142 new SourceControlProviderError({
142 provider: "gitlab",143 provider: SourceControlProviderKind.make("gitlab"),
143 operation: "resolveLink.decode",144 operation: "resolveLink.decode",
144 cwd: input.cwd,145 cwd: input.cwd,
145 detail: "The linked subject could not be read.",146 detail: "The linked subject could not be read.",
...@@ -151,7 +152,7 @@ export const make = Effect.gen(function* () {...@@ -151,7 +152,7 @@ export const make = Effect.gen(function* () {
151 });152 });
152153
153 return SourceControlProvider.SourceControlProvider.of({154 return SourceControlProvider.SourceControlProvider.of({
154 kind: "gitlab",155 kind: SourceControlProviderKind.make("gitlab"),
155 resolveLink: (input) => {156 resolveLink: (input) => {
156 // Automatic enrichment must not send ambient CLI credentials to a host from message text.157 // Automatic enrichment must not send ambient CLI credentials to a host from message text.
157 if (input.url.host !== "gitlab.com") return undefined;158 if (input.url.host !== "gitlab.com") return undefined;
...@@ -179,7 +180,7 @@ export const make = Effect.gen(function* () {...@@ -179,7 +180,7 @@ export const make = Effect.gen(function* () {
179 Effect.mapError(180 Effect.mapError(
180 (error) =>181 (error) =>
181 new SourceControlProviderError({182 new SourceControlProviderError({
182 provider: "gitlab",183 provider: SourceControlProviderKind.make("gitlab"),
183 operation: "listChangeRequests",184 operation: "listChangeRequests",
184 command: error.command,185 command: error.command,
185 cwd: input.cwd,186 cwd: input.cwd,
...@@ -198,7 +199,7 @@ export const make = Effect.gen(function* () {...@@ -198,7 +199,7 @@ export const make = Effect.gen(function* () {
198 Effect.mapError(199 Effect.mapError(
199 (error) =>200 (error) =>
200 new SourceControlProviderError({201 new SourceControlProviderError({
201 provider: "gitlab",202 provider: SourceControlProviderKind.make("gitlab"),
202 operation: "getChangeRequest",203 operation: "getChangeRequest",
203 command: error.command,204 command: error.command,
204 cwd: input.cwd,205 cwd: input.cwd,
...@@ -226,7 +227,7 @@ export const make = Effect.gen(function* () {...@@ -226,7 +227,7 @@ export const make = Effect.gen(function* () {
226 Effect.mapError(227 Effect.mapError(
227 (error) =>228 (error) =>
228 new SourceControlProviderError({229 new SourceControlProviderError({
229 provider: "gitlab",230 provider: SourceControlProviderKind.make("gitlab"),
230 operation: "createChangeRequest",231 operation: "createChangeRequest",
231 command: error.command,232 command: error.command,
232 cwd: input.cwd,233 cwd: input.cwd,
...@@ -244,7 +245,7 @@ export const make = Effect.gen(function* () {...@@ -244,7 +245,7 @@ export const make = Effect.gen(function* () {
244 Effect.mapError(245 Effect.mapError(
245 (error) =>246 (error) =>
246 new SourceControlProviderError({247 new SourceControlProviderError({
247 provider: "gitlab",248 provider: SourceControlProviderKind.make("gitlab"),
248 operation: "getRepositoryCloneUrls",249 operation: "getRepositoryCloneUrls",
249 command: error.command,250 command: error.command,
250 cwd: input.cwd,251 cwd: input.cwd,
...@@ -261,7 +262,7 @@ export const make = Effect.gen(function* () {...@@ -261,7 +262,7 @@ export const make = Effect.gen(function* () {
261 Effect.mapError(262 Effect.mapError(
262 (error) =>263 (error) =>
263 new SourceControlProviderError({264 new SourceControlProviderError({
264 provider: "gitlab",265 provider: SourceControlProviderKind.make("gitlab"),
265 operation: "createRepository",266 operation: "createRepository",
266 command: error.command,267 command: error.command,
267 cwd: input.cwd,268 cwd: input.cwd,
...@@ -278,7 +279,7 @@ export const make = Effect.gen(function* () {...@@ -278,7 +279,7 @@ export const make = Effect.gen(function* () {
278 Effect.mapError(279 Effect.mapError(
279 (error) =>280 (error) =>
280 new SourceControlProviderError({281 new SourceControlProviderError({
281 provider: "gitlab",282 provider: SourceControlProviderKind.make("gitlab"),
282 operation: "getDefaultBranch",283 operation: "getDefaultBranch",
283 command: error.command,284 command: error.command,
284 cwd: input.cwd,285 cwd: input.cwd,
...@@ -292,7 +293,7 @@ export const make = Effect.gen(function* () {...@@ -292,7 +293,7 @@ export const make = Effect.gen(function* () {
292 Effect.mapError(293 Effect.mapError(
293 (error) =>294 (error) =>
294 new SourceControlProviderError({295 new SourceControlProviderError({
295 provider: "gitlab",296 provider: SourceControlProviderKind.make("gitlab"),
296 operation: "checkoutChangeRequest",297 operation: "checkoutChangeRequest",
297 command: error.command,298 command: error.command,
298 cwd: input.cwd,299 cwd: input.cwd,
packages/source-control-gitlab/src/server/driver.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1/**2/**
2 * The GitLab driver: repository operations, discovery, and merge requests through `glab`.3 * The GitLab driver: repository operations, discovery, and merge requests through `glab`.
3 *4 *
...@@ -10,7 +11,7 @@ import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";...@@ -10,7 +11,7 @@ import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";
10import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";11import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
1112
12export const driver = defineSourceControlDriver({13export const driver = defineSourceControlDriver({
13 kind: "gitlab",14 kind: SourceControlProviderKind.make("gitlab"),
14 make: Effect.all({15 make: Effect.all({
15 sourceControl: GitLabSourceControlProvider.make,16 sourceControl: GitLabSourceControlProvider.make,
16 discovery: Effect.succeed(GitLabSourceControlProvider.discovery),17 discovery: Effect.succeed(GitLabSourceControlProvider.discovery),