1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as Cache from "effect/Cache";
3import * as Context from "effect/Context";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Equal from "effect/Equal";
7import * as Exit from "effect/Exit";
8import * as Hash from "effect/Hash";
9import * as Layer from "effect/Layer";
10import * as Stream from "effect/Stream";
11import {
12 SourceControlProviderError,
13 type SourceControlProviderDiscoveryItem,
14} from "@t3tools/contracts";
15
16import * as BuiltInDrivers from "./builtInDrivers.ts";
17import { sourceControlDefinitions } from "./sourceControlDefinitions.ts";
18import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
19import {
20 probeSourceControlProvider,
21 refineUnknownRemoteProvider,
22 type SourceControlProviderDiscoverySpec,
23 type UnknownRemoteVerdict,
24} from "@t3tools/source-control-core/server/discovery";
25import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
26
27import * as ServerConfig from "../config.ts";
28import * as ServerSettings from "../serverSettings.ts";
29import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
30
31const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048;
32const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5);
33const UNKNOWN_REMOTE_CACHE_CAPACITY = 512;
34const UNKNOWN_REMOTE_CACHE_TTL = Duration.minutes(5);
35
36export interface SourceControlProviderRegistration {
37 readonly kind: SourceControlProviderKind;
38 readonly provider: SourceControlProvider.SourceControlProvider["Service"];
39 readonly discovery: SourceControlProviderDiscoverySpec;
40}
41
42export interface SourceControlProviderHandle {
43 readonly provider: SourceControlProvider.SourceControlProvider["Service"];
44 readonly context: SourceControlProvider.SourceControlProviderContext | null;
45}
46
47export class SourceControlProviderRegistry extends Context.Service<
48 SourceControlProviderRegistry,
49 {
50 readonly resolveLink: SourceControlProvider.ResolveSourceControlLink;
51 readonly get: (
52 kind: SourceControlProviderKind,
53 ) => Effect.Effect<
54 SourceControlProvider.SourceControlProvider["Service"],
55 SourceControlProviderError
56 >;
57 readonly resolveHandle: (input: {
58 readonly cwd: string;
59 readonly context?: SourceControlProvider.SourceControlProviderContext;
60 }) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>;
61 readonly resolve: (input: {
62 readonly cwd: string;
63 }) => Effect.Effect<
64 SourceControlProvider.SourceControlProvider["Service"],
65 SourceControlProviderError
66 >;
67 readonly discover: Effect.Effect<ReadonlyArray<SourceControlProviderDiscoveryItem>>;
68 }
69>()("t3/sourceControl/SourceControlProviderRegistry") {}
70
71function unsupportedProvider(
72 kind: SourceControlProviderKind,
73): SourceControlProvider.SourceControlProvider["Service"] {
74 // Binding context does not create a second provider-operation span.
75 return SourceControlProvider.SourceControlProvider.of({
76 kind,
77 // Unknown hosts have no readable change requests; return none to avoid poll warnings on every refresh.
78 listChangeRequests: (input) =>
79 kind === "unknown"
80 ? Effect.succeed([])
81 : new SourceControlProviderError({
82 provider: kind,
83 operation: "listChangeRequests",
84 cwd: input.cwd,
85 detail: `No ${kind} source control provider is registered.`,
86 }),
87 getChangeRequest: (input) =>
88 new SourceControlProviderError({
89 provider: kind,
90 operation: "getChangeRequest",
91 cwd: input.cwd,
92 reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.reference),
93 detail: `No ${kind} source control provider is registered.`,
94 }),
95 createChangeRequest: (input) =>
96 new SourceControlProviderError({
97 provider: kind,
98 operation: "createChangeRequest",
99 cwd: input.cwd,
100 reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.headSelector),
101 detail: `No ${kind} source control provider is registered.`,
102 }),
103 getRepositoryCloneUrls: (input) =>
104 new SourceControlProviderError({
105 provider: kind,
106 operation: "getRepositoryCloneUrls",
107 cwd: input.cwd,
108 repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository),
109 detail: `No ${kind} source control provider is registered.`,
110 }),
111 createRepository: (input) =>
112 new SourceControlProviderError({
113 provider: kind,
114 operation: "createRepository",
115 cwd: input.cwd,
116 repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository),
117 detail: `No ${kind} source control provider is registered.`,
118 }),
119 getDefaultBranch: (input) =>
120 new SourceControlProviderError({
121 provider: kind,
122 operation: "getDefaultBranch",
123 cwd: input.cwd,
124 detail: `No ${kind} source control provider is registered.`,
125 }),
126 checkoutChangeRequest: (input) =>
127 new SourceControlProviderError({
128 provider: kind,
129 operation: "checkoutChangeRequest",
130 cwd: input.cwd,
131 reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.reference),
132 detail: `No ${kind} source control provider is registered.`,
133 }),
134 });
135}
136
137function selectProviderContext(
138 remotes: ReadonlyArray<{
139 readonly name: string;
140 readonly url: string;
141 }>,
142): SourceControlProvider.SourceControlProviderContext | null {
143 const candidates: Array<SourceControlProvider.SourceControlProviderContext> = [];
144 for (const remote of remotes) {
145 const provider = sourceControlDefinitions.recognizeRemote(remote.url);
146 if (provider) {
147 candidates.push({
148 provider,
149 remoteName: remote.name,
150 remoteUrl: remote.url,
151 });
152 }
153 }
154
155 return (
156 candidates.find((candidate) => candidate.remoteName === "origin") ??
157 candidates.find((candidate) => candidate.provider.kind !== "unknown") ??
158 candidates[0] ??
159 null
160 );
161}
162
163// Failed probes are not cached, so cwd is irrelevant; equality ignores lookup, unlike Data.Class.
164class UnknownRemoteLookup {
165 readonly key: string;
166 readonly lookup: Effect.Effect<UnknownRemoteVerdict>;
167
168 constructor(
169 context: SourceControlProvider.SourceControlProviderContext,
170 lookup: Effect.Effect<UnknownRemoteVerdict>,
171 ) {
172 this.lookup = lookup;
173 this.key = [
174 context.provider.name,
175 context.provider.baseUrl,
176 context.remoteUrl.trim().replace(/(?<!\/\/[^/]*)[^/:]+\/[^/:]+\/?$/u, ""),
177 context.requestedHost ?? "",
178 ].join("\n");
179 }
180
181 [Equal.symbol](that: unknown): boolean {
182 return that instanceof UnknownRemoteLookup && that.key === this.key;
183 }
184 [Hash.symbol](): number {
185 return Hash.string(this.key);
186 }
187}
188
189function bindProviderContext(
190 provider: SourceControlProvider.SourceControlProvider["Service"],
191 context: SourceControlProvider.SourceControlProviderContext | null,
192): SourceControlProvider.SourceControlProvider["Service"] {
193 if (context === null) {
194 return provider;
195 }
196
197 return SourceControlProvider.SourceControlProvider.of({
198 ...provider,
199 listChangeRequests: Effect.fnUntraced(function* (input) {
200 return yield* provider.listChangeRequests({
201 ...input,
202 context: input.context ?? context,
203 });
204 }),
205 getChangeRequest: Effect.fnUntraced(function* (input) {
206 return yield* provider.getChangeRequest({
207 ...input,
208 context: input.context ?? context,
209 });
210 }),
211 createChangeRequest: Effect.fnUntraced(function* (input) {
212 return yield* provider.createChangeRequest({
213 ...input,
214 context: input.context ?? context,
215 });
216 }),
217 getRepositoryCloneUrls: Effect.fnUntraced(function* (input) {
218 return yield* provider.getRepositoryCloneUrls({
219 ...input,
220 context: input.context ?? context,
221 });
222 }),
223 createRepository: Effect.fnUntraced(function* (input) {
224 return yield* provider.createRepository(input);
225 }),
226 getDefaultBranch: Effect.fnUntraced(function* (input) {
227 return yield* provider.getDefaultBranch({
228 ...input,
229 context: input.context ?? context,
230 });
231 }),
232 checkoutChangeRequest: Effect.fnUntraced(function* (input) {
233 return yield* provider.checkoutChangeRequest({
234 ...input,
235 context: input.context ?? context,
236 });
237 }),
238 });
239}
240
241/** @public */
242export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")(
243 function* (registrations: ReadonlyArray<SourceControlProviderRegistration>) {
244 const config = yield* ServerConfig.ServerConfig;
245 const serverSettings = yield* ServerSettings.ServerSettingsService;
246 const { process } = yield* SourceControlHost.SourceControlHost;
247 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
248 const providers = new Map<
249 SourceControlProviderKind,
250 SourceControlProvider.SourceControlProvider["Service"]
251 >(registrations.map((registration) => [registration.kind, registration.provider]));
252 const discoverySpecs = registrations.map((registration) => registration.discovery);
253
254 const get: SourceControlProviderRegistry["Service"]["get"] = (kind) =>
255 Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind));
256
257 const unknownRemoteCache = yield* Cache.makeWith(
258 (request: UnknownRemoteLookup) => request.lookup,
259 {
260 capacity: UNKNOWN_REMOTE_CACHE_CAPACITY,
261 timeToLive: (exit) =>
262 Exit.isSuccess(exit) && exit.value.settled ? UNKNOWN_REMOTE_CACHE_TTL : Duration.zero,
263 },
264 );
265 const settingsChanges = yield* serverSettings.subscribeChanges;
266 yield* Stream.runForEach(settingsChanges, () => Cache.invalidateAll(unknownRemoteCache)).pipe(
267 Effect.forkScoped,
268 );
269
270 const refineContext = (
271 cwd: string,
272 context: SourceControlProvider.SourceControlProviderContext | null,
273 ): Effect.Effect<SourceControlProvider.SourceControlProviderContext | null> =>
274 context === null || context.provider.kind !== "unknown"
275 ? Effect.succeed(context)
276 : Cache.get(
277 unknownRemoteCache,
278 new UnknownRemoteLookup(
279 context,
280 refineUnknownRemoteProvider({ specs: discoverySpecs, process, cwd, context }),
281 ),
282 ).pipe(Effect.map(({ provider }) => (provider ? { ...context, provider } : context)));
283
284 const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")(
285 function* (cwd: string) {
286 const handle = yield* vcsRegistry.resolve({ cwd }).pipe(
287 Effect.mapError(
288 (error) =>
289 new SourceControlProviderError({
290 provider: SourceControlProviderKind.make("unknown"),
291 operation: "detectProvider",
292 cwd,
293 detail: "Failed to detect source control provider.",
294 cause: error,
295 }),
296 ),
297 );
298 const remotes = yield* handle.driver.listRemotes(cwd).pipe(
299 Effect.mapError(
300 (error) =>
301 new SourceControlProviderError({
302 provider: SourceControlProviderKind.make("unknown"),
303 operation: "detectProvider",
304 cwd,
305 detail: "Failed to detect source control provider.",
306 cause: error,
307 }),
308 ),
309 );
310 return yield* refineContext(cwd, selectProviderContext(remotes.remotes));
311 },
312 );
313
314 const providerContextCache = yield* Cache.makeWith<
315 string,
316 SourceControlProvider.SourceControlProviderContext | null,
317 SourceControlProviderError
318 >(detectProviderContext, {
319 capacity: PROVIDER_DETECTION_CACHE_CAPACITY,
320 timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero),
321 });
322
323 const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) =>
324 (input.context === undefined
325 ? Cache.get(providerContextCache, input.cwd)
326 : refineContext(input.cwd, input.context)
327 ).pipe(
328 Effect.map((context) => {
329 const kind = context?.provider.kind ?? SourceControlProviderKind.make("unknown");
330 const provider = providers.get(kind) ?? unsupportedProvider(kind);
331 return {
332 provider: bindProviderContext(provider, context),
333 context,
334 } satisfies SourceControlProviderHandle;
335 }),
336 );
337
338 return SourceControlProviderRegistry.of({
339 resolveLink: (input) => {
340 if (input.url.protocol !== "https:" || input.url.username || input.url.password) {
341 return undefined;
342 }
343 const kind = sourceControlDefinitions.recognizeRemote(input.url.href)?.kind;
344 return kind ? providers.get(kind)?.resolveLink?.(input) : undefined;
345 },
346 get,
347 resolveHandle,
348 resolve: Effect.fn("SourceControlProviderRegistry.resolve")(function (input) {
349 return resolveHandle(input).pipe(Effect.map((handle) => handle.provider));
350 }),
351 discover: Effect.forEach(
352 discoverySpecs,
353 (spec) =>
354 probeSourceControlProvider({
355 spec,
356 process,
357 cwd: config.cwd,
358 }),
359 { concurrency: "unbounded" },
360 ),
361 });
362 },
363);
364
365const make = Effect.gen(function* () {
366 const drivers = yield* BuiltInDrivers.SourceControlDrivers;
367 return yield* makeWithProviders(
368 drivers.map(({ driver, instance }): SourceControlProviderRegistration => ({
369 kind: driver.kind,
370 provider: instance.sourceControl,
371 discovery: instance.discovery,
372 })),
373 );
374});
375
376export const layer = Layer.effect(SourceControlProviderRegistry, make);