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 | 2 | import { Effect, Layer } from "effect"; |
| 3 | 3 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | 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 | 7 | import * as GitHubCli from "./GitHubCli.ts"; |
| 9 | 8 | |
| 10 | −const processOutput = (stdout: string): VcsProcessOutput => ({ | |
| 9 | +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ | |
| 11 | 10 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 12 | 11 | stdout, |
| 13 | 12 | stderr: "", |
| @@ -15,11 +14,11 @@ const processOutput = (stdout: string): VcsProcessOutput => ({ · 1 unmodified line | ||
| 15 | 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 | 19 | const layer = GitHubCli.layer.pipe( |
| 21 | 20 | Layer.provide( |
| 22 | − Layer.mock(VcsProcess)({ | |
| 21 | + Layer.mock(VcsProcess.VcsProcess)({ | |
| 23 | 22 | run: mockRun, |
| 24 | 23 | }), |
| 25 | 24 | ), |
| @@ -6,12 +6,8 @@ import { · 5 unmodified lines | ||
| 6 | 6 | type VcsError, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | |
| 9 | −import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; | |
| 10 | −import { | |
| 11 | − decodeGitHubPullRequestJson, | |
| 12 | − decodeGitHubPullRequestListJson, | |
| 13 | − formatGitHubJsonDecodeError, | |
| 14 | −} from "./gitHubPullRequests.ts"; | |
| 9 | +import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 10 | +import * as GitHubPullRequests from "./gitHubPullRequests.ts"; | |
| 15 | 11 | |
| 16 | 12 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 17 | 13 | |
| @@ -48,7 +44,7 @@ export interface GitHubCliShape { · 30 unmodified lines | ||
| 48 | 44 | readonly cwd: string; |
| 49 | 45 | readonly args: ReadonlyArray<string>; |
| 50 | 46 | readonly timeoutMs?: number; |
| 51 | − }) => Effect.Effect<VcsProcessOutput, GitHubCliError>; | |
| 47 | + }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>; | |
| 52 | 48 | |
| 53 | 49 | readonly listOpenPullRequests: (input: { |
| 54 | 50 | readonly cwd: string; |
| @@ -226,7 +222,7 @@ function decodeGitHubJson<S extends Schema.Top>( · 171 unmodified lines | ||
| 226 | 222 | } |
| 227 | 223 | |
| 228 | 224 | export const make = Effect.fn("makeGitHubCli")(function* () { |
| 229 | − const process = yield* VcsProcess; | |
| 225 | + const process = yield* VcsProcess.VcsProcess; | |
| 230 | 226 | |
| 231 | 227 | const execute: GitHubCliShape["execute"] = (input) => |
| 232 | 228 | process |
| @@ -261,13 +257,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { · 28 unmodified lines | ||
| 261 | 257 | Effect.flatMap((raw) => |
| 262 | 258 | raw.length === 0 |
| 263 | 259 | ? Effect.succeed([]) |
| 264 | − : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( | |
| 260 | + : Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( | |
| 265 | 261 | Effect.flatMap((decoded) => { |
| 266 | 262 | if (!Result.isSuccess(decoded)) { |
| 267 | 263 | return Effect.fail( |
| 268 | 264 | new GitHubCliError({ |
| 269 | 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 | 267 | cause: decoded.failure, |
| 272 | 268 | }), |
| 273 | 269 | ); |
| @@ -293,13 +289,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { · 19 unmodified lines | ||
| 293 | 289 | }).pipe( |
| 294 | 290 | Effect.map((result) => result.stdout.trim()), |
| 295 | 291 | Effect.flatMap((raw) => |
| 296 | − Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( | |
| 292 | + Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestJson(raw)).pipe( | |
| 297 | 293 | Effect.flatMap((decoded) => { |
| 298 | 294 | if (!Result.isSuccess(decoded)) { |
| 299 | 295 | return Effect.fail( |
| 300 | 296 | new GitHubCliError({ |
| 301 | 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 | 299 | cause: decoded.failure, |
| 304 | 300 | }), |
| 305 | 301 | ); |
| @@ -2,11 +2,11 @@ import { assert, it } from "@effect/vitest"; · 1 unmodified line | ||
| 2 | 2 | import { DateTime, Effect, Layer, Option } from "effect"; |
| 3 | 3 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | 4 | |
| 5 | −import { GitHubCli, type GitHubCliShape } from "./GitHubCli.ts"; | |
| 6 | −import type { VcsProcessOutput } from "../vcs/VcsProcess.ts"; | |
| 5 | +import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 6 | +import * as GitHubCli from "./GitHubCli.ts"; | |
| 7 | 7 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 8 | 8 | |
| 9 | −const processResult = (stdout: string): VcsProcessOutput => ({ | |
| 9 | +const processResult = (stdout: string): VcsProcess.VcsProcessOutput => ({ | |
| 10 | 10 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 11 | 11 | stdout, |
| 12 | 12 | stderr: "", |
| @@ -14,8 +14,10 @@ const processResult = (stdout: string): VcsProcessOutput => ({ · 1 unmodified line | ||
| 14 | 14 | stderrTruncated: false, |
| 15 | 15 | }); |
| 16 | 16 | |
| 17 | −function makeProvider(github: Partial<GitHubCliShape>) { | |
| 18 | − return GitHubSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitHubCli)(github))); | |
| 17 | +function makeProvider(github: Partial<GitHubCli.GitHubCliShape>) { | |
| 18 | + return GitHubSourceControlProvider.make().pipe( | |
| 19 | + Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), | |
| 20 | + ); | |
| 19 | 21 | } |
| 20 | 22 | |
| 21 | 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 | 130 | it.effect("creates GitHub PRs through provider-neutral input names", () => |
| 129 | 131 | Effect.gen(function* () { |
| 130 | − let createInput: Parameters<GitHubCliShape["createPullRequest"]>[0] | null = null; | |
| 132 | + let createInput: Parameters<GitHubCli.GitHubCliShape["createPullRequest"]>[0] | null = null; | |
| 131 | 133 | const provider = yield* makeProvider({ |
| 132 | 134 | createPullRequest: (input) => { |
| 133 | 135 | createInput = input; |
| @@ -5,11 +5,15 @@ import { · 4 unmodified lines | ||
| 5 | 5 | type ChangeRequestState, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | |
| 8 | −import { GitHubCli, type GitHubCliError, type GitHubPullRequestSummary } from "./GitHubCli.ts"; | |
| 9 | −import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; | |
| 10 | −import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts"; | |
| 8 | +import * as GitHubCli from "./GitHubCli.ts"; | |
| 9 | +import * as GitHubPullRequests from "./gitHubPullRequests.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 | 17 | return new SourceControlProviderError({ |
| 14 | 18 | provider: "github", |
| 15 | 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 | 26 | return { |
| 23 | 27 | provider: "github", |
| 24 | 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 | 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) => { | |
| 47 | − if (input.state === "open") { | |
| 93 | + const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] = | |
| 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 | 109 | return github |
| 49 | − .listOpenPullRequests({ | |
| 110 | + .execute({ | |
| 50 | 111 | cwd: input.cwd, |
| 51 | − headSelector: input.headSelector, | |
| 52 | − ...(input.limit !== undefined ? { limit: input.limit } : {}), | |
| 112 | + args: [ | |
| 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 | 125 | .pipe( |
| 55 | − Effect.map((items) => items.map(toChangeRequest)), | |
| 56 | − Effect.mapError((error) => providerError("listChangeRequests", error)), | |
| 126 | + Effect.flatMap((result) => { | |
| 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 | − } | |
| 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 | − }; | |
| 157 | + }; | |
| 110 | 158 | |
| 111 | − return SourceControlProvider.of({ | |
| 159 | + return SourceControlProvider.SourceControlProvider.of({ | |
| 112 | 160 | kind: "github", |
| 113 | 161 | listChangeRequests, |
| 114 | 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 | 2 | import { Effect, Layer } from "effect"; |
| 3 | 3 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | −import { afterEach, expect, vi } from "vitest"; | |
| 5 | 4 | |
| 6 | 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 | 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 | 11 | const layer = it.layer( |
| 13 | 12 | GitLabCli.layer.pipe( |
| 14 | 13 | Layer.provide( |
| 15 | − Layer.mock(VcsProcess)({ | |
| 14 | + Layer.mock(VcsProcess.VcsProcess)({ | |
| 16 | 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 | 22 | return { |
| 24 | 23 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 25 | 24 | stdout, |
| @@ -2,13 +2,9 @@ import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateT · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; |
| 4 | 4 | |
| 5 | −import { | |
| 6 | − decodeGitLabMergeRequestJson, | |
| 7 | − decodeGitLabMergeRequestListJson, | |
| 8 | − formatGitLabJsonDecodeError, | |
| 9 | −} from "./gitLabMergeRequests.ts"; | |
| 10 | −import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; | |
| 11 | −import type { SourceControlRefSelector } from "./SourceControlProvider.ts"; | |
| 5 | +import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 6 | +import * as GitLabMergeRequests from "./gitLabMergeRequests.ts"; | |
| 7 | +import type * as SourceControlProvider from "./SourceControlProvider.ts"; | |
| 12 | 8 | |
| 13 | 9 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 14 | 10 | |
| @@ -46,12 +42,12 @@ export interface GitLabCliShape { · 31 unmodified lines | ||
| 46 | 42 | readonly cwd: string; |
| 47 | 43 | readonly args: ReadonlyArray<string>; |
| 48 | 44 | readonly timeoutMs?: number; |
| 49 | − }) => Effect.Effect<VcsProcessOutput, GitLabCliError>; | |
| 45 | + }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitLabCliError>; | |
| 50 | 46 | |
| 51 | 47 | readonly listMergeRequests: (input: { |
| 52 | 48 | readonly cwd: string; |
| 53 | 49 | readonly headSelector: string; |
| 54 | − readonly source?: SourceControlRefSelector; | |
| 50 | + readonly source?: SourceControlProvider.SourceControlRefSelector; | |
| 55 | 51 | readonly state: "open" | "closed" | "merged" | "all"; |
| 56 | 52 | readonly limit?: number; |
| 57 | 53 | }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>; |
| @@ -76,8 +72,8 @@ export interface GitLabCliShape { · 18 unmodified lines | ||
| 76 | 72 | readonly cwd: string; |
| 77 | 73 | readonly baseBranch: string; |
| 78 | 74 | readonly headSelector: string; |
| 79 | − readonly source?: SourceControlRefSelector; | |
| 80 | − readonly target?: SourceControlRefSelector; | |
| 75 | + readonly source?: SourceControlProvider.SourceControlRefSelector; | |
| 76 | + readonly target?: SourceControlProvider.SourceControlRefSelector; | |
| 81 | 77 | readonly title: string; |
| 82 | 78 | readonly bodyFile: string; |
| 83 | 79 | }) => Effect.Effect<void, GitLabCliError>; |
| @@ -220,12 +216,14 @@ function normalizeHeadSelector(headSelector: string): string { · 136 unmodified lines | ||
| 220 | 216 | |
| 221 | 217 | function sourceRefName(input: { |
| 222 | 218 | readonly headSelector: string; |
| 223 | − readonly source?: SourceControlRefSelector; | |
| 219 | + readonly source?: SourceControlProvider.SourceControlRefSelector; | |
| 224 | 220 | }): string { |
| 225 | 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 | 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 | 252 | export const make = Effect.fn("makeGitLabCli")(function* () { |
| 255 | − const process = yield* VcsProcess; | |
| 253 | + const process = yield* VcsProcess.VcsProcess; | |
| 256 | 254 | |
| 257 | 255 | const execute: GitLabCliShape["execute"] = (input) => |
| 258 | 256 | process |
| @@ -286,13 +284,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { · 27 unmodified lines | ||
| 286 | 284 | Effect.flatMap((raw) => |
| 287 | 285 | raw.length === 0 |
| 288 | 286 | ? Effect.succeed([]) |
| 289 | − : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( | |
| 287 | + : Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestListJson(raw)).pipe( | |
| 290 | 288 | Effect.flatMap((decoded) => { |
| 291 | 289 | if (!Result.isSuccess(decoded)) { |
| 292 | 290 | return Effect.fail( |
| 293 | 291 | new GitLabCliError({ |
| 294 | 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 | 294 | cause: decoded.failure, |
| 297 | 295 | }), |
| 298 | 296 | ); |
| @@ -310,13 +308,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { · 11 unmodified lines | ||
| 310 | 308 | }).pipe( |
| 311 | 309 | Effect.map((result) => result.stdout.trim()), |
| 312 | 310 | Effect.flatMap((raw) => |
| 313 | − Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( | |
| 311 | + Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestJson(raw)).pipe( | |
| 314 | 312 | Effect.flatMap((decoded) => { |
| 315 | 313 | if (!Result.isSuccess(decoded)) { |
| 316 | 314 | return Effect.fail( |
| 317 | 315 | new GitLabCliError({ |
| 318 | 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 | 318 | cause: decoded.failure, |
| 321 | 319 | }), |
| 322 | 320 | ); |
| @@ -1,11 +1,13 @@ | ||
| 1 | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | 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 | 5 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; |
| 6 | 6 | |
| 7 | −function makeProvider(gitlab: Partial<GitLabCliShape>) { | |
| 8 | − return GitLabSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitLabCli)(gitlab))); | |
| 7 | +function makeProvider(gitlab: Partial<GitLabCli.GitLabCliShape>) { | |
| 8 | + return GitLabSourceControlProvider.make().pipe( | |
| 9 | + Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), | |
| 10 | + ); | |
| 9 | 11 | } |
| 10 | 12 | |
| 11 | 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 | 51 | it.effect("lists GitLab MRs through provider-neutral input names", () => |
| 50 | 52 | Effect.gen(function* () { |
| 51 | − let listInput: Parameters<GitLabCliShape["listMergeRequests"]>[0] | null = null; | |
| 53 | + let listInput: Parameters<GitLabCli.GitLabCliShape["listMergeRequests"]>[0] | null = null; | |
| 52 | 54 | const provider = yield* makeProvider({ |
| 53 | 55 | listMergeRequests: (input) => { |
| 54 | 56 | listInput = input; |
| @@ -74,7 +76,7 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => · 19 unmodified lines | ||
| 74 | 76 | |
| 75 | 77 | it.effect("creates GitLab MRs through provider-neutral input names", () => |
| 76 | 78 | Effect.gen(function* () { |
| 77 | − let createInput: Parameters<GitLabCliShape["createMergeRequest"]>[0] | null = null; | |
| 79 | + let createInput: Parameters<GitLabCli.GitLabCliShape["createMergeRequest"]>[0] | null = null; | |
| 78 | 80 | const provider = yield* makeProvider({ |
| 79 | 81 | createMergeRequest: (input) => { |
| 80 | 82 | createInput = input; |
| @@ -1,10 +1,14 @@ | ||
| 1 | 1 | import { Effect, Layer, Option } from "effect"; |
| 2 | 2 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; |
| 3 | 3 | |
| 4 | −import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts"; | |
| 5 | −import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; | |
| 4 | +import * as GitLabCli from "./GitLabCli.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 | 12 | return new SourceControlProviderError({ |
| 9 | 13 | provider: "gitlab", |
| 10 | 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 | 21 | return { |
| 18 | 22 | provider: "gitlab", |
| 19 | 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: { | |
| 39 | − readonly headSelector: string; | |
| 40 | − readonly source?: SourceControlRefSelector; | |
| 41 | −}): SourceControlRefSelector | undefined { | |
| 42 | − if (input.source) { | |
| 43 | − return input.source; | |
| 42 | +function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { | |
| 43 | + const output = SourceControlProviderDiscovery.combinedAuthOutput(input); | |
| 44 | + const account = SourceControlProviderDiscovery.matchFirst(output, [ | |
| 45 | + /Logged in to .* as\s+([^\s(]+)/iu, | |
| 46 | + /Logged in to .* account\s+([^\s(]+)/iu, | |
| 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()); | |
| 47 | − const owner = match?.[1]?.trim(); | |
| 48 | − const refName = match?.[2]?.trim(); | |
| 49 | − return owner && refName ? { owner, refName } : undefined; | |
| 65 | + return SourceControlProviderDiscovery.providerAuth({ | |
| 66 | + status: "unknown", | |
| 67 | + host, | |
| 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 | 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 | 90 | kind: "gitlab", |
| 57 | 91 | listChangeRequests: (input) => { |
| 58 | − const source = sourceFromInput(input); | |
| 92 | + const source = SourceControlProvider.sourceControlRefFromInput(input); | |
| 59 | 93 | return gitlab |
| 60 | 94 | .listMergeRequests({ |
| 61 | 95 | cwd: input.cwd, |
| @@ -75,7 +109,7 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { · 13 unmodified lines | ||
| 75 | 109 | Effect.mapError((error) => providerError("getChangeRequest", error)), |
| 76 | 110 | ), |
| 77 | 111 | createChangeRequest: (input) => { |
| 78 | − const source = sourceFromInput(input); | |
| 112 | + const source = SourceControlProvider.sourceControlRefFromInput(input); | |
| 79 | 113 | return gitlab |
| 80 | 114 | .createMergeRequest({ |
| 81 | 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 | 5 | import { VcsProcessSpawnError } from "@t3tools/contracts"; |
| 6 | 6 | |
| 7 | 7 | import { ServerConfig } from "../config.ts"; |
| 8 | +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | |
| 8 | 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 | 37 | const processOutput = ( |
| 12 | 38 | stdout: string, |
| @@ -22,39 +48,53 @@ const processOutput = ( · 9 unmodified lines | ||
| 22 | 48 | stderrTruncated: false, |
| 23 | 49 | }); |
| 24 | 50 | |
| 25 | −it.effect("reports implemented tools separately from locally available CLIs", () => { | |
| 26 | − const testLayer = layer.pipe( | |
| 27 | − Layer.provide( | |
| 28 | − ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), | |
| 29 | − ), | |
| 30 | − Layer.provide( | |
| 31 | − Layer.mock(VcsProcess.VcsProcess)({ | |
| 32 | − run: (input) => { | |
| 33 | − if (input.command === "git") { | |
| 34 | − return Effect.succeed(processOutput("git version 2.51.0\n")); | |
| 35 | − } | |
| 36 | − if (input.command === "gh" && input.args[0] === "--version") { | |
| 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 | |
| 51 | +it.effect("reports implemented tools separately from locally available executables", () => { | |
| 52 | + const processMock = { | |
| 53 | + run: (input: VcsProcess.VcsProcessInput) => { | |
| 54 | + if (input.command === "git") { | |
| 55 | + return Effect.succeed(processOutput("git version 2.51.0\n")); | |
| 56 | + } | |
| 57 | + if (input.command === "gh" && input.args[0] === "--version") { | |
| 58 | + return Effect.succeed(processOutput("gh version 2.83.0\n")); | |
| 59 | + } | |
| 60 | + if (input.command === "gh" && input.args.join(" ") === "auth status") { | |
| 61 | + return Effect.succeed( | |
| 62 | + processOutput(`github.com | |
| 42 | 63 | Logged in to github.com account juliusmarminge (keyring) |
| 43 | 64 | - Active account: true |
| 44 | 65 | - Git operations protocol: ssh |
| 45 | 66 | - Token: gho_************************************ |
| 46 | 67 | - Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo' |
| 47 | 68 | `), |
| 48 | − ); | |
| 49 | − } | |
| 50 | − return Effect.fail( | |
| 51 | − new VcsProcessSpawnError({ | |
| 52 | − operation: input.operation, | |
| 53 | − command: input.command, | |
| 54 | − cwd: input.cwd, | |
| 55 | − cause: new Error(`${input.command} not found`), | |
| 56 | − }), | |
| 57 | − ); | |
| 69 | + ); | |
| 70 | + } | |
| 71 | + return Effect.fail( | |
| 72 | + new VcsProcessSpawnError({ | |
| 73 | + operation: input.operation, | |
| 74 | + command: input.command, | |
| 75 | + cwd: input.cwd, | |
| 76 | + cause: new Error(`${input.command} not found`), | |
| 77 | + }), | |
| 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 | 104 | return Effect.gen(function* () { |
| 65 | − const discovery = yield* SourceControlDiscovery; | |
| 105 | + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; | |
| 66 | 106 | const result = yield* discovery.discover; |
| 67 | 107 | |
| 68 | 108 | assert.deepStrictEqual( |
| @@ -79,7 +119,6 @@ Logged in to github.com account juliusmarminge (keyring) · 10 unmodified lines | ||
| 79 | 119 | assert.deepStrictEqual( |
| 80 | 120 | result.sourceControlProviders.map((item) => ({ |
| 81 | 121 | kind: item.kind, |
| 82 | − implemented: item.implemented, | |
| 83 | 122 | status: item.status, |
| 84 | 123 | auth: item.auth.status, |
| 85 | 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 | 128 | kind: "github", |
| 90 | − implemented: true, | |
| 91 | 129 | status: "available", |
| 92 | 130 | auth: "authenticated", |
| 93 | 131 | account: Option.some("juliusmarminge"), |
| 94 | 132 | }, |
| 95 | 133 | { |
| 96 | 134 | kind: "gitlab", |
| 97 | − implemented: true, | |
| 98 | 135 | status: "missing", |
| 99 | 136 | auth: "unknown", |
| 100 | 137 | account: Option.none(), |
| 101 | 138 | }, |
| 102 | 139 | { |
| 103 | 140 | kind: "azure-devops", |
| 104 | − implemented: false, | |
| 105 | 141 | status: "missing", |
| 106 | 142 | auth: "unknown", |
| 107 | 143 | account: Option.none(), |
| 108 | 144 | }, |
| 109 | 145 | { |
| 110 | 146 | kind: "bitbucket", |
| 111 | − implemented: false, | |
| 112 | − status: "missing", | |
| 113 | − auth: "unknown", | |
| 147 | + status: "available", | |
| 148 | + auth: "unauthenticated", | |
| 114 | 149 | account: Option.none(), |
| 115 | 150 | }, |
| 116 | 151 | ], |
| 117 | 152 | ); |
| 118 | 153 | const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); |
| 119 | 154 | assert.ok(bitbucket); |
| 120 | − assert.strictEqual("executable" in bitbucket, false); | |
| 155 | + assert.strictEqual(bitbucket.executable, undefined); | |
| 121 | 156 | }).pipe(Effect.provide(testLayer)); |
| 122 | 157 | }); |
| 123 | 158 | |
| 124 | 159 | it.effect("probes provider authentication without exposing token details", () => { |
| 125 | − const testLayer = layer.pipe( | |
| 126 | − Layer.provide( | |
| 127 | − ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), | |
| 128 | − ), | |
| 129 | − Layer.provide( | |
| 130 | − Layer.mock(VcsProcess.VcsProcess)({ | |
| 131 | − run: (input) => { | |
| 132 | − if (input.args[0] === "--version") { | |
| 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 | |
| 160 | + const processMock = { | |
| 161 | + run: (input: VcsProcess.VcsProcessInput) => { | |
| 162 | + if (input.args[0] === "--version") { | |
| 163 | + return Effect.succeed(processOutput(`${input.command} version test\n`)); | |
| 164 | + } | |
| 165 | + if (input.command === "gh" && input.args.join(" ") === "auth status") { | |
| 166 | + return Effect.succeed( | |
| 167 | + processOutput(`github.com | |
| 138 | 168 | Logged in to github.com account octocat (keyring) |
| 139 | 169 | - Token: gho_************************************ |
| 140 | 170 | - Token scopes: 'repo' |
| 141 | 171 | `), |
| 142 | − ); | |
| 143 | − } | |
| 144 | − if (input.command === "glab" && input.args.join(" ") === "auth status") { | |
| 145 | − return Effect.succeed( | |
| 146 | − processOutput(`gitlab.com | |
| 172 | + ); | |
| 173 | + } | |
| 174 | + if (input.command === "glab" && input.args.join(" ") === "auth status") { | |
| 175 | + return Effect.succeed( | |
| 176 | + processOutput(`gitlab.com | |
| 147 | 177 | Logged in to gitlab.com as gitlab-user |
| 148 | 178 | `), |
| 149 | − ); | |
| 150 | − } | |
| 151 | − if ( | |
| 152 | − input.command === "az" && | |
| 153 | − input.args.join(" ") === "account show --query user.name -o tsv" | |
| 154 | − ) { | |
| 155 | − return Effect.succeed(processOutput("azure-user@example.com\n")); | |
| 156 | − } | |
| 157 | − if (input.command === "bb" && input.args.join(" ") === "auth status") { | |
| 158 | − return Effect.succeed( | |
| 159 | − processOutput(`bitbucket.org | |
| 160 | −Logged in as bitbucket-user | |
| 161 | −`), | |
| 162 | − ); | |
| 163 | − } | |
| 164 | − return Effect.fail( | |
| 165 | − new VcsProcessSpawnError({ | |
| 166 | − operation: input.operation, | |
| 167 | − command: input.command, | |
| 168 | − cwd: input.cwd, | |
| 169 | − cause: new Error(`${input.command} not found`), | |
| 170 | − }), | |
| 171 | − ); | |
| 179 | + ); | |
| 180 | + } | |
| 181 | + if ( | |
| 182 | + input.command === "az" && | |
| 183 | + input.args.join(" ") === "account show --query user.name -o tsv" | |
| 184 | + ) { | |
| 185 | + return Effect.succeed(processOutput("azure-user@example.com\n")); | |
| 186 | + } | |
| 187 | + return Effect.fail( | |
| 188 | + new VcsProcessSpawnError({ | |
| 189 | + operation: input.operation, | |
| 190 | + command: input.command, | |
| 191 | + cwd: input.cwd, | |
| 192 | + cause: new Error(`${input.command} not found`), | |
| 193 | + }), | |
| 194 | + ); | |
| 195 | + }, | |
| 196 | + } satisfies Partial<VcsProcess.VcsProcessShape>; | |
| 197 | + const testLayer = SourceControlDiscovery.layer.pipe( | |
| 198 | + Layer.provide( | |
| 199 | + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), | |
| 200 | + ), | |
| 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 | 218 | return Effect.gen(function* () { |
| 179 | − const discovery = yield* SourceControlDiscovery; | |
| 219 | + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; | |
| 180 | 220 | const result = yield* discovery.discover; |
| 181 | 221 | |
| 182 | 222 | assert.deepStrictEqual( |
| @@ -207,9 +247,9 @@ Logged in as bitbucket-user · 24 unmodified lines | ||
| 207 | 247 | }, |
| 208 | 248 | { |
| 209 | 249 | kind: "bitbucket", |
| 210 | − auth: "unknown", | |
| 211 | − account: Option.none(), | |
| 212 | − detail: Option.some("Bitbucket provider support is not available yet."), | |
| 250 | + auth: "authenticated", | |
| 251 | + account: Option.some("bitbucket-user"), | |
| 252 | + detail: Option.none(), | |
| 213 | 253 | }, |
| 214 | 254 | ], |
| 215 | 255 | ); |
| @@ -1,8 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | − type SourceControlProviderAuth, | |
| 3 | 2 | type SourceControlDiscoveryResult, |
| 4 | − type SourceControlProviderDiscoveryItem, | |
| 5 | − type SourceControlProviderKind, | |
| 6 | 3 | type VcsDiscoveryItem, |
| 7 | 4 | type VcsDriverKind, |
| 8 | 5 | } from "@t3tools/contracts"; |
| @@ -10,6 +7,8 @@ import { Context, Effect, Layer, Option } from "effect"; · 1 unmodified line | ||
| 10 | 7 | |
| 11 | 8 | import { ServerConfig } from "../config.ts"; |
| 12 | 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 | 13 | interface DiscoveryProbe { |
| 15 | 14 | readonly label: string; |
| @@ -25,18 +24,6 @@ type VcsProbe = DiscoveryProbe & { · 9 unmodified lines | ||
| 25 | 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 | 27 | interface DiscoveryProbeResult<Kind extends string> { |
| 41 | 28 | readonly kind: Kind; |
| 42 | 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 | 57 | export interface SourceControlDiscoveryShape { |
| 268 | 58 | readonly discover: Effect.Effect<SourceControlDiscoveryResult>; |
| 269 | 59 | } |
| @@ -278,8 +68,10 @@ export const layer = Layer.effect( · 8 unmodified lines | ||
| 278 | 68 | Effect.gen(function* () { |
| 279 | 69 | const config = yield* ServerConfig; |
| 280 | 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 | 75 | input: DiscoveryProbe & { readonly kind: Kind }, |
| 284 | 76 | ): Effect.Effect<DiscoveryProbeResult<Kind>> => { |
| 285 | 77 | const executable = input.executable; |
| @@ -316,8 +108,9 @@ export const layer = Layer.effect( · 30 unmodified lines | ||
| 316 | 108 | executable, |
| 317 | 109 | implemented: input.implemented, |
| 318 | 110 | status: "available" as const, |
| 319 | − version: Option.orElse(firstNonEmptyLine(result.stdout), () => | |
| 320 | − firstNonEmptyLine(result.stderr), | |
| 111 | + version: Option.orElse( | |
| 112 | + SourceControlProviderDiscovery.firstNonEmptyLine(result.stdout), | |
| 113 | + () => SourceControlProviderDiscovery.firstNonEmptyLine(result.stderr), | |
| 321 | 114 | ), |
| 322 | 115 | installHint: input.installHint, |
| 323 | 116 | detail: Option.none<string>(), |
| @@ -332,74 +125,19 @@ export const layer = Layer.effect( · 8 unmodified lines | ||
| 332 | 125 | status: "missing" as const, |
| 333 | 126 | version: Option.none<string>(), |
| 334 | 127 | installHint: input.installHint, |
| 335 | − detail: detailFromCause(cause), | |
| 128 | + detail: SourceControlProviderDiscovery.detailFromCause(cause), | |
| 336 | 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 | 134 | return SourceControlDiscovery.of({ |
| 392 | 135 | discover: Effect.all({ |
| 393 | 136 | versionControlSystems: Effect.all( |
| 394 | 137 | VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>, |
| 395 | 138 | { concurrency: "unbounded" }, |
| 396 | 139 | ), |
| 397 | − sourceControlProviders: Effect.all( | |
| 398 | − SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray< | |
| 399 | − Effect.Effect<SourceControlProviderDiscoveryItem> | |
| 400 | − >, | |
| 401 | − { concurrency: "unbounded" }, | |
| 402 | − ), | |
| 140 | + sourceControlProviders: sourceControlProviders.discover, | |
| 403 | 141 | }), |
| 404 | 142 | }); |
| 405 | 143 | }), |
| @@ -21,6 +21,33 @@ export interface SourceControlRefSelector { · 20 unmodified lines | ||
| 21 | 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 | 51 | export interface SourceControlProviderShape { |
| 25 | 52 | readonly kind: SourceControlProviderKind; |
| 26 | 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 | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | 3 | import { DateTime, Effect, Layer, Option } from "effect"; |
| 3 | 4 | |
| 4 | −import { GitHubCli } from "./GitHubCli.ts"; | |
| 5 | −import { GitLabCli } from "./GitLabCli.ts"; | |
| 5 | +import { ServerConfig } from "../config.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 | 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 | 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 | 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)({ | |
| 35 | − get: () => Effect.succeed(driver as unknown as VcsDriverShape), | |
| 39 | + const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ | |
| 40 | + get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriverShape), | |
| 36 | 41 | resolve: () => |
| 37 | 42 | Effect.succeed({ |
| 38 | 43 | kind: "git", |
| @@ -46,13 +51,23 @@ function makeRegistry(input: { · 7 unmodified lines | ||
| 46 | 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 | 58 | return SourceControlProviderRegistry.make().pipe( |
| 54 | 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 | 135 | it.effect("falls back to a non-origin remote when origin is not configured", () => |
| 97 | 136 | Effect.gen(function* () { |
| 98 | 137 | const registry = yield* makeRegistry({ |
| @@ -1,40 +1,46 @@ | ||
| 1 | 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 | 6 | import type { SourceControlProviderKind } from "@t3tools/contracts"; |
| 4 | 7 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 5 | 8 | |
| 6 | −import { | |
| 7 | − SourceControlProvider, | |
| 8 | − type SourceControlProviderContext, | |
| 9 | − type SourceControlProviderShape, | |
| 10 | −} from "./SourceControlProvider.ts"; | |
| 9 | +import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; | |
| 10 | +import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; | |
| 11 | 11 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 12 | 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 | 19 | const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; |
| 16 | 20 | const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); |
| 17 | 21 | |
| 18 | 22 | export interface SourceControlProviderRegistration { |
| 19 | 23 | readonly kind: SourceControlProviderKind; |
| 20 | − readonly provider: SourceControlProviderShape; | |
| 24 | + readonly provider: SourceControlProvider.SourceControlProviderShape; | |
| 25 | + readonly discovery: SourceControlProviderDiscovery.SourceControlProviderDiscoverySpec; | |
| 21 | 26 | } |
| 22 | 27 | |
| 23 | 28 | export interface SourceControlProviderHandle { |
| 24 | − readonly provider: SourceControlProviderShape; | |
| 25 | − readonly context: SourceControlProviderContext | null; | |
| 29 | + readonly provider: SourceControlProvider.SourceControlProviderShape; | |
| 30 | + readonly context: SourceControlProvider.SourceControlProviderContext | null; | |
| 26 | 31 | } |
| 27 | 32 | |
| 28 | 33 | export interface SourceControlProviderRegistryShape { |
| 29 | 34 | readonly get: ( |
| 30 | 35 | kind: SourceControlProviderKind, |
| 31 | − ) => Effect.Effect<SourceControlProviderShape, SourceControlProviderError>; | |
| 36 | + ) => Effect.Effect<SourceControlProvider.SourceControlProviderShape, SourceControlProviderError>; | |
| 32 | 37 | readonly resolveHandle: (input: { |
| 33 | 38 | readonly cwd: string; |
| 34 | 39 | }) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>; |
| 35 | 40 | readonly resolve: (input: { |
| 36 | 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 | 46 | export class SourceControlProviderRegistry extends Context.Service< |
| @@ -42,7 +48,9 @@ export class SourceControlProviderRegistry extends Context.Service< · 1 unmodified line | ||
| 42 | 48 | SourceControlProviderRegistryShape |
| 43 | 49 | >()("t3/source-control/SourceControlProviderRegistry") {} |
| 44 | 50 | |
| 45 | −function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProviderShape { | |
| 51 | +function unsupportedProvider( | |
| 52 | + kind: SourceControlProviderKind, | |
| 53 | +): SourceControlProvider.SourceControlProviderShape { | |
| 46 | 54 | const unsupported = (operation: string) => |
| 47 | 55 | Effect.fail( |
| 48 | 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 | 64 | kind, |
| 57 | 65 | listChangeRequests: () => unsupported("listChangeRequests"), |
| 58 | 66 | getChangeRequest: () => unsupported("getChangeRequest"), |
| @@ -78,7 +86,7 @@ function selectProviderContext( · 19 unmodified lines | ||
| 78 | 86 | readonly name: string; |
| 79 | 87 | readonly url: string; |
| 80 | 88 | }>, |
| 81 | −): SourceControlProviderContext | null { | |
| 89 | +): SourceControlProvider.SourceControlProviderContext | null { | |
| 82 | 90 | const candidates = remotes |
| 83 | 91 | .map((remote) => { |
| 84 | 92 | const provider = detectSourceControlProviderFromRemoteUrl(remote.url); |
| @@ -90,7 +98,7 @@ function selectProviderContext( · 5 unmodified lines | ||
| 90 | 98 | } |
| 91 | 99 | : null; |
| 92 | 100 | }) |
| 93 | − .filter((value): value is SourceControlProviderContext => value !== null); | |
| 101 | + .filter((value): value is SourceControlProvider.SourceControlProviderContext => value !== null); | |
| 94 | 102 | |
| 95 | 103 | return ( |
| 96 | 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 | 155 | export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( |
| 104 | 156 | function* (registrations: ReadonlyArray<SourceControlProviderRegistration>) { |
| 105 | − const vcsRegistry = yield* VcsDriverRegistry; | |
| 106 | − const providers = new Map<SourceControlProviderKind, SourceControlProviderShape>( | |
| 107 | − registrations.map((registration) => [registration.kind, registration.provider]), | |
| 108 | − ); | |
| 157 | + const config = yield* ServerConfig; | |
| 158 | + const process = yield* VcsProcess.VcsProcess; | |
| 159 | + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; | |
| 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 | 166 | const get: SourceControlProviderRegistryShape["get"] = (kind) => |
| 111 | 167 | Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); |
| @@ -125,7 +181,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit · 13 unmodified lines | ||
| 125 | 181 | |
| 126 | 182 | const providerContextCache = yield* Cache.makeWith< |
| 127 | 183 | string, |
| 128 | − SourceControlProviderContext | null, | |
| 184 | + SourceControlProvider.SourceControlProviderContext | null, | |
| 129 | 185 | SourceControlProviderError |
| 130 | 186 | >(detectProviderContext, { |
| 131 | 187 | capacity: PROVIDER_DETECTION_CACHE_CAPACITY, |
| @@ -136,8 +192,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit · 4 unmodified lines | ||
| 136 | 192 | Cache.get(providerContextCache, input.cwd).pipe( |
| 137 | 193 | Effect.map((context) => { |
| 138 | 194 | const kind = context?.provider.kind ?? "unknown"; |
| 195 | + const provider = providers.get(kind) ?? unsupportedProvider(kind); | |
| 139 | 196 | return { |
| 140 | − provider: providers.get(kind) ?? unsupportedProvider(kind), | |
| 197 | + provider: bindProviderContext(provider, context), | |
| 141 | 198 | context, |
| 142 | 199 | } satisfies SourceControlProviderHandle; |
| 143 | 200 | }), |
| @@ -147,6 +204,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit · 3 unmodified lines | ||
| 147 | 204 | get, |
| 148 | 205 | resolveHandle, |
| 149 | 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 | 221 | export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { |
| 155 | 222 | const github = yield* GitHubSourceControlProvider.make(); |
| 156 | 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 | 227 | return yield* makeWithProviders([ |
| 158 | 228 | { |
| 159 | 229 | kind: "github", |
| 160 | 230 | provider: github, |
| 231 | + discovery: GitHubSourceControlProvider.discovery, | |
| 161 | 232 | }, |
| 162 | 233 | { |
| 163 | 234 | kind: "gitlab", |
| 164 | 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 | 6 | import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; |
| 7 | 7 | |
| 8 | 8 | import { ServerConfig } from "../config.ts"; |
| 9 | −import { | |
| 10 | − GitVcsDriver, | |
| 11 | − type ExecuteGitResult, | |
| 12 | − type GitVcsDriverShape, | |
| 13 | −} from "../vcs/GitVcsDriver.ts"; | |
| 14 | −import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; | |
| 15 | −import type { SourceControlProviderShape } from "./SourceControlProvider.ts"; | |
| 16 | −import { SourceControlRepositoryService, layer } from "./SourceControlRepositoryService.ts"; | |
| 9 | +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 10 | +import type * as SourceControlProvider from "./SourceControlProvider.ts"; | |
| 11 | +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | |
| 12 | +import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; | |
| 17 | 13 | |
| 18 | 14 | const CLONE_URLS = { |
| 19 | 15 | nameWithOwner: "octocat/t3code", |
| @@ -22,8 +18,8 @@ const CLONE_URLS = { · 2 unmodified lines | ||
| 22 | 18 | }; |
| 23 | 19 | |
| 24 | 20 | function makeProvider( |
| 25 | − overrides: Partial<SourceControlProviderShape> = {}, | |
| 26 | −): SourceControlProviderShape { | |
| 21 | + overrides: Partial<SourceControlProvider.SourceControlProviderShape> = {}, | |
| 22 | +): SourceControlProvider.SourceControlProviderShape { | |
| 27 | 23 | const unsupported = (operation: string) => |
| 28 | 24 | Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect< |
| 29 | 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 | 43 | return { |
| 48 | 44 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 49 | 45 | stdout: "", |
| @@ -54,17 +50,17 @@ function processOutput(): ExecuteGitResult { · 4 unmodified lines | ||
| 54 | 50 | } |
| 55 | 51 | |
| 56 | 52 | function makeLayer(input: { |
| 57 | − readonly provider?: SourceControlProviderShape; | |
| 58 | − readonly git?: Partial<GitVcsDriverShape>; | |
| 53 | + readonly provider?: SourceControlProvider.SourceControlProviderShape; | |
| 54 | + readonly git?: Partial<GitVcsDriver.GitVcsDriverShape>; | |
| 59 | 55 | }) { |
| 60 | − return layer.pipe( | |
| 56 | + return SourceControlRepositoryService.layer.pipe( | |
| 61 | 57 | Layer.provide( |
| 62 | − Layer.mock(SourceControlProviderRegistry)({ | |
| 58 | + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ | |
| 63 | 59 | get: () => Effect.succeed(input.provider ?? makeProvider()), |
| 64 | 60 | }), |
| 65 | 61 | ), |
| 66 | 62 | Layer.provide( |
| 67 | − Layer.mock(GitVcsDriver)({ | |
| 63 | + Layer.mock(GitVcsDriver.GitVcsDriver)({ | |
| 68 | 64 | execute: () => Effect.succeed(processOutput()), |
| 69 | 65 | ensureRemote: () => Effect.succeed("origin"), |
| 70 | 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 | 91 | return Effect.gen(function* () { |
| 96 | − const service = yield* SourceControlRepositoryService; | |
| 92 | + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; | |
| 97 | 93 | const result = yield* service.lookupRepository({ |
| 98 | 94 | provider: "github", |
| 99 | 95 | repository: "octocat/t3code", |
| @@ -115,7 +111,7 @@ it.effect("clones a looked-up repository into the requested destination", () => · 15 unmodified lines | ||
| 115 | 111 | const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = []; |
| 116 | 112 | |
| 117 | 113 | yield* Effect.gen(function* () { |
| 118 | − const service = yield* SourceControlRepositoryService; | |
| 114 | + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; | |
| 119 | 115 | const result = yield* service.cloneRepository({ |
| 120 | 116 | provider: "github", |
| 121 | 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 | 165 | return Effect.gen(function* () { |
| 170 | − const service = yield* SourceControlRepositoryService; | |
| 166 | + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; | |
| 171 | 167 | const result = yield* service.publishRepository({ |
| 172 | 168 | cwd: "/workspace", |
| 173 | 169 | provider: "github", |
| @@ -222,7 +218,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { · 48 unmodified lines | ||
| 222 | 218 | const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; |
| 223 | 219 | |
| 224 | 220 | return Effect.gen(function* () { |
| 225 | − const service = yield* SourceControlRepositoryService; | |
| 221 | + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; | |
| 226 | 222 | const result = yield* service.publishRepository({ |
| 227 | 223 | cwd: "/workspace", |
| 228 | 224 | provider: "github", |
| @@ -258,7 +254,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { · 29 unmodified lines | ||
| 258 | 254 | it.effect("publish succeeds with status remote_added when the local repo has no commits", () => { |
| 259 | 255 | let pushCalls = 0; |
| 260 | 256 | return Effect.gen(function* () { |
| 261 | − const service = yield* SourceControlRepositoryService; | |
| 257 | + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; | |
| 262 | 258 | const result = yield* service.publishRepository({ |
| 263 | 259 | cwd: "/workspace", |
| 264 | 260 | provider: "github", |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import OS from "node:os"; | |
| 1 | +import * as NodeOS from "node:os"; | |
| 2 | 2 | import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect"; |
| 3 | 3 | |
| 4 | 4 | import { |
| @@ -15,8 +15,8 @@ import { · 10 unmodified lines | ||
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | 16 | |
| 17 | 17 | import { ServerConfig } from "../config.ts"; |
| 18 | −import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; | |
| 19 | −import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; | |
| 18 | +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 19 | +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | |
| 20 | 20 | |
| 21 | 21 | export interface SourceControlRepositoryServiceShape { |
| 22 | 22 | readonly lookupRepository: ( |
| @@ -102,10 +102,10 @@ function selectRemoteUrl( · 79 unmodified lines | ||
| 102 | 102 | |
| 103 | 103 | function expandHomePath(input: string, path: Path.Path): string { |
| 104 | 104 | if (input === "~") { |
| 105 | − return OS.homedir(); | |
| 105 | + return NodeOS.homedir(); | |
| 106 | 106 | } |
| 107 | 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 | 110 | return input; |
| 111 | 111 | } |
| @@ -113,9 +113,9 @@ function expandHomePath(input: string, path: Path.Path): string { · 1 unmodified line | ||
| 113 | 113 | export const make = Effect.fn("makeSourceControlRepositoryService")(function* () { |
| 114 | 114 | const config = yield* ServerConfig; |
| 115 | 115 | const fileSystem = yield* FileSystem.FileSystem; |
| 116 | − const git = yield* GitVcsDriver; | |
| 116 | + const git = yield* GitVcsDriver.GitVcsDriver; | |
| 117 | 117 | const path = yield* Path.Path; |
| 118 | − const providers = yield* SourceControlProviderRegistry; | |
| 118 | + const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; | |
| 119 | 119 | |
| 120 | 120 | const ensureConcreteProvider = (input: { |
| 121 | 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 | +} | |