History

Add remote repository publish and discovery support (#2482)

Julius Marminge committed 6 months ago

Commit d7969261 parent f7748a033 files · +2502 −157Browse files

Showing the changes to apps/server/src/sourceControl (12 of 33 files). Show all

Unified · Split

12 files
  1. apps/server/src/sourceControl/GitHubCli.test.ts +52 −0
  2. apps/server/src/sourceControl/GitHubCli.ts +57 −1
  3. apps/server/src/sourceControl/GitHubSourceControlProvider.ts +4 −0
  4. apps/server/src/sourceControl/GitLabCli.test.ts +62 −1
  5. apps/server/src/sourceControl/GitLabCli.ts +79 −3
  6. apps/server/src/sourceControl/GitLabSourceControlProvider.ts +4 −0
  7. apps/server/src/sourceControl/SourceControlDiscovery.test.ts +6 −3
  8. apps/server/src/sourceControl/SourceControlDiscovery.ts +44 −50
  9. apps/server/src/sourceControl/SourceControlProvider.ts +6 −0
  10. apps/server/src/sourceControl/SourceControlProviderRegistry.ts +1 −0
  11. apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +322 −0
  12. apps/server/src/sourceControl/SourceControlRepositoryService.ts +320 −0
apps/server/src/sourceControl/GitHubCli.test.ts+52−0
@@ -211,6 +211,58 @@ describe("GitHubCli.layer", () => { · 210 unmodified lines
211211 }).pipe(Effect.provide(layer)),
212212 );
213213
214+ it.effect("creates repositories and parses clone URLs from create output", () =>
215+ Effect.gen(function* () {
216+ mockRun.mockReturnValueOnce(
217+ Effect.succeed(
218+ processOutput(
219+ "✓ Created repository octocat/codething-mvp on github.com\nhttps://github.com/octocat/codething-mvp\n",
220+ ),
221+ ),
222+ );
223+
224+ const gh = yield* GitHubCli.GitHubCli;
225+ const result = yield* gh.createRepository({
226+ cwd: "/repo",
227+ repository: "octocat/codething-mvp",
228+ visibility: "private",
229+ });
230+
231+ assert.deepStrictEqual(result, {
232+ nameWithOwner: "octocat/codething-mvp",
233+ url: "https://github.com/octocat/codething-mvp",
234+ sshUrl: "git@github.com:octocat/codething-mvp.git",
235+ });
236+ expect(mockRun).toHaveBeenCalledTimes(1);
237+ expect(mockRun).toHaveBeenNthCalledWith(1, {
238+ operation: "GitHubCli.execute",
239+ command: "gh",
240+ args: ["repo", "create", "octocat/codething-mvp", "--private"],
241+ cwd: "/repo",
242+ timeoutMs: 30_000,
243+ });
244+ }).pipe(Effect.provide(layer)),
245+ );
246+
247+ it.effect("falls back to constructed URLs when create output omits a URL", () =>
248+ Effect.gen(function* () {
249+ mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
250+
251+ const gh = yield* GitHubCli.GitHubCli;
252+ const result = yield* gh.createRepository({
253+ cwd: "/repo",
254+ repository: "octocat/codething-mvp",
255+ visibility: "private",
256+ });
257+
258+ assert.deepStrictEqual(result, {
259+ nameWithOwner: "octocat/codething-mvp",
260+ url: "https://github.com/octocat/codething-mvp",
261+ sshUrl: "git@github.com:octocat/codething-mvp.git",
262+ });
263+ }).pipe(Effect.provide(layer)),
264+ );
265+
214266 it.effect("surfaces a friendly error when the pull request is not found", () =>
215267 Effect.gen(function* () {
216268 mockRun.mockReturnValueOnce(
apps/server/src/sourceControl/GitHubCli.ts+57−1
@@ -1,6 +1,10 @@
11 import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect";
22
3−import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts";
3+import {
4+ TrimmedNonEmptyString,
5+ type SourceControlRepositoryVisibility,
6+ type VcsError,
7+} from "@t3tools/contracts";
48
59 import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
610 import {
@@ -62,6 +66,12 @@ export interface GitHubCliShape { · 55 unmodified lines
6266 readonly repository: string;
6367 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
6468
69+ readonly createRepository: (input: {
70+ readonly cwd: string;
71+ readonly repository: string;
72+ readonly visibility: SourceControlRepositoryVisibility;
73+ }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
74+
6575 readonly createPullRequest: (input: {
6676 readonly cwd: string;
6777 readonly baseBranch: string;
@@ -160,6 +170,43 @@ function normalizeRepositoryCloneUrls( · 92 unmodified lines
160170 };
161171 }
162172
173+/**
174+ * `gh repo create` prints the canonical URL of the new repository on stdout
175+ * (e.g. `https://github.com/owner/repo`). Reading it back here avoids a
176+ * follow-up `gh repo view`, which can race GitHub's GraphQL eventual
177+ * consistency window and falsely report the just-created repo as missing.
178+ */
179+function deriveRepositoryCloneUrlsFromCreateOutput(
180+ stdout: string,
181+ repository: string,
182+): GitHubRepositoryCloneUrls {
183+ const fallbackHost = "github.com";
184+ const match = stdout.match(/https?:\/\/[^\s]+/);
185+ if (match) {
186+ const cleaned = match[0].replace(/\.git$/, "");
187+ try {
188+ const parsed = new URL(cleaned);
189+ const pathname = parsed.pathname.replace(/^\/+|\/+$/g, "");
190+ const segments = pathname.split("/").filter(Boolean);
191+ if (segments.length === 2) {
192+ const nameWithOwner = `${segments[0]}/${segments[1]}`;
193+ return {
194+ nameWithOwner,
195+ url: `${parsed.origin}/${nameWithOwner}`,
196+ sshUrl: `git@${parsed.host}:${nameWithOwner}.git`,
197+ };
198+ }
199+ } catch {
200+ // Fall through to the input-derived defaults below.
201+ }
202+ }
203+ return {
204+ nameWithOwner: repository,
205+ url: `https://${fallbackHost}/${repository}`,
206+ sshUrl: `git@${fallbackHost}:${repository}.git`,
207+ };
208+}
209+
163210 function decodeGitHubJson<S extends Schema.Top>(
164211 raw: string,
165212 schema: S,
@@ -281,6 +328,15 @@ export const make = Effect.fn("makeGitHubCli")(function* () { · 115 unmodified lines
281328 ),
282329 Effect.map(normalizeRepositoryCloneUrls),
283330 ),
331+ createRepository: (input) =>
332+ execute({
333+ cwd: input.cwd,
334+ args: ["repo", "create", input.repository, `--${input.visibility}`],
335+ }).pipe(
336+ Effect.map((result) =>
337+ deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository),
338+ ),
339+ ),
284340 createPullRequest: (input) =>
285341 execute({
286342 cwd: input.cwd,
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+4−0
@@ -130,6 +130,10 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { · 129 unmodified lines
130130 github
131131 .getRepositoryCloneUrls(input)
132132 .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),
133+ createRepository: (input) =>
134+ github
135+ .createRepository(input)
136+ .pipe(Effect.mapError((error) => providerError("createRepository", error))),
133137 getDefaultBranch: (input) =>
134138 github
135139 .getDefaultBranch(input)
apps/server/src/sourceControl/GitLabCli.test.ts+62−1
@@ -175,7 +175,7 @@ layer("GitLabCli.layer", (it) => { · 174 unmodified lines
175175
176176 assert.deepStrictEqual(result, {
177177 nameWithOwner: "octocat/t3code",
178− url: "https://gitlab.com/octocat/t3code.git",
178+ url: "https://gitlab.com/octocat/t3code",
179179 sshUrl: "git@gitlab.com:octocat/t3code.git",
180180 });
181181 }),
@@ -217,6 +217,67 @@ layer("GitLabCli.layer", (it) => { · 35 unmodified lines
217217 }),
218218 );
219219
220+ it.effect("creates repositories under an explicit namespace", () =>
221+ Effect.gen(function* () {
222+ mockedRun
223+ .mockReturnValueOnce(Effect.succeed(processOutput(JSON.stringify({ id: 1234 }))))
224+ .mockReturnValueOnce(
225+ Effect.succeed(
226+ processOutput(
227+ JSON.stringify({
228+ path_with_namespace: "octocat/t3code",
229+ web_url: "https://gitlab.com/octocat/t3code",
230+ http_url_to_repo: "https://gitlab.com/octocat/t3code.git",
231+ ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git",
232+ }),
233+ ),
234+ ),
235+ );
236+
237+ const glab = yield* GitLabCli.GitLabCli;
238+ const result = yield* glab.createRepository({
239+ cwd: "/repo",
240+ repository: "octocat/t3code",
241+ visibility: "public",
242+ });
243+
244+ assert.deepStrictEqual(result, {
245+ nameWithOwner: "octocat/t3code",
246+ url: "https://gitlab.com/octocat/t3code",
247+ sshUrl: "git@gitlab.com:octocat/t3code.git",
248+ });
249+ expect(mockedRun).toHaveBeenNthCalledWith(
250+ 1,
251+ expect.objectContaining({
252+ command: "glab",
253+ cwd: "/repo",
254+ args: ["api", "namespaces/octocat"],
255+ }),
256+ );
257+ expect(mockedRun).toHaveBeenNthCalledWith(
258+ 2,
259+ expect.objectContaining({
260+ command: "glab",
261+ cwd: "/repo",
262+ args: [
263+ "api",
264+ "--method",
265+ "POST",
266+ "projects",
267+ "--raw-field",
268+ "path=t3code",
269+ "--raw-field",
270+ "name=t3code",
271+ "--raw-field",
272+ "visibility=public",
273+ "--raw-field",
274+ "namespace_id=1234",
275+ ],
276+ }),
277+ );
278+ }),
279+ );
280+
220281 it.effect("does not pass unsupported force flags when checking out merge requests", () =>
221282 Effect.gen(function* () {
222283 mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
apps/server/src/sourceControl/GitLabCli.ts+79−3
@@ -1,6 +1,6 @@
11 import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect";
22
3−import { TrimmedNonEmptyString } from "@t3tools/contracts";
3+import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
44
55 import {
66 decodeGitLabMergeRequestJson,
@@ -66,6 +66,12 @@ export interface GitLabCliShape { · 59 unmodified lines
6666 readonly repository: string;
6767 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
6868
69+ readonly createRepository: (input: {
70+ readonly cwd: string;
71+ readonly repository: string;
72+ readonly visibility: SourceControlRepositoryVisibility;
73+ }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
74+
6975 readonly createMergeRequest: (input: {
7076 readonly cwd: string;
7177 readonly baseBranch: string;
@@ -161,12 +167,16 @@ const RawGitLabDefaultBranchSchema = Schema.Struct({ · 89 unmodified lines
161167 default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
162168 });
163169
170+const RawGitLabNamespaceSchema = Schema.Struct({
171+ id: Schema.Number,
172+});
173+
164174 function normalizeRepositoryCloneUrls(
165175 raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>,
166176 ): GitLabRepositoryCloneUrls {
167177 return {
168178 nameWithOwner: raw.path_with_namespace,
169− url: raw.http_url_to_repo || raw.web_url,
179+ url: raw.web_url,
170180 sshUrl: raw.ssh_url_to_repo,
171181 };
172182 }
@@ -174,7 +184,7 @@ function normalizeRepositoryCloneUrls( · 1 unmodified line
174184 function decodeGitLabJson<S extends Schema.Top>(
175185 raw: string,
176186 schema: S,
177− operation: "getRepositoryCloneUrls" | "getDefaultBranch",
187+ operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository",
178188 invalidDetail: string,
179189 ): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> {
180190 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
@@ -228,6 +238,19 @@ function toSummaryWithOptionalUpdatedAt( · 47 unmodified lines
228238 return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary;
229239 }
230240
241+function parseRepositoryPath(repository: string): {
242+ readonly namespacePath: string | null;
243+ readonly projectPath: string;
244+} {
245+ const parts = repository
246+ .split("/")
247+ .map((part) => part.trim())
248+ .filter((part) => part.length > 0);
249+ const projectPath = parts.at(-1) ?? repository.trim();
250+ const namespacePath = parts.length > 1 ? parts.slice(0, -1).join("/") : null;
251+ return { namespacePath, projectPath };
252+}
253+
231254 export const make = Effect.fn("makeGitLabCli")(function* () {
232255 const process = yield* VcsProcess;
233256
@@ -320,6 +343,59 @@ export const make = Effect.fn("makeGitLabCli")(function* () { · 86 unmodified lines
320343 ),
321344 Effect.map(normalizeRepositoryCloneUrls),
322345 ),
346+ createRepository: (input) => {
347+ const { namespacePath, projectPath } = parseRepositoryPath(input.repository);
348+ const namespaceId: Effect.Effect<number | null, GitLabCliError> = namespacePath
349+ ? execute({
350+ cwd: input.cwd,
351+ args: ["api", `namespaces/${encodeURIComponent(namespacePath)}`],
352+ }).pipe(
353+ Effect.map((result) => result.stdout.trim()),
354+ Effect.flatMap((raw) =>
355+ decodeGitLabJson(
356+ raw,
357+ RawGitLabNamespaceSchema,
358+ "createRepository",
359+ "GitLab CLI returned invalid namespace JSON.",
360+ ),
361+ ),
362+ Effect.map((namespace) => namespace.id),
363+ )
364+ : Effect.succeed(null);
365+
366+ return namespaceId.pipe(
367+ Effect.flatMap((resolvedNamespaceId) =>
368+ execute({
369+ cwd: input.cwd,
370+ args: [
371+ "api",
372+ "--method",
373+ "POST",
374+ "projects",
375+ "--raw-field",
376+ `path=${projectPath}`,
377+ "--raw-field",
378+ `name=${projectPath}`,
379+ "--raw-field",
380+ `visibility=${input.visibility}`,
381+ ...(resolvedNamespaceId === null
382+ ? []
383+ : ["--raw-field", `namespace_id=${resolvedNamespaceId}`]),
384+ ],
385+ }),
386+ ),
387+ Effect.map((result) => result.stdout.trim()),
388+ Effect.flatMap((raw) =>
389+ decodeGitLabJson(
390+ raw,
391+ RawGitLabRepositoryCloneUrlsSchema,
392+ "createRepository",
393+ "GitLab CLI returned invalid repository JSON.",
394+ ),
395+ ),
396+ Effect.map(normalizeRepositoryCloneUrls),
397+ );
398+ },
323399 createMergeRequest: (input) => {
324400 const sourceProject = sourceProjectIdentifier(input.source);
325401 return execute({
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+4−0
@@ -92,6 +92,10 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { · 91 unmodified lines
9292 gitlab
9393 .getRepositoryCloneUrls(input)
9494 .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),
95+ createRepository: (input) =>
96+ gitlab
97+ .createRepository(input)
98+ .pipe(Effect.mapError((error) => providerError("createRepository", error))),
9599 getDefaultBranch: (input) =>
96100 gitlab
97101 .getDefaultBranch(input)
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+6−3
@@ -115,6 +115,9 @@ Logged in to github.com account juliusmarminge (keyring) · 114 unmodified lines
115115 },
116116 ],
117117 );
118+ const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket");
119+ assert.ok(bitbucket);
120+ assert.strictEqual("executable" in bitbucket, false);
118121 }).pipe(Effect.provide(testLayer));
119122 });
120123
@@ -204,9 +207,9 @@ Logged in as bitbucket-user · 83 unmodified lines
204207 },
205208 {
206209 kind: "bitbucket",
207− auth: "authenticated",
208− account: Option.some("bitbucket-user"),
209− detail: Option.none(),
210+ auth: "unknown",
211+ account: Option.none(),
212+ detail: Option.some("Bitbucket provider support is not available yet."),
210213 },
211214 ],
212215 );
apps/server/src/sourceControl/SourceControlDiscovery.ts+44−50
@@ -13,20 +13,22 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; · 12 unmodified lines
1313
1414 interface DiscoveryProbe {
1515 readonly label: string;
16− readonly executable: string;
17− readonly versionArgs: ReadonlyArray<string>;
16+ readonly executable?: string;
17+ readonly versionArgs?: ReadonlyArray<string>;
1818 readonly implemented: boolean;
1919 readonly installHint: string;
2020 }
2121
2222 type VcsProbe = DiscoveryProbe & {
2323 readonly kind: VcsDriverKind;
24+ readonly executable: string;
25+ readonly versionArgs: ReadonlyArray<string>;
2426 };
2527
2628 type ProviderProbe = DiscoveryProbe & {
2729 readonly kind: SourceControlProviderKind;
28− readonly authArgs: ReadonlyArray<string>;
29− readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth;
30+ readonly authArgs?: ReadonlyArray<string>;
31+ readonly parseAuth?: (input: AuthProbeInput) => SourceControlProviderAuth;
3032 };
3133
3234 interface AuthProbeInput {
@@ -38,7 +40,7 @@ interface AuthProbeInput { · 5 unmodified lines
3840 interface DiscoveryProbeResult<Kind extends string> {
3941 readonly kind: Kind;
4042 readonly label: string;
41− readonly executable: string;
43+ readonly executable?: string;
4244 readonly implemented: boolean;
4345 readonly status: "available" | "missing";
4446 readonly version: Option.Option<string>;
@@ -101,12 +103,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 56 unmodified lines
101103 {
102104 kind: "bitbucket",
103105 label: "Bitbucket",
104− executable: "bb",
105− versionArgs: ["--version"],
106− authArgs: ["auth", "status"],
107− parseAuth: parseBitbucketAuth,
108106 implemented: false,
109− installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.",
107+ installHint: "Bitbucket provider support is not available yet.",
110108 },
111109 ];
112110
@@ -266,37 +264,6 @@ function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { · 153 unmodified lines
266264 });
267265 }
268266
269−function parseBitbucketAuth(input: AuthProbeInput): SourceControlProviderAuth {
270− const output = combinedAuthOutput(input);
271− const account = matchFirst(output, [
272− /Logged in to .* as\s+([^\s(]+)/iu,
273− /Logged in as\s+([^\s(]+)/iu,
274− /account:\s*([^\s(]+)/iu,
275− /user:\s*([^\s(]+)/iu,
276− /username:\s*([^\s(]+)/iu,
277− ]);
278− const host = parseCliHost(output) ?? "bitbucket.org";
279−
280− if (input.exitCode !== 0) {
281− return providerAuth({
282− status: "unauthenticated",
283− host,
284− detail:
285− firstSafeAuthLine(output) ?? "Authenticate the Bitbucket CLI before enabling Bitbucket.",
286− });
287− }
288−
289− if (account) {
290− return providerAuth({ status: "authenticated", account, host });
291− }
292−
293− return providerAuth({
294− status: "unknown",
295− host,
296− detail: firstSafeAuthLine(output) ?? "Bitbucket CLI auth status could not be parsed.",
297− });
298−}
299−
300267 export interface SourceControlDiscoveryShape {
301268 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
302269 }
@@ -314,12 +281,27 @@ export const layer = Layer.effect( · 11 unmodified lines
314281
315282 const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(
316283 input: DiscoveryProbe & { readonly kind: Kind },
317− ): Effect.Effect<DiscoveryProbeResult<Kind>> =>
318− process
284+ ): Effect.Effect<DiscoveryProbeResult<Kind>> => {
285+ const executable = input.executable;
286+ const versionArgs = input.versionArgs;
287+
288+ if (!executable || !versionArgs) {
289+ return Effect.succeed({
290+ kind: input.kind,
291+ label: input.label,
292+ implemented: input.implemented,
293+ status: "missing" as const,
294+ version: Option.none<string>(),
295+ installHint: input.installHint,
296+ detail: Option.some(input.installHint),
297+ } satisfies DiscoveryProbeResult<Kind>);
298+ }
299+
300+ return process
319301 .run({
320302 operation: "source-control.discovery.probe",
321− command: input.executable,
322− args: input.versionArgs,
303+ command: executable,
304+ args: versionArgs,
323305 cwd: config.cwd,
324306 timeoutMs: 5_000,
325307 maxOutputBytes: 8_000,
@@ -331,7 +313,7 @@ export const layer = Layer.effect( · 5 unmodified lines
331313 ({
332314 kind: input.kind,
333315 label: input.label,
334− executable: input.executable,
316+ executable,
335317 implemented: input.implemented,
336318 status: "available" as const,
337319 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
@@ -345,7 +327,7 @@ export const layer = Layer.effect( · 7 unmodified lines
345327 Effect.succeed({
346328 kind: input.kind,
347329 label: input.label,
348− executable: input.executable,
330+ executable,
349331 implemented: input.implemented,
350332 status: "missing" as const,
351333 version: Option.none<string>(),
@@ -354,10 +336,22 @@ export const layer = Layer.effect( · 2 unmodified lines
354336 } satisfies DiscoveryProbeResult<Kind>),
355337 ),
356338 );
339+ };
357340
358341 const probeProvider = (input: ProviderProbe) =>
359342 probe(input).pipe(
360343 Effect.flatMap((item) => {
344+ const executable = input.executable;
345+ const authArgs = input.authArgs;
346+ const parseAuth = input.parseAuth;
347+
348+ if (!executable || !authArgs || !parseAuth) {
349+ return Effect.succeed({
350+ ...item,
351+ auth: unknownAuth(input.installHint),
352+ } satisfies SourceControlProviderDiscoveryItem);
353+ }
354+
361355 if (item.status !== "available") {
362356 return Effect.succeed({
363357 ...item,
@@ -368,8 +362,8 @@ export const layer = Layer.effect( · 4 unmodified lines
368362 return process
369363 .run({
370364 operation: "source-control.discovery.auth",
371− command: input.executable,
372− args: input.authArgs,
365+ command: executable,
366+ args: authArgs,
373367 cwd: config.cwd,
374368 allowNonZeroExit: true,
375369 timeoutMs: 5_000,
@@ -381,7 +375,7 @@ export const layer = Layer.effect( · 5 unmodified lines
381375 (result) =>
382376 ({
383377 ...item,
384− auth: input.parseAuth(result),
378+ auth: parseAuth(result),
385379 }) satisfies SourceControlProviderDiscoveryItem,
386380 ),
387381 Effect.catch((cause) =>
apps/server/src/sourceControl/SourceControlProvider.ts+6−0
@@ -6,6 +6,7 @@ import type { · 5 unmodified lines
66 SourceControlProviderInfo,
77 SourceControlProviderKind,
88 SourceControlRepositoryCloneUrls,
9+ SourceControlRepositoryVisibility,
910 } from "@t3tools/contracts";
1011
1112 export interface SourceControlProviderContext {
@@ -50,6 +51,11 @@ export interface SourceControlProviderShape { · 38 unmodified lines
5051 readonly context?: SourceControlProviderContext;
5152 readonly repository: string;
5253 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
54+ readonly createRepository: (input: {
55+ readonly cwd: string;
56+ readonly repository: string;
57+ readonly visibility: SourceControlRepositoryVisibility;
58+ }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
5359 readonly getDefaultBranch: (input: {
5460 readonly cwd: string;
5561 readonly context?: SourceControlProviderContext;
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+1−0
@@ -58,6 +58,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv · 57 unmodified lines
5858 getChangeRequest: () => unsupported("getChangeRequest"),
5959 createChangeRequest: () => unsupported("createChangeRequest"),
6060 getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"),
61+ createRepository: () => unsupported("createRepository"),
6162 getDefaultBranch: () => unsupported("getDefaultBranch"),
6263 checkoutChangeRequest: () => unsupported("checkoutChangeRequest"),
6364 });
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+322−0
@@ -0,0 +1,322 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, it } from "@effect/vitest";
3+import { Effect, FileSystem, Layer } from "effect";
4+import { ChildProcessSpawner } from "effect/unstable/process";
5+
6+import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts";
7+
8+import { ServerConfig } from "../config.ts";
9+import {
10+ GitVcsDriver,
11+ type ExecuteGitResult,
12+ type GitVcsDriverShape,
13+} from "../vcs/GitVcsDriver.ts";
14+import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts";
15+import type { SourceControlProviderShape } from "./SourceControlProvider.ts";
16+import { SourceControlRepositoryService, layer } from "./SourceControlRepositoryService.ts";
17+
18+const CLONE_URLS = {
19+ nameWithOwner: "octocat/t3code",
20+ url: "https://github.com/octocat/t3code",
21+ sshUrl: "git@github.com:octocat/t3code.git",
22+};
23+
24+function makeProvider(
25+ overrides: Partial<SourceControlProviderShape> = {},
26+): SourceControlProviderShape {
27+ const unsupported = (operation: string) =>
28+ Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect<
29+ never,
30+ SourceControlProviderError
31+ >;
32+
33+ return {
34+ kind: "github",
35+ listChangeRequests: () => unsupported("listChangeRequests"),
36+ getChangeRequest: () => unsupported("getChangeRequest"),
37+ createChangeRequest: () => unsupported("createChangeRequest"),
38+ getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS),
39+ createRepository: () => Effect.succeed(CLONE_URLS),
40+ getDefaultBranch: () => Effect.succeed(null),
41+ checkoutChangeRequest: () => unsupported("checkoutChangeRequest"),
42+ ...overrides,
43+ };
44+}
45+
46+function processOutput(): ExecuteGitResult {
47+ return {
48+ exitCode: ChildProcessSpawner.ExitCode(0),
49+ stdout: "",
50+ stderr: "",
51+ stdoutTruncated: false,
52+ stderrTruncated: false,
53+ };
54+}
55+
56+function makeLayer(input: {
57+ readonly provider?: SourceControlProviderShape;
58+ readonly git?: Partial<GitVcsDriverShape>;
59+}) {
60+ return layer.pipe(
61+ Layer.provide(
62+ Layer.mock(SourceControlProviderRegistry)({
63+ get: () => Effect.succeed(input.provider ?? makeProvider()),
64+ }),
65+ ),
66+ Layer.provide(
67+ Layer.mock(GitVcsDriver)({
68+ execute: () => Effect.succeed(processOutput()),
69+ ensureRemote: () => Effect.succeed("origin"),
70+ pushCurrentBranch: () =>
71+ Effect.succeed({
72+ status: "pushed" as const,
73+ branch: "feature/remote-v1",
74+ upstreamBranch: "origin/feature/remote-v1",
75+ setUpstream: true,
76+ }),
77+ ...input.git,
78+ }),
79+ ),
80+ Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-repos-" })),
81+ Layer.provideMerge(NodeServices.layer),
82+ );
83+}
84+
85+it.effect("looks up repositories through the requested provider without search", () => {
86+ const calls: Array<{ cwd: string; repository: string }> = [];
87+ const provider = makeProvider({
88+ getRepositoryCloneUrls: (input) =>
89+ Effect.sync(() => {
90+ calls.push({ cwd: input.cwd, repository: input.repository });
91+ return CLONE_URLS;
92+ }),
93+ });
94+
95+ return Effect.gen(function* () {
96+ const service = yield* SourceControlRepositoryService;
97+ const result = yield* service.lookupRepository({
98+ provider: "github",
99+ repository: "octocat/t3code",
100+ cwd: "/workspace",
101+ });
102+
103+ assert.deepStrictEqual(result, { provider: "github", ...CLONE_URLS });
104+ assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]);
105+ }).pipe(Effect.provide(makeLayer({ provider })));
106+});
107+
108+it.effect("clones a looked-up repository into the requested destination", () =>
109+ Effect.gen(function* () {
110+ const fs = yield* FileSystem.FileSystem;
111+ const parent = yield* fs.makeTempDirectoryScoped({
112+ prefix: "t3-source-control-clone-parent-",
113+ });
114+ const destinationPath = `${parent}/t3code`;
115+ const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = [];
116+
117+ yield* Effect.gen(function* () {
118+ const service = yield* SourceControlRepositoryService;
119+ const result = yield* service.cloneRepository({
120+ provider: "github",
121+ repository: "octocat/t3code",
122+ destinationPath,
123+ protocol: "https",
124+ });
125+
126+ assert.deepStrictEqual(result, {
127+ cwd: destinationPath,
128+ remoteUrl: CLONE_URLS.url,
129+ repository: { provider: "github", ...CLONE_URLS },
130+ });
131+ assert.deepStrictEqual(cloneCalls, [
132+ {
133+ cwd: parent,
134+ args: ["clone", CLONE_URLS.url, "t3code"],
135+ },
136+ ]);
137+ }).pipe(
138+ Effect.provide(
139+ makeLayer({
140+ git: {
141+ execute: (input) =>
142+ Effect.sync(() => {
143+ cloneCalls.push({ cwd: input.cwd, args: input.args });
144+ return processOutput();
145+ }),
146+ },
147+ }),
148+ ),
149+ );
150+ }).pipe(Effect.provide(NodeServices.layer)),
151+);
152+
153+it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => {
154+ const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = [];
155+ const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = [];
156+ const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = [];
157+ const provider = makeProvider({
158+ createRepository: (input) =>
159+ Effect.sync(() => {
160+ createCalls.push({
161+ cwd: input.cwd,
162+ repository: input.repository,
163+ visibility: input.visibility,
164+ });
165+ return CLONE_URLS;
166+ }),
167+ });
168+
169+ return Effect.gen(function* () {
170+ const service = yield* SourceControlRepositoryService;
171+ const result = yield* service.publishRepository({
172+ cwd: "/workspace",
173+ provider: "github",
174+ repository: "octocat/t3code",
175+ visibility: "private",
176+ remoteName: "origin",
177+ protocol: "ssh",
178+ });
179+
180+ assert.deepStrictEqual(result, {
181+ repository: { provider: "github", ...CLONE_URLS },
182+ remoteName: "origin",
183+ remoteUrl: CLONE_URLS.sshUrl,
184+ branch: "feature/remote-v1",
185+ upstreamBranch: "origin/feature/remote-v1",
186+ status: "pushed",
187+ });
188+ assert.deepStrictEqual(createCalls, [
189+ { cwd: "/workspace", repository: "octocat/t3code", visibility: "private" },
190+ ]);
191+ assert.deepStrictEqual(remoteCalls, [
192+ { cwd: "/workspace", preferredName: "origin", url: CLONE_URLS.sshUrl },
193+ ]);
194+ assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin" }]);
195+ }).pipe(
196+ Effect.provide(
197+ makeLayer({
198+ provider,
199+ git: {
200+ ensureRemote: (input) =>
201+ Effect.sync(() => {
202+ remoteCalls.push(input);
203+ return "origin";
204+ }),
205+ pushCurrentBranch: (cwd, _fallbackBranch, options) =>
206+ Effect.sync(() => {
207+ pushCalls.push({ cwd, remoteName: options?.remoteName });
208+ return {
209+ status: "pushed" as const,
210+ branch: "feature/remote-v1",
211+ upstreamBranch: "origin/feature/remote-v1",
212+ setUpstream: true,
213+ };
214+ }),
215+ },
216+ }),
217+ ),
218+ );
219+});
220+
221+it.effect("publishes to the remote name returned by ensureRemote", () => {
222+ const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = [];
223+
224+ return Effect.gen(function* () {
225+ const service = yield* SourceControlRepositoryService;
226+ const result = yield* service.publishRepository({
227+ cwd: "/workspace",
228+ provider: "github",
229+ repository: "octocat/t3code",
230+ visibility: "private",
231+ remoteName: "origin",
232+ protocol: "ssh",
233+ });
234+
235+ assert.equal(result.remoteName, "origin-1");
236+ assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin-1" }]);
237+ }).pipe(
238+ Effect.provide(
239+ makeLayer({
240+ git: {
241+ ensureRemote: () => Effect.succeed("origin-1"),
242+ pushCurrentBranch: (cwd, _fallbackBranch, options) =>
243+ Effect.sync(() => {
244+ pushCalls.push({ cwd, remoteName: options?.remoteName });
245+ return {
246+ status: "pushed" as const,
247+ branch: "feature/remote-v1",
248+ upstreamBranch: `${options?.remoteName ?? "missing"}/feature/remote-v1`,
249+ setUpstream: true,
250+ };
251+ }),
252+ },
253+ }),
254+ ),
255+ );
256+});
257+
258+it.effect("publish succeeds with status remote_added when the local repo has no commits", () => {
259+ let pushCalls = 0;
260+ return Effect.gen(function* () {
261+ const service = yield* SourceControlRepositoryService;
262+ const result = yield* service.publishRepository({
263+ cwd: "/workspace",
264+ provider: "github",
265+ repository: "octocat/t3code",
266+ visibility: "private",
267+ remoteName: "origin",
268+ protocol: "ssh",
269+ });
270+
271+ assert.deepStrictEqual(result, {
272+ repository: { provider: "github", ...CLONE_URLS },
273+ remoteName: "origin",
274+ remoteUrl: CLONE_URLS.sshUrl,
275+ branch: "main",
276+ status: "remote_added",
277+ });
278+ assert.strictEqual(pushCalls, 0);
279+ }).pipe(
280+ Effect.provide(
281+ makeLayer({
282+ git: {
283+ execute: (input) =>
284+ input.args[0] === "rev-parse"
285+ ? Effect.fail(
286+ new GitCommandError({
287+ operation: input.operation,
288+ command: "git rev-parse --verify HEAD",
289+ cwd: input.cwd,
290+ detail: "fatal: Needed a single revision",
291+ }),
292+ )
293+ : Effect.succeed(processOutput()),
294+ statusDetails: () =>
295+ Effect.succeed({
296+ isRepo: true,
297+ hasOriginRemote: true,
298+ isDefaultBranch: true,
299+ branch: "main",
300+ upstreamRef: null,
301+ hasWorkingTreeChanges: false,
302+ workingTree: { files: [], insertions: 0, deletions: 0 },
303+ hasUpstream: false,
304+ aheadCount: 0,
305+ behindCount: 0,
306+ aheadOfDefaultCount: 0,
307+ }),
308+ pushCurrentBranch: () =>
309+ Effect.sync(() => {
310+ pushCalls += 1;
311+ return {
312+ status: "pushed" as const,
313+ branch: "main",
314+ upstreamBranch: "origin/main",
315+ setUpstream: true,
316+ };
317+ }),
318+ },
319+ }),
320+ ),
321+ );
322+});
apps/server/src/sourceControl/SourceControlRepositoryService.ts+320−0
@@ -0,0 +1,320 @@
1+import OS from "node:os";
2+import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
3+
4+import {
5+ SourceControlRepositoryError,
6+ type SourceControlCloneRepositoryInput,
7+ type SourceControlCloneRepositoryResult,
8+ type SourceControlCloneProtocol,
9+ type SourceControlProviderKind,
10+ type SourceControlPublishRepositoryInput,
11+ type SourceControlPublishRepositoryResult,
12+ type SourceControlRepositoryCloneUrls,
13+ type SourceControlRepositoryInfo,
14+ type SourceControlRepositoryLookupInput,
15+} from "@t3tools/contracts";
16+
17+import { ServerConfig } from "../config.ts";
18+import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";
19+import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts";
20+
21+export interface SourceControlRepositoryServiceShape {
22+ readonly lookupRepository: (
23+ input: SourceControlRepositoryLookupInput,
24+ ) => Effect.Effect<SourceControlRepositoryInfo, SourceControlRepositoryError>;
25+ readonly cloneRepository: (
26+ input: SourceControlCloneRepositoryInput,
27+ ) => Effect.Effect<SourceControlCloneRepositoryResult, SourceControlRepositoryError>;
28+ readonly publishRepository: (
29+ input: SourceControlPublishRepositoryInput,
30+ ) => Effect.Effect<SourceControlPublishRepositoryResult, SourceControlRepositoryError>;
31+}
32+
33+export class SourceControlRepositoryService extends Context.Service<
34+ SourceControlRepositoryService,
35+ SourceControlRepositoryServiceShape
36+>()("t3/source-control/SourceControlRepositoryService") {}
37+
38+function detailFromUnknown(cause: unknown): string {
39+ if (typeof cause === "object" && cause !== null) {
40+ if ("detail" in cause && typeof cause.detail === "string" && cause.detail.length > 0) {
41+ return cause.detail;
42+ }
43+ if ("message" in cause && typeof cause.message === "string" && cause.message.length > 0) {
44+ return cause.message;
45+ }
46+ }
47+
48+ return "An unexpected source control error occurred.";
49+}
50+
51+function repositoryError(input: {
52+ readonly operation: string;
53+ readonly provider: SourceControlProviderKind;
54+ readonly detail: string;
55+ readonly cause?: unknown;
56+}): SourceControlRepositoryError {
57+ return new SourceControlRepositoryError({
58+ provider: input.provider,
59+ operation: input.operation,
60+ detail: input.detail,
61+ ...(input.cause === undefined ? {} : { cause: input.cause }),
62+ });
63+}
64+
65+function mapRepositoryError(operation: string, provider: SourceControlProviderKind) {
66+ return Effect.mapError((cause: unknown) =>
67+ Schema.is(SourceControlRepositoryError)(cause)
68+ ? cause
69+ : repositoryError({
70+ operation,
71+ provider,
72+ detail: detailFromUnknown(cause),
73+ cause,
74+ }),
75+ );
76+}
77+
78+function toRepositoryInfo(
79+ provider: SourceControlProviderKind,
80+ urls: SourceControlRepositoryCloneUrls,
81+): SourceControlRepositoryInfo {
82+ return {
83+ provider,
84+ nameWithOwner: urls.nameWithOwner,
85+ url: urls.url,
86+ sshUrl: urls.sshUrl,
87+ };
88+}
89+
90+function selectRemoteUrl(
91+ urls: SourceControlRepositoryCloneUrls,
92+ protocol: SourceControlCloneProtocol | undefined,
93+): string {
94+ switch (protocol ?? "auto") {
95+ case "https":
96+ return urls.url;
97+ case "ssh":
98+ case "auto":
99+ return urls.sshUrl;
100+ }
101+}
102+
103+function expandHomePath(input: string, path: Path.Path): string {
104+ if (input === "~") {
105+ return OS.homedir();
106+ }
107+ if (input.startsWith("~/") || input.startsWith("~\\")) {
108+ return path.join(OS.homedir(), input.slice(2));
109+ }
110+ return input;
111+}
112+
113+export const make = Effect.fn("makeSourceControlRepositoryService")(function* () {
114+ const config = yield* ServerConfig;
115+ const fileSystem = yield* FileSystem.FileSystem;
116+ const git = yield* GitVcsDriver;
117+ const path = yield* Path.Path;
118+ const providers = yield* SourceControlProviderRegistry;
119+
120+ const ensureConcreteProvider = (input: {
121+ readonly operation: string;
122+ readonly provider: SourceControlProviderKind;
123+ }) => {
124+ if (input.provider !== "unknown") {
125+ return Effect.succeed(input.provider);
126+ }
127+
128+ return Effect.fail(
129+ repositoryError({
130+ operation: input.operation,
131+ provider: input.provider,
132+ detail: "Choose a source control provider before continuing.",
133+ }),
134+ );
135+ };
136+
137+ const lookupRepository = Effect.fn("SourceControlRepositoryService.lookupRepository")(function* (
138+ input: SourceControlRepositoryLookupInput,
139+ ) {
140+ const providerKind = yield* ensureConcreteProvider({
141+ operation: "lookupRepository",
142+ provider: input.provider,
143+ });
144+ const provider = yield* providers.get(providerKind);
145+ const urls = yield* provider.getRepositoryCloneUrls({
146+ cwd: input.cwd ?? config.cwd,
147+ repository: input.repository.trim(),
148+ });
149+ return toRepositoryInfo(providerKind, urls);
150+ });
151+
152+ const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")(
153+ function* (destinationPath: string) {
154+ const trimmed = destinationPath.trim();
155+ if (trimmed.length === 0) {
156+ return yield* Effect.fail(
157+ repositoryError({
158+ operation: "cloneRepository",
159+ provider: "unknown",
160+ detail: "Choose a destination path before cloning.",
161+ }),
162+ );
163+ }
164+
165+ return path.resolve(expandHomePath(trimmed, path));
166+ },
167+ );
168+
169+ const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")(
170+ function* (destinationPath: string) {
171+ const normalizedDestination = yield* normalizeDestinationPath(destinationPath);
172+ if (yield* fileSystem.exists(normalizedDestination).pipe(Effect.orElseSucceed(() => false))) {
173+ const entries = yield* fileSystem
174+ .readDirectory(normalizedDestination, { recursive: false })
175+ .pipe(
176+ Effect.mapError((cause) =>
177+ repositoryError({
178+ operation: "cloneRepository",
179+ provider: "unknown",
180+ detail: "Destination path already exists and is not a directory.",
181+ cause,
182+ }),
183+ ),
184+ );
185+ if (entries.length > 0) {
186+ return yield* Effect.fail(
187+ repositoryError({
188+ operation: "cloneRepository",
189+ provider: "unknown",
190+ detail: "Destination path already exists and is not empty.",
191+ }),
192+ );
193+ }
194+ } else {
195+ yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true });
196+ }
197+
198+ return {
199+ destinationPath: normalizedDestination,
200+ parentPath: path.dirname(normalizedDestination),
201+ directoryName: path.basename(normalizedDestination),
202+ };
203+ },
204+ );
205+
206+ const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* (
207+ input: SourceControlCloneRepositoryInput,
208+ ) {
209+ const preparedDestination = yield* prepareDestination(input.destinationPath);
210+ let repository: SourceControlRepositoryInfo | null = null;
211+ let remoteUrl = input.remoteUrl?.trim() ?? null;
212+ let provider: SourceControlProviderKind = input.provider ?? "unknown";
213+
214+ if (input.provider && input.repository) {
215+ repository = yield* lookupRepository({
216+ provider: input.provider,
217+ repository: input.repository,
218+ cwd: preparedDestination.parentPath,
219+ });
220+ remoteUrl = selectRemoteUrl(repository, input.protocol);
221+ provider = input.provider;
222+ }
223+
224+ if (!remoteUrl) {
225+ return yield* Effect.fail(
226+ repositoryError({
227+ operation: "cloneRepository",
228+ provider,
229+ detail: "Enter a repository path or clone URL before cloning.",
230+ }),
231+ );
232+ }
233+
234+ yield* git.execute({
235+ operation: "SourceControlRepositoryService.cloneRepository",
236+ cwd: preparedDestination.parentPath,
237+ args: ["clone", remoteUrl, preparedDestination.directoryName],
238+ timeoutMs: 120_000,
239+ maxOutputBytes: 256 * 1024,
240+ });
241+
242+ return {
243+ cwd: preparedDestination.destinationPath,
244+ remoteUrl,
245+ repository,
246+ };
247+ });
248+
249+ const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")(
250+ function* (input: SourceControlPublishRepositoryInput) {
251+ const providerKind = yield* ensureConcreteProvider({
252+ operation: "publishRepository",
253+ provider: input.provider,
254+ });
255+ const provider = yield* providers.get(providerKind);
256+ const urls = yield* provider.createRepository({
257+ cwd: input.cwd,
258+ repository: input.repository.trim(),
259+ visibility: input.visibility,
260+ });
261+ const remoteUrl = selectRemoteUrl(urls, input.protocol);
262+ const remoteName = yield* git.ensureRemote({
263+ cwd: input.cwd,
264+ preferredName: input.remoteName?.trim() || "origin",
265+ url: remoteUrl,
266+ });
267+
268+ // An empty local repo (no commits) would make `git push HEAD:...` fail
269+ // with an opaque "src refspec HEAD does not match any". Treat this as a
270+ // partial success: the remote was created and wired up, but there is
271+ // nothing to push yet.
272+ const hasCommits = yield* git
273+ .execute({
274+ operation: "SourceControlRepositoryService.publishRepository.headCheck",
275+ cwd: input.cwd,
276+ args: ["rev-parse", "--verify", "HEAD"],
277+ })
278+ .pipe(
279+ Effect.map(() => true),
280+ Effect.catch(() => Effect.succeed(false)),
281+ );
282+ if (!hasCommits) {
283+ const details = yield* git
284+ .statusDetails(input.cwd)
285+ .pipe(Effect.catch(() => Effect.succeed(null)));
286+ return {
287+ repository: toRepositoryInfo(providerKind, urls),
288+ remoteName,
289+ remoteUrl,
290+ branch: details?.branch ?? "main",
291+ status: "remote_added" as const,
292+ };
293+ }
294+
295+ const pushResult = yield* git.pushCurrentBranch(input.cwd, null, { remoteName });
296+
297+ return {
298+ repository: toRepositoryInfo(providerKind, urls),
299+ remoteName,
300+ remoteUrl,
301+ branch: pushResult.branch,
302+ ...(pushResult.upstreamBranch ? { upstreamBranch: pushResult.upstreamBranch } : {}),
303+ status: "pushed" as const,
304+ };
305+ },
306+ );
307+
308+ return SourceControlRepositoryService.of({
309+ lookupRepository: (input) =>
310+ lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)),
311+ cloneRepository: (input) =>
312+ cloneRepository(input).pipe(
313+ mapRepositoryError("cloneRepository", input.provider ?? "unknown"),
314+ ),
315+ publishRepository: (input) =>
316+ publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)),
317+ });
318+});
319+
320+export const layer = Layer.effect(SourceControlRepositoryService, make());