Showing the changes to apps/server/src/sourceControl (12 of 33 files). Show all
Unified · Split
| @@ -211,6 +211,58 @@ describe("GitHubCli.layer", () => { · 210 unmodified lines | ||
| 211 | 211 | }).pipe(Effect.provide(layer)), |
| 212 | 212 | ); |
| 213 | 213 | |
| 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 | + | |
| 214 | 266 | it.effect("surfaces a friendly error when the pull request is not found", () => |
| 215 | 267 | Effect.gen(function* () { |
| 216 | 268 | mockRun.mockReturnValueOnce( |
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; |
| 2 | 2 | |
| 3 | −import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; | |
| 3 | +import { | |
| 4 | + TrimmedNonEmptyString, | |
| 5 | + type SourceControlRepositoryVisibility, | |
| 6 | + type VcsError, | |
| 7 | +} from "@t3tools/contracts"; | |
| 4 | 8 | |
| 5 | 9 | import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; |
| 6 | 10 | import { |
| @@ -62,6 +66,12 @@ export interface GitHubCliShape { · 55 unmodified lines | ||
| 62 | 66 | readonly repository: string; |
| 63 | 67 | }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>; |
| 64 | 68 | |
| 69 | + readonly createRepository: (input: { | |
| 70 | + readonly cwd: string; | |
| 71 | + readonly repository: string; | |
| 72 | + readonly visibility: SourceControlRepositoryVisibility; | |
| 73 | + }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>; | |
| 74 | + | |
| 65 | 75 | readonly createPullRequest: (input: { |
| 66 | 76 | readonly cwd: string; |
| 67 | 77 | readonly baseBranch: string; |
| @@ -160,6 +170,43 @@ function normalizeRepositoryCloneUrls( · 92 unmodified lines | ||
| 160 | 170 | }; |
| 161 | 171 | } |
| 162 | 172 | |
| 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 | + | |
| 163 | 210 | function decodeGitHubJson<S extends Schema.Top>( |
| 164 | 211 | raw: string, |
| 165 | 212 | schema: S, |
| @@ -281,6 +328,15 @@ export const make = Effect.fn("makeGitHubCli")(function* () { · 115 unmodified lines | ||
| 281 | 328 | ), |
| 282 | 329 | Effect.map(normalizeRepositoryCloneUrls), |
| 283 | 330 | ), |
| 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 | + ), | |
| 284 | 340 | createPullRequest: (input) => |
| 285 | 341 | execute({ |
| 286 | 342 | cwd: input.cwd, |
| @@ -130,6 +130,10 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { · 129 unmodified lines | ||
| 130 | 130 | github |
| 131 | 131 | .getRepositoryCloneUrls(input) |
| 132 | 132 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), |
| 133 | + createRepository: (input) => | |
| 134 | + github | |
| 135 | + .createRepository(input) | |
| 136 | + .pipe(Effect.mapError((error) => providerError("createRepository", error))), | |
| 133 | 137 | getDefaultBranch: (input) => |
| 134 | 138 | github |
| 135 | 139 | .getDefaultBranch(input) |
| @@ -175,7 +175,7 @@ layer("GitLabCli.layer", (it) => { · 174 unmodified lines | ||
| 175 | 175 | |
| 176 | 176 | assert.deepStrictEqual(result, { |
| 177 | 177 | nameWithOwner: "octocat/t3code", |
| 178 | − url: "https://gitlab.com/octocat/t3code.git", | |
| 178 | + url: "https://gitlab.com/octocat/t3code", | |
| 179 | 179 | sshUrl: "git@gitlab.com:octocat/t3code.git", |
| 180 | 180 | }); |
| 181 | 181 | }), |
| @@ -217,6 +217,67 @@ layer("GitLabCli.layer", (it) => { · 35 unmodified lines | ||
| 217 | 217 | }), |
| 218 | 218 | ); |
| 219 | 219 | |
| 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 | + | |
| 220 | 281 | it.effect("does not pass unsupported force flags when checking out merge requests", () => |
| 221 | 282 | Effect.gen(function* () { |
| 222 | 283 | mockedRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect"; |
| 2 | 2 | |
| 3 | −import { TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 3 | +import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; | |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | 6 | decodeGitLabMergeRequestJson, |
| @@ -66,6 +66,12 @@ export interface GitLabCliShape { · 59 unmodified lines | ||
| 66 | 66 | readonly repository: string; |
| 67 | 67 | }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>; |
| 68 | 68 | |
| 69 | + readonly createRepository: (input: { | |
| 70 | + readonly cwd: string; | |
| 71 | + readonly repository: string; | |
| 72 | + readonly visibility: SourceControlRepositoryVisibility; | |
| 73 | + }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>; | |
| 74 | + | |
| 69 | 75 | readonly createMergeRequest: (input: { |
| 70 | 76 | readonly cwd: string; |
| 71 | 77 | readonly baseBranch: string; |
| @@ -161,12 +167,16 @@ const RawGitLabDefaultBranchSchema = Schema.Struct({ · 89 unmodified lines | ||
| 161 | 167 | default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 162 | 168 | }); |
| 163 | 169 | |
| 170 | +const RawGitLabNamespaceSchema = Schema.Struct({ | |
| 171 | + id: Schema.Number, | |
| 172 | +}); | |
| 173 | + | |
| 164 | 174 | function normalizeRepositoryCloneUrls( |
| 165 | 175 | raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>, |
| 166 | 176 | ): GitLabRepositoryCloneUrls { |
| 167 | 177 | return { |
| 168 | 178 | nameWithOwner: raw.path_with_namespace, |
| 169 | − url: raw.http_url_to_repo || raw.web_url, | |
| 179 | + url: raw.web_url, | |
| 170 | 180 | sshUrl: raw.ssh_url_to_repo, |
| 171 | 181 | }; |
| 172 | 182 | } |
| @@ -174,7 +184,7 @@ function normalizeRepositoryCloneUrls( · 1 unmodified line | ||
| 174 | 184 | function decodeGitLabJson<S extends Schema.Top>( |
| 175 | 185 | raw: string, |
| 176 | 186 | schema: S, |
| 177 | − operation: "getRepositoryCloneUrls" | "getDefaultBranch", | |
| 187 | + operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", | |
| 178 | 188 | invalidDetail: string, |
| 179 | 189 | ): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> { |
| 180 | 190 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( |
| @@ -228,6 +238,19 @@ function toSummaryWithOptionalUpdatedAt( · 47 unmodified lines | ||
| 228 | 238 | return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; |
| 229 | 239 | } |
| 230 | 240 | |
| 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 | + | |
| 231 | 254 | export const make = Effect.fn("makeGitLabCli")(function* () { |
| 232 | 255 | const process = yield* VcsProcess; |
| 233 | 256 | |
| @@ -320,6 +343,59 @@ export const make = Effect.fn("makeGitLabCli")(function* () { · 86 unmodified lines | ||
| 320 | 343 | ), |
| 321 | 344 | Effect.map(normalizeRepositoryCloneUrls), |
| 322 | 345 | ), |
| 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 | + }, | |
| 323 | 399 | createMergeRequest: (input) => { |
| 324 | 400 | const sourceProject = sourceProjectIdentifier(input.source); |
| 325 | 401 | return execute({ |
| @@ -92,6 +92,10 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { · 91 unmodified lines | ||
| 92 | 92 | gitlab |
| 93 | 93 | .getRepositoryCloneUrls(input) |
| 94 | 94 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), |
| 95 | + createRepository: (input) => | |
| 96 | + gitlab | |
| 97 | + .createRepository(input) | |
| 98 | + .pipe(Effect.mapError((error) => providerError("createRepository", error))), | |
| 95 | 99 | getDefaultBranch: (input) => |
| 96 | 100 | gitlab |
| 97 | 101 | .getDefaultBranch(input) |
| @@ -115,6 +115,9 @@ Logged in to github.com account juliusmarminge (keyring) · 114 unmodified lines | ||
| 115 | 115 | }, |
| 116 | 116 | ], |
| 117 | 117 | ); |
| 118 | + const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); | |
| 119 | + assert.ok(bitbucket); | |
| 120 | + assert.strictEqual("executable" in bitbucket, false); | |
| 118 | 121 | }).pipe(Effect.provide(testLayer)); |
| 119 | 122 | }); |
| 120 | 123 | |
| @@ -204,9 +207,9 @@ Logged in as bitbucket-user · 83 unmodified lines | ||
| 204 | 207 | }, |
| 205 | 208 | { |
| 206 | 209 | 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."), | |
| 210 | 213 | }, |
| 211 | 214 | ], |
| 212 | 215 | ); |
| @@ -13,20 +13,22 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; · 12 unmodified lines | ||
| 13 | 13 | |
| 14 | 14 | interface DiscoveryProbe { |
| 15 | 15 | readonly label: string; |
| 16 | − readonly executable: string; | |
| 17 | − readonly versionArgs: ReadonlyArray<string>; | |
| 16 | + readonly executable?: string; | |
| 17 | + readonly versionArgs?: ReadonlyArray<string>; | |
| 18 | 18 | readonly implemented: boolean; |
| 19 | 19 | readonly installHint: string; |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | 22 | type VcsProbe = DiscoveryProbe & { |
| 23 | 23 | readonly kind: VcsDriverKind; |
| 24 | + readonly executable: string; | |
| 25 | + readonly versionArgs: ReadonlyArray<string>; | |
| 24 | 26 | }; |
| 25 | 27 | |
| 26 | 28 | type ProviderProbe = DiscoveryProbe & { |
| 27 | 29 | 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; | |
| 30 | 32 | }; |
| 31 | 33 | |
| 32 | 34 | interface AuthProbeInput { |
| @@ -38,7 +40,7 @@ interface AuthProbeInput { · 5 unmodified lines | ||
| 38 | 40 | interface DiscoveryProbeResult<Kind extends string> { |
| 39 | 41 | readonly kind: Kind; |
| 40 | 42 | readonly label: string; |
| 41 | − readonly executable: string; | |
| 43 | + readonly executable?: string; | |
| 42 | 44 | readonly implemented: boolean; |
| 43 | 45 | readonly status: "available" | "missing"; |
| 44 | 46 | readonly version: Option.Option<string>; |
| @@ -101,12 +103,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 56 unmodified lines | ||
| 101 | 103 | { |
| 102 | 104 | kind: "bitbucket", |
| 103 | 105 | label: "Bitbucket", |
| 104 | − executable: "bb", | |
| 105 | − versionArgs: ["--version"], | |
| 106 | − authArgs: ["auth", "status"], | |
| 107 | − parseAuth: parseBitbucketAuth, | |
| 108 | 106 | 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.", | |
| 110 | 108 | }, |
| 111 | 109 | ]; |
| 112 | 110 | |
| @@ -266,37 +264,6 @@ function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { · 153 unmodified lines | ||
| 266 | 264 | }); |
| 267 | 265 | } |
| 268 | 266 | |
| 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 | − | |
| 300 | 267 | export interface SourceControlDiscoveryShape { |
| 301 | 268 | readonly discover: Effect.Effect<SourceControlDiscoveryResult>; |
| 302 | 269 | } |
| @@ -314,12 +281,27 @@ export const layer = Layer.effect( · 11 unmodified lines | ||
| 314 | 281 | |
| 315 | 282 | const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>( |
| 316 | 283 | 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 | |
| 319 | 301 | .run({ |
| 320 | 302 | operation: "source-control.discovery.probe", |
| 321 | − command: input.executable, | |
| 322 | − args: input.versionArgs, | |
| 303 | + command: executable, | |
| 304 | + args: versionArgs, | |
| 323 | 305 | cwd: config.cwd, |
| 324 | 306 | timeoutMs: 5_000, |
| 325 | 307 | maxOutputBytes: 8_000, |
| @@ -331,7 +313,7 @@ export const layer = Layer.effect( · 5 unmodified lines | ||
| 331 | 313 | ({ |
| 332 | 314 | kind: input.kind, |
| 333 | 315 | label: input.label, |
| 334 | − executable: input.executable, | |
| 316 | + executable, | |
| 335 | 317 | implemented: input.implemented, |
| 336 | 318 | status: "available" as const, |
| 337 | 319 | version: Option.orElse(firstNonEmptyLine(result.stdout), () => |
| @@ -345,7 +327,7 @@ export const layer = Layer.effect( · 7 unmodified lines | ||
| 345 | 327 | Effect.succeed({ |
| 346 | 328 | kind: input.kind, |
| 347 | 329 | label: input.label, |
| 348 | − executable: input.executable, | |
| 330 | + executable, | |
| 349 | 331 | implemented: input.implemented, |
| 350 | 332 | status: "missing" as const, |
| 351 | 333 | version: Option.none<string>(), |
| @@ -354,10 +336,22 @@ export const layer = Layer.effect( · 2 unmodified lines | ||
| 354 | 336 | } satisfies DiscoveryProbeResult<Kind>), |
| 355 | 337 | ), |
| 356 | 338 | ); |
| 339 | + }; | |
| 357 | 340 | |
| 358 | 341 | const probeProvider = (input: ProviderProbe) => |
| 359 | 342 | probe(input).pipe( |
| 360 | 343 | 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 | + | |
| 361 | 355 | if (item.status !== "available") { |
| 362 | 356 | return Effect.succeed({ |
| 363 | 357 | ...item, |
| @@ -368,8 +362,8 @@ export const layer = Layer.effect( · 4 unmodified lines | ||
| 368 | 362 | return process |
| 369 | 363 | .run({ |
| 370 | 364 | operation: "source-control.discovery.auth", |
| 371 | − command: input.executable, | |
| 372 | − args: input.authArgs, | |
| 365 | + command: executable, | |
| 366 | + args: authArgs, | |
| 373 | 367 | cwd: config.cwd, |
| 374 | 368 | allowNonZeroExit: true, |
| 375 | 369 | timeoutMs: 5_000, |
| @@ -381,7 +375,7 @@ export const layer = Layer.effect( · 5 unmodified lines | ||
| 381 | 375 | (result) => |
| 382 | 376 | ({ |
| 383 | 377 | ...item, |
| 384 | − auth: input.parseAuth(result), | |
| 378 | + auth: parseAuth(result), | |
| 385 | 379 | }) satisfies SourceControlProviderDiscoveryItem, |
| 386 | 380 | ), |
| 387 | 381 | Effect.catch((cause) => |
| @@ -6,6 +6,7 @@ import type { · 5 unmodified lines | ||
| 6 | 6 | SourceControlProviderInfo, |
| 7 | 7 | SourceControlProviderKind, |
| 8 | 8 | SourceControlRepositoryCloneUrls, |
| 9 | + SourceControlRepositoryVisibility, | |
| 9 | 10 | } from "@t3tools/contracts"; |
| 10 | 11 | |
| 11 | 12 | export interface SourceControlProviderContext { |
| @@ -50,6 +51,11 @@ export interface SourceControlProviderShape { · 38 unmodified lines | ||
| 50 | 51 | readonly context?: SourceControlProviderContext; |
| 51 | 52 | readonly repository: string; |
| 52 | 53 | }) => 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>; | |
| 53 | 59 | readonly getDefaultBranch: (input: { |
| 54 | 60 | readonly cwd: string; |
| 55 | 61 | readonly context?: SourceControlProviderContext; |
| @@ -58,6 +58,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv · 57 unmodified lines | ||
| 58 | 58 | getChangeRequest: () => unsupported("getChangeRequest"), |
| 59 | 59 | createChangeRequest: () => unsupported("createChangeRequest"), |
| 60 | 60 | getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"), |
| 61 | + createRepository: () => unsupported("createRepository"), | |
| 61 | 62 | getDefaultBranch: () => unsupported("getDefaultBranch"), |
| 62 | 63 | checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), |
| 63 | 64 | }); |
| @@ -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 | +}); | |
| @@ -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()); | |