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 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { MaterialListRow } from "../../components/MaterialListRow";
23import { SettingsScreen } from "../settings/components/SettingsScreen";
34import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
......@@ -812,7 +813,7 @@ export function AddProjectRepositoryScreen(props: {
812813 const result = await lookupRepositoryQuery({
813814 environmentId: environment.environmentId,
814815 input: {
815 provider,
816 provider: SourceControlProviderKind.make(provider),
816817 repository: repositoryInput.trim(),
817818 },
818819 });
......@@ -1079,7 +1080,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
10791080 environmentId: environment.environmentId,
10801081 input: {
10811082 cwd: workspaceRoot,
1082 provider: "github",
1083 provider: SourceControlProviderKind.make("github"),
10831084 repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot),
10841085 visibility: "private",
10851086 },
apps/mobile/src/state/use-thread-pr.test.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { ProjectId, type ThreadPullRequestLink, type VcsStatusResult } from "@t3tools/contracts";
23import { describe, expect, it } from "vite-plus/test";
34
......@@ -28,7 +29,7 @@ describe("presentThreadPr", () => {
2829 it("uses merge-request terminology for GitLab", () => {
2930 expect(
3031 presentThreadPr(pullRequest, {
31 kind: "gitlab",
32 kind: SourceControlProviderKind.make("gitlab"),
3233 name: "GitLab",
3334 baseUrl: "https://gitlab.com",
3435 }),
apps/server/src/git/GitManager.test.ts+15-5
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12// @effect-diagnostics nodeBuiltinImport:off
23import * as NodeFS from "node:fs";
34import * as NodePath from "node:path";
......@@ -410,7 +411,13 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
410411 const ghCalls: string[] = [];
411412
412413 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
415422 /** `operation` is the provider method the call answers, which a failure reports. */
416423 const execute = (input: {
......@@ -534,7 +541,7 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
534541 /** What GitHubSourceControlProvider makes of a pull request it read. */
535542 const toChangeRequest = (summary: FakePullRequestSummary): ChangeRequest => ({
536543 ...summary,
537 provider: "github",
544 provider: SourceControlProviderKind.make("github"),
538545 state: summary.state ?? "open",
539546 closedAt: summary.closedAt ?? null,
540547 mergedAt: summary.mergedAt ?? null,
......@@ -546,7 +553,7 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
546553
547554 return {
548555 service: {
549 kind: "github",
556 kind: SourceControlProviderKind.make("github"),
550557 // The GitHub provider's own lookup rule and template convention, which GitManager reads
551558 // instead of the kind.
552559 headBranchProbe: ({ headSelectors }) => ({
......@@ -602,7 +609,10 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
602609 if (raw.length === 0) return [];
603610 const decoded = decodeGitHubPullRequestListJson(raw);
604611 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 }))
606616 : [];
607617 }),
608618 ),
......@@ -2841,7 +2851,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
28412851 operation: "lookupStatusPr",
28422852 branch: "feature/status-rate-limited",
28432853 errorTag: "SourceControlProviderError",
2844 provider: "github",
2854 provider: SourceControlProviderKind.make("github"),
28452855 providerOperation: "listChangeRequests",
28462856 errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.",
28472857 });
apps/server/src/git/GitManager.ts+7-2
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Arr from "effect/Array";
23import * as Cache from "effect/Cache";
34import * as Context from "effect/Context";
......@@ -1787,7 +1788,9 @@ export const make = Effect.gen(function* () {
17871788 ) {
17881789 const terms = yield* sourceControlProvider(cwd).pipe(
17891790 Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)),
1790 Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")),
1791 Effect.orElseSucceed(() =>
1792 getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")),
1793 ),
17911794 );
17921795 const summary = summarizeGitActionResult(result, terms);
17931796 let latestOpenPr: PullRequestInfo | null = null;
......@@ -2853,7 +2856,9 @@ export const make = Effect.gen(function* () {
28532856 const changeRequestTerms = wantsPr
28542857 ? yield* sourceControlProvider(input.cwd).pipe(
28552858 Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)),
2856 Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")),
2859 Effect.orElseSucceed(() =>
2860 getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")),
2861 ),
28572862 )
28582863 : null;
28592864
apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts+4-3
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Stream from "effect/Stream";
23import {
34 EventId,
......@@ -148,7 +149,7 @@ function makeSummary(
148149 overrides: Partial<PullRequestSummary> = {},
149150): PullRequestSummary {
150151 return {
151 provider: "github",
152 provider: SourceControlProviderKind.make("github"),
152153 projectId: input.projectId,
153154 repository: input.repository,
154155 number: input.number,
......@@ -1009,7 +1010,7 @@ describe("PullRequestSyncReactor", () => {
10091010 Effect.gen(function* () {
10101011 if (input.host === "github.com" && (yield* Clock.currentTimeMillis) < retryAt) {
10111012 const paused = new PullRequestProviderError({
1012 provider: "github",
1013 provider: SourceControlProviderKind.make("github"),
10131014 operation: "getChangeRequestSummary",
10141015 reason: "rate-limited",
10151016 detail: "paused",
......@@ -1079,7 +1080,7 @@ describe("PullRequestSyncReactor", () => {
10791080 operation: "stack",
10801081 detail: "paused",
10811082 cause: new PullRequestProviderError({
1082 provider: "github",
1083 provider: SourceControlProviderKind.make("github"),
10831084 operation: "getChangeRequestStack",
10841085 reason: "rate-limited",
10851086 detail: "paused",
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { assert, describe, expect, it } from "@effect/vitest";
23import * as DateTime from "effect/DateTime";
34import {
......@@ -472,7 +473,7 @@ function makePullRequestSummary(input: {
472473 readonly updatedAt?: string;
473474}): PullRequestSummary {
474475 return {
475 provider: "github",
476 provider: SourceControlProviderKind.make("github"),
476477 projectId: input.projectId,
477478 repository: input.repository,
478479 number: input.number,
apps/server/src/orchestration-v2/runtimeLayer.test.ts+4-3
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts";
23import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
34import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
......@@ -169,7 +170,7 @@ const watchedPullRequestDetail = (input: {
169170 readonly number: number;
170171 readonly at: string;
171172}): PullRequestDetail => ({
172 provider: "github",
173 provider: SourceControlProviderKind.make("github"),
173174 capabilities: {
174175 diff: true,
175176 comment: true,
......@@ -2572,7 +2573,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
25722573 operation: "getChangeRequest",
25732574 detail: "github requests are paused until the rate limit resets",
25742575 cause: new PullRequestProviderError({
2575 provider: "github",
2576 provider: SourceControlProviderKind.make("github"),
25762577 operation: "getChangeRequest",
25772578 reason: "rate-limited",
25782579 detail: "paused",
......@@ -2831,7 +2832,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
28312832 operation: "watchFingerprint",
28322833 detail: "paused",
28332834 cause: new PullRequestProviderError({
2834 provider: "github",
2835 provider: SourceControlProviderKind.make("github"),
28352836 operation: "getChangeRequestWatchFingerprint",
28362837 reason: "rate-limited",
28372838 detail: "paused",
apps/server/src/project/ProjectCloneTracker.test.ts+3-2
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { describe, expect, it } from "@effect/vitest";
23import {
34 OrchestrationDispatchCommandError,
......@@ -116,7 +117,7 @@ describe("ProjectCloneTracker", () => {
116117 ? Effect.fail(
117118 new SourceControlRepositoryError({
118119 operation: "cloneRepository",
119 provider: "unknown",
120 provider: SourceControlProviderKind.make("unknown"),
120121 detail: "fatal: repository not found",
121122 }),
122123 )
......@@ -255,7 +256,7 @@ describe("ProjectCloneTracker", () => {
255256 Effect.fail(
256257 new SourceControlRepositoryError({
257258 operation: "cloneRepository",
258 provider: "unknown",
259 provider: SourceControlProviderKind.make("unknown"),
259260 detail: "Destination path already exists and is not empty.",
260261 }),
261262 ),
apps/server/src/project/ProjectCloneTracker.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import type {
23 ProjectCloneSnapshot,
34 ProjectCloneStage,
......@@ -301,7 +302,7 @@ export const make = Effect.gen(function* () {
301302 if (!claimed) {
302303 return yield* new SourceControlRepositoryError({
303304 operation: "cloneRepository",
304 provider: input.provider ?? "unknown",
305 provider: input.provider ?? SourceControlProviderKind.make("unknown"),
305306 detail: "A clone into this destination is already in progress.",
306307 });
307308 }
apps/server/src/project/RepositoryIdentityResolver.test.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not.
23import * as NodeFS from "node:fs";
34import * as NodeServices from "@effect/platform-node/NodeServices";
......@@ -71,7 +72,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
7172 if (refinementFails)
7273 return Effect.fail(
7374 new SourceControlProviderError({
74 provider: "forgejo",
75 provider: SourceControlProviderKind.make("forgejo"),
7576 operation: "detectProvider",
7677 cwd: rootPath,
7778 detail: "account unavailable",
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { assert, it } from "@effect/vitest";
23
34import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";
......@@ -59,7 +60,7 @@ it("keeps GitHub's exact retry time", () => {
5960 assert.deepStrictEqual(
6061 gitHubProviderFailure(
6162 new SourceControlRateLimit.SourceControlRateLimitPausedError({
62 provider: "github",
63 provider: SourceControlProviderKind.make("github"),
6364 host: "github.com",
6465 retryAt: 1_786_802_400_000,
6566 }),
apps/server/src/pullRequest/PullRequestService.test.ts+295-238
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as NodeServices from "@effect/platform-node/NodeServices";
23import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
34import * as KeyValueStore from "effect/persistence/KeyValueStore";
......@@ -17,7 +18,6 @@ import type {
1718 ProjectId,
1819 PullRequestReviewCapabilities,
1920 PullRequestReviewerCapabilities,
20 SourceControlProviderKind,
2121} from "@t3tools/contracts";
2222import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings";
2323import type { ServerSettings } from "@t3tools/contracts";
......@@ -129,7 +129,7 @@ it.effect("caches narrow previews and invalidates them after refresh or mutation
129129 const service = yield* makeService({
130130 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
131131 providers: [
132 fakeProvider("github", {
132 fakeProvider(SourceControlProviderKind.make("github"), {
133133 getViewer: () => Effect.die("preview must not read the viewer"),
134134 getChangeRequestPreview: () =>
135135 Effect.sync(() => {
......@@ -175,13 +175,13 @@ it.effect("keeps cached previews available and pauses uncached previews until qu
175175 const service = yield* makeService({
176176 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
177177 providers: [
178 fakeProvider("github", {
178 fakeProvider(SourceControlProviderKind.make("github"), {
179179 getChangeRequestPreview: ({ number }) =>
180180 Effect.gen(function* () {
181181 reads++;
182182 if (reads === 2)
183183 return yield* new PullRequestProviderError({
184 provider: "github",
184 provider: SourceControlProviderKind.make("github"),
185185 operation: "getChangeRequestPreview",
186186 reason: "rate-limited",
187187 detail: "GitHub requests are paused until the rate limit resets.",
......@@ -218,7 +218,7 @@ it.effect("reuses only unexpired detail for previews", () =>
218218 const service = yield* makeService({
219219 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
220220 providers: [
221 fakeProvider("github", {
221 fakeProvider(SourceControlProviderKind.make("github"), {
222222 getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")),
223223 getChangeRequestPreview: () =>
224224 Effect.sync(() => {
......@@ -250,7 +250,7 @@ it.effect("shares detail and activity for a minute, and for ten once merged", ()
250250 const service = yield* makeService({
251251 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
252252 providers: [
253 fakeProvider("github", {
253 fakeProvider(SourceControlProviderKind.make("github"), {
254254 getChangeRequest: ({ number }) =>
255255 Effect.sync(() => {
256256 reads.push(`detail #${number}`);
......@@ -314,7 +314,7 @@ it.effect("does not wait for an in-flight detail read to display a preview", ()
314314 const service = yield* makeService({
315315 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
316316 providers: [
317 fakeProvider("github", {
317 fakeProvider(SourceControlProviderKind.make("github"), {
318318 getChangeRequest: () =>
319319 Deferred.succeed(detailStarted, undefined).pipe(
320320 Effect.andThen(Deferred.await(releaseDetail)),
......@@ -342,7 +342,7 @@ it.effect("keeps previews warm when another project finishes a turn", () =>
342342 project({ id: "p2", title: "docs", workspaceRoot: "/d", repository: "acme/docs" }),
343343 ],
344344 providers: [
345 fakeProvider("github", {
345 fakeProvider(SourceControlProviderKind.make("github"), {
346346 getChangeRequestPreview: (input) =>
347347 Effect.sync(() => {
348348 reads.push(input.repository);
......@@ -374,11 +374,11 @@ it.effect("uses full detail for hosts without a narrow preview", () =>
374374 title: "web",
375375 workspaceRoot: "/w",
376376 repository: "acme/web",
377 provider: "gitlab",
377 provider: SourceControlProviderKind.make("gitlab"),
378378 }),
379379 ],
380380 providers: [
381 fakeProvider("gitlab", {
381 fakeProvider(SourceControlProviderKind.make("gitlab"), {
382382 getChangeRequest: () =>
383383 Effect.sync(() => {
384384 reads += 1;
......@@ -405,7 +405,7 @@ function unusable(provider: SourceControlProviderKind, reason: "missing-tool" |
405405}
406406
407407const requestFailed = new PullRequestProviderError({
408 provider: "github",
408 provider: SourceControlProviderKind.make("github"),
409409 operation: "listChangeRequests",
410410 reason: "failed",
411411 detail: "HTTP 404",
......@@ -530,12 +530,12 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos
530530 title: "enterprise",
531531 workspaceRoot: "/repo",
532532 repository: "team/project",
533 provider: "unknown",
533 provider: SourceControlProviderKind.make("unknown"),
534534 host: "code.example.test",
535535 }),
536536 ],
537537 providers: [
538 fakeProvider("github", {
538 fakeProvider(SourceControlProviderKind.make("github"), {
539539 /** Supplies a PR only after verifying discovery retained the Enterprise repository target. */
540540 listChangeRequests: ({ host, repository }) => {
541541 assert.strictEqual(host, "code.example.test");
......@@ -554,7 +554,11 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos
554554 return Effect.succeed({
555555 context: {
556556 ...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 },
558562 },
559563 provider: undefined as never,
560564 });
......@@ -576,7 +580,7 @@ it.effect("refines unknown self-hosted GitLab projects before listing merge requ
576580 title: "self-hosted",
577581 workspaceRoot: "/gitlab",
578582 repository: "group/project",
579 provider: "unknown",
583 provider: SourceControlProviderKind.make("unknown"),
580584 host: "code.example.test",
581585 });
582586 const service = yield* makeService({
......@@ -584,12 +588,15 @@ it.effect("refines unknown self-hosted GitLab projects before listing merge requ
584588 selfHosted,
585589 { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/gitlab-worktree" },
586590 ],
587 providers: [fakeProvider("gitlab")],
591 providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))],
588592 resolveHandle: ({ context }) => {
589593 refinementCalls += 1;
590594 assert.strictEqual(context?.remoteUrl, "https://code.example.test/group/project.git");
591595 return Effect.succeed({
592 context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } },
596 context: {
597 ...context!,
598 provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") },
599 },
593600 provider: undefined as never,
594601 });
595602 },
......@@ -610,7 +617,7 @@ it.effect("derives a legacy repository host after refining its provider", () =>
610617 title: "legacy self-hosted",
611618 workspaceRoot: "/gitlab",
612619 repository: "group/project",
613 provider: "unknown",
620 provider: SourceControlProviderKind.make("unknown"),
614621 host: "code.example.test",
615622 });
616623 const identity = current.repositoryIdentity!;
......@@ -625,10 +632,13 @@ it.effect("derives a legacy repository host after refining its provider", () =>
625632 } as unknown as OrchestrationProjectShell;
626633 const service = yield* makeService({
627634 projects: [legacy],
628 providers: [fakeProvider("gitlab")],
635 providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))],
629636 resolveHandle: ({ context }) =>
630637 Effect.succeed({
631 context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } },
638 context: {
639 ...context!,
640 provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") },
641 },
632642 provider: undefined as never,
633643 }),
634644 });
......@@ -648,18 +658,21 @@ it.effect("tries another checkout when provider refinement remains unknown", ()
648658 title: "self-hosted",
649659 workspaceRoot: "/gone",
650660 repository: "group/project",
651 provider: "unknown",
661 provider: SourceControlProviderKind.make("unknown"),
652662 host: "code.example.test",
653663 });
654664 const service = yield* makeService({
655665 projects: [selfHosted, { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/healthy" }],
656 providers: [fakeProvider("gitlab")],
666 providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))],
657667 resolveHandle: ({ cwd, context }) => {
658668 asked.push(cwd);
659669 return cwd === "/gone"
660670 ? Effect.succeed({ context: context!, provider: undefined as never })
661671 : Effect.succeed({
662 context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } },
672 context: {
673 ...context!,
674 provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") },
675 },
663676 provider: undefined as never,
664677 });
665678 },
......@@ -689,11 +702,11 @@ it.effect("reads nothing from a host with no implementation, but reports it", ()
689702 title: "on gitlab",
690703 workspaceRoot: "/c",
691704 repository: "group/project",
692 provider: "gitlab",
705 provider: SourceControlProviderKind.make("gitlab"),
693706 }),
694707 ],
695708 providers: [
696 fakeProvider("github", {
709 fakeProvider(SourceControlProviderKind.make("github"), {
697710 listChangeRequests: (input) => {
698711 listed.push(input.repository);
699712 return Effect.succeed({
......@@ -718,8 +731,8 @@ it.effect("reads nothing from a host with no implementation, but reports it", ()
718731 projectCount: summary.projectCount,
719732 })),
720733 [
721 { kind: "github", configured: true, projectCount: 1 },
722 { kind: "gitlab", configured: false, projectCount: 1 },
734 { kind: SourceControlProviderKind.make("github"), configured: true, projectCount: 1 },
735 { kind: SourceControlProviderKind.make("gitlab"), configured: false, projectCount: 1 },
723736 ],
724737 );
725738 }),
......@@ -733,7 +746,7 @@ it.effect("asks for a whole page of a host, and for the reader's own size when g
733746 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
734747 ],
735748 providers: [
736 fakeProvider("github", {
749 fakeProvider(SourceControlProviderKind.make("github"), {
737750 listChangeRequests: (input) => {
738751 limits.push(input.limit);
739752 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
759772 project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }),
760773 ],
761774 providers: [
762 fakeProvider("github", {
775 fakeProvider(SourceControlProviderKind.make("github"), {
763776 listChangeRequests: ({ repository }) =>
764777 Effect.succeed({
765778 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", ()
787800 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
788801 ],
789802 providers: [
790 fakeProvider("github", {
803 fakeProvider(SourceControlProviderKind.make("github"), {
791804 listChangeRequests: () =>
792805 Effect.succeed({
793806 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"
815828 title: "web",
816829 workspaceRoot: "/a",
817830 repository: "acme/web",
818 provider: "azure-devops",
831 provider: SourceControlProviderKind.make("azure-devops"),
819832 host: "dev.azure.com",
820833 }),
821834 ],
822835 providers: [
823 fakeProvider("azure-devops", {
836 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
824837 listChangeRequests: () =>
825838 Effect.succeed({
826839 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", () =>
851864 project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }),
852865 ],
853866 providers: [
854 fakeProvider("github", {
867 fakeProvider(SourceControlProviderKind.make("github"), {
855868 listChangeRequests: (input) => {
856869 listed.push(input.repository);
857870 cursors.push(input.cursor);
......@@ -882,7 +895,7 @@ it.effect("keeps a row already sent at the boundary instant from arriving twice"
882895 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
883896 ],
884897 providers: [
885 fakeProvider("github", {
898 fakeProvider(SourceControlProviderKind.make("github"), {
886899 // The boundary instant is asked for inclusively, so the host hands back the rows
887900 // already sent at it alongside the ones beside them — which a strictly-older read
888901 // would have lost instead.
......@@ -922,7 +935,7 @@ it.effect("keeps the earlier exclusions when a slice ends on the instant it bega
922935 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
923936 ],
924937 providers: [
925 fakeProvider("github", {
938 fakeProvider(SourceControlProviderKind.make("github"), {
926939 listChangeRequests: () =>
927940 Effect.succeed({
928941 items: [
......@@ -960,7 +973,9 @@ it.effect("refuses a continuation it did not issue, before asking any host anyth
960973 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
961974 ],
962975 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 }),
964979 ],
965980 });
966981
......@@ -983,11 +998,11 @@ it.effect("calls a transient viewer failure a failed operation, not a signed-out
983998 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
984999 ],
9851000 providers: [
986 fakeProvider("github", {
1001 fakeProvider(SourceControlProviderKind.make("github"), {
9871002 getViewer: () =>
9881003 Effect.fail(
9891004 new PullRequestProviderError({
990 provider: "github",
1005 provider: SourceControlProviderKind.make("github"),
9911006 operation: "getViewer",
9921007 reason: "failed",
9931008 detail: "HTTP 500",
......@@ -1014,23 +1029,24 @@ it.effect("reports an unusable host over a merely failing one", () =>
10141029 title: "on gitlab",
10151030 workspaceRoot: "/c",
10161031 repository: "group/project",
1017 provider: "gitlab",
1032 provider: SourceControlProviderKind.make("gitlab"),
10181033 }),
10191034 ],
10201035 providers: [
1021 fakeProvider("github", {
1036 fakeProvider(SourceControlProviderKind.make("github"), {
10221037 getViewer: () =>
10231038 Effect.fail(
10241039 new PullRequestProviderError({
1025 provider: "github",
1040 provider: SourceControlProviderKind.make("github"),
10261041 operation: "getViewer",
10271042 reason: "failed",
10281043 detail: "HTTP 500",
10291044 }),
10301045 ),
10311046 }),
1032 fakeProvider("gitlab", {
1033 getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")),
1047 fakeProvider(SourceControlProviderKind.make("gitlab"), {
1048 getViewer: () =>
1049 Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")),
10341050 }),
10351051 ],
10361052 });
......@@ -1052,11 +1068,11 @@ it.effect("lists every host that has an implementation", () =>
10521068 title: "on gitlab",
10531069 workspaceRoot: "/b",
10541070 repository: "group/sub/project",
1055 provider: "gitlab",
1071 provider: SourceControlProviderKind.make("gitlab"),
10561072 }),
10571073 ],
10581074 providers: [
1059 fakeProvider("github", {
1075 fakeProvider(SourceControlProviderKind.make("github"), {
10601076 listChangeRequests: () =>
10611077 Effect.succeed({
10621078 items: [changeRequest(1, "2026-07-01T00:00:00Z")],
......@@ -1064,7 +1080,7 @@ it.effect("lists every host that has an implementation", () =>
10641080 continues: true,
10651081 }),
10661082 }),
1067 fakeProvider("gitlab", {
1083 fakeProvider(SourceControlProviderKind.make("gitlab"), {
10681084 listChangeRequests: (input) =>
10691085 // Nested groups need the full path, not the last two segments.
10701086 input.repository === "group/sub/project"
......@@ -1100,12 +1116,14 @@ it.effect("narrows the listing to one host when asked", () =>
11001116 title: "on gitlab",
11011117 workspaceRoot: "/b",
11021118 repository: "group/project",
1103 provider: "gitlab",
1119 provider: SourceControlProviderKind.make("gitlab"),
11041120 }),
11051121 ],
11061122 providers: [
1107 fakeProvider("github", { listChangeRequests: () => Effect.die("should not be read") }),
1108 fakeProvider("gitlab", {
1123 fakeProvider(SourceControlProviderKind.make("github"), {
1124 listChangeRequests: () => Effect.die("should not be read"),
1125 }),
1126 fakeProvider(SourceControlProviderKind.make("gitlab"), {
11091127 listChangeRequests: () =>
11101128 Effect.succeed({
11111129 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", ()
11391157 }),
11401158 ],
11411159 providers: [
1142 fakeProvider("github", {
1160 fakeProvider(SourceControlProviderKind.make("github"), {
11431161 listChangeRequests: ({ host }) =>
11441162 Effect.succeed({
11451163 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", () =>
11791197 title: "on gitlab",
11801198 workspaceRoot: "/b",
11811199 repository: "group/project",
1182 provider: "gitlab",
1200 provider: SourceControlProviderKind.make("gitlab"),
11831201 }),
11841202 ],
11851203 providers: [
1186 fakeProvider("github", {
1204 fakeProvider(SourceControlProviderKind.make("github"), {
11871205 listChangeRequests: () =>
11881206 Effect.succeed({
11891207 items: [changeRequest(1, "2026-07-01T00:00:00Z")],
......@@ -1191,8 +1209,9 @@ it.effect("keeps one host listed when another is not set up", () =>
11911209 continues: true,
11921210 }),
11931211 }),
1194 fakeProvider("gitlab", {
1195 getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")),
1212 fakeProvider(SourceControlProviderKind.make("gitlab"), {
1213 getViewer: () =>
1214 Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")),
11961215 }),
11971216 ],
11981217 });
......@@ -1220,8 +1239,9 @@ it.effect("fails as unavailable only when no host can be read", () =>
12201239 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
12211240 ],
12221241 providers: [
1223 fakeProvider("github", {
1224 getViewer: () => Effect.fail(unusable("github", "missing-tool")),
1242 fakeProvider(SourceControlProviderKind.make("github"), {
1243 getViewer: () =>
1244 Effect.fail(unusable(SourceControlProviderKind.make("github"), "missing-tool")),
12251245 }),
12261246 ],
12271247 });
......@@ -1250,7 +1270,7 @@ it.effect("reads a repository once when several worktrees share it", () =>
12501270 }),
12511271 ],
12521272 providers: [
1253 fakeProvider("github", {
1273 fakeProvider(SourceControlProviderKind.make("github"), {
12541274 listChangeRequests: () => {
12551275 calls += 1;
12561276 return Effect.succeed({
......@@ -1278,7 +1298,7 @@ it.effect("keeps healthy repositories when one of them cannot be read", () =>
12781298 project({ id: "p2", title: "broken", workspaceRoot: "/b", repository: "pingdotgg/broken" }),
12791299 ],
12801300 providers: [
1281 fakeProvider("github", {
1301 fakeProvider(SourceControlProviderKind.make("github"), {
12821302 listChangeRequests: (input) =>
12831303 input.repository === "pingdotgg/broken"
12841304 ? Effect.fail(requestFailed)
......@@ -1309,11 +1329,11 @@ it.effect("tries another workspace on the same host for the viewer", () =>
13091329 project({ id: "p2", title: "healthy", workspaceRoot: "/healthy", repository: "acme/two" }),
13101330 ],
13111331 providers: [
1312 fakeProvider("github", {
1332 fakeProvider(SourceControlProviderKind.make("github"), {
13131333 getViewer: (input) =>
13141334 input.cwd === "/healthy"
13151335 ? Effect.succeed("bilal")
1316 : Effect.fail(unusable("github", "missing-tool")),
1336 : Effect.fail(unusable(SourceControlProviderKind.make("github"), "missing-tool")),
13171337 listChangeRequests: () =>
13181338 Effect.succeed({
13191339 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
13451365 }),
13461366 ],
13471367 providers: [
1348 fakeProvider("github", {
1368 fakeProvider(SourceControlProviderKind.make("github"), {
13491369 getRoutingIdentity: (input) => {
13501370 assert.deepStrictEqual(input, { cwd: "/a", host: "github.example.test" });
13511371 return Effect.succeed({
......@@ -1359,7 +1379,7 @@ it.effect("routing verifies the current account on the requested host without ca
13591379 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
13601380 assert.deepStrictEqual(yield* service.routing(ref), {
13611381 host: "github.example.test",
1362 provider: "github",
1382 provider: SourceControlProviderKind.make("github"),
13631383 viewer: "first-account",
13641384 accountId: "123",
13651385 projectTitle: "web",
......@@ -1382,7 +1402,7 @@ it.effect("refuses an action the host never claimed it could run", () =>
13821402 const service = yield* makeService({
13831403 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
13841404 providers: [
1385 fakeProvider("github", {
1405 fakeProvider(SourceControlProviderKind.make("github"), {
13861406 capabilities: {
13871407 diff: true,
13881408 comment: true,
......@@ -1428,12 +1448,12 @@ it.effect("publishes a merge for immediate settlement only after host confirmati
14281448 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
14291449 ],
14301450 providers: [
1431 fakeProvider("github", {
1451 fakeProvider(SourceControlProviderKind.make("github"), {
14321452 getChangeRequestSummary: () =>
14331453 confirmationFails
14341454 ? Effect.fail(
14351455 new PullRequestProviderError({
1436 provider: "github",
1456 provider: SourceControlProviderKind.make("github"),
14371457 operation: "getChangeRequestSummary",
14381458 reason: "failed",
14391459 detail: "HTTP 504",
......@@ -1486,7 +1506,7 @@ it.effect("refreshes every reader before a queued merge confirmation finishes",
14861506 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
14871507 ],
14881508 providers: [
1489 fakeProvider("github", {
1509 fakeProvider(SourceControlProviderKind.make("github"), {
14901510 getChangeRequestSummary: () =>
14911511 Effect.gen(function* () {
14921512 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
15281548 const service = yield* makeService({
15291549 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
15301550 providers: [
1531 fakeProvider("github", {
1551 fakeProvider(SourceControlProviderKind.make("github"), {
15321552 // The host merges; this account only reads it, and opened the change request — which
15331553 // is every contributor to a repository they do not own.
15341554 getViewerPermissions: () =>
......@@ -1565,7 +1585,7 @@ it.effect("gates arming a merge for later exactly as it gates merging now", () =
15651585 const service = yield* makeService({
15661586 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
15671587 providers: [
1568 fakeProvider("github", {
1588 fakeProvider(SourceControlProviderKind.make("github"), {
15691589 capabilities: {
15701590 diff: true,
15711591 comment: true,
......@@ -1620,7 +1640,7 @@ it.effect("hands the host the strategy an armed merge was asked for", () =>
16201640 const service = yield* makeService({
16211641 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
16221642 providers: [
1623 fakeProvider("github", {
1643 fakeProvider(SourceControlProviderKind.make("github"), {
16241644 capabilities: {
16251645 diff: true,
16261646 comment: true,
......@@ -1666,7 +1686,7 @@ it.effect("refuses an auto-merge the host never claimed, without asking it", ()
16661686 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
16671687 providers: [
16681688 // Bitbucket's shape: it merges, and has nothing that merges later on its own.
1669 fakeProvider("github", {
1689 fakeProvider(SourceControlProviderKind.make("github"), {
16701690 runAction: () => {
16711691 ran = true;
16721692 return Effect.void;
......@@ -1694,7 +1714,7 @@ it.effect("refuses to resolve a conversation this viewer may not, without asking
16941714 const service = yield* makeService({
16951715 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
16961716 providers: [
1697 fakeProvider("github", {
1717 fakeProvider(SourceControlProviderKind.make("github"), {
16981718 getViewerPermissions: () =>
16991719 Effect.succeed({
17001720 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"
17291749 const service = yield* makeService({
17301750 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
17311751 providers: [
1732 fakeProvider("github", {
1752 fakeProvider(SourceControlProviderKind.make("github"), {
17331753 capabilities: {
17341754 diff: true,
17351755 comment: true,
......@@ -1768,7 +1788,7 @@ it.effect("refuses a comment on a host that cannot post one", () =>
17681788 const service = yield* makeService({
17691789 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
17701790 providers: [
1771 fakeProvider("github", {
1791 fakeProvider(SourceControlProviderKind.make("github"), {
17721792 capabilities: {
17731793 diff: false,
17741794 comment: false,
......@@ -1817,7 +1837,7 @@ it.effect("keeps two hosts of one provider kind as two accounts", () =>
18171837 }),
18181838 ],
18191839 providers: [
1820 fakeProvider("github", {
1840 fakeProvider(SourceControlProviderKind.make("github"), {
18211841 getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"),
18221842 listChangeRequests: () =>
18231843 Effect.succeed({
......@@ -1858,11 +1878,11 @@ it.effect("reports repositories on a host that could not be read", () =>
18581878 }),
18591879 ],
18601880 providers: [
1861 fakeProvider("github", {
1881 fakeProvider(SourceControlProviderKind.make("github"), {
18621882 getViewer: (input) =>
18631883 input.cwd === "/cloud"
18641884 ? Effect.succeed("bilal")
1865 : Effect.fail(unusable("github", "unauthenticated")),
1885 : Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")),
18661886 listChangeRequests: () =>
18671887 Effect.succeed({
18681888 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
18931913 project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }),
18941914 ],
18951915 providers: [
1896 fakeProvider("github", {
1916 fakeProvider(SourceControlProviderKind.make("github"), {
18971917 listChangeRequests: () =>
18981918 Effect.sync(() => {
18991919 listCalls += 1;
......@@ -1901,7 +1921,7 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail
19011921 Effect.andThen(
19021922 Effect.fail(
19031923 new PullRequestProviderError({
1904 provider: "github",
1924 provider: SourceControlProviderKind.make("github"),
19051925 operation: "listChangeRequests",
19061926 reason: "rate-limited",
19071927 detail: "GitHub API rate limit exceeded.",
......@@ -1933,13 +1953,14 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail
19331953 }),
19341954);
19351955
1936for (const [provider, host] of [
1956for (const [providerName, host] of [
19371957 ["github", "github.com"],
19381958 ["gitlab", "gitlab.com"],
19391959 ["forgejo", "code.example.test"],
19401960 ["bitbucket", "bitbucket.org"],
19411961 ["azure-devops", "dev.azure.com"],
19421962] as const) {
1963 const provider = SourceControlProviderKind.make(providerName);
19431964 it.effect.each(["detail", "checks"] as const)(
19441965 `shares fresh ${provider} %s reads and respects rate-limit resets`,
19451966 (read) =>
......@@ -2006,7 +2027,7 @@ it.effect("does not fall back to full detail when checks are unsupported", () =>
20062027 project({ id: "p1", title: "web", workspaceRoot: "/repo", repository: "acme/web" }),
20072028 ],
20082029 providers: [
2009 fakeProvider("github", {
2030 fakeProvider(SourceControlProviderKind.make("github"), {
20102031 getChangeRequest: () => Effect.die("Unexpected full detail read"),
20112032 }),
20122033 ],
......@@ -2025,7 +2046,7 @@ it.effect("uses a manual rate limit to pause later reads", () =>
20252046 project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }),
20262047 ],
20272048 providers: [
2028 fakeProvider("github", {
2049 fakeProvider(SourceControlProviderKind.make("github"), {
20292050 listChangeRequests: () =>
20302051 Effect.sync(() => {
20312052 listCalls += 1;
......@@ -2034,7 +2055,7 @@ it.effect("uses a manual rate limit to pause later reads", () =>
20342055 runAction: () =>
20352056 Effect.fail(
20362057 new PullRequestProviderError({
2037 provider: "github",
2058 provider: SourceControlProviderKind.make("github"),
20382059 operation: "runAction",
20392060 reason: "rate-limited",
20402061 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
20662087 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
20672088 ],
20682089 providers: [
2069 fakeProvider("github", {
2090 fakeProvider(SourceControlProviderKind.make("github"), {
20702091 listChangeRequests: () =>
20712092 Effect.succeed({
20722093 items: [
......@@ -2099,7 +2120,7 @@ it.effect("refuses a repository that does not belong to the requested project",
20992120 projects: [
21002121 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
21012122 ],
2102 providers: [fakeProvider("github")],
2123 providers: [fakeProvider(SourceControlProviderKind.make("github"))],
21032124 });
21042125
21052126 const error = yield* service
......@@ -2116,7 +2137,7 @@ it.effect("caches stack membership separately from action details", () =>
21162137 const service = yield* makeService({
21172138 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
21182139 providers: [
2119 fakeProvider("github", {
2140 fakeProvider(SourceControlProviderKind.make("github"), {
21202141 getChangeRequestStack: (input) =>
21212142 Effect.sync(() => {
21222143 reads.push(input.includeDetails === true);
......@@ -2157,7 +2178,7 @@ it.effect("reads a host-native stack through the provider and null where it has
21572178 const service = yield* makeService({
21582179 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
21592180 providers: [
2160 fakeProvider("github", {
2181 fakeProvider(SourceControlProviderKind.make("github"), {
21612182 getChangeRequestStack: () =>
21622183 Effect.succeed({
21632184 id: "9",
......@@ -2185,7 +2206,7 @@ it.effect("reads a host-native stack through the provider and null where it has
21852206
21862207 const withoutStacks = yield* makeService({
21872208 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2188 providers: [fakeProvider("github")],
2209 providers: [fakeProvider(SourceControlProviderKind.make("github"))],
21892210 });
21902211 assert.isNull(
21912212 yield* withoutStacks.stack({
......@@ -2215,7 +2236,7 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after
22152236 }),
22162237 ],
22172238 providers: [
2218 fakeProvider("forgejo", {
2239 fakeProvider(SourceControlProviderKind.make("forgejo"), {
22192240 getViewer: (input) => {
22202241 viewers.push(input.host);
22212242 assert.strictEqual(input.host, "code.example:3000");
......@@ -2244,7 +2265,11 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after
22442265 return Effect.succeed({
22452266 context: {
22462267 ...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 },
22482273 },
22492274 provider: undefined as never,
22502275 });
......@@ -2294,12 +2319,12 @@ it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () =>
22942319 title: "http",
22952320 workspaceRoot: "/http",
22962321 repository: "team/repo",
2297 provider: "forgejo",
2322 provider: SourceControlProviderKind.make("forgejo"),
22982323 host: "code.example",
22992324 remoteUrl: "http://code.example:4000/team/repo.git",
23002325 }),
23012326 ],
2302 providers: [fakeProvider("forgejo")],
2327 providers: [fakeProvider(SourceControlProviderKind.make("forgejo"))],
23032328 });
23042329 const failure = yield* service
23052330 .summary(
......@@ -2327,7 +2352,7 @@ it.effect("resolves a project's repository identity when its shell has none cach
23272352 const service = yield* makeService({
23282353 projects: [{ ...resolved, repositoryIdentity: null }],
23292354 providers: [
2330 fakeProvider("github", {
2355 fakeProvider(SourceControlProviderKind.make("github"), {
23312356 getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")),
23322357 }),
23332358 ],
......@@ -2351,7 +2376,7 @@ it.effect("routes a hosted reference to another repository through a project on
23512376 project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),
23522377 ],
23532378 providers: [
2354 fakeProvider("github", {
2379 fakeProvider(SourceControlProviderKind.make("github"), {
23552380 getChangeRequestSummary: (input) =>
23562381 Effect.sync(() => {
23572382 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
23812406 title: organization,
23822407 workspaceRoot: `/${organization}`,
23832408 repository: `${organization}/project/_git/web`,
2384 provider: "azure-devops",
2409 provider: SourceControlProviderKind.make("azure-devops"),
23852410 host: "dev.azure.com",
23862411 }),
23872412 ),
23882413 providers: [
2389 fakeProvider("azure-devops", {
2414 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
23902415 getChangeRequestSummary: (input) =>
23912416 Effect.sync(() => {
23922417 seen.push(`read ${input.cwd} ${input.repository}`);
......@@ -2434,7 +2459,7 @@ it.effect.each([
24342459 id: "target",
24352460 title: "target",
24362461 workspaceRoot: "/target",
2437 provider: "azure-devops",
2462 provider: SourceControlProviderKind.make("azure-devops"),
24382463 ...checkout,
24392464 });
24402465 const service = yield* makeService({
......@@ -2444,7 +2469,7 @@ it.effect.each([
24442469 id: repository,
24452470 title: repository,
24462471 workspaceRoot: `/${repository}`,
2447 provider: "azure-devops",
2472 provider: SourceControlProviderKind.make("azure-devops"),
24482473 host: "dev.azure.com",
24492474 repository,
24502475 }),
......@@ -2452,7 +2477,7 @@ it.effect.each([
24522477 target,
24532478 ],
24542479 providers: [
2455 fakeProvider("azure-devops", {
2480 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
24562481 getChangeRequestSummary: (input) =>
24572482 Effect.sync(() => {
24582483 seen.push(`read ${input.cwd} ${input.repository}`);
......@@ -2486,12 +2511,12 @@ it.effect("refuses Azure cross-organization reads and writes without its checkou
24862511 title: "org-a",
24872512 workspaceRoot: "/org-a",
24882513 repository: "org-a/project/_git/web",
2489 provider: "azure-devops",
2514 provider: SourceControlProviderKind.make("azure-devops"),
24902515 host: "dev.azure.com",
24912516 }),
24922517 ],
24932518 providers: [
2494 fakeProvider("azure-devops", {
2519 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
24952520 getChangeRequestSummary: () => Effect.die("must not read the wrong organization"),
24962521 runAction: () => Effect.die("must not modify the wrong organization"),
24972522 }),
......@@ -2518,7 +2543,7 @@ it.effect("refuses a hosted reference when nothing is checked out from that host
25182543 projects: [
25192544 project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),
25202545 ],
2521 providers: [fakeProvider("github")],
2546 providers: [fakeProvider(SourceControlProviderKind.make("github"))],
25222547 });
25232548
25242549 const error = yield* service
......@@ -2546,11 +2571,11 @@ it.effect("refuses a diff on a host that cannot produce one", () =>
25462571 title: "on azure",
25472572 workspaceRoot: "/a",
25482573 repository: "org/project",
2549 provider: "azure-devops",
2574 provider: SourceControlProviderKind.make("azure-devops"),
25502575 }),
25512576 ],
25522577 providers: [
2553 fakeProvider("azure-devops", {
2578 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
25542579 capabilities: {
25552580 diff: false,
25562581 comment: true,
......@@ -2580,7 +2605,11 @@ it.effect("rejects an empty comment before reaching the host", () =>
25802605 projects: [
25812606 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
25822607 ],
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 ],
25842613 });
25852614
25862615 const error = yield* service
......@@ -2606,11 +2635,11 @@ it.effect("refuses a verdict the host never claimed, without asking the provider
26062635 title: "on gitlab",
26072636 workspaceRoot: "/a",
26082637 repository: "group/project",
2609 provider: "gitlab",
2638 provider: SourceControlProviderKind.make("gitlab"),
26102639 }),
26112640 ],
26122641 providers: [
2613 fakeProvider("gitlab", {
2642 fakeProvider(SourceControlProviderKind.make("gitlab"), {
26142643 capabilities: {
26152644 diff: true,
26162645 comment: true,
......@@ -2658,7 +2687,7 @@ it.effect("refuses line comments on a host that takes only a summary", () =>
26582687 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
26592688 ],
26602689 providers: [
2661 fakeProvider("github", {
2690 fakeProvider(SourceControlProviderKind.make("github"), {
26622691 capabilities: {
26632692 diff: true,
26642693 comment: true,
......@@ -2704,7 +2733,7 @@ it.effect(
27042733 }),
27052734 ],
27062735 providers: [
2707 fakeProvider("github", {
2736 fakeProvider(SourceControlProviderKind.make("github"), {
27082737 submitReview: () => {
27092738 approved = true;
27102739 return Effect.void;
......@@ -2736,7 +2765,7 @@ it.effect("refuses to resolve a conversation on a host that cannot", () =>
27362765 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
27372766 ],
27382767 providers: [
2739 fakeProvider("github", {
2768 fakeProvider(SourceControlProviderKind.make("github"), {
27402769 capabilities: {
27412770 diff: true,
27422771 comment: true,
......@@ -2777,7 +2806,7 @@ it.effect("refuses to react on a host with no reactions", () =>
27772806 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
27782807 ],
27792808 providers: [
2780 fakeProvider("github", {
2809 fakeProvider(SourceControlProviderKind.make("github"), {
27812810 capabilities: {
27822811 diff: true,
27832812 comment: true,
......@@ -2814,7 +2843,7 @@ it.effect("refuses to react on a host whose capabilities omit reactions entirely
28142843 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
28152844 ],
28162845 providers: [
2817 fakeProvider("github", {
2846 fakeProvider(SourceControlProviderKind.make("github"), {
28182847 capabilities: {
28192848 diff: true,
28202849 comment: true,
......@@ -2855,7 +2884,7 @@ it.effect("passes a reaction through with its subject id on a host that has them
28552884 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
28562885 ],
28572886 providers: [
2858 fakeProvider("github", {
2887 fakeProvider(SourceControlProviderKind.make("github"), {
28592888 setReaction: (input) => {
28602889 received = {
28612890 subjectId: input.subjectId,
......@@ -2888,7 +2917,7 @@ it.effect("invalidates the cached activity after reacting, like the other mutati
28882917 const service = yield* makeService({
28892918 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
28902919 providers: [
2891 fakeProvider("github", {
2920 fakeProvider(SourceControlProviderKind.make("github"), {
28922921 getChangeRequestActivity: () => {
28932922 activityCalls += 1;
28942923 return Effect.succeed({
......@@ -2926,7 +2955,9 @@ it.effect("refuses an empty reply before it reaches the host", () =>
29262955 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
29272956 ],
29282957 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 }),
29302961 ],
29312962 });
29322963
......@@ -2952,7 +2983,7 @@ it.effect("refuses a merge strategy the host does not offer", () =>
29522983 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
29532984 ],
29542985 providers: [
2955 fakeProvider("github", {
2986 fakeProvider(SourceControlProviderKind.make("github"), {
29562987 capabilities: {
29572988 diff: true,
29582989 comment: true,
......@@ -3005,7 +3036,7 @@ it.effect("hands the provider the host its repository lives on", () =>
30053036 }),
30063037 ],
30073038 providers: [
3008 fakeProvider("github", {
3039 fakeProvider(SourceControlProviderKind.make("github"), {
30093040 listChangeRequests: (input) => {
30103041 hosts.push(input.host);
30113042 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
30373068 title: "on gitlab",
30383069 workspaceRoot: "/b",
30393070 repository: "group/project",
3040 provider: "gitlab",
3071 provider: SourceControlProviderKind.make("gitlab"),
30413072 }),
30423073 ],
30433074 providers: [
3044 fakeProvider("github", { listChangeRequests: listing }),
3045 fakeProvider("gitlab", { listChangeRequests: listing }),
3075 fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: listing }),
3076 fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: listing }),
30463077 ],
30473078 });
30483079
......@@ -3062,7 +3093,7 @@ it.effect("asks for no search when the reader has typed nothing", () =>
30623093 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
30633094 ],
30643095 providers: [
3065 fakeProvider("github", {
3096 fakeProvider(SourceControlProviderKind.make("github"), {
30663097 listChangeRequests: (input) => {
30673098 asked.push(input.query);
30683099 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
30983129 }),
30993130 ],
31003131 providers: [
3101 fakeProvider("github", {
3132 fakeProvider(SourceControlProviderKind.make("github"), {
31023133 getViewer: (input) => {
31033134 asked.push(input.cwd);
31043135 return input.cwd === "/gone"
31053136 ? Effect.fail(
31063137 new PullRequestProviderError({
3107 provider: "github",
3138 provider: SourceControlProviderKind.make("github"),
31083139 operation: "getViewer",
31093140 reason: "failed",
31103141 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
31383169 const service = yield* makeService({
31393170 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
31403171 providers: [
3141 fakeProvider("github", {
3172 fakeProvider(SourceControlProviderKind.make("github"), {
31423173 capabilities: {
31433174 diff: true,
31443175 comment: true,
......@@ -3179,7 +3210,7 @@ it.effect("refuses the candidate list on a host that has no such list to give",
31793210 const service = yield* makeService({
31803211 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
31813212 providers: [
3182 fakeProvider("github", {
3213 fakeProvider(SourceControlProviderKind.make("github"), {
31833214 capabilities: {
31843215 diff: false,
31853216 comment: false,
......@@ -3215,7 +3246,7 @@ it.effect("refuses a review request this viewer may not make, and says what acce
32153246 const service = yield* makeService({
32163247 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
32173248 providers: [
3218 fakeProvider("github", {
3249 fakeProvider(SourceControlProviderKind.make("github"), {
32193250 // The host asks for reviews; this account only reads the repository.
32203251 getViewerPermissions: () =>
32213252 Effect.succeed({
......@@ -3254,7 +3285,7 @@ it.effect("keeps the menu from a viewer who may not ask, which is all it is for"
32543285 const service = yield* makeService({
32553286 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
32563287 providers: [
3257 fakeProvider("github", {
3288 fakeProvider(SourceControlProviderKind.make("github"), {
32583289 getViewerPermissions: () =>
32593290 Effect.succeed({
32603291 actions: [],
......@@ -3286,7 +3317,7 @@ it.effect("hands the host's own candidate list back, and asks for it with the ch
32863317 const service = yield* makeService({
32873318 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
32883319 providers: [
3289 fakeProvider("github", {
3320 fakeProvider(SourceControlProviderKind.make("github"), {
32903321 listReviewerCandidates: (input) => {
32913322 askedFor = input.number;
32923323 return Effect.succeed({
......@@ -3328,7 +3359,7 @@ it.effect("refuses a label change on a host that has not said it takes one", ()
33283359 const service = yield* makeService({
33293360 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
33303361 providers: [
3331 fakeProvider("github", {
3362 fakeProvider(SourceControlProviderKind.make("github"), {
33323363 // The method is there; the capability that would let it be called is not.
33333364 setLabels: () => {
33343365 changed = true;
......@@ -3360,8 +3391,11 @@ it.effect("refuses a label change this viewer may not make, and says what access
33603391 const service = yield* makeService({
33613392 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
33623393 providers: [
3363 fakeProvider("github", {
3364 capabilities: { ...fakeProvider("github").capabilities, labels: true },
3394 fakeProvider(SourceControlProviderKind.make("github"), {
3395 capabilities: {
3396 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
3397 labels: true,
3398 },
33653399 getViewerPermissions: () =>
33663400 Effect.succeed({
33673401 actions: [],
......@@ -3405,8 +3439,11 @@ it.effect("hands a label change to the host, and reads the labels back for the m
34053439 const service = yield* makeService({
34063440 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
34073441 providers: [
3408 fakeProvider("github", {
3409 capabilities: { ...fakeProvider("github").capabilities, labels: true },
3442 fakeProvider(SourceControlProviderKind.make("github"), {
3443 capabilities: {
3444 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
3445 labels: true,
3446 },
34103447 listLabelCandidates: () =>
34113448 Effect.succeed({
34123449 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
34473484 const service = yield* makeService({
34483485 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
34493486 providers: [
3450 fakeProvider("github", {
3487 fakeProvider(SourceControlProviderKind.make("github"), {
34513488 listChangeRequests: () => {
34523489 hostCalls += 1;
34533490 return Effect.succeed({
......@@ -3496,7 +3533,7 @@ it.effect.each(["list", "listStats"] as const)(
34963533 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
34973534 ],
34983535 providers: [
3499 fakeProvider("github", {
3536 fakeProvider(SourceControlProviderKind.make("github"), {
35003537 listChangeRequests: () =>
35013538 lookup.pipe(
35023539 Effect.as({
......@@ -3545,7 +3582,7 @@ it.effect("shares one cold viewer lookup across distinct concurrent lists", () =
35453582 const service = yield* makeService({
35463583 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
35473584 providers: [
3548 fakeProvider("github", {
3585 fakeProvider(SourceControlProviderKind.make("github"), {
35493586 getViewer: () =>
35503587 Effect.sync(() => {
35513588 viewerCalls += 1;
......@@ -3583,7 +3620,7 @@ it.effect("uses five host reads for the normal indexed-repository page workflow"
35833620 const service = yield* makeService({
35843621 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
35853622 providers: [
3586 fakeProvider("github", {
3623 fakeProvider(SourceControlProviderKind.make("github"), {
35873624 getViewer: () =>
35883625 Effect.sync(() => {
35893626 viewerCalls += 1;
......@@ -3643,7 +3680,7 @@ it.effect("returns the refreshed listing on the first read after its cache expir
36433680 const service = yield* makeService({
36443681 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
36453682 providers: [
3646 fakeProvider("github", {
3683 fakeProvider(SourceControlProviderKind.make("github"), {
36473684 listChangeRequests: () => {
36483685 hostCalls += 1;
36493686 return Effect.succeed({
......@@ -3682,7 +3719,7 @@ it.effect("a listing narrowed to some projects is its own cache entry", () =>
36823719 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
36833720 ],
36843721 providers: [
3685 fakeProvider("github", {
3722 fakeProvider(SourceControlProviderKind.make("github"), {
36863723 listChangeRequestsAcross: (input) => {
36873724 asked.push(input.repositories);
36883725 return Effect.succeed({
......@@ -3725,7 +3762,7 @@ it.effect(
37253762 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
37263763 ],
37273764 providers: [
3728 fakeProvider("github", {
3765 fakeProvider(SourceControlProviderKind.make("github"), {
37293766 listChangeRequests: ({ filters }) =>
37303767 Effect.gen(function* () {
37313768 reads += 1;
......@@ -3782,7 +3819,7 @@ it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and proj
37823819 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
37833820 ],
37843821 providers: [
3785 fakeProvider("github", {
3822 fakeProvider(SourceControlProviderKind.make("github"), {
37863823 getChangeRequest: (input) =>
37873824 Effect.sync(() => {
37883825 calls.push(`${input.repository}/${input.number}`);
......@@ -3827,7 +3864,7 @@ it.effect(
38273864 }),
38283865 ],
38293866 providers: [
3830 fakeProvider("github", {
3867 fakeProvider(SourceControlProviderKind.make("github"), {
38313868 getChangeRequest: (input) =>
38323869 Effect.sync(() => {
38333870 hosts.push(input.host);
......@@ -3858,7 +3895,7 @@ it.effect("does not revive old summaries when project epochs are evicted", () =>
38583895 const service = yield* makeService({
38593896 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
38603897 providers: [
3861 fakeProvider("github", {
3898 fakeProvider(SourceControlProviderKind.make("github"), {
38623899 getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), title }),
38633900 }),
38643901 ],
......@@ -3882,7 +3919,7 @@ it.effect("explicit and turn invalidations make the next listing ask the host ag
38823919 const service = yield* makeService({
38833920 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
38843921 providers: [
3885 fakeProvider("github", {
3922 fakeProvider(SourceControlProviderKind.make("github"), {
38863923 getViewer: () => {
38873924 viewerCalls += 1;
38883925 return Effect.succeed("bilal");
......@@ -3924,7 +3961,7 @@ it.effect("close and reopen notify subscribed readers after invalidating their c
39243961 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
39253962 ],
39263963 providers: [
3927 fakeProvider("github", {
3964 fakeProvider(SourceControlProviderKind.make("github"), {
39283965 getChangeRequestSummary: () =>
39293966 Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }),
39303967 runAction: (input) =>
......@@ -3976,7 +4013,7 @@ it.effect("explicit invalidation refreshes origin readers after a routed host mu
39764013 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
39774014 ],
39784015 providers: [
3979 fakeProvider("github", {
4016 fakeProvider(SourceControlProviderKind.make("github"), {
39804017 getChangeRequestSummary: () =>
39814018 Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }),
39824019 }),
......@@ -4021,7 +4058,7 @@ it.effect("does not cache a failed listing", () =>
40214058 const service = yield* makeService({
40224059 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
40234060 providers: [
4024 fakeProvider("github", {
4061 fakeProvider(SourceControlProviderKind.make("github"), {
40254062 // The viewer lookup is what fails the whole listing rather than one repository.
40264063 getViewer: () => {
40274064 hostCalls += 1;
......@@ -4052,11 +4089,11 @@ it.effect("reads a host's repositories in one search, and files the rows back un
40524089 title: "on gitlab",
40534090 workspaceRoot: "/c",
40544091 repository: "group/project",
4055 provider: "gitlab",
4092 provider: SourceControlProviderKind.make("gitlab"),
40564093 }),
40574094 ],
40584095 providers: [
4059 fakeProvider("github", {
4096 fakeProvider(SourceControlProviderKind.make("github"), {
40604097 listChangeRequests: ({ repository }) => {
40614098 separately.push(repository);
40624099 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
40734110 },
40744111 }),
40754112 // A host with no search across repositories keeps being asked one at a time.
4076 fakeProvider("gitlab", {
4113 fakeProvider(SourceControlProviderKind.make("gitlab"), {
40774114 listChangeRequests: ({ repository }) => {
40784115 separately.push(repository);
40794116 return Effect.succeed({
......@@ -4111,7 +4148,7 @@ it.effect("carries every repository of a slice on from the oldest row in it", ()
41114148 project({ id: "p3", title: "docs", workspaceRoot: "/c", repository: "acme/docs" }),
41124149 ],
41134150 providers: [
4114 fakeProvider("github", {
4151 fakeProvider(SourceControlProviderKind.make("github"), {
41154152 listChangeRequestsAcross: () =>
41164153 Effect.succeed({
41174154 items: [
......@@ -4144,7 +4181,7 @@ it.effect("carries a slice on without sending the rows it already sent", () =>
41444181 const service = yield* makeService({
41454182 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
41464183 providers: [
4147 fakeProvider("github", {
4184 fakeProvider(SourceControlProviderKind.make("github"), {
41484185 listChangeRequestsAcross: (input) => {
41494186 cursors.push(input.cursor);
41504187 return Effect.succeed({
......@@ -4189,7 +4226,7 @@ it.effect("reads a workspace larger than one search in chunks, and merges them",
41894226 }),
41904227 ),
41914228 providers: [
4192 fakeProvider("github", {
4229 fakeProvider(SourceControlProviderKind.make("github"), {
41934230 listChangeRequestsAcross: (input) => {
41944231 asked.push(input.repositories.length);
41954232 return Effect.succeed({
......@@ -4218,7 +4255,7 @@ it.effect("asks on its own for a repository a search answered nothing for", () =
42184255 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
42194256 ],
42204257 providers: [
4221 fakeProvider("github", {
4258 fakeProvider(SourceControlProviderKind.make("github"), {
42224259 listChangeRequests: ({ repository }) => {
42234260 separately.push(repository);
42244261 return repository === "acme/docs"
......@@ -4262,7 +4299,7 @@ it.effect("reads the repositories one at a time when the search itself fails", (
42624299 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
42634300 ],
42644301 providers: [
4265 fakeProvider("github", {
4302 fakeProvider(SourceControlProviderKind.make("github"), {
42664303 listChangeRequests: ({ repository }) => {
42674304 separately.push(repository);
42684305 return Effect.succeed({
......@@ -4295,11 +4332,11 @@ it.effect("fills in the line counts for the rows it is given", () =>
42954332 title: "on gitlab",
42964333 workspaceRoot: "/b",
42974334 repository: "group/project",
4298 provider: "gitlab",
4335 provider: SourceControlProviderKind.make("gitlab"),
42994336 }),
43004337 ],
43014338 providers: [
4302 fakeProvider("github", {
4339 fakeProvider(SourceControlProviderKind.make("github"), {
43034340 listChangeRequestStats: (input) => {
43044341 asked.push(input.changeRequests);
43054342 return Effect.succeed([
......@@ -4308,7 +4345,7 @@ it.effect("fills in the line counts for the rows it is given", () =>
43084345 },
43094346 }),
43104347 // Its listing carries the counts already, so it has nothing to be asked.
4311 fakeProvider("gitlab"),
4348 fakeProvider(SourceControlProviderKind.make("gitlab")),
43124349 ],
43134350 });
43144351
......@@ -4355,7 +4392,7 @@ it.effect(
43554392 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
43564393 ],
43574394 providers: [
4358 fakeProvider("github", {
4395 fakeProvider(SourceControlProviderKind.make("github"), {
43594396 listChangeRequestStats: (input) => {
43604397 asked.push(input.changeRequests.map((ref) => ref.number));
43614398 return Effect.succeed(
......@@ -4404,7 +4441,7 @@ it.effect("reads the fresh diff when detail or summary discovers a changed revis
44044441 const service = yield* makeService({
44054442 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
44064443 providers: [
4407 fakeProvider("github", {
4444 fakeProvider(SourceControlProviderKind.make("github"), {
44084445 getChangeRequest: () =>
44094446 Effect.sync(() => ({ ...hostedChangeRequest("body"), updatedAt: revision })),
44104447 getChangeRequestSummary: () =>
......@@ -4453,7 +4490,9 @@ it.effect("keeps the rows when the line counts cannot be read", () =>
44534490 const service = yield* makeService({
44544491 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
44554492 providers: [
4456 fakeProvider("github", { listChangeRequestStats: () => Effect.fail(requestFailed) }),
4493 fakeProvider(SourceControlProviderKind.make("github"), {
4494 listChangeRequestStats: () => Effect.fail(requestFailed),
4495 }),
44574496 ],
44584497 });
44594498
......@@ -4478,7 +4517,7 @@ it.effect(
44784517 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
44794518 ],
44804519 providers: [
4481 fakeProvider("github", {
4520 fakeProvider(SourceControlProviderKind.make("github"), {
44824521 listChangeRequestStats: () => {
44834522 statsCalls += 1;
44844523 return Effect.succeed([]);
......@@ -4556,7 +4595,7 @@ it.effect("shares linked summaries and reuses them for display without asking th
45564595 const service = yield* makeService({
45574596 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
45584597 providers: [
4559 fakeProvider("github", {
4598 fakeProvider(SourceControlProviderKind.make("github"), {
45604599 getChangeRequestSummary: () =>
45614600 Effect.sync(() => {
45624601 calls += 1;
......@@ -4567,7 +4606,7 @@ it.effect("shares linked summaries and reuses them for display without asking th
45674606 shouldFail
45684607 ? Effect.fail(
45694608 new PullRequestProviderError({
4570 provider: "github",
4609 provider: SourceControlProviderKind.make("github"),
45714610 operation: "getChangeRequestSummary",
45724611 reason: "failed",
45734612 detail: "HTTP 504",
......@@ -4622,8 +4661,11 @@ it.effect("keeps routed reads separate when the GitHub account changes", () =>
46224661 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
46234662 ],
46244663 providers: [
4625 fakeProvider("github", {
4626 capabilities: { ...fakeProvider("github").capabilities, viewedFiles: "host" },
4664 fakeProvider(SourceControlProviderKind.make("github"), {
4665 capabilities: {
4666 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
4667 viewedFiles: "host",
4668 },
46274669 getFilesViewed: () =>
46284670 read().pipe(
46294671 Effect.as({
......@@ -4685,8 +4727,11 @@ it.effect("isolates routed caches for two credentials belonging to the same acco
46854727 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
46864728 ],
46874729 providers: [
4688 fakeProvider("github", {
4689 capabilities: { ...fakeProvider("github").capabilities, viewedFiles: "host" },
4730 fakeProvider(SourceControlProviderKind.make("github"), {
4731 capabilities: {
4732 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
4733 viewedFiles: "host",
4734 },
46904735 getFilesViewed: () =>
46914736 read().pipe(
46924737 Effect.as({
......@@ -4744,7 +4789,7 @@ it.effect("rejects mismatched routing credentials before use and preserves actio
47444789 const service = yield* makeService({
47454790 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
47464791 providers: [
4747 fakeProvider("github", {
4792 fakeProvider(SourceControlProviderKind.make("github"), {
47484793 getRoutingIdentity: () => Effect.succeed({ accountId: "101", viewer: "octocat" }),
47494794 withVerifiedCredential: (_, use) =>
47504795 use({
......@@ -4798,7 +4843,7 @@ it.effect("answers a known pull request immediately while the host refreshes", (
47984843 const service = yield* makeService({
47994844 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
48004845 providers: [
4801 fakeProvider("github", {
4846 fakeProvider(SourceControlProviderKind.make("github"), {
48024847 getChangeRequest: () =>
48034848 Effect.gen(function* () {
48044849 calls += 1;
......@@ -4829,7 +4874,7 @@ it.effect("does not ask the host again for a linked summary it already holds", (
48294874 const service = yield* makeService({
48304875 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
48314876 providers: [
4832 fakeProvider("github", {
4877 fakeProvider(SourceControlProviderKind.make("github"), {
48334878 getChangeRequestSummary: () =>
48344879 Effect.sync(() => {
48354880 calls += 1;
......@@ -4858,7 +4903,7 @@ it.effect(
48584903 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
48594904 ],
48604905 providers: [
4861 fakeProvider("github", {
4906 fakeProvider(SourceControlProviderKind.make("github"), {
48624907 getChangeRequestSummary: () =>
48634908 Effect.succeed({
48644909 ...changeRequest(1, "2026-07-02T00:00:00Z"),
......@@ -4896,7 +4941,7 @@ it.effect("reuses an observed merged state for strict settlement reads", () =>
48964941 const service = yield* makeService({
48974942 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
48984943 providers: [
4899 fakeProvider("github", {
4944 fakeProvider(SourceControlProviderKind.make("github"), {
49004945 getChangeRequest: () =>
49014946 Effect.succeed({
49024947 ...hostedChangeRequest("merged body", 4),
......@@ -4927,7 +4972,7 @@ it.effect("announces state a detail read sees first or newly", () =>
49274972 const service = yield* makeService({
49284973 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
49294974 providers: [
4930 fakeProvider("github", {
4975 fakeProvider(SourceControlProviderKind.make("github"), {
49314976 getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }),
49324977 getChangeRequestSummary: () =>
49334978 Effect.succeed({
......@@ -4979,7 +5024,7 @@ it.effect("announces a reported state without trusting it", () =>
49795024 const service = yield* makeService({
49805025 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
49815026 providers: [
4982 fakeProvider("github", {
5027 fakeProvider(SourceControlProviderKind.make("github"), {
49835028 getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }),
49845029 }),
49855030 ],
......@@ -5024,7 +5069,7 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary
50245069 const service = yield* makeService({
50255070 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
50265071 providers: [
5027 fakeProvider("github", {
5072 fakeProvider(SourceControlProviderKind.make("github"), {
50285073 getChangeRequest: () =>
50295074 Effect.gen(function* () {
50305075 detailCalls += 1;
......@@ -5078,7 +5123,7 @@ it.effect("does not let a still-cached detail overwrite a fresher linked summary
50785123 const service = yield* makeService({
50795124 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
50805125 providers: [
5081 fakeProvider("github", {
5126 fakeProvider(SourceControlProviderKind.make("github"), {
50825127 getChangeRequest: () => Effect.succeed(hostedChangeRequest("old body", 4)),
50835128 getChangeRequestSummary: () =>
50845129 Effect.succeed({
......@@ -5114,11 +5159,11 @@ it.effect("tells the client when the host has no pull request under that number"
51145159 const service = yield* makeService({
51155160 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
51165161 providers: [
5117 fakeProvider("github", {
5162 fakeProvider(SourceControlProviderKind.make("github"), {
51185163 getChangeRequest: () =>
51195164 Effect.fail(
51205165 new PullRequestProviderError({
5121 provider: "github",
5166 provider: SourceControlProviderKind.make("github"),
51225167 operation: "getChangeRequest",
51235168 reason: "not-found",
51245169 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
51425187 const service = yield* makeService({
51435188 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
51445189 providers: [
5145 fakeProvider("github", {
5190 fakeProvider(SourceControlProviderKind.make("github"), {
51465191 getChangeRequest: () =>
51475192 failing
51485193 ? Effect.fail(
51495194 new PullRequestProviderError({
5150 provider: "github",
5195 provider: SourceControlProviderKind.make("github"),
51515196 operation: "getChangeRequest",
51525197 reason: "failed",
51535198 detail: "spawn gh EAGAIN",
......@@ -5197,7 +5242,7 @@ it.effect("carries an armed auto-merge through to the detail, and silence as sil
51975242 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
51985243 ],
51995244 providers: [
5200 fakeProvider("github", {
5245 fakeProvider(SourceControlProviderKind.make("github"), {
52015246 getChangeRequest: () =>
52025247 Effect.succeed({
52035248 ...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", (
52435288 title: "web",
52445289 workspaceRoot: "/a",
52455290 repository: "acme/web",
5246 provider: "gitlab",
5291 provider: SourceControlProviderKind.make("gitlab"),
52475292 }),
52485293 ],
52495294 providers: [
52505295 // Only GitHub narrows a listing for itself; every other host answers unnarrowed, and
52515296 // sending it a draft filter it quietly ignores used to put drafts on a filtered page.
5252 fakeProvider("gitlab", {
5297 fakeProvider(SourceControlProviderKind.make("gitlab"), {
52535298 listChangeRequests: () =>
52545299 Effect.succeed({
52555300 items: [
......@@ -5285,11 +5330,11 @@ it.effect("keeps a row of a host that ignored the filters if any name of a label
52855330 title: "web",
52865331 workspaceRoot: "/a",
52875332 repository: "acme/web",
5288 provider: "gitlab",
5333 provider: SourceControlProviderKind.make("gitlab"),
52895334 }),
52905335 ],
52915336 providers: [
5292 fakeProvider("gitlab", {
5337 fakeProvider(SourceControlProviderKind.make("gitlab"), {
52935338 listChangeRequests: () =>
52945339 Effect.succeed({
52955340 items: [
......@@ -5328,13 +5373,13 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho
53285373 title: "web",
53295374 workspaceRoot: "/a",
53305375 repository: "acme/web",
5331 provider: "gitlab",
5376 provider: SourceControlProviderKind.make("gitlab"),
53325377 }),
53335378 ],
53345379 providers: [
53355380 // Only GitHub narrows a listing for itself, so this fixture's "me" has to be resolved
53365381 // locally too — the same helper both call sites lean on.
5337 fakeProvider("gitlab", {
5382 fakeProvider(SourceControlProviderKind.make("gitlab"), {
53385383 listChangeRequests: () =>
53395384 Effect.succeed({
53405385 items: [
......@@ -5395,7 +5440,7 @@ it.effect.each([false, true])(
53955440 }),
53965441 ],
53975442 providers: [
5398 fakeProvider("github", {
5443 fakeProvider(SourceControlProviderKind.make("github"), {
53995444 capabilities,
54005445 getViewerPermissions: () =>
54015446 Effect.succeed({
......@@ -5463,7 +5508,7 @@ it.effect("refuses a way of updating a branch that the host or the viewer does n
54635508 const service = yield* makeService({
54645509 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
54655510 providers: [
5466 fakeProvider("github", {
5511 fakeProvider(SourceControlProviderKind.make("github"), {
54675512 capabilities: {
54685513 diff: true,
54695514 comment: true,
......@@ -5516,11 +5561,11 @@ it.effect("refuses to merge a target branch into a source branch on a host that
55165561 title: "on gitlab",
55175562 workspaceRoot: "/a",
55185563 repository: "group/project",
5519 provider: "gitlab",
5564 provider: SourceControlProviderKind.make("gitlab"),
55205565 }),
55215566 ],
55225567 providers: [
5523 fakeProvider("gitlab", {
5568 fakeProvider(SourceControlProviderKind.make("gitlab"), {
55245569 capabilities: {
55255570 diff: true,
55265571 comment: true,
......@@ -5575,12 +5620,12 @@ it.effect("judges the review filter only on a host that summarises its reviews",
55755620 title: "on gitlab",
55765621 workspaceRoot: "/b",
55775622 repository: "group/project",
5578 provider: "gitlab",
5623 provider: SourceControlProviderKind.make("gitlab"),
55795624 }),
55805625 ],
55815626 providers: [
55825627 // GitHub answers with the field on every row: null is "nobody has decided yet".
5583 fakeProvider("github", {
5628 fakeProvider(SourceControlProviderKind.make("github"), {
55845629 listChangeRequests: () =>
55855630 Effect.succeed({
55865631 items: [
......@@ -5595,7 +5640,7 @@ it.effect("judges the review filter only on a host that summarises its reviews",
55955640 }),
55965641 }),
55975642 // GitLab never supplies the field, so its rows are not the filter's to judge.
5598 fakeProvider("gitlab", {
5643 fakeProvider(SourceControlProviderKind.make("gitlab"), {
55995644 listChangeRequests: () =>
56005645 Effect.succeed({
56015646 items: [changeRequest(3, "2026-07-02T00:00:00Z")],
......@@ -5621,7 +5666,7 @@ it.effect("sends only the words a rewrite carries", () =>
56215666 const service = yield* makeService({
56225667 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
56235668 providers: [
5624 fakeProvider("github", {
5669 fakeProvider(SourceControlProviderKind.make("github"), {
56255670 updateChangeRequest: (input) => {
56265671 received.push({ title: input.title, body: input.body });
56275672 return Effect.void;
......@@ -5647,7 +5692,9 @@ it.effect("refuses a rewrite that changes nothing, before any call is made", ()
56475692 const service = yield* makeService({
56485693 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
56495694 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 }),
56515698 ],
56525699 });
56535700
......@@ -5665,7 +5712,7 @@ it.effect("refuses to rewrite anything on a host that never claimed it", () =>
56655712 const service = yield* makeService({
56665713 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
56675714 providers: [
5668 fakeProvider("github", {
5715 fakeProvider(SourceControlProviderKind.make("github"), {
56695716 capabilities: {
56705717 diff: true,
56715718 comment: true,
......@@ -5704,7 +5751,7 @@ it.effect("passes a rewritten remark through with the id and kind it arrived und
57045751 const service = yield* makeService({
57055752 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
57065753 providers: [
5707 fakeProvider("github", {
5754 fakeProvider(SourceControlProviderKind.make("github"), {
57085755 updateComment: (input) => {
57095756 received = { id: input.commentId, kind: input.kind, body: input.body };
57105757 return Effect.void;
......@@ -5735,7 +5782,9 @@ it.effect("refuses a remark rewritten into nothing but whitespace", () =>
57355782 const service = yield* makeService({
57365783 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
57375784 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 }),
57395788 ],
57405789 });
57415790
......@@ -5761,7 +5810,7 @@ it.effect("forgets the cached detail after a rewrite or terminal turn", () =>
57615810 const service = yield* makeService({
57625811 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
57635812 providers: [
5764 fakeProvider("github", {
5813 fakeProvider(SourceControlProviderKind.make("github"), {
57655814 getChangeRequest: () => {
57665815 coreCalls += 1;
57675816 return Effect.succeed({
......@@ -5813,7 +5862,7 @@ it.effect("names the signed-in account in the detail, and says nothing where the
58135862 number: 1,
58145863 });
58155864 });
5816 const readable = fakeProvider("github", {
5865 const readable = fakeProvider(SourceControlProviderKind.make("github"), {
58175866 getChangeRequest: () =>
58185867 Effect.succeed({
58195868 ...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
58375886 const named = yield* detailFrom(readable);
58385887 const unnamed = yield* detailFrom({
58395888 ...readable,
5840 getViewer: () => Effect.fail(unusable("github", "unauthenticated")),
5889 getViewer: () =>
5890 Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")),
58415891 });
58425892
58435893 assert.strictEqual(named.viewer, "bilal");
......@@ -5855,7 +5905,7 @@ it.effect("keeps the diff cached across a file being ticked off", () =>
58555905 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
58565906 ],
58575907 providers: [
5858 fakeProvider("github", {
5908 fakeProvider(SourceControlProviderKind.make("github"), {
58595909 capabilities: {
58605910 diff: true,
58615911 comment: true,
......@@ -5912,7 +5962,7 @@ it.effect("returns large diff slices intact without retaining them in either cac
59125962 const service = yield* makeService({
59135963 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
59145964 providers: [
5915 fakeProvider("github", {
5965 fakeProvider(SourceControlProviderKind.make("github"), {
59165966 getDiff: () =>
59175967 Effect.sync(() => {
59185968 reads += 1;
......@@ -5950,7 +6000,7 @@ it.effect("caches a small replacement after releasing a large diff", () =>
59506000 const service = yield* makeService({
59516001 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
59526002 providers: [
5953 fakeProvider("github", {
6003 fakeProvider(SourceControlProviderKind.make("github"), {
59546004 getDiff: () =>
59556005 Effect.sync(() => {
59566006 reads += 1;
......@@ -5976,7 +6026,7 @@ const environmentViewedProvider = (
59766026 asked: Array<ReadonlyArray<string>>,
59776027 unreadable: ReadonlySet<string> = new Set(),
59786028) =>
5979 fakeProvider("gitlab", {
6029 fakeProvider(SourceControlProviderKind.make("gitlab"), {
59806030 capabilities: {
59816031 diff: true,
59826032 comment: true,
......@@ -6018,7 +6068,7 @@ const environmentViewedService = (
60186068 title: "on gitlab",
60196069 workspaceRoot: "/a",
60206070 repository: "group/project",
6021 provider: "gitlab",
6071 provider: SourceControlProviderKind.make("gitlab"),
60226072 }),
60236073 ],
60246074 providers: [environmentViewedProvider(revisions, asked, unreadable)],
......@@ -6083,7 +6133,7 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba
60836133 title: "on forgejo",
60846134 workspaceRoot: "/forgejo",
60856135 repository: "reviewer/project",
6086 provider: "forgejo",
6136 provider: SourceControlProviderKind.make("forgejo"),
60876137 host: "forge.example:3000",
60886138 remoteUrl: "http://forge.example:3000/reviewer/project.git",
60896139 }),
......@@ -6145,7 +6195,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi
61456195 title: "first repository",
61466196 workspaceRoot: "/first",
61476197 repository: "reviewer/first",
6148 provider: "forgejo",
6198 provider: SourceControlProviderKind.make("forgejo"),
61496199 host: "forge.example",
61506200 remoteUrl: "https://forge.example:3000/reviewer/first.git",
61516201 }),
......@@ -6153,13 +6203,20 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi
61536203 const service = yield* makeService({
61546204 projects,
61556205 providers: [
6156 { ...environmentViewedProvider(new Map([["same.ts", "blob"]]), []), kind: "forgejo" },
6206 {
6207 ...environmentViewedProvider(new Map([["same.ts", "blob"]]), []),
6208 kind: SourceControlProviderKind.make("forgejo"),
6209 },
61576210 ],
61586211 resolveHandle: ({ context }) =>
61596212 Effect.succeed({
61606213 context: {
61616214 ...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 },
61636220 },
61646221 provider: undefined as never,
61656222 }),
......@@ -6186,7 +6243,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi
61866243 title: "second repository",
61876244 workspaceRoot: "/second",
61886245 repository: "reviewer/second",
6189 provider: "forgejo",
6246 provider: SourceControlProviderKind.make("forgejo"),
61906247 host: "forge.example",
61916248 remoteUrl: "https://forge.example:3000/reviewer/second.git",
61926249 }),
......@@ -6201,7 +6258,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi
62016258 title: "second repository over SSH",
62026259 workspaceRoot: "/ssh",
62036260 repository: "reviewer/second",
6204 provider: "forgejo",
6261 provider: SourceControlProviderKind.make("forgejo"),
62056262 host: "ssh.forge.example",
62066263 remoteUrl: "git@ssh.forge.example:reviewer/second.git",
62076264 }),
......@@ -6269,7 +6326,7 @@ it.effect("holds what a whole-change answer carried, so the next tick reads noth
62696326 title: "on gitlab",
62706327 workspaceRoot: "/a",
62716328 repository: "group/project",
6272 provider: "gitlab",
6329 provider: SourceControlProviderKind.make("gitlab"),
62736330 }),
62746331 ],
62756332 providers: [
......@@ -6643,11 +6700,11 @@ it.effect("still reports its own marks when the host will not say what the head
66436700 title: "on gitlab",
66446701 workspaceRoot: "/a",
66456702 repository: "group/project",
6646 provider: "gitlab",
6703 provider: SourceControlProviderKind.make("gitlab"),
66476704 }),
66486705 ],
66496706 providers: [
6650 fakeProvider("gitlab", {
6707 fakeProvider(SourceControlProviderKind.make("gitlab"), {
66516708 capabilities: {
66526709 diff: true,
66536710 comment: true,
......@@ -6699,11 +6756,11 @@ it.effect("finishes two presses on one file in the order they were made", () =>
66996756 title: "on gitlab",
67006757 workspaceRoot: "/a",
67016758 repository: "group/project",
6702 provider: "gitlab",
6759 provider: SourceControlProviderKind.make("gitlab"),
67036760 }),
67046761 ],
67056762 providers: [
6706 fakeProvider("gitlab", {
6763 fakeProvider(SourceControlProviderKind.make("gitlab"), {
67076764 capabilities: {
67086765 diff: true,
67096766 comment: true,
......@@ -6755,7 +6812,7 @@ const azureViewedService = (projects: ReadonlyArray<OrchestrationProjectShell>)
67556812 makeService({
67566813 projects,
67576814 providers: [
6758 fakeProvider("azure-devops", {
6815 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
67596816 capabilities: {
67606817 diff: true,
67616818 comment: true,
......@@ -6781,7 +6838,7 @@ const AZURE_PAIR = [
67816838 title: "platform web",
67826839 workspaceRoot: "/a",
67836840 repository: "acme/platform/_git/web",
6784 provider: "azure-devops",
6841 provider: SourceControlProviderKind.make("azure-devops"),
67856842 host: "dev.azure.com",
67866843 }),
67876844 project({
......@@ -6789,7 +6846,7 @@ const AZURE_PAIR = [
67896846 title: "other web",
67906847 workspaceRoot: "/b",
67916848 repository: "acme/other/_git/web",
6792 provider: "azure-devops",
6849 provider: SourceControlProviderKind.make("azure-devops"),
67936850 host: "dev.azure.com",
67946851 }),
67956852];
......@@ -6880,7 +6937,7 @@ it.effect("keeps the marked paths when a whole-change answer is wider than the c
68806937 title: "on gitlab",
68816938 workspaceRoot: "/a",
68826939 repository: "group/project",
6883 provider: "gitlab",
6940 provider: SourceControlProviderKind.make("gitlab"),
68846941 }),
68856942 ],
68866943 providers: [
......@@ -6949,7 +7006,7 @@ const environmentViewedServiceWithViewer = (
69497006 title: "on gitlab",
69507007 workspaceRoot: "/a",
69517008 repository: "group/project",
6952 provider: "gitlab",
7009 provider: SourceControlProviderKind.make("gitlab"),
69537010 }),
69547011 ],
69557012 providers: [{ ...environmentViewedProvider(revisions, []), getViewer }],
......@@ -6983,7 +7040,7 @@ it.effect("puts a listing and a press for one host on a single viewer lookup", (
69837040 title: "on gitlab",
69847041 workspaceRoot: "/a",
69857042 repository: "group/project",
6986 provider: "gitlab",
7043 provider: SourceControlProviderKind.make("gitlab"),
69877044 }),
69887045 ],
69897046 providers: [
......@@ -7048,7 +7105,7 @@ it.effect("records a press while the host is backing off", () =>
70487105 title: "on gitlab",
70497106 workspaceRoot: "/a",
70507107 repository: "group/project",
7051 provider: "gitlab",
7108 provider: SourceControlProviderKind.make("gitlab"),
70527109 }),
70537110 ],
70547111 providers: [
......@@ -7064,7 +7121,7 @@ it.effect("records a press while the host is backing off", () =>
70647121 getFileRevisions: () =>
70657122 Effect.fail(
70667123 new PullRequestProviderError({
7067 provider: "gitlab",
7124 provider: SourceControlProviderKind.make("gitlab"),
70687125 operation: "getFileRevisions",
70697126 reason: "rate-limited",
70707127 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
71117168 title: "on gitlab",
71127169 workspaceRoot: "/a",
71137170 repository: "group/project",
7114 provider: "gitlab",
7171 provider: SourceControlProviderKind.make("gitlab"),
71157172 }),
71167173 ],
71177174 providers: [
......@@ -7129,7 +7186,7 @@ it.effect("asks who is reading through a pause only for the press that is waitin
71297186 getFileRevisions: () =>
71307187 Effect.fail(
71317188 new PullRequestProviderError({
7132 provider: "gitlab",
7189 provider: SourceControlProviderKind.make("gitlab"),
71337190 operation: "getFileRevisions",
71347191 reason: "rate-limited",
71357192 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"
71747231 title: "on gitlab",
71757232 workspaceRoot: "/a",
71767233 repository: "group/project",
7177 provider: "gitlab",
7234 provider: SourceControlProviderKind.make("gitlab"),
71787235 }),
71797236 ],
71807237 providers: [
......@@ -7185,7 +7242,7 @@ it.effect("does not ask a paused host who is reading again after the ask failed"
71857242 viewerLookups += 1;
71867243 return Effect.fail(
71877244 new PullRequestProviderError({
7188 provider: "gitlab",
7245 provider: SourceControlProviderKind.make("gitlab"),
71897246 operation: "getViewer",
71907247 reason: "failed",
71917248 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"
71977254 runAction: () =>
71987255 Effect.fail(
71997256 new PullRequestProviderError({
7200 provider: "gitlab",
7257 provider: SourceControlProviderKind.make("gitlab"),
72017258 operation: "runAction",
72027259 reason: "rate-limited",
72037260 detail: "API rate limit exceeded.",
......@@ -7231,7 +7288,7 @@ it.effect("refuses the marks when the host could not be asked who is reading", (
72317288 ? Effect.succeed("bilal")
72327289 : Effect.fail(
72337290 new PullRequestProviderError({
7234 provider: "gitlab",
7291 provider: SourceControlProviderKind.make("gitlab"),
72357292 operation: "getViewer",
72367293 reason: "failed",
72377294 detail: "glab exited with status 1",
......@@ -7276,11 +7333,11 @@ it.effect("refuses to track viewed files on a host that does not", () =>
72767333 title: "on gitlab",
72777334 workspaceRoot: "/a",
72787335 repository: "group/project",
7279 provider: "gitlab",
7336 provider: SourceControlProviderKind.make("gitlab"),
72807337 }),
72817338 ],
72827339 providers: [
7283 fakeProvider("gitlab", {
7340 fakeProvider(SourceControlProviderKind.make("gitlab"), {
72847341 getFilesViewed: () => Effect.die("must not be called"),
72857342 setFilesViewed: () => Effect.die("must not be called"),
72867343 }),
......@@ -7308,12 +7365,12 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s
73087365 title: organization,
73097366 workspaceRoot: `/${organization}`,
73107367 repository: `${organization}/project/_git/web`,
7311 provider: "azure-devops",
7368 provider: SourceControlProviderKind.make("azure-devops"),
73127369 host: "dev.azure.com",
73137370 }),
73147371 ),
73157372 providers: [
7316 fakeProvider("azure-devops", {
7373 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
73177374 listChangeRequests: (input) =>
73187375 Effect.sync(() => {
73197376 seen.push(input.cwd);
......@@ -7363,9 +7420,9 @@ it.effect.each([
73637420 },
73647421 },
73657422 providers: [
7366 fakeProvider("github", {
7423 fakeProvider(SourceControlProviderKind.make("github"), {
73677424 capabilities: {
7368 ...fakeProvider("github").capabilities,
7425 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
73697426 actions: ["merge", "enable-auto-merge"],
73707427 },
73717428 getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
apps/server/src/pullRequest/PullRequestService.ts+7-4
......@@ -72,7 +72,7 @@ import {
7272 type PullRequestThreadCommentsResult,
7373 type PullRequestUpdateInput,
7474 type SourceControlProviderInfo,
75 type SourceControlProviderKind,
75 SourceControlProviderKind,
7676 type ThreadPullRequestKey,
7777} from "@t3tools/contracts";
7878import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
......@@ -736,14 +736,15 @@ export const make = Effect.gen(function* () {
736736 sourceControlRepositorySelector(project.repositoryIdentity) === null
737737 )
738738 continue;
739 const host = pullRequestHostOf(identity, "unknown");
739 const host = pullRequestHostOf(identity, SourceControlProviderKind.make("unknown"));
740740 // A legacy identity has no canonical host until its provider is refined, so it must reach
741741 // the refinement before a host filter can decide whether it belongs in the result.
742742 if (
743743 filter.host !== undefined &&
744744 host !== "unknown" &&
745745 host !== filter.host.toLowerCase() &&
746 pullRequestHostOf(identity, "forgejo") !== filter.host.toLowerCase() &&
746 pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo")) !==
747 filter.host.toLowerCase() &&
747748 !isSshRemoteUrl(identity.locator.remoteUrl)
748749 ) {
749750 continue;
......@@ -768,7 +769,9 @@ export const make = Effect.gen(function* () {
768769 cwd: project.workspaceRoot,
769770 context: {
770771 provider:
771 provider.kind === "forgejo" ? { ...provider, kind: "unknown" } : provider,
772 provider.kind === "forgejo"
773 ? { ...provider, kind: SourceControlProviderKind.make("unknown") }
774 : provider,
772775 remoteName,
773776 remoteUrl,
774777 ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl)
apps/server/src/pullRequest/pullRequestSyncKey.ts+3-2
......@@ -2,7 +2,7 @@ import {
22 pullRequestHostOf,
33 type PullRequestRef,
44 type RepositoryIdentity,
5 type SourceControlProviderKind,
5 SourceControlProviderKind,
66 type ThreadPullRequestKey,
77} from "@t3tools/contracts";
88import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
......@@ -18,7 +18,8 @@ export function pullRequestSyncKey(
1818 reference.repository.toLowerCase() !==
1919 sourceControlRepositorySelector(identity)?.toLowerCase() ||
2020 (reference.host !== undefined &&
21 reference.host.toLowerCase() !== pullRequestHostOf(identity, "azure-devops"))
21 reference.host.toLowerCase() !==
22 pullRequestHostOf(identity, SourceControlProviderKind.make("azure-devops")))
2223 )
2324 return null;
2425 const [host, ...repository] = identity.canonicalKey.split("/");
apps/server/src/sourceControl/BitbucketApi.test.ts+8-3
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { assert, it, vi } from "@effect/vitest";
23import * as NodeServices from "@effect/platform-node/NodeServices";
34import * as ConfigProvider from "effect/ConfigProvider";
......@@ -362,7 +363,7 @@ it.effect.each([false, true])(
362363 ? {
363364 context: {
364365 provider: {
365 kind: "bitbucket" as const,
366 kind: SourceControlProviderKind.make("bitbucket"),
366367 name: "Bitbucket",
367368 baseUrl: "https://bitbucket.org",
368369 },
......@@ -396,7 +397,11 @@ it.effect("prefers an explicit repository and uses context when the repository i
396397 return Effect.gen(function* () {
397398 const bitbucket = yield* BitbucketApi.BitbucketApi;
398399 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 },
400405 remoteName: "origin",
401406 remoteUrl: "git@bitbucket.org:another/context.git",
402407 };
......@@ -838,7 +843,7 @@ it.effect("checks out same-repository pull requests with the existing Bitbucket
838843 cwd: "/repo",
839844 context: {
840845 provider: {
841 kind: "bitbucket",
846 kind: SourceControlProviderKind.make("bitbucket"),
842847 name: "Bitbucket",
843848 baseUrl: "https://bitbucket.org",
844849 },
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+18-13
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { assert, it } from "@effect/vitest";
23import * as NodeServices from "@effect/platform-node/NodeServices";
34import * as Effect from "effect/Effect";
......@@ -176,37 +177,37 @@ it.effect("reports implemented tools separately from locally available executabl
176177 })),
177178 [
178179 {
179 kind: "github",
180 kind: SourceControlProviderKind.make("github"),
180181 status: "available",
181182 auth: "authenticated",
182183 account: Option.some("juliusmarminge"),
183184 },
184185 {
185 kind: "gitlab",
186 kind: SourceControlProviderKind.make("gitlab"),
186187 status: "missing",
187188 auth: "unknown",
188189 account: Option.none(),
189190 },
190191 {
191 kind: "azure-devops",
192 kind: SourceControlProviderKind.make("azure-devops"),
192193 status: "missing",
193194 auth: "unknown",
194195 account: Option.none(),
195196 },
196197 {
197 kind: "bitbucket",
198 kind: SourceControlProviderKind.make("bitbucket"),
198199 status: "available",
199200 auth: "unauthenticated",
200201 account: Option.none(),
201202 },
202203 {
203 kind: "forgejo",
204 kind: SourceControlProviderKind.make("forgejo"),
204205 status: "missing",
205206 auth: "unknown",
206207 account: Option.none(),
207208 },
208209 {
209 kind: "gitcafe",
210 kind: SourceControlProviderKind.make("gitcafe"),
210211 status: "missing",
211212 auth: "unknown",
212213 account: Option.none(),
......@@ -327,37 +328,37 @@ Logged in to gitlab.com as gitlab-user
327328 })),
328329 [
329330 {
330 kind: "github",
331 kind: SourceControlProviderKind.make("github"),
331332 auth: "authenticated",
332333 account: Option.some("octocat"),
333334 detail: Option.none(),
334335 },
335336 {
336 kind: "gitlab",
337 kind: SourceControlProviderKind.make("gitlab"),
337338 auth: "authenticated",
338339 account: Option.some("gitlab-user"),
339340 detail: Option.none(),
340341 },
341342 {
342 kind: "azure-devops",
343 kind: SourceControlProviderKind.make("azure-devops"),
343344 auth: "authenticated",
344345 account: Option.some("azure-user@example.com"),
345346 detail: Option.none(),
346347 },
347348 {
348 kind: "bitbucket",
349 kind: SourceControlProviderKind.make("bitbucket"),
349350 auth: "authenticated",
350351 account: Option.some("bitbucket-user"),
351352 detail: Option.none(),
352353 },
353354 {
354 kind: "forgejo",
355 kind: SourceControlProviderKind.make("forgejo"),
355356 auth: "authenticated",
356357 account: Option.some("forgejo-user"),
357358 detail: Option.none(),
358359 },
359360 {
360 kind: "gitcafe",
361 kind: SourceControlProviderKind.make("gitcafe"),
361362 auth: "authenticated",
362363 account: Option.some("cafe-user"),
363364 detail: Option.none(),
......@@ -528,7 +529,11 @@ it.effect(
528529 reference: "42",
529530 force: true,
530531 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 },
532537 remoteName: "origin",
533538 remoteUrl: "git@forgejo.test:maria/project.git",
534539 },
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+6-5
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { assert, it } from "@effect/vitest";
23import * as NodeServices from "@effect/platform-node/NodeServices";
34import * as DateTime from "effect/DateTime";
......@@ -154,7 +155,7 @@ it.effect("routes directly by provider kind for remote-first workflows", () =>
154155 remotes: [],
155156 });
156157
157 const provider = yield* registry.get("github");
158 const provider = yield* registry.get(SourceControlProviderKind.make("github"));
158159
159160 assert.strictEqual(provider.kind, "github");
160161 }),
......@@ -163,7 +164,7 @@ it.effect("routes directly by provider kind for remote-first workflows", () =>
163164it.effect("includes the request cwd when an unregistered provider is used", () =>
164165 Effect.gen(function* () {
165166 const registry = yield* makeRegistry({ remotes: [] });
166 const provider = yield* registry.get("unknown");
167 const provider = yield* registry.get(SourceControlProviderKind.make("unknown"));
167168
168169 const error = yield* provider
169170 .getChangeRequest({ cwd: "/repo", reference: "#42" })
......@@ -257,7 +258,7 @@ it.effect("refines the caller-selected remote instead of choosing another config
257258 cwd: "/repo",
258259 context: {
259260 provider: {
260 kind: "unknown",
261 kind: SourceControlProviderKind.make("unknown"),
261262 name: "self-hosted.example.test",
262263 baseUrl: "https://self-hosted.example.test",
263264 },
......@@ -412,7 +413,7 @@ it.effect.each([
412413 },
413414 });
414415 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));
416417 assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test");
417418 assert.deepStrictEqual(hosts, ["code.example.test"]);
418419 // 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", (
437438 },
438439 });
439440 const context = {
440 provider: { kind: "unknown" as const, name: "Unknown", baseUrl: "" },
441 provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" },
441442 remoteName: "origin",
442443 remoteUrl,
443444 };
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+5-4
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Cache from "effect/Cache";
23import * as Context from "effect/Context";
34import * as Duration from "effect/Duration";
......@@ -8,7 +9,7 @@ import {
89 SourceControlProviderError,
910 type SourceControlProviderDiscoveryItem,
1011} from "@t3tools/contracts";
11import type { SourceControlProviderKind } from "@t3tools/contracts";
12import type {} from "@t3tools/contracts";
1213import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
1314
1415import * as BuiltInDrivers from "./builtInDrivers.ts";
......@@ -214,7 +215,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
214215 Effect.mapError(
215216 (error) =>
216217 new SourceControlProviderError({
217 provider: "unknown",
218 provider: SourceControlProviderKind.make("unknown"),
218219 operation: "detectProvider",
219220 cwd,
220221 detail: "Failed to detect source control provider.",
......@@ -226,7 +227,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
226227 Effect.mapError(
227228 (error) =>
228229 new SourceControlProviderError({
229 provider: "unknown",
230 provider: SourceControlProviderKind.make("unknown"),
230231 operation: "detectProvider",
231232 cwd,
232233 detail: "Failed to detect source control provider.",
......@@ -265,7 +266,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
265266 })
266267 ).pipe(
267268 Effect.map((context) => {
268 const kind = context?.provider.kind ?? "unknown";
269 const kind = context?.provider.kind ?? SourceControlProviderKind.make("unknown");
269270 const provider = providers.get(kind) ?? unsupportedProvider(kind);
270271 return {
271272 provider: bindProviderContext(provider, context),
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+24-17
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as NodePath from "@effect/platform-node/NodePath";
23import * as NodeServices from "@effect/platform-node/NodeServices";
34import { assert, it } from "@effect/vitest";
......@@ -37,7 +38,7 @@ function makeProvider(
3738 >;
3839
3940 return {
40 kind: "github",
41 kind: SourceControlProviderKind.make("github"),
4142 listChangeRequests: () => unsupported("listChangeRequests"),
4243 getChangeRequest: () => unsupported("getChangeRequest"),
4344 createChangeRequest: () => unsupported("createChangeRequest"),
......@@ -114,19 +115,22 @@ it.effect("looks up repositories through the requested provider without search",
114115 return Effect.gen(function* () {
115116 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
116117 const result = yield* service.lookupRepository({
117 provider: "github",
118 provider: SourceControlProviderKind.make("github"),
118119 repository: "octocat/t3code",
119120 cwd: "/workspace",
120121 });
121122
122 assert.deepStrictEqual(result, { provider: "github", ...CLONE_URLS });
123 assert.deepStrictEqual(result, {
124 provider: SourceControlProviderKind.make("github"),
125 ...CLONE_URLS,
126 });
123127 assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]);
124128 }).pipe(Effect.provide(layer({ provider })));
125129});
126130
127131it.effect("preserves provider failures without deriving the repository message from them", () => {
128132 const providerCause = new SourceControlProviderError({
129 provider: "github",
133 provider: SourceControlProviderKind.make("github"),
130134 operation: "getRepositoryCloneUrls",
131135 cwd: "/workspace",
132136 repository: "octocat/t3code",
......@@ -140,7 +144,7 @@ it.effect("preserves provider failures without deriving the repository message f
140144 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
141145 const error = yield* Effect.flip(
142146 service.lookupRepository({
143 provider: "github",
147 provider: SourceControlProviderKind.make("github"),
144148 repository: "octocat/t3code",
145149 cwd: "/workspace",
146150 }),
......@@ -176,7 +180,7 @@ it.effect(
176180 yield* Effect.gen(function* () {
177181 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
178182 const input = {
179 provider: "bitbucket" as const,
183 provider: SourceControlProviderKind.make("bitbucket"),
180184 repository: locatorCause.repository,
181185 cwd: "/workspace",
182186 };
......@@ -204,11 +208,11 @@ it.effect(
204208
205209it.effect("keeps a plain Bitbucket locator lookalike out of the repository message", () => {
206210 const provider = makeProvider({
207 kind: "bitbucket",
211 kind: SourceControlProviderKind.make("bitbucket"),
208212 getRepositoryCloneUrls: () =>
209213 Effect.fail(
210214 new SourceControlProviderError({
211 provider: "bitbucket",
215 provider: SourceControlProviderKind.make("bitbucket"),
212216 operation: "getRepositoryCloneUrls",
213217 cwd: "/workspace",
214218 detail: "credential token abc123 was rejected",
......@@ -224,7 +228,10 @@ it.effect("keeps a plain Bitbucket locator lookalike out of the repository messa
224228 return Effect.gen(function* () {
225229 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
226230 const error = yield* Effect.flip(
227 service.lookupRepository({ provider: "bitbucket", repository: "t3code" }),
231 service.lookupRepository({
232 provider: SourceControlProviderKind.make("bitbucket"),
233 repository: "t3code",
234 }),
228235 );
229236
230237 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
250257 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
251258 const error = yield* Effect.flip(
252259 service.lookupRepository({
253 provider: "bitbucket",
260 provider: SourceControlProviderKind.make("bitbucket"),
254261 repository: "pingdotgg/t3code",
255262 cwd: "/workspace",
256263 }),
......@@ -276,7 +283,7 @@ it.effect("clones a looked-up repository into the requested destination", () =>
276283 yield* Effect.gen(function* () {
277284 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
278285 const result = yield* service.cloneRepository({
279 provider: "github",
286 provider: SourceControlProviderKind.make("github"),
280287 repository: "octocat/t3code",
281288 destinationPath,
282289 protocol: "https",
......@@ -285,7 +292,7 @@ it.effect("clones a looked-up repository into the requested destination", () =>
285292 assert.deepStrictEqual(result, {
286293 cwd: destinationPath,
287294 remoteUrl: CLONE_URLS.url,
288 repository: { provider: "github", ...CLONE_URLS },
295 repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS },
289296 });
290297 assert.deepStrictEqual(cloneCalls, [
291298 {
......@@ -546,7 +553,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up
546553 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
547554 const result = yield* service.publishRepository({
548555 cwd: "/workspace",
549 provider: "github",
556 provider: SourceControlProviderKind.make("github"),
550557 repository: "octocat/t3code",
551558 visibility: "private",
552559 remoteName: "origin",
......@@ -554,7 +561,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up
554561 });
555562
556563 assert.deepStrictEqual(result, {
557 repository: { provider: "github", ...CLONE_URLS },
564 repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS },
558565 remoteName: "origin",
559566 remoteUrl: CLONE_URLS.sshUrl,
560567 branch: "feature/remote-v1",
......@@ -601,7 +608,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => {
601608 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
602609 const result = yield* service.publishRepository({
603610 cwd: "/workspace",
604 provider: "github",
611 provider: SourceControlProviderKind.make("github"),
605612 repository: "octocat/t3code",
606613 visibility: "private",
607614 remoteName: "origin",
......@@ -637,7 +644,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no
637644 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
638645 const result = yield* service.publishRepository({
639646 cwd: "/workspace",
640 provider: "github",
647 provider: SourceControlProviderKind.make("github"),
641648 repository: "octocat/t3code",
642649 visibility: "private",
643650 remoteName: "origin",
......@@ -645,7 +652,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no
645652 });
646653
647654 assert.deepStrictEqual(result, {
648 repository: { provider: "github", ...CLONE_URLS },
655 repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS },
649656 remoteName: "origin",
650657 remoteUrl: CLONE_URLS.sshUrl,
651658 branch: "main",
apps/server/src/sourceControl/SourceControlRepositoryService.ts+23-12
......@@ -12,7 +12,7 @@ import {
1212 type SourceControlCloneRepositoryInput,
1313 type SourceControlCloneRepositoryResult,
1414 type SourceControlCloneProtocol,
15 type SourceControlProviderKind,
15 SourceControlProviderKind,
1616 type SourceControlPublishRepositoryInput,
1717 type SourceControlPublishRepositoryResult,
1818 type SourceControlRepositoryCloneUrls,
......@@ -209,7 +209,7 @@ export const make = Effect.gen(function* () {
209209 if (trimmed.length === 0) {
210210 return yield* new SourceControlRepositoryError({
211211 operation: "cloneRepository",
212 provider: "unknown",
212 provider: SourceControlProviderKind.make("unknown"),
213213 detail: "Choose a destination path before cloning.",
214214 });
215215 }
......@@ -229,7 +229,7 @@ export const make = Effect.gen(function* () {
229229 (cause) =>
230230 new SourceControlRepositoryError({
231231 operation: "cloneRepository",
232 provider: "unknown",
232 provider: SourceControlProviderKind.make("unknown"),
233233 detail: "Destination path already exists and is not a directory.",
234234 cause,
235235 }),
......@@ -238,7 +238,7 @@ export const make = Effect.gen(function* () {
238238 if (entries.length > 0) {
239239 return yield* new SourceControlRepositoryError({
240240 operation: "cloneRepository",
241 provider: "unknown",
241 provider: SourceControlProviderKind.make("unknown"),
242242 detail: "Destination path already exists and is not empty.",
243243 });
244244 }
......@@ -260,7 +260,8 @@ export const make = Effect.gen(function* () {
260260 const preparedDestination = yield* prepareDestination(input.destinationPath);
261261 let repository: SourceControlRepositoryInfo | null = null;
262262 let remoteUrl = input.remoteUrl?.trim() ?? null;
263 let provider: SourceControlProviderKind = input.provider ?? "unknown";
263 let provider: SourceControlProviderKind =
264 input.provider ?? SourceControlProviderKind.make("unknown");
264265
265266 if (input.provider && input.repository) {
266267 repository = yield* lookupRepository({
......@@ -335,7 +336,7 @@ export const make = Effect.gen(function* () {
335336 (cause) =>
336337 new SourceControlRepositoryError({
337338 operation: "cloneRepository",
338 provider: input.provider ?? "unknown",
339 provider: input.provider ?? SourceControlProviderKind.make("unknown"),
339340 detail:
340341 stderrTail.length > 0
341342 ? stderrTail.join(" ")
......@@ -370,7 +371,7 @@ export const make = Effect.gen(function* () {
370371 (cause) =>
371372 new SourceControlRepositoryError({
372373 operation: "discardClone",
373 provider: "unknown",
374 provider: SourceControlProviderKind.make("unknown"),
374375 detail: "The clone destination could not be inspected.",
375376 cause,
376377 }),
......@@ -379,7 +380,7 @@ export const make = Effect.gen(function* () {
379380 if (entries.length > 0 && !entries.includes(".git")) {
380381 return yield* new SourceControlRepositoryError({
381382 operation: "discardClone",
382 provider: "unknown",
383 provider: SourceControlProviderKind.make("unknown"),
383384 detail: "Destination path contains files that are not from the clone.",
384385 });
385386 }
......@@ -393,7 +394,7 @@ export const make = Effect.gen(function* () {
393394 (cause) =>
394395 new SourceControlRepositoryError({
395396 operation: "discardClone",
396 provider: "unknown",
397 provider: SourceControlProviderKind.make("unknown"),
397398 detail: "The partial clone could not be removed.",
398399 cause,
399400 }),
......@@ -462,13 +463,23 @@ export const make = Effect.gen(function* () {
462463 lookupRepository: (input) =>
463464 lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)),
464465 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 ),
466472 cloneRepository: (input, options) =>
467473 cloneRepository(input, options).pipe(
468 mapRepositoryError("cloneRepository", input.provider ?? "unknown"),
474 mapRepositoryError(
475 "cloneRepository",
476 input.provider ?? SourceControlProviderKind.make("unknown"),
477 ),
469478 ),
470479 discardClone: (destinationPath) =>
471 discardClone(destinationPath).pipe(mapRepositoryError("discardClone", "unknown")),
480 discardClone(destinationPath).pipe(
481 mapRepositoryError("discardClone", SourceControlProviderKind.make("unknown")),
482 ),
472483 publishRepository: (input) =>
473484 publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)),
474485 });
apps/server/src/textGeneration/ThreadTitleLinks.test.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { expect, it } from "@effect/vitest";
23import * as Effect from "effect/Effect";
34import * as Schema from "effect/Schema";
......@@ -87,7 +88,7 @@ it.effect("keeps lookup failure out of generation and skips unlinked messages",
8788 resolveLink: () =>
8889 Effect.fail(
8990 new SourceControlProviderError({
90 provider: "unknown",
91 provider: SourceControlProviderKind.make("unknown"),
9192 operation: "resolveLink",
9293 cwd: "/tmp",
9394 detail: "Unavailable",
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { assert, it, describe } from "@effect/vitest";
23import * as NodeServices from "@effect/platform-node/NodeServices";
34import * as Cause from "effect/Cause";
......@@ -34,7 +35,7 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
3435const baseLocalStatus: VcsStatusLocalResult = {
3536 isRepo: true,
3637 sourceControlProvider: {
37 kind: "github",
38 kind: SourceControlProviderKind.make("github"),
3839 name: "GitHub",
3940 baseUrl: "https://github.com",
4041 },
apps/web/src/components/CommandPalette.tsx+10-7
......@@ -39,7 +39,7 @@ import {
3939 type FilesystemBrowseResult,
4040 type ProjectId,
4141 type SourceControlDiscoveryResult,
42 type SourceControlProviderKind,
42 SourceControlProviderKind,
4343 type SourceControlRepositoryInfo,
4444 PRIMARY_LOCAL_ENVIRONMENT_ID,
4545 resolveEnvironmentMachineKind,
......@@ -257,10 +257,13 @@ interface AddProjectEnvironmentOption {
257257 readonly status: string;
258258}
259259
260type AddProjectRemoteProviderKind = Extract<
261 SourceControlProviderKind,
262 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"
263>;
260type AddProjectRemoteProviderKind =
261 | "github"
262 | "gitlab"
263 | "forgejo"
264 | "bitbucket"
265 | "azure-devops"
266 | "gitcafe";
264267type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
265268
266269type AddProjectCloneFlow =
......@@ -421,7 +424,7 @@ function buildAddProjectRemoteSourceReadiness(
421424 for (const source of REMOTE_PROJECT_SOURCES) {
422425 const kind = sourceProviderKind(source);
423426 if (!kind) continue;
424 const provider = providerByKind.get(kind);
427 const provider = providerByKind.get(SourceControlProviderKind.make(kind));
425428 if (!provider) {
426429 readiness[source] = unavailable;
427430 continue;
......@@ -2628,7 +2631,7 @@ function OpenCommandPaletteDialog(props: {
26282631 const lookupResult = await lookupRepository({
26292632 environmentId: addProjectCloneFlow.environmentId,
26302633 input: {
2631 provider,
2634 provider: SourceControlProviderKind.make(provider),
26322635 repository: rawRepository,
26332636 },
26342637 });
apps/web/src/components/GitActionsControl.logic.test.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import type { VcsStatusResult } from "@t3tools/contracts";
23import { assert, describe, it } from "vite-plus/test";
34import {
......@@ -343,7 +344,7 @@ describe("when: source control provider uses merge requests", () => {
343344 const gitlabStatus = status({
344345 aheadCount: 2,
345346 sourceControlProvider: {
346 kind: "gitlab",
347 kind: SourceControlProviderKind.make("gitlab"),
347348 name: "GitLab",
348349 baseUrl: "https://gitlab.com",
349350 },
apps/web/src/components/GitActionsControl.tsx+13-11
......@@ -3,6 +3,7 @@ import { useAtomValue } from "@effect/atom-react";
33import {
44 AuthOrchestrationOperateScope,
55 AuthSourceControlWriteScope,
6 SourceControlProviderKind,
67 type ScopedThreadRef,
78} from "@t3tools/contracts";
89import {
......@@ -14,7 +15,6 @@ import type {
1415 GitStackedAction,
1516 SourceControlCloneProtocol,
1617 SourceControlProviderDiscoveryItem,
17 SourceControlProviderKind,
1818 SourceControlPublishRepositoryResult,
1919 SourceControlRepositoryVisibility,
2020 VcsStatusResult,
......@@ -158,10 +158,13 @@ interface PendingDefaultBranchAction {
158158 filePaths?: string[];
159159}
160160
161type PublishProviderKind = Extract<
162 SourceControlProviderKind,
163 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"
164>;
161type PublishProviderKind =
162 | "github"
163 | "gitlab"
164 | "forgejo"
165 | "bitbucket"
166 | "azure-devops"
167 | "gitcafe";
165168
166169type GitActionToastId = ReturnType<typeof toastManager.add>;
167170
......@@ -265,9 +268,7 @@ function publishProviderOption(provider: PublishProviderKind) {
265268 );
266269}
267270
268function isPublishProviderKind(
269 provider: SourceControlProviderKind,
270): provider is PublishProviderKind {
271function isPublishProviderKind(provider: string): provider is PublishProviderKind {
271272 return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider);
272273}
273274
......@@ -582,8 +583,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
582583 gitcafe: null,
583584 };
584585 for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) {
585 if (isPublishProviderKind(provider.kind)) {
586 accounts[provider.kind] = Option.getOrNull(provider.auth.account);
586 const kind: string = provider.kind;
587 if (isPublishProviderKind(kind)) {
588 accounts[kind] = Option.getOrNull(provider.auth.account);
587589 }
588590 }
589591 return accounts;
......@@ -670,7 +672,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
670672
671673 void (async () => {
672674 const result = await publishRepositoryAction.run({
673 provider: publishProvider,
675 provider: SourceControlProviderKind.make(publishProvider),
674676 repository: publishRepository.trim(),
675677 visibility: publishVisibility,
676678 remoteName: publishRemoteName.trim() || "origin",
apps/web/src/components/ThreadStatusIndicators.test.ts+3-2
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";
23import { describe, expect, it } from "@effect/vitest";
34import * as Effect from "effect/Effect";
......@@ -93,7 +94,7 @@ function pullRequestSummary(
9394 updatedAt: string,
9495): PullRequestSummary {
9596 return {
96 provider: "github",
97 provider: SourceControlProviderKind.make("github"),
9798 projectId: ProjectId.make("project-1"),
9899 repository: "pingdotgg/t3code",
99100 number: 42,
......@@ -179,7 +180,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
179180 url: "https://github.com/pingdotgg/t3code/pull/42",
180181 };
181182 const provider = {
182 kind: "github" as const,
183 kind: SourceControlProviderKind.make("github"),
183184 name: "GitHub",
184185 baseUrl: "https://github.com",
185186 };
apps/web/src/components/ThreadStatusIndicators.tsx+2-1
......@@ -13,6 +13,7 @@ import {
1313 type ThreadLinkedPullRequest,
1414 type ThreadPullRequestLink,
1515 type VcsStatusResult,
16 SourceControlProviderKind,
1617} from "@t3tools/contracts";
1718import { Atom } from "effect/reactivity";
1819import { FolderGit2Icon, TerminalIcon } from "lucide-react";
......@@ -154,7 +155,7 @@ export function linkedPullRequestSnapshotStatus(
154155 baseRef: snapshot.baseBranch,
155156 ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }),
156157 },
157 sourceControlProvider: { kind, name: kind, baseUrl: "" },
158 sourceControlProvider: { kind: SourceControlProviderKind.make(kind), name: kind, baseUrl: "" },
158159 };
159160}
160161
apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import {
23 EnvironmentId,
34 ProjectId,
......@@ -151,7 +152,7 @@ import { PullRequestDetailPanel } from "./PullRequestDetailPanel";
151152import { pullRequestPanelContext } from "./pullRequestDetail.logic";
152153
153154const detail: PullRequestDetailView = {
154 provider: "github",
155 provider: SourceControlProviderKind.make("github"),
155156 projectId: ProjectId.make("project"),
156157 projectTitle: "Project",
157158 workspaceRoot: "/workspace",
apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/contracts";
23import { act, type ReactNode } from "react";
34import { create, type ReactTestRenderer } from "react-test-renderer";
......@@ -26,7 +27,7 @@ vi.mock("../ui/tooltip", () => ({
2627import { PullRequestSummaryTab } from "./PullRequestSummaryTab";
2728
2829const detail: PullRequestDetailView = {
29 provider: "github",
30 provider: SourceControlProviderKind.make("github"),
3031 projectId: ProjectId.make("project"),
3132 projectTitle: "Project",
3233 workspaceRoot: "/workspace",
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+12-8
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { resolvePlanFollowUpSubmission } from "../../proposedPlan";
23import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend";
34import {
......@@ -95,12 +96,14 @@ describe("pull request checkout commands", () => {
9596 ],
9697 ["unknown", "feature", null, null],
9798 ] 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);
99102 });
100103 it("fetches Forgejo pull refs from the actual repository, including a mounted host and port", () => {
101104 expect(
102105 pullRequestCheckoutCommand(
103 "forgejo",
106 SourceControlProviderKind.make("forgejo"),
104107 42,
105108 "feature",
106109 null,
......@@ -113,7 +116,7 @@ describe("pull request checkout commands", () => {
113116 it("quotes shell metacharacters in Forgejo repository URLs", () => {
114117 expect(
115118 pullRequestCheckoutCommand(
116 "forgejo",
119 SourceControlProviderKind.make("forgejo"),
117120 42,
118121 "feature",
119122 null,
......@@ -1714,7 +1717,7 @@ describe("cached pull request detail", () => {
17141717 const reference = { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 7 };
17151718 const detail = (overrides: Partial<PullRequestDetail> = {}): PullRequestDetail =>
17161719 ({
1717 provider: "github",
1720 provider: SourceControlProviderKind.make("github"),
17181721 capabilities: {
17191722 diff: true,
17201723 comment: true,
......@@ -1797,7 +1800,7 @@ describe("cached pull request detail", () => {
17971800 remoteName: "origin",
17981801 remoteUrl: "https://github.com/acme/web.git",
17991802 },
1800 provider: "github",
1803 provider: SourceControlProviderKind.make("github"),
18011804 });
18021805 expect(readPullRequestDetailSnapshot(storage, "env-1", resolved)?.title).toBe(
18031806 "Cache the title",
......@@ -1824,7 +1827,7 @@ describe("cached pull request detail", () => {
18241827 remoteName: "origin",
18251828 remoteUrl: "git@ssh.dev.azure.com:v3/org/project/web",
18261829 },
1827 provider: "azure-devops",
1830 provider: SourceControlProviderKind.make("azure-devops"),
18281831 }),
18291832 ).toBe(reference);
18301833 expect(resolvePullRequestReferenceHost(reference, undefined)).toBe(reference);
......@@ -1847,7 +1850,7 @@ describe("cached pull request detail", () => {
18471850 it("keeps Forgejo ports isolated when recovering legacy snapshots", () => {
18481851 const storage = makeStorage();
18491852 const cached = detail({
1850 provider: "forgejo",
1853 provider: SourceControlProviderKind.make("forgejo"),
18511854 url: "https://forge.example:8443/acme/web/pulls/7",
18521855 });
18531856 writePullRequestDetailSnapshot(storage, "env-1", reference, cached);
......@@ -1869,7 +1872,8 @@ describe("cached pull request detail", () => {
18691872
18701873 it.each(["github", "gitlab"] as const)(
18711874 "retains portless %s snapshot identities for custom web ports",
1872 (provider) => {
1875 (providerName) => {
1876 const provider = SourceControlProviderKind.make(providerName);
18731877 const storage = makeStorage();
18741878 const host = `${provider}.example.com`;
18751879 const hosted = { ...reference, host };
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+9-5
......@@ -21,7 +21,7 @@ import {
2121 type PullRequestReviewThread,
2222 type PullRequestState,
2323 type PullRequestUpdateMethod,
24 type SourceControlProviderKind,
24 SourceControlProviderKind,
2525 type ThreadLinkedPullRequest,
2626 type ThreadPullRequestLink,
2727 type VcsRef,
......@@ -140,7 +140,7 @@ export function pullRequestCheckoutCommand(
140140 }
141141 return `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`;
142142 }
143 case "unknown":
143 default:
144144 return null;
145145 }
146146}
......@@ -155,10 +155,11 @@ export function loadingPullRequestCheckoutCommand(
155155 identity?.provider ??
156156 (host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null);
157157 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) {
159160 return null;
160161 }
161 return pullRequestCheckoutCommand(provider, reference.number, "");
162 return pullRequestCheckoutCommand(kind, reference.number, "");
162163}
163164
164165/** Activity changes only when the same host resource reports a newer revision. */
......@@ -1304,7 +1305,10 @@ export function resolvePullRequestReferenceHost(
13041305): PullRequestRef {
13051306 // Other providers may resolve an SSH remote to a different web authority on the server.
13061307 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 };
13081312}
13091313
13101314export interface PullRequestDetailSnapshotRef {
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+3-2
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import type { EnvironmentId, ProjectId, PullRequestListEntry } from "@t3tools/contracts";
23import { describe, expect, it } from "vite-plus/test";
34
......@@ -330,7 +331,7 @@ describe("pull request involvement filtering", () => {
330331 entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }),
331332 entry({
332333 number: 2,
333 provider: "gitlab",
334 provider: SourceControlProviderKind.make("gitlab"),
334335 host: "gitlab.com",
335336 author: { login: "Bilal", name: null, avatarUrl: null },
336337 }),
......@@ -1322,7 +1323,7 @@ describe("merging the environments' own listings", () => {
13221323 providers: [
13231324 {
13241325 host: "github.com",
1325 kind: "github",
1326 kind: SourceControlProviderKind.make("github"),
13261327 searchesOnHost: false,
13271328 projectCount: 2,
13281329 configured: false,
apps/web/src/components/settings/SourceControlSettings.tsx+1-1
......@@ -78,7 +78,7 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
7878 sourceControlProviders: [],
7979};
8080
81const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = {
81const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<string, Icon>> = {
8282 github: GitHubIcon,
8383 gitlab: GitLabIcon,
8484 forgejo: ForgejoIcon,
apps/web/src/hooks/useNewProject.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { scopeProjectRef } from "@t3tools/client-runtime/environment";
23import { getNewProjectGitHubRepository } from "@t3tools/client-runtime/operations/projects";
34import {
......@@ -45,7 +46,7 @@ export function useNewProject() {
4546 environmentId: input.environmentId,
4647 input: {
4748 cwd: input.workspaceRoot,
48 provider: "github",
49 provider: SourceControlProviderKind.make("github"),
4950 repository: getNewProjectGitHubRepository(input, input.workspaceRoot),
5051 visibility: "private",
5152 },
apps/web/src/state/pullRequests.test.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { ProjectId, type PullRequestSummary } from "@t3tools/contracts";
23import { describe, expect, it } from "vite-plus/test";
34
......@@ -5,7 +6,7 @@ import { newestPullRequestObservation, newestPullRequestSummary } from "./pullRe
56
67function summary(overrides: Partial<PullRequestSummary> = {}): PullRequestSummary {
78 return {
8 provider: "github",
9 provider: SourceControlProviderKind.make("github"),
910 projectId: ProjectId.make("pull-request-cache-test"),
1011 repository: "acme/widget",
1112 number: 7,
apps/web/src/state/sourceControlActions.test.ts+2-1
......@@ -3,6 +3,7 @@ import {
33 AuthSourceControlWriteScope,
44 EnvironmentId,
55 ThreadId,
6 SourceControlProviderKind,
67} from "@t3tools/contracts";
78import * as Cause from "effect/Cause";
89import { AsyncResult } from "effect/reactivity";
......@@ -98,7 +99,7 @@ const cases = [
9899 isAllowed: action.isAllowed,
99100 run: () =>
100101 action.run({
101 provider: "github",
102 provider: SourceControlProviderKind.make("github"),
102103 repository: "owner/repo",
103104 visibility: "private",
104105 remoteName: "origin",
apps/web/src/state/sourceControlActions.ts+2-1
......@@ -16,6 +16,7 @@ import {
1616 type EnvironmentId,
1717 type GitResolvePullRequestResult,
1818 type SourceControlCloneProtocol,
19 type SourceControlProviderKind,
1920 type SourceControlRepositoryVisibility,
2021 type ThreadId,
2122} from "@t3tools/contracts";
......@@ -275,7 +276,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi
275276 );
276277 const action = useCallback(
277278 async (input: {
278 provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe";
279 provider: SourceControlProviderKind;
279280 repository: string;
280281 visibility: SourceControlRepositoryVisibility;
281282 remoteName: string;
packages/client-runtime/src/operations/projects.test.ts+6-5
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { describe, expect, it } from "vite-plus/test";
23import {
34 EnvironmentId,
......@@ -71,14 +72,14 @@ describe("add project shared logic", () => {
7172 it("uses HTTPS for repositories selected through a provider", () => {
7273 expect(
7374 getDefaultCloneUrl({
74 provider: "github",
75 provider: SourceControlProviderKind.make("github"),
7576 url: "https://github.com/imputnet/helium",
7677 sshUrl: "git@github.com:imputnet/helium.git",
7778 }),
7879 ).toBe("https://github.com/imputnet/helium");
7980 expect(
8081 getDefaultCloneUrl({
81 provider: "forgejo",
82 provider: SourceControlProviderKind.make("forgejo"),
8283 url: "https://forgejo.example.test:8443/owner/repo.git",
8384 sshUrl: "ssh://git@forgejo.example.test:2222/owner/repo.git",
8485 }),
......@@ -88,7 +89,7 @@ describe("add project shared logic", () => {
8889 it("preserves existing clone transport behavior for other providers", () => {
8990 expect(
9091 getDefaultCloneUrl({
91 provider: "gitlab",
92 provider: SourceControlProviderKind.make("gitlab"),
9293 url: "https://gitlab.com/group/project.git",
9394 sshUrl: "git@gitlab.com:group/project.git",
9495 }),
......@@ -187,7 +188,7 @@ describe("add project shared logic", () => {
187188 versionControlSystems: [],
188189 sourceControlProviders: [
189190 {
190 kind: "github",
191 kind: SourceControlProviderKind.make("github"),
191192 label: "GitHub",
192193 status: "available",
193194 installHint: "Install gh",
......@@ -201,7 +202,7 @@ describe("add project shared logic", () => {
201202 },
202203 },
203204 {
204 kind: "gitlab",
205 kind: SourceControlProviderKind.make("gitlab"),
205206 label: "GitLab",
206207 status: "available",
207208 installHint: "Install glab",
packages/client-runtime/src/operations/projects.ts+8-6
......@@ -6,9 +6,9 @@ import type {
66 ProjectId,
77 ServerConfig,
88 SourceControlDiscoveryResult,
9 SourceControlProviderKind,
109 SourceControlRepositoryInfo,
1110} from "@t3tools/contracts";
11import { SourceControlProviderKind } from "@t3tools/contracts";
1212import { newProjectFolderName } from "@t3tools/shared/path";
1313import * as Arr from "effect/Array";
1414import * as Option from "effect/Option";
......@@ -25,10 +25,12 @@ import {
2525} from "../state/projects.ts";
2626import type { EnvironmentProject } from "../state/models.ts";
2727
28export type AddProjectRemoteProviderKind = Extract<
29 SourceControlProviderKind,
30 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"
31>;
28export type AddProjectRemoteProviderKind =
29 | "github"
30 | "gitlab"
31 | "forgejo"
32 | "bitbucket"
33 | "azure-devops";
3234export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
3335
3436export function canCreateProjectInEnvironment(
......@@ -190,7 +192,7 @@ export function buildAddProjectRemoteSourceReadiness(
190192 for (const source of ADD_PROJECT_REMOTE_SOURCES) {
191193 const kind = addProjectRemoteSourceProvider(source);
192194 if (!kind) continue;
193 const provider = providerByKind.get(kind);
195 const provider = providerByKind.get(SourceControlProviderKind.make(kind));
194196 if (!provider) {
195197 readiness[source] = unavailable;
196198 continue;
packages/client-runtime/src/state/sourceControl.test.ts+4-3
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { vi } from "vite-plus/test";
23import {
34 EnvironmentId,
......@@ -36,7 +37,7 @@ const TARGET = new PrimaryConnectionTarget({
3637
3738const PUBLISH_RESULT: SourceControlPublishRepositoryResult = {
3839 repository: {
39 provider: "github",
40 provider: SourceControlProviderKind.make("github"),
4041 nameWithOwner: "t3tools/t3code",
4142 url: "https://github.com/t3tools/t3code",
4243 sshUrl: "git@github.com:t3tools/t3code.git",
......@@ -140,7 +141,7 @@ describe("source control environment atoms", () => {
140141 environmentId: TARGET.environmentId,
141142 input: {
142143 cwd: "/repo",
143 provider: "github",
144 provider: SourceControlProviderKind.make("github"),
144145 repository: "t3tools/t3code",
145146 visibility: "private",
146147 },
......@@ -156,7 +157,7 @@ describe("source control environment atoms", () => {
156157 environmentId: TARGET.environmentId,
157158 input: {
158159 cwd: "/repo",
159 provider: "github",
160 provider: SourceControlProviderKind.make("github"),
160161 repository: "t3tools/t3code",
161162 visibility: "private",
162163 },
packages/contracts/src/pullRequest.test.ts+11-6
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "./sourceControl.ts";
12import * as Schema from "effect/Schema";
23import { describe, expect, it } from "vite-plus/test";
34
......@@ -25,7 +26,7 @@ const LIST_RESULT: PullRequestListResult = {
2526 providers: [
2627 {
2728 host: "github.com",
28 kind: "github",
29 kind: SourceControlProviderKind.make("github"),
2930 searchesOnHost: true,
3031 projectCount: 1,
3132 configured: true,
......@@ -33,7 +34,7 @@ const LIST_RESULT: PullRequestListResult = {
3334 },
3435 {
3536 host: "gitlab.com",
36 kind: "gitlab",
37 kind: SourceControlProviderKind.make("gitlab"),
3738 searchesOnHost: true,
3839 projectCount: 1,
3940 configured: false,
......@@ -42,7 +43,7 @@ const LIST_RESULT: PullRequestListResult = {
4243 ],
4344 entries: [
4445 {
45 provider: "github",
46 provider: SourceControlProviderKind.make("github"),
4647 host: "github.com",
4748 projectId: "project-1" as PullRequestListResult["entries"][number]["projectId"],
4849 projectTitle: "t3code",
......@@ -75,12 +76,16 @@ describe("PullRequestListResult", () => {
7576 canonicalKey: "forge.example/team/repo",
7677 locator: { remoteUrl: "http://forge.example:3000/team/repo.git" },
7778 };
78 expect(pullRequestHostOf(identity, "forgejo")).toBe("forge.example:3000");
79 expect(pullRequestHostOf(identity, "gitlab")).toBe("forge.example");
79 expect(pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo"))).toBe(
80 "forge.example:3000",
81 );
82 expect(pullRequestHostOf(identity, SourceControlProviderKind.make("gitlab"))).toBe(
83 "forge.example",
84 );
8085 expect(
8186 pullRequestHostOf(
8287 { ...identity, locator: { remoteUrl: "ssh://git@forge.example:2222/team/repo.git" } },
83 "forgejo",
88 SourceControlProviderKind.make("forgejo"),
8489 ),
8590 ).toBe("forge.example");
8691 });
packages/contracts/src/pullRequest.ts+50-34
......@@ -1301,38 +1301,54 @@ export type PullRequestUnavailableReason = typeof PullRequestUnavailableReason.T
13011301 * symptom. The reason names keep their `cli-` prefix for wire compatibility; for GitHub and
13021302 * Bitbucket they mean "no credential" and "a refused credential", not a missing tool.
13031303 */
1304const PROVIDER_REQUIREMENT: Partial<
1305 Record<SourceControlProviderKind, { readonly missing: string; readonly unauthenticated: string }>
1306> = {
1307 github: {
1308 missing:
1309 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI (https://cli.github.com/) and run `gh auth login`.",
1310 unauthenticated:
1311 "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.",
1312 },
1313 forgejo: {
1314 missing:
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.",
1316 unauthenticated:
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.",
1318 },
1319 gitlab: {
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.",
1322 unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.",
1323 },
1324 "azure-devops": {
1325 missing:
1326 "Azure CLI (`az`) with the Azure DevOps extension is required. Install `az`, then run `az extension add --name azure-devops`.",
1327 unauthenticated: "Azure CLI is not signed in. Run `az login` and retry.",
1328 },
1329 bitbucket: {
1330 missing:
1331 "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.",
1332 unauthenticated:
1333 "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.",
1334 },
1335};
1304const PROVIDER_REQUIREMENT = new Map<
1305 string,
1306 { readonly missing: string; readonly unauthenticated: string }
1307>([
1308 [
1309 "github",
1310 {
1311 missing:
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 unauthenticated:
1314 "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.",
1315 },
1316 ],
1317 [
1318 "forgejo",
1319 {
1320 missing:
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:
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 },
1325 ],
1326 [
1327 "gitlab",
1328 {
1329 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 unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.",
1332 },
1333 ],
1334 [
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
13371353/**
13381354 * The host a project's repository is addressed below. `canonicalKey` is the normalized remote,
......@@ -1394,7 +1410,7 @@ export function pullRequestProviderRequirement(
13941410 provider: SourceControlProviderKind,
13951411 reason: PullRequestUnavailableReason,
13961412): string | null {
1397 const requirement = PROVIDER_REQUIREMENT[provider];
1413 const requirement = PROVIDER_REQUIREMENT.get(provider);
13981414 if (requirement === undefined) return null;
13991415 switch (reason) {
14001416 case "cli-missing":
......@@ -1427,7 +1443,7 @@ export class PullRequestUnavailableError extends Schema.TaggedError<PullRequestU
14271443
14281444 override get message(): string {
14291445 const requirement =
1430 this.provider === undefined ? undefined : PROVIDER_REQUIREMENT[this.provider];
1446 this.provider === undefined ? undefined : PROVIDER_REQUIREMENT.get(this.provider);
14311447 switch (this.reason) {
14321448 case "cli-missing":
14331449 return (
packages/contracts/src/sourceControl.ts+11-9
......@@ -2,15 +2,17 @@ import * as Schema from "effect/Schema";
22import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
33import { VcsDriverKind } from "./vcs.ts";
44
5export const SourceControlProviderKind = Schema.Literals([
6 "github",
7 "gitlab",
8 "forgejo",
9 "azure-devops",
10 "bitbucket",
11 "gitcafe",
12 "unknown",
13]);
5/**
6 * `SourceControlProviderKind` — open branded slug naming a source control host, such as `github`.
7 *
8 * Open on purpose, like `ProviderDriverKind`: a server can report a host this client does not
9 * ship a definition for, and clients fall back to a generic presentation instead of failing to
10 * decode. `unknown` is what the server reports for a remote it cannot attribute to any host.
11 */
12export const SourceControlProviderKind = TrimmedNonEmptyString.check(
13 Schema.isMaxLength(64),
14 Schema.isPattern(/^[a-z][a-z0-9-]*$/),
15).pipe(Schema.brand("SourceControlProviderKind"));
1416export type SourceControlProviderKind = typeof SourceControlProviderKind.Type;
1517
1618export const SourceControlProviderInfo = Schema.Struct({
packages/shared/src/git.test.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import type { VcsStatusRemoteResult, VcsStatusResult } from "@t3tools/contracts";
23import { describe, expect, it } from "vite-plus/test";
34
......@@ -257,7 +258,7 @@ describe("applyGitStatusStreamEvent", () => {
257258 const current: VcsStatusResult = {
258259 isRepo: true,
259260 sourceControlProvider: {
260 kind: "github",
261 kind: SourceControlProviderKind.make("github"),
261262 name: "GitHub",
262263 baseUrl: "https://github.com",
263264 },
packages/shared/src/sourceControl.test.ts+18-9
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { describe, expect, it } from "vite-plus/test";
23
34import {
......@@ -10,30 +11,38 @@ import {
1011
1112describe("source control presentation", () => {
1213 it("uses merge request terminology for GitLab", () => {
13 expect(getChangeRequestTerminologyForKind("gitlab")).toEqual({
14 expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("gitlab"))).toEqual({
1415 shortLabel: "MR",
1516 singular: "merge request",
1617 });
1718 });
1819
1920 it("uses pull request terminology for GitHub-compatible providers", () => {
20 expect(getChangeRequestTerminologyForKind("github")).toEqual({
21 expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("github"))).toEqual({
2122 shortLabel: "PR",
2223 singular: "pull request",
2324 });
24 expect(getChangeRequestTerminologyForKind("azure-devops")).toEqual({
25 shortLabel: "PR",
26 singular: "pull request",
27 });
28 expect(getChangeRequestTerminologyForKind("bitbucket")).toEqual({
25 expect(
26 getChangeRequestTerminologyForKind(SourceControlProviderKind.make("azure-devops")),
27 ).toEqual({
2928 shortLabel: "PR",
3029 singular: "pull request",
3130 });
31 expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("bitbucket"))).toEqual(
32 {
33 shortLabel: "PR",
34 singular: "pull request",
35 },
36 );
3237 });
3338
3439 it("falls back to generic change request copy for unknown providers", () => {
3540 expect(
36 resolveChangeRequestPresentation({ kind: "unknown", name: "forge", baseUrl: "" }),
41 resolveChangeRequestPresentation({
42 kind: SourceControlProviderKind.make("unknown"),
43 name: "forge",
44 baseUrl: "",
45 }),
3746 ).toEqual(
3847 expect.objectContaining({
3948 shortName: "change request",
......@@ -69,7 +78,7 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {
6978 },
7079 );
7180 }
72 expect(getChangeRequestTerminologyForKind("forgejo")).toEqual({
81 expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("forgejo"))).toEqual({
7382 shortLabel: "PR",
7483 singular: "pull request",
7584 });
packages/shared/src/sourceControl.ts+14-13
......@@ -1,8 +1,5 @@
1import type {
2 RepositoryIdentity,
3 SourceControlProviderInfo,
4 SourceControlProviderKind,
5} from "@t3tools/contracts";
1import { SourceControlProviderKind } from "@t3tools/contracts";
2import type { RepositoryIdentity, SourceControlProviderInfo } from "@t3tools/contracts";
63
74export interface ChangeRequestPresentation {
85 readonly icon:
......@@ -124,7 +121,7 @@ export function resolveChangeRequestPresentation(
124121 return BITBUCKET_CHANGE_REQUEST_PRESENTATION;
125122 case "gitcafe":
126123 return GITCAFE_CHANGE_REQUEST_PRESENTATION;
127 case "unknown":
124 default:
128125 return GENERIC_CHANGE_REQUEST_PRESENTATION;
129126 }
130127}
......@@ -244,7 +241,11 @@ export function detectSourceControlProviderFromRemoteUrl(
244241 const hostname = parseHostName(host);
245242
246243 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 };
248249 }
249250
250251 if (
......@@ -253,7 +254,7 @@ export function detectSourceControlProviderFromRemoteUrl(
253254 hasDnsLabel(hostname, "gitea")
254255 ) {
255256 return {
256 kind: "forgejo",
257 kind: SourceControlProviderKind.make("forgejo"),
257258 name: "Forgejo",
258259 baseUrl: /^https?:/iu.test(remoteUrl.trim())
259260 ? new URL(remoteUrl.trim()).origin
......@@ -263,7 +264,7 @@ export function detectSourceControlProviderFromRemoteUrl(
263264
264265 if (isGitHubHost(hostname)) {
265266 return {
266 kind: "github",
267 kind: SourceControlProviderKind.make("github"),
267268 name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted",
268269 baseUrl: toBaseUrl(host),
269270 };
......@@ -271,7 +272,7 @@ export function detectSourceControlProviderFromRemoteUrl(
271272
272273 if (isGitLabHost(hostname)) {
273274 return {
274 kind: "gitlab",
275 kind: SourceControlProviderKind.make("gitlab"),
275276 name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted",
276277 baseUrl: toBaseUrl(host),
277278 };
......@@ -279,7 +280,7 @@ export function detectSourceControlProviderFromRemoteUrl(
279280
280281 if (isAzureDevOpsHost(hostname)) {
281282 return {
282 kind: "azure-devops",
283 kind: SourceControlProviderKind.make("azure-devops"),
283284 name: "Azure DevOps",
284285 baseUrl: toBaseUrl(host),
285286 };
......@@ -287,14 +288,14 @@ export function detectSourceControlProviderFromRemoteUrl(
287288
288289 if (isBitbucketHost(hostname)) {
289290 return {
290 kind: "bitbucket",
291 kind: SourceControlProviderKind.make("bitbucket"),
291292 name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted",
292293 baseUrl: toBaseUrl(host),
293294 };
294295 }
295296
296297 return {
297 kind: "unknown",
298 kind: SourceControlProviderKind.make("unknown"),
298299 name: host,
299300 baseUrl: toBaseUrl(host),
300301 };
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts+5-4
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
23import * as Semaphore from "effect/Semaphore";
34import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
......@@ -157,7 +158,7 @@ export const make = Effect.gen(function* () {
157158 const fail =
158159 (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) =>
159160 new PullRequestProviderError({
160 provider: "azure-devops",
161 provider: SourceControlProviderKind.make("azure-devops"),
161162 operation,
162163 ...azureDevOpsProviderFailure(error),
163164 detail: error.detail,
......@@ -168,7 +169,7 @@ export const make = Effect.gen(function* () {
168169 const unsupported = (operation: string) =>
169170 Effect.fail(
170171 new PullRequestProviderError({
171 provider: "azure-devops",
172 provider: SourceControlProviderKind.make("azure-devops"),
172173 operation,
173174 reason: "failed",
174175 detail: "Azure DevOps reviews cannot be written from here yet.",
......@@ -296,7 +297,7 @@ export const make = Effect.gen(function* () {
296297 };
297298
298299 const provider: PullRequestProviderApi = {
299 kind: "azure-devops",
300 kind: SourceControlProviderKind.make("azure-devops"),
300301 capabilities: CAPABILITIES,
301302 // The bare repository name repeats across an organization; the project path does not.
302303 repositoryKey: ({ canonicalKey }) => canonicalKey,
......@@ -612,7 +613,7 @@ export const make = Effect.gen(function* () {
612613 listReviewerCandidates: () =>
613614 Effect.fail(
614615 new PullRequestProviderError({
615 provider: "azure-devops",
616 provider: SourceControlProviderKind.make("azure-devops"),
616617 operation: "listReviewerCandidates",
617618 reason: "failed",
618619 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 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { assert, it } from "@effect/vitest";
23import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";
......@@ -35,7 +36,7 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests"
3536 });
3637
3738 assert.deepStrictEqual(changeRequest, {
38 provider: "azure-devops",
39 provider: SourceControlProviderKind.make("azure-devops"),
3940 number: 42,
4041 title: "Add Azure provider",
4142 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 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
23import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
34
......@@ -39,7 +40,7 @@ function parseAzureAuth(input: SourceControlAuthProbeInput) {
3940
4041export const discovery = {
4142 type: "cli",
42 kind: "azure-devops",
43 kind: SourceControlProviderKind.make("azure-devops"),
4344 label: "Azure DevOps",
4445 executable: "az",
4546 versionArgs: ["--version"],
......@@ -67,7 +68,7 @@ function toChangeRequest(summary: {
6768 readonly updatedAt: ChangeRequest["updatedAt"];
6869}): ChangeRequest {
6970 return {
70 provider: "azure-devops",
71 provider: SourceControlProviderKind.make("azure-devops"),
7172 number: summary.number,
7273 title: summary.title,
7374 url: summary.url,
......@@ -86,7 +87,7 @@ export const make = Effect.gen(function* () {
8687 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
8788
8889 return SourceControlProvider.SourceControlProvider.of({
89 kind: "azure-devops",
90 kind: SourceControlProviderKind.make("azure-devops"),
9091 listChangeRequests: (input) => {
9192 const source = SourceControlProvider.sourceControlRefFromInput(input);
9293 return azure
......@@ -102,7 +103,7 @@ export const make = Effect.gen(function* () {
102103 Effect.mapError(
103104 (error) =>
104105 new SourceControlProviderError({
105 provider: "azure-devops",
106 provider: SourceControlProviderKind.make("azure-devops"),
106107 operation: "listChangeRequests",
107108 command: error.command,
108109 cwd: input.cwd,
......@@ -121,7 +122,7 @@ export const make = Effect.gen(function* () {
121122 Effect.mapError(
122123 (error) =>
123124 new SourceControlProviderError({
124 provider: "azure-devops",
125 provider: SourceControlProviderKind.make("azure-devops"),
125126 operation: "getChangeRequest",
126127 command: error.command,
127128 cwd: input.cwd,
......@@ -149,7 +150,7 @@ export const make = Effect.gen(function* () {
149150 Effect.mapError(
150151 (error) =>
151152 new SourceControlProviderError({
152 provider: "azure-devops",
153 provider: SourceControlProviderKind.make("azure-devops"),
153154 operation: "createChangeRequest",
154155 command: error.command,
155156 cwd: input.cwd,
......@@ -167,7 +168,7 @@ export const make = Effect.gen(function* () {
167168 Effect.mapError(
168169 (error) =>
169170 new SourceControlProviderError({
170 provider: "azure-devops",
171 provider: SourceControlProviderKind.make("azure-devops"),
171172 operation: "getRepositoryCloneUrls",
172173 command: error.command,
173174 cwd: input.cwd,
......@@ -184,7 +185,7 @@ export const make = Effect.gen(function* () {
184185 Effect.mapError(
185186 (error) =>
186187 new SourceControlProviderError({
187 provider: "azure-devops",
188 provider: SourceControlProviderKind.make("azure-devops"),
188189 operation: "createRepository",
189190 command: error.command,
190191 cwd: input.cwd,
......@@ -201,7 +202,7 @@ export const make = Effect.gen(function* () {
201202 Effect.mapError(
202203 (error) =>
203204 new SourceControlProviderError({
204 provider: "azure-devops",
205 provider: SourceControlProviderKind.make("azure-devops"),
205206 operation: "getDefaultBranch",
206207 command: error.command,
207208 cwd: input.cwd,
......@@ -221,7 +222,7 @@ export const make = Effect.gen(function* () {
221222 Effect.mapError(
222223 (error) =>
223224 new SourceControlProviderError({
224 provider: "azure-devops",
225 provider: SourceControlProviderKind.make("azure-devops"),
225226 operation: "checkoutChangeRequest",
226227 command: error.command,
227228 cwd: input.cwd,
packages/source-control-azure-devops/src/server/driver.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12/**
23 * The Azure DevOps driver: repository operations, discovery, and pull requests through `az`.
34 *
......@@ -10,7 +11,7 @@ import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvide
1011import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
1112
1213export const driver = defineSourceControlDriver({
13 kind: "azure-devops",
14 kind: SourceControlProviderKind.make("azure-devops"),
1415 make: Effect.all({
1516 sourceControl: AzureDevOpsSourceControlProvider.make,
1617 discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery),
packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts+4-3
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
23import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
34
......@@ -122,7 +123,7 @@ export const make = Effect.gen(function* () {
122123 const fail =
123124 (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) =>
124125 new PullRequestProviderError({
125 provider: "bitbucket",
126 provider: SourceControlProviderKind.make("bitbucket"),
126127 operation,
127128 ...bitbucketProviderFailure(error),
128129 // Every Bitbucket failure states its own fact; this names the operation around it, so
......@@ -157,7 +158,7 @@ export const make = Effect.gen(function* () {
157158 };
158159
159160 const provider: PullRequestProviderApi = {
160 kind: "bitbucket",
161 kind: SourceControlProviderKind.make("bitbucket"),
161162 capabilities: CAPABILITIES,
162163
163164 // Bitbucket credentials come from the server's environment rather than a checkout, so the
......@@ -363,7 +364,7 @@ export const make = Effect.gen(function* () {
363364 setReaction: () =>
364365 Effect.fail(
365366 new PullRequestProviderError({
366 provider: "bitbucket",
367 provider: SourceControlProviderKind.make("bitbucket"),
367368 operation: "setReaction",
368369 reason: "failed",
369370 detail: "Bitbucket does not support reactions.",
packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { assert, it } from "@effect/vitest";
23import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";
......@@ -36,7 +37,7 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", (
3637 });
3738
3839 assert.deepStrictEqual(changeRequest, {
39 provider: "bitbucket",
40 provider: SourceControlProviderKind.make("bitbucket"),
4041 number: 42,
4142 title: "Add Bitbucket provider",
4243 url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts+11-10
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
23import * as Option from "effect/Option";
34import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
......@@ -9,7 +10,7 @@ import type { SourceControlApiDiscoverySpec } from "@t3tools/source-control-core
910
1011function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest {
1112 return {
12 provider: "bitbucket",
13 provider: SourceControlProviderKind.make("bitbucket"),
1314 number: summary.number,
1415 title: summary.title,
1516 url: summary.url,
......@@ -34,7 +35,7 @@ export const make = Effect.gen(function* () {
3435 const bitbucket = yield* BitbucketApi.BitbucketApi;
3536
3637 return SourceControlProvider.SourceControlProvider.of({
37 kind: "bitbucket",
38 kind: SourceControlProviderKind.make("bitbucket"),
3839 listChangeRequests: (input) => {
3940 const source = SourceControlProvider.sourceControlRefFromInput(input);
4041 return bitbucket
......@@ -51,7 +52,7 @@ export const make = Effect.gen(function* () {
5152 Effect.mapError(
5253 (error) =>
5354 new SourceControlProviderError({
54 provider: "bitbucket",
55 provider: SourceControlProviderKind.make("bitbucket"),
5556 operation: "listChangeRequests",
5657 cwd: input.cwd,
5758 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
......@@ -69,7 +70,7 @@ export const make = Effect.gen(function* () {
6970 Effect.mapError(
7071 (error) =>
7172 new SourceControlProviderError({
72 provider: "bitbucket",
73 provider: SourceControlProviderKind.make("bitbucket"),
7374 operation: "getChangeRequest",
7475 cwd: input.cwd,
7576 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
......@@ -97,7 +98,7 @@ export const make = Effect.gen(function* () {
9798 Effect.mapError(
9899 (error) =>
99100 new SourceControlProviderError({
100 provider: "bitbucket",
101 provider: SourceControlProviderKind.make("bitbucket"),
101102 operation: "createChangeRequest",
102103 cwd: input.cwd,
103104 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
......@@ -114,7 +115,7 @@ export const make = Effect.gen(function* () {
114115 Effect.mapError(
115116 (error) =>
116117 new SourceControlProviderError({
117 provider: "bitbucket",
118 provider: SourceControlProviderKind.make("bitbucket"),
118119 operation: "getRepositoryCloneUrls",
119120 cwd: input.cwd,
120121 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
......@@ -130,7 +131,7 @@ export const make = Effect.gen(function* () {
130131 Effect.mapError(
131132 (error) =>
132133 new SourceControlProviderError({
133 provider: "bitbucket",
134 provider: SourceControlProviderKind.make("bitbucket"),
134135 operation: "createRepository",
135136 cwd: input.cwd,
136137 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
......@@ -151,7 +152,7 @@ export const make = Effect.gen(function* () {
151152 Effect.mapError(
152153 (error) =>
153154 new SourceControlProviderError({
154 provider: "bitbucket",
155 provider: SourceControlProviderKind.make("bitbucket"),
155156 operation: "getDefaultBranch",
156157 cwd: input.cwd,
157158 detail: "Failed to get default branch.",
......@@ -171,7 +172,7 @@ export const make = Effect.gen(function* () {
171172 Effect.mapError(
172173 (error) =>
173174 new SourceControlProviderError({
174 provider: "bitbucket",
175 provider: SourceControlProviderKind.make("bitbucket"),
175176 operation: "checkoutChangeRequest",
176177 cwd: input.cwd,
177178 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
......@@ -190,7 +191,7 @@ export const makeDiscovery = Effect.gen(function* () {
190191
191192 return {
192193 type: "api",
193 kind: "bitbucket",
194 kind: SourceControlProviderKind.make("bitbucket"),
194195 label: "Bitbucket",
195196 installHint: "Add a Bitbucket token in Settings → Source Control.",
196197 probeAuth: bitbucket.probeAuth,
packages/source-control-bitbucket/src/server/driver.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12/**
23 * The Bitbucket Cloud driver: repository operations, discovery, and pull requests through the
34 * Bitbucket REST API.
......@@ -11,7 +12,7 @@ import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts
1112import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
1213
1314export const driver = defineSourceControlDriver({
14 kind: "bitbucket",
15 kind: SourceControlProviderKind.make("bitbucket"),
1516 make: Effect.all({
1617 sourceControl: BitbucketSourceControlProvider.make,
1718 discovery: BitbucketSourceControlProvider.makeDiscovery,
packages/source-control-core/src/server/SourceControlRateLimit.test.ts+17-4
......@@ -1,10 +1,11 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { assert, it } from "@effect/vitest";
23import * as Effect from "effect/Effect";
34import * as TestClock from "effect/testing/TestClock";
45
56import * 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
910it.effect("isolates cooldowns for verified credentials on the same host", () =>
1011 Effect.gen(function* () {
......@@ -41,7 +42,7 @@ it.effect("backs off repeated rate limits until a successful request", () =>
4142 const firstPause = yield* Effect.flip(limits.check(github));
4243 assert.deepInclude(firstPause, {
4344 _tag: "SourceControlRateLimitPausedError",
44 provider: "github",
45 provider: SourceControlProviderKind.make("github"),
4546 host: "github.com",
4647 retryAt: 30_000,
4748 });
......@@ -105,8 +106,20 @@ it.effect("keeps providers and hosts isolated", () =>
105106 const lease = yield* limits.check(github);
106107 yield* limits.recordRateLimit({ ...github, lease });
107108
108 assert.equal(yield* limits.check({ provider: "gitlab", host: "github.com" }), 0);
109 assert.equal(yield* limits.check({ provider: "github", host: "github.example.com" }), 0);
109 assert.equal(
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 );
110123 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
111124);
112125
packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts+4-3
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
23import * as Schema from "effect/Schema";
34import * as Result from "effect/Result";
......@@ -67,7 +68,7 @@ export const make = Effect.gen(function* () {
6768 const cli = yield* ForgejoCli.ForgejoCli;
6869 const failure = (operation: string, detail: string, cause?: unknown) =>
6970 new PullRequestProviderError({
70 provider: "forgejo",
71 provider: SourceControlProviderKind.make("forgejo"),
7172 operation,
7273 reason: "failed",
7374 detail,
......@@ -78,7 +79,7 @@ export const make = Effect.gen(function* () {
7879 Effect.mapError(
7980 (error) =>
8081 new PullRequestProviderError({
81 provider: "forgejo",
82 provider: SourceControlProviderKind.make("forgejo"),
8283 operation: input.path,
8384 detail: error.detail,
8485 cause: error,
......@@ -203,7 +204,7 @@ export const make = Effect.gen(function* () {
203204 const unsupported = (operation: string) =>
204205 Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`));
205206 const provider: PullRequestProviderApi = {
206 kind: "forgejo",
207 kind: SourceControlProviderKind.make("forgejo"),
207208 capabilities: CAPABILITIES,
208209 getViewer,
209210 listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")(
packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts+25-8
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { assert, it } from "@effect/vitest";
23import * as Effect from "effect/Effect";
34import * as FileSystem from "effect/FileSystem";
......@@ -258,7 +259,11 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the
258259 });
259260 const served = (baseUrl: string) => () =>
260261 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 },
262267 remoteName: "origin",
263268 remoteUrl: "",
264269 });
......@@ -271,7 +276,7 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the
271276 }),
272277 {
273278 ...identity("http://forge.test:3000/git/team/repo.git"),
274 provider: "forgejo",
279 provider: SourceControlProviderKind.make("forgejo"),
275280 webUrl: "http://forge.test:3000/git/team/repo",
276281 },
277282 );
......@@ -557,7 +562,7 @@ it.effect("discovers Forgejo accounts and retains the server port", () =>
557562 cwd: "/repo",
558563 context: {
559564 provider: {
560 kind: "unknown",
565 kind: SourceControlProviderKind.make("unknown"),
561566 name: "git.forgejo.local",
562567 baseUrl: "https://git.forgejo.local",
563568 },
......@@ -577,7 +582,7 @@ it.effect("discovers Forgejo accounts and retains the server port", () =>
577582 ),
578583 });
579584 assert.deepStrictEqual(refined, {
580 kind: "forgejo",
585 kind: SourceControlProviderKind.make("forgejo"),
581586 name: "Forgejo / Gitea",
582587 baseUrl: "http://forgejo.local:3000",
583588 });
......@@ -622,7 +627,11 @@ it.effect("does not choose a default Forgejo login across ambiguous SSH server p
622627 const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
623628 cwd: "/repo",
624629 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 },
626635 remoteName: "origin",
627636 remoteUrl: "git@forgejo.local:maria/project.git",
628637 requestedHost: "forgejo.local:4000",
......@@ -732,7 +741,11 @@ it.effect("routes mounted Forgejo repositories without repeating the mount in AP
732741 cwd: "/repo",
733742 repository: "forgejo/maria/project",
734743 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 },
736749 remoteName: "origin",
737750 remoteUrl: "https://code.test/forgejo/maria/project.git",
738751 },
......@@ -744,7 +757,11 @@ it.effect("routes mounted Forgejo repositories without repeating the mount in AP
744757 cwd: "/repo",
745758 repository: "forgejo/project",
746759 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 },
748765 remoteName: "origin",
749766 remoteUrl: "ssh://git@code.test/forgejo/project.git",
750767 },
......@@ -827,7 +844,7 @@ it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => {
827844 repository: "maria/project",
828845 context: {
829846 provider: {
830 kind: "forgejo",
847 kind: SourceControlProviderKind.make("forgejo"),
831848 name: "Forgejo",
832849 baseUrl: "http://forgejo.local:3000",
833850 },
packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts+24-8
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
23import * as FileSystem from "effect/FileSystem";
34import * as Schema from "effect/Schema";
......@@ -18,7 +19,7 @@ const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError);
1819
1920export const discovery = {
2021 type: "cli",
21 kind: "forgejo",
22 kind: SourceControlProviderKind.make("forgejo"),
2223 label: "Forgejo / Gitea",
2324 executable: "tea",
2425 versionArgs: ["--version"],
......@@ -47,7 +48,13 @@ export const discovery = {
4748 remote,
4849 input.context.requestedHost,
4950 );
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;
5158 },
5259 installHint:
5360 "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* () {
6067 if (!listLogins) return discovery;
6168 return {
6269 type: "managed-cli",
63 kind: "forgejo",
70 kind: SourceControlProviderKind.make("forgejo"),
6471 label: discovery.label,
6572 installHint: discovery.installHint,
6673 probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) {
......@@ -150,7 +157,12 @@ export const makeDiscovery = Effect.gen(function* () {
150157 remoteUrl: input.context.remoteUrl,
151158 }).pipe(Effect.orElseSucceed(() => []));
152159 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 };
154166 }
155167 return null;
156168 },
......@@ -188,7 +200,7 @@ const refineRepositoryIdentity: NonNullable<
188200 const context = yield* resolveContext({
189201 cwd: identity.rootPath,
190202 context: {
191 provider: { kind: "unknown", name: "Unknown", baseUrl: "" },
203 provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" },
192204 remoteName: identity.locator.remoteName,
193205 remoteUrl: identity.locator.remoteUrl,
194206 },
......@@ -200,7 +212,11 @@ const refineRepositoryIdentity: NonNullable<
200212 !remote.ssh && basePath && remote.path.startsWith(`${basePath}/`)
201213 ? remote.path.slice(basePath.length + 1)
202214 : remote.path;
203 return { ...identity, provider: "forgejo", webUrl: `${baseUrl}/${path}` };
215 return {
216 ...identity,
217 provider: SourceControlProviderKind.make("forgejo"),
218 webUrl: `${baseUrl}/${path}`,
219 };
204220});
205221
206222const RepositorySchema = Schema.Struct({
......@@ -245,7 +261,7 @@ export const make = Effect.gen(function* () {
245261 Effect.mapError(
246262 (cause: unknown) =>
247263 new SourceControlProviderError({
248 provider: "forgejo",
264 provider: SourceControlProviderKind.make("forgejo"),
249265 operation,
250266 cwd,
251267 ...(isForgejoCliError(cause) ? { command: cause.command } : {}),
......@@ -272,7 +288,7 @@ export const make = Effect.gen(function* () {
272288 );
273289 });
274290 return SourceControlProvider.SourceControlProvider.of({
275 kind: "forgejo",
291 kind: SourceControlProviderKind.make("forgejo"),
276292 repositoryNameFromRemoteUrl,
277293 refineRepositoryIdentity,
278294 listChangeRequests: (input) =>
packages/source-control-forgejo/src/server/driver.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12/**
23 * The Forgejo / Gitea driver: repository operations, discovery, and pull requests through
34 * `fj` or `tea`.
......@@ -11,7 +12,7 @@ import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
1112import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
1213
1314export const driver = defineSourceControlDriver({
14 kind: "forgejo",
15 kind: SourceControlProviderKind.make("forgejo"),
1516 make: Effect.all({
1617 sourceControl: ForgejoSourceControlProvider.make,
1718 discovery: ForgejoSourceControlProvider.makeDiscovery,
packages/source-control-forgejo/src/server/forgejoPullRequests.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Schema from "effect/Schema";
23import * as Option from "effect/Option";
34import type { ChangeRequest } from "@t3tools/contracts";
......@@ -26,7 +27,7 @@ export const ForgejoPullRequestSchema = Schema.Struct({
2627});
2728export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type): ChangeRequest {
2829 return {
29 provider: "forgejo",
30 provider: SourceControlProviderKind.make("forgejo"),
3031 number: raw.number,
3132 title: raw.title,
3233 url: raw.html_url,
packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts+4-3
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
23import * as Option from "effect/Option";
34import * as Schema from "effect/Schema";
......@@ -176,7 +177,7 @@ export const make = Effect.gen(function* () {
176177
177178 const failure = (operation: string, detail: string, cause?: unknown) =>
178179 new PullRequestProviderError({
179 provider: "gitcafe",
180 provider: SourceControlProviderKind.make("gitcafe"),
180181 operation,
181182 reason: "failed",
182183 detail,
......@@ -207,7 +208,7 @@ export const make = Effect.gen(function* () {
207208 Effect.mapError(
208209 (error) =>
209210 new PullRequestProviderError({
210 provider: "gitcafe",
211 provider: SourceControlProviderKind.make("gitcafe"),
211212 operation,
212213 reason: error.reason,
213214 detail: error.detail,
......@@ -475,7 +476,7 @@ export const make = Effect.gen(function* () {
475476 });
476477
477478 const provider: PullRequestProviderApi = {
478 kind: "gitcafe",
479 kind: SourceControlProviderKind.make("gitcafe"),
479480 capabilities: CAPABILITIES,
480481 getViewer: (input) =>
481482 read(
packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts+11-2
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12/**
23 * Pins `./GitCafeSourceControlProvider.ts`, which pass 2 creates:
34 *
......@@ -87,7 +88,11 @@ describe("GitCafeSourceControlProvider", () => {
8788 );
8889 });
8990 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 },
9196 remoteName: "origin",
9297 remoteUrl,
9398 };
......@@ -220,7 +225,11 @@ describe("GitCafeSourceControlProvider", () => {
220225 remoteUrl === undefined
221226 ? undefined
222227 : {
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 },
224233 remoteName: "origin",
225234 remoteUrl,
226235 };
packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts+5-4
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
23import * as Option from "effect/Option";
34import * as Result from "effect/Result";
......@@ -59,7 +60,7 @@ const cliFailure = (output: string) =>
5960
6061export const discovery = {
6162 type: "cli",
62 kind: "gitcafe",
63 kind: SourceControlProviderKind.make("gitcafe"),
6364 label: "GitCafe",
6465 executable: "cafe",
6566 versionArgs: ["--version"],
......@@ -150,7 +151,7 @@ export const make = Effect.gen(function* () {
150151
151152 const error = (operation: string, cwd: string, detail: string, cause?: unknown) =>
152153 new SourceControlProviderError({
153 provider: "gitcafe",
154 provider: SourceControlProviderKind.make("gitcafe"),
154155 operation,
155156 cwd,
156157 detail,
......@@ -269,7 +270,7 @@ export const make = Effect.gen(function* () {
269270 );
270271 const owner = target.repository.split("/")[0] ?? null;
271272 return {
272 provider: "gitcafe",
273 provider: SourceControlProviderKind.make("gitcafe"),
273274 number: pull.number,
274275 title: pull.title,
275276 url: `https://${target.host}/${target.repository}/pulls/${pull.number}`,
......@@ -290,7 +291,7 @@ export const make = Effect.gen(function* () {
290291 });
291292
292293 return SourceControlProvider.SourceControlProvider.of({
293 kind: "gitcafe",
294 kind: SourceControlProviderKind.make("gitcafe"),
294295 repositoryNameFromRemoteUrl: (url) => GitCafeHosts.parseGitCafeRemote(url)?.repository ?? null,
295296 getChangeRequest: getPull,
296297 listChangeRequests: Effect.fn("GitCafeSourceControlProvider.listChangeRequests")(
packages/source-control-gitcafe/src/server/driver.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12/**
23 * The GitCafe driver: repository operations and discovery through `cafe` and git, and pull
34 * requests through GitCafe's REST API on git.cafe and staging.git.cafe.
......@@ -11,7 +12,7 @@ import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts";
1112import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts";
1213
1314export const driver = defineSourceControlDriver({
14 kind: "gitcafe",
15 kind: SourceControlProviderKind.make("gitcafe"),
1516 make: Effect.all({
1617 sourceControl: GitCafeSourceControlProvider.make,
1718 discovery: Effect.succeed(GitCafeSourceControlProvider.discovery),
packages/source-control-github/src/server/GitHubApi.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Clock from "effect/Clock";
23import * as Context from "effect/Context";
34import * as Data from "effect/Data";
......@@ -389,7 +390,7 @@ export const make = Effect.gen(function* () {
389390 // this reads today is `core`; a `search/` read would need its own resource here. A refusal
390391 // still pauses the whole host, the key PullRequestService records its own backoff under.
391392 const resource = input.graphql === true ? "graphql" : "core";
392 const key = { provider: "github" as const, host };
393 const key = { provider: SourceControlProviderKind.make("github"), host };
393394 const run = Effect.gen(function* () {
394395 const lease = yield* quota
395396 .admit(host, resource, { allowReserve: input.allowReserve })
packages/source-control-github/src/server/GitHubPullRequestProvider.ts+3-2
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage";
23import * as Effect from "effect/Effect";
34import type {
......@@ -202,7 +203,7 @@ export const make = Effect.gen(function* () {
202203
203204 const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) =>
204205 new PullRequestProviderError({
205 provider: "github",
206 provider: SourceControlProviderKind.make("github"),
206207 operation,
207208 ...gitHubProviderFailure(error),
208209 detail: error.message,
......@@ -261,7 +262,7 @@ export const make = Effect.gen(function* () {
261262 );
262263
263264 const provider: PullRequestProviderApi = {
264 kind: "github",
265 kind: SourceControlProviderKind.make("github"),
265266 capabilities: CAPABILITIES,
266267 mergeMessageRewrite: removeAgentCredits,
267268 getRoutingIdentity: (input) =>
packages/source-control-github/src/server/GitHubQuota.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Clock from "effect/Clock";
23import * as Context from "effect/Context";
34import * as Effect from "effect/Effect";
......@@ -86,7 +87,7 @@ const make = Effect.gen(function* () {
8687 const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO;
8788 if (snapshot.remaining >= floor) return;
8889 return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({
89 provider: "github",
90 provider: SourceControlProviderKind.make("github"),
9091 host: host.trim().toLowerCase(),
9192 retryAt: snapshot.resetAtMs,
9293 });
packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts+12-3
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as HostProcess from "@t3tools/shared/HostProcess";
23import { assert, describe, it } from "@effect/vitest";
34import * as NodeServices from "@effect/platform-node/NodeServices";
......@@ -137,7 +138,11 @@ const makeProvider = (api: Partial<GitHubApi.GitHubApi["Service"]>) =>
137138 GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer));
138139
139140const 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 },
141146 remoteName: "origin",
142147 remoteUrl: `git@${host}:acme/web.git`,
143148});
......@@ -1067,7 +1072,7 @@ it.live.each([
10671072 cwd: "/repo",
10681073 context: {
10691074 provider: {
1070 kind: "github" as const,
1075 kind: SourceControlProviderKind.make("github"),
10711076 name: "GitHub Self-Hosted",
10721077 baseUrl: "https://code.example.test",
10731078 },
......@@ -1184,7 +1189,11 @@ it.effect.each([
11841189 const input = {
11851190 cwd: "/repo",
11861191 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 },
11881197 remoteName: "origin",
11891198 remoteUrl: `git@${host}:me/web.git`,
11901199 },
packages/source-control-github/src/server/GitHubSourceControlProvider.ts+8-7
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Context from "effect/Context";
23import * as Data from "effect/Data";
34import * as Effect from "effect/Effect";
......@@ -62,7 +63,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect(
6263);
6364
6465function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest {
65 return { provider: "github", ...record };
66 return { provider: SourceControlProviderKind.make("github"), ...record };
6667}
6768
6869function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) {
......@@ -164,7 +165,7 @@ export function parseGitHubAuth(
164165
165166export const discovery = {
166167 type: "cli",
167 kind: "github",
168 kind: SourceControlProviderKind.make("github"),
168169 label: "GitHub",
169170 executable: "gh",
170171 versionArgs: ["--version"],
......@@ -271,7 +272,7 @@ export const makeDiscovery = Effect.gen(function* () {
271272 Effect.map((known) =>
272273 known
273274 ? ({
274 kind: "github",
275 kind: SourceControlProviderKind.make("github"),
275276 name: "GitHub Self-Hosted",
276277 baseUrl: context.provider.baseUrl,
277278 } as const)
......@@ -878,7 +879,7 @@ export const make = Effect.gen(function* () {
878879 ) =>
879880 (error: GitHubFailure) =>
880881 new SourceControlProviderError({
881 provider: "github",
882 provider: SourceControlProviderKind.make("github"),
882883 operation,
883884 cwd,
884885 ...(context?.reference === undefined
......@@ -915,7 +916,7 @@ export const make = Effect.gen(function* () {
915916 Effect.mapError(
916917 (cause) =>
917918 new SourceControlProviderError({
918 provider: "github",
919 provider: SourceControlProviderKind.make("github"),
919920 operation: "resolveLink",
920921 cwd: input.cwd,
921922 detail: "The linked subject could not be read.",
......@@ -927,7 +928,7 @@ export const make = Effect.gen(function* () {
927928 Effect.mapError(
928929 (cause) =>
929930 new SourceControlProviderError({
930 provider: "github",
931 provider: SourceControlProviderKind.make("github"),
931932 operation: "resolveLink.decode",
932933 cwd: input.cwd,
933934 detail: "The linked subject could not be read.",
......@@ -939,7 +940,7 @@ export const make = Effect.gen(function* () {
939940 });
940941
941942 return SourceControlProvider.SourceControlProvider.of({
942 kind: "github",
943 kind: SourceControlProviderKind.make("github"),
943944 // `gh pr list --head` filters on the head ref name alone and accepts anything, so an
944945 // `owner:branch` or `remote:branch` selector silently lists zero pull requests while
945946 // 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 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12/**
23 * The GitHub driver: repository operations and discovery through `gh` and git, and pull requests
34 * through the GitHub REST and GraphQL APIs.
......@@ -11,7 +12,7 @@ import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
1112import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1213
1314export const driver = defineSourceControlDriver({
14 kind: "github",
15 kind: SourceControlProviderKind.make("github"),
1516 make: Effect.all({
1617 sourceControl: GitHubSourceControlProvider.make,
1718 discovery: GitHubSourceControlProvider.makeDiscovery,
packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts+3-2
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
23import type {
34 PullRequestCapabilities,
......@@ -111,7 +112,7 @@ export const make = Effect.gen(function* () {
111112
112113 const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) =>
113114 new PullRequestProviderError({
114 provider: "gitlab",
115 provider: SourceControlProviderKind.make("gitlab"),
115116 operation,
116117 ...gitLabProviderFailure(error),
117118 detail: error.detail,
......@@ -119,7 +120,7 @@ export const make = Effect.gen(function* () {
119120 });
120121
121122 const provider: PullRequestProviderApi = {
122 kind: "gitlab",
123 kind: SourceControlProviderKind.make("gitlab"),
123124 capabilities: CAPABILITIES,
124125
125126 getViewer: (input) =>
packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts+3-2
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import { assert, it } from "@effect/vitest";
23import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";
......@@ -38,7 +39,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () =
3839 });
3940
4041 assert.deepStrictEqual(changeRequest, {
41 provider: "gitlab",
42 provider: SourceControlProviderKind.make("gitlab"),
4243 number: 42,
4344 title: "Add GitLab provider",
4445 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
......@@ -189,7 +190,7 @@ it("refines unknown GitLab remotes with mixed-case provider hosts", () => {
189190 cwd: "/repo",
190191 context: {
191192 provider: {
192 kind: "unknown",
193 kind: SourceControlProviderKind.make("unknown"),
193194 name: "Self-Hosted.Example.Test",
194195 baseUrl: "https://Self-Hosted.Example.Test",
195196 },
packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts+14-13
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12import * as Schema from "effect/Schema";
23import * as Effect from "effect/Effect";
34import * as Option from "effect/Option";
......@@ -25,7 +26,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect(
2526
2627function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest {
2728 return {
28 provider: "gitlab",
29 provider: SourceControlProviderKind.make("gitlab"),
2930 number: summary.number,
3031 title: summary.title,
3132 url: summary.url,
......@@ -90,7 +91,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn
9091 }
9192
9293 return {
93 kind: "gitlab",
94 kind: SourceControlProviderKind.make("gitlab"),
9495 name: "GitLab Self-Hosted",
9596 baseUrl: input.context.provider.baseUrl,
9697 } as const;
......@@ -98,7 +99,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn
9899
99100export const discovery = {
100101 type: "cli",
101 kind: "gitlab",
102 kind: SourceControlProviderKind.make("gitlab"),
102103 label: "GitLab",
103104 executable: "glab",
104105 versionArgs: ["--version"],
......@@ -127,7 +128,7 @@ export const make = Effect.gen(function* () {
127128 Effect.mapError(
128129 (cause) =>
129130 new SourceControlProviderError({
130 provider: "gitlab",
131 provider: SourceControlProviderKind.make("gitlab"),
131132 operation: "resolveLink",
132133 cwd: input.cwd,
133134 detail: "The linked subject could not be read.",
......@@ -139,7 +140,7 @@ export const make = Effect.gen(function* () {
139140 Effect.mapError(
140141 (cause) =>
141142 new SourceControlProviderError({
142 provider: "gitlab",
143 provider: SourceControlProviderKind.make("gitlab"),
143144 operation: "resolveLink.decode",
144145 cwd: input.cwd,
145146 detail: "The linked subject could not be read.",
......@@ -151,7 +152,7 @@ export const make = Effect.gen(function* () {
151152 });
152153
153154 return SourceControlProvider.SourceControlProvider.of({
154 kind: "gitlab",
155 kind: SourceControlProviderKind.make("gitlab"),
155156 resolveLink: (input) => {
156157 // Automatic enrichment must not send ambient CLI credentials to a host from message text.
157158 if (input.url.host !== "gitlab.com") return undefined;
......@@ -179,7 +180,7 @@ export const make = Effect.gen(function* () {
179180 Effect.mapError(
180181 (error) =>
181182 new SourceControlProviderError({
182 provider: "gitlab",
183 provider: SourceControlProviderKind.make("gitlab"),
183184 operation: "listChangeRequests",
184185 command: error.command,
185186 cwd: input.cwd,
......@@ -198,7 +199,7 @@ export const make = Effect.gen(function* () {
198199 Effect.mapError(
199200 (error) =>
200201 new SourceControlProviderError({
201 provider: "gitlab",
202 provider: SourceControlProviderKind.make("gitlab"),
202203 operation: "getChangeRequest",
203204 command: error.command,
204205 cwd: input.cwd,
......@@ -226,7 +227,7 @@ export const make = Effect.gen(function* () {
226227 Effect.mapError(
227228 (error) =>
228229 new SourceControlProviderError({
229 provider: "gitlab",
230 provider: SourceControlProviderKind.make("gitlab"),
230231 operation: "createChangeRequest",
231232 command: error.command,
232233 cwd: input.cwd,
......@@ -244,7 +245,7 @@ export const make = Effect.gen(function* () {
244245 Effect.mapError(
245246 (error) =>
246247 new SourceControlProviderError({
247 provider: "gitlab",
248 provider: SourceControlProviderKind.make("gitlab"),
248249 operation: "getRepositoryCloneUrls",
249250 command: error.command,
250251 cwd: input.cwd,
......@@ -261,7 +262,7 @@ export const make = Effect.gen(function* () {
261262 Effect.mapError(
262263 (error) =>
263264 new SourceControlProviderError({
264 provider: "gitlab",
265 provider: SourceControlProviderKind.make("gitlab"),
265266 operation: "createRepository",
266267 command: error.command,
267268 cwd: input.cwd,
......@@ -278,7 +279,7 @@ export const make = Effect.gen(function* () {
278279 Effect.mapError(
279280 (error) =>
280281 new SourceControlProviderError({
281 provider: "gitlab",
282 provider: SourceControlProviderKind.make("gitlab"),
282283 operation: "getDefaultBranch",
283284 command: error.command,
284285 cwd: input.cwd,
......@@ -292,7 +293,7 @@ export const make = Effect.gen(function* () {
292293 Effect.mapError(
293294 (error) =>
294295 new SourceControlProviderError({
295 provider: "gitlab",
296 provider: SourceControlProviderKind.make("gitlab"),
296297 operation: "checkoutChangeRequest",
297298 command: error.command,
298299 cwd: input.cwd,
packages/source-control-gitlab/src/server/driver.ts+2-1
......@@ -1,3 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
12/**
23 * The GitLab driver: repository operations, discovery, and merge requests through `glab`.
34 *
......@@ -10,7 +11,7 @@ import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";
1011import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
1112
1213export const driver = defineSourceControlDriver({
13 kind: "gitlab",
14 kind: SourceControlProviderKind.make("gitlab"),
1415 make: Effect.all({
1516 sourceControl: GitLabSourceControlProvider.make,
1617 discovery: Effect.succeed(GitLabSourceControlProvider.discovery),