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