| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; |
| 4 | |
| 5 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| 6 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; |
| 7 | import { |
| 8 | combinedAuthOutput, |
| 9 | firstSafeAuthLine, |
| 10 | providerAuth, |
| 11 | type SourceControlAuthProbeInput, |
| 12 | type SourceControlCliDiscoverySpec, |
| 13 | } from "@t3tools/source-control-core/server/discovery"; |
| 14 | |
| 15 | function parseAzureAuth(input: SourceControlAuthProbeInput) { |
| 16 | const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); |
| 17 | |
| 18 | if (input.exitCode !== 0) { |
| 19 | return providerAuth({ |
| 20 | status: "unauthenticated", |
| 21 | detail: |
| 22 | firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", |
| 23 | }); |
| 24 | } |
| 25 | |
| 26 | if (account !== undefined && account.length > 0) { |
| 27 | return providerAuth({ |
| 28 | status: "authenticated", |
| 29 | account, |
| 30 | host: "dev.azure.com", |
| 31 | }); |
| 32 | } |
| 33 | |
| 34 | return providerAuth({ |
| 35 | status: "unknown", |
| 36 | host: "dev.azure.com", |
| 37 | detail: "Azure CLI account status could not be parsed.", |
| 38 | }); |
| 39 | } |
| 40 | |
| 41 | export const discovery = { |
| 42 | type: "cli", |
| 43 | kind: SourceControlProviderKind.make("azure-devops"), |
| 44 | label: "Azure DevOps", |
| 45 | executable: "az", |
| 46 | versionArgs: ["--version"], |
| 47 | authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], |
| 48 | // `az` boots a fresh Python interpreter on every invocation, so even `az --version` |
| 49 | // takes ~6s on Windows and overruns the default budget, leaving the provider reported |
| 50 | // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s. |
| 51 | probeTimeoutMs: 20_000, |
| 52 | parseAuth: parseAzureAuth, |
| 53 | installHint: |
| 54 | "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", |
| 55 | } satisfies SourceControlCliDiscoverySpec; |
| 56 | |
| 57 | function toChangeRequest(summary: { |
| 58 | readonly number: number; |
| 59 | readonly title: string; |
| 60 | readonly url: string; |
| 61 | readonly baseRefName: string; |
| 62 | readonly headRefName: string; |
| 63 | readonly state: "open" | "closed" | "merged"; |
| 64 | readonly isDraft?: boolean; |
| 65 | readonly isCrossRepository: boolean; |
| 66 | readonly closedAt?: string | null; |
| 67 | readonly mergedAt?: string | null; |
| 68 | readonly updatedAt: ChangeRequest["updatedAt"]; |
| 69 | }): ChangeRequest { |
| 70 | return { |
| 71 | provider: SourceControlProviderKind.make("azure-devops"), |
| 72 | number: summary.number, |
| 73 | title: summary.title, |
| 74 | url: summary.url, |
| 75 | baseRefName: summary.baseRefName, |
| 76 | headRefName: summary.headRefName, |
| 77 | state: summary.state, |
| 78 | ...(summary.isDraft === true ? { isDraft: true } : {}), |
| 79 | closedAt: summary.closedAt ?? null, |
| 80 | mergedAt: summary.mergedAt ?? null, |
| 81 | updatedAt: summary.updatedAt, |
| 82 | isCrossRepository: summary.isCrossRepository, |
| 83 | }; |
| 84 | } |
| 85 | |
| 86 | export const make = Effect.gen(function* () { |
| 87 | const azure = yield* AzureDevOpsCli.AzureDevOpsCli; |
| 88 | |
| 89 | return SourceControlProvider.SourceControlProvider.of({ |
| 90 | kind: SourceControlProviderKind.make("azure-devops"), |
| 91 | listChangeRequests: (input) => { |
| 92 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| 93 | return azure |
| 94 | .listPullRequests({ |
| 95 | cwd: input.cwd, |
| 96 | headSelector: input.headSelector, |
| 97 | ...(source !== undefined ? { source } : {}), |
| 98 | state: input.state, |
| 99 | ...(input.limit !== undefined ? { limit: input.limit } : {}), |
| 100 | }) |
| 101 | .pipe( |
| 102 | Effect.map((items) => items.map(toChangeRequest)), |
| 103 | Effect.mapError( |
| 104 | (error) => |
| 105 | new SourceControlProviderError({ |
| 106 | provider: SourceControlProviderKind.make("azure-devops"), |
| 107 | operation: "listChangeRequests", |
| 108 | command: error.command, |
| 109 | cwd: input.cwd, |
| 110 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 111 | input.headSelector, |
| 112 | ), |
| 113 | detail: error.detail, |
| 114 | cause: error, |
| 115 | }), |
| 116 | ), |
| 117 | ); |
| 118 | }, |
| 119 | getChangeRequest: (input) => |
| 120 | azure.getPullRequest(input).pipe( |
| 121 | Effect.map(toChangeRequest), |
| 122 | Effect.mapError( |
| 123 | (error) => |
| 124 | new SourceControlProviderError({ |
| 125 | provider: SourceControlProviderKind.make("azure-devops"), |
| 126 | operation: "getChangeRequest", |
| 127 | command: error.command, |
| 128 | cwd: input.cwd, |
| 129 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 130 | input.reference, |
| 131 | ), |
| 132 | detail: error.detail, |
| 133 | cause: error, |
| 134 | }), |
| 135 | ), |
| 136 | ), |
| 137 | createChangeRequest: (input) => { |
| 138 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| 139 | return azure |
| 140 | .createPullRequest({ |
| 141 | cwd: input.cwd, |
| 142 | baseBranch: input.baseRefName, |
| 143 | headSelector: input.headSelector, |
| 144 | ...(source !== undefined ? { source } : {}), |
| 145 | ...(input.target !== undefined ? { target: input.target } : {}), |
| 146 | title: input.title, |
| 147 | bodyFile: input.bodyFile, |
| 148 | }) |
| 149 | .pipe( |
| 150 | Effect.mapError( |
| 151 | (error) => |
| 152 | new SourceControlProviderError({ |
| 153 | provider: SourceControlProviderKind.make("azure-devops"), |
| 154 | operation: "createChangeRequest", |
| 155 | command: error.command, |
| 156 | cwd: input.cwd, |
| 157 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 158 | input.headSelector, |
| 159 | ), |
| 160 | detail: error.detail, |
| 161 | cause: error, |
| 162 | }), |
| 163 | ), |
| 164 | ); |
| 165 | }, |
| 166 | getRepositoryCloneUrls: (input) => |
| 167 | azure.getRepositoryCloneUrls(input).pipe( |
| 168 | Effect.mapError( |
| 169 | (error) => |
| 170 | new SourceControlProviderError({ |
| 171 | provider: SourceControlProviderKind.make("azure-devops"), |
| 172 | operation: "getRepositoryCloneUrls", |
| 173 | command: error.command, |
| 174 | cwd: input.cwd, |
| 175 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 176 | input.repository, |
| 177 | ), |
| 178 | detail: error.detail, |
| 179 | cause: error, |
| 180 | }), |
| 181 | ), |
| 182 | ), |
| 183 | createRepository: (input) => |
| 184 | azure.createRepository(input).pipe( |
| 185 | Effect.mapError( |
| 186 | (error) => |
| 187 | new SourceControlProviderError({ |
| 188 | provider: SourceControlProviderKind.make("azure-devops"), |
| 189 | operation: "createRepository", |
| 190 | command: error.command, |
| 191 | cwd: input.cwd, |
| 192 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 193 | input.repository, |
| 194 | ), |
| 195 | detail: error.detail, |
| 196 | cause: error, |
| 197 | }), |
| 198 | ), |
| 199 | ), |
| 200 | getDefaultBranch: (input) => |
| 201 | azure.getDefaultBranch({ cwd: input.cwd }).pipe( |
| 202 | Effect.mapError( |
| 203 | (error) => |
| 204 | new SourceControlProviderError({ |
| 205 | provider: SourceControlProviderKind.make("azure-devops"), |
| 206 | operation: "getDefaultBranch", |
| 207 | command: error.command, |
| 208 | cwd: input.cwd, |
| 209 | detail: error.detail, |
| 210 | cause: error, |
| 211 | }), |
| 212 | ), |
| 213 | ), |
| 214 | checkoutChangeRequest: (input) => |
| 215 | azure |
| 216 | .checkoutPullRequest({ |
| 217 | cwd: input.cwd, |
| 218 | reference: input.reference, |
| 219 | ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), |
| 220 | }) |
| 221 | .pipe( |
| 222 | Effect.mapError( |
| 223 | (error) => |
| 224 | new SourceControlProviderError({ |
| 225 | provider: SourceControlProviderKind.make("azure-devops"), |
| 226 | operation: "checkoutChangeRequest", |
| 227 | command: error.command, |
| 228 | cwd: input.cwd, |
| 229 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( |
| 230 | input.reference, |
| 231 | ), |
| 232 | detail: error.detail, |
| 233 | cause: error, |
| 234 | }), |
| 235 | ), |
| 236 | ), |
| 237 | }); |
| 238 | }); |