Showing the changes to apps/server/src/sourceControl (26 of 59 files). Show all
Unified · Split
| @@ -0,0 +1,288 @@ | |||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; | ||
| 3 | import { Effect, FileSystem, Layer, Option } from "effect"; | ||
| 4 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 5 | import type { VcsError } from "@t3tools/contracts"; | ||
| 6 | |||
| 7 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 8 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | ||
| 9 | |||
| 10 | const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ | ||
| 11 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 12 | stdout, | ||
| 13 | stderr: "", | ||
| 14 | stdoutTruncated: false, | ||
| 15 | stderrTruncated: false, | ||
| 16 | }); | ||
| 17 | |||
| 18 | const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>(); | ||
| 19 | |||
| 20 | const supportLayer = Layer.mergeAll( | ||
| 21 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 22 | run: mockRun, | ||
| 23 | }), | ||
| 24 | NodeServices.layer, | ||
| 25 | ); | ||
| 26 | const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(supportLayer)), supportLayer); | ||
| 27 | |||
| 28 | afterEach(() => { | ||
| 29 | mockRun.mockReset(); | ||
| 30 | }); | ||
| 31 | |||
| 32 | describe("AzureDevOpsCli.layer", () => { | ||
| 33 | it.effect("parses pull request view output", () => | ||
| 34 | Effect.gen(function* () { | ||
| 35 | mockRun.mockReturnValueOnce( | ||
| 36 | Effect.succeed( | ||
| 37 | processOutput( | ||
| 38 | JSON.stringify({ | ||
| 39 | pullRequestId: 42, | ||
| 40 | title: "Add Azure provider", | ||
| 41 | sourceRefName: "refs/heads/feature/source-control", | ||
| 42 | targetRefName: "refs/heads/main", | ||
| 43 | status: "active", | ||
| 44 | creationDate: "2026-01-02T00:00:00.000Z", | ||
| 45 | closedDate: null, | ||
| 46 | _links: { | ||
| 47 | web: { | ||
| 48 | href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", | ||
| 49 | }, | ||
| 50 | }, | ||
| 51 | }), | ||
| 52 | ), | ||
| 53 | ), | ||
| 54 | ); | ||
| 55 | |||
| 56 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 57 | const result = yield* az.getPullRequest({ | ||
| 58 | cwd: "/repo", | ||
| 59 | reference: "#42", | ||
| 60 | }); | ||
| 61 | |||
| 62 | assert.strictEqual(result.number, 42); | ||
| 63 | assert.strictEqual(result.title, "Add Azure provider"); | ||
| 64 | assert.strictEqual(result.baseRefName, "main"); | ||
| 65 | assert.strictEqual(result.headRefName, "feature/source-control"); | ||
| 66 | assert.strictEqual(result.state, "open"); | ||
| 67 | assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag); | ||
| 68 | assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], { | ||
| 69 | operation: "AzureDevOpsCli.execute", | ||
| 70 | command: "az", | ||
| 71 | args: [ | ||
| 72 | "repos", | ||
| 73 | "pr", | ||
| 74 | "show", | ||
| 75 | "--detect", | ||
| 76 | "true", | ||
| 77 | "--id", | ||
| 78 | "42", | ||
| 79 | "--only-show-errors", | ||
| 80 | "--output", | ||
| 81 | "json", | ||
| 82 | ], | ||
| 83 | cwd: "/repo", | ||
| 84 | timeoutMs: 30_000, | ||
| 85 | }); | ||
| 86 | }).pipe(Effect.provide(layer)), | ||
| 87 | ); | ||
| 88 | |||
| 89 | it.effect("lists pull requests with Azure status and source branch arguments", () => | ||
| 90 | Effect.gen(function* () { | ||
| 91 | mockRun.mockReturnValueOnce( | ||
| 92 | Effect.succeed( | ||
| 93 | processOutput( | ||
| 94 | JSON.stringify([ | ||
| 95 | { | ||
| 96 | pullRequestId: 7, | ||
| 97 | title: "Merged work", | ||
| 98 | sourceRefName: "refs/heads/feature/merged", | ||
| 99 | targetRefName: "refs/heads/main", | ||
| 100 | status: "completed", | ||
| 101 | closedDate: "2026-01-03T00:00:00.000Z", | ||
| 102 | _links: { | ||
| 103 | web: { | ||
| 104 | href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7", | ||
| 105 | }, | ||
| 106 | }, | ||
| 107 | }, | ||
| 108 | ]), | ||
| 109 | ), | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | |||
| 113 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 114 | const result = yield* az.listPullRequests({ | ||
| 115 | cwd: "/repo", | ||
| 116 | headSelector: "origin:feature/merged", | ||
| 117 | state: "merged", | ||
| 118 | limit: 10, | ||
| 119 | }); | ||
| 120 | |||
| 121 | assert.strictEqual(result[0]?.state, "merged"); | ||
| 122 | expect(mockRun).toHaveBeenCalledWith({ | ||
| 123 | operation: "AzureDevOpsCli.execute", | ||
| 124 | command: "az", | ||
| 125 | args: [ | ||
| 126 | "repos", | ||
| 127 | "pr", | ||
| 128 | "list", | ||
| 129 | "--detect", | ||
| 130 | "true", | ||
| 131 | "--source-branch", | ||
| 132 | "feature/merged", | ||
| 133 | "--status", | ||
| 134 | "completed", | ||
| 135 | "--top", | ||
| 136 | "10", | ||
| 137 | "--only-show-errors", | ||
| 138 | "--output", | ||
| 139 | "json", | ||
| 140 | ], | ||
| 141 | cwd: "/repo", | ||
| 142 | timeoutMs: 30_000, | ||
| 143 | }); | ||
| 144 | }).pipe(Effect.provide(layer)), | ||
| 145 | ); | ||
| 146 | |||
| 147 | it.effect("reads repository clone URLs", () => | ||
| 148 | Effect.gen(function* () { | ||
| 149 | mockRun.mockReturnValueOnce( | ||
| 150 | Effect.succeed( | ||
| 151 | processOutput( | ||
| 152 | JSON.stringify({ | ||
| 153 | name: "repo", | ||
| 154 | webUrl: "https://dev.azure.com/acme/project/_git/repo", | ||
| 155 | remoteUrl: "https://dev.azure.com/acme/project/_git/repo", | ||
| 156 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | ||
| 157 | project: { | ||
| 158 | name: "project", | ||
| 159 | }, | ||
| 160 | }), | ||
| 161 | ), | ||
| 162 | ), | ||
| 163 | ); | ||
| 164 | |||
| 165 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 166 | const result = yield* az.getRepositoryCloneUrls({ | ||
| 167 | cwd: "/repo", | ||
| 168 | repository: "repo", | ||
| 169 | }); | ||
| 170 | |||
| 171 | assert.deepStrictEqual(result, { | ||
| 172 | nameWithOwner: "project/repo", | ||
| 173 | url: "https://dev.azure.com/acme/project/_git/repo", | ||
| 174 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | ||
| 175 | }); | ||
| 176 | }).pipe(Effect.provide(layer)), | ||
| 177 | ); | ||
| 178 | |||
| 179 | it.effect("creates repositories through Azure Repos", () => | ||
| 180 | Effect.gen(function* () { | ||
| 181 | mockRun.mockReturnValueOnce( | ||
| 182 | Effect.succeed( | ||
| 183 | processOutput( | ||
| 184 | JSON.stringify({ | ||
| 185 | name: "repo", | ||
| 186 | webUrl: "https://dev.azure.com/acme/project/_git/repo", | ||
| 187 | remoteUrl: "https://dev.azure.com/acme/project/_git/repo", | ||
| 188 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | ||
| 189 | project: { | ||
| 190 | name: "project", | ||
| 191 | }, | ||
| 192 | }), | ||
| 193 | ), | ||
| 194 | ), | ||
| 195 | ); | ||
| 196 | |||
| 197 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 198 | const result = yield* az.createRepository({ | ||
| 199 | cwd: "/repo", | ||
| 200 | repository: "project/repo", | ||
| 201 | visibility: "private", | ||
| 202 | }); | ||
| 203 | |||
| 204 | assert.deepStrictEqual(result, { | ||
| 205 | nameWithOwner: "project/repo", | ||
| 206 | url: "https://dev.azure.com/acme/project/_git/repo", | ||
| 207 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | ||
| 208 | }); | ||
| 209 | expect(mockRun).toHaveBeenCalledWith({ | ||
| 210 | operation: "AzureDevOpsCli.execute", | ||
| 211 | command: "az", | ||
| 212 | args: [ | ||
| 213 | "repos", | ||
| 214 | "create", | ||
| 215 | "--detect", | ||
| 216 | "true", | ||
| 217 | "--name", | ||
| 218 | "repo", | ||
| 219 | "--project", | ||
| 220 | "project", | ||
| 221 | "--only-show-errors", | ||
| 222 | "--output", | ||
| 223 | "json", | ||
| 224 | ], | ||
| 225 | cwd: "/repo", | ||
| 226 | timeoutMs: 30_000, | ||
| 227 | }); | ||
| 228 | }).pipe(Effect.provide(layer)), | ||
| 229 | ); | ||
| 230 | |||
| 231 | it.effect("creates pull requests using the body file as the Azure description", () => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 234 | const bodyFile = `/tmp/t3code-azure-devops-cli-${Date.now()}.md`; | ||
| 235 | yield* fileSystem.writeFileString(bodyFile, "Generated body"); | ||
| 236 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); | ||
| 237 | |||
| 238 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 239 | yield* az.createPullRequest({ | ||
| 240 | cwd: "/repo", | ||
| 241 | baseBranch: "main", | ||
| 242 | headSelector: "feature/provider", | ||
| 243 | title: "Provider PR", | ||
| 244 | bodyFile, | ||
| 245 | }); | ||
| 246 | |||
| 247 | expect(mockRun).toHaveBeenCalledWith( | ||
| 248 | expect.objectContaining({ | ||
| 249 | command: "az", | ||
| 250 | cwd: "/repo", | ||
| 251 | args: expect.arrayContaining(["--description", `@${bodyFile}`]), | ||
| 252 | }), | ||
| 253 | ); | ||
| 254 | expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output"); | ||
| 255 | }).pipe(Effect.provide(layer)), | ||
| 256 | ); | ||
| 257 | |||
| 258 | it.effect("does not force JSON output on checkout side-effect commands", () => | ||
| 259 | Effect.gen(function* () { | ||
| 260 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); | ||
| 261 | |||
| 262 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 263 | yield* az.checkoutPullRequest({ | ||
| 264 | cwd: "/repo", | ||
| 265 | reference: "42", | ||
| 266 | }); | ||
| 267 | |||
| 268 | expect(mockRun).toHaveBeenCalledWith({ | ||
| 269 | operation: "AzureDevOpsCli.execute", | ||
| 270 | command: "az", | ||
| 271 | args: [ | ||
| 272 | "repos", | ||
| 273 | "pr", | ||
| 274 | "checkout", | ||
| 275 | "--only-show-errors", | ||
| 276 | "--detect", | ||
| 277 | "true", | ||
| 278 | "--id", | ||
| 279 | "42", | ||
| 280 | "--remote-name", | ||
| 281 | "origin", | ||
| 282 | ], | ||
| 283 | cwd: "/repo", | ||
| 284 | timeoutMs: 30_000, | ||
| 285 | }); | ||
| 286 | }).pipe(Effect.provide(layer)), | ||
| 287 | ); | ||
| 288 | }); | ||
| @@ -0,0 +1,429 @@ | |||
| 1 | import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; | ||
| 2 | import { | ||
| 3 | TrimmedNonEmptyString, | ||
| 4 | type SourceControlRepositoryVisibility, | ||
| 5 | type VcsError, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 9 | import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts"; | ||
| 10 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | ||
| 11 | |||
| 12 | const DEFAULT_TIMEOUT_MS = 30_000; | ||
| 13 | |||
| 14 | export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliError>()( | ||
| 15 | "AzureDevOpsCliError", | ||
| 16 | { | ||
| 17 | operation: Schema.String, | ||
| 18 | detail: Schema.String, | ||
| 19 | cause: Schema.optional(Schema.Defect), | ||
| 20 | }, | ||
| 21 | ) { | ||
| 22 | override get message(): string { | ||
| 23 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | export interface AzureDevOpsRepositoryCloneUrls { | ||
| 28 | readonly nameWithOwner: string; | ||
| 29 | readonly url: string; | ||
| 30 | readonly sshUrl: string; | ||
| 31 | } | ||
| 32 | |||
| 33 | export interface AzureDevOpsCliShape { | ||
| 34 | readonly execute: (input: { | ||
| 35 | readonly cwd: string; | ||
| 36 | readonly args: ReadonlyArray<string>; | ||
| 37 | readonly timeoutMs?: number; | ||
| 38 | }) => Effect.Effect<VcsProcess.VcsProcessOutput, AzureDevOpsCliError>; | ||
| 39 | |||
| 40 | readonly listPullRequests: (input: { | ||
| 41 | readonly cwd: string; | ||
| 42 | readonly headSelector: string; | ||
| 43 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 44 | readonly state: "open" | "closed" | "merged" | "all"; | ||
| 45 | readonly limit?: number; | ||
| 46 | }) => Effect.Effect< | ||
| 47 | ReadonlyArray<AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord>, | ||
| 48 | AzureDevOpsCliError | ||
| 49 | >; | ||
| 50 | |||
| 51 | readonly getPullRequest: (input: { | ||
| 52 | readonly cwd: string; | ||
| 53 | readonly reference: string; | ||
| 54 | }) => Effect.Effect< | ||
| 55 | AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord, | ||
| 56 | AzureDevOpsCliError | ||
| 57 | >; | ||
| 58 | |||
| 59 | readonly getRepositoryCloneUrls: (input: { | ||
| 60 | readonly cwd: string; | ||
| 61 | readonly repository: string; | ||
| 62 | }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>; | ||
| 63 | |||
| 64 | readonly createRepository: (input: { | ||
| 65 | readonly cwd: string; | ||
| 66 | readonly repository: string; | ||
| 67 | readonly visibility: SourceControlRepositoryVisibility; | ||
| 68 | }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>; | ||
| 69 | |||
| 70 | readonly createPullRequest: (input: { | ||
| 71 | readonly cwd: string; | ||
| 72 | readonly baseBranch: string; | ||
| 73 | readonly headSelector: string; | ||
| 74 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 75 | readonly target?: SourceControlProvider.SourceControlRefSelector; | ||
| 76 | readonly title: string; | ||
| 77 | readonly bodyFile: string; | ||
| 78 | }) => Effect.Effect<void, AzureDevOpsCliError>; | ||
| 79 | |||
| 80 | readonly getDefaultBranch: (input: { | ||
| 81 | readonly cwd: string; | ||
| 82 | }) => Effect.Effect<string | null, AzureDevOpsCliError>; | ||
| 83 | |||
| 84 | readonly checkoutPullRequest: (input: { | ||
| 85 | readonly cwd: string; | ||
| 86 | readonly reference: string; | ||
| 87 | readonly remoteName?: string; | ||
| 88 | }) => Effect.Effect<void, AzureDevOpsCliError>; | ||
| 89 | } | ||
| 90 | |||
| 91 | export class AzureDevOpsCli extends Context.Service<AzureDevOpsCli, AzureDevOpsCliShape>()( | ||
| 92 | "t3/source-control/AzureDevOpsCli", | ||
| 93 | ) {} | ||
| 94 | |||
| 95 | function errorText(error: VcsError | unknown): string { | ||
| 96 | if (typeof error === "object" && error !== null) { | ||
| 97 | const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; | ||
| 98 | const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; | ||
| 99 | const message = "message" in error && typeof error.message === "string" ? error.message : ""; | ||
| 100 | return [tag, detail, message].filter(Boolean).join("\n"); | ||
| 101 | } | ||
| 102 | |||
| 103 | return String(error); | ||
| 104 | } | ||
| 105 | |||
| 106 | function normalizeAzureDevOpsCliError( | ||
| 107 | operation: "execute", | ||
| 108 | error: VcsError | unknown, | ||
| 109 | ): AzureDevOpsCliError { | ||
| 110 | const text = errorText(error); | ||
| 111 | const lower = text.toLowerCase(); | ||
| 112 | |||
| 113 | if (lower.includes("command not found: az") || lower.includes("enoent")) { | ||
| 114 | return new AzureDevOpsCliError({ | ||
| 115 | operation, | ||
| 116 | detail: | ||
| 117 | "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.", | ||
| 118 | cause: error, | ||
| 119 | }); | ||
| 120 | } | ||
| 121 | |||
| 122 | if ( | ||
| 123 | lower.includes("az devops login") || | ||
| 124 | lower.includes("please run az login") || | ||
| 125 | lower.includes("not logged in") || | ||
| 126 | lower.includes("authentication failed") || | ||
| 127 | lower.includes("unauthorized") | ||
| 128 | ) { | ||
| 129 | return new AzureDevOpsCliError({ | ||
| 130 | operation, | ||
| 131 | detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.", | ||
| 132 | cause: error, | ||
| 133 | }); | ||
| 134 | } | ||
| 135 | |||
| 136 | if ( | ||
| 137 | lower.includes("pull request") && | ||
| 138 | (lower.includes("not found") || lower.includes("does not exist")) | ||
| 139 | ) { | ||
| 140 | return new AzureDevOpsCliError({ | ||
| 141 | operation, | ||
| 142 | detail: "Pull request not found. Check the PR number or URL and try again.", | ||
| 143 | cause: error, | ||
| 144 | }); | ||
| 145 | } | ||
| 146 | |||
| 147 | return new AzureDevOpsCliError({ | ||
| 148 | operation, | ||
| 149 | detail: text, | ||
| 150 | cause: error, | ||
| 151 | }); | ||
| 152 | } | ||
| 153 | |||
| 154 | function normalizeChangeRequestId(reference: string): string { | ||
| 155 | const trimmed = reference.trim().replace(/^#/, ""); | ||
| 156 | const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); | ||
| 157 | return urlMatch?.[1] ?? trimmed; | ||
| 158 | } | ||
| 159 | |||
| 160 | function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string { | ||
| 161 | switch (state) { | ||
| 162 | case "open": | ||
| 163 | return "active"; | ||
| 164 | case "closed": | ||
| 165 | return "abandoned"; | ||
| 166 | case "merged": | ||
| 167 | return "completed"; | ||
| 168 | case "all": | ||
| 169 | return "all"; | ||
| 170 | } | ||
| 171 | } | ||
| 172 | |||
| 173 | const RawAzureDevOpsRepositorySchema = Schema.Struct({ | ||
| 174 | name: TrimmedNonEmptyString, | ||
| 175 | webUrl: TrimmedNonEmptyString, | ||
| 176 | remoteUrl: TrimmedNonEmptyString, | ||
| 177 | sshUrl: TrimmedNonEmptyString, | ||
| 178 | project: Schema.optional( | ||
| 179 | Schema.Struct({ | ||
| 180 | name: TrimmedNonEmptyString, | ||
| 181 | }), | ||
| 182 | ), | ||
| 183 | defaultBranch: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 184 | }); | ||
| 185 | |||
| 186 | function normalizeDefaultBranch(value: string | null | undefined): string | null { | ||
| 187 | const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? ""; | ||
| 188 | return trimmed.length > 0 ? trimmed : null; | ||
| 189 | } | ||
| 190 | |||
| 191 | function normalizeRepositoryCloneUrls( | ||
| 192 | raw: Schema.Schema.Type<typeof RawAzureDevOpsRepositorySchema>, | ||
| 193 | ): AzureDevOpsRepositoryCloneUrls { | ||
| 194 | const projectName = raw.project?.name.trim(); | ||
| 195 | return { | ||
| 196 | nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name, | ||
| 197 | url: raw.remoteUrl, | ||
| 198 | sshUrl: raw.sshUrl, | ||
| 199 | }; | ||
| 200 | } | ||
| 201 | |||
| 202 | function parseRepositorySpecifier(repository: string): { | ||
| 203 | readonly project: string | null; | ||
| 204 | readonly name: string; | ||
| 205 | } { | ||
| 206 | const parts = repository | ||
| 207 | .split("/") | ||
| 208 | .map((part) => part.trim()) | ||
| 209 | .filter((part) => part.length > 0); | ||
| 210 | return { | ||
| 211 | project: parts.length > 1 ? (parts.at(-2) ?? null) : null, | ||
| 212 | name: parts.at(-1) ?? repository.trim(), | ||
| 213 | }; | ||
| 214 | } | ||
| 215 | |||
| 216 | function decodeAzureDevOpsJson<S extends Schema.Top>( | ||
| 217 | raw: string, | ||
| 218 | schema: S, | ||
| 219 | operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", | ||
| 220 | invalidDetail: string, | ||
| 221 | ): Effect.Effect<S["Type"], AzureDevOpsCliError, S["DecodingServices"]> { | ||
| 222 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( | ||
| 223 | Effect.mapError( | ||
| 224 | (error) => | ||
| 225 | new AzureDevOpsCliError({ | ||
| 226 | operation, | ||
| 227 | detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, | ||
| 228 | cause: error, | ||
| 229 | }), | ||
| 230 | ), | ||
| 231 | ); | ||
| 232 | } | ||
| 233 | |||
| 234 | export const make = Effect.fn("makeAzureDevOpsCli")(function* () { | ||
| 235 | const process = yield* VcsProcess.VcsProcess; | ||
| 236 | |||
| 237 | const execute: AzureDevOpsCliShape["execute"] = (input) => | ||
| 238 | process | ||
| 239 | .run({ | ||
| 240 | operation: "AzureDevOpsCli.execute", | ||
| 241 | command: "az", | ||
| 242 | args: input.args, | ||
| 243 | cwd: input.cwd, | ||
| 244 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | ||
| 245 | }) | ||
| 246 | .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); | ||
| 247 | |||
| 248 | const executeJson = (input: Parameters<AzureDevOpsCliShape["execute"]>[0]) => | ||
| 249 | execute({ | ||
| 250 | ...input, | ||
| 251 | args: [...input.args, "--only-show-errors", "--output", "json"], | ||
| 252 | }); | ||
| 253 | |||
| 254 | return AzureDevOpsCli.of({ | ||
| 255 | execute, | ||
| 256 | listPullRequests: (input) => | ||
| 257 | executeJson({ | ||
| 258 | cwd: input.cwd, | ||
| 259 | args: [ | ||
| 260 | "repos", | ||
| 261 | "pr", | ||
| 262 | "list", | ||
| 263 | "--detect", | ||
| 264 | "true", | ||
| 265 | "--source-branch", | ||
| 266 | SourceControlProvider.sourceBranch(input), | ||
| 267 | "--status", | ||
| 268 | toAzureStatus(input.state), | ||
| 269 | "--top", | ||
| 270 | String(input.limit ?? 20), | ||
| 271 | ], | ||
| 272 | }).pipe( | ||
| 273 | Effect.map((result) => result.stdout.trim()), | ||
| 274 | Effect.flatMap((raw) => | ||
| 275 | raw.length === 0 | ||
| 276 | ? Effect.succeed([]) | ||
| 277 | : Effect.sync(() => | ||
| 278 | AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestListJson(raw), | ||
| 279 | ).pipe( | ||
| 280 | Effect.flatMap((decoded) => { | ||
| 281 | if (!Result.isSuccess(decoded)) { | ||
| 282 | return Effect.fail( | ||
| 283 | new AzureDevOpsCliError({ | ||
| 284 | operation: "listPullRequests", | ||
| 285 | detail: `Azure DevOps CLI returned invalid PR list JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, | ||
| 286 | cause: decoded.failure, | ||
| 287 | }), | ||
| 288 | ); | ||
| 289 | } | ||
| 290 | |||
| 291 | return Effect.succeed(decoded.success); | ||
| 292 | }), | ||
| 293 | ), | ||
| 294 | ), | ||
| 295 | ), | ||
| 296 | getPullRequest: (input) => | ||
| 297 | executeJson({ | ||
| 298 | cwd: input.cwd, | ||
| 299 | args: [ | ||
| 300 | "repos", | ||
| 301 | "pr", | ||
| 302 | "show", | ||
| 303 | "--detect", | ||
| 304 | "true", | ||
| 305 | "--id", | ||
| 306 | normalizeChangeRequestId(input.reference), | ||
| 307 | ], | ||
| 308 | }).pipe( | ||
| 309 | Effect.map((result) => result.stdout.trim()), | ||
| 310 | Effect.flatMap((raw) => | ||
| 311 | Effect.sync(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestJson(raw)).pipe( | ||
| 312 | Effect.flatMap((decoded) => { | ||
| 313 | if (!Result.isSuccess(decoded)) { | ||
| 314 | return Effect.fail( | ||
| 315 | new AzureDevOpsCliError({ | ||
| 316 | operation: "getPullRequest", | ||
| 317 | detail: `Azure DevOps CLI returned invalid pull request JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, | ||
| 318 | cause: decoded.failure, | ||
| 319 | }), | ||
| 320 | ); | ||
| 321 | } | ||
| 322 | |||
| 323 | return Effect.succeed(decoded.success); | ||
| 324 | }), | ||
| 325 | ), | ||
| 326 | ), | ||
| 327 | ), | ||
| 328 | getRepositoryCloneUrls: (input) => | ||
| 329 | executeJson({ | ||
| 330 | cwd: input.cwd, | ||
| 331 | args: ["repos", "show", "--detect", "true", "--repository", input.repository], | ||
| 332 | }).pipe( | ||
| 333 | Effect.map((result) => result.stdout.trim()), | ||
| 334 | Effect.flatMap((raw) => | ||
| 335 | decodeAzureDevOpsJson( | ||
| 336 | raw, | ||
| 337 | RawAzureDevOpsRepositorySchema, | ||
| 338 | "getRepositoryCloneUrls", | ||
| 339 | "Azure DevOps CLI returned invalid repository JSON.", | ||
| 340 | ), | ||
| 341 | ), | ||
| 342 | Effect.map(normalizeRepositoryCloneUrls), | ||
| 343 | ), | ||
| 344 | createRepository: (input) => { | ||
| 345 | const repository = parseRepositorySpecifier(input.repository); | ||
| 346 | // Azure Repos access is governed by project/organization permissions. | ||
| 347 | // `az repos create` does not expose a per-repository visibility flag, so | ||
| 348 | // the generic source-control visibility input is intentionally not | ||
| 349 | // translated into CLI args for this provider. | ||
| 350 | return executeJson({ | ||
| 351 | cwd: input.cwd, | ||
| 352 | args: [ | ||
| 353 | "repos", | ||
| 354 | "create", | ||
| 355 | "--detect", | ||
| 356 | "true", | ||
| 357 | "--name", | ||
| 358 | repository.name, | ||
| 359 | ...(repository.project ? ["--project", repository.project] : []), | ||
| 360 | ], | ||
| 361 | }).pipe( | ||
| 362 | Effect.map((result) => result.stdout.trim()), | ||
| 363 | Effect.flatMap((raw) => | ||
| 364 | decodeAzureDevOpsJson( | ||
| 365 | raw, | ||
| 366 | RawAzureDevOpsRepositorySchema, | ||
| 367 | "createRepository", | ||
| 368 | "Azure DevOps CLI returned invalid repository JSON.", | ||
| 369 | ), | ||
| 370 | ), | ||
| 371 | Effect.map(normalizeRepositoryCloneUrls), | ||
| 372 | ); | ||
| 373 | }, | ||
| 374 | createPullRequest: (input) => | ||
| 375 | execute({ | ||
| 376 | cwd: input.cwd, | ||
| 377 | args: [ | ||
| 378 | "repos", | ||
| 379 | "pr", | ||
| 380 | "create", | ||
| 381 | "--only-show-errors", | ||
| 382 | "--detect", | ||
| 383 | "true", | ||
| 384 | "--target-branch", | ||
| 385 | input.target?.refName ?? input.baseBranch, | ||
| 386 | "--source-branch", | ||
| 387 | SourceControlProvider.sourceBranch(input), | ||
| 388 | "--title", | ||
| 389 | input.title, | ||
| 390 | "--description", | ||
| 391 | `@${input.bodyFile}`, | ||
| 392 | ], | ||
| 393 | }).pipe(Effect.asVoid), | ||
| 394 | getDefaultBranch: (input) => | ||
| 395 | executeJson({ | ||
| 396 | cwd: input.cwd, | ||
| 397 | args: ["repos", "show", "--detect", "true"], | ||
| 398 | }).pipe( | ||
| 399 | Effect.map((result) => result.stdout.trim()), | ||
| 400 | Effect.flatMap((raw) => | ||
| 401 | decodeAzureDevOpsJson( | ||
| 402 | raw, | ||
| 403 | RawAzureDevOpsRepositorySchema, | ||
| 404 | "getDefaultBranch", | ||
| 405 | "Azure DevOps CLI returned invalid repository JSON.", | ||
| 406 | ), | ||
| 407 | ), | ||
| 408 | Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), | ||
| 409 | ), | ||
| 410 | checkoutPullRequest: (input) => | ||
| 411 | execute({ | ||
| 412 | cwd: input.cwd, | ||
| 413 | args: [ | ||
| 414 | "repos", | ||
| 415 | "pr", | ||
| 416 | "checkout", | ||
| 417 | "--only-show-errors", | ||
| 418 | "--detect", | ||
| 419 | "true", | ||
| 420 | "--id", | ||
| 421 | normalizeChangeRequestId(input.reference), | ||
| 422 | "--remote-name", | ||
| 423 | input.remoteName ?? "origin", | ||
| 424 | ], | ||
| 425 | }).pipe(Effect.asVoid), | ||
| 426 | }); | ||
| 427 | }); | ||
| 428 | |||
| 429 | export const layer = Layer.effect(AzureDevOpsCli, make()); | ||
| @@ -0,0 +1,91 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer, Option } from "effect"; | ||
| 3 | |||
| 4 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | ||
| 5 | import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; | ||
| 6 | |||
| 7 | function makeProvider(azure: Partial<AzureDevOpsCli.AzureDevOpsCliShape>) { | ||
| 8 | return AzureDevOpsSourceControlProvider.make().pipe( | ||
| 9 | Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), | ||
| 10 | ); | ||
| 11 | } | ||
| 12 | |||
| 13 | it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () => | ||
| 14 | Effect.gen(function* () { | ||
| 15 | const provider = yield* makeProvider({ | ||
| 16 | getPullRequest: () => | ||
| 17 | Effect.succeed({ | ||
| 18 | number: 42, | ||
| 19 | title: "Add Azure provider", | ||
| 20 | url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", | ||
| 21 | baseRefName: "main", | ||
| 22 | headRefName: "feature/source-control", | ||
| 23 | state: "open", | ||
| 24 | updatedAt: Option.none(), | ||
| 25 | }), | ||
| 26 | }); | ||
| 27 | |||
| 28 | const changeRequest = yield* provider.getChangeRequest({ | ||
| 29 | cwd: "/repo", | ||
| 30 | reference: "42", | ||
| 31 | }); | ||
| 32 | |||
| 33 | assert.deepStrictEqual(changeRequest, { | ||
| 34 | provider: "azure-devops", | ||
| 35 | number: 42, | ||
| 36 | title: "Add Azure provider", | ||
| 37 | url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", | ||
| 38 | baseRefName: "main", | ||
| 39 | headRefName: "feature/source-control", | ||
| 40 | state: "open", | ||
| 41 | updatedAt: Option.none(), | ||
| 42 | isCrossRepository: false, | ||
| 43 | }); | ||
| 44 | }), | ||
| 45 | ); | ||
| 46 | |||
| 47 | it.effect("creates Azure DevOps PRs through provider-neutral input names", () => | ||
| 48 | Effect.gen(function* () { | ||
| 49 | let createInput: Parameters<AzureDevOpsCli.AzureDevOpsCliShape["createPullRequest"]>[0] | null = | ||
| 50 | null; | ||
| 51 | const provider = yield* makeProvider({ | ||
| 52 | createPullRequest: (input) => { | ||
| 53 | createInput = input; | ||
| 54 | return Effect.void; | ||
| 55 | }, | ||
| 56 | }); | ||
| 57 | |||
| 58 | yield* provider.createChangeRequest({ | ||
| 59 | cwd: "/repo", | ||
| 60 | baseRefName: "main", | ||
| 61 | headSelector: "feature/provider", | ||
| 62 | title: "Provider PR", | ||
| 63 | bodyFile: "/tmp/body.md", | ||
| 64 | }); | ||
| 65 | |||
| 66 | assert.deepStrictEqual(createInput, { | ||
| 67 | cwd: "/repo", | ||
| 68 | baseBranch: "main", | ||
| 69 | headSelector: "feature/provider", | ||
| 70 | title: "Provider PR", | ||
| 71 | bodyFile: "/tmp/body.md", | ||
| 72 | }); | ||
| 73 | }), | ||
| 74 | ); | ||
| 75 | |||
| 76 | it.effect("uses Azure CLI repository detection for default branch lookup", () => | ||
| 77 | Effect.gen(function* () { | ||
| 78 | let cwdInput: string | null = null; | ||
| 79 | const provider = yield* makeProvider({ | ||
| 80 | getDefaultBranch: (input) => { | ||
| 81 | cwdInput = input.cwd; | ||
| 82 | return Effect.succeed("main"); | ||
| 83 | }, | ||
| 84 | }); | ||
| 85 | |||
| 86 | const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); | ||
| 87 | |||
| 88 | assert.strictEqual(defaultBranch, "main"); | ||
| 89 | assert.strictEqual(cwdInput, "/repo"); | ||
| 90 | }), | ||
| 91 | ); | ||
| @@ -0,0 +1,144 @@ | |||
| 1 | import { Effect, Layer } from "effect"; | ||
| 2 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | ||
| 5 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | ||
| 6 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | ||
| 7 | |||
| 8 | function providerError( | ||
| 9 | operation: string, | ||
| 10 | cause: AzureDevOpsCli.AzureDevOpsCliError, | ||
| 11 | ): SourceControlProviderError { | ||
| 12 | return new SourceControlProviderError({ | ||
| 13 | provider: "azure-devops", | ||
| 14 | operation, | ||
| 15 | detail: cause.detail, | ||
| 16 | cause, | ||
| 17 | }); | ||
| 18 | } | ||
| 19 | |||
| 20 | function parseAzureAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { | ||
| 21 | const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); | ||
| 22 | |||
| 23 | if (input.exitCode !== 0) { | ||
| 24 | return SourceControlProviderDiscovery.providerAuth({ | ||
| 25 | status: "unauthenticated", | ||
| 26 | detail: | ||
| 27 | SourceControlProviderDiscovery.firstSafeAuthLine( | ||
| 28 | SourceControlProviderDiscovery.combinedAuthOutput(input), | ||
| 29 | ) ?? "Run `az login` to authenticate Azure CLI.", | ||
| 30 | }); | ||
| 31 | } | ||
| 32 | |||
| 33 | if (account && account.length > 0) { | ||
| 34 | return SourceControlProviderDiscovery.providerAuth({ | ||
| 35 | status: "authenticated", | ||
| 36 | account, | ||
| 37 | host: "dev.azure.com", | ||
| 38 | }); | ||
| 39 | } | ||
| 40 | |||
| 41 | return SourceControlProviderDiscovery.providerAuth({ | ||
| 42 | status: "unknown", | ||
| 43 | host: "dev.azure.com", | ||
| 44 | detail: "Azure CLI account status could not be parsed.", | ||
| 45 | }); | ||
| 46 | } | ||
| 47 | |||
| 48 | export const discovery = { | ||
| 49 | type: "cli", | ||
| 50 | kind: "azure-devops", | ||
| 51 | label: "Azure DevOps", | ||
| 52 | executable: "az", | ||
| 53 | versionArgs: ["--version"], | ||
| 54 | authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], | ||
| 55 | parseAuth: parseAzureAuth, | ||
| 56 | installHint: | ||
| 57 | "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", | ||
| 58 | } satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; | ||
| 59 | |||
| 60 | function toChangeRequest(summary: { | ||
| 61 | readonly number: number; | ||
| 62 | readonly title: string; | ||
| 63 | readonly url: string; | ||
| 64 | readonly baseRefName: string; | ||
| 65 | readonly headRefName: string; | ||
| 66 | readonly state: "open" | "closed" | "merged"; | ||
| 67 | readonly updatedAt: ChangeRequest["updatedAt"]; | ||
| 68 | }): ChangeRequest { | ||
| 69 | return { | ||
| 70 | provider: "azure-devops", | ||
| 71 | number: summary.number, | ||
| 72 | title: summary.title, | ||
| 73 | url: summary.url, | ||
| 74 | baseRefName: summary.baseRefName, | ||
| 75 | headRefName: summary.headRefName, | ||
| 76 | state: summary.state, | ||
| 77 | updatedAt: summary.updatedAt, | ||
| 78 | isCrossRepository: false, | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () { | ||
| 83 | const azure = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 84 | |||
| 85 | return SourceControlProvider.SourceControlProvider.of({ | ||
| 86 | kind: "azure-devops", | ||
| 87 | listChangeRequests: (input) => { | ||
| 88 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 89 | return azure | ||
| 90 | .listPullRequests({ | ||
| 91 | cwd: input.cwd, | ||
| 92 | headSelector: input.headSelector, | ||
| 93 | ...(source ? { source } : {}), | ||
| 94 | state: input.state, | ||
| 95 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | ||
| 96 | }) | ||
| 97 | .pipe( | ||
| 98 | Effect.map((items) => items.map(toChangeRequest)), | ||
| 99 | Effect.mapError((error) => providerError("listChangeRequests", error)), | ||
| 100 | ); | ||
| 101 | }, | ||
| 102 | getChangeRequest: (input) => | ||
| 103 | azure.getPullRequest(input).pipe( | ||
| 104 | Effect.map(toChangeRequest), | ||
| 105 | Effect.mapError((error) => providerError("getChangeRequest", error)), | ||
| 106 | ), | ||
| 107 | createChangeRequest: (input) => { | ||
| 108 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 109 | return azure | ||
| 110 | .createPullRequest({ | ||
| 111 | cwd: input.cwd, | ||
| 112 | baseBranch: input.baseRefName, | ||
| 113 | headSelector: input.headSelector, | ||
| 114 | ...(source ? { source } : {}), | ||
| 115 | ...(input.target ? { target: input.target } : {}), | ||
| 116 | title: input.title, | ||
| 117 | bodyFile: input.bodyFile, | ||
| 118 | }) | ||
| 119 | .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); | ||
| 120 | }, | ||
| 121 | getRepositoryCloneUrls: (input) => | ||
| 122 | azure | ||
| 123 | .getRepositoryCloneUrls(input) | ||
| 124 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | ||
| 125 | createRepository: (input) => | ||
| 126 | azure | ||
| 127 | .createRepository(input) | ||
| 128 | .pipe(Effect.mapError((error) => providerError("createRepository", error))), | ||
| 129 | getDefaultBranch: (input) => | ||
| 130 | azure | ||
| 131 | .getDefaultBranch({ cwd: input.cwd }) | ||
| 132 | .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), | ||
| 133 | checkoutChangeRequest: (input) => | ||
| 134 | azure | ||
| 135 | .checkoutPullRequest({ | ||
| 136 | cwd: input.cwd, | ||
| 137 | reference: input.reference, | ||
| 138 | ...(input.context ? { remoteName: input.context.remoteName } : {}), | ||
| 139 | }) | ||
| 140 | .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), | ||
| 141 | }); | ||
| 142 | }); | ||
| 143 | |||
| 144 | export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); | ||
| @@ -0,0 +1,513 @@ | |||
| 1 | import { assert, it, vi } from "@effect/vitest"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { ConfigProvider, DateTime, Effect, FileSystem, Layer, Option } from "effect"; | ||
| 4 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 5 | |||
| 6 | import * as BitbucketApi from "./BitbucketApi.ts"; | ||
| 7 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | ||
| 8 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 9 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; | ||
| 10 | |||
| 11 | const bitbucketPullRequest = { | ||
| 12 | id: 42, | ||
| 13 | title: "Add Bitbucket provider", | ||
| 14 | state: "OPEN", | ||
| 15 | updated_on: "2026-01-02T00:00:00.000Z", | ||
| 16 | links: { | ||
| 17 | html: { | ||
| 18 | href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", | ||
| 19 | }, | ||
| 20 | }, | ||
| 21 | source: { | ||
| 22 | branch: { name: "feature/source-control" }, | ||
| 23 | repository: { | ||
| 24 | full_name: "octocat/t3code", | ||
| 25 | workspace: { slug: "octocat" }, | ||
| 26 | }, | ||
| 27 | }, | ||
| 28 | destination: { | ||
| 29 | branch: { name: "main" }, | ||
| 30 | repository: { | ||
| 31 | full_name: "pingdotgg/t3code", | ||
| 32 | workspace: { slug: "pingdotgg" }, | ||
| 33 | }, | ||
| 34 | }, | ||
| 35 | }; | ||
| 36 | |||
| 37 | const repositoryJson = { | ||
| 38 | full_name: "pingdotgg/t3code", | ||
| 39 | links: { | ||
| 40 | html: { href: "https://bitbucket.org/pingdotgg/t3code" }, | ||
| 41 | clone: [ | ||
| 42 | { name: "https", href: "https://bitbucket.org/pingdotgg/t3code.git" }, | ||
| 43 | { name: "ssh", href: "git@bitbucket.org:pingdotgg/t3code.git" }, | ||
| 44 | ], | ||
| 45 | }, | ||
| 46 | mainbranch: { name: "main" }, | ||
| 47 | }; | ||
| 48 | |||
| 49 | function makeLayer(input: { | ||
| 50 | readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; | ||
| 51 | readonly git?: Partial<GitVcsDriver.GitVcsDriverShape>; | ||
| 52 | }) { | ||
| 53 | const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => | ||
| 54 | Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), | ||
| 55 | ); | ||
| 56 | const gitMock = { | ||
| 57 | readConfigValue: vi.fn<GitVcsDriver.GitVcsDriverShape["readConfigValue"]>(() => | ||
| 58 | Effect.succeed<string | null>("git@bitbucket.org:pingdotgg/t3code.git"), | ||
| 59 | ), | ||
| 60 | resolvePrimaryRemoteName: vi.fn<GitVcsDriver.GitVcsDriverShape["resolvePrimaryRemoteName"]>( | ||
| 61 | () => Effect.succeed("origin"), | ||
| 62 | ), | ||
| 63 | ensureRemote: vi.fn<GitVcsDriver.GitVcsDriverShape["ensureRemote"]>(() => | ||
| 64 | Effect.succeed("octocat"), | ||
| 65 | ), | ||
| 66 | fetchRemoteBranch: vi.fn<GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"]>( | ||
| 67 | () => Effect.void, | ||
| 68 | ), | ||
| 69 | fetchRemoteTrackingBranch: vi.fn<GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"]>( | ||
| 70 | () => Effect.void, | ||
| 71 | ), | ||
| 72 | setBranchUpstream: vi.fn<GitVcsDriver.GitVcsDriverShape["setBranchUpstream"]>( | ||
| 73 | () => Effect.void, | ||
| 74 | ), | ||
| 75 | switchRef: vi.fn<GitVcsDriver.GitVcsDriverShape["switchRef"]>((request) => | ||
| 76 | Effect.succeed({ refName: request.refName }), | ||
| 77 | ), | ||
| 78 | listLocalBranchNames: vi.fn<GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"]>(() => | ||
| 79 | Effect.succeed([]), | ||
| 80 | ), | ||
| 81 | }; | ||
| 82 | const git = { | ||
| 83 | ...gitMock, | ||
| 84 | ...input.git, | ||
| 85 | } satisfies Partial<GitVcsDriver.GitVcsDriverShape>; | ||
| 86 | |||
| 87 | const driver = { | ||
| 88 | listRemotes: () => | ||
| 89 | Effect.succeed({ | ||
| 90 | remotes: [ | ||
| 91 | { | ||
| 92 | name: "origin", | ||
| 93 | url: "git@bitbucket.org:pingdotgg/t3code.git", | ||
| 94 | pushUrl: Option.none(), | ||
| 95 | isPrimary: true, | ||
| 96 | }, | ||
| 97 | ], | ||
| 98 | freshness: { | ||
| 99 | source: "live-local" as const, | ||
| 100 | observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), | ||
| 101 | expiresAt: Option.none(), | ||
| 102 | }, | ||
| 103 | }), | ||
| 104 | } satisfies Partial<VcsDriver.VcsDriverShape>; | ||
| 105 | |||
| 106 | const layer = BitbucketApi.layer.pipe( | ||
| 107 | Layer.provide( | ||
| 108 | Layer.succeed( | ||
| 109 | HttpClient.HttpClient, | ||
| 110 | HttpClient.make((request) => execute(request)), | ||
| 111 | ), | ||
| 112 | ), | ||
| 113 | Layer.provide( | ||
| 114 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ | ||
| 115 | resolve: () => | ||
| 116 | Effect.succeed({ | ||
| 117 | kind: "git", | ||
| 118 | repository: { | ||
| 119 | kind: "git", | ||
| 120 | rootPath: "/repo", | ||
| 121 | metadataPath: null, | ||
| 122 | freshness: { | ||
| 123 | source: "live-local" as const, | ||
| 124 | observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), | ||
| 125 | expiresAt: Option.none(), | ||
| 126 | }, | ||
| 127 | }, | ||
| 128 | driver: driver as unknown as VcsDriver.VcsDriverShape, | ||
| 129 | }), | ||
| 130 | }), | ||
| 131 | ), | ||
| 132 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)), | ||
| 133 | Layer.provide( | ||
| 134 | ConfigProvider.layer( | ||
| 135 | ConfigProvider.fromEnv({ | ||
| 136 | env: { | ||
| 137 | T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", | ||
| 138 | T3CODE_BITBUCKET_EMAIL: "user@example.com", | ||
| 139 | T3CODE_BITBUCKET_API_TOKEN: "token", | ||
| 140 | }, | ||
| 141 | }), | ||
| 142 | ), | ||
| 143 | ), | ||
| 144 | Layer.provideMerge(NodeServices.layer), | ||
| 145 | ); | ||
| 146 | |||
| 147 | return { execute, git: gitMock, layer }; | ||
| 148 | } | ||
| 149 | |||
| 150 | it.effect("parses pull request responses from the Bitbucket REST API", () => { | ||
| 151 | const { execute, layer } = makeLayer({ | ||
| 152 | response: () => | ||
| 153 | Response.json({ | ||
| 154 | ...bitbucketPullRequest, | ||
| 155 | }), | ||
| 156 | }); | ||
| 157 | |||
| 158 | return Effect.gen(function* () { | ||
| 159 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 160 | const result = yield* bitbucket.getPullRequest({ | ||
| 161 | cwd: "/repo", | ||
| 162 | reference: "#42", | ||
| 163 | }); | ||
| 164 | |||
| 165 | assert.deepStrictEqual(result, { | ||
| 166 | number: 42, | ||
| 167 | title: "Add Bitbucket provider", | ||
| 168 | url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", | ||
| 169 | baseRefName: "main", | ||
| 170 | headRefName: "feature/source-control", | ||
| 171 | state: "open", | ||
| 172 | updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), | ||
| 173 | isCrossRepository: true, | ||
| 174 | headRepositoryNameWithOwner: "octocat/t3code", | ||
| 175 | headRepositoryOwnerLogin: "octocat", | ||
| 176 | }); | ||
| 177 | assert.strictEqual( | ||
| 178 | execute.mock.calls[0]?.[0].url, | ||
| 179 | "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests/42", | ||
| 180 | ); | ||
| 181 | }).pipe(Effect.provide(layer)); | ||
| 182 | }); | ||
| 183 | |||
| 184 | it.effect("lists pull requests with Bitbucket state and source branch query params", () => { | ||
| 185 | const { execute, layer } = makeLayer({ | ||
| 186 | response: () => | ||
| 187 | Response.json({ | ||
| 188 | values: [ | ||
| 189 | { | ||
| 190 | ...bitbucketPullRequest, | ||
| 191 | id: 7, | ||
| 192 | state: "MERGED", | ||
| 193 | source: { | ||
| 194 | branch: { name: "feature/merged" }, | ||
| 195 | repository: { full_name: "pingdotgg/t3code" }, | ||
| 196 | }, | ||
| 197 | }, | ||
| 198 | ], | ||
| 199 | }), | ||
| 200 | }); | ||
| 201 | |||
| 202 | return Effect.gen(function* () { | ||
| 203 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 204 | const result = yield* bitbucket.listPullRequests({ | ||
| 205 | cwd: "/repo", | ||
| 206 | headSelector: "origin:feature/merged", | ||
| 207 | state: "merged", | ||
| 208 | limit: 10, | ||
| 209 | }); | ||
| 210 | |||
| 211 | assert.strictEqual(result[0]?.state, "merged"); | ||
| 212 | const request = execute.mock.calls[0]?.[0]; | ||
| 213 | assert.strictEqual( | ||
| 214 | request?.url, | ||
| 215 | "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests", | ||
| 216 | ); | ||
| 217 | assert.deepStrictEqual(request?.urlParams.params, [ | ||
| 218 | ["pagelen", "10"], | ||
| 219 | ["sort", "-updated_on"], | ||
| 220 | ["q", 'source.branch.name = "feature/merged" AND state = "MERGED"'], | ||
| 221 | ["state", "MERGED"], | ||
| 222 | ]); | ||
| 223 | }).pipe(Effect.provide(layer)); | ||
| 224 | }); | ||
| 225 | |||
| 226 | it.effect("lists closed pull requests with both closed Bitbucket states", () => { | ||
| 227 | const { execute, layer } = makeLayer({ | ||
| 228 | response: () => | ||
| 229 | Response.json({ | ||
| 230 | values: [], | ||
| 231 | }), | ||
| 232 | }); | ||
| 233 | |||
| 234 | return Effect.gen(function* () { | ||
| 235 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 236 | yield* bitbucket.listPullRequests({ | ||
| 237 | cwd: "/repo", | ||
| 238 | headSelector: "feature/closed", | ||
| 239 | state: "closed", | ||
| 240 | limit: 10, | ||
| 241 | }); | ||
| 242 | |||
| 243 | assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ | ||
| 244 | ["pagelen", "10"], | ||
| 245 | ["sort", "-updated_on"], | ||
| 246 | [ | ||
| 247 | "q", | ||
| 248 | 'source.branch.name = "feature/closed" AND (state = "DECLINED" OR state = "SUPERSEDED")', | ||
| 249 | ], | ||
| 250 | ["state", "DECLINED"], | ||
| 251 | ["state", "SUPERSEDED"], | ||
| 252 | ]); | ||
| 253 | }).pipe(Effect.provide(layer)); | ||
| 254 | }); | ||
| 255 | |||
| 256 | it.effect("expands all-state pull request listing instead of relying on Bitbucket defaults", () => { | ||
| 257 | const { execute, layer } = makeLayer({ | ||
| 258 | response: () => | ||
| 259 | Response.json({ | ||
| 260 | values: [], | ||
| 261 | }), | ||
| 262 | }); | ||
| 263 | |||
| 264 | return Effect.gen(function* () { | ||
| 265 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 266 | yield* bitbucket.listPullRequests({ | ||
| 267 | cwd: "/repo", | ||
| 268 | headSelector: "feature/all", | ||
| 269 | state: "all", | ||
| 270 | limit: 10, | ||
| 271 | }); | ||
| 272 | |||
| 273 | assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ | ||
| 274 | ["pagelen", "10"], | ||
| 275 | ["sort", "-updated_on"], | ||
| 276 | [ | ||
| 277 | "q", | ||
| 278 | 'source.branch.name = "feature/all" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")', | ||
| 279 | ], | ||
| 280 | ["state", "OPEN"], | ||
| 281 | ["state", "MERGED"], | ||
| 282 | ["state", "DECLINED"], | ||
| 283 | ["state", "SUPERSEDED"], | ||
| 284 | ]); | ||
| 285 | }).pipe(Effect.provide(layer)); | ||
| 286 | }); | ||
| 287 | |||
| 288 | it.effect("reads repository clone URLs and default branch", () => { | ||
| 289 | const { layer } = makeLayer({ | ||
| 290 | response: () => Response.json(repositoryJson), | ||
| 291 | }); | ||
| 292 | |||
| 293 | return Effect.gen(function* () { | ||
| 294 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 295 | const cloneUrls = yield* bitbucket.getRepositoryCloneUrls({ | ||
| 296 | cwd: "/repo", | ||
| 297 | repository: "pingdotgg/t3code", | ||
| 298 | }); | ||
| 299 | const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); | ||
| 300 | |||
| 301 | assert.deepStrictEqual(cloneUrls, { | ||
| 302 | nameWithOwner: "pingdotgg/t3code", | ||
| 303 | url: "https://bitbucket.org/pingdotgg/t3code.git", | ||
| 304 | sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", | ||
| 305 | }); | ||
| 306 | assert.strictEqual(defaultBranch, "main"); | ||
| 307 | }).pipe(Effect.provide(layer)); | ||
| 308 | }); | ||
| 309 | |||
| 310 | it.effect("creates repositories through the Bitbucket REST API", () => { | ||
| 311 | const { execute, layer } = makeLayer({ | ||
| 312 | response: () => Response.json(repositoryJson), | ||
| 313 | }); | ||
| 314 | |||
| 315 | return Effect.gen(function* () { | ||
| 316 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 317 | const cloneUrls = yield* bitbucket.createRepository({ | ||
| 318 | cwd: "/repo", | ||
| 319 | repository: "pingdotgg/t3code", | ||
| 320 | visibility: "private", | ||
| 321 | }); | ||
| 322 | |||
| 323 | assert.deepStrictEqual(cloneUrls, { | ||
| 324 | nameWithOwner: "pingdotgg/t3code", | ||
| 325 | url: "https://bitbucket.org/pingdotgg/t3code.git", | ||
| 326 | sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", | ||
| 327 | }); | ||
| 328 | |||
| 329 | const request = execute.mock.calls[0]?.[0]; | ||
| 330 | assert.strictEqual(request?.url, "https://api.test.local/2.0/repositories/pingdotgg/t3code"); | ||
| 331 | assert.strictEqual(request?.method, "POST"); | ||
| 332 | assert.ok(request); | ||
| 333 | const rawBody = (request.body as { readonly body?: Uint8Array }).body; | ||
| 334 | assert.ok(rawBody); | ||
| 335 | assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { | ||
| 336 | scm: "git", | ||
| 337 | is_private: true, | ||
| 338 | }); | ||
| 339 | }).pipe(Effect.provide(layer)); | ||
| 340 | }); | ||
| 341 | |||
| 342 | it.effect("creates pull requests using the official REST payload shape", () => { | ||
| 343 | const { execute, layer } = makeLayer({ | ||
| 344 | response: () => Response.json(bitbucketPullRequest), | ||
| 345 | }); | ||
| 346 | |||
| 347 | return Effect.gen(function* () { | ||
| 348 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 349 | const bodyFile = yield* fileSystem.makeTempFileScoped({ prefix: "bitbucket-pr-body-" }); | ||
| 350 | yield* fileSystem.writeFileString(bodyFile, "PR body"); | ||
| 351 | |||
| 352 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 353 | yield* bitbucket.createPullRequest({ | ||
| 354 | cwd: "/repo", | ||
| 355 | baseBranch: "main", | ||
| 356 | headSelector: "owner:feature/provider", | ||
| 357 | title: "Provider PR", | ||
| 358 | bodyFile, | ||
| 359 | }); | ||
| 360 | |||
| 361 | const request = execute.mock.calls[0]?.[0]; | ||
| 362 | assert.strictEqual( | ||
| 363 | request?.url, | ||
| 364 | "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests", | ||
| 365 | ); | ||
| 366 | assert.strictEqual(request?.method, "POST"); | ||
| 367 | assert.ok(request); | ||
| 368 | const rawBody = (request.body as { readonly body?: Uint8Array }).body; | ||
| 369 | assert.ok(rawBody); | ||
| 370 | assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { | ||
| 371 | title: "Provider PR", | ||
| 372 | description: "PR body", | ||
| 373 | source: { | ||
| 374 | branch: { name: "feature/provider" }, | ||
| 375 | repository: { full_name: "owner/t3code" }, | ||
| 376 | }, | ||
| 377 | destination: { | ||
| 378 | branch: { name: "main" }, | ||
| 379 | }, | ||
| 380 | }); | ||
| 381 | }).pipe(Effect.provide(layer), Effect.scoped); | ||
| 382 | }); | ||
| 383 | |||
| 384 | it.effect("reports auth status through the Bitbucket REST /user endpoint", () => { | ||
| 385 | const { layer } = makeLayer({ | ||
| 386 | response: () => Response.json({ username: "bitbucket-user" }), | ||
| 387 | }); | ||
| 388 | |||
| 389 | return Effect.gen(function* () { | ||
| 390 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 391 | const auth = yield* bitbucket.probeAuth; | ||
| 392 | |||
| 393 | assert.deepStrictEqual(auth, { | ||
| 394 | status: "authenticated", | ||
| 395 | account: Option.some("bitbucket-user"), | ||
| 396 | host: Option.some("bitbucket.org"), | ||
| 397 | detail: Option.none(), | ||
| 398 | }); | ||
| 399 | }).pipe(Effect.provide(layer)); | ||
| 400 | }); | ||
| 401 | |||
| 402 | it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { | ||
| 403 | const { git, layer } = makeLayer({ | ||
| 404 | response: () => | ||
| 405 | Response.json({ | ||
| 406 | ...bitbucketPullRequest, | ||
| 407 | source: { | ||
| 408 | branch: { name: "feature/source-control" }, | ||
| 409 | repository: { | ||
| 410 | full_name: "pingdotgg/t3code", | ||
| 411 | workspace: { slug: "pingdotgg" }, | ||
| 412 | }, | ||
| 413 | }, | ||
| 414 | }), | ||
| 415 | }); | ||
| 416 | |||
| 417 | return Effect.gen(function* () { | ||
| 418 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 419 | yield* bitbucket.checkoutPullRequest({ | ||
| 420 | cwd: "/repo", | ||
| 421 | context: { | ||
| 422 | provider: { | ||
| 423 | kind: "bitbucket", | ||
| 424 | name: "Bitbucket", | ||
| 425 | baseUrl: "https://bitbucket.org", | ||
| 426 | }, | ||
| 427 | remoteName: "origin", | ||
| 428 | remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git", | ||
| 429 | }, | ||
| 430 | reference: "42", | ||
| 431 | force: true, | ||
| 432 | }); | ||
| 433 | |||
| 434 | assert.strictEqual(git.ensureRemote.mock.calls.length, 0); | ||
| 435 | assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], { | ||
| 436 | cwd: "/repo", | ||
| 437 | remoteName: "origin", | ||
| 438 | remoteBranch: "feature/source-control", | ||
| 439 | localBranch: "feature/source-control", | ||
| 440 | }); | ||
| 441 | assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], { | ||
| 442 | cwd: "/repo", | ||
| 443 | branch: "feature/source-control", | ||
| 444 | remoteName: "origin", | ||
| 445 | remoteBranch: "feature/source-control", | ||
| 446 | }); | ||
| 447 | assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], { | ||
| 448 | cwd: "/repo", | ||
| 449 | refName: "feature/source-control", | ||
| 450 | }); | ||
| 451 | }).pipe(Effect.provide(layer)); | ||
| 452 | }); | ||
| 453 | |||
| 454 | it.effect("checks out fork pull requests through an ensured fork remote", () => { | ||
| 455 | const { git, layer } = makeLayer({ | ||
| 456 | response: (request) => { | ||
| 457 | if (request.url.endsWith("/repositories/octocat/t3code")) { | ||
| 458 | return Response.json({ | ||
| 459 | ...repositoryJson, | ||
| 460 | full_name: "octocat/t3code", | ||
| 461 | links: { | ||
| 462 | html: { href: "https://bitbucket.org/octocat/t3code" }, | ||
| 463 | clone: [ | ||
| 464 | { name: "https", href: "https://bitbucket.org/octocat/t3code.git" }, | ||
| 465 | { name: "ssh", href: "git@bitbucket.org:octocat/t3code.git" }, | ||
| 466 | ], | ||
| 467 | }, | ||
| 468 | }); | ||
| 469 | } | ||
| 470 | return Response.json({ | ||
| 471 | ...bitbucketPullRequest, | ||
| 472 | source: { | ||
| 473 | branch: { name: "main" }, | ||
| 474 | repository: { | ||
| 475 | full_name: "octocat/t3code", | ||
| 476 | workspace: { slug: "octocat" }, | ||
| 477 | }, | ||
| 478 | }, | ||
| 479 | }); | ||
| 480 | }, | ||
| 481 | }); | ||
| 482 | |||
| 483 | return Effect.gen(function* () { | ||
| 484 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 485 | yield* bitbucket.checkoutPullRequest({ | ||
| 486 | cwd: "/repo", | ||
| 487 | reference: "42", | ||
| 488 | force: true, | ||
| 489 | }); | ||
| 490 | |||
| 491 | assert.deepStrictEqual(git.ensureRemote.mock.calls[0]?.[0], { | ||
| 492 | cwd: "/repo", | ||
| 493 | preferredName: "octocat", | ||
| 494 | url: "git@bitbucket.org:octocat/t3code.git", | ||
| 495 | }); | ||
| 496 | assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], { | ||
| 497 | cwd: "/repo", | ||
| 498 | remoteName: "octocat", | ||
| 499 | remoteBranch: "main", | ||
| 500 | localBranch: "t3code/pr-42/main", | ||
| 501 | }); | ||
| 502 | assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], { | ||
| 503 | cwd: "/repo", | ||
| 504 | branch: "t3code/pr-42/main", | ||
| 505 | remoteName: "octocat", | ||
| 506 | remoteBranch: "main", | ||
| 507 | }); | ||
| 508 | assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], { | ||
| 509 | cwd: "/repo", | ||
| 510 | refName: "t3code/pr-42/main", | ||
| 511 | }); | ||
| 512 | }).pipe(Effect.provide(layer)); | ||
| 513 | }); | ||
| @@ -0,0 +1,703 @@ | |||
| 1 | import { Config, Context, Effect, FileSystem, Layer, Option, Schema } from "effect"; | ||
| 2 | import { | ||
| 3 | TrimmedNonEmptyString, | ||
| 4 | type SourceControlProviderAuth, | ||
| 5 | type SourceControlRepositoryCloneUrls, | ||
| 6 | type SourceControlRepositoryVisibility, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 9 | import { sanitizeBranchFragment } from "@t3tools/shared/git"; | ||
| 10 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; | ||
| 11 | |||
| 12 | import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; | ||
| 13 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | ||
| 14 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | ||
| 15 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 16 | |||
| 17 | const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; | ||
| 18 | |||
| 19 | const BitbucketApiEnvConfig = Config.all({ | ||
| 20 | baseUrl: Config.string("T3CODE_BITBUCKET_API_BASE_URL").pipe( | ||
| 21 | Config.withDefault(DEFAULT_API_BASE_URL), | ||
| 22 | ), | ||
| 23 | accessToken: Config.string("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option), | ||
| 24 | email: Config.string("T3CODE_BITBUCKET_EMAIL").pipe(Config.option), | ||
| 25 | apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), | ||
| 26 | }); | ||
| 27 | |||
| 28 | export class BitbucketApiError extends Schema.TaggedErrorClass<BitbucketApiError>()( | ||
| 29 | "BitbucketApiError", | ||
| 30 | { | ||
| 31 | operation: Schema.String, | ||
| 32 | detail: Schema.String, | ||
| 33 | status: Schema.optional(Schema.Number), | ||
| 34 | cause: Schema.optional(Schema.Defect), | ||
| 35 | }, | ||
| 36 | ) { | ||
| 37 | override get message(): string { | ||
| 38 | return `Bitbucket API failed in ${this.operation}: ${this.detail}`; | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | const RawBitbucketRepositorySchema = Schema.Struct({ | ||
| 43 | full_name: TrimmedNonEmptyString, | ||
| 44 | links: Schema.Struct({ | ||
| 45 | html: Schema.optional( | ||
| 46 | Schema.Struct({ | ||
| 47 | href: TrimmedNonEmptyString, | ||
| 48 | }), | ||
| 49 | ), | ||
| 50 | clone: Schema.optional( | ||
| 51 | Schema.Array( | ||
| 52 | Schema.Struct({ | ||
| 53 | name: TrimmedNonEmptyString, | ||
| 54 | href: TrimmedNonEmptyString, | ||
| 55 | }), | ||
| 56 | ), | ||
| 57 | ), | ||
| 58 | }), | ||
| 59 | mainbranch: Schema.optional( | ||
| 60 | Schema.NullOr( | ||
| 61 | Schema.Struct({ | ||
| 62 | name: TrimmedNonEmptyString, | ||
| 63 | }), | ||
| 64 | ), | ||
| 65 | ), | ||
| 66 | }); | ||
| 67 | |||
| 68 | const BitbucketUserSchema = Schema.Struct({ | ||
| 69 | username: Schema.optional(TrimmedNonEmptyString), | ||
| 70 | display_name: Schema.optional(TrimmedNonEmptyString), | ||
| 71 | account_id: Schema.optional(TrimmedNonEmptyString), | ||
| 72 | }); | ||
| 73 | |||
| 74 | export interface BitbucketRepositoryLocator { | ||
| 75 | readonly workspace: string; | ||
| 76 | readonly repoSlug: string; | ||
| 77 | } | ||
| 78 | |||
| 79 | export interface BitbucketApiShape { | ||
| 80 | readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>; | ||
| 81 | readonly listPullRequests: (input: { | ||
| 82 | readonly cwd: string; | ||
| 83 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 84 | readonly headSelector: string; | ||
| 85 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 86 | readonly state: "open" | "closed" | "merged" | "all"; | ||
| 87 | readonly limit?: number; | ||
| 88 | }) => Effect.Effect< | ||
| 89 | ReadonlyArray<BitbucketPullRequests.NormalizedBitbucketPullRequestRecord>, | ||
| 90 | BitbucketApiError | ||
| 91 | >; | ||
| 92 | readonly getPullRequest: (input: { | ||
| 93 | readonly cwd: string; | ||
| 94 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 95 | readonly reference: string; | ||
| 96 | }) => Effect.Effect< | ||
| 97 | BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, | ||
| 98 | BitbucketApiError | ||
| 99 | >; | ||
| 100 | readonly getRepositoryCloneUrls: (input: { | ||
| 101 | readonly cwd: string; | ||
| 102 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 103 | readonly repository: string; | ||
| 104 | }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>; | ||
| 105 | readonly createRepository: (input: { | ||
| 106 | readonly cwd: string; | ||
| 107 | readonly repository: string; | ||
| 108 | readonly visibility: SourceControlRepositoryVisibility; | ||
| 109 | }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>; | ||
| 110 | readonly createPullRequest: (input: { | ||
| 111 | readonly cwd: string; | ||
| 112 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 113 | readonly baseBranch: string; | ||
| 114 | readonly headSelector: string; | ||
| 115 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 116 | readonly target?: SourceControlProvider.SourceControlRefSelector; | ||
| 117 | readonly title: string; | ||
| 118 | readonly bodyFile: string; | ||
| 119 | }) => Effect.Effect<void, BitbucketApiError>; | ||
| 120 | readonly getDefaultBranch: (input: { | ||
| 121 | readonly cwd: string; | ||
| 122 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 123 | }) => Effect.Effect<string | null, BitbucketApiError>; | ||
| 124 | readonly checkoutPullRequest: (input: { | ||
| 125 | readonly cwd: string; | ||
| 126 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 127 | readonly reference: string; | ||
| 128 | readonly force?: boolean; | ||
| 129 | }) => Effect.Effect<void, BitbucketApiError>; | ||
| 130 | } | ||
| 131 | |||
| 132 | export class BitbucketApi extends Context.Service<BitbucketApi, BitbucketApiShape>()( | ||
| 133 | "t3/source-control/BitbucketApi", | ||
| 134 | ) {} | ||
| 135 | |||
| 136 | function nonEmpty(value: string | undefined): Option.Option<string> { | ||
| 137 | const trimmed = value?.trim(); | ||
| 138 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 139 | } | ||
| 140 | |||
| 141 | function normalizeChangeRequestId(reference: string): string { | ||
| 142 | const trimmed = reference.trim().replace(/^#/, ""); | ||
| 143 | const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec( | ||
| 144 | trimmed, | ||
| 145 | ); | ||
| 146 | return urlMatch?.[1] ?? trimmed; | ||
| 147 | } | ||
| 148 | |||
| 149 | function sourceWorkspace(input: { | ||
| 150 | readonly headSelector: string; | ||
| 151 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 152 | }): string | undefined { | ||
| 153 | if (input.source?.owner) return input.source.owner; | ||
| 154 | return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner; | ||
| 155 | } | ||
| 156 | |||
| 157 | function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> { | ||
| 158 | switch (state) { | ||
| 159 | case "open": | ||
| 160 | return ["OPEN"]; | ||
| 161 | case "closed": | ||
| 162 | return ["DECLINED", "SUPERSEDED"]; | ||
| 163 | case "merged": | ||
| 164 | return ["MERGED"]; | ||
| 165 | case "all": | ||
| 166 | return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; | ||
| 167 | } | ||
| 168 | } | ||
| 169 | |||
| 170 | function bitbucketQueryString(filters: ReadonlyArray<string>): string { | ||
| 171 | return filters.join(" AND "); | ||
| 172 | } | ||
| 173 | |||
| 174 | function bitbucketStateFilter(states: ReadonlyArray<string>): string { | ||
| 175 | return states.length === 1 | ||
| 176 | ? `state = "${states[0]}"` | ||
| 177 | : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`; | ||
| 178 | } | ||
| 179 | |||
| 180 | function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null { | ||
| 181 | const normalized = value.trim().replace(/\.git$/u, ""); | ||
| 182 | const parts = normalized.split("/").filter((part) => part.length > 0); | ||
| 183 | if (parts.length < 2) return null; | ||
| 184 | const workspace = parts.at(-2); | ||
| 185 | const repoSlug = parts.at(-1); | ||
| 186 | return workspace && repoSlug ? { workspace, repoSlug } : null; | ||
| 187 | } | ||
| 188 | |||
| 189 | function requireRepositoryLocator( | ||
| 190 | operation: string, | ||
| 191 | repository: string, | ||
| 192 | ): Effect.Effect<BitbucketRepositoryLocator, BitbucketApiError> { | ||
| 193 | const locator = parseBitbucketRepositorySlug(repository); | ||
| 194 | return locator | ||
| 195 | ? Effect.succeed(locator) | ||
| 196 | : Effect.fail( | ||
| 197 | new BitbucketApiError({ | ||
| 198 | operation, | ||
| 199 | detail: "Bitbucket repositories must be specified as workspace/repository.", | ||
| 200 | }), | ||
| 201 | ); | ||
| 202 | } | ||
| 203 | |||
| 204 | function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null { | ||
| 205 | const trimmed = remoteUrl.trim(); | ||
| 206 | if (trimmed.startsWith("git@")) { | ||
| 207 | const pathStart = trimmed.indexOf(":"); | ||
| 208 | return pathStart < 0 ? null : parseBitbucketRepositorySlug(trimmed.slice(pathStart + 1)); | ||
| 209 | } | ||
| 210 | |||
| 211 | try { | ||
| 212 | return parseBitbucketRepositorySlug(new URL(trimmed).pathname); | ||
| 213 | } catch { | ||
| 214 | return null; | ||
| 215 | } | ||
| 216 | } | ||
| 217 | |||
| 218 | function normalizeRepositoryCloneUrls( | ||
| 219 | raw: Schema.Schema.Type<typeof RawBitbucketRepositorySchema>, | ||
| 220 | ): SourceControlRepositoryCloneUrls { | ||
| 221 | const httpClone = | ||
| 222 | raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ?? | ||
| 223 | raw.links.html?.href; | ||
| 224 | const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href; | ||
| 225 | |||
| 226 | return { | ||
| 227 | nameWithOwner: raw.full_name, | ||
| 228 | url: httpClone ?? raw.links.html?.href ?? raw.full_name, | ||
| 229 | sshUrl: sshClone ?? httpClone ?? raw.full_name, | ||
| 230 | }; | ||
| 231 | } | ||
| 232 | |||
| 233 | function shouldPreferSshRemote(originRemoteUrl: string | null): boolean { | ||
| 234 | const trimmed = originRemoteUrl?.trim() ?? ""; | ||
| 235 | return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); | ||
| 236 | } | ||
| 237 | |||
| 238 | function selectCloneUrl(input: { | ||
| 239 | readonly cloneUrls: SourceControlRepositoryCloneUrls; | ||
| 240 | readonly originRemoteUrl: string | null; | ||
| 241 | }): string { | ||
| 242 | return shouldPreferSshRemote(input.originRemoteUrl) | ||
| 243 | ? input.cloneUrls.sshUrl | ||
| 244 | : input.cloneUrls.url; | ||
| 245 | } | ||
| 246 | |||
| 247 | function checkoutBranchName(input: { | ||
| 248 | readonly pullRequestId: number; | ||
| 249 | readonly headBranch: string; | ||
| 250 | readonly isCrossRepository: boolean; | ||
| 251 | }): string { | ||
| 252 | if (!input.isCrossRepository) { | ||
| 253 | return input.headBranch; | ||
| 254 | } | ||
| 255 | |||
| 256 | return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`; | ||
| 257 | } | ||
| 258 | |||
| 259 | function repositoryNameWithOwner( | ||
| 260 | repository: Schema.Schema.Type< | ||
| 261 | typeof BitbucketPullRequests.BitbucketPullRequestSchema | ||
| 262 | >["source"]["repository"], | ||
| 263 | ): string | null { | ||
| 264 | const fullName = repository?.full_name?.trim() ?? ""; | ||
| 265 | return fullName.length > 0 ? fullName : null; | ||
| 266 | } | ||
| 267 | |||
| 268 | function repositoryOwnerName(repositoryName: string): string { | ||
| 269 | return repositoryName.split("/")[0]?.trim() || "bitbucket"; | ||
| 270 | } | ||
| 271 | |||
| 272 | function authFromConfig( | ||
| 273 | config: Config.Success<typeof BitbucketApiEnvConfig>, | ||
| 274 | ): SourceControlProviderAuth { | ||
| 275 | if (Option.isSome(config.accessToken)) { | ||
| 276 | return { | ||
| 277 | status: "unknown", | ||
| 278 | account: Option.none(), | ||
| 279 | host: Option.some("bitbucket.org"), | ||
| 280 | detail: Option.some("Bitbucket access token is configured."), | ||
| 281 | }; | ||
| 282 | } | ||
| 283 | |||
| 284 | if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { | ||
| 285 | return { | ||
| 286 | status: "unknown", | ||
| 287 | account: config.email, | ||
| 288 | host: Option.some("bitbucket.org"), | ||
| 289 | detail: Option.some("Bitbucket API token is configured."), | ||
| 290 | }; | ||
| 291 | } | ||
| 292 | |||
| 293 | return { | ||
| 294 | status: "unauthenticated", | ||
| 295 | account: Option.none(), | ||
| 296 | host: Option.some("bitbucket.org"), | ||
| 297 | detail: Option.some( | ||
| 298 | "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", | ||
| 299 | ), | ||
| 300 | }; | ||
| 301 | } | ||
| 302 | |||
| 303 | function requestError(operation: string, cause: unknown): BitbucketApiError { | ||
| 304 | return new BitbucketApiError({ | ||
| 305 | operation, | ||
| 306 | detail: cause instanceof Error ? cause.message : String(cause), | ||
| 307 | cause, | ||
| 308 | }); | ||
| 309 | } | ||
| 310 | |||
| 311 | function isBitbucketApiError(cause: unknown): cause is BitbucketApiError { | ||
| 312 | return Schema.is(BitbucketApiError)(cause); | ||
| 313 | } | ||
| 314 | |||
| 315 | function responseError( | ||
| 316 | operation: string, | ||
| 317 | response: HttpClientResponse.HttpClientResponse, | ||
| 318 | ): Effect.Effect<never, BitbucketApiError> { | ||
| 319 | return response.text.pipe( | ||
| 320 | Effect.catch(() => Effect.succeed("")), | ||
| 321 | Effect.flatMap((body) => | ||
| 322 | Effect.fail( | ||
| 323 | new BitbucketApiError({ | ||
| 324 | operation, | ||
| 325 | status: response.status, | ||
| 326 | detail: | ||
| 327 | body.trim().length > 0 | ||
| 328 | ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}` | ||
| 329 | : `Bitbucket returned HTTP ${response.status}.`, | ||
| 330 | }), | ||
| 331 | ), | ||
| 332 | ), | ||
| 333 | ); | ||
| 334 | } | ||
| 335 | |||
| 336 | export const make = Effect.fn("makeBitbucketApi")(function* () { | ||
| 337 | const config = yield* BitbucketApiEnvConfig; | ||
| 338 | const httpClient = yield* HttpClient.HttpClient; | ||
| 339 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 340 | const git = yield* GitVcsDriver.GitVcsDriver; | ||
| 341 | const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; | ||
| 342 | |||
| 343 | const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; | ||
| 344 | |||
| 345 | const withAuth = (request: HttpClientRequest.HttpClientRequest) => { | ||
| 346 | if (Option.isSome(config.accessToken)) { | ||
| 347 | return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value)); | ||
| 348 | } | ||
| 349 | if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { | ||
| 350 | return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value)); | ||
| 351 | } | ||
| 352 | return request; | ||
| 353 | }; | ||
| 354 | |||
| 355 | const decodeResponse = <S extends Schema.Top>( | ||
| 356 | operation: string, | ||
| 357 | schema: S, | ||
| 358 | response: HttpClientResponse.HttpClientResponse, | ||
| 359 | ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> => | ||
| 360 | HttpClientResponse.matchStatus({ | ||
| 361 | "2xx": (success) => | ||
| 362 | HttpClientResponse.schemaBodyJson(schema)(success).pipe( | ||
| 363 | Effect.mapError( | ||
| 364 | (cause) => | ||
| 365 | new BitbucketApiError({ | ||
| 366 | operation, | ||
| 367 | detail: "Bitbucket returned invalid JSON for the requested resource.", | ||
| 368 | cause, | ||
| 369 | }), | ||
| 370 | ), | ||
| 371 | ), | ||
| 372 | orElse: (failed) => responseError(operation, failed), | ||
| 373 | })(response); | ||
| 374 | |||
| 375 | const executeJson = <S extends Schema.Top>( | ||
| 376 | operation: string, | ||
| 377 | request: HttpClientRequest.HttpClientRequest, | ||
| 378 | schema: S, | ||
| 379 | ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> => | ||
| 380 | httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( | ||
| 381 | Effect.mapError((cause) => requestError(operation, cause)), | ||
| 382 | Effect.flatMap((response) => decodeResponse(operation, schema, response)), | ||
| 383 | ); | ||
| 384 | |||
| 385 | const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: { | ||
| 386 | readonly cwd: string; | ||
| 387 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 388 | readonly repository?: string; | ||
| 389 | }) { | ||
| 390 | const fromRepository = | ||
| 391 | input.repository !== undefined ? parseBitbucketRepositorySlug(input.repository) : null; | ||
| 392 | if (fromRepository) return fromRepository; | ||
| 393 | |||
| 394 | const fromContext = | ||
| 395 | input.context?.provider.kind === "bitbucket" | ||
| 396 | ? parseBitbucketRemoteUrl(input.context.remoteUrl) | ||
| 397 | : null; | ||
| 398 | if (fromContext) return fromContext; | ||
| 399 | |||
| 400 | const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( | ||
| 401 | Effect.mapError( | ||
| 402 | (cause) => | ||
| 403 | new BitbucketApiError({ | ||
| 404 | operation: "resolveRepository", | ||
| 405 | detail: `Failed to resolve VCS repository for ${input.cwd}.`, | ||
| 406 | cause, | ||
| 407 | }), | ||
| 408 | ), | ||
| 409 | ); | ||
| 410 | const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( | ||
| 411 | Effect.mapError( | ||
| 412 | (cause) => | ||
| 413 | new BitbucketApiError({ | ||
| 414 | operation: "resolveRepository", | ||
| 415 | detail: `Failed to list remotes for ${input.cwd}.`, | ||
| 416 | cause, | ||
| 417 | }), | ||
| 418 | ), | ||
| 419 | ); | ||
| 420 | |||
| 421 | for (const remote of remotes.remotes) { | ||
| 422 | if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue; | ||
| 423 | const parsed = parseBitbucketRemoteUrl(remote.url); | ||
| 424 | if (parsed) return parsed; | ||
| 425 | } | ||
| 426 | |||
| 427 | return yield* new BitbucketApiError({ | ||
| 428 | operation: "resolveRepository", | ||
| 429 | detail: `No Bitbucket repository remote was detected for ${input.cwd}.`, | ||
| 430 | }); | ||
| 431 | }); | ||
| 432 | |||
| 433 | const getRepository = (input: { | ||
| 434 | readonly cwd: string; | ||
| 435 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 436 | readonly repository?: string; | ||
| 437 | }) => | ||
| 438 | resolveRepository(input).pipe( | ||
| 439 | Effect.flatMap((repository) => | ||
| 440 | executeJson( | ||
| 441 | "getRepository", | ||
| 442 | HttpClientRequest.get( | ||
| 443 | apiUrl( | ||
| 444 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, | ||
| 445 | ), | ||
| 446 | ), | ||
| 447 | RawBitbucketRepositorySchema, | ||
| 448 | ), | ||
| 449 | ), | ||
| 450 | ); | ||
| 451 | |||
| 452 | const getRawPullRequestFromRepository = ( | ||
| 453 | repository: BitbucketRepositoryLocator, | ||
| 454 | reference: string, | ||
| 455 | ) => | ||
| 456 | executeJson( | ||
| 457 | "getPullRequest", | ||
| 458 | HttpClientRequest.get( | ||
| 459 | apiUrl( | ||
| 460 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, | ||
| 461 | ), | ||
| 462 | ), | ||
| 463 | BitbucketPullRequests.BitbucketPullRequestSchema, | ||
| 464 | ); | ||
| 465 | |||
| 466 | const getRawPullRequest = (input: { | ||
| 467 | readonly cwd: string; | ||
| 468 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 469 | readonly reference: string; | ||
| 470 | }) => | ||
| 471 | resolveRepository(input).pipe( | ||
| 472 | Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)), | ||
| 473 | ); | ||
| 474 | |||
| 475 | const readConfigValueNullable = (cwd: string, key: string) => | ||
| 476 | git.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 477 | |||
| 478 | const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: { | ||
| 479 | readonly cwd: string; | ||
| 480 | readonly context?: SourceControlProvider.SourceControlProviderContext; | ||
| 481 | readonly destinationRepository: BitbucketRepositoryLocator; | ||
| 482 | readonly sourceRepositoryName: string; | ||
| 483 | readonly isCrossRepository: boolean; | ||
| 484 | }) { | ||
| 485 | if ( | ||
| 486 | input.context?.provider.kind === "bitbucket" && | ||
| 487 | !input.isCrossRepository && | ||
| 488 | parseBitbucketRemoteUrl(input.context.remoteUrl) !== null | ||
| 489 | ) { | ||
| 490 | return input.context.remoteName; | ||
| 491 | } | ||
| 492 | |||
| 493 | if (!input.isCrossRepository) { | ||
| 494 | const remoteName = yield* git | ||
| 495 | .resolvePrimaryRemoteName(input.cwd) | ||
| 496 | .pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 497 | if (remoteName) return remoteName; | ||
| 498 | } | ||
| 499 | |||
| 500 | const cloneUrls = yield* getRepository({ | ||
| 501 | cwd: input.cwd, | ||
| 502 | repository: input.sourceRepositoryName, | ||
| 503 | ...(input.context ? { context: input.context } : {}), | ||
| 504 | }).pipe(Effect.map(normalizeRepositoryCloneUrls)); | ||
| 505 | const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url"); | ||
| 506 | return yield* git.ensureRemote({ | ||
| 507 | cwd: input.cwd, | ||
| 508 | preferredName: input.isCrossRepository | ||
| 509 | ? repositoryOwnerName(input.sourceRepositoryName) | ||
| 510 | : input.destinationRepository.workspace, | ||
| 511 | url: selectCloneUrl({ cloneUrls, originRemoteUrl }), | ||
| 512 | }); | ||
| 513 | }); | ||
| 514 | |||
| 515 | return BitbucketApi.of({ | ||
| 516 | probeAuth: executeJson( | ||
| 517 | "probeAuth", | ||
| 518 | HttpClientRequest.get(apiUrl("/user")), | ||
| 519 | BitbucketUserSchema, | ||
| 520 | ).pipe( | ||
| 521 | Effect.map((user) => ({ | ||
| 522 | status: "authenticated" as const, | ||
| 523 | account: nonEmpty(user.username ?? user.display_name ?? user.account_id), | ||
| 524 | host: Option.some("bitbucket.org"), | ||
| 525 | detail: Option.none<string>(), | ||
| 526 | })), | ||
| 527 | Effect.catch(() => Effect.succeed(authFromConfig(config))), | ||
| 528 | ), | ||
| 529 | listPullRequests: (input) => | ||
| 530 | resolveRepository(input).pipe( | ||
| 531 | Effect.flatMap((repository) => { | ||
| 532 | const states = toBitbucketStates(input.state); | ||
| 533 | const query: Record<string, string | ReadonlyArray<string>> = { | ||
| 534 | pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))), | ||
| 535 | sort: "-updated_on", | ||
| 536 | q: bitbucketQueryString([ | ||
| 537 | `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`, | ||
| 538 | bitbucketStateFilter(states), | ||
| 539 | ]), | ||
| 540 | state: states, | ||
| 541 | }; | ||
| 542 | |||
| 543 | return executeJson( | ||
| 544 | "listPullRequests", | ||
| 545 | HttpClientRequest.get( | ||
| 546 | apiUrl( | ||
| 547 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, | ||
| 548 | ), | ||
| 549 | { urlParams: query }, | ||
| 550 | ), | ||
| 551 | BitbucketPullRequests.BitbucketPullRequestListSchema, | ||
| 552 | ); | ||
| 553 | }), | ||
| 554 | Effect.map((list) => | ||
| 555 | list.values.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), | ||
| 556 | ), | ||
| 557 | ), | ||
| 558 | getPullRequest: (input) => | ||
| 559 | getRawPullRequest(input).pipe( | ||
| 560 | Effect.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), | ||
| 561 | ), | ||
| 562 | getRepositoryCloneUrls: (input) => | ||
| 563 | getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), | ||
| 564 | createRepository: (input) => | ||
| 565 | requireRepositoryLocator("createRepository", input.repository).pipe( | ||
| 566 | Effect.flatMap((repository) => | ||
| 567 | executeJson( | ||
| 568 | "createRepository", | ||
| 569 | HttpClientRequest.post( | ||
| 570 | apiUrl( | ||
| 571 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, | ||
| 572 | ), | ||
| 573 | ).pipe( | ||
| 574 | HttpClientRequest.bodyJsonUnsafe({ | ||
| 575 | scm: "git", | ||
| 576 | is_private: input.visibility === "private", | ||
| 577 | }), | ||
| 578 | ), | ||
| 579 | RawBitbucketRepositorySchema, | ||
| 580 | ), | ||
| 581 | ), | ||
| 582 | Effect.map(normalizeRepositoryCloneUrls), | ||
| 583 | ), | ||
| 584 | createPullRequest: (input) => | ||
| 585 | Effect.gen(function* () { | ||
| 586 | const repository = yield* resolveRepository(input); | ||
| 587 | const description = yield* fileSystem.readFileString(input.bodyFile).pipe( | ||
| 588 | Effect.mapError( | ||
| 589 | (cause) => | ||
| 590 | new BitbucketApiError({ | ||
| 591 | operation: "createPullRequest", | ||
| 592 | detail: `Failed to read pull request body file ${input.bodyFile}.`, | ||
| 593 | cause, | ||
| 594 | }), | ||
| 595 | ), | ||
| 596 | ); | ||
| 597 | const sourceOwner = sourceWorkspace(input); | ||
| 598 | const body = { | ||
| 599 | title: input.title, | ||
| 600 | description, | ||
| 601 | source: { | ||
| 602 | branch: { | ||
| 603 | name: SourceControlProvider.sourceBranch(input), | ||
| 604 | }, | ||
| 605 | ...(sourceOwner | ||
| 606 | ? { | ||
| 607 | repository: { | ||
| 608 | full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`, | ||
| 609 | }, | ||
| 610 | } | ||
| 611 | : {}), | ||
| 612 | }, | ||
| 613 | destination: { | ||
| 614 | branch: { | ||
| 615 | name: input.target?.refName ?? input.baseBranch, | ||
| 616 | }, | ||
| 617 | }, | ||
| 618 | }; | ||
| 619 | |||
| 620 | yield* executeJson( | ||
| 621 | "createPullRequest", | ||
| 622 | HttpClientRequest.post( | ||
| 623 | apiUrl( | ||
| 624 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, | ||
| 625 | ), | ||
| 626 | ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), | ||
| 627 | BitbucketPullRequests.BitbucketPullRequestSchema, | ||
| 628 | ); | ||
| 629 | }), | ||
| 630 | getDefaultBranch: (input) => | ||
| 631 | getRepository(input).pipe(Effect.map((repository) => repository.mainbranch?.name ?? null)), | ||
| 632 | // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide | ||
| 633 | // an official checkout CLI. This provider-local path uses GitVcsDriver as a | ||
| 634 | // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this | ||
| 635 | // as the source-control provider model: if we support non-Git-compatible | ||
| 636 | // hosting providers or native JJ/Sapling checkout flows, move this into a | ||
| 637 | // VCS-specific change-request checkout capability. | ||
| 638 | checkoutPullRequest: (input) => | ||
| 639 | Effect.gen(function* () { | ||
| 640 | const destinationRepository = yield* resolveRepository(input); | ||
| 641 | const pullRequest = yield* getRawPullRequestFromRepository( | ||
| 642 | destinationRepository, | ||
| 643 | input.reference, | ||
| 644 | ); | ||
| 645 | const destinationRepositoryName = | ||
| 646 | repositoryNameWithOwner(pullRequest.destination.repository) ?? | ||
| 647 | `${destinationRepository.workspace}/${destinationRepository.repoSlug}`; | ||
| 648 | const sourceRepositoryName = | ||
| 649 | repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName; | ||
| 650 | const isCrossRepository = sourceRepositoryName !== destinationRepositoryName; | ||
| 651 | const remoteName = yield* resolveCheckoutRemote({ | ||
| 652 | cwd: input.cwd, | ||
| 653 | destinationRepository, | ||
| 654 | sourceRepositoryName, | ||
| 655 | isCrossRepository, | ||
| 656 | ...(input.context ? { context: input.context } : {}), | ||
| 657 | }); | ||
| 658 | const remoteBranch = pullRequest.source.branch.name; | ||
| 659 | const localBranch = checkoutBranchName({ | ||
| 660 | pullRequestId: pullRequest.id, | ||
| 661 | headBranch: remoteBranch, | ||
| 662 | isCrossRepository, | ||
| 663 | }); | ||
| 664 | const localBranchNames = yield* git.listLocalBranchNames(input.cwd); | ||
| 665 | const localBranchExists = localBranchNames.includes(localBranch); | ||
| 666 | |||
| 667 | if (input.force === true || !localBranchExists) { | ||
| 668 | yield* git.fetchRemoteBranch({ | ||
| 669 | cwd: input.cwd, | ||
| 670 | remoteName, | ||
| 671 | remoteBranch, | ||
| 672 | localBranch, | ||
| 673 | }); | ||
| 674 | } else { | ||
| 675 | yield* git.fetchRemoteTrackingBranch({ | ||
| 676 | cwd: input.cwd, | ||
| 677 | remoteName, | ||
| 678 | remoteBranch, | ||
| 679 | }); | ||
| 680 | } | ||
| 681 | |||
| 682 | yield* git.setBranchUpstream({ | ||
| 683 | cwd: input.cwd, | ||
| 684 | branch: localBranch, | ||
| 685 | remoteName, | ||
| 686 | remoteBranch, | ||
| 687 | }); | ||
| 688 | yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); | ||
| 689 | }).pipe( | ||
| 690 | Effect.mapError((cause) => | ||
| 691 | isBitbucketApiError(cause) | ||
| 692 | ? cause | ||
| 693 | : new BitbucketApiError({ | ||
| 694 | operation: "checkoutPullRequest", | ||
| 695 | detail: cause instanceof Error ? cause.message : String(cause), | ||
| 696 | cause, | ||
| 697 | }), | ||
| 698 | ), | ||
| 699 | ), | ||
| 700 | }); | ||
| 701 | }); | ||
| 702 | |||
| 703 | export const layer = Layer.effect(BitbucketApi, make()); | ||
| @@ -0,0 +1,126 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer, Option } from "effect"; | ||
| 3 | |||
| 4 | import * as BitbucketApi from "./BitbucketApi.ts"; | ||
| 5 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; | ||
| 6 | |||
| 7 | function makeProvider(bitbucket: Partial<BitbucketApi.BitbucketApiShape>) { | ||
| 8 | return BitbucketSourceControlProvider.make().pipe( | ||
| 9 | Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), | ||
| 10 | ); | ||
| 11 | } | ||
| 12 | |||
| 13 | it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () => | ||
| 14 | Effect.gen(function* () { | ||
| 15 | const provider = yield* makeProvider({ | ||
| 16 | getPullRequest: () => | ||
| 17 | Effect.succeed({ | ||
| 18 | number: 42, | ||
| 19 | title: "Add Bitbucket provider", | ||
| 20 | url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", | ||
| 21 | baseRefName: "main", | ||
| 22 | headRefName: "feature/source-control", | ||
| 23 | state: "open", | ||
| 24 | updatedAt: Option.none(), | ||
| 25 | isCrossRepository: true, | ||
| 26 | headRepositoryNameWithOwner: "fork/t3code", | ||
| 27 | headRepositoryOwnerLogin: "fork", | ||
| 28 | }), | ||
| 29 | }); | ||
| 30 | |||
| 31 | const changeRequest = yield* provider.getChangeRequest({ | ||
| 32 | cwd: "/repo", | ||
| 33 | reference: "42", | ||
| 34 | }); | ||
| 35 | |||
| 36 | assert.deepStrictEqual(changeRequest, { | ||
| 37 | provider: "bitbucket", | ||
| 38 | number: 42, | ||
| 39 | title: "Add Bitbucket provider", | ||
| 40 | url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", | ||
| 41 | baseRefName: "main", | ||
| 42 | headRefName: "feature/source-control", | ||
| 43 | state: "open", | ||
| 44 | updatedAt: Option.none(), | ||
| 45 | isCrossRepository: true, | ||
| 46 | headRepositoryNameWithOwner: "fork/t3code", | ||
| 47 | headRepositoryOwnerLogin: "fork", | ||
| 48 | }); | ||
| 49 | }), | ||
| 50 | ); | ||
| 51 | |||
| 52 | it.effect("lists Bitbucket PRs through provider-neutral input names", () => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | let listInput: Parameters<BitbucketApi.BitbucketApiShape["listPullRequests"]>[0] | null = null; | ||
| 55 | const provider = yield* makeProvider({ | ||
| 56 | listPullRequests: (input) => { | ||
| 57 | listInput = input; | ||
| 58 | return Effect.succeed([]); | ||
| 59 | }, | ||
| 60 | }); | ||
| 61 | |||
| 62 | yield* provider.listChangeRequests({ | ||
| 63 | cwd: "/repo", | ||
| 64 | headSelector: "feature/provider", | ||
| 65 | state: "all", | ||
| 66 | limit: 10, | ||
| 67 | }); | ||
| 68 | |||
| 69 | assert.deepStrictEqual(listInput, { | ||
| 70 | cwd: "/repo", | ||
| 71 | headSelector: "feature/provider", | ||
| 72 | state: "all", | ||
| 73 | limit: 10, | ||
| 74 | }); | ||
| 75 | }), | ||
| 76 | ); | ||
| 77 | |||
| 78 | it.effect("creates Bitbucket PRs through provider-neutral input names", () => | ||
| 79 | Effect.gen(function* () { | ||
| 80 | let createInput: Parameters<BitbucketApi.BitbucketApiShape["createPullRequest"]>[0] | null = | ||
| 81 | null; | ||
| 82 | const provider = yield* makeProvider({ | ||
| 83 | createPullRequest: (input) => { | ||
| 84 | createInput = input; | ||
| 85 | return Effect.void; | ||
| 86 | }, | ||
| 87 | }); | ||
| 88 | |||
| 89 | yield* provider.createChangeRequest({ | ||
| 90 | cwd: "/repo", | ||
| 91 | baseRefName: "main", | ||
| 92 | headSelector: "owner:feature/provider", | ||
| 93 | title: "Provider PR", | ||
| 94 | bodyFile: "/tmp/body.md", | ||
| 95 | }); | ||
| 96 | |||
| 97 | assert.deepStrictEqual(createInput, { | ||
| 98 | cwd: "/repo", | ||
| 99 | baseBranch: "main", | ||
| 100 | headSelector: "owner:feature/provider", | ||
| 101 | source: { | ||
| 102 | owner: "owner", | ||
| 103 | refName: "feature/provider", | ||
| 104 | }, | ||
| 105 | title: "Provider PR", | ||
| 106 | bodyFile: "/tmp/body.md", | ||
| 107 | }); | ||
| 108 | }), | ||
| 109 | ); | ||
| 110 | |||
| 111 | it.effect("uses Bitbucket API repository detection for default branch lookup", () => | ||
| 112 | Effect.gen(function* () { | ||
| 113 | let cwdInput: string | null = null; | ||
| 114 | const provider = yield* makeProvider({ | ||
| 115 | getDefaultBranch: (input) => { | ||
| 116 | cwdInput = input.cwd; | ||
| 117 | return Effect.succeed("main"); | ||
| 118 | }, | ||
| 119 | }); | ||
| 120 | |||
| 121 | const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); | ||
| 122 | |||
| 123 | assert.strictEqual(defaultBranch, "main"); | ||
| 124 | assert.strictEqual(cwdInput, "/repo"); | ||
| 125 | }), | ||
| 126 | ); | ||
| @@ -0,0 +1,126 @@ | |||
| 1 | import { Effect, Layer, Option } from "effect"; | ||
| 2 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import * as BitbucketApi from "./BitbucketApi.ts"; | ||
| 5 | import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; | ||
| 6 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | ||
| 7 | import type * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | ||
| 8 | |||
| 9 | function providerError( | ||
| 10 | operation: string, | ||
| 11 | cause: BitbucketApi.BitbucketApiError, | ||
| 12 | ): SourceControlProviderError { | ||
| 13 | return new SourceControlProviderError({ | ||
| 14 | provider: "bitbucket", | ||
| 15 | operation, | ||
| 16 | detail: cause.detail, | ||
| 17 | cause, | ||
| 18 | }); | ||
| 19 | } | ||
| 20 | |||
| 21 | function toChangeRequest( | ||
| 22 | summary: BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, | ||
| 23 | ): ChangeRequest { | ||
| 24 | return { | ||
| 25 | provider: "bitbucket", | ||
| 26 | number: summary.number, | ||
| 27 | title: summary.title, | ||
| 28 | url: summary.url, | ||
| 29 | baseRefName: summary.baseRefName, | ||
| 30 | headRefName: summary.headRefName, | ||
| 31 | state: summary.state, | ||
| 32 | updatedAt: summary.updatedAt ?? Option.none(), | ||
| 33 | ...(summary.isCrossRepository !== undefined | ||
| 34 | ? { isCrossRepository: summary.isCrossRepository } | ||
| 35 | : {}), | ||
| 36 | ...(summary.headRepositoryNameWithOwner !== undefined | ||
| 37 | ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } | ||
| 38 | : {}), | ||
| 39 | ...(summary.headRepositoryOwnerLogin !== undefined | ||
| 40 | ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } | ||
| 41 | : {}), | ||
| 42 | }; | ||
| 43 | } | ||
| 44 | |||
| 45 | export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () { | ||
| 46 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 47 | |||
| 48 | return SourceControlProvider.SourceControlProvider.of({ | ||
| 49 | kind: "bitbucket", | ||
| 50 | listChangeRequests: (input) => { | ||
| 51 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 52 | return bitbucket | ||
| 53 | .listPullRequests({ | ||
| 54 | cwd: input.cwd, | ||
| 55 | ...(input.context ? { context: input.context } : {}), | ||
| 56 | headSelector: input.headSelector, | ||
| 57 | ...(source ? { source } : {}), | ||
| 58 | state: input.state, | ||
| 59 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | ||
| 60 | }) | ||
| 61 | .pipe( | ||
| 62 | Effect.map((items) => items.map(toChangeRequest)), | ||
| 63 | Effect.mapError((error) => providerError("listChangeRequests", error)), | ||
| 64 | ); | ||
| 65 | }, | ||
| 66 | getChangeRequest: (input) => | ||
| 67 | bitbucket.getPullRequest(input).pipe( | ||
| 68 | Effect.map(toChangeRequest), | ||
| 69 | Effect.mapError((error) => providerError("getChangeRequest", error)), | ||
| 70 | ), | ||
| 71 | createChangeRequest: (input) => { | ||
| 72 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 73 | return bitbucket | ||
| 74 | .createPullRequest({ | ||
| 75 | cwd: input.cwd, | ||
| 76 | ...(input.context ? { context: input.context } : {}), | ||
| 77 | baseBranch: input.baseRefName, | ||
| 78 | headSelector: input.headSelector, | ||
| 79 | ...(source ? { source } : {}), | ||
| 80 | ...(input.target ? { target: input.target } : {}), | ||
| 81 | title: input.title, | ||
| 82 | bodyFile: input.bodyFile, | ||
| 83 | }) | ||
| 84 | .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); | ||
| 85 | }, | ||
| 86 | getRepositoryCloneUrls: (input) => | ||
| 87 | bitbucket | ||
| 88 | .getRepositoryCloneUrls(input) | ||
| 89 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | ||
| 90 | createRepository: (input) => | ||
| 91 | bitbucket | ||
| 92 | .createRepository(input) | ||
| 93 | .pipe(Effect.mapError((error) => providerError("createRepository", error))), | ||
| 94 | getDefaultBranch: (input) => | ||
| 95 | bitbucket | ||
| 96 | .getDefaultBranch({ | ||
| 97 | cwd: input.cwd, | ||
| 98 | ...(input.context ? { context: input.context } : {}), | ||
| 99 | }) | ||
| 100 | .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), | ||
| 101 | checkoutChangeRequest: (input) => | ||
| 102 | bitbucket | ||
| 103 | .checkoutPullRequest({ | ||
| 104 | cwd: input.cwd, | ||
| 105 | ...(input.context ? { context: input.context } : {}), | ||
| 106 | reference: input.reference, | ||
| 107 | ...(input.force !== undefined ? { force: input.force } : {}), | ||
| 108 | }) | ||
| 109 | .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), | ||
| 110 | }); | ||
| 111 | }); | ||
| 112 | |||
| 113 | export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); | ||
| 114 | |||
| 115 | export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscovery")(function* () { | ||
| 116 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 117 | |||
| 118 | return { | ||
| 119 | type: "api", | ||
| 120 | kind: "bitbucket", | ||
| 121 | label: "Bitbucket", | ||
| 122 | installHint: | ||
| 123 | "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request and repository scopes).", | ||
| 124 | probeAuth: bitbucket.probeAuth, | ||
| 125 | } satisfies SourceControlProviderDiscovery.SourceControlApiDiscoverySpec; | ||
| 126 | }); | ||
| @@ -1,13 +1,12 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; |
| 2 | import { Effect, Layer } from "effect"; | 2 | import { Effect, Layer } from "effect"; |
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | 3 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | import { VcsProcessExitError, type VcsError } from "@t3tools/contracts"; | 4 | import { VcsProcessExitError, type VcsError } from "@t3tools/contracts"; |
| 5 | import { afterEach, describe, expect, vi } from "vitest"; | ||
| 6 | 5 | ||
| 7 | import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; | 6 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 8 | import * as GitHubCli from "./GitHubCli.ts"; | 7 | import * as GitHubCli from "./GitHubCli.ts"; |
| 9 | 8 | ||
| 10 | const processOutput = (stdout: string): VcsProcessOutput => ({ | 9 | const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ |
| 11 | exitCode: ChildProcessSpawner.ExitCode(0), | 10 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 12 | stdout, | 11 | stdout, |
| 13 | stderr: "", | 12 | stderr: "", |
| @@ -15,11 +14,11 @@ const processOutput = (stdout: string): VcsProcessOutput => ({ · 1 unmodified line | |||
| 15 | stderrTruncated: false, | 14 | stderrTruncated: false, |
| 16 | }); | 15 | }); |
| 17 | 16 | ||
| 18 | const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>>(); | 17 | const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>(); |
| 19 | 18 | ||
| 20 | const layer = GitHubCli.layer.pipe( | 19 | const layer = GitHubCli.layer.pipe( |
| 21 | Layer.provide( | 20 | Layer.provide( |
| 22 | Layer.mock(VcsProcess)({ | 21 | Layer.mock(VcsProcess.VcsProcess)({ |
| 23 | run: mockRun, | 22 | run: mockRun, |
| 24 | }), | 23 | }), |
| 25 | ), | 24 | ), |
| @@ -6,12 +6,8 @@ import { · 5 unmodified lines | |||
| 6 | type VcsError, | 6 | type VcsError, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | ||
| 9 | import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; | 9 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 10 | import { | 10 | import * as GitHubPullRequests from "./gitHubPullRequests.ts"; |
| 11 | decodeGitHubPullRequestJson, | ||
| 12 | decodeGitHubPullRequestListJson, | ||
| 13 | formatGitHubJsonDecodeError, | ||
| 14 | } from "./gitHubPullRequests.ts"; | ||
| 15 | 11 | ||
| 16 | const DEFAULT_TIMEOUT_MS = 30_000; | 12 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 17 | 13 | ||
| @@ -48,7 +44,7 @@ export interface GitHubCliShape { · 30 unmodified lines | |||
| 48 | readonly cwd: string; | 44 | readonly cwd: string; |
| 49 | readonly args: ReadonlyArray<string>; | 45 | readonly args: ReadonlyArray<string>; |
| 50 | readonly timeoutMs?: number; | 46 | readonly timeoutMs?: number; |
| 51 | }) => Effect.Effect<VcsProcessOutput, GitHubCliError>; | 47 | }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>; |
| 52 | 48 | ||
| 53 | readonly listOpenPullRequests: (input: { | 49 | readonly listOpenPullRequests: (input: { |
| 54 | readonly cwd: string; | 50 | readonly cwd: string; |
| @@ -226,7 +222,7 @@ function decodeGitHubJson<S extends Schema.Top>( · 171 unmodified lines | |||
| 226 | } | 222 | } |
| 227 | 223 | ||
| 228 | export const make = Effect.fn("makeGitHubCli")(function* () { | 224 | export const make = Effect.fn("makeGitHubCli")(function* () { |
| 229 | const process = yield* VcsProcess; | 225 | const process = yield* VcsProcess.VcsProcess; |
| 230 | 226 | ||
| 231 | const execute: GitHubCliShape["execute"] = (input) => | 227 | const execute: GitHubCliShape["execute"] = (input) => |
| 232 | process | 228 | process |
| @@ -261,13 +257,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { · 28 unmodified lines | |||
| 261 | Effect.flatMap((raw) => | 257 | Effect.flatMap((raw) => |
| 262 | raw.length === 0 | 258 | raw.length === 0 |
| 263 | ? Effect.succeed([]) | 259 | ? Effect.succeed([]) |
| 264 | : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( | 260 | : Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( |
| 265 | Effect.flatMap((decoded) => { | 261 | Effect.flatMap((decoded) => { |
| 266 | if (!Result.isSuccess(decoded)) { | 262 | if (!Result.isSuccess(decoded)) { |
| 267 | return Effect.fail( | 263 | return Effect.fail( |
| 268 | new GitHubCliError({ | 264 | new GitHubCliError({ |
| 269 | operation: "listOpenPullRequests", | 265 | operation: "listOpenPullRequests", |
| 270 | detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | 266 | detail: `GitHub CLI returned invalid PR list JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, |
| 271 | cause: decoded.failure, | 267 | cause: decoded.failure, |
| 272 | }), | 268 | }), |
| 273 | ); | 269 | ); |
| @@ -293,13 +289,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { · 19 unmodified lines | |||
| 293 | }).pipe( | 289 | }).pipe( |
| 294 | Effect.map((result) => result.stdout.trim()), | 290 | Effect.map((result) => result.stdout.trim()), |
| 295 | Effect.flatMap((raw) => | 291 | Effect.flatMap((raw) => |
| 296 | Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( | 292 | Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestJson(raw)).pipe( |
| 297 | Effect.flatMap((decoded) => { | 293 | Effect.flatMap((decoded) => { |
| 298 | if (!Result.isSuccess(decoded)) { | 294 | if (!Result.isSuccess(decoded)) { |
| 299 | return Effect.fail( | 295 | return Effect.fail( |
| 300 | new GitHubCliError({ | 296 | new GitHubCliError({ |
| 301 | operation: "getPullRequest", | 297 | operation: "getPullRequest", |
| 302 | detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | 298 | detail: `GitHub CLI returned invalid pull request JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, |
| 303 | cause: decoded.failure, | 299 | cause: decoded.failure, |
| 304 | }), | 300 | }), |
| 305 | ); | 301 | ); |
| @@ -2,11 +2,11 @@ import { assert, it } from "@effect/vitest"; · 1 unmodified line | |||
| 2 | import { DateTime, Effect, Layer, Option } from "effect"; | 2 | import { DateTime, Effect, Layer, Option } from "effect"; |
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | 3 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | 4 | ||
| 5 | import { GitHubCli, type GitHubCliShape } from "./GitHubCli.ts"; | 5 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 6 | import type { VcsProcessOutput } from "../vcs/VcsProcess.ts"; | 6 | import * as GitHubCli from "./GitHubCli.ts"; |
| 7 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | 7 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 8 | 8 | ||
| 9 | const processResult = (stdout: string): VcsProcessOutput => ({ | 9 | const processResult = (stdout: string): VcsProcess.VcsProcessOutput => ({ |
| 10 | exitCode: ChildProcessSpawner.ExitCode(0), | 10 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 11 | stdout, | 11 | stdout, |
| 12 | stderr: "", | 12 | stderr: "", |
| @@ -14,8 +14,10 @@ const processResult = (stdout: string): VcsProcessOutput => ({ · 1 unmodified line | |||
| 14 | stderrTruncated: false, | 14 | stderrTruncated: false, |
| 15 | }); | 15 | }); |
| 16 | 16 | ||
| 17 | function makeProvider(github: Partial<GitHubCliShape>) { | 17 | function makeProvider(github: Partial<GitHubCli.GitHubCliShape>) { |
| 18 | return GitHubSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitHubCli)(github))); | 18 | return GitHubSourceControlProvider.make().pipe( |
| 19 | Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), | ||
| 20 | ); | ||
| 19 | } | 21 | } |
| 20 | 22 | ||
| 21 | it.effect("maps GitHub PR summaries into provider-neutral change requests", () => | 23 | it.effect("maps GitHub PR summaries into provider-neutral change requests", () => |
| @@ -127,7 +129,7 @@ it.effect("treats empty non-open change request listing output as no results", ( · 105 unmodified lines | |||
| 127 | 129 | ||
| 128 | it.effect("creates GitHub PRs through provider-neutral input names", () => | 130 | it.effect("creates GitHub PRs through provider-neutral input names", () => |
| 129 | Effect.gen(function* () { | 131 | Effect.gen(function* () { |
| 130 | let createInput: Parameters<GitHubCliShape["createPullRequest"]>[0] | null = null; | 132 | let createInput: Parameters<GitHubCli.GitHubCliShape["createPullRequest"]>[0] | null = null; |
| 131 | const provider = yield* makeProvider({ | 133 | const provider = yield* makeProvider({ |
| 132 | createPullRequest: (input) => { | 134 | createPullRequest: (input) => { |
| 133 | createInput = input; | 135 | createInput = input; |
| @@ -5,11 +5,15 @@ import { · 4 unmodified lines | |||
| 5 | type ChangeRequestState, | 5 | type ChangeRequestState, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | ||
| 8 | import { GitHubCli, type GitHubCliError, type GitHubPullRequestSummary } from "./GitHubCli.ts"; | 8 | import * as GitHubCli from "./GitHubCli.ts"; |
| 9 | import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; | 9 | import * as GitHubPullRequests from "./gitHubPullRequests.ts"; |
| 10 | import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts"; | 10 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 11 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | ||
| 11 | 12 | ||
| 12 | function providerError(operation: string, cause: GitHubCliError): SourceControlProviderError { | 13 | function providerError( |
| 14 | operation: string, | ||
| 15 | cause: GitHubCli.GitHubCliError, | ||
| 16 | ): SourceControlProviderError { | ||
| 13 | return new SourceControlProviderError({ | 17 | return new SourceControlProviderError({ |
| 14 | provider: "github", | 18 | provider: "github", |
| 15 | operation, | 19 | operation, |
| @@ -18,7 +22,7 @@ function providerError(operation: string, cause: GitHubCliError): SourceControlP · 2 unmodified lines | |||
| 18 | }); | 22 | }); |
| 19 | } | 23 | } |
| 20 | 24 | ||
| 21 | function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { | 25 | function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { |
| 22 | return { | 26 | return { |
| 23 | provider: "github", | 27 | provider: "github", |
| 24 | number: summary.number, | 28 | number: summary.number, |
| @@ -40,75 +44,119 @@ function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { · 15 unmodified lines | |||
| 40 | }; | 44 | }; |
| 41 | } | 45 | } |
| 42 | 46 | ||
| 47 | function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { | ||
| 48 | const output = SourceControlProviderDiscovery.combinedAuthOutput(input); | ||
| 49 | const account = SourceControlProviderDiscovery.matchFirst(output, [ | ||
| 50 | /Logged in to .* account\s+([^\s(]+)/iu, | ||
| 51 | /Logged in to .* as\s+([^\s(]+)/iu, | ||
| 52 | ]); | ||
| 53 | const host = SourceControlProviderDiscovery.parseCliHost(output); | ||
| 54 | |||
| 55 | if (input.exitCode !== 0) { | ||
| 56 | return SourceControlProviderDiscovery.providerAuth({ | ||
| 57 | status: "unauthenticated", | ||
| 58 | host, | ||
| 59 | detail: | ||
| 60 | SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? | ||
| 61 | "Run `gh auth login` to authenticate GitHub CLI.", | ||
| 62 | }); | ||
| 63 | } | ||
| 64 | |||
| 65 | if (account) { | ||
| 66 | return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); | ||
| 67 | } | ||
| 68 | |||
| 69 | return SourceControlProviderDiscovery.providerAuth({ | ||
| 70 | status: "unknown", | ||
| 71 | host, | ||
| 72 | detail: | ||
| 73 | SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? | ||
| 74 | "GitHub CLI auth status could not be parsed.", | ||
| 75 | }); | ||
| 76 | } | ||
| 77 | |||
| 78 | export const discovery = { | ||
| 79 | type: "cli", | ||
| 80 | kind: "github", | ||
| 81 | label: "GitHub", | ||
| 82 | executable: "gh", | ||
| 83 | versionArgs: ["--version"], | ||
| 84 | authArgs: ["auth", "status"], | ||
| 85 | parseAuth: parseGitHubAuth, | ||
| 86 | installHint: | ||
| 87 | "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", | ||
| 88 | } satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; | ||
| 89 | |||
| 43 | export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { | 90 | export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { |
| 44 | const github = yield* GitHubCli; | 91 | const github = yield* GitHubCli.GitHubCli; |
| 45 | 92 | ||
| 46 | const listChangeRequests: SourceControlProviderShape["listChangeRequests"] = (input) => { | 93 | const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] = |
| 47 | if (input.state === "open") { | 94 | (input) => { |
| 95 | if (input.state === "open") { | ||
| 96 | return github | ||
| 97 | .listOpenPullRequests({ | ||
| 98 | cwd: input.cwd, | ||
| 99 | headSelector: input.headSelector, | ||
| 100 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | ||
| 101 | }) | ||
| 102 | .pipe( | ||
| 103 | Effect.map((items) => items.map(toChangeRequest)), | ||
| 104 | Effect.mapError((error) => providerError("listChangeRequests", error)), | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | |||
| 108 | const stateArg: ChangeRequestState | "all" = input.state; | ||
| 48 | return github | 109 | return github |
| 49 | .listOpenPullRequests({ | 110 | .execute({ |
| 50 | cwd: input.cwd, | 111 | cwd: input.cwd, |
| 51 | headSelector: input.headSelector, | 112 | args: [ |
| 52 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | 113 | "pr", |
| 114 | "list", | ||
| 115 | "--head", | ||
| 116 | input.headSelector, | ||
| 117 | "--state", | ||
| 118 | stateArg, | ||
| 119 | "--limit", | ||
| 120 | String(input.limit ?? 20), | ||
| 121 | "--json", | ||
| 122 | "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 123 | ], | ||
| 53 | }) | 124 | }) |
| 54 | .pipe( | 125 | .pipe( |
| 55 | Effect.map((items) => items.map(toChangeRequest)), | 126 | Effect.flatMap((result) => { |
| 56 | Effect.mapError((error) => providerError("listChangeRequests", error)), | 127 | const raw = result.stdout.trim(); |
| 128 | if (raw.length === 0) { | ||
| 129 | return Effect.succeed([]); | ||
| 130 | } | ||
| 131 | return Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( | ||
| 132 | Effect.flatMap((decoded) => | ||
| 133 | Result.isSuccess(decoded) | ||
| 134 | ? Effect.succeed( | ||
| 135 | decoded.success.map((item) => ({ | ||
| 136 | ...toChangeRequest(item), | ||
| 137 | updatedAt: item.updatedAt, | ||
| 138 | })), | ||
| 139 | ) | ||
| 140 | : Effect.fail( | ||
| 141 | new SourceControlProviderError({ | ||
| 142 | provider: "github", | ||
| 143 | operation: "listChangeRequests", | ||
| 144 | detail: "GitHub CLI returned invalid change request JSON.", | ||
| 145 | cause: decoded.failure, | ||
| 146 | }), | ||
| 147 | ), | ||
| 148 | ), | ||
| 149 | ); | ||
| 150 | }), | ||
| 151 | Effect.mapError((error) => | ||
| 152 | Schema.is(SourceControlProviderError)(error) | ||
| 153 | ? error | ||
| 154 | : providerError("listChangeRequests", error), | ||
| 155 | ), | ||
| 57 | ); | 156 | ); |
| 58 | } | 157 | }; |
| 59 | |||
| 60 | const stateArg: ChangeRequestState | "all" = input.state; | ||
| 61 | return github | ||
| 62 | .execute({ | ||
| 63 | cwd: input.cwd, | ||
| 64 | args: [ | ||
| 65 | "pr", | ||
| 66 | "list", | ||
| 67 | "--head", | ||
| 68 | input.headSelector, | ||
| 69 | "--state", | ||
| 70 | stateArg, | ||
| 71 | "--limit", | ||
| 72 | String(input.limit ?? 20), | ||
| 73 | "--json", | ||
| 74 | "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 75 | ], | ||
| 76 | }) | ||
| 77 | .pipe( | ||
| 78 | Effect.flatMap((result) => { | ||
| 79 | const raw = result.stdout.trim(); | ||
| 80 | if (raw.length === 0) { | ||
| 81 | return Effect.succeed([]); | ||
| 82 | } | ||
| 83 | return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( | ||
| 84 | Effect.flatMap((decoded) => | ||
| 85 | Result.isSuccess(decoded) | ||
| 86 | ? Effect.succeed( | ||
| 87 | decoded.success.map((item) => ({ | ||
| 88 | ...toChangeRequest(item), | ||
| 89 | updatedAt: item.updatedAt, | ||
| 90 | })), | ||
| 91 | ) | ||
| 92 | : Effect.fail( | ||
| 93 | new SourceControlProviderError({ | ||
| 94 | provider: "github", | ||
| 95 | operation: "listChangeRequests", | ||
| 96 | detail: "GitHub CLI returned invalid change request JSON.", | ||
| 97 | cause: decoded.failure, | ||
| 98 | }), | ||
| 99 | ), | ||
| 100 | ), | ||
| 101 | ); | ||
| 102 | }), | ||
| 103 | Effect.mapError((error) => | ||
| 104 | Schema.is(SourceControlProviderError)(error) | ||
| 105 | ? error | ||
| 106 | : providerError("listChangeRequests", error), | ||
| 107 | ), | ||
| 108 | ); | ||
| 109 | }; | ||
| 110 | 158 | ||
| 111 | return SourceControlProvider.of({ | 159 | return SourceControlProvider.SourceControlProvider.of({ |
| 112 | kind: "github", | 160 | kind: "github", |
| 113 | listChangeRequests, | 161 | listChangeRequests, |
| 114 | getChangeRequest: (input) => | 162 | getChangeRequest: (input) => |
| @@ -145,4 +193,4 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { · 30 unmodified lines | |||
| 145 | }); | 193 | }); |
| 146 | }); | 194 | }); |
| 147 | 195 | ||
| 148 | export const layer = Layer.effect(SourceControlProvider, make()); | 196 | export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); |
| @@ -1,25 +1,24 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it, afterEach, expect, vi } from "@effect/vitest"; |
| 2 | import { Effect, Layer } from "effect"; | 2 | import { Effect, Layer } from "effect"; |
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | 3 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | import { afterEach, expect, vi } from "vitest"; | ||
| 5 | 4 | ||
| 6 | import { VcsProcessExitError } from "@t3tools/contracts"; | 5 | import { VcsProcessExitError } from "@t3tools/contracts"; |
| 7 | 6 | ||
| 8 | import { VcsProcess, type VcsProcessOutput, type VcsProcessShape } from "../vcs/VcsProcess.ts"; | 7 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 9 | import * as GitLabCli from "./GitLabCli.ts"; | 8 | import * as GitLabCli from "./GitLabCli.ts"; |
| 10 | 9 | ||
| 11 | const mockedRun = vi.fn<VcsProcessShape["run"]>(); | 10 | const mockedRun = vi.fn<VcsProcess.VcsProcessShape["run"]>(); |
| 12 | const layer = it.layer( | 11 | const layer = it.layer( |
| 13 | GitLabCli.layer.pipe( | 12 | GitLabCli.layer.pipe( |
| 14 | Layer.provide( | 13 | Layer.provide( |
| 15 | Layer.mock(VcsProcess)({ | 14 | Layer.mock(VcsProcess.VcsProcess)({ |
| 16 | run: mockedRun, | 15 | run: mockedRun, |
| 17 | }), | 16 | }), |
| 18 | ), | 17 | ), |
| 19 | ), | 18 | ), |
| 20 | ); | 19 | ); |
| 21 | 20 | ||
| 22 | function processOutput(stdout: string): VcsProcessOutput { | 21 | function processOutput(stdout: string): VcsProcess.VcsProcessOutput { |
| 23 | return { | 22 | return { |
| 24 | exitCode: ChildProcessSpawner.ExitCode(0), | 23 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 25 | stdout, | 24 | stdout, |
| @@ -2,13 +2,9 @@ import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateT · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; | 3 | import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; |
| 4 | 4 | ||
| 5 | import { | 5 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 6 | decodeGitLabMergeRequestJson, | 6 | import * as GitLabMergeRequests from "./gitLabMergeRequests.ts"; |
| 7 | decodeGitLabMergeRequestListJson, | 7 | import type * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 8 | formatGitLabJsonDecodeError, | ||
| 9 | } from "./gitLabMergeRequests.ts"; | ||
| 10 | import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; | ||
| 11 | import type { SourceControlRefSelector } from "./SourceControlProvider.ts"; | ||
| 12 | 8 | ||
| 13 | const DEFAULT_TIMEOUT_MS = 30_000; | 9 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 14 | 10 | ||
| @@ -46,12 +42,12 @@ export interface GitLabCliShape { · 31 unmodified lines | |||
| 46 | readonly cwd: string; | 42 | readonly cwd: string; |
| 47 | readonly args: ReadonlyArray<string>; | 43 | readonly args: ReadonlyArray<string>; |
| 48 | readonly timeoutMs?: number; | 44 | readonly timeoutMs?: number; |
| 49 | }) => Effect.Effect<VcsProcessOutput, GitLabCliError>; | 45 | }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitLabCliError>; |
| 50 | 46 | ||
| 51 | readonly listMergeRequests: (input: { | 47 | readonly listMergeRequests: (input: { |
| 52 | readonly cwd: string; | 48 | readonly cwd: string; |
| 53 | readonly headSelector: string; | 49 | readonly headSelector: string; |
| 54 | readonly source?: SourceControlRefSelector; | 50 | readonly source?: SourceControlProvider.SourceControlRefSelector; |
| 55 | readonly state: "open" | "closed" | "merged" | "all"; | 51 | readonly state: "open" | "closed" | "merged" | "all"; |
| 56 | readonly limit?: number; | 52 | readonly limit?: number; |
| 57 | }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>; | 53 | }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>; |
| @@ -76,8 +72,8 @@ export interface GitLabCliShape { · 18 unmodified lines | |||
| 76 | readonly cwd: string; | 72 | readonly cwd: string; |
| 77 | readonly baseBranch: string; | 73 | readonly baseBranch: string; |
| 78 | readonly headSelector: string; | 74 | readonly headSelector: string; |
| 79 | readonly source?: SourceControlRefSelector; | 75 | readonly source?: SourceControlProvider.SourceControlRefSelector; |
| 80 | readonly target?: SourceControlRefSelector; | 76 | readonly target?: SourceControlProvider.SourceControlRefSelector; |
| 81 | readonly title: string; | 77 | readonly title: string; |
| 82 | readonly bodyFile: string; | 78 | readonly bodyFile: string; |
| 83 | }) => Effect.Effect<void, GitLabCliError>; | 79 | }) => Effect.Effect<void, GitLabCliError>; |
| @@ -220,12 +216,14 @@ function normalizeHeadSelector(headSelector: string): string { · 136 unmodified lines | |||
| 220 | 216 | ||
| 221 | function sourceRefName(input: { | 217 | function sourceRefName(input: { |
| 222 | readonly headSelector: string; | 218 | readonly headSelector: string; |
| 223 | readonly source?: SourceControlRefSelector; | 219 | readonly source?: SourceControlProvider.SourceControlRefSelector; |
| 224 | }): string { | 220 | }): string { |
| 225 | return input.source?.refName ?? normalizeHeadSelector(input.headSelector); | 221 | return input.source?.refName ?? normalizeHeadSelector(input.headSelector); |
| 226 | } | 222 | } |
| 227 | 223 | ||
| 228 | function sourceProjectIdentifier(source: SourceControlRefSelector | undefined): string | null { | 224 | function sourceProjectIdentifier( |
| 225 | source: SourceControlProvider.SourceControlRefSelector | undefined, | ||
| 226 | ): string | null { | ||
| 229 | return source?.repository ?? source?.owner ?? null; | 227 | return source?.repository ?? source?.owner ?? null; |
| 230 | } | 228 | } |
| 231 | 229 | ||
| @@ -252,7 +250,7 @@ function parseRepositoryPath(repository: string): { · 20 unmodified lines | |||
| 252 | } | 250 | } |
| 253 | 251 | ||
| 254 | export const make = Effect.fn("makeGitLabCli")(function* () { | 252 | export const make = Effect.fn("makeGitLabCli")(function* () { |
| 255 | const process = yield* VcsProcess; | 253 | const process = yield* VcsProcess.VcsProcess; |
| 256 | 254 | ||
| 257 | const execute: GitLabCliShape["execute"] = (input) => | 255 | const execute: GitLabCliShape["execute"] = (input) => |
| 258 | process | 256 | process |
| @@ -286,13 +284,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { · 27 unmodified lines | |||
| 286 | Effect.flatMap((raw) => | 284 | Effect.flatMap((raw) => |
| 287 | raw.length === 0 | 285 | raw.length === 0 |
| 288 | ? Effect.succeed([]) | 286 | ? Effect.succeed([]) |
| 289 | : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( | 287 | : Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestListJson(raw)).pipe( |
| 290 | Effect.flatMap((decoded) => { | 288 | Effect.flatMap((decoded) => { |
| 291 | if (!Result.isSuccess(decoded)) { | 289 | if (!Result.isSuccess(decoded)) { |
| 292 | return Effect.fail( | 290 | return Effect.fail( |
| 293 | new GitLabCliError({ | 291 | new GitLabCliError({ |
| 294 | operation: "listMergeRequests", | 292 | operation: "listMergeRequests", |
| 295 | detail: `GitLab CLI returned invalid MR list JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, | 293 | detail: `GitLab CLI returned invalid MR list JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, |
| 296 | cause: decoded.failure, | 294 | cause: decoded.failure, |
| 297 | }), | 295 | }), |
| 298 | ); | 296 | ); |
| @@ -310,13 +308,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { · 11 unmodified lines | |||
| 310 | }).pipe( | 308 | }).pipe( |
| 311 | Effect.map((result) => result.stdout.trim()), | 309 | Effect.map((result) => result.stdout.trim()), |
| 312 | Effect.flatMap((raw) => | 310 | Effect.flatMap((raw) => |
| 313 | Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( | 311 | Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestJson(raw)).pipe( |
| 314 | Effect.flatMap((decoded) => { | 312 | Effect.flatMap((decoded) => { |
| 315 | if (!Result.isSuccess(decoded)) { | 313 | if (!Result.isSuccess(decoded)) { |
| 316 | return Effect.fail( | 314 | return Effect.fail( |
| 317 | new GitLabCliError({ | 315 | new GitLabCliError({ |
| 318 | operation: "getMergeRequest", | 316 | operation: "getMergeRequest", |
| 319 | detail: `GitLab CLI returned invalid merge request JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, | 317 | detail: `GitLab CLI returned invalid merge request JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, |
| 320 | cause: decoded.failure, | 318 | cause: decoded.failure, |
| 321 | }), | 319 | }), |
| 322 | ); | 320 | ); |
| @@ -1,11 +1,13 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import { Effect, Layer, Option } from "effect"; | 2 | import { Effect, Layer, Option } from "effect"; |
| 3 | 3 | ||
| 4 | import { GitLabCli, type GitLabCliShape } from "./GitLabCli.ts"; | 4 | import * as GitLabCli from "./GitLabCli.ts"; |
| 5 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; | 5 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; |
| 6 | 6 | ||
| 7 | function makeProvider(gitlab: Partial<GitLabCliShape>) { | 7 | function makeProvider(gitlab: Partial<GitLabCli.GitLabCliShape>) { |
| 8 | return GitLabSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitLabCli)(gitlab))); | 8 | return GitLabSourceControlProvider.make().pipe( |
| 9 | Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), | ||
| 10 | ); | ||
| 9 | } | 11 | } |
| 10 | 12 | ||
| 11 | it.effect("maps GitLab MR summaries into provider-neutral change requests", () => | 13 | it.effect("maps GitLab MR summaries into provider-neutral change requests", () => |
| @@ -48,7 +50,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = · 36 unmodified lines | |||
| 48 | 50 | ||
| 49 | it.effect("lists GitLab MRs through provider-neutral input names", () => | 51 | it.effect("lists GitLab MRs through provider-neutral input names", () => |
| 50 | Effect.gen(function* () { | 52 | Effect.gen(function* () { |
| 51 | let listInput: Parameters<GitLabCliShape["listMergeRequests"]>[0] | null = null; | 53 | let listInput: Parameters<GitLabCli.GitLabCliShape["listMergeRequests"]>[0] | null = null; |
| 52 | const provider = yield* makeProvider({ | 54 | const provider = yield* makeProvider({ |
| 53 | listMergeRequests: (input) => { | 55 | listMergeRequests: (input) => { |
| 54 | listInput = input; | 56 | listInput = input; |
| @@ -74,7 +76,7 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => · 19 unmodified lines | |||
| 74 | 76 | ||
| 75 | it.effect("creates GitLab MRs through provider-neutral input names", () => | 77 | it.effect("creates GitLab MRs through provider-neutral input names", () => |
| 76 | Effect.gen(function* () { | 78 | Effect.gen(function* () { |
| 77 | let createInput: Parameters<GitLabCliShape["createMergeRequest"]>[0] | null = null; | 79 | let createInput: Parameters<GitLabCli.GitLabCliShape["createMergeRequest"]>[0] | null = null; |
| 78 | const provider = yield* makeProvider({ | 80 | const provider = yield* makeProvider({ |
| 79 | createMergeRequest: (input) => { | 81 | createMergeRequest: (input) => { |
| 80 | createInput = input; | 82 | createInput = input; |
| @@ -1,10 +1,14 @@ | |||
| 1 | import { Effect, Layer, Option } from "effect"; | 1 | import { Effect, Layer, Option } from "effect"; |
| 2 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | 2 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts"; | 4 | import * as GitLabCli from "./GitLabCli.ts"; |
| 5 | import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; | 5 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 6 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | ||
| 6 | 7 | ||
| 7 | function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError { | 8 | function providerError( |
| 9 | operation: string, | ||
| 10 | cause: GitLabCli.GitLabCliError, | ||
| 11 | ): SourceControlProviderError { | ||
| 8 | return new SourceControlProviderError({ | 12 | return new SourceControlProviderError({ |
| 9 | provider: "gitlab", | 13 | provider: "gitlab", |
| 10 | operation, | 14 | operation, |
| @@ -13,7 +17,7 @@ function providerError(operation: string, cause: GitLabCliError): SourceControlP · 2 unmodified lines | |||
| 13 | }); | 17 | }); |
| 14 | } | 18 | } |
| 15 | 19 | ||
| 16 | function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { | 20 | function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { |
| 17 | return { | 21 | return { |
| 18 | provider: "gitlab", | 22 | provider: "gitlab", |
| 19 | number: summary.number, | 23 | number: summary.number, |
| @@ -35,27 +39,57 @@ function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { · 15 unmodified lines | |||
| 35 | }; | 39 | }; |
| 36 | } | 40 | } |
| 37 | 41 | ||
| 38 | function sourceFromInput(input: { | 42 | function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { |
| 39 | readonly headSelector: string; | 43 | const output = SourceControlProviderDiscovery.combinedAuthOutput(input); |
| 40 | readonly source?: SourceControlRefSelector; | 44 | const account = SourceControlProviderDiscovery.matchFirst(output, [ |
| 41 | }): SourceControlRefSelector | undefined { | 45 | /Logged in to .* as\s+([^\s(]+)/iu, |
| 42 | if (input.source) { | 46 | /Logged in to .* account\s+([^\s(]+)/iu, |
| 43 | return input.source; | 47 | /account:\s*([^\s(]+)/iu, |
| 48 | ]); | ||
| 49 | const host = SourceControlProviderDiscovery.parseCliHost(output); | ||
| 50 | |||
| 51 | if (input.exitCode !== 0) { | ||
| 52 | return SourceControlProviderDiscovery.providerAuth({ | ||
| 53 | status: "unauthenticated", | ||
| 54 | host, | ||
| 55 | detail: | ||
| 56 | SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? | ||
| 57 | "Run `glab auth login` to authenticate GitLab CLI.", | ||
| 58 | }); | ||
| 59 | } | ||
| 60 | |||
| 61 | if (account) { | ||
| 62 | return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); | ||
| 44 | } | 63 | } |
| 45 | 64 | ||
| 46 | const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); | 65 | return SourceControlProviderDiscovery.providerAuth({ |
| 47 | const owner = match?.[1]?.trim(); | 66 | status: "unknown", |
| 48 | const refName = match?.[2]?.trim(); | 67 | host, |
| 49 | return owner && refName ? { owner, refName } : undefined; | 68 | detail: |
| 69 | SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? | ||
| 70 | "GitLab CLI auth status could not be parsed.", | ||
| 71 | }); | ||
| 50 | } | 72 | } |
| 51 | 73 | ||
| 74 | export const discovery = { | ||
| 75 | type: "cli", | ||
| 76 | kind: "gitlab", | ||
| 77 | label: "GitLab", | ||
| 78 | executable: "glab", | ||
| 79 | versionArgs: ["--version"], | ||
| 80 | authArgs: ["auth", "status"], | ||
| 81 | parseAuth: parseGitLabAuth, | ||
| 82 | installHint: | ||
| 83 | "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).", | ||
| 84 | } satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; | ||
| 85 | |||
| 52 | export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { | 86 | export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { |
| 53 | const gitlab = yield* GitLabCli; | 87 | const gitlab = yield* GitLabCli.GitLabCli; |
| 54 | 88 | ||
| 55 | return SourceControlProvider.of({ | 89 | return SourceControlProvider.SourceControlProvider.of({ |
| 56 | kind: "gitlab", | 90 | kind: "gitlab", |
| 57 | listChangeRequests: (input) => { | 91 | listChangeRequests: (input) => { |
| 58 | const source = sourceFromInput(input); | 92 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| 59 | return gitlab | 93 | return gitlab |
| 60 | .listMergeRequests({ | 94 | .listMergeRequests({ |
| 61 | cwd: input.cwd, | 95 | cwd: input.cwd, |
| @@ -75,7 +109,7 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { · 13 unmodified lines | |||
| 75 | Effect.mapError((error) => providerError("getChangeRequest", error)), | 109 | Effect.mapError((error) => providerError("getChangeRequest", error)), |
| 76 | ), | 110 | ), |
| 77 | createChangeRequest: (input) => { | 111 | createChangeRequest: (input) => { |
| 78 | const source = sourceFromInput(input); | 112 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| 79 | return gitlab | 113 | return gitlab |
| 80 | .createMergeRequest({ | 114 | .createMergeRequest({ |
| 81 | cwd: input.cwd, | 115 | cwd: input.cwd, |
| @@ -107,4 +141,4 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { · 25 unmodified lines | |||
| 107 | }); | 141 | }); |
| 108 | }); | 142 | }); |
| 109 | 143 | ||
| 110 | export const layer = Layer.effect(SourceControlProvider, make()); | 144 | export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); |
| @@ -5,8 +5,34 @@ import { ChildProcessSpawner } from "effect/unstable/process"; · 4 unmodified lines | |||
| 5 | import { VcsProcessSpawnError } from "@t3tools/contracts"; | 5 | import { VcsProcessSpawnError } from "@t3tools/contracts"; |
| 6 | 6 | ||
| 7 | import { ServerConfig } from "../config.ts"; | 7 | import { ServerConfig } from "../config.ts"; |
| 8 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 9 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 9 | import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts"; | 10 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| 11 | import * as BitbucketApi from "./BitbucketApi.ts"; | ||
| 12 | import * as GitHubCli from "./GitHubCli.ts"; | ||
| 13 | import * as GitLabCli from "./GitLabCli.ts"; | ||
| 14 | import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; | ||
| 15 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | ||
| 16 | |||
| 17 | const sourceControlProviderRegistryTestLayer = (input: { | ||
| 18 | readonly bitbucket: Partial<BitbucketApi.BitbucketApiShape>; | ||
| 19 | readonly process: Partial<VcsProcess.VcsProcessShape>; | ||
| 20 | }) => | ||
| 21 | SourceControlProviderRegistry.layer.pipe( | ||
| 22 | Layer.provide( | ||
| 23 | Layer.mergeAll( | ||
| 24 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( | ||
| 25 | Layer.provide(NodeServices.layer), | ||
| 26 | ), | ||
| 27 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), | ||
| 28 | Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), | ||
| 29 | Layer.mock(GitHubCli.GitHubCli)({}), | ||
| 30 | Layer.mock(GitLabCli.GitLabCli)({}), | ||
| 31 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), | ||
| 32 | Layer.mock(VcsProcess.VcsProcess)(input.process), | ||
| 33 | ), | ||
| 34 | ), | ||
| 35 | ); | ||
| 10 | 36 | ||
| 11 | const processOutput = ( | 37 | const processOutput = ( |
| 12 | stdout: string, | 38 | stdout: string, |
| @@ -22,39 +48,53 @@ const processOutput = ( · 9 unmodified lines | |||
| 22 | stderrTruncated: false, | 48 | stderrTruncated: false, |
| 23 | }); | 49 | }); |
| 24 | 50 | ||
| 25 | it.effect("reports implemented tools separately from locally available CLIs", () => { | 51 | it.effect("reports implemented tools separately from locally available executables", () => { |
| 26 | const testLayer = layer.pipe( | 52 | const processMock = { |
| 27 | Layer.provide( | 53 | run: (input: VcsProcess.VcsProcessInput) => { |
| 28 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), | 54 | if (input.command === "git") { |
| 29 | ), | 55 | return Effect.succeed(processOutput("git version 2.51.0\n")); |
| 30 | Layer.provide( | 56 | } |
| 31 | Layer.mock(VcsProcess.VcsProcess)({ | 57 | if (input.command === "gh" && input.args[0] === "--version") { |
| 32 | run: (input) => { | 58 | return Effect.succeed(processOutput("gh version 2.83.0\n")); |
| 33 | if (input.command === "git") { | 59 | } |
| 34 | return Effect.succeed(processOutput("git version 2.51.0\n")); | 60 | if (input.command === "gh" && input.args.join(" ") === "auth status") { |
| 35 | } | 61 | return Effect.succeed( |
| 36 | if (input.command === "gh" && input.args[0] === "--version") { | 62 | processOutput(`github.com |
| 37 | return Effect.succeed(processOutput("gh version 2.83.0\n")); | ||
| 38 | } | ||
| 39 | if (input.command === "gh" && input.args.join(" ") === "auth status") { | ||
| 40 | return Effect.succeed( | ||
| 41 | processOutput(`github.com | ||
| 42 | Logged in to github.com account juliusmarminge (keyring) | 63 | Logged in to github.com account juliusmarminge (keyring) |
| 43 | - Active account: true | 64 | - Active account: true |
| 44 | - Git operations protocol: ssh | 65 | - Git operations protocol: ssh |
| 45 | - Token: gho_************************************ | 66 | - Token: gho_************************************ |
| 46 | - Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo' | 67 | - Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo' |
| 47 | `), | 68 | `), |
| 48 | ); | 69 | ); |
| 49 | } | 70 | } |
| 50 | return Effect.fail( | 71 | return Effect.fail( |
| 51 | new VcsProcessSpawnError({ | 72 | new VcsProcessSpawnError({ |
| 52 | operation: input.operation, | 73 | operation: input.operation, |
| 53 | command: input.command, | 74 | command: input.command, |
| 54 | cwd: input.cwd, | 75 | cwd: input.cwd, |
| 55 | cause: new Error(`${input.command} not found`), | 76 | cause: new Error(`${input.command} not found`), |
| 56 | }), | 77 | }), |
| 57 | ); | 78 | ); |
| 79 | }, | ||
| 80 | } satisfies Partial<VcsProcess.VcsProcessShape>; | ||
| 81 | const testLayer = SourceControlDiscovery.layer.pipe( | ||
| 82 | Layer.provide( | ||
| 83 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), | ||
| 84 | ), | ||
| 85 | Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), | ||
| 86 | Layer.provide( | ||
| 87 | sourceControlProviderRegistryTestLayer({ | ||
| 88 | process: processMock, | ||
| 89 | bitbucket: { | ||
| 90 | probeAuth: Effect.succeed({ | ||
| 91 | status: "unauthenticated", | ||
| 92 | account: Option.none(), | ||
| 93 | host: Option.some("bitbucket.org"), | ||
| 94 | detail: Option.some( | ||
| 95 | "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", | ||
| 96 | ), | ||
| 97 | }), | ||
| 58 | }, | 98 | }, |
| 59 | }), | 99 | }), |
| 60 | ), | 100 | ), |
| @@ -62,7 +102,7 @@ Logged in to github.com account juliusmarminge (keyring) · 1 unmodified line | |||
| 62 | ); | 102 | ); |
| 63 | 103 | ||
| 64 | return Effect.gen(function* () { | 104 | return Effect.gen(function* () { |
| 65 | const discovery = yield* SourceControlDiscovery; | 105 | const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; |
| 66 | const result = yield* discovery.discover; | 106 | const result = yield* discovery.discover; |
| 67 | 107 | ||
| 68 | assert.deepStrictEqual( | 108 | assert.deepStrictEqual( |
| @@ -79,7 +119,6 @@ Logged in to github.com account juliusmarminge (keyring) · 10 unmodified lines | |||
| 79 | assert.deepStrictEqual( | 119 | assert.deepStrictEqual( |
| 80 | result.sourceControlProviders.map((item) => ({ | 120 | result.sourceControlProviders.map((item) => ({ |
| 81 | kind: item.kind, | 121 | kind: item.kind, |
| 82 | implemented: item.implemented, | ||
| 83 | status: item.status, | 122 | status: item.status, |
| 84 | auth: item.auth.status, | 123 | auth: item.auth.status, |
| 85 | account: item.auth.account, | 124 | account: item.auth.account, |
| @@ -87,88 +126,89 @@ Logged in to github.com account juliusmarminge (keyring) · 1 unmodified line | |||
| 87 | [ | 126 | [ |
| 88 | { | 127 | { |
| 89 | kind: "github", | 128 | kind: "github", |
| 90 | implemented: true, | ||
| 91 | status: "available", | 129 | status: "available", |
| 92 | auth: "authenticated", | 130 | auth: "authenticated", |
| 93 | account: Option.some("juliusmarminge"), | 131 | account: Option.some("juliusmarminge"), |
| 94 | }, | 132 | }, |
| 95 | { | 133 | { |
| 96 | kind: "gitlab", | 134 | kind: "gitlab", |
| 97 | implemented: true, | ||
| 98 | status: "missing", | 135 | status: "missing", |
| 99 | auth: "unknown", | 136 | auth: "unknown", |
| 100 | account: Option.none(), | 137 | account: Option.none(), |
| 101 | }, | 138 | }, |
| 102 | { | 139 | { |
| 103 | kind: "azure-devops", | 140 | kind: "azure-devops", |
| 104 | implemented: false, | ||
| 105 | status: "missing", | 141 | status: "missing", |
| 106 | auth: "unknown", | 142 | auth: "unknown", |
| 107 | account: Option.none(), | 143 | account: Option.none(), |
| 108 | }, | 144 | }, |
| 109 | { | 145 | { |
| 110 | kind: "bitbucket", | 146 | kind: "bitbucket", |
| 111 | implemented: false, | 147 | status: "available", |
| 112 | status: "missing", | 148 | auth: "unauthenticated", |
| 113 | auth: "unknown", | ||
| 114 | account: Option.none(), | 149 | account: Option.none(), |
| 115 | }, | 150 | }, |
| 116 | ], | 151 | ], |
| 117 | ); | 152 | ); |
| 118 | const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); | 153 | const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); |
| 119 | assert.ok(bitbucket); | 154 | assert.ok(bitbucket); |
| 120 | assert.strictEqual("executable" in bitbucket, false); | 155 | assert.strictEqual(bitbucket.executable, undefined); |
| 121 | }).pipe(Effect.provide(testLayer)); | 156 | }).pipe(Effect.provide(testLayer)); |
| 122 | }); | 157 | }); |
| 123 | 158 | ||
| 124 | it.effect("probes provider authentication without exposing token details", () => { | 159 | it.effect("probes provider authentication without exposing token details", () => { |
| 125 | const testLayer = layer.pipe( | 160 | const processMock = { |
| 126 | Layer.provide( | 161 | run: (input: VcsProcess.VcsProcessInput) => { |
| 127 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), | 162 | if (input.args[0] === "--version") { |
| 128 | ), | 163 | return Effect.succeed(processOutput(`${input.command} version test\n`)); |
| 129 | Layer.provide( | 164 | } |
| 130 | Layer.mock(VcsProcess.VcsProcess)({ | 165 | if (input.command === "gh" && input.args.join(" ") === "auth status") { |
| 131 | run: (input) => { | 166 | return Effect.succeed( |
| 132 | if (input.args[0] === "--version") { | 167 | processOutput(`github.com |
| 133 | return Effect.succeed(processOutput(`${input.command} version test\n`)); | ||
| 134 | } | ||
| 135 | if (input.command === "gh" && input.args.join(" ") === "auth status") { | ||
| 136 | return Effect.succeed( | ||
| 137 | processOutput(`github.com | ||
| 138 | Logged in to github.com account octocat (keyring) | 168 | Logged in to github.com account octocat (keyring) |
| 139 | - Token: gho_************************************ | 169 | - Token: gho_************************************ |
| 140 | - Token scopes: 'repo' | 170 | - Token scopes: 'repo' |
| 141 | `), | 171 | `), |
| 142 | ); | 172 | ); |
| 143 | } | 173 | } |
| 144 | if (input.command === "glab" && input.args.join(" ") === "auth status") { | 174 | if (input.command === "glab" && input.args.join(" ") === "auth status") { |
| 145 | return Effect.succeed( | 175 | return Effect.succeed( |
| 146 | processOutput(`gitlab.com | 176 | processOutput(`gitlab.com |
| 147 | Logged in to gitlab.com as gitlab-user | 177 | Logged in to gitlab.com as gitlab-user |
| 148 | `), | 178 | `), |
| 149 | ); | 179 | ); |
| 150 | } | 180 | } |
| 151 | if ( | 181 | if ( |
| 152 | input.command === "az" && | 182 | input.command === "az" && |
| 153 | input.args.join(" ") === "account show --query user.name -o tsv" | 183 | input.args.join(" ") === "account show --query user.name -o tsv" |
| 154 | ) { | 184 | ) { |
| 155 | return Effect.succeed(processOutput("azure-user@example.com\n")); | 185 | return Effect.succeed(processOutput("azure-user@example.com\n")); |
| 156 | } | 186 | } |
| 157 | if (input.command === "bb" && input.args.join(" ") === "auth status") { | 187 | return Effect.fail( |
| 158 | return Effect.succeed( | 188 | new VcsProcessSpawnError({ |
| 159 | processOutput(`bitbucket.org | 189 | operation: input.operation, |
| 160 | Logged in as bitbucket-user | 190 | command: input.command, |
| 161 | `), | 191 | cwd: input.cwd, |
| 162 | ); | 192 | cause: new Error(`${input.command} not found`), |
| 163 | } | 193 | }), |
| 164 | return Effect.fail( | 194 | ); |
| 165 | new VcsProcessSpawnError({ | 195 | }, |
| 166 | operation: input.operation, | 196 | } satisfies Partial<VcsProcess.VcsProcessShape>; |
| 167 | command: input.command, | 197 | const testLayer = SourceControlDiscovery.layer.pipe( |
| 168 | cwd: input.cwd, | 198 | Layer.provide( |
| 169 | cause: new Error(`${input.command} not found`), | 199 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), |
| 170 | }), | 200 | ), |
| 171 | ); | 201 | Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), |
| 202 | Layer.provide( | ||
| 203 | sourceControlProviderRegistryTestLayer({ | ||
| 204 | process: processMock, | ||
| 205 | bitbucket: { | ||
| 206 | probeAuth: Effect.succeed({ | ||
| 207 | status: "authenticated", | ||
| 208 | account: Option.some("bitbucket-user"), | ||
| 209 | host: Option.some("bitbucket.org"), | ||
| 210 | detail: Option.none(), | ||
| 211 | }), | ||
| 172 | }, | 212 | }, |
| 173 | }), | 213 | }), |
| 174 | ), | 214 | ), |
| @@ -176,7 +216,7 @@ Logged in as bitbucket-user · 1 unmodified line | |||
| 176 | ); | 216 | ); |
| 177 | 217 | ||
| 178 | return Effect.gen(function* () { | 218 | return Effect.gen(function* () { |
| 179 | const discovery = yield* SourceControlDiscovery; | 219 | const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; |
| 180 | const result = yield* discovery.discover; | 220 | const result = yield* discovery.discover; |
| 181 | 221 | ||
| 182 | assert.deepStrictEqual( | 222 | assert.deepStrictEqual( |
| @@ -207,9 +247,9 @@ Logged in as bitbucket-user · 24 unmodified lines | |||
| 207 | }, | 247 | }, |
| 208 | { | 248 | { |
| 209 | kind: "bitbucket", | 249 | kind: "bitbucket", |
| 210 | auth: "unknown", | 250 | auth: "authenticated", |
| 211 | account: Option.none(), | 251 | account: Option.some("bitbucket-user"), |
| 212 | detail: Option.some("Bitbucket provider support is not available yet."), | 252 | detail: Option.none(), |
| 213 | }, | 253 | }, |
| 214 | ], | 254 | ], |
| 215 | ); | 255 | ); |
| @@ -1,8 +1,5 @@ | |||
| 1 | import { | 1 | import { |
| 2 | type SourceControlProviderAuth, | ||
| 3 | type SourceControlDiscoveryResult, | 2 | type SourceControlDiscoveryResult, |
| 4 | type SourceControlProviderDiscoveryItem, | ||
| 5 | type SourceControlProviderKind, | ||
| 6 | type VcsDiscoveryItem, | 3 | type VcsDiscoveryItem, |
| 7 | type VcsDriverKind, | 4 | type VcsDriverKind, |
| 8 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| @@ -10,6 +7,8 @@ import { Context, Effect, Layer, Option } from "effect"; · 1 unmodified line | |||
| 10 | 7 | ||
| 11 | import { ServerConfig } from "../config.ts"; | 8 | import { ServerConfig } from "../config.ts"; |
| 12 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 9 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 10 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | ||
| 11 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | ||
| 13 | 12 | ||
| 14 | interface DiscoveryProbe { | 13 | interface DiscoveryProbe { |
| 15 | readonly label: string; | 14 | readonly label: string; |
| @@ -25,18 +24,6 @@ type VcsProbe = DiscoveryProbe & { · 9 unmodified lines | |||
| 25 | readonly versionArgs: ReadonlyArray<string>; | 24 | readonly versionArgs: ReadonlyArray<string>; |
| 26 | }; | 25 | }; |
| 27 | 26 | ||
| 28 | type ProviderProbe = DiscoveryProbe & { | ||
| 29 | readonly kind: SourceControlProviderKind; | ||
| 30 | readonly authArgs?: ReadonlyArray<string>; | ||
| 31 | readonly parseAuth?: (input: AuthProbeInput) => SourceControlProviderAuth; | ||
| 32 | }; | ||
| 33 | |||
| 34 | interface AuthProbeInput { | ||
| 35 | readonly stdout: string; | ||
| 36 | readonly stderr: string; | ||
| 37 | readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; | ||
| 38 | } | ||
| 39 | |||
| 40 | interface DiscoveryProbeResult<Kind extends string> { | 27 | interface DiscoveryProbeResult<Kind extends string> { |
| 41 | readonly kind: Kind; | 28 | readonly kind: Kind; |
| 42 | readonly label: string; | 29 | readonly label: string; |
| @@ -67,203 +54,6 @@ const VCS_PROBES: ReadonlyArray<VcsProbe> = [ · 24 unmodified lines | |||
| 67 | }, | 54 | }, |
| 68 | ]; | 55 | ]; |
| 69 | 56 | ||
| 70 | const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ | ||
| 71 | { | ||
| 72 | kind: "github", | ||
| 73 | label: "GitHub", | ||
| 74 | executable: "gh", | ||
| 75 | versionArgs: ["--version"], | ||
| 76 | authArgs: ["auth", "status"], | ||
| 77 | parseAuth: parseGitHubAuth, | ||
| 78 | implemented: true, | ||
| 79 | installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.", | ||
| 80 | }, | ||
| 81 | { | ||
| 82 | kind: "gitlab", | ||
| 83 | label: "GitLab", | ||
| 84 | executable: "glab", | ||
| 85 | versionArgs: ["--version"], | ||
| 86 | authArgs: ["auth", "status"], | ||
| 87 | parseAuth: parseGitLabAuth, | ||
| 88 | implemented: true, | ||
| 89 | installHint: | ||
| 90 | "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", | ||
| 91 | }, | ||
| 92 | { | ||
| 93 | kind: "azure-devops", | ||
| 94 | label: "Azure DevOps", | ||
| 95 | executable: "az", | ||
| 96 | versionArgs: ["--version"], | ||
| 97 | authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], | ||
| 98 | parseAuth: parseAzureAuth, | ||
| 99 | implemented: false, | ||
| 100 | installHint: | ||
| 101 | "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", | ||
| 102 | }, | ||
| 103 | { | ||
| 104 | kind: "bitbucket", | ||
| 105 | label: "Bitbucket", | ||
| 106 | implemented: false, | ||
| 107 | installHint: "Bitbucket provider support is not available yet.", | ||
| 108 | }, | ||
| 109 | ]; | ||
| 110 | |||
| 111 | function firstNonEmptyLine(text: string): Option.Option<string> { | ||
| 112 | const line = text | ||
| 113 | .split(/\r?\n/) | ||
| 114 | .map((entry) => entry.trim()) | ||
| 115 | .find((entry) => entry.length > 0); | ||
| 116 | return line === undefined ? Option.none() : Option.some(line); | ||
| 117 | } | ||
| 118 | |||
| 119 | function detailFromCause(cause: unknown): Option.Option<string> { | ||
| 120 | if (cause instanceof Error && cause.message.trim().length > 0) { | ||
| 121 | return Option.some(cause.message.trim()); | ||
| 122 | } | ||
| 123 | return Option.none(); | ||
| 124 | } | ||
| 125 | |||
| 126 | function authAccount(account: string | undefined): Option.Option<string> { | ||
| 127 | const trimmed = account?.trim(); | ||
| 128 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 129 | } | ||
| 130 | |||
| 131 | function authHost(host: string | undefined): Option.Option<string> { | ||
| 132 | const trimmed = host?.trim(); | ||
| 133 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 134 | } | ||
| 135 | |||
| 136 | function authDetail(detail: string | undefined): Option.Option<string> { | ||
| 137 | const trimmed = detail?.trim(); | ||
| 138 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 139 | } | ||
| 140 | |||
| 141 | function providerAuth(input: { | ||
| 142 | readonly status: SourceControlProviderAuth["status"]; | ||
| 143 | readonly account?: string | undefined; | ||
| 144 | readonly host?: string | undefined; | ||
| 145 | readonly detail?: string | undefined; | ||
| 146 | }): SourceControlProviderAuth { | ||
| 147 | return { | ||
| 148 | status: input.status, | ||
| 149 | account: authAccount(input.account), | ||
| 150 | host: authHost(input.host), | ||
| 151 | detail: authDetail(input.detail), | ||
| 152 | }; | ||
| 153 | } | ||
| 154 | |||
| 155 | function unknownAuth(detail?: string): SourceControlProviderAuth { | ||
| 156 | return providerAuth({ status: "unknown", detail }); | ||
| 157 | } | ||
| 158 | |||
| 159 | function combinedAuthOutput(input: AuthProbeInput): string { | ||
| 160 | return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n"); | ||
| 161 | } | ||
| 162 | |||
| 163 | function sanitizedAuthLines(text: string): ReadonlyArray<string> { | ||
| 164 | return text | ||
| 165 | .split(/\r?\n/) | ||
| 166 | .map((entry) => entry.trim()) | ||
| 167 | .filter((entry) => entry.length > 0) | ||
| 168 | .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry)); | ||
| 169 | } | ||
| 170 | |||
| 171 | function firstSafeAuthLine(text: string): string | undefined { | ||
| 172 | return sanitizedAuthLines(text)[0]; | ||
| 173 | } | ||
| 174 | |||
| 175 | function parseCliHost(text: string): string | undefined { | ||
| 176 | return sanitizedAuthLines(text) | ||
| 177 | .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) | ||
| 178 | .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); | ||
| 179 | } | ||
| 180 | |||
| 181 | function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined { | ||
| 182 | for (const pattern of patterns) { | ||
| 183 | const match = pattern.exec(text); | ||
| 184 | const value = match?.[1]?.trim(); | ||
| 185 | if (value && value.length > 0) return value; | ||
| 186 | } | ||
| 187 | return undefined; | ||
| 188 | } | ||
| 189 | |||
| 190 | function parseGitHubAuth(input: AuthProbeInput): SourceControlProviderAuth { | ||
| 191 | const output = combinedAuthOutput(input); | ||
| 192 | const account = matchFirst(output, [ | ||
| 193 | /Logged in to .* account\s+([^\s(]+)/iu, | ||
| 194 | /Logged in to .* as\s+([^\s(]+)/iu, | ||
| 195 | ]); | ||
| 196 | const host = parseCliHost(output); | ||
| 197 | |||
| 198 | if (input.exitCode !== 0) { | ||
| 199 | return providerAuth({ | ||
| 200 | status: "unauthenticated", | ||
| 201 | host, | ||
| 202 | detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", | ||
| 203 | }); | ||
| 204 | } | ||
| 205 | |||
| 206 | if (account) { | ||
| 207 | return providerAuth({ status: "authenticated", account, host }); | ||
| 208 | } | ||
| 209 | |||
| 210 | return providerAuth({ | ||
| 211 | status: "unknown", | ||
| 212 | host, | ||
| 213 | detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", | ||
| 214 | }); | ||
| 215 | } | ||
| 216 | |||
| 217 | function parseGitLabAuth(input: AuthProbeInput): SourceControlProviderAuth { | ||
| 218 | const output = combinedAuthOutput(input); | ||
| 219 | const account = matchFirst(output, [ | ||
| 220 | /Logged in to .* as\s+([^\s(]+)/iu, | ||
| 221 | /Logged in to .* account\s+([^\s(]+)/iu, | ||
| 222 | /account:\s*([^\s(]+)/iu, | ||
| 223 | ]); | ||
| 224 | const host = parseCliHost(output); | ||
| 225 | |||
| 226 | if (input.exitCode !== 0) { | ||
| 227 | return providerAuth({ | ||
| 228 | status: "unauthenticated", | ||
| 229 | host, | ||
| 230 | detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", | ||
| 231 | }); | ||
| 232 | } | ||
| 233 | |||
| 234 | if (account) { | ||
| 235 | return providerAuth({ status: "authenticated", account, host }); | ||
| 236 | } | ||
| 237 | |||
| 238 | return providerAuth({ | ||
| 239 | status: "unknown", | ||
| 240 | host, | ||
| 241 | detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", | ||
| 242 | }); | ||
| 243 | } | ||
| 244 | |||
| 245 | function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { | ||
| 246 | const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); | ||
| 247 | |||
| 248 | if (input.exitCode !== 0) { | ||
| 249 | return providerAuth({ | ||
| 250 | status: "unauthenticated", | ||
| 251 | detail: | ||
| 252 | firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", | ||
| 253 | }); | ||
| 254 | } | ||
| 255 | |||
| 256 | if (account && account.length > 0) { | ||
| 257 | return providerAuth({ status: "authenticated", account, host: "dev.azure.com" }); | ||
| 258 | } | ||
| 259 | |||
| 260 | return providerAuth({ | ||
| 261 | status: "unknown", | ||
| 262 | host: "dev.azure.com", | ||
| 263 | detail: "Azure CLI account status could not be parsed.", | ||
| 264 | }); | ||
| 265 | } | ||
| 266 | |||
| 267 | export interface SourceControlDiscoveryShape { | 57 | export interface SourceControlDiscoveryShape { |
| 268 | readonly discover: Effect.Effect<SourceControlDiscoveryResult>; | 58 | readonly discover: Effect.Effect<SourceControlDiscoveryResult>; |
| 269 | } | 59 | } |
| @@ -278,8 +68,10 @@ export const layer = Layer.effect( · 8 unmodified lines | |||
| 278 | Effect.gen(function* () { | 68 | Effect.gen(function* () { |
| 279 | const config = yield* ServerConfig; | 69 | const config = yield* ServerConfig; |
| 280 | const process = yield* VcsProcess.VcsProcess; | 70 | const process = yield* VcsProcess.VcsProcess; |
| 71 | const sourceControlProviders = | ||
| 72 | yield* SourceControlProviderRegistry.SourceControlProviderRegistry; | ||
| 281 | 73 | ||
| 282 | const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>( | 74 | const probe = <Kind extends VcsDriverKind>( |
| 283 | input: DiscoveryProbe & { readonly kind: Kind }, | 75 | input: DiscoveryProbe & { readonly kind: Kind }, |
| 284 | ): Effect.Effect<DiscoveryProbeResult<Kind>> => { | 76 | ): Effect.Effect<DiscoveryProbeResult<Kind>> => { |
| 285 | const executable = input.executable; | 77 | const executable = input.executable; |
| @@ -316,8 +108,9 @@ export const layer = Layer.effect( · 30 unmodified lines | |||
| 316 | executable, | 108 | executable, |
| 317 | implemented: input.implemented, | 109 | implemented: input.implemented, |
| 318 | status: "available" as const, | 110 | status: "available" as const, |
| 319 | version: Option.orElse(firstNonEmptyLine(result.stdout), () => | 111 | version: Option.orElse( |
| 320 | firstNonEmptyLine(result.stderr), | 112 | SourceControlProviderDiscovery.firstNonEmptyLine(result.stdout), |
| 113 | () => SourceControlProviderDiscovery.firstNonEmptyLine(result.stderr), | ||
| 321 | ), | 114 | ), |
| 322 | installHint: input.installHint, | 115 | installHint: input.installHint, |
| 323 | detail: Option.none<string>(), | 116 | detail: Option.none<string>(), |
| @@ -332,74 +125,19 @@ export const layer = Layer.effect( · 8 unmodified lines | |||
| 332 | status: "missing" as const, | 125 | status: "missing" as const, |
| 333 | version: Option.none<string>(), | 126 | version: Option.none<string>(), |
| 334 | installHint: input.installHint, | 127 | installHint: input.installHint, |
| 335 | detail: detailFromCause(cause), | 128 | detail: SourceControlProviderDiscovery.detailFromCause(cause), |
| 336 | } satisfies DiscoveryProbeResult<Kind>), | 129 | } satisfies DiscoveryProbeResult<Kind>), |
| 337 | ), | 130 | ), |
| 338 | ); | 131 | ); |
| 339 | }; | 132 | }; |
| 340 | 133 | ||
| 341 | const probeProvider = (input: ProviderProbe) => | ||
| 342 | probe(input).pipe( | ||
| 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 | |||
| 355 | if (item.status !== "available") { | ||
| 356 | return Effect.succeed({ | ||
| 357 | ...item, | ||
| 358 | auth: unknownAuth("CLI is not installed."), | ||
| 359 | } satisfies SourceControlProviderDiscoveryItem); | ||
| 360 | } | ||
| 361 | |||
| 362 | return process | ||
| 363 | .run({ | ||
| 364 | operation: "source-control.discovery.auth", | ||
| 365 | command: executable, | ||
| 366 | args: authArgs, | ||
| 367 | cwd: config.cwd, | ||
| 368 | allowNonZeroExit: true, | ||
| 369 | timeoutMs: 5_000, | ||
| 370 | maxOutputBytes: 8_000, | ||
| 371 | truncateOutputAtMaxBytes: true, | ||
| 372 | }) | ||
| 373 | .pipe( | ||
| 374 | Effect.map( | ||
| 375 | (result) => | ||
| 376 | ({ | ||
| 377 | ...item, | ||
| 378 | auth: parseAuth(result), | ||
| 379 | }) satisfies SourceControlProviderDiscoveryItem, | ||
| 380 | ), | ||
| 381 | Effect.catch((cause) => | ||
| 382 | Effect.succeed({ | ||
| 383 | ...item, | ||
| 384 | auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), | ||
| 385 | } satisfies SourceControlProviderDiscoveryItem), | ||
| 386 | ), | ||
| 387 | ); | ||
| 388 | }), | ||
| 389 | ); | ||
| 390 | |||
| 391 | return SourceControlDiscovery.of({ | 134 | return SourceControlDiscovery.of({ |
| 392 | discover: Effect.all({ | 135 | discover: Effect.all({ |
| 393 | versionControlSystems: Effect.all( | 136 | versionControlSystems: Effect.all( |
| 394 | VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>, | 137 | VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>, |
| 395 | { concurrency: "unbounded" }, | 138 | { concurrency: "unbounded" }, |
| 396 | ), | 139 | ), |
| 397 | sourceControlProviders: Effect.all( | 140 | sourceControlProviders: sourceControlProviders.discover, |
| 398 | SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray< | ||
| 399 | Effect.Effect<SourceControlProviderDiscoveryItem> | ||
| 400 | >, | ||
| 401 | { concurrency: "unbounded" }, | ||
| 402 | ), | ||
| 403 | }), | 141 | }), |
| 404 | }); | 142 | }); |
| 405 | }), | 143 | }), |
| @@ -21,6 +21,33 @@ export interface SourceControlRefSelector { · 20 unmodified lines | |||
| 21 | readonly repository?: string; | 21 | readonly repository?: string; |
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | export function parseSourceControlOwnerRef( | ||
| 25 | headSelector: string, | ||
| 26 | ): SourceControlRefSelector | undefined { | ||
| 27 | const match = /^([^:/\s]+):(.+)$/u.exec(headSelector.trim()); | ||
| 28 | const owner = match?.[1]?.trim(); | ||
| 29 | const refName = match?.[2]?.trim(); | ||
| 30 | return owner && refName ? { owner, refName } : undefined; | ||
| 31 | } | ||
| 32 | |||
| 33 | export function normalizeSourceBranch(headSelector: string): string { | ||
| 34 | return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim(); | ||
| 35 | } | ||
| 36 | |||
| 37 | export function sourceBranch(input: { | ||
| 38 | readonly headSelector: string; | ||
| 39 | readonly source?: SourceControlRefSelector; | ||
| 40 | }): string { | ||
| 41 | return input.source?.refName ?? normalizeSourceBranch(input.headSelector); | ||
| 42 | } | ||
| 43 | |||
| 44 | export function sourceControlRefFromInput(input: { | ||
| 45 | readonly headSelector: string; | ||
| 46 | readonly source?: SourceControlRefSelector; | ||
| 47 | }): SourceControlRefSelector | undefined { | ||
| 48 | return input.source ?? parseSourceControlOwnerRef(input.headSelector); | ||
| 49 | } | ||
| 50 | |||
| 24 | export interface SourceControlProviderShape { | 51 | export interface SourceControlProviderShape { |
| 25 | readonly kind: SourceControlProviderKind; | 52 | readonly kind: SourceControlProviderKind; |
| 26 | readonly listChangeRequests: (input: { | 53 | readonly listChangeRequests: (input: { |
| @@ -0,0 +1,237 @@ | |||
| 1 | import type { | ||
| 2 | SourceControlProviderAuth, | ||
| 3 | SourceControlProviderDiscoveryItem, | ||
| 4 | SourceControlProviderKind, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { Effect, Option } from "effect"; | ||
| 7 | |||
| 8 | import type * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 9 | |||
| 10 | export interface SourceControlAuthProbeInput { | ||
| 11 | readonly stdout: string; | ||
| 12 | readonly stderr: string; | ||
| 13 | readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; | ||
| 14 | } | ||
| 15 | |||
| 16 | interface SourceControlDiscoverySpecBase { | ||
| 17 | readonly kind: SourceControlProviderKind; | ||
| 18 | readonly label: string; | ||
| 19 | readonly installHint: string; | ||
| 20 | } | ||
| 21 | |||
| 22 | export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { | ||
| 23 | readonly type: "cli"; | ||
| 24 | readonly executable: string; | ||
| 25 | readonly versionArgs: ReadonlyArray<string>; | ||
| 26 | readonly authArgs: ReadonlyArray<string>; | ||
| 27 | readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; | ||
| 28 | }; | ||
| 29 | |||
| 30 | export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { | ||
| 31 | readonly type: "api"; | ||
| 32 | readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>; | ||
| 33 | }; | ||
| 34 | |||
| 35 | export type SourceControlProviderDiscoverySpec = | ||
| 36 | | SourceControlCliDiscoverySpec | ||
| 37 | | SourceControlApiDiscoverySpec; | ||
| 38 | |||
| 39 | interface DiscoveryProbeResult { | ||
| 40 | readonly kind: SourceControlProviderKind; | ||
| 41 | readonly label: string; | ||
| 42 | readonly executable: string; | ||
| 43 | readonly status: "available" | "missing"; | ||
| 44 | readonly version: Option.Option<string>; | ||
| 45 | readonly installHint: string; | ||
| 46 | readonly detail: Option.Option<string>; | ||
| 47 | } | ||
| 48 | |||
| 49 | export function firstNonEmptyLine(text: string): Option.Option<string> { | ||
| 50 | const line = text | ||
| 51 | .split(/\r?\n/) | ||
| 52 | .map((entry) => entry.trim()) | ||
| 53 | .find((entry) => entry.length > 0); | ||
| 54 | return line === undefined ? Option.none() : Option.some(line); | ||
| 55 | } | ||
| 56 | |||
| 57 | export function detailFromCause(cause: unknown): Option.Option<string> { | ||
| 58 | if (cause instanceof Error && cause.message.trim().length > 0) { | ||
| 59 | return Option.some(cause.message.trim()); | ||
| 60 | } | ||
| 61 | return Option.none(); | ||
| 62 | } | ||
| 63 | |||
| 64 | function authAccount(account: string | undefined): Option.Option<string> { | ||
| 65 | const trimmed = account?.trim(); | ||
| 66 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 67 | } | ||
| 68 | |||
| 69 | function authHost(host: string | undefined): Option.Option<string> { | ||
| 70 | const trimmed = host?.trim(); | ||
| 71 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 72 | } | ||
| 73 | |||
| 74 | function authDetail(detail: string | undefined): Option.Option<string> { | ||
| 75 | const trimmed = detail?.trim(); | ||
| 76 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 77 | } | ||
| 78 | |||
| 79 | export function providerAuth(input: { | ||
| 80 | readonly status: SourceControlProviderAuth["status"]; | ||
| 81 | readonly account?: string | undefined; | ||
| 82 | readonly host?: string | undefined; | ||
| 83 | readonly detail?: string | undefined; | ||
| 84 | }): SourceControlProviderAuth { | ||
| 85 | return { | ||
| 86 | status: input.status, | ||
| 87 | account: authAccount(input.account), | ||
| 88 | host: authHost(input.host), | ||
| 89 | detail: authDetail(input.detail), | ||
| 90 | }; | ||
| 91 | } | ||
| 92 | |||
| 93 | export function unknownAuth(detail?: string): SourceControlProviderAuth { | ||
| 94 | return providerAuth({ status: "unknown", detail }); | ||
| 95 | } | ||
| 96 | |||
| 97 | export function combinedAuthOutput(input: SourceControlAuthProbeInput): string { | ||
| 98 | return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n"); | ||
| 99 | } | ||
| 100 | |||
| 101 | function sanitizedAuthLines(text: string): ReadonlyArray<string> { | ||
| 102 | return text | ||
| 103 | .split(/\r?\n/) | ||
| 104 | .map((entry) => entry.trim()) | ||
| 105 | .filter((entry) => entry.length > 0) | ||
| 106 | .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry)); | ||
| 107 | } | ||
| 108 | |||
| 109 | export function firstSafeAuthLine(text: string): string | undefined { | ||
| 110 | return sanitizedAuthLines(text)[0]; | ||
| 111 | } | ||
| 112 | |||
| 113 | export function parseCliHost(text: string): string | undefined { | ||
| 114 | return sanitizedAuthLines(text) | ||
| 115 | .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) | ||
| 116 | .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); | ||
| 117 | } | ||
| 118 | |||
| 119 | export function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined { | ||
| 120 | for (const pattern of patterns) { | ||
| 121 | const match = pattern.exec(text); | ||
| 122 | const value = match?.[1]?.trim(); | ||
| 123 | if (value && value.length > 0) return value; | ||
| 124 | } | ||
| 125 | return undefined; | ||
| 126 | } | ||
| 127 | |||
| 128 | function probeCli(input: { | ||
| 129 | readonly spec: SourceControlCliDiscoverySpec; | ||
| 130 | readonly process: VcsProcess.VcsProcessShape; | ||
| 131 | readonly cwd: string; | ||
| 132 | }): Effect.Effect<DiscoveryProbeResult> { | ||
| 133 | return input.process | ||
| 134 | .run({ | ||
| 135 | operation: "source-control.discovery.probe", | ||
| 136 | command: input.spec.executable, | ||
| 137 | args: input.spec.versionArgs, | ||
| 138 | cwd: input.cwd, | ||
| 139 | timeoutMs: 5_000, | ||
| 140 | maxOutputBytes: 8_000, | ||
| 141 | truncateOutputAtMaxBytes: true, | ||
| 142 | }) | ||
| 143 | .pipe( | ||
| 144 | Effect.map( | ||
| 145 | (result) => | ||
| 146 | ({ | ||
| 147 | kind: input.spec.kind, | ||
| 148 | label: input.spec.label, | ||
| 149 | executable: input.spec.executable, | ||
| 150 | status: "available" as const, | ||
| 151 | version: Option.orElse(firstNonEmptyLine(result.stdout), () => | ||
| 152 | firstNonEmptyLine(result.stderr), | ||
| 153 | ), | ||
| 154 | installHint: input.spec.installHint, | ||
| 155 | detail: Option.none<string>(), | ||
| 156 | }) satisfies DiscoveryProbeResult, | ||
| 157 | ), | ||
| 158 | Effect.catch((cause) => | ||
| 159 | Effect.succeed({ | ||
| 160 | kind: input.spec.kind, | ||
| 161 | label: input.spec.label, | ||
| 162 | executable: input.spec.executable, | ||
| 163 | status: "missing" as const, | ||
| 164 | version: Option.none<string>(), | ||
| 165 | installHint: input.spec.installHint, | ||
| 166 | detail: detailFromCause(cause), | ||
| 167 | } satisfies DiscoveryProbeResult), | ||
| 168 | ), | ||
| 169 | ); | ||
| 170 | } | ||
| 171 | |||
| 172 | export function probeSourceControlProvider(input: { | ||
| 173 | readonly spec: SourceControlProviderDiscoverySpec; | ||
| 174 | readonly process: VcsProcess.VcsProcessShape; | ||
| 175 | readonly cwd: string; | ||
| 176 | }): Effect.Effect<SourceControlProviderDiscoveryItem> { | ||
| 177 | if (input.spec.type === "api") { | ||
| 178 | return input.spec.probeAuth.pipe( | ||
| 179 | Effect.map( | ||
| 180 | (auth) => | ||
| 181 | ({ | ||
| 182 | kind: input.spec.kind, | ||
| 183 | label: input.spec.label, | ||
| 184 | status: "available" as const, | ||
| 185 | version: Option.none<string>(), | ||
| 186 | installHint: input.spec.installHint, | ||
| 187 | detail: Option.none<string>(), | ||
| 188 | auth, | ||
| 189 | }) satisfies SourceControlProviderDiscoveryItem, | ||
| 190 | ), | ||
| 191 | ); | ||
| 192 | } | ||
| 193 | |||
| 194 | const spec = input.spec; | ||
| 195 | |||
| 196 | return probeCli({ | ||
| 197 | spec, | ||
| 198 | process: input.process, | ||
| 199 | cwd: input.cwd, | ||
| 200 | }).pipe( | ||
| 201 | Effect.flatMap((item) => { | ||
| 202 | if (item.status !== "available") { | ||
| 203 | return Effect.succeed({ | ||
| 204 | ...item, | ||
| 205 | auth: unknownAuth("Hosting integration command was not found on the server PATH."), | ||
| 206 | } satisfies SourceControlProviderDiscoveryItem); | ||
| 207 | } | ||
| 208 | |||
| 209 | return input.process | ||
| 210 | .run({ | ||
| 211 | operation: "source-control.discovery.auth", | ||
| 212 | command: spec.executable, | ||
| 213 | args: spec.authArgs, | ||
| 214 | cwd: input.cwd, | ||
| 215 | allowNonZeroExit: true, | ||
| 216 | timeoutMs: 5_000, | ||
| 217 | maxOutputBytes: 8_000, | ||
| 218 | truncateOutputAtMaxBytes: true, | ||
| 219 | }) | ||
| 220 | .pipe( | ||
| 221 | Effect.map( | ||
| 222 | (result) => | ||
| 223 | ({ | ||
| 224 | ...item, | ||
| 225 | auth: spec.parseAuth(result), | ||
| 226 | }) satisfies SourceControlProviderDiscoveryItem, | ||
| 227 | ), | ||
| 228 | Effect.catch((cause) => | ||
| 229 | Effect.succeed({ | ||
| 230 | ...item, | ||
| 231 | auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), | ||
| 232 | } satisfies SourceControlProviderDiscoveryItem), | ||
| 233 | ), | ||
| 234 | ); | ||
| 235 | }), | ||
| 236 | ); | ||
| 237 | } | ||
| @@ -1,11 +1,16 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { DateTime, Effect, Layer, Option } from "effect"; | 3 | import { DateTime, Effect, Layer, Option } from "effect"; |
| 3 | 4 | ||
| 4 | import { GitHubCli } from "./GitHubCli.ts"; | 5 | import { ServerConfig } from "../config.ts"; |
| 5 | import { GitLabCli } from "./GitLabCli.ts"; | 6 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; |
| 7 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 9 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | ||
| 10 | import * as BitbucketApi from "./BitbucketApi.ts"; | ||
| 11 | import * as GitHubCli from "./GitHubCli.ts"; | ||
| 12 | import * as GitLabCli from "./GitLabCli.ts"; | ||
| 6 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | 13 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 7 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; | ||
| 8 | import type { VcsDriverShape } from "../vcs/VcsDriver.ts"; | ||
| 9 | 14 | ||
| 10 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | 15 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); |
| 11 | 16 | ||
| @@ -29,10 +34,10 @@ function makeRegistry(input: { · 17 unmodified lines | |||
| 29 | expiresAt: Option.none(), | 34 | expiresAt: Option.none(), |
| 30 | }, | 35 | }, |
| 31 | }), | 36 | }), |
| 32 | } satisfies Partial<VcsDriverShape>; | 37 | } satisfies Partial<VcsDriver.VcsDriverShape>; |
| 33 | 38 | ||
| 34 | const registryLayer = Layer.mock(VcsDriverRegistry)({ | 39 | const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ |
| 35 | get: () => Effect.succeed(driver as unknown as VcsDriverShape), | 40 | get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriverShape), |
| 36 | resolve: () => | 41 | resolve: () => |
| 37 | Effect.succeed({ | 42 | Effect.succeed({ |
| 38 | kind: "git", | 43 | kind: "git", |
| @@ -46,13 +51,23 @@ function makeRegistry(input: { · 7 unmodified lines | |||
| 46 | expiresAt: Option.none(), | 51 | expiresAt: Option.none(), |
| 47 | }, | 52 | }, |
| 48 | }, | 53 | }, |
| 49 | driver: driver as unknown as VcsDriverShape, | 54 | driver: driver as unknown as VcsDriver.VcsDriverShape, |
| 50 | }), | 55 | }), |
| 51 | }); | 56 | }); |
| 52 | 57 | ||
| 53 | return SourceControlProviderRegistry.make().pipe( | 58 | return SourceControlProviderRegistry.make().pipe( |
| 54 | Effect.provide( | 59 | Effect.provide( |
| 55 | Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({})), | 60 | Layer.mergeAll( |
| 61 | registryLayer, | ||
| 62 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), | ||
| 63 | Layer.mock(BitbucketApi.BitbucketApi)({}), | ||
| 64 | Layer.mock(GitHubCli.GitHubCli)({}), | ||
| 65 | Layer.mock(GitLabCli.GitLabCli)({}), | ||
| 66 | Layer.mock(VcsProcess.VcsProcess)({}), | ||
| 67 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( | ||
| 68 | Layer.provide(NodeServices.layer), | ||
| 69 | ), | ||
| 70 | ), | ||
| 56 | ), | 71 | ), |
| 57 | ); | 72 | ); |
| 58 | } | 73 | } |
| @@ -93,6 +108,30 @@ it.effect("routes GitLab remotes to the GitLab provider", () => · 34 unmodified lines | |||
| 93 | }), | 108 | }), |
| 94 | ); | 109 | ); |
| 95 | 110 | ||
| 111 | it.effect("routes Bitbucket remotes to the Bitbucket provider", () => | ||
| 112 | Effect.gen(function* () { | ||
| 113 | const registry = yield* makeRegistry({ | ||
| 114 | remotes: [{ name: "origin", url: "git@bitbucket.org:pingdotgg/t3code.git" }], | ||
| 115 | }); | ||
| 116 | |||
| 117 | const provider = yield* registry.resolve({ cwd: "/repo" }); | ||
| 118 | |||
| 119 | assert.strictEqual(provider.kind, "bitbucket"); | ||
| 120 | }), | ||
| 121 | ); | ||
| 122 | |||
| 123 | it.effect("routes Azure DevOps remotes to the Azure DevOps provider", () => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const registry = yield* makeRegistry({ | ||
| 126 | remotes: [{ name: "origin", url: "https://dev.azure.com/acme/project/_git/repo" }], | ||
| 127 | }); | ||
| 128 | |||
| 129 | const provider = yield* registry.resolve({ cwd: "/repo" }); | ||
| 130 | |||
| 131 | assert.strictEqual(provider.kind, "azure-devops"); | ||
| 132 | }), | ||
| 133 | ); | ||
| 134 | |||
| 96 | it.effect("falls back to a non-origin remote when origin is not configured", () => | 135 | it.effect("falls back to a non-origin remote when origin is not configured", () => |
| 97 | Effect.gen(function* () { | 136 | Effect.gen(function* () { |
| 98 | const registry = yield* makeRegistry({ | 137 | const registry = yield* makeRegistry({ |
| @@ -1,40 +1,46 @@ | |||
| 1 | import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; | 1 | import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; |
| 2 | import { SourceControlProviderError } from "@t3tools/contracts"; | 2 | import { |
| 3 | SourceControlProviderError, | ||
| 4 | type SourceControlProviderDiscoveryItem, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 3 | import type { SourceControlProviderKind } from "@t3tools/contracts"; | 6 | import type { SourceControlProviderKind } from "@t3tools/contracts"; |
| 4 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; | 7 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 5 | 8 | ||
| 6 | import { | 9 | import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; |
| 7 | SourceControlProvider, | 10 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; |
| 8 | type SourceControlProviderContext, | ||
| 9 | type SourceControlProviderShape, | ||
| 10 | } from "./SourceControlProvider.ts"; | ||
| 11 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | 11 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 12 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; | 12 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; |
| 13 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; | 13 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 14 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | ||
| 15 | import { ServerConfig } from "../config.ts"; | ||
| 16 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 17 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 14 | 18 | ||
| 15 | const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; | 19 | const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; |
| 16 | const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); | 20 | const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); |
| 17 | 21 | ||
| 18 | export interface SourceControlProviderRegistration { | 22 | export interface SourceControlProviderRegistration { |
| 19 | readonly kind: SourceControlProviderKind; | 23 | readonly kind: SourceControlProviderKind; |
| 20 | readonly provider: SourceControlProviderShape; | 24 | readonly provider: SourceControlProvider.SourceControlProviderShape; |
| 25 | readonly discovery: SourceControlProviderDiscovery.SourceControlProviderDiscoverySpec; | ||
| 21 | } | 26 | } |
| 22 | 27 | ||
| 23 | export interface SourceControlProviderHandle { | 28 | export interface SourceControlProviderHandle { |
| 24 | readonly provider: SourceControlProviderShape; | 29 | readonly provider: SourceControlProvider.SourceControlProviderShape; |
| 25 | readonly context: SourceControlProviderContext | null; | 30 | readonly context: SourceControlProvider.SourceControlProviderContext | null; |
| 26 | } | 31 | } |
| 27 | 32 | ||
| 28 | export interface SourceControlProviderRegistryShape { | 33 | export interface SourceControlProviderRegistryShape { |
| 29 | readonly get: ( | 34 | readonly get: ( |
| 30 | kind: SourceControlProviderKind, | 35 | kind: SourceControlProviderKind, |
| 31 | ) => Effect.Effect<SourceControlProviderShape, SourceControlProviderError>; | 36 | ) => Effect.Effect<SourceControlProvider.SourceControlProviderShape, SourceControlProviderError>; |
| 32 | readonly resolveHandle: (input: { | 37 | readonly resolveHandle: (input: { |
| 33 | readonly cwd: string; | 38 | readonly cwd: string; |
| 34 | }) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>; | 39 | }) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>; |
| 35 | readonly resolve: (input: { | 40 | readonly resolve: (input: { |
| 36 | readonly cwd: string; | 41 | readonly cwd: string; |
| 37 | }) => Effect.Effect<SourceControlProviderShape, SourceControlProviderError>; | 42 | }) => Effect.Effect<SourceControlProvider.SourceControlProviderShape, SourceControlProviderError>; |
| 43 | readonly discover: Effect.Effect<ReadonlyArray<SourceControlProviderDiscoveryItem>>; | ||
| 38 | } | 44 | } |
| 39 | 45 | ||
| 40 | export class SourceControlProviderRegistry extends Context.Service< | 46 | export class SourceControlProviderRegistry extends Context.Service< |
| @@ -42,7 +48,9 @@ export class SourceControlProviderRegistry extends Context.Service< · 1 unmodified line | |||
| 42 | SourceControlProviderRegistryShape | 48 | SourceControlProviderRegistryShape |
| 43 | >()("t3/source-control/SourceControlProviderRegistry") {} | 49 | >()("t3/source-control/SourceControlProviderRegistry") {} |
| 44 | 50 | ||
| 45 | function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProviderShape { | 51 | function unsupportedProvider( |
| 52 | kind: SourceControlProviderKind, | ||
| 53 | ): SourceControlProvider.SourceControlProviderShape { | ||
| 46 | const unsupported = (operation: string) => | 54 | const unsupported = (operation: string) => |
| 47 | Effect.fail( | 55 | Effect.fail( |
| 48 | new SourceControlProviderError({ | 56 | new SourceControlProviderError({ |
| @@ -52,7 +60,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv · 3 unmodified lines | |||
| 52 | }), | 60 | }), |
| 53 | ); | 61 | ); |
| 54 | 62 | ||
| 55 | return SourceControlProvider.of({ | 63 | return SourceControlProvider.SourceControlProvider.of({ |
| 56 | kind, | 64 | kind, |
| 57 | listChangeRequests: () => unsupported("listChangeRequests"), | 65 | listChangeRequests: () => unsupported("listChangeRequests"), |
| 58 | getChangeRequest: () => unsupported("getChangeRequest"), | 66 | getChangeRequest: () => unsupported("getChangeRequest"), |
| @@ -78,7 +86,7 @@ function selectProviderContext( · 19 unmodified lines | |||
| 78 | readonly name: string; | 86 | readonly name: string; |
| 79 | readonly url: string; | 87 | readonly url: string; |
| 80 | }>, | 88 | }>, |
| 81 | ): SourceControlProviderContext | null { | 89 | ): SourceControlProvider.SourceControlProviderContext | null { |
| 82 | const candidates = remotes | 90 | const candidates = remotes |
| 83 | .map((remote) => { | 91 | .map((remote) => { |
| 84 | const provider = detectSourceControlProviderFromRemoteUrl(remote.url); | 92 | const provider = detectSourceControlProviderFromRemoteUrl(remote.url); |
| @@ -90,7 +98,7 @@ function selectProviderContext( · 5 unmodified lines | |||
| 90 | } | 98 | } |
| 91 | : null; | 99 | : null; |
| 92 | }) | 100 | }) |
| 93 | .filter((value): value is SourceControlProviderContext => value !== null); | 101 | .filter((value): value is SourceControlProvider.SourceControlProviderContext => value !== null); |
| 94 | 102 | ||
| 95 | return ( | 103 | return ( |
| 96 | candidates.find((candidate) => candidate.remoteName === "origin") ?? | 104 | candidates.find((candidate) => candidate.remoteName === "origin") ?? |
| @@ -100,12 +108,60 @@ function selectProviderContext( · 3 unmodified lines | |||
| 100 | ); | 108 | ); |
| 101 | } | 109 | } |
| 102 | 110 | ||
| 111 | function bindProviderContext( | ||
| 112 | provider: SourceControlProvider.SourceControlProviderShape, | ||
| 113 | context: SourceControlProvider.SourceControlProviderContext | null, | ||
| 114 | ): SourceControlProvider.SourceControlProviderShape { | ||
| 115 | if (context === null) { | ||
| 116 | return provider; | ||
| 117 | } | ||
| 118 | |||
| 119 | return SourceControlProvider.SourceControlProvider.of({ | ||
| 120 | kind: provider.kind, | ||
| 121 | listChangeRequests: (input) => | ||
| 122 | provider.listChangeRequests({ | ||
| 123 | ...input, | ||
| 124 | context: input.context ?? context, | ||
| 125 | }), | ||
| 126 | getChangeRequest: (input) => | ||
| 127 | provider.getChangeRequest({ | ||
| 128 | ...input, | ||
| 129 | context: input.context ?? context, | ||
| 130 | }), | ||
| 131 | createChangeRequest: (input) => | ||
| 132 | provider.createChangeRequest({ | ||
| 133 | ...input, | ||
| 134 | context: input.context ?? context, | ||
| 135 | }), | ||
| 136 | getRepositoryCloneUrls: (input) => | ||
| 137 | provider.getRepositoryCloneUrls({ | ||
| 138 | ...input, | ||
| 139 | context: input.context ?? context, | ||
| 140 | }), | ||
| 141 | createRepository: (input) => provider.createRepository(input), | ||
| 142 | getDefaultBranch: (input) => | ||
| 143 | provider.getDefaultBranch({ | ||
| 144 | ...input, | ||
| 145 | context: input.context ?? context, | ||
| 146 | }), | ||
| 147 | checkoutChangeRequest: (input) => | ||
| 148 | provider.checkoutChangeRequest({ | ||
| 149 | ...input, | ||
| 150 | context: input.context ?? context, | ||
| 151 | }), | ||
| 152 | }); | ||
| 153 | } | ||
| 154 | |||
| 103 | export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( | 155 | export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( |
| 104 | function* (registrations: ReadonlyArray<SourceControlProviderRegistration>) { | 156 | function* (registrations: ReadonlyArray<SourceControlProviderRegistration>) { |
| 105 | const vcsRegistry = yield* VcsDriverRegistry; | 157 | const config = yield* ServerConfig; |
| 106 | const providers = new Map<SourceControlProviderKind, SourceControlProviderShape>( | 158 | const process = yield* VcsProcess.VcsProcess; |
| 107 | registrations.map((registration) => [registration.kind, registration.provider]), | 159 | const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; |
| 108 | ); | 160 | const providers = new Map< |
| 161 | SourceControlProviderKind, | ||
| 162 | SourceControlProvider.SourceControlProviderShape | ||
| 163 | >(registrations.map((registration) => [registration.kind, registration.provider])); | ||
| 164 | const discoverySpecs = registrations.map((registration) => registration.discovery); | ||
| 109 | 165 | ||
| 110 | const get: SourceControlProviderRegistryShape["get"] = (kind) => | 166 | const get: SourceControlProviderRegistryShape["get"] = (kind) => |
| 111 | Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); | 167 | Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); |
| @@ -125,7 +181,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit · 13 unmodified lines | |||
| 125 | 181 | ||
| 126 | const providerContextCache = yield* Cache.makeWith< | 182 | const providerContextCache = yield* Cache.makeWith< |
| 127 | string, | 183 | string, |
| 128 | SourceControlProviderContext | null, | 184 | SourceControlProvider.SourceControlProviderContext | null, |
| 129 | SourceControlProviderError | 185 | SourceControlProviderError |
| 130 | >(detectProviderContext, { | 186 | >(detectProviderContext, { |
| 131 | capacity: PROVIDER_DETECTION_CACHE_CAPACITY, | 187 | capacity: PROVIDER_DETECTION_CACHE_CAPACITY, |
| @@ -136,8 +192,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit · 4 unmodified lines | |||
| 136 | Cache.get(providerContextCache, input.cwd).pipe( | 192 | Cache.get(providerContextCache, input.cwd).pipe( |
| 137 | Effect.map((context) => { | 193 | Effect.map((context) => { |
| 138 | const kind = context?.provider.kind ?? "unknown"; | 194 | const kind = context?.provider.kind ?? "unknown"; |
| 195 | const provider = providers.get(kind) ?? unsupportedProvider(kind); | ||
| 139 | return { | 196 | return { |
| 140 | provider: providers.get(kind) ?? unsupportedProvider(kind), | 197 | provider: bindProviderContext(provider, context), |
| 141 | context, | 198 | context, |
| 142 | } satisfies SourceControlProviderHandle; | 199 | } satisfies SourceControlProviderHandle; |
| 143 | }), | 200 | }), |
| @@ -147,6 +204,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit · 3 unmodified lines | |||
| 147 | get, | 204 | get, |
| 148 | resolveHandle, | 205 | resolveHandle, |
| 149 | resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), | 206 | resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), |
| 207 | discover: Effect.all( | ||
| 208 | discoverySpecs.map((spec) => | ||
| 209 | SourceControlProviderDiscovery.probeSourceControlProvider({ | ||
| 210 | spec, | ||
| 211 | process, | ||
| 212 | cwd: config.cwd, | ||
| 213 | }), | ||
| 214 | ), | ||
| 215 | { concurrency: "unbounded" }, | ||
| 216 | ), | ||
| 150 | }); | 217 | }); |
| 151 | }, | 218 | }, |
| 152 | ); | 219 | ); |
| @@ -154,14 +221,29 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit · 1 unmodified line | |||
| 154 | export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { | 221 | export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { |
| 155 | const github = yield* GitHubSourceControlProvider.make(); | 222 | const github = yield* GitHubSourceControlProvider.make(); |
| 156 | const gitlab = yield* GitLabSourceControlProvider.make(); | 223 | const gitlab = yield* GitLabSourceControlProvider.make(); |
| 224 | const bitbucket = yield* BitbucketSourceControlProvider.make(); | ||
| 225 | const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery(); | ||
| 226 | const azureDevOps = yield* AzureDevOpsSourceControlProvider.make(); | ||
| 157 | return yield* makeWithProviders([ | 227 | return yield* makeWithProviders([ |
| 158 | { | 228 | { |
| 159 | kind: "github", | 229 | kind: "github", |
| 160 | provider: github, | 230 | provider: github, |
| 231 | discovery: GitHubSourceControlProvider.discovery, | ||
| 161 | }, | 232 | }, |
| 162 | { | 233 | { |
| 163 | kind: "gitlab", | 234 | kind: "gitlab", |
| 164 | provider: gitlab, | 235 | provider: gitlab, |
| 236 | discovery: GitLabSourceControlProvider.discovery, | ||
| 237 | }, | ||
| 238 | { | ||
| 239 | kind: "azure-devops", | ||
| 240 | provider: azureDevOps, | ||
| 241 | discovery: AzureDevOpsSourceControlProvider.discovery, | ||
| 242 | }, | ||
| 243 | { | ||
| 244 | kind: "bitbucket", | ||
| 245 | provider: bitbucket, | ||
| 246 | discovery: bitbucketDiscovery, | ||
| 165 | }, | 247 | }, |
| 166 | ]); | 248 | ]); |
| 167 | }); | 249 | }); |
| @@ -6,14 +6,10 @@ import { ChildProcessSpawner } from "effect/unstable/process"; · 5 unmodified lines | |||
| 6 | import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; | 6 | import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; |
| 7 | 7 | ||
| 8 | import { ServerConfig } from "../config.ts"; | 8 | import { ServerConfig } from "../config.ts"; |
| 9 | import { | 9 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 10 | GitVcsDriver, | 10 | import type * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 11 | type ExecuteGitResult, | 11 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 12 | type GitVcsDriverShape, | 12 | import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; |
| 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 | 13 | ||
| 18 | const CLONE_URLS = { | 14 | const CLONE_URLS = { |
| 19 | nameWithOwner: "octocat/t3code", | 15 | nameWithOwner: "octocat/t3code", |
| @@ -22,8 +18,8 @@ const CLONE_URLS = { · 2 unmodified lines | |||
| 22 | }; | 18 | }; |
| 23 | 19 | ||
| 24 | function makeProvider( | 20 | function makeProvider( |
| 25 | overrides: Partial<SourceControlProviderShape> = {}, | 21 | overrides: Partial<SourceControlProvider.SourceControlProviderShape> = {}, |
| 26 | ): SourceControlProviderShape { | 22 | ): SourceControlProvider.SourceControlProviderShape { |
| 27 | const unsupported = (operation: string) => | 23 | const unsupported = (operation: string) => |
| 28 | Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect< | 24 | Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect< |
| 29 | never, | 25 | never, |
| @@ -43,7 +39,7 @@ function makeProvider( · 13 unmodified lines | |||
| 43 | }; | 39 | }; |
| 44 | } | 40 | } |
| 45 | 41 | ||
| 46 | function processOutput(): ExecuteGitResult { | 42 | function processOutput(): GitVcsDriver.ExecuteGitResult { |
| 47 | return { | 43 | return { |
| 48 | exitCode: ChildProcessSpawner.ExitCode(0), | 44 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 49 | stdout: "", | 45 | stdout: "", |
| @@ -54,17 +50,17 @@ function processOutput(): ExecuteGitResult { · 4 unmodified lines | |||
| 54 | } | 50 | } |
| 55 | 51 | ||
| 56 | function makeLayer(input: { | 52 | function makeLayer(input: { |
| 57 | readonly provider?: SourceControlProviderShape; | 53 | readonly provider?: SourceControlProvider.SourceControlProviderShape; |
| 58 | readonly git?: Partial<GitVcsDriverShape>; | 54 | readonly git?: Partial<GitVcsDriver.GitVcsDriverShape>; |
| 59 | }) { | 55 | }) { |
| 60 | return layer.pipe( | 56 | return SourceControlRepositoryService.layer.pipe( |
| 61 | Layer.provide( | 57 | Layer.provide( |
| 62 | Layer.mock(SourceControlProviderRegistry)({ | 58 | Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ |
| 63 | get: () => Effect.succeed(input.provider ?? makeProvider()), | 59 | get: () => Effect.succeed(input.provider ?? makeProvider()), |
| 64 | }), | 60 | }), |
| 65 | ), | 61 | ), |
| 66 | Layer.provide( | 62 | Layer.provide( |
| 67 | Layer.mock(GitVcsDriver)({ | 63 | Layer.mock(GitVcsDriver.GitVcsDriver)({ |
| 68 | execute: () => Effect.succeed(processOutput()), | 64 | execute: () => Effect.succeed(processOutput()), |
| 69 | ensureRemote: () => Effect.succeed("origin"), | 65 | ensureRemote: () => Effect.succeed("origin"), |
| 70 | pushCurrentBranch: () => | 66 | pushCurrentBranch: () => |
| @@ -93,7 +89,7 @@ it.effect("looks up repositories through the requested provider without search", · 22 unmodified lines | |||
| 93 | }); | 89 | }); |
| 94 | 90 | ||
| 95 | return Effect.gen(function* () { | 91 | return Effect.gen(function* () { |
| 96 | const service = yield* SourceControlRepositoryService; | 92 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 97 | const result = yield* service.lookupRepository({ | 93 | const result = yield* service.lookupRepository({ |
| 98 | provider: "github", | 94 | provider: "github", |
| 99 | repository: "octocat/t3code", | 95 | repository: "octocat/t3code", |
| @@ -115,7 +111,7 @@ it.effect("clones a looked-up repository into the requested destination", () => · 15 unmodified lines | |||
| 115 | const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = []; | 111 | const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = []; |
| 116 | 112 | ||
| 117 | yield* Effect.gen(function* () { | 113 | yield* Effect.gen(function* () { |
| 118 | const service = yield* SourceControlRepositoryService; | 114 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 119 | const result = yield* service.cloneRepository({ | 115 | const result = yield* service.cloneRepository({ |
| 120 | provider: "github", | 116 | provider: "github", |
| 121 | repository: "octocat/t3code", | 117 | repository: "octocat/t3code", |
| @@ -167,7 +163,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up · 45 unmodified lines | |||
| 167 | }); | 163 | }); |
| 168 | 164 | ||
| 169 | return Effect.gen(function* () { | 165 | return Effect.gen(function* () { |
| 170 | const service = yield* SourceControlRepositoryService; | 166 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 171 | const result = yield* service.publishRepository({ | 167 | const result = yield* service.publishRepository({ |
| 172 | cwd: "/workspace", | 168 | cwd: "/workspace", |
| 173 | provider: "github", | 169 | provider: "github", |
| @@ -222,7 +218,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { · 48 unmodified lines | |||
| 222 | const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; | 218 | const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; |
| 223 | 219 | ||
| 224 | return Effect.gen(function* () { | 220 | return Effect.gen(function* () { |
| 225 | const service = yield* SourceControlRepositoryService; | 221 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 226 | const result = yield* service.publishRepository({ | 222 | const result = yield* service.publishRepository({ |
| 227 | cwd: "/workspace", | 223 | cwd: "/workspace", |
| 228 | provider: "github", | 224 | provider: "github", |
| @@ -258,7 +254,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { · 29 unmodified lines | |||
| 258 | it.effect("publish succeeds with status remote_added when the local repo has no commits", () => { | 254 | it.effect("publish succeeds with status remote_added when the local repo has no commits", () => { |
| 259 | let pushCalls = 0; | 255 | let pushCalls = 0; |
| 260 | return Effect.gen(function* () { | 256 | return Effect.gen(function* () { |
| 261 | const service = yield* SourceControlRepositoryService; | 257 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 262 | const result = yield* service.publishRepository({ | 258 | const result = yield* service.publishRepository({ |
| 263 | cwd: "/workspace", | 259 | cwd: "/workspace", |
| 264 | provider: "github", | 260 | provider: "github", |
| @@ -1,4 +1,4 @@ | |||
| 1 | import OS from "node:os"; | 1 | import * as NodeOS from "node:os"; |
| 2 | import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect"; | 2 | import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| @@ -15,8 +15,8 @@ import { · 10 unmodified lines | |||
| 15 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 16 | 16 | ||
| 17 | import { ServerConfig } from "../config.ts"; | 17 | import { ServerConfig } from "../config.ts"; |
| 18 | import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; | 18 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 19 | import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; | 19 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 20 | 20 | ||
| 21 | export interface SourceControlRepositoryServiceShape { | 21 | export interface SourceControlRepositoryServiceShape { |
| 22 | readonly lookupRepository: ( | 22 | readonly lookupRepository: ( |
| @@ -102,10 +102,10 @@ function selectRemoteUrl( · 79 unmodified lines | |||
| 102 | 102 | ||
| 103 | function expandHomePath(input: string, path: Path.Path): string { | 103 | function expandHomePath(input: string, path: Path.Path): string { |
| 104 | if (input === "~") { | 104 | if (input === "~") { |
| 105 | return OS.homedir(); | 105 | return NodeOS.homedir(); |
| 106 | } | 106 | } |
| 107 | if (input.startsWith("~/") || input.startsWith("~\\")) { | 107 | if (input.startsWith("~/") || input.startsWith("~\\")) { |
| 108 | return path.join(OS.homedir(), input.slice(2)); | 108 | return path.join(NodeOS.homedir(), input.slice(2)); |
| 109 | } | 109 | } |
| 110 | return input; | 110 | return input; |
| 111 | } | 111 | } |
| @@ -113,9 +113,9 @@ function expandHomePath(input: string, path: Path.Path): string { · 1 unmodified line | |||
| 113 | export const make = Effect.fn("makeSourceControlRepositoryService")(function* () { | 113 | export const make = Effect.fn("makeSourceControlRepositoryService")(function* () { |
| 114 | const config = yield* ServerConfig; | 114 | const config = yield* ServerConfig; |
| 115 | const fileSystem = yield* FileSystem.FileSystem; | 115 | const fileSystem = yield* FileSystem.FileSystem; |
| 116 | const git = yield* GitVcsDriver; | 116 | const git = yield* GitVcsDriver.GitVcsDriver; |
| 117 | const path = yield* Path.Path; | 117 | const path = yield* Path.Path; |
| 118 | const providers = yield* SourceControlProviderRegistry; | 118 | const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; |
| 119 | 119 | ||
| 120 | const ensureConcreteProvider = (input: { | 120 | const ensureConcreteProvider = (input: { |
| 121 | readonly operation: string; | 121 | readonly operation: string; |
| @@ -0,0 +1,106 @@ | |||
| 1 | import { Cause, DateTime, Exit, Option, Result, Schema } from "effect"; | ||
| 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | ||
| 3 | import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; | ||
| 4 | |||
| 5 | export interface NormalizedAzureDevOpsPullRequestRecord { | ||
| 6 | readonly number: number; | ||
| 7 | readonly title: string; | ||
| 8 | readonly url: string; | ||
| 9 | readonly baseRefName: string; | ||
| 10 | readonly headRefName: string; | ||
| 11 | readonly state: "open" | "closed" | "merged"; | ||
| 12 | readonly updatedAt: Option.Option<DateTime.Utc>; | ||
| 13 | } | ||
| 14 | |||
| 15 | const AzureDevOpsPullRequestSchema = Schema.Struct({ | ||
| 16 | pullRequestId: PositiveInt, | ||
| 17 | title: TrimmedNonEmptyString, | ||
| 18 | url: Schema.optional(Schema.String), | ||
| 19 | sourceRefName: TrimmedNonEmptyString, | ||
| 20 | targetRefName: TrimmedNonEmptyString, | ||
| 21 | status: Schema.String, | ||
| 22 | creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | ||
| 23 | closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | ||
| 24 | _links: Schema.optional( | ||
| 25 | Schema.Struct({ | ||
| 26 | web: Schema.optional( | ||
| 27 | Schema.Struct({ | ||
| 28 | href: Schema.String, | ||
| 29 | }), | ||
| 30 | ), | ||
| 31 | }), | ||
| 32 | ), | ||
| 33 | }); | ||
| 34 | |||
| 35 | function trimOptionalString(value: string | null | undefined): string | null { | ||
| 36 | const trimmed = value?.trim() ?? ""; | ||
| 37 | return trimmed.length > 0 ? trimmed : null; | ||
| 38 | } | ||
| 39 | |||
| 40 | function normalizeRefName(refName: string): string { | ||
| 41 | return refName.trim().replace(/^refs\/heads\//, ""); | ||
| 42 | } | ||
| 43 | |||
| 44 | function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" | "merged" { | ||
| 45 | switch (status.trim().toLowerCase()) { | ||
| 46 | case "completed": | ||
| 47 | return "merged"; | ||
| 48 | case "abandoned": | ||
| 49 | return "closed"; | ||
| 50 | default: | ||
| 51 | return "open"; | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | function normalizeAzureDevOpsPullRequestRecord( | ||
| 56 | raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>, | ||
| 57 | ): NormalizedAzureDevOpsPullRequestRecord { | ||
| 58 | return { | ||
| 59 | number: raw.pullRequestId, | ||
| 60 | title: raw.title, | ||
| 61 | url: trimOptionalString(raw._links?.web?.href) ?? trimOptionalString(raw.url) ?? "", | ||
| 62 | baseRefName: normalizeRefName(raw.targetRefName), | ||
| 63 | headRefName: normalizeRefName(raw.sourceRefName), | ||
| 64 | state: normalizeAzureDevOpsPullRequestState(raw.status), | ||
| 65 | updatedAt: (raw.closedDate ?? Option.none()).pipe( | ||
| 66 | Option.orElse(() => raw.creationDate ?? Option.none()), | ||
| 67 | ), | ||
| 68 | }; | ||
| 69 | } | ||
| 70 | |||
| 71 | const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | ||
| 72 | const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema); | ||
| 73 | const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema); | ||
| 74 | |||
| 75 | export const formatAzureDevOpsJsonDecodeError = formatSchemaError; | ||
| 76 | |||
| 77 | export function decodeAzureDevOpsPullRequestListJson( | ||
| 78 | raw: string, | ||
| 79 | ): Result.Result< | ||
| 80 | ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, | ||
| 81 | Cause.Cause<Schema.SchemaError> | ||
| 82 | > { | ||
| 83 | const result = decodeAzureDevOpsPullRequestList(raw); | ||
| 84 | if (Result.isSuccess(result)) { | ||
| 85 | const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = []; | ||
| 86 | for (const entry of result.success) { | ||
| 87 | const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry); | ||
| 88 | if (Exit.isFailure(decodedEntry)) { | ||
| 89 | continue; | ||
| 90 | } | ||
| 91 | pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value)); | ||
| 92 | } | ||
| 93 | return Result.succeed(pullRequests); | ||
| 94 | } | ||
| 95 | return Result.fail(result.failure); | ||
| 96 | } | ||
| 97 | |||
| 98 | export function decodeAzureDevOpsPullRequestJson( | ||
| 99 | raw: string, | ||
| 100 | ): Result.Result<NormalizedAzureDevOpsPullRequestRecord, Cause.Cause<Schema.SchemaError>> { | ||
| 101 | const result = decodeAzureDevOpsPullRequest(raw); | ||
| 102 | if (Result.isSuccess(result)) { | ||
| 103 | return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success)); | ||
| 104 | } | ||
| 105 | return Result.fail(result.failure); | ||
| 106 | } | ||
| @@ -0,0 +1,104 @@ | |||
| 1 | import { DateTime, Option, Schema } from "effect"; | ||
| 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | export interface NormalizedBitbucketPullRequestRecord { | ||
| 5 | readonly number: number; | ||
| 6 | readonly title: string; | ||
| 7 | readonly url: string; | ||
| 8 | readonly baseRefName: string; | ||
| 9 | readonly headRefName: string; | ||
| 10 | readonly state: "open" | "closed" | "merged"; | ||
| 11 | readonly updatedAt: Option.Option<DateTime.Utc>; | ||
| 12 | readonly isCrossRepository?: boolean; | ||
| 13 | readonly headRepositoryNameWithOwner?: string | null; | ||
| 14 | readonly headRepositoryOwnerLogin?: string | null; | ||
| 15 | } | ||
| 16 | |||
| 17 | export const BitbucketRepositoryRefSchema = Schema.Struct({ | ||
| 18 | full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | ||
| 19 | workspace: Schema.optional( | ||
| 20 | Schema.NullOr( | ||
| 21 | Schema.Struct({ | ||
| 22 | slug: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | ||
| 23 | }), | ||
| 24 | ), | ||
| 25 | ), | ||
| 26 | }); | ||
| 27 | |||
| 28 | export const BitbucketPullRequestBranchSchema = Schema.Struct({ | ||
| 29 | repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)), | ||
| 30 | branch: Schema.Struct({ | ||
| 31 | name: TrimmedNonEmptyString, | ||
| 32 | }), | ||
| 33 | }); | ||
| 34 | |||
| 35 | export const BitbucketPullRequestSchema = Schema.Struct({ | ||
| 36 | id: PositiveInt, | ||
| 37 | title: TrimmedNonEmptyString, | ||
| 38 | state: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 39 | updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | ||
| 40 | links: Schema.Struct({ | ||
| 41 | html: Schema.Struct({ | ||
| 42 | href: TrimmedNonEmptyString, | ||
| 43 | }), | ||
| 44 | }), | ||
| 45 | source: BitbucketPullRequestBranchSchema, | ||
| 46 | destination: BitbucketPullRequestBranchSchema, | ||
| 47 | }); | ||
| 48 | |||
| 49 | export const BitbucketPullRequestListSchema = Schema.Struct({ | ||
| 50 | values: Schema.Array(BitbucketPullRequestSchema), | ||
| 51 | next: Schema.optional(TrimmedNonEmptyString), | ||
| 52 | }); | ||
| 53 | |||
| 54 | function trimOptionalString(value: string | null | undefined): string | null { | ||
| 55 | const trimmed = value?.trim() ?? ""; | ||
| 56 | return trimmed.length > 0 ? trimmed : null; | ||
| 57 | } | ||
| 58 | |||
| 59 | function repositoryOwner(repository: Schema.Schema.Type<typeof BitbucketRepositoryRefSchema>) { | ||
| 60 | return ( | ||
| 61 | trimOptionalString(repository.workspace?.slug) ?? | ||
| 62 | (repository.full_name?.includes("/") ? (repository.full_name.split("/")[0] ?? null) : null) | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | |||
| 66 | function normalizeBitbucketPullRequestState(state: string | null | undefined) { | ||
| 67 | switch (state?.trim().toUpperCase()) { | ||
| 68 | case "MERGED": | ||
| 69 | return "merged" as const; | ||
| 70 | case "DECLINED": | ||
| 71 | case "SUPERSEDED": | ||
| 72 | return "closed" as const; | ||
| 73 | case "OPEN": | ||
| 74 | default: | ||
| 75 | return "open" as const; | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | export function normalizeBitbucketPullRequestRecord( | ||
| 80 | raw: Schema.Schema.Type<typeof BitbucketPullRequestSchema>, | ||
| 81 | ): NormalizedBitbucketPullRequestRecord { | ||
| 82 | const headRepositoryNameWithOwner = trimOptionalString(raw.source.repository?.full_name); | ||
| 83 | const baseRepositoryNameWithOwner = trimOptionalString(raw.destination.repository?.full_name); | ||
| 84 | const headRepositoryOwnerLogin = raw.source.repository | ||
| 85 | ? repositoryOwner(raw.source.repository) | ||
| 86 | : null; | ||
| 87 | const isCrossRepository = | ||
| 88 | headRepositoryNameWithOwner !== null && | ||
| 89 | baseRepositoryNameWithOwner !== null && | ||
| 90 | headRepositoryNameWithOwner !== baseRepositoryNameWithOwner; | ||
| 91 | |||
| 92 | return { | ||
| 93 | number: raw.id, | ||
| 94 | title: raw.title, | ||
| 95 | url: raw.links.html.href, | ||
| 96 | baseRefName: raw.destination.branch.name, | ||
| 97 | headRefName: raw.source.branch.name, | ||
| 98 | state: normalizeBitbucketPullRequestState(raw.state), | ||
| 99 | updatedAt: raw.updated_on ?? Option.none(), | ||
| 100 | ...(isCrossRepository ? { isCrossRepository: true } : {}), | ||
| 101 | ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | ||
| 102 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | ||
| 103 | }; | ||
| 104 | } | ||