| author | |
| committer | |
| log | d01febb509a376d93af2d298a7787449792b5fcb |
| tree | 5846048f7587d66b6639b6ed42858fb6063a5d9b |
| parent | f6e45028efb69d208a45aee8c2fa514adde44977 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>43 files changed, 6966 insertions(+), 6844 deletions(-)
apps/server/package.json+1-1| ... | ... | @@ -45,9 +45,9 @@ |
| 45 | 45 | "@t3tools/provider-muse": "workspace:*", |
| 46 | 46 | "@t3tools/provider-opencode": "workspace:*", |
| 47 | 47 | "@t3tools/provider-pi": "workspace:*", |
| 48 | "@t3tools/source-control-azure-devops": "workspace:*", | |
| 48 | 49 | "@t3tools/source-control-core": "workspace:*", |
| 49 | 50 | "@t3tools/source-control-forgejo": "workspace:*", |
| 50 | "diff": "8.0.3", | |
| 51 | 51 | "effect": "catalog:", |
| 52 | 52 | "jose": "catalog:", |
| 53 | 53 | "node-pty": "^1.2.0-beta.15", |
apps/server/scripts/evaluate-thread-titles.ts-2| ... | ... | @@ -26,7 +26,6 @@ import * as SourceControlProviderRegistry from "../src/sourceControl/SourceContr |
| 26 | 26 | import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; |
| 27 | 27 | import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; |
| 28 | 28 | import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts"; |
| 29 | import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; | |
| 30 | 29 | import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; |
| 31 | 30 | import * as ServerSettings from "../src/serverSettings.ts"; |
| 32 | 31 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; |
| ... | ... | @@ -155,7 +154,6 @@ await Effect.runPromise( |
| 155 | 154 | GitHubApi.layerWithDependencies, |
| 156 | 155 | GitLabCli.layer, |
| 157 | 156 | SourceControlBuiltInDrivers.layer, |
| 158 | AzureDevOpsCli.layer, | |
| 159 | 157 | // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. |
| 160 | 158 | BitbucketApi.layer, |
| 161 | 159 | ), |
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts deleted-1462| ... | ... | @@ -1,1462 +0,0 @@ |
| 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import { ChildProcessSpawner } from "effect/process"; | |
| 5 | ||
| 6 | import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; | |
| 7 | import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; | |
| 8 | import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; | |
| 9 | ||
| 10 | const mockedExecute = vi.fn<AzureDevOpsCli.AzureDevOpsCli["Service"]["execute"]>(); | |
| 11 | ||
| 12 | const layer = it.layer( | |
| 13 | AzureDevOpsPullRequestCli.layer.pipe( | |
| 14 | Layer.provide( | |
| 15 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({ | |
| 16 | execute: mockedExecute, | |
| 17 | }), | |
| 18 | ), | |
| 19 | ), | |
| 20 | ); | |
| 21 | ||
| 22 | function output(stdout: string) { | |
| 23 | return { | |
| 24 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 25 | stdout, | |
| 26 | stderr: "", | |
| 27 | stdoutTruncated: false, | |
| 28 | stderrTruncated: false, | |
| 29 | }; | |
| 30 | } | |
| 31 | ||
| 32 | /** What VcsProcess allows a read that asked for no ceiling of its own. */ | |
| 33 | const VCS_DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 34 | ||
| 35 | /** | |
| 36 | * The runner as it really behaves: it cuts stdout at the ceiling its caller asked for, and cuts | |
| 37 | * it at the process default when the caller asked for none. A read whose response is larger than | |
| 38 | * its ceiling gets JSON that stops mid-string, which is the whole cost of an unset ceiling. | |
| 39 | */ | |
| 40 | function outputWithin(maxOutputBytes: number | undefined, response: string) { | |
| 41 | const ceiling = maxOutputBytes ?? VCS_DEFAULT_MAX_OUTPUT_BYTES; | |
| 42 | return ceiling >= Buffer.byteLength(response) | |
| 43 | ? output(response) | |
| 44 | : { ...output(response.slice(0, ceiling)), stdoutTruncated: true }; | |
| 45 | } | |
| 46 | ||
| 47 | /** A fixture's own shape, spelled the way `az` would answer with it. */ | |
| 48 | const json = (value: Record<string, unknown>) => JSON.stringify(value); | |
| 49 | ||
| 50 | const pullRequestRow = { | |
| 51 | pullRequestId: 42, | |
| 52 | title: "Add the page", | |
| 53 | status: "active", | |
| 54 | sourceRefName: "refs/heads/feat/page", | |
| 55 | targetRefName: "refs/heads/main", | |
| 56 | creationDate: "2026-07-01T00:00:00Z", | |
| 57 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 58 | repository: { name: "web", project: { name: "platform" } }, | |
| 59 | }; | |
| 60 | ||
| 61 | const oneIteration = { | |
| 62 | value: [ | |
| 63 | { | |
| 64 | id: 1, | |
| 65 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 66 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 67 | }, | |
| 68 | ], | |
| 69 | }; | |
| 70 | ||
| 71 | function pullRequestRows( | |
| 72 | count: number, | |
| 73 | firstNumber: number, | |
| 74 | ): ReadonlyArray<Record<string, unknown>> { | |
| 75 | return Array.from({ length: count }, (_, index) => ({ | |
| 76 | pullRequestId: firstNumber + index, | |
| 77 | title: `Pull request ${firstNumber + index}`, | |
| 78 | status: "active", | |
| 79 | sourceRefName: "refs/heads/feat/page", | |
| 80 | targetRefName: "refs/heads/main", | |
| 81 | creationDate: "2026-07-01T00:00:00Z", | |
| 82 | repository: { name: "web", project: { name: "platform" } }, | |
| 83 | url: `https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/${firstNumber + index}`, | |
| 84 | })); | |
| 85 | } | |
| 86 | ||
| 87 | function pullRequests(count: number, firstNumber: number): string { | |
| 88 | return JSON.stringify(pullRequestRows(count, firstNumber)); | |
| 89 | } | |
| 90 | ||
| 91 | /** The arguments of the nth az invocation. */ | |
| 92 | function argsOfCall(index: number): ReadonlyArray<string> { | |
| 93 | const call = mockedExecute.mock.calls[index]; | |
| 94 | assert.isDefined(call); | |
| 95 | return call[0].args; | |
| 96 | } | |
| 97 | ||
| 98 | /** The output ceiling the nth az invocation asked for, if it asked for one at all. */ | |
| 99 | function maxOutputBytesOfCall(index: number) { | |
| 100 | const call = mockedExecute.mock.calls[index]; | |
| 101 | assert.isDefined(call); | |
| 102 | return call[0].maxOutputBytes; | |
| 103 | } | |
| 104 | ||
| 105 | /** A page of change entries the size Azure really answers with, url and object ids and all. */ | |
| 106 | function changeEntries(count: number): ReadonlyArray<Record<string, unknown>> { | |
| 107 | const commit = "c".repeat(40); | |
| 108 | return Array.from({ length: count }, (_, index) => { | |
| 109 | const path = `/apps/server/src/generated/module-${index}/persisted-projection-${index}.ts`; | |
| 110 | return { | |
| 111 | changeType: "edit", | |
| 112 | item: { | |
| 113 | path, | |
| 114 | objectId: "a".repeat(40), | |
| 115 | originalObjectId: "b".repeat(40), | |
| 116 | commitId: commit, | |
| 117 | gitObjectType: "blob", | |
| 118 | url: `https://dev.azure.com/acme/platform/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/items${path}?versionType=Commit&version=${commit}`, | |
| 119 | }, | |
| 120 | }; | |
| 121 | }); | |
| 122 | } | |
| 123 | ||
| 124 | /** An Azure identity, which rides along with every comment and every push Azure answers with. */ | |
| 125 | function identity(name: string) { | |
| 126 | const id = "6f9c9b7f-0000-0000-0000-000000000000"; | |
| 127 | return { | |
| 128 | displayName: name, | |
| 129 | id, | |
| 130 | uniqueName: `${name.toLowerCase().replace(/ /g, ".")}@acme.com`, | |
| 131 | descriptor: `aad.${"z".repeat(52)}`, | |
| 132 | imageUrl: `https://dev.azure.com/acme/_api/_common/identityImage?id=${id}`, | |
| 133 | url: `https://spsprodweu1.vssps.visualstudio.com/A${id}/_apis/Identities/${id}`, | |
| 134 | _links: { | |
| 135 | avatar: { | |
| 136 | href: `https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.${"z".repeat(52)}`, | |
| 137 | }, | |
| 138 | }, | |
| 139 | }; | |
| 140 | } | |
| 141 | ||
| 142 | /** A review's threads the shape Azure answers with, system threads and identities and all. */ | |
| 143 | function threadRows(count: number): ReadonlyArray<Record<string, unknown>> { | |
| 144 | return Array.from({ length: count }, (_, index) => ({ | |
| 145 | id: index + 1, | |
| 146 | publishedDate: "2026-07-02T00:00:00Z", | |
| 147 | lastUpdatedDate: "2026-07-02T00:00:00Z", | |
| 148 | status: "active", | |
| 149 | threadContext: { filePath: `/apps/server/src/generated/module-${index}.ts` }, | |
| 150 | identities: { 1: identity("Reviewer One") }, | |
| 151 | isDeleted: false, | |
| 152 | comments: [ | |
| 153 | { | |
| 154 | id: 1, | |
| 155 | parentCommentId: 0, | |
| 156 | author: identity("Reviewer One"), | |
| 157 | content: `Comment ${index}: ${"this needs another look. ".repeat(20)}`, | |
| 158 | publishedDate: "2026-07-02T00:00:00Z", | |
| 159 | lastUpdatedDate: "2026-07-02T00:00:00Z", | |
| 160 | commentType: "text", | |
| 161 | usersLiked: [], | |
| 162 | }, | |
| 163 | ], | |
| 164 | })); | |
| 165 | } | |
| 166 | ||
| 167 | /** A review's iterations the shape Azure answers with, one per push. */ | |
| 168 | function iterationRows(count: number): ReadonlyArray<Record<string, unknown>> { | |
| 169 | return Array.from({ length: count }, (_, index) => ({ | |
| 170 | id: index + 1, | |
| 171 | description: `Pushed ${index} commits`, | |
| 172 | author: identity("Author One"), | |
| 173 | createdDate: "2026-07-02T00:00:00Z", | |
| 174 | updatedDate: "2026-07-02T00:00:00Z", | |
| 175 | sourceRefCommit: { commitId: `${index}`.padStart(40, "a") }, | |
| 176 | targetRefCommit: { commitId: "b".repeat(40) }, | |
| 177 | commonRefCommit: { commitId: "c".repeat(40) }, | |
| 178 | hasMultipleCommits: true, | |
| 179 | reason: "push", | |
| 180 | push: { pushId: index + 1, date: "2026-07-02T00:00:00Z", pushedBy: identity("Author One") }, | |
| 181 | })); | |
| 182 | } | |
| 183 | ||
| 184 | afterEach(() => { | |
| 185 | mockedExecute.mockReset(); | |
| 186 | }); | |
| 187 | ||
| 188 | layer("AzureDevOpsPullRequestCli.layer", (it) => { | |
| 189 | it.effect("asks for one row more than the page, to probe for a next page", () => | |
| 190 | Effect.gen(function* () { | |
| 191 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); | |
| 192 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 193 | ||
| 194 | const batch = yield* cli.listPullRequests({ | |
| 195 | cwd: "/w", | |
| 196 | repository: "web", | |
| 197 | state: "open", | |
| 198 | involvement: "all", | |
| 199 | viewer: "bilal@acme.dev", | |
| 200 | limit: 10, | |
| 201 | }); | |
| 202 | ||
| 203 | assert.strictEqual(batch.items.length, 3); | |
| 204 | assert.isFalse(batch.truncated); | |
| 205 | expect(argsOfCall(0)).toEqual([ | |
| 206 | "repos", | |
| 207 | "pr", | |
| 208 | "list", | |
| 209 | "--detect", | |
| 210 | "true", | |
| 211 | "--repository", | |
| 212 | "web", | |
| 213 | "--status", | |
| 214 | "active", | |
| 215 | "--include-links", | |
| 216 | "--top", | |
| 217 | "11", | |
| 218 | "--only-show-errors", | |
| 219 | "--output", | |
| 220 | "json", | |
| 221 | ]); | |
| 222 | }), | |
| 223 | ); | |
| 224 | ||
| 225 | it.effect("reads an Azure pull request page larger than the VCS default output limit", () => | |
| 226 | Effect.gen(function* () { | |
| 227 | const rows = pullRequestRows(100, 1).map((row) => ({ | |
| 228 | ...row, | |
| 229 | description: "x".repeat(10_000), | |
| 230 | })); | |
| 231 | const response = JSON.stringify(rows); | |
| 232 | expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000); | |
| 233 | ||
| 234 | mockedExecute.mockImplementationOnce((input) => | |
| 235 | Effect.succeed(outputWithin(input.maxOutputBytes, response)), | |
| 236 | ); | |
| 237 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 238 | ||
| 239 | const batch = yield* cli.listPullRequests({ | |
| 240 | cwd: "/w", | |
| 241 | repository: "web", | |
| 242 | state: "merged", | |
| 243 | involvement: "all", | |
| 244 | viewer: "bilal@acme.dev", | |
| 245 | limit: 99, | |
| 246 | }); | |
| 247 | ||
| 248 | assert.strictEqual(batch.items.length, 99); | |
| 249 | assert.isTrue(batch.truncated); | |
| 250 | }), | |
| 251 | ); | |
| 252 | ||
| 253 | it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () => | |
| 254 | Effect.gen(function* () { | |
| 255 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); | |
| 256 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 257 | ||
| 258 | const page = yield* provider.listChangeRequests({ | |
| 259 | cwd: "/w", | |
| 260 | repository: "web", | |
| 261 | host: "dev.azure.com", | |
| 262 | state: "open", | |
| 263 | involvement: "all", | |
| 264 | viewer: "bilal@acme.dev", | |
| 265 | limit: 10, | |
| 266 | query: "page", | |
| 267 | }); | |
| 268 | ||
| 269 | // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at | |
| 270 | // all. The rows come back as they would have without a search, for the caller to narrow; | |
| 271 | // nothing of the search reaches the command, where it could only mean the wrong thing. | |
| 272 | assert.strictEqual(page.items.length, 3); | |
| 273 | expect(argsOfCall(0)).toEqual([ | |
| 274 | "repos", | |
| 275 | "pr", | |
| 276 | "list", | |
| 277 | "--detect", | |
| 278 | "true", | |
| 279 | "--repository", | |
| 280 | "web", | |
| 281 | "--status", | |
| 282 | "active", | |
| 283 | "--include-links", | |
| 284 | "--top", | |
| 285 | "11", | |
| 286 | "--only-show-errors", | |
| 287 | "--output", | |
| 288 | "json", | |
| 289 | ]); | |
| 290 | }), | |
| 291 | ); | |
| 292 | ||
| 293 | it.effect("steps over what it has already handed over, which is all Azure can be told", () => | |
| 294 | Effect.gen(function* () { | |
| 295 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); | |
| 296 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 297 | ||
| 298 | yield* cli.listPullRequests({ | |
| 299 | cwd: "/w", | |
| 300 | repository: "web", | |
| 301 | state: "open", | |
| 302 | involvement: "all", | |
| 303 | viewer: "bilal@acme.dev", | |
| 304 | limit: 10, | |
| 305 | // The instant is the same cursor every other host reads; Azure has no filter for it and | |
| 306 | // takes the count instead. | |
| 307 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 20 }, | |
| 308 | }); | |
| 309 | ||
| 310 | const args = argsOfCall(0); | |
| 311 | expect(args).toContain("--skip"); | |
| 312 | assert.strictEqual(args[args.indexOf("--skip") + 1], "20"); | |
| 313 | expect(args).not.toContain("2026-07-02T00:00:00Z"); | |
| 314 | }), | |
| 315 | ); | |
| 316 | ||
| 317 | it.effect("reports truncation from the extra row", () => | |
| 318 | Effect.gen(function* () { | |
| 319 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); | |
| 320 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 321 | ||
| 322 | const batch = yield* cli.listPullRequests({ | |
| 323 | cwd: "/w", | |
| 324 | repository: "web", | |
| 325 | state: "open", | |
| 326 | involvement: "all", | |
| 327 | viewer: "bilal@acme.dev", | |
| 328 | limit: 10, | |
| 329 | }); | |
| 330 | ||
| 331 | assert.strictEqual(batch.items.length, 10); | |
| 332 | assert.isTrue(batch.truncated); | |
| 333 | assert.strictEqual(batch.cursorAdvance, 10); | |
| 334 | }), | |
| 335 | ); | |
| 336 | ||
| 337 | it.effect("advances by malformed raw rows and keeps reading until the page is full", () => | |
| 338 | Effect.gen(function* () { | |
| 339 | mockedExecute | |
| 340 | .mockReturnValueOnce( | |
| 341 | Effect.succeed( | |
| 342 | output( | |
| 343 | JSON.stringify([ | |
| 344 | { pullRequestId: "malformed" }, | |
| 345 | pullRequestRows(1, 1)[0], | |
| 346 | { pullRequestId: "also malformed" }, | |
| 347 | ]), | |
| 348 | ), | |
| 349 | ), | |
| 350 | ) | |
| 351 | .mockReturnValueOnce(Effect.succeed(output(pullRequests(2, 2)))); | |
| 352 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 353 | ||
| 354 | const batch = yield* cli.listPullRequests({ | |
| 355 | cwd: "/w", | |
| 356 | repository: "web", | |
| 357 | state: "open", | |
| 358 | involvement: "all", | |
| 359 | viewer: "bilal@acme.dev", | |
| 360 | limit: 2, | |
| 361 | }); | |
| 362 | ||
| 363 | expect(batch.items.map((item) => item.number)).toEqual([1, 2]); | |
| 364 | assert.isTrue(batch.truncated); | |
| 365 | // Three raw rows from the first request and one from the second produced this page. | |
| 366 | assert.strictEqual(batch.cursorAdvance, 4); | |
| 367 | const secondArgs = argsOfCall(1); | |
| 368 | assert.strictEqual(secondArgs[secondArgs.indexOf("--skip") + 1], "3"); | |
| 369 | assert.strictEqual(secondArgs[secondArgs.indexOf("--top") + 1], "2"); | |
| 370 | }), | |
| 371 | ); | |
| 372 | ||
| 373 | it.effect("narrows to the author on the authored tab", () => | |
| 374 | Effect.gen(function* () { | |
| 375 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 376 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 377 | ||
| 378 | yield* cli.listPullRequests({ | |
| 379 | cwd: "/w", | |
| 380 | repository: "web", | |
| 381 | state: "closed", | |
| 382 | involvement: "authored", | |
| 383 | viewer: "bilal@acme.dev", | |
| 384 | limit: 10, | |
| 385 | }); | |
| 386 | ||
| 387 | expect(argsOfCall(0)).toContain("--creator"); | |
| 388 | expect(argsOfCall(0)).toContain("bilal@acme.dev"); | |
| 389 | // Azure calls a closed pull request abandoned. | |
| 390 | expect(argsOfCall(0)).toContain("abandoned"); | |
| 391 | }), | |
| 392 | ); | |
| 393 | ||
| 394 | it.effect("asks Azure for every status on the All tab", () => | |
| 395 | Effect.gen(function* () { | |
| 396 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 397 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 398 | ||
| 399 | yield* cli.listPullRequests({ | |
| 400 | cwd: "/w", | |
| 401 | repository: "web", | |
| 402 | state: "all", | |
| 403 | involvement: "all", | |
| 404 | viewer: "bilal@acme.dev", | |
| 405 | limit: 10, | |
| 406 | }); | |
| 407 | ||
| 408 | expect(argsOfCall(0)).toContain("--status"); | |
| 409 | expect(argsOfCall(0)).toContain("all"); | |
| 410 | }), | |
| 411 | ); | |
| 412 | ||
| 413 | it.effect("narrows to the reviewer on the reviewing tab", () => | |
| 414 | Effect.gen(function* () { | |
| 415 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 416 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 417 | ||
| 418 | yield* cli.listPullRequests({ | |
| 419 | cwd: "/w", | |
| 420 | repository: "web", | |
| 421 | state: "open", | |
| 422 | involvement: "reviewing", | |
| 423 | viewer: "bilal@acme.dev", | |
| 424 | limit: 10, | |
| 425 | }); | |
| 426 | ||
| 427 | expect(argsOfCall(0)).toContain("--reviewer"); | |
| 428 | }), | |
| 429 | ); | |
| 430 | ||
| 431 | it.effect("reads the signed-in account, which az reports as a bare value", () => | |
| 432 | Effect.gen(function* () { | |
| 433 | // `--query user` unwraps the object, so the wrapper has to put it back. | |
| 434 | mockedExecute.mockReturnValueOnce( | |
| 435 | Effect.succeed(output(JSON.stringify({ name: "bilal@acme.dev", type: "user" }))), | |
| 436 | ); | |
| 437 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 438 | ||
| 439 | const viewer = yield* cli.getViewer({ cwd: "/w" }); | |
| 440 | ||
| 441 | assert.strictEqual(viewer, "bilal@acme.dev"); | |
| 442 | expect(argsOfCall(0)).toEqual([ | |
| 443 | "account", | |
| 444 | "show", | |
| 445 | "--query", | |
| 446 | "user", | |
| 447 | "--only-show-errors", | |
| 448 | "--output", | |
| 449 | "json", | |
| 450 | ]); | |
| 451 | }), | |
| 452 | ); | |
| 453 | ||
| 454 | it.effect("fails when nobody is signed in", () => | |
| 455 | Effect.gen(function* () { | |
| 456 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); | |
| 457 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 458 | ||
| 459 | const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); | |
| 460 | ||
| 461 | assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError"); | |
| 462 | }), | |
| 463 | ); | |
| 464 | ||
| 465 | it.effect("completes a pull request to merge it, squashing only when asked", () => | |
| 466 | Effect.gen(function* () { | |
| 467 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 468 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 469 | ||
| 470 | yield* cli.runPullRequestAction({ | |
| 471 | cwd: "/w", | |
| 472 | number: 42, | |
| 473 | action: "merge", | |
| 474 | mergeMethod: "squash", | |
| 475 | }); | |
| 476 | ||
| 477 | expect(argsOfCall(0)).toEqual([ | |
| 478 | "repos", | |
| 479 | "pr", | |
| 480 | "update", | |
| 481 | "--detect", | |
| 482 | "true", | |
| 483 | "--id", | |
| 484 | "42", | |
| 485 | "--status", | |
| 486 | "completed", | |
| 487 | "--squash", | |
| 488 | "true", | |
| 489 | "--only-show-errors", | |
| 490 | "--output", | |
| 491 | "json", | |
| 492 | ]); | |
| 493 | }), | |
| 494 | ); | |
| 495 | ||
| 496 | it.effect("stores the squash choice with an auto-completion, as a merge now does", () => | |
| 497 | Effect.gen(function* () { | |
| 498 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 499 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 500 | ||
| 501 | yield* cli.runPullRequestAction({ | |
| 502 | cwd: "/w", | |
| 503 | number: 42, | |
| 504 | action: "enable-auto-merge", | |
| 505 | mergeMethod: "squash", | |
| 506 | }); | |
| 507 | ||
| 508 | expect(argsOfCall(0)).toEqual([ | |
| 509 | "repos", | |
| 510 | "pr", | |
| 511 | "update", | |
| 512 | "--detect", | |
| 513 | "true", | |
| 514 | "--id", | |
| 515 | "42", | |
| 516 | "--auto-complete", | |
| 517 | "true", | |
| 518 | "--squash", | |
| 519 | "true", | |
| 520 | "--only-show-errors", | |
| 521 | "--output", | |
| 522 | "json", | |
| 523 | ]); | |
| 524 | }), | |
| 525 | ); | |
| 526 | ||
| 527 | it.effect.each([ | |
| 528 | { action: "enable-auto-merge", expected: ["--auto-complete", "true"] }, | |
| 529 | { action: "disable-auto-merge", expected: ["--auto-complete", "false"] }, | |
| 530 | { action: "draft", expected: ["--draft", "true"] }, | |
| 531 | { action: "ready", expected: ["--draft", "false"] }, | |
| 532 | { action: "close", expected: ["--status", "abandoned"] }, | |
| 533 | { action: "reopen", expected: ["--status", "active"] }, | |
| 534 | ] as const)("moves a pull request with $action", ({ action, expected }) => | |
| 535 | Effect.gen(function* () { | |
| 536 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 537 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 538 | ||
| 539 | yield* cli.runPullRequestAction({ cwd: "/w", number: 42, action }); | |
| 540 | ||
| 541 | expect(argsOfCall(0)).toEqual([ | |
| 542 | "repos", | |
| 543 | "pr", | |
| 544 | "update", | |
| 545 | "--detect", | |
| 546 | "true", | |
| 547 | "--id", | |
| 548 | "42", | |
| 549 | ...expected, | |
| 550 | "--only-show-errors", | |
| 551 | "--output", | |
| 552 | "json", | |
| 553 | ]); | |
| 554 | }), | |
| 555 | ); | |
| 556 | ||
| 557 | it.effect.each([ | |
| 558 | { name: "a title", rewrite: { title: "Add the page" }, expected: ["--title=Add the page"] }, | |
| 559 | { | |
| 560 | name: "a description", | |
| 561 | rewrite: { body: "Why the page changed" }, | |
| 562 | expected: ["--description=Why the page changed"], | |
| 563 | }, | |
| 564 | { | |
| 565 | name: "both", | |
| 566 | rewrite: { title: "Add the page", body: "Why the page changed" }, | |
| 567 | expected: ["--title=Add the page", "--description=Why the page changed"], | |
| 568 | }, | |
| 569 | ] as const)("rewrites $name, sending nothing it was not given", ({ rewrite, expected }) => | |
| 570 | Effect.gen(function* () { | |
| 571 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 572 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 573 | ||
| 574 | yield* cli.updatePullRequest({ cwd: "/w", number: 42, ...rewrite }); | |
| 575 | ||
| 576 | expect(argsOfCall(0)).toEqual([ | |
| 577 | "repos", | |
| 578 | "pr", | |
| 579 | "update", | |
| 580 | "--detect", | |
| 581 | "true", | |
| 582 | "--id", | |
| 583 | "42", | |
| 584 | ...expected, | |
| 585 | "--only-show-errors", | |
| 586 | "--output", | |
| 587 | "json", | |
| 588 | ]); | |
| 589 | }), | |
| 590 | ); | |
| 591 | ||
| 592 | it.effect("sends a description that starts with a dash as one value, not as a flag", () => | |
| 593 | Effect.gen(function* () { | |
| 594 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 595 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 596 | ||
| 597 | yield* cli.updatePullRequest({ | |
| 598 | cwd: "/w", | |
| 599 | number: 42, | |
| 600 | body: "- rewrote the page\n- kept the rest", | |
| 601 | }); | |
| 602 | ||
| 603 | // One argument, so the leading dash of an ordinary bullet list never reaches az as a flag, | |
| 604 | // and the whole text stays together where `--description` would otherwise take several. | |
| 605 | expect(argsOfCall(0)).toContain("--description=- rewrote the page\n- kept the rest"); | |
| 606 | }), | |
| 607 | ); | |
| 608 | ||
| 609 | it.effect("rewrites through the provider, which says it takes one", () => | |
| 610 | Effect.gen(function* () { | |
| 611 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 612 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 613 | ||
| 614 | // False for a remark because nothing here can post one, so there is none to rewrite. | |
| 615 | expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: false }); | |
| 616 | assert.isDefined(provider.updateChangeRequest); | |
| 617 | yield* provider.updateChangeRequest({ | |
| 618 | cwd: "/w", | |
| 619 | repository: "web", | |
| 620 | host: "dev.azure.com", | |
| 621 | number: 42, | |
| 622 | title: "Add the page", | |
| 623 | }); | |
| 624 | ||
| 625 | expect(argsOfCall(0)).toContain("--title=Add the page"); | |
| 626 | expect(argsOfCall(0)).not.toContain("--description"); | |
| 627 | }), | |
| 628 | ); | |
| 629 | ||
| 630 | it.effect("names the head's blob as what a cleared file was cleared at", () => | |
| 631 | Effect.gen(function* () { | |
| 632 | mockedExecute | |
| 633 | .mockReturnValueOnce( | |
| 634 | Effect.succeed( | |
| 635 | output( | |
| 636 | JSON.stringify({ | |
| 637 | pullRequestId: 42, | |
| 638 | title: "Add the page", | |
| 639 | status: "active", | |
| 640 | sourceRefName: "refs/heads/feat/page", | |
| 641 | targetRefName: "refs/heads/main", | |
| 642 | creationDate: "2026-07-01T00:00:00Z", | |
| 643 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 644 | repository: { name: "web", project: { name: "platform" } }, | |
| 645 | }), | |
| 646 | ), | |
| 647 | ), | |
| 648 | ) | |
| 649 | .mockReturnValueOnce( | |
| 650 | Effect.succeed( | |
| 651 | output( | |
| 652 | JSON.stringify({ | |
| 653 | value: [ | |
| 654 | { | |
| 655 | id: 1, | |
| 656 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 657 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 658 | }, | |
| 659 | { | |
| 660 | id: 2, | |
| 661 | sourceRefCommit: { commitId: "c".repeat(40) }, | |
| 662 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 663 | }, | |
| 664 | ], | |
| 665 | }), | |
| 666 | ), | |
| 667 | ), | |
| 668 | ) | |
| 669 | .mockReturnValueOnce( | |
| 670 | Effect.succeed( | |
| 671 | output( | |
| 672 | JSON.stringify({ | |
| 673 | changeEntries: [ | |
| 674 | { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, | |
| 675 | { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, | |
| 676 | ], | |
| 677 | }), | |
| 678 | ), | |
| 679 | ), | |
| 680 | ); | |
| 681 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 682 | ||
| 683 | // Kept here rather than on Azure: its own record of what a reader has read is behind an | |
| 684 | // undocumented endpoint, so the marks belong to this environment and need a revision of | |
| 685 | // their own to tell a re-push from a file still as it was read. | |
| 686 | assert.strictEqual(provider.capabilities.viewedFiles, "environment"); | |
| 687 | assert.isDefined(provider.getFileRevisions); | |
| 688 | const answer = yield* provider.getFileRevisions({ | |
| 689 | cwd: "/w", | |
| 690 | repository: "web", | |
| 691 | host: "dev.azure.com", | |
| 692 | number: 42, | |
| 693 | paths: ["README.md"], | |
| 694 | }); | |
| 695 | ||
| 696 | // The latest push, since an iteration's changes are reported against the merge base rather | |
| 697 | // than against the push before it. | |
| 698 | expect(argsOfCall(2)).toContain("iterationId=2"); | |
| 699 | // Only what was asked for. DEMO.md changed too, and nobody has marked it. | |
| 700 | expect([...answer.revisions]).toEqual([["README.md", "8f80"]]); | |
| 701 | }), | |
| 702 | ); | |
| 703 | ||
| 704 | it.effect("reads where a pull request lives once, however often it is asked about", () => | |
| 705 | Effect.gen(function* () { | |
| 706 | // A pull request cannot move repositories, and the marks would otherwise pay for a whole | |
| 707 | // pull request read every time they checked whether a file had been pushed to. | |
| 708 | const pullRequest = Effect.succeed( | |
| 709 | output( | |
| 710 | JSON.stringify({ | |
| 711 | pullRequestId: 42, | |
| 712 | title: "Add the page", | |
| 713 | status: "active", | |
| 714 | sourceRefName: "refs/heads/feat/page", | |
| 715 | targetRefName: "refs/heads/main", | |
| 716 | creationDate: "2026-07-01T00:00:00Z", | |
| 717 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 718 | repository: { name: "web", project: { name: "platform" } }, | |
| 719 | }), | |
| 720 | ), | |
| 721 | ); | |
| 722 | const iterations = () => | |
| 723 | Effect.succeed( | |
| 724 | output( | |
| 725 | JSON.stringify({ | |
| 726 | value: [ | |
| 727 | { | |
| 728 | id: 1, | |
| 729 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 730 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 731 | }, | |
| 732 | ], | |
| 733 | }), | |
| 734 | ), | |
| 735 | ); | |
| 736 | const changes = () => | |
| 737 | Effect.succeed( | |
| 738 | output( | |
| 739 | JSON.stringify({ | |
| 740 | changeEntries: [ | |
| 741 | { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, | |
| 742 | ], | |
| 743 | }), | |
| 744 | ), | |
| 745 | ); | |
| 746 | mockedExecute | |
| 747 | .mockReturnValueOnce(pullRequest) | |
| 748 | .mockReturnValueOnce(iterations()) | |
| 749 | .mockReturnValueOnce(changes()) | |
| 750 | .mockReturnValueOnce(iterations()) | |
| 751 | .mockReturnValueOnce(changes()); | |
| 752 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 753 | const read = provider.getFileRevisions; | |
| 754 | assert.isDefined(read); | |
| 755 | const ask = () => | |
| 756 | read({ | |
| 757 | cwd: "/w", | |
| 758 | repository: "web", | |
| 759 | host: "dev.azure.com", | |
| 760 | number: 42, | |
| 761 | paths: ["README.md"], | |
| 762 | }); | |
| 763 | ||
| 764 | yield* ask(); | |
| 765 | const again = yield* ask(); | |
| 766 | ||
| 767 | assert.strictEqual(mockedExecute.mock.calls.length, 5); | |
| 768 | // The second read goes straight to the pushes, and still answers with the head's blob. | |
| 769 | expect(argsOfCall(3)).toContain("pullRequestIterations"); | |
| 770 | expect([...again.revisions]).toEqual([["README.md", "8f80"]]); | |
| 771 | }), | |
| 772 | ); | |
| 773 | ||
| 774 | it.effect( | |
| 775 | "answers for a marked file the pull request no longer changes as the empty version", | |
| 776 | () => | |
| 777 | Effect.gen(function* () { | |
| 778 | // Which is what was stored for it when it was ticked with nothing on the head, so a file | |
| 779 | // the pull request deletes is cleared once and stays cleared. | |
| 780 | mockedExecute | |
| 781 | .mockReturnValueOnce( | |
| 782 | Effect.succeed( | |
| 783 | output( | |
| 784 | json({ | |
| 785 | pullRequestId: 42, | |
| 786 | title: "Add the page", | |
| 787 | status: "active", | |
| 788 | sourceRefName: "refs/heads/feat/page", | |
| 789 | targetRefName: "refs/heads/main", | |
| 790 | creationDate: "2026-07-01T00:00:00Z", | |
| 791 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 792 | repository: { name: "web", project: { name: "platform" } }, | |
| 793 | }), | |
| 794 | ), | |
| 795 | ), | |
| 796 | ) | |
| 797 | .mockReturnValueOnce( | |
| 798 | Effect.succeed( | |
| 799 | output( | |
| 800 | json({ | |
| 801 | value: [ | |
| 802 | { | |
| 803 | id: 1, | |
| 804 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 805 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 806 | }, | |
| 807 | ], | |
| 808 | }), | |
| 809 | ), | |
| 810 | ), | |
| 811 | ) | |
| 812 | .mockReturnValueOnce(Effect.succeed(output('{"changeEntries":[]}'))); | |
| 813 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 814 | assert.isDefined(provider.getFileRevisions); | |
| 815 | ||
| 816 | const answer = yield* provider.getFileRevisions({ | |
| 817 | cwd: "/w", | |
| 818 | repository: "web", | |
| 819 | host: "dev.azure.com", | |
| 820 | number: 42, | |
| 821 | paths: ["GONE.md"], | |
| 822 | }); | |
| 823 | ||
| 824 | expect([...answer.revisions]).toEqual([["GONE.md", ""]]); | |
| 825 | }), | |
| 826 | ); | |
| 827 | ||
| 828 | it.effect("says nothing about the files past the end of a change it gave up following", () => | |
| 829 | Effect.gen(function* () { | |
| 830 | // Every page is an `az` process of its own, so a change past the ceiling stops being | |
| 831 | // followed. A path nobody looked at must not be answered for as deleted. | |
| 832 | const entries = (from: number, count: number) => | |
| 833 | Array.from({ length: count }, (_, index) => ({ | |
| 834 | changeType: "edit", | |
| 835 | item: { path: `/src/f${from + index}.ts`, objectId: `blob-${from + index}` }, | |
| 836 | })); | |
| 837 | mockedExecute | |
| 838 | .mockReturnValueOnce( | |
| 839 | Effect.succeed( | |
| 840 | output( | |
| 841 | json({ | |
| 842 | pullRequestId: 42, | |
| 843 | title: "Add the page", | |
| 844 | status: "active", | |
| 845 | sourceRefName: "refs/heads/feat/page", | |
| 846 | targetRefName: "refs/heads/main", | |
| 847 | creationDate: "2026-07-01T00:00:00Z", | |
| 848 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 849 | repository: { name: "web", project: { name: "platform" } }, | |
| 850 | }), | |
| 851 | ), | |
| 852 | ), | |
| 853 | ) | |
| 854 | .mockReturnValueOnce( | |
| 855 | Effect.succeed( | |
| 856 | output( | |
| 857 | json({ | |
| 858 | value: [ | |
| 859 | { | |
| 860 | id: 1, | |
| 861 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 862 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 863 | }, | |
| 864 | ], | |
| 865 | }), | |
| 866 | ), | |
| 867 | ), | |
| 868 | ) | |
| 869 | .mockReturnValueOnce( | |
| 870 | Effect.succeed(output(json({ changeEntries: entries(0, 5_000), nextSkip: 5_000 }))), | |
| 871 | ) | |
| 872 | .mockReturnValueOnce( | |
| 873 | Effect.succeed(output(json({ changeEntries: entries(5_000, 5_000), nextSkip: 10_000 }))), | |
| 874 | ); | |
| 875 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 876 | assert.isDefined(provider.getFileRevisions); | |
| 877 | ||
| 878 | const answer = yield* provider.getFileRevisions({ | |
| 879 | cwd: "/w", | |
| 880 | repository: "web", | |
| 881 | host: "dev.azure.com", | |
| 882 | number: 42, | |
| 883 | paths: ["src/f1.ts", "src/f5001.ts", "src/past-the-cut.ts"], | |
| 884 | }); | |
| 885 | ||
| 886 | // The second page picks up where the first said it ended. | |
| 887 | expect(argsOfCall(3)).toContain("$skip=5000"); | |
| 888 | expect([...answer.revisions]).toEqual([ | |
| 889 | ["src/f1.ts", "blob-1"], | |
| 890 | ["src/f5001.ts", "blob-5001"], | |
| 891 | ]); | |
| 892 | }), | |
| 893 | ); | |
| 894 | ||
| 895 | it.effect("stops following pages by what Azure counts, not by what survives decoding", () => | |
| 896 | Effect.gen(function* () { | |
| 897 | mockedExecute | |
| 898 | .mockReturnValueOnce( | |
| 899 | Effect.succeed( | |
| 900 | output( | |
| 901 | json({ | |
| 902 | pullRequestId: 42, | |
| 903 | title: "Add the page", | |
| 904 | status: "active", | |
| 905 | sourceRefName: "refs/heads/feat/page", | |
| 906 | targetRefName: "refs/heads/main", | |
| 907 | creationDate: "2026-07-01T00:00:00Z", | |
| 908 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 909 | repository: { name: "web", project: { name: "platform" } }, | |
| 910 | }), | |
| 911 | ), | |
| 912 | ), | |
| 913 | ) | |
| 914 | .mockReturnValueOnce( | |
| 915 | Effect.succeed( | |
| 916 | output( | |
| 917 | json({ | |
| 918 | value: [ | |
| 919 | { | |
| 920 | id: 1, | |
| 921 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 922 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 923 | }, | |
| 924 | ], | |
| 925 | }), | |
| 926 | ), | |
| 927 | ), | |
| 928 | ) | |
| 929 | // Nothing a review can show, so every page decodes to nothing at all and a ceiling counted | |
| 930 | // in files would never be reached however long the walk went on. | |
| 931 | .mockImplementation((command) => { | |
| 932 | const skip = command.args.find((arg) => arg.startsWith("$skip=")); | |
| 933 | const from = Number(skip?.slice("$skip=".length) ?? 0); | |
| 934 | return Effect.succeed( | |
| 935 | output( | |
| 936 | json({ | |
| 937 | changeEntries: [{ changeType: "add", item: { path: "/src", isFolder: true } }], | |
| 938 | nextSkip: from + 2_000, | |
| 939 | }), | |
| 940 | ), | |
| 941 | ); | |
| 942 | }); | |
| 943 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 944 | assert.isDefined(provider.getFileRevisions); | |
| 945 | ||
| 946 | const answer = yield* provider.getFileRevisions({ | |
| 947 | cwd: "/w", | |
| 948 | repository: "web", | |
| 949 | host: "dev.azure.com", | |
| 950 | number: 42, | |
| 951 | paths: ["src/page.ts"], | |
| 952 | }); | |
| 953 | ||
| 954 | // The pull request, its pushes, and five pages: the walk gives up on Azure's own offset | |
| 955 | // rather than spending an `az` process a page for as long as Azure keeps paging. | |
| 956 | assert.strictEqual(mockedExecute.mock.calls.length, 7); | |
| 957 | // And it read part of a change, so it says nothing about the file it never saw. | |
| 958 | assert.strictEqual(answer.revisions.size, 0); | |
| 959 | }), | |
| 960 | ); | |
| 961 | ||
| 962 | it.effect("leaves one file the host would not hand over listed without its hunks", () => | |
| 963 | Effect.gen(function* () { | |
| 964 | mockedExecute | |
| 965 | .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))) | |
| 966 | .mockReturnValueOnce(Effect.succeed(output(json(oneIteration)))) | |
| 967 | .mockReturnValueOnce( | |
| 968 | Effect.succeed( | |
| 969 | output( | |
| 970 | json({ | |
| 971 | changeEntries: [ | |
| 972 | { changeType: "add", item: { path: "/huge.bin", objectId: "8f80" } }, | |
| 973 | { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, | |
| 974 | ], | |
| 975 | }), | |
| 976 | ), | |
| 977 | ), | |
| 978 | ) | |
| 979 | .mockReturnValueOnce( | |
| 980 | Effect.fail( | |
| 981 | new AzureDevOpsCli.AzureDevOpsCommandFailedError({ | |
| 982 | operation: "execute", | |
| 983 | command: "az", | |
| 984 | cwd: "/w", | |
| 985 | argumentCount: 1, | |
| 986 | cause: "the blob is past what the route will carry", | |
| 987 | }), | |
| 988 | ), | |
| 989 | ) | |
| 990 | .mockReturnValueOnce(Effect.succeed(output(json({ content: "hello\n" })))); | |
| 991 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 992 | ||
| 993 | const slice = yield* provider.getDiff({ | |
| 994 | cwd: "/w", | |
| 995 | repository: "web", | |
| 996 | host: "dev.azure.com", | |
| 997 | number: 42, | |
| 998 | }); | |
| 999 | ||
| 1000 | assert.isTrue(slice.truncated); | |
| 1001 | expect(slice.patch).toContain("diff --git a/huge.bin b/huge.bin"); | |
| 1002 | // And the file behind it still renders, which is the point of giving up on one file. | |
| 1003 | expect(slice.patch).toContain("+hello"); | |
| 1004 | }), | |
| 1005 | ); | |
| 1006 | ||
| 1007 | it.effect("fails the whole read when it is the connection that would not answer", () => | |
| 1008 | Effect.gen(function* () { | |
| 1009 | // A rate limit is not this file's problem, and answering with a change full of files listed | |
| 1010 | // without their hunks would read as a change nobody can see rather than as a host to wait | |
| 1011 | // for. | |
| 1012 | mockedExecute | |
| 1013 | .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))) | |
| 1014 | .mockReturnValueOnce(Effect.succeed(output(json(oneIteration)))) | |
| 1015 | .mockReturnValueOnce( | |
| 1016 | Effect.succeed( | |
| 1017 | output( | |
| 1018 | json({ | |
| 1019 | changeEntries: [ | |
| 1020 | { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, | |
| 1021 | ], | |
| 1022 | }), | |
| 1023 | ), | |
| 1024 | ), | |
| 1025 | ) | |
| 1026 | .mockReturnValueOnce( | |
| 1027 | Effect.fail( | |
| 1028 | new AzureDevOpsCli.AzureDevOpsCliRateLimitError({ | |
| 1029 | operation: "execute", | |
| 1030 | command: "az", | |
| 1031 | cwd: "/w", | |
| 1032 | argumentCount: 1, | |
| 1033 | cause: "429", | |
| 1034 | }), | |
| 1035 | ), | |
| 1036 | ); | |
| 1037 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 1038 | ||
| 1039 | const error = yield* Effect.flip( | |
| 1040 | provider.getDiff({ cwd: "/w", repository: "web", host: "dev.azure.com", number: 42 }), | |
| 1041 | ); | |
| 1042 | ||
| 1043 | assert.strictEqual(error.reason, "rate-limited"); | |
| 1044 | }), | |
| 1045 | ); | |
| 1046 | ||
| 1047 | it.effect("takes Azure's own word on a file it will not spell out", () => | |
| 1048 | Effect.gen(function* () { | |
| 1049 | mockedExecute | |
| 1050 | .mockReturnValueOnce( | |
| 1051 | Effect.succeed( | |
| 1052 | output( | |
| 1053 | json({ | |
| 1054 | pullRequestId: 42, | |
| 1055 | title: "Add the page", | |
| 1056 | status: "active", | |
| 1057 | sourceRefName: "refs/heads/feat/page", | |
| 1058 | targetRefName: "refs/heads/main", | |
| 1059 | creationDate: "2026-07-01T00:00:00Z", | |
| 1060 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 1061 | repository: { name: "web", project: { name: "platform" } }, | |
| 1062 | }), | |
| 1063 | ), | |
| 1064 | ), | |
| 1065 | ) | |
| 1066 | .mockReturnValueOnce( | |
| 1067 | Effect.succeed( | |
| 1068 | output( | |
| 1069 | json({ | |
| 1070 | value: [ | |
| 1071 | { | |
| 1072 | id: 1, | |
| 1073 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 1074 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 1075 | }, | |
| 1076 | ], | |
| 1077 | }), | |
| 1078 | ), | |
| 1079 | ), | |
| 1080 | ) | |
| 1081 | .mockReturnValueOnce( | |
| 1082 | Effect.succeed( | |
| 1083 | output( | |
| 1084 | json({ | |
| 1085 | changeEntries: [ | |
| 1086 | { changeType: "add", item: { path: "/logo.png", objectId: "8f80" } }, | |
| 1087 | ], | |
| 1088 | }), | |
| 1089 | ), | |
| 1090 | ), | |
| 1091 | ) | |
| 1092 | .mockReturnValueOnce( | |
| 1093 | Effect.succeed( | |
| 1094 | output(json({ content: "iVBORw0KGgo=", contentMetadata: { isBinary: true } })), | |
| 1095 | ), | |
| 1096 | ); | |
| 1097 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 1098 | ||
| 1099 | const slice = yield* provider.getDiff({ | |
| 1100 | cwd: "/w", | |
| 1101 | repository: "web", | |
| 1102 | host: "dev.azure.com", | |
| 1103 | number: 42, | |
| 1104 | }); | |
| 1105 | ||
| 1106 | // Azure leaves the metadata out unless it is asked for, and without it every file reads as | |
| 1107 | // text however it was stored. | |
| 1108 | expect(argsOfCall(3)).toContain("includeContentMetadata=true"); | |
| 1109 | expect(slice.patch).toContain("Binary files a/logo.png and b/logo.png differ"); | |
| 1110 | assert.isTrue(slice.truncated); | |
| 1111 | }), | |
| 1112 | ); | |
| 1113 | ||
| 1114 | it.effect("stops following pages when one of them does not move the cursor on", () => | |
| 1115 | Effect.gen(function* () { | |
| 1116 | mockedExecute | |
| 1117 | .mockReturnValueOnce( | |
| 1118 | Effect.succeed( | |
| 1119 | output( | |
| 1120 | json({ | |
| 1121 | pullRequestId: 42, | |
| 1122 | title: "Add the page", | |
| 1123 | status: "active", | |
| 1124 | sourceRefName: "refs/heads/feat/page", | |
| 1125 | targetRefName: "refs/heads/main", | |
| 1126 | creationDate: "2026-07-01T00:00:00Z", | |
| 1127 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 1128 | repository: { name: "web", project: { name: "platform" } }, | |
| 1129 | }), | |
| 1130 | ), | |
| 1131 | ), | |
| 1132 | ) | |
| 1133 | .mockReturnValueOnce( | |
| 1134 | Effect.succeed( | |
| 1135 | output( | |
| 1136 | json({ | |
| 1137 | value: [ | |
| 1138 | { | |
| 1139 | id: 1, | |
| 1140 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 1141 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 1142 | }, | |
| 1143 | ], | |
| 1144 | }), | |
| 1145 | ), | |
| 1146 | ), | |
| 1147 | ) | |
| 1148 | .mockReturnValueOnce( | |
| 1149 | Effect.succeed( | |
| 1150 | output( | |
| 1151 | json({ | |
| 1152 | changeEntries: [{ changeType: "edit", item: { path: "/a.ts", objectId: "8f80" } }], | |
| 1153 | nextSkip: 2_000, | |
| 1154 | }), | |
| 1155 | ), | |
| 1156 | ), | |
| 1157 | ) | |
| 1158 | .mockReturnValueOnce( | |
| 1159 | Effect.succeed( | |
| 1160 | output( | |
| 1161 | json({ | |
| 1162 | changeEntries: [{ changeType: "edit", item: { path: "/b.ts", objectId: "0ca4" } }], | |
| 1163 | nextSkip: 2_000, | |
| 1164 | }), | |
| 1165 | ), | |
| 1166 | ), | |
| 1167 | ); | |
| 1168 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 1169 | assert.isDefined(provider.getFileRevisions); | |
| 1170 | ||
| 1171 | const answer = yield* provider.getFileRevisions({ | |
| 1172 | cwd: "/w", | |
| 1173 | repository: "web", | |
| 1174 | host: "dev.azure.com", | |
| 1175 | number: 42, | |
| 1176 | paths: ["a.ts", "b.ts", "unlisted.ts"], | |
| 1177 | }); | |
| 1178 | ||
| 1179 | // Four reads and no more: a page pointing at where it already is would be read forever. | |
| 1180 | assert.strictEqual(mockedExecute.mock.calls.length, 4); | |
| 1181 | // And what was read is not the whole change, so the file nobody listed is left unanswered | |
| 1182 | // rather than reported as gone from the change request. | |
| 1183 | expect([...answer.revisions]).toEqual([ | |
| 1184 | ["a.ts", "8f80"], | |
| 1185 | ["b.ts", "0ca4"], | |
| 1186 | ]); | |
| 1187 | }), | |
| 1188 | ); | |
| 1189 | ||
| 1190 | it.effect("asks Azure nothing when no file has been ticked off", () => | |
| 1191 | Effect.gen(function* () { | |
| 1192 | mockedExecute.mockReturnValue(Effect.succeed(output('{"changeEntries":[]}'))); | |
| 1193 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 1194 | assert.isDefined(provider.getFileRevisions); | |
| 1195 | ||
| 1196 | const answer = yield* provider.getFileRevisions({ | |
| 1197 | cwd: "/w", | |
| 1198 | repository: "web", | |
| 1199 | host: "dev.azure.com", | |
| 1200 | number: 42, | |
| 1201 | paths: [], | |
| 1202 | }); | |
| 1203 | ||
| 1204 | expect(answer.revisions.size).toBe(0); | |
| 1205 | // Nothing was marked, so Azure was not asked at all. | |
| 1206 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 1207 | }), | |
| 1208 | ); | |
| 1209 | ||
| 1210 | it.effect("reads the conversation through the REST API, pinned to a version", () => | |
| 1211 | Effect.gen(function* () { | |
| 1212 | mockedExecute.mockReturnValueOnce( | |
| 1213 | Effect.succeed( | |
| 1214 | output( | |
| 1215 | JSON.stringify({ | |
| 1216 | value: [ | |
| 1217 | { | |
| 1218 | id: 1, | |
| 1219 | comments: [ | |
| 1220 | { id: 1, content: "Looks good.", publishedDate: "2026-07-02T00:00:00Z" }, | |
| 1221 | ], | |
| 1222 | }, | |
| 1223 | ], | |
| 1224 | }), | |
| 1225 | ), | |
| 1226 | ), | |
| 1227 | ); | |
| 1228 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1229 | ||
| 1230 | const comments = yield* cli.listThreads({ | |
| 1231 | cwd: "/w", | |
| 1232 | location: { project: "platform", repository: "web" }, | |
| 1233 | number: 42, | |
| 1234 | }); | |
| 1235 | ||
| 1236 | assert.strictEqual(comments.length, 1); | |
| 1237 | // `az devops invoke` rather than `az rest`: it signs in the way the azure-devops extension | |
| 1238 | // does, and `az rest` mints its own token against whichever tenant `az` defaults to. | |
| 1239 | expect(argsOfCall(0)).toContain("invoke"); | |
| 1240 | expect(argsOfCall(0)).toContain("pullRequestThreads"); | |
| 1241 | expect(argsOfCall(0)).toContain("project=platform"); | |
| 1242 | expect(argsOfCall(0)).toContain("repositoryId=web"); | |
| 1243 | expect(argsOfCall(0)).toContain("pullRequestId=42"); | |
| 1244 | // A review's threads grow with how long it ran, and this route does not page, so the read | |
| 1245 | // asks for more than the process default rather than taking whatever it is given. | |
| 1246 | expect(maxOutputBytesOfCall(0)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); | |
| 1247 | }), | |
| 1248 | ); | |
| 1249 | ||
| 1250 | it.effect("reads a long review's threads, which are past the default output limit", () => | |
| 1251 | Effect.gen(function* () { | |
| 1252 | const response = json({ value: threadRows(800) }); | |
| 1253 | // Azure opens a thread per vote and per ref update beside the ones people wrote, and every | |
| 1254 | // comment carries a full identity, so a review argued over for weeks outgrows the default. | |
| 1255 | expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); | |
| 1256 | mockedExecute.mockImplementationOnce((input) => | |
| 1257 | Effect.succeed(outputWithin(input.maxOutputBytes, response)), | |
| 1258 | ); | |
| 1259 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1260 | ||
| 1261 | const comments = yield* cli.listThreads({ | |
| 1262 | cwd: "/w", | |
| 1263 | location: { project: "platform", repository: "web" }, | |
| 1264 | number: 42, | |
| 1265 | }); | |
| 1266 | ||
| 1267 | assert.strictEqual(comments.length, 800); | |
| 1268 | }), | |
| 1269 | ); | |
| 1270 | ||
| 1271 | it.effect("reads a long review's iterations, which are past the default output limit", () => | |
| 1272 | Effect.gen(function* () { | |
| 1273 | const response = json({ value: iterationRows(1_200) }); | |
| 1274 | // This route does not page, so the whole history arrives at once. Cut at the default it is | |
| 1275 | // JSON stopping mid-string, and every diff and file revision read on this host fails with | |
| 1276 | // it, since each of them starts by asking which iteration is the latest. | |
| 1277 | expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); | |
| 1278 | mockedExecute.mockImplementationOnce((input) => | |
| 1279 | Effect.succeed(outputWithin(input.maxOutputBytes, response)), | |
| 1280 | ); | |
| 1281 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1282 | ||
| 1283 | const iterations = yield* cli.listIterations({ | |
| 1284 | cwd: "/w", | |
| 1285 | location: { project: "platform", repository: "web" }, | |
| 1286 | number: 42, | |
| 1287 | }); | |
| 1288 | ||
| 1289 | assert.strictEqual(iterations.length, 1_200); | |
| 1290 | assert.strictEqual(iterations.at(-1)?.id, 1_200); | |
| 1291 | }), | |
| 1292 | ); | |
| 1293 | ||
| 1294 | it.effect("reads a full page of change entries, which is past the default output limit", () => | |
| 1295 | Effect.gen(function* () { | |
| 1296 | const response = json({ changeEntries: changeEntries(2_000) }); | |
| 1297 | // Azure's own maximum for this route, and every entry carries a path, a url and three | |
| 1298 | // object ids, so an ordinary page of a large change already outgrows the default. | |
| 1299 | expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); | |
| 1300 | mockedExecute.mockImplementationOnce((input) => | |
| 1301 | Effect.succeed(outputWithin(input.maxOutputBytes, response)), | |
| 1302 | ); | |
| 1303 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1304 | ||
| 1305 | const page = yield* cli.listIterationChanges({ | |
| 1306 | cwd: "/w", | |
| 1307 | location: { project: "platform", repository: "web" }, | |
| 1308 | number: 42, | |
| 1309 | iterationId: 1, | |
| 1310 | }); | |
| 1311 | ||
| 1312 | // Cut at the default this would arrive as JSON stopping mid-string, and a perfectly | |
| 1313 | // ordinary page would be reported as a host answering with nonsense. | |
| 1314 | assert.strictEqual(page.changes.length, 2_000); | |
| 1315 | assert.isFalse(page.truncated); | |
| 1316 | }), | |
| 1317 | ); | |
| 1318 | ||
| 1319 | it.effect("reads a file whose JSON envelope is past the default output limit", () => | |
| 1320 | Effect.gen(function* () { | |
| 1321 | // Under a megabyte as bytes on the host, so this is a file the other hosts hand over. | |
| 1322 | const file = "const value = 1;\n".repeat(57_000); | |
| 1323 | const response = json({ content: file }); | |
| 1324 | expect(Buffer.byteLength(file)).toBeLessThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); | |
| 1325 | // And past it once Azure wraps it, because there is no route here that serves the bytes. | |
| 1326 | expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); | |
| 1327 | mockedExecute.mockImplementationOnce((input) => | |
| 1328 | Effect.succeed(outputWithin(input.maxOutputBytes, response)), | |
| 1329 | ); | |
| 1330 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1331 | ||
| 1332 | const item = yield* cli.readItemContent({ | |
| 1333 | cwd: "/w", | |
| 1334 | location: { project: "platform", repository: "web" }, | |
| 1335 | path: "src/generated/schema.ts", | |
| 1336 | commit: "a".repeat(40), | |
| 1337 | }); | |
| 1338 | ||
| 1339 | assert.strictEqual(item.contents, file); | |
| 1340 | assert.isFalse(item.isBinary); | |
| 1341 | }), | |
| 1342 | ); | |
| 1343 | ||
| 1344 | it.effect("asks for a file by Azure's own spelling of its path", () => | |
| 1345 | Effect.gen(function* () { | |
| 1346 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(json({ content: "const a = 1;" })))); | |
| 1347 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1348 | ||
| 1349 | // The path carried around here has had Azure's leading slash taken off so it matches the | |
| 1350 | // patch and the viewed mark. The items route is documented in Azure's spelling, so it goes | |
| 1351 | // back on the way out rather than being sent as the shorter name. | |
| 1352 | yield* cli.readItemContent({ | |
| 1353 | cwd: "/w", | |
| 1354 | location: { project: "platform", repository: "web" }, | |
| 1355 | path: "src/app.ts", | |
| 1356 | commit: "a".repeat(40), | |
| 1357 | }); | |
| 1358 | ||
| 1359 | expect(argsOfCall(0)).toContain("path=/src/app.ts"); | |
| 1360 | }), | |
| 1361 | ); | |
| 1362 | ||
| 1363 | it.effect("reports a pull request it cannot place as its own outcome", () => | |
| 1364 | Effect.gen(function* () { | |
| 1365 | mockedExecute.mockReturnValueOnce( | |
| 1366 | Effect.succeed( | |
| 1367 | output( | |
| 1368 | // Well-formed, but with nothing to build a link from: not a decode failure. | |
| 1369 | JSON.stringify({ | |
| 1370 | pullRequestId: 42, | |
| 1371 | title: "Add the page", | |
| 1372 | sourceRefName: "refs/heads/feat/page", | |
| 1373 | targetRefName: "refs/heads/main", | |
| 1374 | creationDate: "2026-07-01T00:00:00Z", | |
| 1375 | }), | |
| 1376 | ), | |
| 1377 | ), | |
| 1378 | ); | |
| 1379 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1380 | ||
| 1381 | const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 })); | |
| 1382 | ||
| 1383 | assert.strictEqual(error._tag, "AzureDevOpsPullRequestIncompleteError"); | |
| 1384 | }), | |
| 1385 | ); | |
| 1386 | ||
| 1387 | it.effect("fails the read when az returns something unreadable", () => | |
| 1388 | Effect.gen(function* () { | |
| 1389 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); | |
| 1390 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1391 | ||
| 1392 | const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 })); | |
| 1393 | ||
| 1394 | assert.strictEqual(error._tag, "AzureDevOpsPullRequestReadError"); | |
| 1395 | }), | |
| 1396 | ); | |
| 1397 | ||
| 1398 | it.effect("adds reviewers with the one command Azure has for it", () => | |
| 1399 | Effect.gen(function* () { | |
| 1400 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 1401 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1402 | ||
| 1403 | yield* cli.setPullRequestReviewers({ | |
| 1404 | cwd: "/w", | |
| 1405 | number: 42, | |
| 1406 | reviewers: ["octocat@acme.test", "hubot@acme.test"], | |
| 1407 | requested: true, | |
| 1408 | }); | |
| 1409 | ||
| 1410 | expect(argsOfCall(0)).toEqual([ | |
| 1411 | "repos", | |
| 1412 | "pr", | |
| 1413 | "reviewer", | |
| 1414 | "add", | |
| 1415 | "--detect", | |
| 1416 | "true", | |
| 1417 | "--id", | |
| 1418 | "42", | |
| 1419 | "--reviewers", | |
| 1420 | "octocat@acme.test", | |
| 1421 | "hubot@acme.test", | |
| 1422 | "--only-show-errors", | |
| 1423 | "--output", | |
| 1424 | "json", | |
| 1425 | ]); | |
| 1426 | }), | |
| 1427 | ); | |
| 1428 | ||
| 1429 | it.effect("takes a reviewer off the pull request with the same command's counterpart", () => | |
| 1430 | Effect.gen(function* () { | |
| 1431 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 1432 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1433 | ||
| 1434 | yield* cli.setPullRequestReviewers({ | |
| 1435 | cwd: "/w", | |
| 1436 | number: 42, | |
| 1437 | reviewers: ["octocat@acme.test"], | |
| 1438 | requested: false, | |
| 1439 | }); | |
| 1440 | ||
| 1441 | expect(argsOfCall(0)).toContain("remove"); | |
| 1442 | }), | |
| 1443 | ); | |
| 1444 | ||
| 1445 | it.effect("refuses a reviewer az would read as a flag, before running anything", () => | |
| 1446 | Effect.gen(function* () { | |
| 1447 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1448 | ||
| 1449 | const error = yield* Effect.flip( | |
| 1450 | cli.setPullRequestReviewers({ | |
| 1451 | cwd: "/w", | |
| 1452 | number: 42, | |
| 1453 | reviewers: ["--query"], | |
| 1454 | requested: true, | |
| 1455 | }), | |
| 1456 | ); | |
| 1457 | ||
| 1458 | assert.strictEqual(error._tag, "AzureDevOpsReviewerNameError"); | |
| 1459 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 1460 | }), | |
| 1461 | ); | |
| 1462 | }); |
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts deleted-744| ... | ... | @@ -1,744 +0,0 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Result from "effect/Result"; | |
| 5 | import * as Schema from "effect/Schema"; | |
| 6 | import type { | |
| 7 | PullRequestAction, | |
| 8 | PullRequestComment, | |
| 9 | PullRequestInvolvement, | |
| 10 | PullRequestListState, | |
| 11 | PullRequestMergeMethod, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | ||
| 14 | import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; | |
| 15 | import { | |
| 16 | decodeItemContentJson, | |
| 17 | decodeIterationChangesJson, | |
| 18 | decodeIterationsJson, | |
| 19 | decodePullRequestJson, | |
| 20 | decodePullRequestListJson, | |
| 21 | decodeThreadsJson, | |
| 22 | decodeViewerJson, | |
| 23 | type AzureDevOpsChangeEntry, | |
| 24 | type AzureDevOpsItemContent, | |
| 25 | type AzureDevOpsIteration, | |
| 26 | type AzureDevOpsPullRequest, | |
| 27 | type AzureDevOpsRepositoryLocation, | |
| 28 | } from "./azureDevOpsPullRequestJson.ts"; | |
| 29 | import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 30 | ||
| 31 | /** | |
| 32 | * Names the read that produced unusable output, so a failure reports the call it came from | |
| 33 | * rather than borrowing another operation's message. | |
| 34 | */ | |
| 35 | export class AzureDevOpsPullRequestReadError extends Schema.TaggedError<AzureDevOpsPullRequestReadError>()( | |
| 36 | "AzureDevOpsPullRequestReadError", | |
| 37 | { | |
| 38 | command: Schema.Literal("az"), | |
| 39 | cwd: Schema.String, | |
| 40 | operation: Schema.String, | |
| 41 | cause: Schema.Defect(), | |
| 42 | }, | |
| 43 | ) { | |
| 44 | get detail(): string { | |
| 45 | return `Azure CLI returned an unreadable ${this.operation} response.`; | |
| 46 | } | |
| 47 | ||
| 48 | override get message(): string { | |
| 49 | return `Azure CLI failed in ${this.operation}: ${this.detail}`; | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | /** Not a decode failure: az answered, the account it answered for just has no name. */ | |
| 54 | export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError<AzureDevOpsViewerUnavailableError>()( | |
| 55 | "AzureDevOpsViewerUnavailableError", | |
| 56 | { | |
| 57 | command: Schema.Literal("az"), | |
| 58 | cwd: Schema.String, | |
| 59 | }, | |
| 60 | ) { | |
| 61 | get detail(): string { | |
| 62 | return "Azure CLI returned no account for the current sign-in."; | |
| 63 | } | |
| 64 | ||
| 65 | override get message(): string { | |
| 66 | return `Azure CLI failed in getViewer: ${this.detail}`; | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * Not a decode failure either: az answered with a well-formed pull request that simply carries | |
| 72 | * no branch or link, which is a response this cannot place rather than one it cannot read. | |
| 73 | */ | |
| 74 | export class AzureDevOpsPullRequestIncompleteError extends Schema.TaggedError<AzureDevOpsPullRequestIncompleteError>()( | |
| 75 | "AzureDevOpsPullRequestIncompleteError", | |
| 76 | { | |
| 77 | command: Schema.Literal("az"), | |
| 78 | cwd: Schema.String, | |
| 79 | number: Schema.Int, | |
| 80 | }, | |
| 81 | ) { | |
| 82 | get detail(): string { | |
| 83 | return "Azure DevOps returned no branch or link for the pull request."; | |
| 84 | } | |
| 85 | ||
| 86 | override get message(): string { | |
| 87 | return `Azure CLI failed in getPullRequest: ${this.detail}`; | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * Not a decode failure: the reader named a reviewer `az` would read as a flag of its own. The | |
| 93 | * reviewers travel as argv rather than in a request body — `az repos pr reviewer` takes them no | |
| 94 | * other way — so anything that could leave the value position is refused rather than sent. | |
| 95 | */ | |
| 96 | export class AzureDevOpsReviewerNameError extends Schema.TaggedError<AzureDevOpsReviewerNameError>()( | |
| 97 | "AzureDevOpsReviewerNameError", | |
| 98 | { | |
| 99 | command: Schema.Literal("az"), | |
| 100 | cwd: Schema.String, | |
| 101 | }, | |
| 102 | ) { | |
| 103 | get detail(): string { | |
| 104 | return "A reviewer is named by an email address or an identity id."; | |
| 105 | } | |
| 106 | ||
| 107 | override get message(): string { | |
| 108 | return `Azure CLI failed in setPullRequestReviewers: ${this.detail}`; | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | export type AzureDevOpsPullRequestCliError = | |
| 113 | | AzureDevOpsCli.AzureDevOpsCliError | |
| 114 | | AzureDevOpsPullRequestReadError | |
| 115 | | AzureDevOpsPullRequestIncompleteError | |
| 116 | | AzureDevOpsReviewerNameError | |
| 117 | | AzureDevOpsViewerUnavailableError; | |
| 118 | ||
| 119 | /** The version every REST call below is pinned to, so a new default cannot reshape a response. */ | |
| 120 | const REST_API_VERSION = "7.1"; | |
| 121 | const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; | |
| 122 | /** | |
| 123 | * A full page of change entries is two thousand files, each carrying its path, its url and | |
| 124 | * several object ids, which is past the megabyte a read is given by default. Output cut at that | |
| 125 | * ceiling arrives here as JSON that will not parse, so a change large enough to be paged would | |
| 126 | * report itself as a host returning nonsense rather than as the ordinary page it is. | |
| 127 | */ | |
| 128 | const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | |
| 129 | /** | |
| 130 | * Four times the megabyte of file the other hosts hand over, because Azure has no route that | |
| 131 | * serves the bytes themselves: the file arrives inside a JSON envelope, escaped if it is text and | |
| 132 | * base64 if it is not, and both are larger than the file they carry. | |
| 133 | */ | |
| 134 | const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; | |
| 135 | /** | |
| 136 | * What a review's own history is given. Neither route pages, so each answers with the whole of it | |
| 137 | * at once and grows with how long the review ran rather than with how large the change is. Cut at | |
| 138 | * the default, both arrive as JSON that stops mid-string, and a long review would report its host | |
| 139 | * as answering with nonsense. | |
| 140 | */ | |
| 141 | const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | |
| 142 | ||
| 143 | /** Azure's own ceiling for one page of an iteration's changes. */ | |
| 144 | const CHANGE_ENTRIES_PER_PAGE = 2000; | |
| 145 | ||
| 146 | /** | |
| 147 | * Where following the pages stops, counted in the entries Azure was asked to skip rather than in | |
| 148 | * the files that survived decoding: a page can be entirely folders and other entries a review has | |
| 149 | * nothing to show for, and bounding on what was kept would follow such a change forever. Every | |
| 150 | * page is an `az` process of its own, so the read gives up and reports itself incomplete rather | |
| 151 | * than presenting five pages as the whole change. | |
| 152 | */ | |
| 153 | const MAX_CHANGE_ENTRIES = 10_000; | |
| 154 | ||
| 155 | /** What an iteration changed, and whether following its pages reached the end of it. */ | |
| 156 | export interface AzureDevOpsIterationChanges { | |
| 157 | readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>; | |
| 158 | readonly truncated: boolean; | |
| 159 | } | |
| 160 | ||
| 161 | export class AzureDevOpsPullRequestCli extends Context.Service< | |
| 162 | AzureDevOpsPullRequestCli, | |
| 163 | { | |
| 164 | readonly getViewer: (input: { | |
| 165 | readonly cwd: string; | |
| 166 | }) => Effect.Effect<string, AzureDevOpsPullRequestCliError>; | |
| 167 | ||
| 168 | readonly listPullRequests: (input: { | |
| 169 | readonly cwd: string; | |
| 170 | readonly repository: string; | |
| 171 | readonly state: PullRequestListState; | |
| 172 | readonly involvement: PullRequestInvolvement; | |
| 173 | readonly viewer: string; | |
| 174 | readonly limit: number; | |
| 175 | /** | |
| 176 | * Where to carry on from. Azure has no date filter for a pull request listing, so the only | |
| 177 | * part of a cursor it can use is how many rows have already been handed over. | |
| 178 | */ | |
| 179 | readonly cursor?: ProviderListCursor | undefined; | |
| 180 | }) => Effect.Effect< | |
| 181 | { | |
| 182 | readonly items: ReadonlyArray<AzureDevOpsPullRequest>; | |
| 183 | readonly truncated: boolean; | |
| 184 | /** Raw Azure rows consumed to produce this page, including malformed rows. */ | |
| 185 | readonly cursorAdvance: number; | |
| 186 | }, | |
| 187 | AzureDevOpsPullRequestCliError | |
| 188 | >; | |
| 189 | ||
| 190 | readonly getPullRequest: (input: { | |
| 191 | readonly cwd: string; | |
| 192 | readonly number: number; | |
| 193 | }) => Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError>; | |
| 194 | ||
| 195 | /** Threads are not reachable through `az repos pr`, so they come from the REST API. */ | |
| 196 | readonly listThreads: (input: { | |
| 197 | readonly cwd: string; | |
| 198 | readonly location: AzureDevOpsRepositoryLocation; | |
| 199 | readonly number: number; | |
| 200 | }) => Effect.Effect<ReadonlyArray<PullRequestComment>, AzureDevOpsPullRequestCliError>; | |
| 201 | ||
| 202 | /** | |
| 203 | * The pushes a pull request has had, oldest first. Azure hangs the changed files off an | |
| 204 | * iteration rather than off the pull request, so reading a diff starts here. | |
| 205 | */ | |
| 206 | readonly listIterations: (input: { | |
| 207 | readonly cwd: string; | |
| 208 | readonly location: AzureDevOpsRepositoryLocation; | |
| 209 | readonly number: number; | |
| 210 | }) => Effect.Effect<ReadonlyArray<AzureDevOpsIteration>, AzureDevOpsPullRequestCliError>; | |
| 211 | ||
| 212 | /** | |
| 213 | * What one iteration changed, against the merge base rather than against the previous push, | |
| 214 | * which is the whole of the pull request rather than the latest slice of it. | |
| 215 | */ | |
| 216 | readonly listIterationChanges: (input: { | |
| 217 | readonly cwd: string; | |
| 218 | readonly location: AzureDevOpsRepositoryLocation; | |
| 219 | readonly number: number; | |
| 220 | readonly iterationId: number; | |
| 221 | }) => Effect.Effect<AzureDevOpsIterationChanges, AzureDevOpsPullRequestCliError>; | |
| 222 | ||
| 223 | /** | |
| 224 | * One file's text at one commit. Azure has no diff route that carries content, so both sides | |
| 225 | * of every changed file are read this way and the patch is made from them here. | |
| 226 | */ | |
| 227 | readonly readItemContent: (input: { | |
| 228 | readonly cwd: string; | |
| 229 | readonly location: AzureDevOpsRepositoryLocation; | |
| 230 | readonly path: string; | |
| 231 | readonly commit: string; | |
| 232 | }) => Effect.Effect<AzureDevOpsItemContent, AzureDevOpsPullRequestCliError>; | |
| 233 | ||
| 234 | readonly runPullRequestAction: (input: { | |
| 235 | readonly cwd: string; | |
| 236 | readonly number: number; | |
| 237 | readonly action: PullRequestAction; | |
| 238 | readonly mergeMethod?: PullRequestMergeMethod; | |
| 239 | }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>; | |
| 240 | ||
| 241 | /** Rewrites the pull request's own words, through the same command that moves it. */ | |
| 242 | readonly updatePullRequest: (input: { | |
| 243 | readonly cwd: string; | |
| 244 | readonly number: number; | |
| 245 | readonly title?: string | undefined; | |
| 246 | readonly body?: string | undefined; | |
| 247 | }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>; | |
| 248 | ||
| 249 | /** | |
| 250 | * Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole | |
| 251 | * of what Azure offers here: it adds and removes named identities, and has no counterpart that | |
| 252 | * says who could be named. | |
| 253 | */ | |
| 254 | readonly setPullRequestReviewers: (input: { | |
| 255 | readonly cwd: string; | |
| 256 | readonly number: number; | |
| 257 | readonly reviewers: ReadonlyArray<string>; | |
| 258 | readonly requested: boolean; | |
| 259 | }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>; | |
| 260 | } | |
| 261 | >()("t3/pullRequest/AzureDevOpsPullRequestCli") {} | |
| 262 | ||
| 263 | function statusArgs(state: PullRequestListState): ReadonlyArray<string> { | |
| 264 | switch (state) { | |
| 265 | case "open": | |
| 266 | return ["--status", "active"]; | |
| 267 | case "merged": | |
| 268 | return ["--status", "completed"]; | |
| 269 | case "closed": | |
| 270 | return ["--status", "abandoned"]; | |
| 271 | case "all": | |
| 272 | return ["--status", "all"]; | |
| 273 | } | |
| 274 | } | |
| 275 | ||
| 276 | function involvementArgs(input: { | |
| 277 | readonly involvement: PullRequestInvolvement; | |
| 278 | readonly viewer: string; | |
| 279 | }): ReadonlyArray<string> { | |
| 280 | switch (input.involvement) { | |
| 281 | case "authored": | |
| 282 | return ["--creator", input.viewer]; | |
| 283 | case "reviewing": | |
| 284 | return ["--reviewer", input.viewer]; | |
| 285 | case "all": | |
| 286 | return []; | |
| 287 | } | |
| 288 | } | |
| 289 | ||
| 290 | /** | |
| 291 | * Azure moves a pull request by setting its state rather than by named commands: completing it | |
| 292 | * is the merge, abandoning it is the close, and reactivating it is the reopen. Squashing is a | |
| 293 | * completion option rather than a strategy of its own. | |
| 294 | */ | |
| 295 | function actionArgs( | |
| 296 | action: PullRequestAction, | |
| 297 | mergeMethod: PullRequestMergeMethod | undefined, | |
| 298 | ): ReadonlyArray<string> { | |
| 299 | switch (action) { | |
| 300 | case "merge": | |
| 301 | return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"]; | |
| 302 | // Auto-complete is Azure's own name for it: the pull request stays active and Azure completes | |
| 303 | // it once its policies pass. The squash choice is stored with it, as it is for a merge now. | |
| 304 | case "enable-auto-merge": | |
| 305 | return [ | |
| 306 | "--auto-complete", | |
| 307 | "true", | |
| 308 | ...(mergeMethod === undefined | |
| 309 | ? [] | |
| 310 | : ["--squash", mergeMethod === "squash" ? "true" : "false"]), | |
| 311 | ]; | |
| 312 | case "disable-auto-merge": | |
| 313 | return ["--auto-complete", "false"]; | |
| 314 | case "ready": | |
| 315 | return ["--draft", "false"]; | |
| 316 | case "draft": | |
| 317 | return ["--draft", "true"]; | |
| 318 | case "close": | |
| 319 | return ["--status", "abandoned"]; | |
| 320 | // Never reached: this host does not declare the action, so nothing offers it. | |
| 321 | case "update-branch": | |
| 322 | return []; | |
| 323 | case "reopen": | |
| 324 | return ["--status", "active"]; | |
| 325 | // Never reached: this host does not declare the action, so the service refuses it first. | |
| 326 | case "revert": | |
| 327 | case "approve-workflows": | |
| 328 | throw new Error(`Azure DevOps pull request action ${action} is unsupported`); | |
| 329 | } | |
| 330 | } | |
| 331 | ||
| 332 | /** | |
| 333 | * A reviewer Azure could be given: an email address, a display name or an identity guid, and | |
| 334 | * nothing that starts with a dash. The dash is the whole point — these are argv, and a value that | |
| 335 | * looks like a flag stops being a value. | |
| 336 | */ | |
| 337 | function isReviewerName(value: string): boolean { | |
| 338 | const name = value.trim(); | |
| 339 | return name.length > 0 && !name.startsWith("-"); | |
| 340 | } | |
| 341 | ||
| 342 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 343 | export const make = Effect.gen(function* () { | |
| 344 | const azure = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 345 | ||
| 346 | // Every command resolves the organization, project and repository from the checkout, which is | |
| 347 | // what the rest of the Azure wrapper does. The remote takes three shapes and only `az` knows | |
| 348 | // how to read all of them. | |
| 349 | const detectArgs = ["--detect", "true"] as const; | |
| 350 | ||
| 351 | const executeJson = (input: { | |
| 352 | readonly cwd: string; | |
| 353 | readonly args: ReadonlyArray<string>; | |
| 354 | readonly maxOutputBytes?: number; | |
| 355 | }) => | |
| 356 | azure.execute({ | |
| 357 | cwd: input.cwd, | |
| 358 | args: [...input.args, "--only-show-errors", "--output", "json"], | |
| 359 | ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), | |
| 360 | }); | |
| 361 | ||
| 362 | /** | |
| 363 | * A REST route reached through `az devops invoke`, which addresses it by area, resource and | |
| 364 | * route parameters rather than by URL. Used in place of `az rest` because it signs in the way | |
| 365 | * the azure-devops extension does: `az rest` mints its own token against the tenant `az` | |
| 366 | * defaults to, and an organisation in any other tenant answers that with a sign-in page, which | |
| 367 | * arrives here as unreadable output rather than as a failure. | |
| 368 | */ | |
| 369 | const invoke = <A>(input: { | |
| 370 | readonly cwd: string; | |
| 371 | readonly operation: string; | |
| 372 | readonly resource: string; | |
| 373 | readonly routeParameters: ReadonlyArray<string>; | |
| 374 | readonly queryParameters?: ReadonlyArray<string>; | |
| 375 | readonly maxOutputBytes?: number; | |
| 376 | readonly decode: (raw: string) => Result.Result<A, unknown>; | |
| 377 | }): Effect.Effect<A, AzureDevOpsPullRequestCliError> => | |
| 378 | executeJson({ | |
| 379 | cwd: input.cwd, | |
| 380 | ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), | |
| 381 | args: [ | |
| 382 | "devops", | |
| 383 | "invoke", | |
| 384 | ...detectArgs, | |
| 385 | "--area", | |
| 386 | "git", | |
| 387 | "--resource", | |
| 388 | input.resource, | |
| 389 | "--api-version", | |
| 390 | REST_API_VERSION, | |
| 391 | "--route-parameters", | |
| 392 | ...input.routeParameters, | |
| 393 | ...(input.queryParameters === undefined | |
| 394 | ? [] | |
| 395 | : ["--query-parameters", ...input.queryParameters]), | |
| 396 | ], | |
| 397 | }).pipe( | |
| 398 | Effect.flatMap((result) => { | |
| 399 | const decoded = input.decode(result.stdout.trim()); | |
| 400 | return Result.isSuccess(decoded) | |
| 401 | ? Effect.succeed(decoded.success) | |
| 402 | : Effect.fail( | |
| 403 | new AzureDevOpsPullRequestReadError({ | |
| 404 | command: "az", | |
| 405 | cwd: input.cwd, | |
| 406 | operation: input.operation, | |
| 407 | cause: decoded.failure, | |
| 408 | }), | |
| 409 | ); | |
| 410 | }), | |
| 411 | ); | |
| 412 | ||
| 413 | /** | |
| 414 | * Azure names its own items with a leading slash, which the repository paths carried around | |
| 415 | * here have had taken off so they match the patch and the viewed mark. Put it back on the way | |
| 416 | * out, because the items route is documented in Azure's own spelling. | |
| 417 | */ | |
| 418 | const toItemPath = (path: string) => (path.startsWith("/") ? path : `/${path}`); | |
| 419 | ||
| 420 | const repositoryRoute = (location: AzureDevOpsRepositoryLocation): ReadonlyArray<string> => [ | |
| 421 | `project=${location.project}`, | |
| 422 | `repositoryId=${location.repository}`, | |
| 423 | ]; | |
| 424 | ||
| 425 | const pullRequestRoute = (input: { | |
| 426 | readonly location: AzureDevOpsRepositoryLocation; | |
| 427 | readonly number: number; | |
| 428 | }): ReadonlyArray<string> => [ | |
| 429 | ...repositoryRoute(input.location), | |
| 430 | `pullRequestId=${input.number}`, | |
| 431 | ]; | |
| 432 | ||
| 433 | /** | |
| 434 | * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and | |
| 435 | * retain the raw count so the next public cursor skips every row this walk consumed. | |
| 436 | */ | |
| 437 | const listPullRequestPage = (input: { | |
| 438 | readonly cwd: string; | |
| 439 | readonly repository: string; | |
| 440 | readonly state: PullRequestListState; | |
| 441 | readonly involvement: PullRequestInvolvement; | |
| 442 | readonly viewer: string; | |
| 443 | readonly limit: number; | |
| 444 | readonly skip: number; | |
| 445 | readonly cursorAdvance: number; | |
| 446 | readonly items: ReadonlyArray<AzureDevOpsPullRequest>; | |
| 447 | }): Effect.Effect< | |
| 448 | { | |
| 449 | readonly items: ReadonlyArray<AzureDevOpsPullRequest>; | |
| 450 | readonly truncated: boolean; | |
| 451 | readonly cursorAdvance: number; | |
| 452 | }, | |
| 453 | AzureDevOpsPullRequestCliError | |
| 454 | > => { | |
| 455 | const remaining = input.limit - input.items.length; | |
| 456 | const top = remaining + 1; | |
| 457 | return executeJson({ | |
| 458 | cwd: input.cwd, | |
| 459 | maxOutputBytes: PULL_REQUEST_LIST_MAX_OUTPUT_BYTES, | |
| 460 | args: [ | |
| 461 | "repos", | |
| 462 | "pr", | |
| 463 | "list", | |
| 464 | ...detectArgs, | |
| 465 | "--repository", | |
| 466 | input.repository, | |
| 467 | ...statusArgs(input.state), | |
| 468 | ...involvementArgs(input), | |
| 469 | // A web link per row, which is the only url that needs no assembling. | |
| 470 | "--include-links", | |
| 471 | ...(input.skip === 0 ? [] : ["--skip", String(input.skip)]), | |
| 472 | "--top", | |
| 473 | String(top), | |
| 474 | ], | |
| 475 | }).pipe( | |
| 476 | Effect.flatMap((result) => { | |
| 477 | const raw = result.stdout.trim(); | |
| 478 | if (raw.length === 0) { | |
| 479 | return Effect.succeed({ | |
| 480 | items: input.items, | |
| 481 | truncated: false, | |
| 482 | cursorAdvance: input.cursorAdvance, | |
| 483 | }); | |
| 484 | } | |
| 485 | const decoded = decodePullRequestListJson(raw); | |
| 486 | if (!Result.isSuccess(decoded)) { | |
| 487 | return Effect.fail( | |
| 488 | new AzureDevOpsPullRequestReadError({ | |
| 489 | command: "az", | |
| 490 | cwd: input.cwd, | |
| 491 | operation: "listPullRequests", | |
| 492 | cause: decoded.failure, | |
| 493 | }), | |
| 494 | ); | |
| 495 | } | |
| 496 | ||
| 497 | const lastItemIndex = decoded.success.rawIndexes[remaining - 1]; | |
| 498 | if (lastItemIndex !== undefined) { | |
| 499 | const consumed = lastItemIndex + 1; | |
| 500 | return Effect.succeed({ | |
| 501 | items: [...input.items, ...decoded.success.items.slice(0, remaining)], | |
| 502 | // A full raw response may have more rows even when malformed entries used the probe. | |
| 503 | truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top, | |
| 504 | cursorAdvance: input.cursorAdvance + consumed, | |
| 505 | }); | |
| 506 | } | |
| 507 | ||
| 508 | const items = [...input.items, ...decoded.success.items]; | |
| 509 | if (decoded.success.rawCount < top) { | |
| 510 | return Effect.succeed({ | |
| 511 | items, | |
| 512 | truncated: false, | |
| 513 | cursorAdvance: input.cursorAdvance + decoded.success.rawCount, | |
| 514 | }); | |
| 515 | } | |
| 516 | return listPullRequestPage({ | |
| 517 | ...input, | |
| 518 | skip: input.skip + decoded.success.rawCount, | |
| 519 | cursorAdvance: input.cursorAdvance + decoded.success.rawCount, | |
| 520 | items, | |
| 521 | }); | |
| 522 | }), | |
| 523 | ); | |
| 524 | }; | |
| 525 | ||
| 526 | return AzureDevOpsPullRequestCli.of({ | |
| 527 | getViewer: (input) => | |
| 528 | executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe( | |
| 529 | Effect.flatMap((result): Effect.Effect<string, AzureDevOpsPullRequestCliError> => { | |
| 530 | // `--query user` narrows the payload to the account, so it is nested back under the | |
| 531 | // key the decoder reads to keep one shape for the signed-in user. | |
| 532 | const decoded = decodeViewerJson(`{"user":${result.stdout.trim() || "null"}}`); | |
| 533 | if (!Result.isSuccess(decoded)) { | |
| 534 | return Effect.fail( | |
| 535 | new AzureDevOpsPullRequestReadError({ | |
| 536 | command: "az", | |
| 537 | cwd: input.cwd, | |
| 538 | operation: "getViewer", | |
| 539 | cause: decoded.failure, | |
| 540 | }), | |
| 541 | ); | |
| 542 | } | |
| 543 | return decoded.success === null | |
| 544 | ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd })) | |
| 545 | : Effect.succeed(decoded.success); | |
| 546 | }), | |
| 547 | ), | |
| 548 | ||
| 549 | listPullRequests: (input) => | |
| 550 | listPullRequestPage({ | |
| 551 | cwd: input.cwd, | |
| 552 | repository: input.repository, | |
| 553 | state: input.state, | |
| 554 | involvement: input.involvement, | |
| 555 | viewer: input.viewer, | |
| 556 | limit: input.limit, | |
| 557 | // Azure counts rather than filters, so a slice carries on by stepping over every raw row | |
| 558 | // the prior slice consumed. That is an offset into a list that can shift underneath it: | |
| 559 | // a pull request opened between two slices moves everything down one, and the row on the | |
| 560 | // seam is the one that pays for it. | |
| 561 | skip: input.cursor?.delivered ?? 0, | |
| 562 | cursorAdvance: 0, | |
| 563 | items: [], | |
| 564 | }), | |
| 565 | ||
| 566 | getPullRequest: (input) => | |
| 567 | executeJson({ | |
| 568 | cwd: input.cwd, | |
| 569 | args: ["repos", "pr", "show", ...detectArgs, "--id", String(input.number)], | |
| 570 | }).pipe( | |
| 571 | Effect.flatMap( | |
| 572 | (result): Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError> => { | |
| 573 | const decoded = decodePullRequestJson(result.stdout.trim()); | |
| 574 | if (!Result.isSuccess(decoded)) { | |
| 575 | return Effect.fail( | |
| 576 | new AzureDevOpsPullRequestReadError({ | |
| 577 | command: "az", | |
| 578 | cwd: input.cwd, | |
| 579 | operation: "getPullRequest", | |
| 580 | cause: decoded.failure, | |
| 581 | }), | |
| 582 | ); | |
| 583 | } | |
| 584 | // Null means Azure answered with too little to place the pull request. Nothing | |
| 585 | // failed underneath it, so it is its own outcome rather than a decode failure. | |
| 586 | return decoded.success === null | |
| 587 | ? Effect.fail( | |
| 588 | new AzureDevOpsPullRequestIncompleteError({ | |
| 589 | command: "az", | |
| 590 | cwd: input.cwd, | |
| 591 | number: input.number, | |
| 592 | }), | |
| 593 | ) | |
| 594 | : Effect.succeed(decoded.success); | |
| 595 | }, | |
| 596 | ), | |
| 597 | ), | |
| 598 | ||
| 599 | listThreads: (input) => | |
| 600 | invoke({ | |
| 601 | cwd: input.cwd, | |
| 602 | operation: "listThreads", | |
| 603 | resource: "pullRequestThreads", | |
| 604 | routeParameters: pullRequestRoute(input), | |
| 605 | maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, | |
| 606 | decode: decodeThreadsJson, | |
| 607 | }), | |
| 608 | ||
| 609 | listIterations: (input) => | |
| 610 | invoke({ | |
| 611 | cwd: input.cwd, | |
| 612 | operation: "listIterations", | |
| 613 | resource: "pullRequestIterations", | |
| 614 | routeParameters: pullRequestRoute(input), | |
| 615 | maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, | |
| 616 | decode: decodeIterationsJson, | |
| 617 | }), | |
| 618 | ||
| 619 | listIterationChanges: (input) => { | |
| 620 | const page = (skip: number) => | |
| 621 | invoke({ | |
| 622 | cwd: input.cwd, | |
| 623 | operation: "listIterationChanges", | |
| 624 | resource: "pullRequestIterationChanges", | |
| 625 | routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`], | |
| 626 | // Azure pages this route at 1000 entries by default; this is its own maximum per page, | |
| 627 | // and it names where the next page starts rather than answering with the whole change. | |
| 628 | queryParameters: [`$top=${CHANGE_ENTRIES_PER_PAGE}`, `$skip=${skip}`], | |
| 629 | maxOutputBytes: CHANGE_ENTRIES_MAX_OUTPUT_BYTES, | |
| 630 | decode: decodeIterationChangesJson, | |
| 631 | }); | |
| 632 | const from = ( | |
| 633 | skip: number, | |
| 634 | collected: ReadonlyArray<AzureDevOpsChangeEntry>, | |
| 635 | ): Effect.Effect<AzureDevOpsIterationChanges, AzureDevOpsPullRequestCliError> => | |
| 636 | page(skip).pipe( | |
| 637 | Effect.flatMap((answer) => { | |
| 638 | const changes = [...collected, ...answer.changes]; | |
| 639 | // The last page names no page after it, and only that is the end of the change. | |
| 640 | if (answer.nextSkip === null) return Effect.succeed({ changes, truncated: false }); | |
| 641 | // A page pointing at where the read already is would be followed forever, and one | |
| 642 | // past the ceiling is a change nobody will read to the end of. Both stop the read | |
| 643 | // and both say so, rather than presenting part of a change as the whole of it. | |
| 644 | return answer.nextSkip <= skip || answer.nextSkip >= MAX_CHANGE_ENTRIES | |
| 645 | ? Effect.succeed({ changes, truncated: true }) | |
| 646 | : from(answer.nextSkip, changes); | |
| 647 | }), | |
| 648 | ); | |
| 649 | return from(0, []); | |
| 650 | }, | |
| 651 | ||
| 652 | readItemContent: (input) => | |
| 653 | invoke({ | |
| 654 | cwd: input.cwd, | |
| 655 | operation: "readItemContent", | |
| 656 | resource: "items", | |
| 657 | routeParameters: repositoryRoute(input.location), | |
| 658 | queryParameters: [ | |
| 659 | `path=${toItemPath(input.path)}`, | |
| 660 | "versionDescriptor.versionType=commit", | |
| 661 | `versionDescriptor.version=${input.commit}`, | |
| 662 | "includeContent=true", | |
| 663 | // Azure leaves `contentMetadata` out unless this is asked for, and with it goes its own | |
| 664 | // word on whether the file is binary, which is the only reliable one, since a binary | |
| 665 | // file arrives encoded rather than as the bytes it is on the host. | |
| 666 | "includeContentMetadata=true", | |
| 667 | // Without this Azure answers with the file's own bytes rather than with a JSON | |
| 668 | // envelope, and `az devops invoke` refuses anything it cannot parse as JSON. | |
| 669 | "$format=json", | |
| 670 | ], | |
| 671 | maxOutputBytes: ITEM_CONTENT_MAX_OUTPUT_BYTES, | |
| 672 | decode: decodeItemContentJson, | |
| 673 | }), | |
| 674 | ||
| 675 | setPullRequestReviewers: (input) => | |
| 676 | input.reviewers.some((reviewer) => !isReviewerName(reviewer)) | |
| 677 | ? Effect.fail(new AzureDevOpsReviewerNameError({ command: "az", cwd: input.cwd })) | |
| 678 | : azure | |
| 679 | .execute({ | |
| 680 | cwd: input.cwd, | |
| 681 | args: [ | |
| 682 | "repos", | |
| 683 | "pr", | |
| 684 | "reviewer", | |
| 685 | input.requested ? "add" : "remove", | |
| 686 | ...detectArgs, | |
| 687 | "--id", | |
| 688 | String(input.number), | |
| 689 | // One `--reviewers` takes them all, because az reads the flag as a list and a | |
| 690 | // second one would replace the first rather than add to it. | |
| 691 | "--reviewers", | |
| 692 | ...input.reviewers, | |
| 693 | "--only-show-errors", | |
| 694 | "--output", | |
| 695 | "json", | |
| 696 | ], | |
| 697 | }) | |
| 698 | .pipe(Effect.asVoid), | |
| 699 | ||
| 700 | runPullRequestAction: (input) => | |
| 701 | azure | |
| 702 | .execute({ | |
| 703 | cwd: input.cwd, | |
| 704 | args: [ | |
| 705 | "repos", | |
| 706 | "pr", | |
| 707 | "update", | |
| 708 | ...detectArgs, | |
| 709 | "--id", | |
| 710 | String(input.number), | |
| 711 | ...actionArgs(input.action, input.mergeMethod), | |
| 712 | "--only-show-errors", | |
| 713 | "--output", | |
| 714 | "json", | |
| 715 | ], | |
| 716 | }) | |
| 717 | .pipe(Effect.asVoid), | |
| 718 | ||
| 719 | updatePullRequest: (input) => | |
| 720 | azure | |
| 721 | .execute({ | |
| 722 | cwd: input.cwd, | |
| 723 | args: [ | |
| 724 | "repos", | |
| 725 | "pr", | |
| 726 | "update", | |
| 727 | ...detectArgs, | |
| 728 | "--id", | |
| 729 | String(input.number), | |
| 730 | // One argument rather than a flag and a value beside it: a description usually opens | |
| 731 | // with a bullet, and az reads a dash in the next argv slot as a flag of its own. | |
| 732 | // `--description` also takes several strings, and this keeps the whole text as one. | |
| 733 | ...(input.title === undefined ? [] : [`--title=${input.title}`]), | |
| 734 | ...(input.body === undefined ? [] : [`--description=${input.body}`]), | |
| 735 | "--only-show-errors", | |
| 736 | "--output", | |
| 737 | "json", | |
| 738 | ], | |
| 739 | }) | |
| 740 | .pipe(Effect.asVoid), | |
| 741 | }); | |
| 742 | }); | |
| 743 | ||
| 744 | export const layer = Layer.effect(AzureDevOpsPullRequestCli, make); |
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts deleted-420| ... | ... | @@ -1,420 +0,0 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | ||
| 5 | import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; | |
| 6 | import { | |
| 7 | LOCATION_CACHE_CAPACITY, | |
| 8 | make, | |
| 9 | MAX_DIFF_SPAWNS, | |
| 10 | } from "./AzureDevOpsPullRequestProvider.ts"; | |
| 11 | import { | |
| 12 | byteLength, | |
| 13 | MAX_DIFF_SLICE_BYTES, | |
| 14 | MAX_DIFF_SLICE_FILES, | |
| 15 | MAX_FILE_DIFF_EDITS, | |
| 16 | parseAzureDevOpsDiffCursor, | |
| 17 | } from "./azureDevOpsDiff.ts"; | |
| 18 | import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; | |
| 19 | ||
| 20 | const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" }; | |
| 21 | ||
| 22 | const PULL_REQUEST = { | |
| 23 | number: 7, | |
| 24 | title: "Pull request 7", | |
| 25 | url: "https://dev.azure.com/acme/web/_git/web/pullrequest/7", | |
| 26 | author: null, | |
| 27 | headBranch: "feat/page", | |
| 28 | baseBranch: "main", | |
| 29 | state: "open" as const, | |
| 30 | isDraft: false, | |
| 31 | mergeability: "mergeable" as const, | |
| 32 | createdAt: "2026-07-01T00:00:00Z", | |
| 33 | updatedAt: "2026-07-02T00:00:00Z", | |
| 34 | closedAt: null, | |
| 35 | body: "", | |
| 36 | reviewRequestLogins: [], | |
| 37 | reviewers: [], | |
| 38 | location: { project: "acme", repository: "web" }, | |
| 39 | autoMergeEnabled: false, | |
| 40 | }; | |
| 41 | ||
| 42 | function change( | |
| 43 | path: string, | |
| 44 | changeKind: AzureDevOpsChangeEntry["changeKind"] = "change", | |
| 45 | ): AzureDevOpsChangeEntry { | |
| 46 | return { path, oldPath: path, changeKind, objectId: "8f80", originalObjectId: "0ca4" }; | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * A file whose two sides share no line, so its patch is `lines` removals and `lines` additions of | |
| 51 | * `width` characters each: the diff work and the patch bytes one file costs are both dialled from | |
| 52 | * here, and they are what a slice is bounded by. Each line carries its own number and a prefix | |
| 53 | * as well, so `width` is a floor on how long a line is rather than its byte count. | |
| 54 | */ | |
| 55 | function side(prefix: string, lines: number, width: number): string { | |
| 56 | const pad = "z".repeat(width); | |
| 57 | return `${Array.from({ length: lines }, (_, line) => `${prefix} ${line} ${pad}`).join("\n")}\n`; | |
| 58 | } | |
| 59 | ||
| 60 | const readSlice = (input: { | |
| 61 | readonly paths: ReadonlyArray<string>; | |
| 62 | readonly lines: number; | |
| 63 | readonly width: number; | |
| 64 | /** Paths the host refuses, which is one file's problem rather than the read's. */ | |
| 65 | readonly refused?: ReadonlyArray<string>; | |
| 66 | /** Paths the change creates, so the host has nothing to hand back for their old side. */ | |
| 67 | readonly created?: ReadonlyArray<string>; | |
| 68 | readonly cursor?: string; | |
| 69 | }) => | |
| 70 | Effect.gen(function* () { | |
| 71 | const reads: string[] = []; | |
| 72 | const refused = new Set(input.refused ?? []); | |
| 73 | const created = new Set(input.created ?? []); | |
| 74 | let inFlight = 0; | |
| 75 | let peakInFlight = 0; | |
| 76 | ||
| 77 | const provider = yield* make.pipe( | |
| 78 | Effect.provide( | |
| 79 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ | |
| 80 | getPullRequest: () => Effect.succeed(PULL_REQUEST), | |
| 81 | listIterations: () => Effect.succeed([ITERATION]), | |
| 82 | listIterationChanges: () => | |
| 83 | Effect.succeed({ | |
| 84 | changes: input.paths.map((path) => | |
| 85 | change(path, created.has(path) ? "new" : "change"), | |
| 86 | ), | |
| 87 | truncated: false, | |
| 88 | }), | |
| 89 | readItemContent: (item) => | |
| 90 | Effect.gen(function* () { | |
| 91 | reads.push(item.path); | |
| 92 | inFlight += 1; | |
| 93 | peakInFlight = Math.max(peakInFlight, inFlight); | |
| 94 | // Every read suspends before it answers, as a subprocess would, so what runs at | |
| 95 | // once is the scheduler's answer rather than an artefact of resolving inline. The | |
| 96 | // later a file is listed the sooner it answers, to leave the assembled patch | |
| 97 | // nothing but the change list to take its order from. | |
| 98 | const answersAfter = input.paths.length - input.paths.indexOf(item.path); | |
| 99 | for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow; | |
| 100 | inFlight -= 1; | |
| 101 | if (refused.has(item.path)) { | |
| 102 | return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({ | |
| 103 | command: "az", | |
| 104 | cwd: "/w", | |
| 105 | operation: "readItemContent", | |
| 106 | cause: "refused", | |
| 107 | }); | |
| 108 | } | |
| 109 | const isOldSide = item.commit !== ITERATION.headCommit; | |
| 110 | if (isOldSide && created.has(item.path)) return { contents: "", isBinary: false }; | |
| 111 | return { | |
| 112 | contents: side(isOldSide ? "old" : "new", input.lines, input.width), | |
| 113 | isBinary: false, | |
| 114 | }; | |
| 115 | }), | |
| 116 | }), | |
| 117 | ), | |
| 118 | ); | |
| 119 | ||
| 120 | const slice = yield* provider.getDiff({ | |
| 121 | cwd: "/w", | |
| 122 | repository: "acme/web", | |
| 123 | host: "dev.azure.com", | |
| 124 | number: 7, | |
| 125 | ...(input.cursor === undefined ? {} : { cursor: input.cursor }), | |
| 126 | }); | |
| 127 | return { slice, reads, peakInFlight }; | |
| 128 | }); | |
| 129 | ||
| 130 | /** Which files the patch carries a section for, in the order it carries them. */ | |
| 131 | function patchedPaths(patch: string): ReadonlyArray<string> { | |
| 132 | return [...patch.matchAll(/^diff --git a\/(?<path>\S+) b\//gmu)].map( | |
| 133 | (match) => match.groups?.path ?? "", | |
| 134 | ); | |
| 135 | } | |
| 136 | ||
| 137 | describe("getChangeRequestSummary", () => { | |
| 138 | it.effect("costs the one pull request read, not the iterations changedFiles needs", () => | |
| 139 | Effect.gen(function* () { | |
| 140 | let pullRequestReads = 0; | |
| 141 | ||
| 142 | const provider = yield* make.pipe( | |
| 143 | Effect.provide( | |
| 144 | // listIterations and listIterationChanges are left unimplemented here, so a summary | |
| 145 | // that reached for either would die with UnimplementedError instead of this passing. | |
| 146 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ | |
| 147 | getPullRequest: () => { | |
| 148 | pullRequestReads += 1; | |
| 149 | return Effect.succeed(PULL_REQUEST); | |
| 150 | }, | |
| 151 | }), | |
| 152 | ), | |
| 153 | ); | |
| 154 | ||
| 155 | const readSummary = provider.getChangeRequestSummary; | |
| 156 | if (readSummary === undefined) return yield* Effect.die("summary read was not implemented"); | |
| 157 | const summary = yield* readSummary({ | |
| 158 | cwd: "/w", | |
| 159 | repository: "acme/web", | |
| 160 | host: "dev.azure.com", | |
| 161 | number: 7, | |
| 162 | }); | |
| 163 | ||
| 164 | expect(pullRequestReads).toBe(1); | |
| 165 | expect(summary.title).toBe(PULL_REQUEST.title); | |
| 166 | expect(summary.changedFiles).toBeUndefined(); | |
| 167 | }), | |
| 168 | ); | |
| 169 | }); | |
| 170 | ||
| 171 | describe("getChangeRequest", () => { | |
| 172 | it.effect("still reports the file count the detail panel needs", () => | |
| 173 | Effect.gen(function* () { | |
| 174 | const provider = yield* make.pipe( | |
| 175 | Effect.provide( | |
| 176 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ | |
| 177 | getPullRequest: () => Effect.succeed(PULL_REQUEST), | |
| 178 | listIterations: () => Effect.succeed([ITERATION]), | |
| 179 | listIterationChanges: () => | |
| 180 | Effect.succeed({ changes: [change("a.ts"), change("b.ts")], truncated: false }), | |
| 181 | }), | |
| 182 | ), | |
| 183 | ); | |
| 184 | ||
| 185 | const detail = yield* provider.getChangeRequest({ | |
| 186 | cwd: "/w", | |
| 187 | repository: "acme/web", | |
| 188 | host: "dev.azure.com", | |
| 189 | number: 7, | |
| 190 | }); | |
| 191 | ||
| 192 | expect(detail.changedFiles).toBe(2); | |
| 193 | }), | |
| 194 | ); | |
| 195 | }); | |
| 196 | ||
| 197 | describe("getDiff reads", () => { | |
| 198 | it.effect("holds every reader together to one request's worth of processes", () => | |
| 199 | Effect.gen(function* () { | |
| 200 | // The fan-out inside a read bounds one Code tab. Two people opening two Azure reviews at | |
| 201 | // once are two reads, so without a ceiling above them both they are twice a request's | |
| 202 | // processes, each paying a Python interpreter's start-up on the same machine. | |
| 203 | const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; | |
| 204 | let inFlight = 0; | |
| 205 | let peakInFlight = 0; | |
| 206 | ||
| 207 | const provider = yield* make.pipe( | |
| 208 | Effect.provide( | |
| 209 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ | |
| 210 | getPullRequest: () => Effect.succeed(PULL_REQUEST), | |
| 211 | listIterations: () => Effect.succeed([ITERATION]), | |
| 212 | listIterationChanges: () => | |
| 213 | Effect.succeed({ changes: paths.map((path) => change(path)), truncated: false }), | |
| 214 | readItemContent: () => | |
| 215 | Effect.gen(function* () { | |
| 216 | inFlight += 1; | |
| 217 | peakInFlight = Math.max(peakInFlight, inFlight); | |
| 218 | // Suspends before answering, as a subprocess would, so what is out at once is the | |
| 219 | // scheduler's answer rather than an artefact of resolving inline. | |
| 220 | yield* Effect.yieldNow; | |
| 221 | yield* Effect.yieldNow; | |
| 222 | inFlight -= 1; | |
| 223 | return { contents: side("new", 2, 4), isBinary: false }; | |
| 224 | }), | |
| 225 | }), | |
| 226 | ), | |
| 227 | ); | |
| 228 | ||
| 229 | const readDiff = (number: number) => | |
| 230 | provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number }); | |
| 231 | ||
| 232 | yield* Effect.all([readDiff(7), readDiff(8)], { concurrency: 2 }); | |
| 233 | ||
| 234 | expect(peakInFlight).toBeLessThanOrEqual(MAX_DIFF_SPAWNS); | |
| 235 | }), | |
| 236 | ); | |
| 237 | ||
| 238 | it.effect("leaves a run of files it could not diff at all for the next slice", () => | |
| 239 | Effect.gen(function* () { | |
| 240 | // A binary, oversize, purely renamed or unreadable entry is a header apiece, a couple of | |
| 241 | // hundred bytes with no edits in it, so a change made of them spends neither budget: the | |
| 242 | // byte one would take well over a thousand of them, and the edit one never fills at all. | |
| 243 | // A listing holds up to ten thousand entries and each still costs its two reads, which is | |
| 244 | // what a file count is here to bound. | |
| 245 | const paths = Array.from({ length: MAX_DIFF_SLICE_FILES + 20 }, (_, at) => `gen/a${at}.bin`); | |
| 246 | const read = yield* readSlice({ paths, lines: 2, width: 4, refused: paths }); | |
| 247 | ||
| 248 | expect(patchedPaths(read.slice.patch)).toHaveLength(MAX_DIFF_SLICE_FILES); | |
| 249 | // Well inside the byte budget, so the file count is what stopped it rather than either of | |
| 250 | // the budgets that were already there. | |
| 251 | expect(byteLength(read.slice.patch)).toBeLessThan(MAX_DIFF_SLICE_BYTES); | |
| 252 | expect(parseAzureDevOpsDiffCursor(read.slice.nextCursor)?.fileIndex).toBe( | |
| 253 | MAX_DIFF_SLICE_FILES, | |
| 254 | ); | |
| 255 | }), | |
| 256 | ); | |
| 257 | ||
| 258 | it.effect("asks for both sides of several files at once rather than one side at a time", () => | |
| 259 | Effect.gen(function* () { | |
| 260 | // Each file is two `az` invocations, each paying a Python interpreter's start-up, so a | |
| 261 | // slice read one side after another is most of what the Code tab waits for. | |
| 262 | const read = yield* readSlice({ | |
| 263 | paths: ["a.ts", "b.ts", "c.ts", "d.ts"], | |
| 264 | lines: 2, | |
| 265 | width: 4, | |
| 266 | }); | |
| 267 | ||
| 268 | expect(read.peakInFlight).toBeGreaterThan(2); | |
| 269 | }), | |
| 270 | ); | |
| 271 | ||
| 272 | it.effect("holds the number of files it reads at once down", () => | |
| 273 | Effect.gen(function* () { | |
| 274 | // The host throttles, and `az` is a process on the same machine the reader runs agents on, | |
| 275 | // so a long change is read in batches rather than all at once. | |
| 276 | const paths = Array.from({ length: 24 }, (_, file) => `file-${file}.ts`); | |
| 277 | const read = yield* readSlice({ paths, lines: 2, width: 4 }); | |
| 278 | ||
| 279 | expect(read.reads).toHaveLength(paths.length * 2); | |
| 280 | expect(read.peakInFlight).toBeLessThanOrEqual(8); | |
| 281 | }), | |
| 282 | ); | |
| 283 | ||
| 284 | it.effect("keeps the patch in the order the change was listed, whoever answered first", () => | |
| 285 | Effect.gen(function* () { | |
| 286 | const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"]; | |
| 287 | const read = yield* readSlice({ paths, lines: 2, width: 4 }); | |
| 288 | ||
| 289 | expect(patchedPaths(read.slice.patch)).toEqual(paths); | |
| 290 | expect(read.slice.nextCursor).toBeNull(); | |
| 291 | }), | |
| 292 | ); | |
| 293 | ||
| 294 | it.effect("leaves a file the host refused listed without its hunks, in its place", () => | |
| 295 | Effect.gen(function* () { | |
| 296 | const paths = ["a.ts", "b.ts", "c.ts"]; | |
| 297 | const read = yield* readSlice({ paths, lines: 2, width: 4, refused: ["b.ts"] }); | |
| 298 | ||
| 299 | expect(patchedPaths(read.slice.patch)).toEqual(paths); | |
| 300 | expect(read.slice.truncated).toBe(true); | |
| 301 | // Its section ends at its header, and the files around it still carry their hunks. | |
| 302 | expect(read.slice.patch).toContain("+++ b/b.ts\ndiff --git a/c.ts"); | |
| 303 | expect(read.slice.patch.match(/^@@ /gmu)).toHaveLength(2); | |
| 304 | }), | |
| 305 | ); | |
| 306 | }); | |
| 307 | ||
| 308 | describe("what one diff slice spends", () => { | |
| 309 | it.effect("stops on the byte ceiling without carrying what it read past it", () => | |
| 310 | Effect.gen(function* () { | |
| 311 | // Two of these fill the slice, and the batch they were read in reached two files further. | |
| 312 | // Those two belong to the next slice: carrying them would put the request past a ceiling | |
| 313 | // that is there to bound what one answer weighs. | |
| 314 | const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; | |
| 315 | const read = yield* readSlice({ paths, lines: 100, width: 900 }); | |
| 316 | ||
| 317 | expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES); | |
| 318 | expect(patchedPaths(read.slice.patch)).toEqual(["a.ts", "b.ts"]); | |
| 319 | expect(read.slice.nextCursor).toBe(`${ITERATION.id}:2`); | |
| 320 | expect(new Set(read.reads)).toEqual(new Set(["a.ts", "b.ts", "c.ts", "d.ts"])); | |
| 321 | }), | |
| 322 | ); | |
| 323 | ||
| 324 | it.effect("narrows what it reads at once as the slice fills", () => | |
| 325 | Effect.gen(function* () { | |
| 326 | // Four files fit inside the budget and the fifth is past half of what is left of it, so | |
| 327 | // reading four more would throw most of them away and read them again next slice. Every | |
| 328 | // file is two `az` invocations, so the batch is judged against what the files before it | |
| 329 | // weighed rather than left at its full width to the last file. | |
| 330 | const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts", "g.ts", "h.ts"]; | |
| 331 | const read = yield* readSlice({ paths, lines: 50, width: 450 }); | |
| 332 | ||
| 333 | expect(read.slice.nextCursor).not.toBeNull(); | |
| 334 | expect(new Set(read.reads)).toEqual(new Set(patchedPaths(read.slice.patch))); | |
| 335 | }), | |
| 336 | ); | |
| 337 | ||
| 338 | it.effect("stops once the diff work one request may do is spent", () => | |
| 339 | Effect.gen(function* () { | |
| 340 | // Short lines are cheap on the wire and dear to diff, so the byte ceiling alone would let | |
| 341 | // one request hold the thread through a dozen of them. Each of these is half of what one | |
| 342 | // file is allowed, and the slice ends while there is still room for another. | |
| 343 | const paths = Array.from({ length: 12 }, (_, file) => `file-${file}.ts`); | |
| 344 | const read = yield* readSlice({ paths, lines: MAX_FILE_DIFF_EDITS / 4, width: 1 }); | |
| 345 | ||
| 346 | expect(read.slice.patch.length).toBeLessThan(MAX_DIFF_SLICE_BYTES); | |
| 347 | expect(patchedPaths(read.slice.patch)).toEqual(paths.slice(0, 5)); | |
| 348 | expect(read.slice.nextCursor).toBe(`${ITERATION.id}:5`); | |
| 349 | }), | |
| 350 | ); | |
| 351 | ||
| 352 | it.effect("carries a whole new file and stops the slice on what it weighed", () => | |
| 353 | Effect.gen(function* () { | |
| 354 | // A creation has no edit distance to search out, so no edit bound applies to it and its | |
| 355 | // section is the whole file. What keeps a run of them from filling one answer is the bytes | |
| 356 | // they weighed, which the slice has to be charged for. | |
| 357 | const paths = ["new.ts", "b.ts", "c.ts", "d.ts"]; | |
| 358 | const read = yield* readSlice({ paths, lines: 8_000, width: 30, created: ["new.ts"] }); | |
| 359 | ||
| 360 | expect(patchedPaths(read.slice.patch)).toEqual(["new.ts"]); | |
| 361 | expect(read.slice.patch).toContain("--- /dev/null"); | |
| 362 | expect(read.slice.patch).toContain("@@ -0,0 +1,8000 @@"); | |
| 363 | expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES); | |
| 364 | expect(read.slice.nextCursor).toBe(`${ITERATION.id}:1`); | |
| 365 | }), | |
| 366 | ); | |
| 367 | ||
| 368 | it.effect("carries on from where the last slice stopped", () => | |
| 369 | Effect.gen(function* () { | |
| 370 | const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; | |
| 371 | const read = yield* readSlice({ | |
| 372 | paths, | |
| 373 | lines: 100, | |
| 374 | width: 900, | |
| 375 | cursor: `${ITERATION.id}:2`, | |
| 376 | }); | |
| 377 | ||
| 378 | expect(patchedPaths(read.slice.patch)).toEqual(["c.ts", "d.ts"]); | |
| 379 | expect(read.slice.nextCursor).toBe(`${ITERATION.id}:4`); | |
| 380 | }), | |
| 381 | ); | |
| 382 | it.effect("keeps the pull request being read, not the one looked up first", () => | |
| 383 | Effect.gen(function* () { | |
| 384 | // Where a pull request lives is read from the pull request itself, so an evicted entry | |
| 385 | // costs a whole pull request read before any file can be asked for. Ordered by insertion | |
| 386 | // alone a hit does not renew its entry, so the review being worked through is the first | |
| 387 | // thing dropped once a listing has walked a cache's worth of cold pull requests. | |
| 388 | const HOT = 7; | |
| 389 | const readsOf = new Map<number, number>(); | |
| 390 | ||
| 391 | const provider = yield* make.pipe( | |
| 392 | Effect.provide( | |
| 393 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ | |
| 394 | getPullRequest: (input) => | |
| 395 | Effect.sync(() => { | |
| 396 | readsOf.set(input.number, (readsOf.get(input.number) ?? 0) + 1); | |
| 397 | return { ...PULL_REQUEST, number: input.number }; | |
| 398 | }), | |
| 399 | listIterations: () => Effect.succeed([ITERATION]), | |
| 400 | listIterationChanges: () => | |
| 401 | Effect.succeed({ changes: [change("a.ts")], truncated: false }), | |
| 402 | readItemContent: () => Effect.succeed({ contents: side("new", 2, 4), isBinary: false }), | |
| 403 | }), | |
| 404 | ), | |
| 405 | ); | |
| 406 | ||
| 407 | const readDiff = (number: number) => | |
| 408 | provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number }); | |
| 409 | ||
| 410 | yield* readDiff(HOT); | |
| 411 | // A cache's worth of cold pull requests, with the open one read in between each of them. | |
| 412 | for (let filled = 0; filled < LOCATION_CACHE_CAPACITY; filled += 1) { | |
| 413 | yield* readDiff(HOT + 1 + filled); | |
| 414 | yield* readDiff(HOT); | |
| 415 | } | |
| 416 | ||
| 417 | expect(readsOf.get(HOT)).toBe(1); | |
| 418 | }), | |
| 419 | ); | |
| 420 | }); |
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts deleted-647| ... | ... | @@ -1,647 +0,0 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Semaphore from "effect/Semaphore"; | |
| 3 | import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; | |
| 6 | import { | |
| 7 | azureDevOpsFilePatch, | |
| 8 | azureDevOpsUnreadableFilePatch, | |
| 9 | formatAzureDevOpsDiffCursor, | |
| 10 | parseAzureDevOpsDiffCursor, | |
| 11 | MAX_DIFF_SLICE_BYTES, | |
| 12 | MAX_DIFF_SLICE_EDITS, | |
| 13 | MAX_DIFF_SLICE_FILES, | |
| 14 | byteLength, | |
| 15 | MAX_FILE_DIFF_EDITS, | |
| 16 | type AzureDevOpsFileTexts, | |
| 17 | } from "./azureDevOpsDiff.ts"; | |
| 18 | import { | |
| 19 | PullRequestProviderError, | |
| 20 | type PullRequestProviderFailure, | |
| 21 | type ProviderChangeRequest, | |
| 22 | type ProviderChangeRequestActivity, | |
| 23 | type ProviderChangeRequestDetail, | |
| 24 | type ProviderChangeRequestSummary, | |
| 25 | type ProviderDiffSlice, | |
| 26 | type PullRequestProviderApi, | |
| 27 | } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 28 | import type { | |
| 29 | AzureDevOpsChangeEntry, | |
| 30 | AzureDevOpsItemContent, | |
| 31 | AzureDevOpsIteration, | |
| 32 | AzureDevOpsPullRequest, | |
| 33 | AzureDevOpsRepositoryLocation, | |
| 34 | } from "./azureDevOpsPullRequestJson.ts"; | |
| 35 | ||
| 36 | /** | |
| 37 | * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a | |
| 38 | * Python interpreter's start-up, so reading them one after another is most of what the Code tab | |
| 39 | * waits for. Four files means eight processes at once: the fan-out the GitHub CLI reads its | |
| 40 | * per-file stats with here, and low enough not to swamp the host's throttling or the machine. | |
| 41 | */ | |
| 42 | const DIFF_FILE_CONCURRENCY = 4; | |
| 43 | ||
| 44 | /** | |
| 45 | * How many `az` processes this build will have out at once, counted across every reader rather | |
| 46 | * than per request. The fan-out above bounds one Code tab, so two people opening two Azure reviews | |
| 47 | * had sixteen Python interpreters starting at once and nothing above them. Held at what one | |
| 48 | * request at full width spends, so a second reader waits behind the first instead of adding to it. | |
| 49 | */ | |
| 50 | export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY; | |
| 51 | ||
| 52 | /** How many pull requests' repository locations one provider remembers at once. */ | |
| 53 | export const LOCATION_CACHE_CAPACITY = 128; | |
| 54 | ||
| 55 | const CAPABILITIES: PullRequestCapabilities = { | |
| 56 | // Azure serves no patch of its own, so the one the Code tab reads is built here out of the | |
| 57 | // files an iteration changed and both sides of each of them. | |
| 58 | diff: true, | |
| 59 | // Reading a conversation is a plain REST read, but posting one is not something this can | |
| 60 | // claim without having run it, so the composer stays hidden. | |
| 61 | comment: false, | |
| 62 | actions: [ | |
| 63 | "merge", | |
| 64 | "ready", | |
| 65 | "draft", | |
| 66 | "close", | |
| 67 | "reopen", | |
| 68 | "enable-auto-merge", | |
| 69 | "disable-auto-merge", | |
| 70 | ], | |
| 71 | // Azure squashes as a completion option; it has no rebase strategy of its own. | |
| 72 | mergeMethods: ["merge", "squash"], | |
| 73 | // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. | |
| 74 | search: false, | |
| 75 | reactions: false, | |
| 76 | // The patch has lines to write against, but writing a remark at all is what Azure is not | |
| 77 | // offered for here, so nothing in a review is either. | |
| 78 | review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, | |
| 79 | // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` | |
| 80 | // lists the ones this repository could name — that lives behind the identity and graph APIs, a | |
| 81 | // different service with its own permissions. So the page takes a name here rather than being | |
| 82 | // handed a menu built out of a guess. | |
| 83 | reviewers: { request: true, listCandidates: false }, | |
| 84 | // A new title and description travel on the same `az repos pr update` that moves a pull request. | |
| 85 | // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on | |
| 86 | // Azure DevOps at all, so there is nothing here it could rewrite either. | |
| 87 | edit: { changeRequest: true, comment: false }, | |
| 88 | // Azure does keep a viewed record of its own, but only behind the undocumented contribution | |
| 89 | // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway. | |
| 90 | // So they are kept here instead, and the client says whose they are rather than implying the | |
| 91 | // Azure DevOps page will show them. | |
| 92 | viewedFiles: "environment", | |
| 93 | }; | |
| 94 | ||
| 95 | /** | |
| 96 | * Everything this host offers, granted to whoever is signed in. Azure DevOps states no permission | |
| 97 | * anywhere `az repos pr show` or `az repos pr list` reach: the answer lives in the security | |
| 98 | * namespaces, behind identity descriptors and token paths that would be several calls per pull | |
| 99 | * request to resolve. | |
| 100 | * | |
| 101 | * So the actions stay live and a viewer who may not take one is told so by Azure, at the moment | |
| 102 | * they try. That is the safer half of an unknown: hiding a control from someone entitled to it | |
| 103 | * leaves them no way through and no reason given. | |
| 104 | */ | |
| 105 | const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { | |
| 106 | actions: CAPABILITIES.actions, | |
| 107 | comment: CAPABILITIES.comment, | |
| 108 | resolve: CAPABILITIES.review.resolve, | |
| 109 | verdicts: CAPABILITIES.review.verdicts, | |
| 110 | requestReviewers: CAPABILITIES.reviewers.request, | |
| 111 | }; | |
| 112 | ||
| 113 | /** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ | |
| 114 | export function azureDevOpsProviderFailure( | |
| 115 | error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError, | |
| 116 | ): PullRequestProviderFailure { | |
| 117 | if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" }; | |
| 118 | if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" }; | |
| 119 | if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" }; | |
| 120 | if (error._tag === "AzureDevOpsPullRequestNotFoundError") return { reason: "not-found" }; | |
| 121 | return { reason: "failed" }; | |
| 122 | } | |
| 123 | ||
| 124 | function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeRequest { | |
| 125 | return { | |
| 126 | number: pullRequest.number, | |
| 127 | title: pullRequest.title, | |
| 128 | url: pullRequest.url, | |
| 129 | author: pullRequest.author, | |
| 130 | headBranch: pullRequest.headBranch, | |
| 131 | baseBranch: pullRequest.baseBranch, | |
| 132 | state: pullRequest.state, | |
| 133 | isDraft: pullRequest.isDraft, | |
| 134 | mergeability: pullRequest.mergeability, | |
| 135 | // Azure counts a pull request's files but never its lines, and counting them here would mean | |
| 136 | // reading every file on both sides of every row of a listing. | |
| 137 | additions: 0, | |
| 138 | deletions: 0, | |
| 139 | createdAt: pullRequest.createdAt, | |
| 140 | closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, | |
| 141 | mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, | |
| 142 | updatedAt: pullRequest.updatedAt, | |
| 143 | reviewRequestLogins: pullRequest.reviewRequestLogins, | |
| 144 | // Azure keeps labels on work items rather than on the pull request. | |
| 145 | labels: [], | |
| 146 | }; | |
| 147 | } | |
| 148 | ||
| 149 | export const make = Effect.gen(function* () { | |
| 150 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 151 | // Made once with the provider, which the registry builds once, so this is the whole build's | |
| 152 | // allowance rather than one request's. | |
| 153 | const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS); | |
| 154 | const readItemContent = (input: Parameters<typeof cli.readItemContent>[0]) => | |
| 155 | diffSpawns.withPermits(1)(cli.readItemContent(input)); | |
| 156 | ||
| 157 | const fail = | |
| 158 | (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) => | |
| 159 | new PullRequestProviderError({ | |
| 160 | provider: "azure-devops", | |
| 161 | operation, | |
| 162 | ...azureDevOpsProviderFailure(error), | |
| 163 | detail: error.detail, | |
| 164 | cause: error, | |
| 165 | }); | |
| 166 | ||
| 167 | /** Refuses what the capabilities already say this host cannot do. */ | |
| 168 | const unsupported = (operation: string) => | |
| 169 | Effect.fail( | |
| 170 | new PullRequestProviderError({ | |
| 171 | provider: "azure-devops", | |
| 172 | operation, | |
| 173 | reason: "failed", | |
| 174 | detail: "Azure DevOps reviews cannot be written from here yet.", | |
| 175 | }), | |
| 176 | ); | |
| 177 | ||
| 178 | /** A pull request Azure could not place has no diff to read, which reads as an empty one. */ | |
| 179 | const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null }; | |
| 180 | ||
| 181 | /** | |
| 182 | * Where a pull request's repository lives, which is the route every other read of it needs and | |
| 183 | * the one thing only the pull request itself states. A pull request cannot move between | |
| 184 | * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for | |
| 185 | * a whole pull request read every time they checked whether a file had been pushed to. Least | |
| 186 | * recently used, so a listing walking cold pull requests cannot evict the one being read. | |
| 187 | */ | |
| 188 | const locations = new Map<string, AzureDevOpsRepositoryLocation>(); | |
| 189 | ||
| 190 | const locationOf = (input: { readonly cwd: string; readonly number: number }) => { | |
| 191 | const key = `${input.cwd} ${input.number}`; | |
| 192 | const held = locations.get(key); | |
| 193 | if (held !== undefined) { | |
| 194 | locations.delete(key); | |
| 195 | locations.set(key, held); | |
| 196 | return Effect.succeed(held); | |
| 197 | } | |
| 198 | return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( | |
| 199 | Effect.map((pullRequest) => { | |
| 200 | const location = pullRequest.location; | |
| 201 | if (location === null) return null; | |
| 202 | if (locations.size >= LOCATION_CACHE_CAPACITY) { | |
| 203 | const oldest = locations.keys().next().value; | |
| 204 | if (oldest !== undefined) locations.delete(oldest); | |
| 205 | } | |
| 206 | locations.set(key, location); | |
| 207 | return location; | |
| 208 | }), | |
| 209 | ); | |
| 210 | }; | |
| 211 | ||
| 212 | /** | |
| 213 | * Everything a diff read needs before it can ask for a file: where the repository lives, and | |
| 214 | * which pushes the pull request has had. A client names neither, and the iterations are read | |
| 215 | * afresh every time because the newest one is what a push adds. | |
| 216 | */ | |
| 217 | const diffScope = (input: { readonly cwd: string; readonly number: number }) => | |
| 218 | Effect.gen(function* () { | |
| 219 | const location = yield* locationOf(input); | |
| 220 | if (location === null) return null; | |
| 221 | const iterations = yield* cli.listIterations({ | |
| 222 | cwd: input.cwd, | |
| 223 | location, | |
| 224 | number: input.number, | |
| 225 | }); | |
| 226 | return { location, iterations }; | |
| 227 | }); | |
| 228 | ||
| 229 | const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; | |
| 230 | ||
| 231 | /** | |
| 232 | * Both sides of one changed file, read at once because neither answer depends on the other and | |
| 233 | * `az` pays a Python interpreter's start-up for each. Only the sides a change actually has are | |
| 234 | * asked for: Azure answers for a file that is not at a commit with a failure rather than with | |
| 235 | * nothing. | |
| 236 | */ | |
| 237 | const readTexts = (input: { | |
| 238 | readonly cwd: string; | |
| 239 | readonly location: AzureDevOpsRepositoryLocation; | |
| 240 | readonly iteration: AzureDevOpsIteration; | |
| 241 | readonly change: Pick<AzureDevOpsChangeEntry, "changeKind" | "path" | "oldPath">; | |
| 242 | }) => | |
| 243 | Effect.all( | |
| 244 | [ | |
| 245 | input.change.changeKind === "new" | |
| 246 | ? Effect.succeed(EMPTY_ITEM) | |
| 247 | : readItemContent({ | |
| 248 | cwd: input.cwd, | |
| 249 | location: input.location, | |
| 250 | path: input.change.oldPath, | |
| 251 | commit: input.iteration.mergeBaseCommit, | |
| 252 | }), | |
| 253 | input.change.changeKind === "deleted" | |
| 254 | ? Effect.succeed(EMPTY_ITEM) | |
| 255 | : readItemContent({ | |
| 256 | cwd: input.cwd, | |
| 257 | location: input.location, | |
| 258 | path: input.change.path, | |
| 259 | commit: input.iteration.headCommit, | |
| 260 | }), | |
| 261 | ], | |
| 262 | { concurrency: 2 }, | |
| 263 | ).pipe( | |
| 264 | Effect.map(([oldItem, newItem]): AzureDevOpsFileTexts => ({ | |
| 265 | oldContents: oldItem.contents, | |
| 266 | newContents: newItem.contents, | |
| 267 | // Azure hands a file it calls binary over in an encoding of its own, so its own word on | |
| 268 | // that is taken rather than looked for in bytes it may never have sent verbatim. | |
| 269 | binary: oldItem.isBinary || newItem.isBinary, | |
| 270 | })), | |
| 271 | ); | |
| 272 | ||
| 273 | /** | |
| 274 | * What the whole pull request changed, taken from its latest push. An iteration's changes are | |
| 275 | * reported against the merge base rather than against the push before it, so the newest one is | |
| 276 | * the whole of the change rather than the last slice of it. | |
| 277 | */ | |
| 278 | const listLatestChanges = (input: { | |
| 279 | readonly cwd: string; | |
| 280 | readonly location: AzureDevOpsRepositoryLocation; | |
| 281 | readonly number: number; | |
| 282 | readonly iterations: ReadonlyArray<AzureDevOpsIteration>; | |
| 283 | }) => { | |
| 284 | const latest = input.iterations.at(-1); | |
| 285 | return latest === undefined | |
| 286 | ? Effect.succeed({ | |
| 287 | changes: [], | |
| 288 | truncated: false, | |
| 289 | } as AzureDevOpsPullRequestCli.AzureDevOpsIterationChanges) | |
| 290 | : cli.listIterationChanges({ | |
| 291 | cwd: input.cwd, | |
| 292 | location: input.location, | |
| 293 | number: input.number, | |
| 294 | iterationId: latest.id, | |
| 295 | }); | |
| 296 | }; | |
| 297 | ||
| 298 | const provider: PullRequestProviderApi = { | |
| 299 | kind: "azure-devops", | |
| 300 | capabilities: CAPABILITIES, | |
| 301 | ||
| 302 | getViewer: (input) => | |
| 303 | cli.getViewer({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), | |
| 304 | ||
| 305 | // `input.query` is deliberately dropped: `az repos pr list` filters by status, creator, | |
| 306 | // reviewer and branch, and has nothing that matches text. Sending it as one of those would | |
| 307 | // narrow by the wrong thing, so the page comes back unnarrowed and the caller filters it. | |
| 308 | listChangeRequests: (input) => | |
| 309 | cli | |
| 310 | .listPullRequests({ | |
| 311 | cwd: input.cwd, | |
| 312 | repository: input.repository, | |
| 313 | state: input.state, | |
| 314 | involvement: input.involvement, | |
| 315 | viewer: input.viewer, | |
| 316 | limit: input.limit, | |
| 317 | cursor: input.cursor, | |
| 318 | }) | |
| 319 | .pipe( | |
| 320 | Effect.mapError(fail("listChangeRequests")), | |
| 321 | Effect.map((batch) => ({ | |
| 322 | items: batch.items.map(toChangeRequest), | |
| 323 | truncated: batch.truncated, | |
| 324 | cursorAdvance: batch.cursorAdvance, | |
| 325 | // Azure answers in one order whether or not it is being carried on from, so a slice | |
| 326 | // can always be stepped past — by counting, which is all Azure offers. | |
| 327 | continues: true, | |
| 328 | })), | |
| 329 | ), | |
| 330 | ||
| 331 | // The polled path a linked thread's row stays live on: one `az` read, no iterations or | |
| 332 | // changes behind it, since the file count that would cost is not shown here. | |
| 333 | getChangeRequestSummary: (input) => | |
| 334 | cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( | |
| 335 | Effect.mapError(fail("getChangeRequestSummary")), | |
| 336 | Effect.map((pullRequest): ProviderChangeRequestSummary => ({ | |
| 337 | number: pullRequest.number, | |
| 338 | title: pullRequest.title, | |
| 339 | url: pullRequest.url, | |
| 340 | author: pullRequest.author, | |
| 341 | headBranch: pullRequest.headBranch, | |
| 342 | baseBranch: pullRequest.baseBranch, | |
| 343 | state: pullRequest.state, | |
| 344 | isDraft: pullRequest.isDraft, | |
| 345 | mergeability: pullRequest.mergeability, | |
| 346 | closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, | |
| 347 | mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, | |
| 348 | updatedAt: pullRequest.updatedAt, | |
| 349 | })), | |
| 350 | ), | |
| 351 | ||
| 352 | getChangeRequest: (input) => | |
| 353 | Effect.gen(function* () { | |
| 354 | const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); | |
| 355 | const location = pullRequest.location; | |
| 356 | // The file count is two reads past the pull request itself, and it is the only thing | |
| 357 | // riding on them, so a failure leaves it unknown rather than losing the whole detail. | |
| 358 | const changedFiles = | |
| 359 | location === null | |
| 360 | ? 0 | |
| 361 | : yield* cli.listIterations({ cwd: input.cwd, location, number: input.number }).pipe( | |
| 362 | Effect.flatMap((iterations) => | |
| 363 | listLatestChanges({ | |
| 364 | cwd: input.cwd, | |
| 365 | location, | |
| 366 | number: input.number, | |
| 367 | iterations, | |
| 368 | }), | |
| 369 | ), | |
| 370 | Effect.map((listed) => listed.changes.length), | |
| 371 | Effect.orElseSucceed(() => 0), | |
| 372 | ); | |
| 373 | const detail: ProviderChangeRequestDetail = { | |
| 374 | ...toChangeRequest(pullRequest), | |
| 375 | body: pullRequest.body, | |
| 376 | changedFiles, | |
| 377 | mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, | |
| 378 | closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, | |
| 379 | reviewers: pullRequest.reviewers, | |
| 380 | checks: [], | |
| 381 | mergeCapabilities: { merge: true, squash: true, rebase: false }, | |
| 382 | viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, | |
| 383 | autoMergeEnabled: pullRequest.autoMergeEnabled, | |
| 384 | ...(pullRequest.autoMergeMethod === undefined | |
| 385 | ? {} | |
| 386 | : { autoMergeMethod: pullRequest.autoMergeMethod }), | |
| 387 | }; | |
| 388 | return detail; | |
| 389 | }).pipe(Effect.mapError(fail("getChangeRequest"))), | |
| 390 | ||
| 391 | getChangeRequestActivity: (input) => | |
| 392 | cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( | |
| 393 | Effect.mapError(fail("getChangeRequestActivity")), | |
| 394 | Effect.flatMap((pullRequest) => | |
| 395 | (pullRequest.location === null | |
| 396 | ? Effect.succeed({ comments: [], truncated: true }) | |
| 397 | : cli | |
| 398 | .listThreads({ | |
| 399 | cwd: input.cwd, | |
| 400 | location: pullRequest.location, | |
| 401 | number: input.number, | |
| 402 | }) | |
| 403 | .pipe( | |
| 404 | Effect.map((comments) => ({ comments, truncated: false })), | |
| 405 | Effect.orElseSucceed(() => ({ comments: [], truncated: true })), | |
| 406 | ) | |
| 407 | ).pipe( | |
| 408 | Effect.map((conversation): ProviderChangeRequestActivity => ({ | |
| 409 | comments: conversation.comments, | |
| 410 | commentCount: conversation.comments.length, | |
| 411 | commentsTruncated: conversation.truncated, | |
| 412 | reviewThreads: [], | |
| 413 | commits: [], | |
| 414 | })), | |
| 415 | ), | |
| 416 | ), | |
| 417 | ), | |
| 418 | ||
| 419 | // No request at all: Azure has nothing to say about the viewer that a pull request read can | |
| 420 | // reach, so the answer is the same constant the detail carries. | |
| 421 | getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS), | |
| 422 | ||
| 423 | // `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so | |
| 424 | // the Code tab has nothing to scope itself to and always asks for the whole change. | |
| 425 | getDiff: (input) => | |
| 426 | Effect.gen(function* () { | |
| 427 | const scope = yield* diffScope(input); | |
| 428 | if (scope === null) return EMPTY_DIFF_SLICE; | |
| 429 | const cursor = parseAzureDevOpsDiffCursor(input.cursor); | |
| 430 | // Reading on stays with the push the first slice was taken against. A push landing | |
| 431 | // mid-read would otherwise renumber the files and hand the reader one twice, or none. | |
| 432 | const iteration = | |
| 433 | cursor === null | |
| 434 | ? scope.iterations.at(-1) | |
| 435 | : scope.iterations.find((candidate) => candidate.id === cursor.iterationId); | |
| 436 | if (iteration === undefined) return EMPTY_DIFF_SLICE; | |
| 437 | const listed = yield* cli.listIterationChanges({ | |
| 438 | cwd: input.cwd, | |
| 439 | location: scope.location, | |
| 440 | number: input.number, | |
| 441 | iterationId: iteration.id, | |
| 442 | }); | |
| 443 | const changes = listed.changes; | |
| 444 | ||
| 445 | const sections: string[] = []; | |
| 446 | let truncated = listed.truncated; | |
| 447 | let bytes = 0; | |
| 448 | let edits = 0; | |
| 449 | let index = cursor?.fileIndex ?? 0; | |
| 450 | let full = false; | |
| 451 | // How many files to read at once. Every file costs a request a side, and everything read | |
| 452 | // past the point the slice fills is thrown away and read again by the next slice, so the | |
| 453 | // batch narrows as the budgets do: what is left of each, over what a file has spent of it | |
| 454 | // on average so far, which is the only estimate there is before a file is read. At least | |
| 455 | // one, so a file heavier than the whole budget still moves the cursor. | |
| 456 | const batchWidth = () => { | |
| 457 | if (sections.length === 0) return DIFF_FILE_CONCURRENCY; | |
| 458 | const admits = (left: number, spent: number) => | |
| 459 | Math.ceil(left / Math.max(1, spent / sections.length)); | |
| 460 | return Math.max( | |
| 461 | 1, | |
| 462 | Math.min( | |
| 463 | DIFF_FILE_CONCURRENCY, | |
| 464 | // The file budget needs no estimate: a file spends exactly one of it. | |
| 465 | MAX_DIFF_SLICE_FILES - sections.length, | |
| 466 | admits(MAX_DIFF_SLICE_BYTES - bytes, bytes), | |
| 467 | admits(MAX_DIFF_SLICE_EDITS - MAX_FILE_DIFF_EDITS - edits, edits), | |
| 468 | ), | |
| 469 | ); | |
| 470 | }; | |
| 471 | while (!full && index < changes.length) { | |
| 472 | const batch = changes.slice(index, index + batchWidth()); | |
| 473 | const read = yield* Effect.forEach( | |
| 474 | batch, | |
| 475 | (change) => | |
| 476 | // A pair Azure refuses is one file rather than the whole slice: an oversize blob or | |
| 477 | // a path `az` will not carry through leaves that file listed without its hunks, and | |
| 478 | // everything around it still renders. | |
| 479 | readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).pipe( | |
| 480 | // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az` | |
| 481 | // at all is the read failing rather than the file, and belongs to the caller, | |
| 482 | // which pauses the host rather than showing every file as unreadable. | |
| 483 | Effect.catchTags({ | |
| 484 | AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null), | |
| 485 | AzureDevOpsCommandFailedError: () => Effect.succeed(null), | |
| 486 | AzureDevOpsPullRequestReadError: () => Effect.succeed(null), | |
| 487 | }), | |
| 488 | Effect.map((texts) => ({ change, texts })), | |
| 489 | ), | |
| 490 | { concurrency: DIFF_FILE_CONCURRENCY }, | |
| 491 | ); | |
| 492 | for (const { change, texts } of read) { | |
| 493 | // The diff is synchronous and the reads no longer stand between one file and the next | |
| 494 | // to let anything else on the server run, so the thread is handed back here. | |
| 495 | yield* Effect.yieldNow; | |
| 496 | const file = | |
| 497 | texts === null | |
| 498 | ? azureDevOpsUnreadableFilePatch(change) | |
| 499 | : azureDevOpsFilePatch({ change, texts }); | |
| 500 | sections.push(file.section); | |
| 501 | bytes += byteLength(file.section); | |
| 502 | edits += file.edits; | |
| 503 | truncated = truncated || file.truncated; | |
| 504 | index += 1; | |
| 505 | // A file whose diff was given up on spent the whole of what one file is allowed and | |
| 506 | // has only a header to show for it, and a file the diff never ran on at all weighs | |
| 507 | // almost nothing in either budget and still costs its two reads. So the file count | |
| 508 | // bounds the request alongside the bytes. | |
| 509 | // Checked after the file is added rather than before it, so every slice carries at | |
| 510 | // least one: a section heavier than the whole budget would otherwise never be added, | |
| 511 | // and the read would answer the same slice forever without moving the cursor. | |
| 512 | if ( | |
| 513 | bytes >= MAX_DIFF_SLICE_BYTES || | |
| 514 | edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS || | |
| 515 | sections.length >= MAX_DIFF_SLICE_FILES || | |
| 516 | file.abandoned | |
| 517 | ) { | |
| 518 | full = true; | |
| 519 | break; | |
| 520 | } | |
| 521 | } | |
| 522 | } | |
| 523 | ||
| 524 | const slice: ProviderDiffSlice = { | |
| 525 | patch: sections.join(""), | |
| 526 | truncated, | |
| 527 | nextCursor: | |
| 528 | index >= changes.length | |
| 529 | ? null | |
| 530 | : formatAzureDevOpsDiffCursor({ iterationId: iteration.id, fileIndex: index }), | |
| 531 | }; | |
| 532 | return slice; | |
| 533 | }).pipe(Effect.mapError(fail("getDiff"))), | |
| 534 | ||
| 535 | // The patch is built from whole files, so opening the lines around a hunk is the same two | |
| 536 | // reads over again rather than a wider request. Read against the latest iteration: nothing in | |
| 537 | // the request says which push the reader is looking at, and expansion is stale after a | |
| 538 | // mid-review push on every host here anyway. | |
| 539 | getDiffFileContents: (input) => | |
| 540 | Effect.gen(function* () { | |
| 541 | const scope = yield* diffScope(input); | |
| 542 | const iteration = scope?.iterations.at(-1); | |
| 543 | if (scope === null || iteration === undefined) return { oldContents: "", newContents: "" }; | |
| 544 | return yield* readTexts({ | |
| 545 | cwd: input.cwd, | |
| 546 | location: scope.location, | |
| 547 | iteration, | |
| 548 | change: { | |
| 549 | changeKind: input.changeType, | |
| 550 | path: input.newPath, | |
| 551 | oldPath: input.oldPath, | |
| 552 | }, | |
| 553 | }); | |
| 554 | }).pipe(Effect.mapError(fail("getDiffFileContents"))), | |
| 555 | ||
| 556 | /** | |
| 557 | * What the head has of each marked file, which is the blob Azure already names on the change | |
| 558 | * it reports. One read covers every path, since the latest iteration lists the whole change. | |
| 559 | * | |
| 560 | * A path the change does not carry is at the empty revision, which is where a file the pull | |
| 561 | * request deletes sits. When the change was too long to follow to its end, those paths are | |
| 562 | * left out instead: reporting them as deleted would clear a file nobody has read. | |
| 563 | */ | |
| 564 | getFileRevisions: (input) => | |
| 565 | Effect.gen(function* () { | |
| 566 | const revisions = new Map<string, string>(); | |
| 567 | if (input.paths.length === 0) return { revisions }; | |
| 568 | const scope = yield* diffScope(input); | |
| 569 | if (scope === null) return { revisions }; | |
| 570 | const listed = yield* listLatestChanges({ | |
| 571 | ...scope, | |
| 572 | cwd: input.cwd, | |
| 573 | number: input.number, | |
| 574 | }); | |
| 575 | const marked = new Set(input.paths); | |
| 576 | for (const change of listed.changes) { | |
| 577 | if (!marked.has(change.path) || change.objectId === null) continue; | |
| 578 | revisions.set(change.path, change.objectId); | |
| 579 | } | |
| 580 | if (!listed.truncated) { | |
| 581 | for (const path of input.paths) { | |
| 582 | if (!revisions.has(path)) revisions.set(path, ""); | |
| 583 | } | |
| 584 | } | |
| 585 | return { revisions }; | |
| 586 | }).pipe(Effect.mapError(fail("getFileRevisions"))), | |
| 587 | ||
| 588 | runAction: (input) => | |
| 589 | cli | |
| 590 | .runPullRequestAction({ | |
| 591 | cwd: input.cwd, | |
| 592 | number: input.number, | |
| 593 | action: input.action, | |
| 594 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), | |
| 595 | }) | |
| 596 | .pipe(Effect.mapError(fail("runAction"))), | |
| 597 | ||
| 598 | updateChangeRequest: (input) => | |
| 599 | cli | |
| 600 | .updatePullRequest({ | |
| 601 | cwd: input.cwd, | |
| 602 | number: input.number, | |
| 603 | title: input.title, | |
| 604 | body: input.body, | |
| 605 | }) | |
| 606 | .pipe(Effect.mapError(fail("updateChangeRequest"))), | |
| 607 | ||
| 608 | // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the | |
| 609 | // list without it. | |
| 610 | listReviewerCandidates: () => | |
| 611 | Effect.fail( | |
| 612 | new PullRequestProviderError({ | |
| 613 | provider: "azure-devops", | |
| 614 | operation: "listReviewerCandidates", | |
| 615 | reason: "failed", | |
| 616 | detail: "Azure DevOps cannot say who may review a pull request.", | |
| 617 | }), | |
| 618 | ), | |
| 619 | ||
| 620 | setReviewerRequest: (input) => | |
| 621 | cli | |
| 622 | .setPullRequestReviewers({ | |
| 623 | cwd: input.cwd, | |
| 624 | number: input.number, | |
| 625 | // Azure names an identity by an email address or a guid, and has no team to ask, so a | |
| 626 | // candidate's id is the whole of what it takes. | |
| 627 | reviewers: input.reviewers.map((reviewer) => reviewer.id), | |
| 628 | requested: input.requested, | |
| 629 | }) | |
| 630 | .pipe(Effect.mapError(fail("setReviewerRequest"))), | |
| 631 | ||
| 632 | // Never called: `capabilities.comment` is false, and the service refuses a comment without it. | |
| 633 | comment: () => unsupported("comment"), | |
| 634 | ||
| 635 | // Declared unsupported above, so the service refuses these before a provider is reached. | |
| 636 | // They exist because every provider answers the whole port. | |
| 637 | submitReview: () => unsupported("submitReview"), | |
| 638 | ||
| 639 | replyToThread: () => unsupported("replyToThread"), | |
| 640 | ||
| 641 | setThreadResolution: () => unsupported("setThreadResolution"), | |
| 642 | ||
| 643 | setReaction: () => unsupported("setReaction"), | |
| 644 | }; | |
| 645 | ||
| 646 | return provider; | |
| 647 | }); |
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+2-2| ... | ... | @@ -1,11 +1,11 @@ |
| 1 | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; | |
| 3 | import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; | |
| 4 | 4 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; |
| 5 | 5 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 6 | 6 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 7 | 7 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 8 | import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts"; | |
| 8 | import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider"; | |
| 9 | 9 | import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts"; |
| 10 | 10 | import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts"; |
| 11 | 11 | import { gitLabProviderFailure } from "./GitLabPullRequestProvider.ts"; |
apps/server/src/pullRequest/PullRequestProviderRegistry.ts-5| ... | ... | @@ -3,12 +3,9 @@ import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Layer from "effect/Layer"; |
| 4 | 4 | import type { SourceControlProviderKind } from "@t3tools/contracts"; |
| 5 | 5 | |
| 6 | import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; | |
| 7 | 6 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; |
| 8 | 7 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 9 | 8 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 10 | import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; | |
| 11 | import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; | |
| 12 | 9 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; |
| 13 | 10 | import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; |
| 14 | 11 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; |
| ... | ... | @@ -49,7 +46,6 @@ export const make = Effect.gen(function* () { |
| 49 | 46 | GitHubPullRequestProvider.make, |
| 50 | 47 | GitLabPullRequestProvider.make, |
| 51 | 48 | BitbucketPullRequestProvider.make, |
| 52 | AzureDevOpsPullRequestProvider.make, | |
| 53 | 49 | ]); |
| 54 | 50 | const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => |
| 55 | 51 | driver.make.pipe(Effect.map((instance) => instance.pullRequests)), |
| ... | ... | @@ -72,5 +68,4 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( |
| 72 | 68 | Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), |
| 73 | 69 | Layer.provide(BuiltInDrivers.layer), |
| 74 | 70 | Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), |
| 75 | Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))), | |
| 76 | 71 | ); |
apps/server/src/pullRequest/azureDevOpsDiff.test.ts deleted-459| ... | ... | @@ -1,459 +0,0 @@ |
| 1 | import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | azureDevOpsFilePatch, | |
| 6 | azureDevOpsUnreadableFilePatch, | |
| 7 | byteLength, | |
| 8 | formatAzureDevOpsDiffCursor, | |
| 9 | MAX_FILE_DIFF_EDITS, | |
| 10 | parseAzureDevOpsDiffCursor, | |
| 11 | } from "./azureDevOpsDiff.ts"; | |
| 12 | import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; | |
| 13 | ||
| 14 | function change(overrides: Partial<AzureDevOpsChangeEntry> = {}): AzureDevOpsChangeEntry { | |
| 15 | return { | |
| 16 | path: "README.md", | |
| 17 | oldPath: "README.md", | |
| 18 | changeKind: "change", | |
| 19 | objectId: "8f80", | |
| 20 | originalObjectId: "0ca4", | |
| 21 | ...overrides, | |
| 22 | }; | |
| 23 | } | |
| 24 | ||
| 25 | function texts(oldContents: string, newContents: string, binary = false) { | |
| 26 | return { oldContents, newContents, binary }; | |
| 27 | } | |
| 28 | ||
| 29 | describe("azureDevOpsFilePatch", () => { | |
| 30 | it("writes a changed file as the unified patch every diff viewer already reads", () => { | |
| 31 | const patch = azureDevOpsFilePatch({ | |
| 32 | change: change(), | |
| 33 | texts: texts("one\ntwo\nthree\n", "one\ntwo again\nthree\n"), | |
| 34 | }); | |
| 35 | ||
| 36 | expect(patch.truncated).toBe(false); | |
| 37 | expect(patch.section).toBe( | |
| 38 | [ | |
| 39 | "diff --git a/README.md b/README.md", | |
| 40 | "--- a/README.md", | |
| 41 | "+++ b/README.md", | |
| 42 | "@@ -1,3 +1,3 @@", | |
| 43 | " one", | |
| 44 | "-two", | |
| 45 | "+two again", | |
| 46 | " three", | |
| 47 | "", | |
| 48 | ].join("\n"), | |
| 49 | ); | |
| 50 | }); | |
| 51 | ||
| 52 | it("names the side a new file does not have as /dev/null", () => { | |
| 53 | const patch = azureDevOpsFilePatch({ | |
| 54 | change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), | |
| 55 | texts: texts("", "hello\n"), | |
| 56 | }); | |
| 57 | ||
| 58 | expect(patch.section).toContain("new file mode 100644"); | |
| 59 | expect(patch.section).toContain("--- /dev/null"); | |
| 60 | expect(patch.section).toContain("+++ b/DEMO.md"); | |
| 61 | // Git points the range a new file does not have at line zero, not at line one. | |
| 62 | expect(patch.section).toContain("@@ -0,0 +1 @@"); | |
| 63 | expect(patch.section).toContain("+hello"); | |
| 64 | }); | |
| 65 | ||
| 66 | it("names the side a deleted file no longer has as /dev/null", () => { | |
| 67 | const patch = azureDevOpsFilePatch({ | |
| 68 | change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), | |
| 69 | texts: texts("gone\n", ""), | |
| 70 | }); | |
| 71 | ||
| 72 | expect(patch.section).toContain("deleted file mode 100644"); | |
| 73 | expect(patch.section).toContain("--- a/OLD.md"); | |
| 74 | expect(patch.section).toContain("+++ /dev/null"); | |
| 75 | expect(patch.section).toContain("@@ -1 +0,0 @@"); | |
| 76 | expect(patch.section).toContain("-gone"); | |
| 77 | }); | |
| 78 | ||
| 79 | it("keeps the carriage returns of a file with Windows line endings", () => { | |
| 80 | // They are part of the line rather than around it, so a patch that dropped them would ask | |
| 81 | // the reader to look at a change that is not the one on the host. | |
| 82 | const patch = azureDevOpsFilePatch({ | |
| 83 | change: change(), | |
| 84 | texts: texts("one\r\ntwo\r\n", "one\r\ntwo again\r\n"), | |
| 85 | }); | |
| 86 | ||
| 87 | expect(patch.section).toContain("-two\r"); | |
| 88 | expect(patch.section).toContain("+two again\r"); | |
| 89 | }); | |
| 90 | ||
| 91 | it("keeps a file that only moved, which has no hunks to give", () => { | |
| 92 | const patch = azureDevOpsFilePatch({ | |
| 93 | change: change({ path: "docs/new.md", oldPath: "docs/old.md", changeKind: "rename-pure" }), | |
| 94 | texts: texts("same\n", "same\n"), | |
| 95 | }); | |
| 96 | ||
| 97 | expect(patch.truncated).toBe(false); | |
| 98 | expect(patch.section).toBe( | |
| 99 | [ | |
| 100 | "diff --git a/docs/old.md b/docs/new.md", | |
| 101 | "rename from docs/old.md", | |
| 102 | "rename to docs/new.md", | |
| 103 | "--- a/docs/old.md", | |
| 104 | "+++ b/docs/new.md", | |
| 105 | "", | |
| 106 | ].join("\n"), | |
| 107 | ); | |
| 108 | }); | |
| 109 | ||
| 110 | it("reports a binary file as changed rather than spelling it out", () => { | |
| 111 | const patch = azureDevOpsFilePatch({ | |
| 112 | change: change({ path: "logo.png", oldPath: "logo.png" }), | |
| 113 | texts: texts("PNG\u0000old", "PNG\u0000new"), | |
| 114 | }); | |
| 115 | ||
| 116 | expect(patch.truncated).toBe(true); | |
| 117 | expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ"); | |
| 118 | }); | |
| 119 | ||
| 120 | it("shows an overlong file as changed without its hunks", () => { | |
| 121 | const patch = azureDevOpsFilePatch({ | |
| 122 | change: change({ path: "bundle.js", oldPath: "bundle.js" }), | |
| 123 | texts: texts("a\n".repeat(400_000), "b\n".repeat(400_000)), | |
| 124 | }); | |
| 125 | ||
| 126 | expect(patch.truncated).toBe(true); | |
| 127 | expect(patch.section).toBe( | |
| 128 | ["diff --git a/bundle.js b/bundle.js", "--- a/bundle.js", "+++ b/bundle.js", ""].join("\n"), | |
| 129 | ); | |
| 130 | }); | |
| 131 | ||
| 132 | it("takes the host's word that a file is binary, whatever its bytes look like", () => { | |
| 133 | // Azure hands such a file over base64-encoded, so nothing in the text it sent gives it away. | |
| 134 | const patch = azureDevOpsFilePatch({ | |
| 135 | change: change({ path: "logo.png", oldPath: "logo.png" }), | |
| 136 | texts: texts("b2xk", "bmV3", true), | |
| 137 | }); | |
| 138 | ||
| 139 | expect(patch.truncated).toBe(true); | |
| 140 | expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ"); | |
| 141 | }); | |
| 142 | ||
| 143 | it("counts an overlong file in bytes rather than in characters", () => { | |
| 144 | // Three bytes each, so a ceiling counted in code units would let three times the size through. | |
| 145 | const patch = azureDevOpsFilePatch({ | |
| 146 | change: change({ path: "notes.md", oldPath: "notes.md" }), | |
| 147 | texts: texts("\u4e00".repeat(200_000), "\u4e8c".repeat(200_000)), | |
| 148 | }); | |
| 149 | ||
| 150 | expect(patch.truncated).toBe(true); | |
| 151 | expect(patch.section).toBe( | |
| 152 | ["diff --git a/notes.md b/notes.md", "--- a/notes.md", "+++ b/notes.md", ""].join("\n"), | |
| 153 | ); | |
| 154 | }); | |
| 155 | ||
| 156 | const lineRange = (count: number, prefix: string) => | |
| 157 | Array.from({ length: count }, (_, line) => `${prefix} ${line}`).join("\n"); | |
| 158 | ||
| 159 | it("lists a file too far apart to diff without its hunks", () => { | |
| 160 | // Sharing no line at all costs one edit per line on each side, so this pair is twice the | |
| 161 | // ceiling apart. Left to itself the search costs about the square of that and would hold the | |
| 162 | // whole server, every websocket client with it, while it worked out a patch nobody reads. | |
| 163 | // Writing both sides out instead would read as a genuine rewrite: the ceiling is a distance | |
| 164 | // rather than a proportion, so a long file reaches it having changed in one corner, and that | |
| 165 | // corner would be buried in a wall of red and green. | |
| 166 | const lines = (prefix: string) => | |
| 167 | Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n"); | |
| 168 | const patch = azureDevOpsFilePatch({ | |
| 169 | change: change({ path: "generated.ts", oldPath: "generated.ts" }), | |
| 170 | texts: texts(`${lines("old")}\n`, `${lines("new")}\n`), | |
| 171 | }); | |
| 172 | ||
| 173 | expect(patch.truncated).toBe(true); | |
| 174 | // And it says the search was given up on, because the reader of a run of files is meant to | |
| 175 | // stop rather than spend that work again on each of the ones behind it. | |
| 176 | expect(patch.abandoned).toBe(true); | |
| 177 | expect(patch.section).toBe( | |
| 178 | [ | |
| 179 | "diff --git a/generated.ts b/generated.ts", | |
| 180 | "--- a/generated.ts", | |
| 181 | "+++ b/generated.ts", | |
| 182 | "", | |
| 183 | ].join("\n"), | |
| 184 | ); | |
| 185 | }); | |
| 186 | ||
| 187 | it("writes out a wholly new file however many lines it has", () => { | |
| 188 | // Nothing on the old side means there was no edit distance to search out, so this is the | |
| 189 | // minimal patch and not a stand-in for one. Fifteen thousand lines of thirty bytes is the | |
| 190 | // shape this used to lose: inside the byte ceiling that gates every file, and well past every | |
| 191 | // bound a two-sided file answers to, none of which is protecting against anything here. | |
| 192 | const contents = `${Array.from({ length: 15_000 }, () => "x".repeat(29)).join("\n")}\n`; | |
| 193 | const patch = azureDevOpsFilePatch({ | |
| 194 | change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), | |
| 195 | texts: texts("", contents), | |
| 196 | }); | |
| 197 | ||
| 198 | expect(byteLength(contents)).toBeLessThan(512 * 1024); | |
| 199 | expect(patch.truncated).toBe(false); | |
| 200 | expect(patch.abandoned).toBe(false); | |
| 201 | expect(patch.edits).toBe(15_000); | |
| 202 | expect(patch.section).toContain("@@ -0,0 +1,15000 @@"); | |
| 203 | // Only the `+++` of the header on top of the file's own lines, so nothing was dropped out of | |
| 204 | // the middle. | |
| 205 | expect(patch.section.match(/^\+/gmu)).toHaveLength(15_001); | |
| 206 | }); | |
| 207 | ||
| 208 | it("keeps a wholly new file too heavy to write out listed without its hunks", () => { | |
| 209 | // Every line carries a marker, so a side of very short lines answers with up to twice its own | |
| 210 | // bytes: these 200,000 one-character lines fit the ceiling each side is read under and weigh | |
| 211 | // about 600KB written out, more than twice what a whole slice may carry. There is no smaller | |
| 212 | // true patch for a creation to fall back to, so it is listed without its hunks, the same as a | |
| 213 | // side too big to read at all, rather than sent at a size the slice budget exists to prevent. | |
| 214 | const contents = `${Array.from({ length: 200_000 }, () => "x").join("\n")}\n`; | |
| 215 | const patch = azureDevOpsFilePatch({ | |
| 216 | change: change({ path: "bundle.min.js", oldPath: "bundle.min.js", changeKind: "new" }), | |
| 217 | texts: texts("", contents), | |
| 218 | }); | |
| 219 | ||
| 220 | expect(byteLength(contents)).toBeLessThan(512 * 1024); | |
| 221 | expect(patch.truncated).toBe(true); | |
| 222 | expect(patch.abandoned).toBe(false); | |
| 223 | // It still cost the walk over its lines, which is what the slice is charged for. | |
| 224 | expect(patch.edits).toBe(200_000); | |
| 225 | expect(patch.section).toBe( | |
| 226 | [ | |
| 227 | "diff --git a/bundle.min.js b/bundle.min.js", | |
| 228 | "new file mode 100644", | |
| 229 | "--- /dev/null", | |
| 230 | "+++ b/bundle.min.js", | |
| 231 | "", | |
| 232 | ].join("\n"), | |
| 233 | ); | |
| 234 | }); | |
| 235 | ||
| 236 | it("writes out a wholly deleted file however many lines it had", () => { | |
| 237 | const contents = `${lineRange(20_000, "line")}\n`; | |
| 238 | const patch = azureDevOpsFilePatch({ | |
| 239 | change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), | |
| 240 | texts: texts(contents, ""), | |
| 241 | }); | |
| 242 | ||
| 243 | expect(patch.truncated).toBe(false); | |
| 244 | expect(patch.abandoned).toBe(false); | |
| 245 | expect(patch.edits).toBe(20_000); | |
| 246 | expect(patch.section).toContain("@@ -1,20000 +0,0 @@"); | |
| 247 | expect(patch.section.match(/^-line /gmu)).toHaveLength(20_000); | |
| 248 | expect(patch.section.match(/^-/gmu)).toHaveLength(20_001); | |
| 249 | }); | |
| 250 | ||
| 251 | it("gives an empty new file no hunk to read", () => { | |
| 252 | // A file with nothing on either side has no lines to claim were replaced, and git writes it | |
| 253 | // as a header alone. | |
| 254 | const patch = azureDevOpsFilePatch({ | |
| 255 | change: change({ path: "EMPTY.md", oldPath: "EMPTY.md", changeKind: "new" }), | |
| 256 | texts: texts("", ""), | |
| 257 | }); | |
| 258 | ||
| 259 | expect(patch.edits).toBe(0); | |
| 260 | expect(patch.section).not.toContain("@@"); | |
| 261 | }); | |
| 262 | ||
| 263 | it("marks a wholly new file whose last line has no newline after it", () => { | |
| 264 | const patch = azureDevOpsFilePatch({ | |
| 265 | change: change({ path: "NOTES.md", oldPath: "NOTES.md", changeKind: "new" }), | |
| 266 | texts: texts("", "one\ntwo"), | |
| 267 | }); | |
| 268 | ||
| 269 | expect(patch.section).toBe( | |
| 270 | [ | |
| 271 | "diff --git a/NOTES.md b/NOTES.md", | |
| 272 | "new file mode 100644", | |
| 273 | "--- /dev/null", | |
| 274 | "+++ b/NOTES.md", | |
| 275 | "@@ -0,0 +1,2 @@", | |
| 276 | "+one", | |
| 277 | "+two", | |
| 278 | "\\ No newline at end of file", | |
| 279 | "", | |
| 280 | ].join("\n"), | |
| 281 | ); | |
| 282 | }); | |
| 283 | ||
| 284 | it("keeps a file it did diff out of the giving up", () => { | |
| 285 | const patch = azureDevOpsFilePatch({ | |
| 286 | change: change(), | |
| 287 | texts: texts("one\ntwo\n", "one\ntwo again\n"), | |
| 288 | }); | |
| 289 | ||
| 290 | expect(patch.abandoned).toBe(false); | |
| 291 | }); | |
| 292 | ||
| 293 | it("counts what the diff worked out, which is what the file cost to diff", () => { | |
| 294 | // The caller spends a budget of these across a slice, so they have to be the edits the search | |
| 295 | // actually made: one line replaced is a removal and an addition, and the three lines of | |
| 296 | // context around them cost nothing. | |
| 297 | const patch = azureDevOpsFilePatch({ | |
| 298 | change: change(), | |
| 299 | texts: texts("one\ntwo\nthree\nfour\n", "one\ntwo again\nthree\nfour\n"), | |
| 300 | }); | |
| 301 | ||
| 302 | expect(patch.edits).toBe(2); | |
| 303 | }); | |
| 304 | ||
| 305 | it("counts nothing for a file it never diffed", () => { | |
| 306 | const patch = azureDevOpsFilePatch({ | |
| 307 | change: change({ path: "logo.png", oldPath: "logo.png" }), | |
| 308 | texts: texts("PNG\u0000old", "PNG\u0000new"), | |
| 309 | }); | |
| 310 | ||
| 311 | expect(patch.edits).toBe(0); | |
| 312 | }); | |
| 313 | ||
| 314 | it("lists a file whose hunks outweigh its sides without them", () => { | |
| 315 | // A handful of very long lines is a few edits and nowhere near the edit ceiling, and the | |
| 316 | // patch carries both sides in full with three lines of context around each hunk, so the | |
| 317 | // section comes out heavier than either side was. What one file weighs is what a slice's | |
| 318 | // budget is spent in, so the edit ceiling alone does not bound this. | |
| 319 | const line = `${"a".repeat(400 * 1024)}\n`; | |
| 320 | const patch = azureDevOpsFilePatch({ | |
| 321 | change: change({ path: "min.js", oldPath: "min.js" }), | |
| 322 | texts: texts(line, `${"b".repeat(400 * 1024)}\n`), | |
| 323 | }); | |
| 324 | ||
| 325 | expect(patch.edits).toBeLessThan(MAX_FILE_DIFF_EDITS); | |
| 326 | expect(patch.truncated).toBe(true); | |
| 327 | expect(patch.section).toBe( | |
| 328 | ["diff --git a/min.js b/min.js", "--- a/min.js", "+++ b/min.js", ""].join("\n"), | |
| 329 | ); | |
| 330 | expect(byteLength(patch.section)).toBeLessThan(byteLength(line)); | |
| 331 | }); | |
| 332 | ||
| 333 | it("marks a file that does not end in a newline, as git does", () => { | |
| 334 | const patch = azureDevOpsFilePatch({ | |
| 335 | change: change(), | |
| 336 | texts: texts("one\n", "two"), | |
| 337 | }); | |
| 338 | ||
| 339 | expect(patch.section).toContain("\\ No newline at end of file"); | |
| 340 | }); | |
| 341 | }); | |
| 342 | ||
| 343 | describe("azureDevOpsUnreadableFilePatch", () => { | |
| 344 | it("keeps a file the host would not hand over, listed without its hunks", () => { | |
| 345 | const patch = azureDevOpsUnreadableFilePatch(change({ path: "huge.bin", oldPath: "huge.bin" })); | |
| 346 | ||
| 347 | expect(patch.truncated).toBe(true); | |
| 348 | expect(patch.section).toBe( | |
| 349 | ["diff --git a/huge.bin b/huge.bin", "--- a/huge.bin", "+++ b/huge.bin", ""].join("\n"), | |
| 350 | ); | |
| 351 | }); | |
| 352 | }); | |
| 353 | ||
| 354 | describe("a file Azure names something a patch header cannot carry plainly", () => { | |
| 355 | it("writes each side as git's quoted form, the side letter inside the quotes", () => { | |
| 356 | const patch = azureDevOpsFilePatch({ | |
| 357 | change: change({ path: "notes\treadme.md", oldPath: "notes\treadme.md" }), | |
| 358 | texts: texts("one\n", "two\n"), | |
| 359 | }); | |
| 360 | ||
| 361 | expect(patch.section).toBe( | |
| 362 | [ | |
| 363 | 'diff --git "a/notes\\treadme.md" "b/notes\\treadme.md"', | |
| 364 | '--- "a/notes\\treadme.md"', | |
| 365 | '+++ "b/notes\\treadme.md"', | |
| 366 | "@@ -1 +1 @@", | |
| 367 | "-one", | |
| 368 | "+two", | |
| 369 | "", | |
| 370 | ].join("\n"), | |
| 371 | ); | |
| 372 | }); | |
| 373 | ||
| 374 | it("keeps a name holding a newline on the one header line it belongs to", () => { | |
| 375 | const patch = azureDevOpsFilePatch({ | |
| 376 | change: change({ path: "line\nfile.txt", oldPath: "line\nfile.txt" }), | |
| 377 | texts: texts("one\n", "two\n"), | |
| 378 | }); | |
| 379 | ||
| 380 | // Written as itself the name would start a line of its own, and a reader would take what | |
| 381 | // followed for a header the patch never had. | |
| 382 | expect(patch.section.split("\n").slice(0, 3)).toEqual([ | |
| 383 | 'diff --git "a/line\\nfile.txt" "b/line\\nfile.txt"', | |
| 384 | '--- "a/line\\nfile.txt"', | |
| 385 | '+++ "b/line\\nfile.txt"', | |
| 386 | ]); | |
| 387 | }); | |
| 388 | ||
| 389 | it("quotes the names a rename states, which carry no side letter", () => { | |
| 390 | const patch = azureDevOpsFilePatch({ | |
| 391 | change: change({ | |
| 392 | path: "docs/new\tname.md", | |
| 393 | oldPath: "docs/old\tname.md", | |
| 394 | changeKind: "rename-pure", | |
| 395 | }), | |
| 396 | texts: texts("same\n", "same\n"), | |
| 397 | }); | |
| 398 | ||
| 399 | expect(patch.section).toBe( | |
| 400 | [ | |
| 401 | 'diff --git "a/docs/old\\tname.md" "b/docs/new\\tname.md"', | |
| 402 | 'rename from "docs/old\\tname.md"', | |
| 403 | 'rename to "docs/new\\tname.md"', | |
| 404 | '--- "a/docs/old\\tname.md"', | |
| 405 | '+++ "b/docs/new\\tname.md"', | |
| 406 | "", | |
| 407 | ].join("\n"), | |
| 408 | ); | |
| 409 | }); | |
| 410 | ||
| 411 | it("quotes the sides of the one line a binary file gets", () => { | |
| 412 | const patch = azureDevOpsFilePatch({ | |
| 413 | change: change({ path: "logo\tmark.png", oldPath: "logo\tmark.png" }), | |
| 414 | texts: texts("PNG\u0000old", "PNG\u0000new"), | |
| 415 | }); | |
| 416 | ||
| 417 | expect(patch.section).toContain( | |
| 418 | 'Binary files "a/logo\\tmark.png" and "b/logo\\tmark.png" differ', | |
| 419 | ); | |
| 420 | }); | |
| 421 | ||
| 422 | it("hands a reader of the header back the name Azure gave", () => { | |
| 423 | const path = 'every\t\n"kind"\\of.md'; | |
| 424 | const patch = azureDevOpsFilePatch({ | |
| 425 | change: change({ path, oldPath: path }), | |
| 426 | texts: texts("one\n", "two\n"), | |
| 427 | }); | |
| 428 | const [header, oldLine, newLine] = patch.section.split("\n"); | |
| 429 | ||
| 430 | expect(header).not.toContain("\t"); | |
| 431 | expect(unquoteGitPatchPath(oldLine?.slice(4) ?? "")).toBe(`a/${path}`); | |
| 432 | expect(unquoteGitPatchPath(newLine?.slice(4) ?? "")).toBe(`b/${path}`); | |
| 433 | }); | |
| 434 | }); | |
| 435 | ||
| 436 | describe("a diff cursor", () => { | |
| 437 | it("carries the push it was taken against back to the next slice", () => { | |
| 438 | const cursor = formatAzureDevOpsDiffCursor({ iterationId: 3, fileIndex: 12 }); | |
| 439 | ||
| 440 | expect(parseAzureDevOpsDiffCursor(cursor)).toEqual({ iterationId: 3, fileIndex: 12 }); | |
| 441 | }); | |
| 442 | ||
| 443 | it("reads anything it did not write as no position at all", () => { | |
| 444 | // Which starts the read from the top rather than failing it: a cursor is the client's to | |
| 445 | // hand back, and nothing downstream is worth refusing a whole diff over. | |
| 446 | for (const raw of [undefined, null, "", "abc", "1", "0:4", "1:-2", "1:2:3"]) { | |
| 447 | expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); | |
| 448 | } | |
| 449 | }); | |
| 450 | ||
| 451 | it("refuses a half it did not write rather than reading it as the first file", () => { | |
| 452 | // `Number` is wider than the cursor: an empty, padded or hex half would otherwise pass as a | |
| 453 | // position, and the read would resume against an iteration the client never saw instead of | |
| 454 | // starting again from the latest one. | |
| 455 | for (const raw of ["1:", ":4", "1: ", " 1:4", "1:0x2", "0x1:2", "1e2:0", "1:4.0"]) { | |
| 456 | expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); | |
| 457 | } | |
| 458 | }); | |
| 459 | }); |
apps/server/src/pullRequest/azureDevOpsDiff.ts deleted-273| ... | ... | @@ -1,273 +0,0 @@ |
| 1 | import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | |
| 2 | import { structuredPatch } from "diff"; | |
| 3 | ||
| 4 | import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; | |
| 5 | ||
| 6 | /** | |
| 7 | * How far a diff read got, and which push it was reading. Azure hangs a pull request's changed | |
| 8 | * files off an iteration, so a push landing mid-read would renumber the list under a cursor that | |
| 9 | * did not also pin the iteration. | |
| 10 | */ | |
| 11 | export interface AzureDevOpsDiffCursor { | |
| 12 | readonly iterationId: number; | |
| 13 | readonly fileIndex: number; | |
| 14 | } | |
| 15 | ||
| 16 | const CURSOR_SEPARATOR = ":"; | |
| 17 | ||
| 18 | /** | |
| 19 | * Restricts each half to plain decimal, so `Number` cannot read a foreign cursor's `0x3` as | |
| 20 | * three. | |
| 21 | */ | |
| 22 | const CURSOR_COMPONENT = /^\d+$/; | |
| 23 | ||
| 24 | export function formatAzureDevOpsDiffCursor(cursor: AzureDevOpsDiffCursor): string { | |
| 25 | return `${cursor.iterationId}${CURSOR_SEPARATOR}${cursor.fileIndex}`; | |
| 26 | } | |
| 27 | ||
| 28 | /** Null for anything this did not write, which starts the read from the top rather than failing. */ | |
| 29 | export function parseAzureDevOpsDiffCursor( | |
| 30 | raw: string | null | undefined, | |
| 31 | ): AzureDevOpsDiffCursor | null { | |
| 32 | if (raw === null || raw === undefined) return null; | |
| 33 | const [iteration, file, ...rest] = raw.split(CURSOR_SEPARATOR); | |
| 34 | if (rest.length > 0) return null; | |
| 35 | if (iteration === undefined || file === undefined) return null; | |
| 36 | if (!CURSOR_COMPONENT.test(iteration) || !CURSOR_COMPONENT.test(file)) return null; | |
| 37 | const iterationId = Number(iteration); | |
| 38 | const fileIndex = Number(file); | |
| 39 | if (!Number.isSafeInteger(iterationId) || iterationId <= 0) return null; | |
| 40 | if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) return null; | |
| 41 | return { iterationId, fileIndex }; | |
| 42 | } | |
| 43 | ||
| 44 | /** The two texts of one changed file, empty on whichever side the change does not have. */ | |
| 45 | export interface AzureDevOpsFileTexts { | |
| 46 | readonly oldContents: string; | |
| 47 | readonly newContents: string; | |
| 48 | /** Azure's own flag for a file it hands back base64-encoded instead of as text. */ | |
| 49 | readonly binary: boolean; | |
| 50 | } | |
| 51 | ||
| 52 | export interface AzureDevOpsFilePatch { | |
| 53 | readonly section: string; | |
| 54 | /** The file changed but its hunks are not in the section, so the patch has a hole in it. */ | |
| 55 | readonly truncated: boolean; | |
| 56 | /** | |
| 57 | * The diff was given up on partway, having spent the whole edit budget, so a caller reading a | |
| 58 | * run of files stops here rather than paying that again for each one behind it. | |
| 59 | */ | |
| 60 | readonly abandoned: boolean; | |
| 61 | /** | |
| 62 | * Lines added or removed, which is the edit distance the diff had to search out, and what a | |
| 63 | * slice's budget is spent in: a file of short lines is cheap on the wire and dear to diff. | |
| 64 | */ | |
| 65 | readonly edits: number; | |
| 66 | } | |
| 67 | ||
| 68 | /** | |
| 69 | * Beyond this a file is shown as changed without its hunks. Azure hands back whole files rather | |
| 70 | * than a patch, so an oversize file is paid for twice over before anything can be diffed. | |
| 71 | */ | |
| 72 | const MAX_FILE_BYTES = 512 * 1024; | |
| 73 | ||
| 74 | /** Git's own default, and what the hunks from this repo's other hosts are already cut to. */ | |
| 75 | const PATCH_CONTEXT_LINES = 3; | |
| 76 | ||
| 77 | /** | |
| 78 | * How far apart one file's two sides may be before it is listed without its hunks. The line diff | |
| 79 | * costs about the square of the edit distance, so an unbounded pair sharing almost nothing could | |
| 80 | * hold the whole server. Bounded in edits rather than milliseconds so a change slices the same | |
| 81 | * way on every machine. | |
| 82 | */ | |
| 83 | export const MAX_FILE_DIFF_EDITS = 2_000; | |
| 84 | ||
| 85 | /** | |
| 86 | * A backstop for a machine slower than the edit ceiling was tuned for. The ceiling rather than | |
| 87 | * this is what decides a patch's shape: a timeout that fires would slice the same change one way | |
| 88 | * here and another on a busier host. | |
| 89 | */ | |
| 90 | const MAX_FILE_DIFF_MILLIS = 2_000; | |
| 91 | ||
| 92 | /** How much diff work one slice does before the rest is left for the next request. */ | |
| 93 | export const MAX_DIFF_SLICE_EDITS = 6_000; | |
| 94 | ||
| 95 | /** How much patch one slice carries before the rest is left for the next request. */ | |
| 96 | export const MAX_DIFF_SLICE_BYTES = 256 * 1024; | |
| 97 | ||
| 98 | /** | |
| 99 | * How many files one slice carries however little each one weighs. A binary, oversize, or | |
| 100 | * unreadable entry is just a header, so neither budget above stops a run of thousands of them. | |
| 101 | */ | |
| 102 | export const MAX_DIFF_SLICE_FILES = 300; | |
| 103 | ||
| 104 | /** Git's own note for a side whose last line has no newline after it. */ | |
| 105 | const NO_NEWLINE_MARKER = "\\ No newline at end of file"; | |
| 106 | ||
| 107 | /** A text's lines, without the empty one that a trailing newline leaves behind a split. */ | |
| 108 | function contentLines(contents: string): ReadonlyArray<string> { | |
| 109 | if (contents === "") return []; | |
| 110 | const lines = contents.split("\n"); | |
| 111 | if (lines.at(-1) === "") lines.pop(); | |
| 112 | return lines; | |
| 113 | } | |
| 114 | ||
| 115 | /** A NUL byte is git's own test for it, and it survives Azure's JSON envelope intact. */ | |
| 116 | function isBinary(contents: string): boolean { | |
| 117 | return contents.includes("\u0000"); | |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * What a file costs on the wire, which is its bytes rather than its code units: a ceiling counted | |
| 122 | * in characters lets a file of three-byte glyphs through at three times the intended size. | |
| 123 | */ | |
| 124 | export const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8"); | |
| 125 | ||
| 126 | /** | |
| 127 | * Git points an empty range at the line before it, which is line zero for a file that is wholly | |
| 128 | * new or wholly gone, and writes a single line as its number alone. | |
| 129 | */ | |
| 130 | function hunkRange(start: number, lines: number): string { | |
| 131 | if (lines === 0) return `${start - 1},0`; | |
| 132 | return lines === 1 ? String(start) : `${start},${lines}`; | |
| 133 | } | |
| 134 | ||
| 135 | /** | |
| 136 | * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file | |
| 137 | * mode, so the ordinary one stands in. Names are quoted the way git quotes them, prefix inside the | |
| 138 | * quoting: a header reader stops a bare name at its first tab or newline, so a path holding either | |
| 139 | * would be truncated and its viewed mark would land on the wrong file. | |
| 140 | */ | |
| 141 | function patchHeader(change: AzureDevOpsChangeEntry): string { | |
| 142 | const oldSide = quoteGitPatchPath(`a/${change.oldPath}`); | |
| 143 | const newSide = quoteGitPatchPath(`b/${change.path}`); | |
| 144 | const lines = [`diff --git ${oldSide} ${newSide}`]; | |
| 145 | if (change.changeKind === "new") lines.push("new file mode 100644"); | |
| 146 | if (change.changeKind === "deleted") lines.push("deleted file mode 100644"); | |
| 147 | if (change.changeKind === "rename-pure" || change.changeKind === "rename-changed") { | |
| 148 | lines.push( | |
| 149 | `rename from ${quoteGitPatchPath(change.oldPath)}`, | |
| 150 | `rename to ${quoteGitPatchPath(change.path)}`, | |
| 151 | ); | |
| 152 | } | |
| 153 | lines.push( | |
| 154 | `--- ${change.changeKind === "new" ? "/dev/null" : oldSide}`, | |
| 155 | `+++ ${change.changeKind === "deleted" ? "/dev/null" : newSide}`, | |
| 156 | ); | |
| 157 | return lines.join("\n"); | |
| 158 | } | |
| 159 | ||
| 160 | /** | |
| 161 | * A file written out as wholly replaced: every old line gone, every new line arrived, in one | |
| 162 | * hunk, which needs no edit-distance search at all. | |
| 163 | */ | |
| 164 | function replacementSection(header: string, texts: AzureDevOpsFileTexts): string { | |
| 165 | const oldLines = contentLines(texts.oldContents); | |
| 166 | const newLines = contentLines(texts.newContents); | |
| 167 | const noNewline = (contents: string, lines: ReadonlyArray<string>) => | |
| 168 | lines.length > 0 && !contents.endsWith("\n") ? [NO_NEWLINE_MARKER] : []; | |
| 169 | return [ | |
| 170 | header, | |
| 171 | `@@ -${hunkRange(1, oldLines.length)} +${hunkRange(1, newLines.length)} @@`, | |
| 172 | ...oldLines.map((line) => `-${line}`), | |
| 173 | ...noNewline(texts.oldContents, oldLines), | |
| 174 | ...newLines.map((line) => `+${line}`), | |
| 175 | ...noNewline(texts.newContents, newLines), | |
| 176 | "", | |
| 177 | ].join("\n"); | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * One file's section of a unified patch, built here because Azure has no route that returns one: | |
| 182 | * its diff routes name the files that changed, and the contents are a separate read per side. | |
| 183 | */ | |
| 184 | export function azureDevOpsFilePatch(input: { | |
| 185 | readonly change: AzureDevOpsChangeEntry; | |
| 186 | readonly texts: AzureDevOpsFileTexts; | |
| 187 | }): AzureDevOpsFilePatch { | |
| 188 | const header = patchHeader(input.change); | |
| 189 | const { oldContents, newContents } = input.texts; | |
| 190 | ||
| 191 | if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { | |
| 192 | // Git's own wording for a file it will not spell out. | |
| 193 | const oldSide = quoteGitPatchPath(`a/${input.change.oldPath}`); | |
| 194 | const newSide = quoteGitPatchPath(`b/${input.change.path}`); | |
| 195 | const binary = `Binary files ${oldSide} and ${newSide} differ`; | |
| 196 | return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false, edits: 0 }; | |
| 197 | } | |
| 198 | if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) { | |
| 199 | return { section: `${header}\n`, truncated: true, abandoned: false, edits: 0 }; | |
| 200 | } | |
| 201 | ||
| 202 | // A creation or deletion has nothing on one side, so writing both sides in full is already the | |
| 203 | // minimal patch and needs no diff search. | |
| 204 | const created = oldContents === "" && newContents !== ""; | |
| 205 | const deleted = newContents === "" && oldContents !== ""; | |
| 206 | if (created || deleted) { | |
| 207 | const contents = created ? newContents : oldContents; | |
| 208 | const lines = contentLines(contents); | |
| 209 | // A `+`/`-` marker on every line can put a side that just fits the size ceiling over it. | |
| 210 | // Checked against bytes-plus-marker-count rather than building the section, since that upper | |
| 211 | // bound is cheaper and this is exactly the file shape that would cost the most to build. | |
| 212 | if (byteLength(contents) + lines.length > MAX_FILE_BYTES) { | |
| 213 | return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; | |
| 214 | } | |
| 215 | const section = replacementSection(header, input.texts); | |
| 216 | if (byteLength(section) > MAX_FILE_BYTES) { | |
| 217 | return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; | |
| 218 | } | |
| 219 | return { section, truncated: false, abandoned: false, edits: lines.length }; | |
| 220 | } | |
| 221 | ||
| 222 | const patch = structuredPatch( | |
| 223 | `a/${input.change.oldPath}`, | |
| 224 | `b/${input.change.path}`, | |
| 225 | oldContents, | |
| 226 | newContents, | |
| 227 | undefined, | |
| 228 | undefined, | |
| 229 | { | |
| 230 | context: PATCH_CONTEXT_LINES, | |
| 231 | maxEditLength: MAX_FILE_DIFF_EDITS, | |
| 232 | timeout: MAX_FILE_DIFF_MILLIS, | |
| 233 | }, | |
| 234 | ); | |
| 235 | // Hitting the edit ceiling lists the file without hunks rather than falling back to a full | |
| 236 | // replacement: the ceiling is a distance, not a proportion, so a long file can reach it having | |
| 237 | // changed in one small corner, and both sides in full would bury that corner in a wall of text. | |
| 238 | if (patch === undefined) { | |
| 239 | return { | |
| 240 | section: `${header}\n`, | |
| 241 | truncated: true, | |
| 242 | abandoned: true, | |
| 243 | edits: MAX_FILE_DIFF_EDITS, | |
| 244 | }; | |
| 245 | } | |
| 246 | ||
| 247 | let edits = 0; | |
| 248 | const hunks = patch.hunks.map((hunk) => { | |
| 249 | for (const line of hunk.lines) { | |
| 250 | if (line.startsWith("+") || line.startsWith("-")) edits += 1; | |
| 251 | } | |
| 252 | return [ | |
| 253 | `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`, | |
| 254 | ...hunk.lines, | |
| 255 | ].join("\n"); | |
| 256 | }); | |
| 257 | // A pure rename has no hunks to give but is still listed, since dropping it would take the file | |
| 258 | // out of the change altogether. | |
| 259 | const section = hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`; | |
| 260 | // The edit ceiling bounds edit distance, not the hunks' size: a handful of very long lines plus | |
| 261 | // context can still exceed the size ceiling even well inside the edit ceiling. | |
| 262 | if (byteLength(section) > MAX_FILE_BYTES) { | |
| 263 | return { section: `${header}\n`, truncated: true, abandoned: false, edits }; | |
| 264 | } | |
| 265 | return { section, truncated: false, abandoned: false, edits }; | |
| 266 | } | |
| 267 | ||
| 268 | /** A file listed without its hunks, for when the host would not hand one of its two sides over. */ | |
| 269 | export function azureDevOpsUnreadableFilePatch( | |
| 270 | change: AzureDevOpsChangeEntry, | |
| 271 | ): AzureDevOpsFilePatch { | |
| 272 | return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false, edits: 0 }; | |
| 273 | } |
apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts deleted-652| ... | ... | @@ -1,652 +0,0 @@ |
| 1 | import * as Result from "effect/Result"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | decodeItemContentJson, | |
| 6 | decodeIterationChangesJson, | |
| 7 | decodeIterationsJson, | |
| 8 | decodePullRequestJson, | |
| 9 | decodePullRequestListJson, | |
| 10 | decodeThreadsJson, | |
| 11 | decodeViewerJson, | |
| 12 | } from "./azureDevOpsPullRequestJson.ts"; | |
| 13 | ||
| 14 | const REST_URL = | |
| 15 | "https://dev.azure.com/acme/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/pullRequests/42"; | |
| 16 | ||
| 17 | /** Shaped after Azure's `GitPullRequest`, trimmed to the fields that are read. */ | |
| 18 | function pullRequest(overrides: Record<string, unknown> = {}): Record<string, unknown> { | |
| 19 | return { | |
| 20 | pullRequestId: 42, | |
| 21 | title: "Add the change requests page", | |
| 22 | description: "Ships the page.", | |
| 23 | status: "active", | |
| 24 | isDraft: false, | |
| 25 | mergeStatus: "succeeded", | |
| 26 | createdBy: { displayName: "Bilal Hassan", uniqueName: "bilal@acme.dev" }, | |
| 27 | sourceRefName: "refs/heads/feat/page", | |
| 28 | targetRefName: "refs/heads/main", | |
| 29 | creationDate: "2026-07-01T00:00:00Z", | |
| 30 | url: REST_URL, | |
| 31 | repository: { name: "web", project: { name: "platform" } }, | |
| 32 | ...overrides, | |
| 33 | }; | |
| 34 | } | |
| 35 | ||
| 36 | function expectSuccess<A>(result: Result.Result<A, unknown>): A { | |
| 37 | expect(Result.isSuccess(result)).toBe(true); | |
| 38 | if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); | |
| 39 | return result.success; | |
| 40 | } | |
| 41 | ||
| 42 | const asJson = (value: unknown) => JSON.stringify(value); | |
| 43 | ||
| 44 | describe("decodePullRequestListJson", () => { | |
| 45 | it("reads a pull request as a change request", () => { | |
| 46 | const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); | |
| 47 | ||
| 48 | expect(batch.items).toHaveLength(1); | |
| 49 | expect(batch.items[0]).toMatchObject({ | |
| 50 | number: 42, | |
| 51 | title: "Add the change requests page", | |
| 52 | // The login is an email, because that is what `az account show` reports to compare with. | |
| 53 | author: { login: "bilal@acme.dev", name: "Bilal Hassan" }, | |
| 54 | // Azure prefixes its refs, which no other host does. | |
| 55 | headBranch: "feat/page", | |
| 56 | baseBranch: "main", | |
| 57 | state: "open", | |
| 58 | isDraft: false, | |
| 59 | mergeability: "mergeable", | |
| 60 | }); | |
| 61 | }); | |
| 62 | ||
| 63 | it("assembles a browser url when Azure reports no web link", () => { | |
| 64 | const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); | |
| 65 | ||
| 66 | expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); | |
| 67 | }); | |
| 68 | ||
| 69 | it("prefers the web link Azure sends when asked for one", () => { | |
| 70 | const batch = expectSuccess( | |
| 71 | decodePullRequestListJson( | |
| 72 | asJson([ | |
| 73 | pullRequest({ | |
| 74 | _links: { | |
| 75 | web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, | |
| 76 | }, | |
| 77 | }), | |
| 78 | ]), | |
| 79 | ), | |
| 80 | ); | |
| 81 | ||
| 82 | expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); | |
| 83 | }); | |
| 84 | ||
| 85 | it.each([ | |
| 86 | ["active", "open"], | |
| 87 | ["completed", "merged"], | |
| 88 | ["abandoned", "closed"], | |
| 89 | ["something new", "open"], | |
| 90 | ])("reads the %s status as %s", (status, expected) => { | |
| 91 | const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ status })]))); | |
| 92 | ||
| 93 | expect(batch.items[0]?.state).toBe(expected); | |
| 94 | }); | |
| 95 | ||
| 96 | it.each([ | |
| 97 | ["succeeded", "mergeable"], | |
| 98 | ["conflicts", "conflicting"], | |
| 99 | ["rejectedByPolicy", "conflicting"], | |
| 100 | ["queued", "unknown"], | |
| 101 | ["notSet", "unknown"], | |
| 102 | ])("reads the %s merge status as %s", (mergeStatus, expected) => { | |
| 103 | const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ mergeStatus })]))); | |
| 104 | ||
| 105 | expect(batch.items[0]?.mergeability).toBe(expected); | |
| 106 | }); | |
| 107 | ||
| 108 | it("stands the closing time in for a last-touched time Azure does not keep", () => { | |
| 109 | const batch = expectSuccess( | |
| 110 | decodePullRequestListJson( | |
| 111 | asJson([pullRequest({ status: "completed", closedDate: "2026-07-05T00:00:00Z" })]), | |
| 112 | ), | |
| 113 | ); | |
| 114 | ||
| 115 | expect(batch.items[0]).toMatchObject({ | |
| 116 | createdAt: "2026-07-01T00:00:00Z", | |
| 117 | updatedAt: "2026-07-05T00:00:00Z", | |
| 118 | }); | |
| 119 | }); | |
| 120 | ||
| 121 | it("skips a malformed row but still counts it, so paging does not stop early", () => { | |
| 122 | const batch = expectSuccess( | |
| 123 | decodePullRequestListJson(asJson([{ pullRequestId: "nope" }, pullRequest()])), | |
| 124 | ); | |
| 125 | ||
| 126 | expect(batch.items).toHaveLength(1); | |
| 127 | expect(batch.rawCount).toBe(2); | |
| 128 | expect(batch.rawIndexes).toEqual([1]); | |
| 129 | }); | |
| 130 | }); | |
| 131 | ||
| 132 | describe("decodePullRequestJson", () => { | |
| 133 | it("reads reviewers as review requests", () => { | |
| 134 | const detail = expectSuccess( | |
| 135 | decodePullRequestJson( | |
| 136 | asJson( | |
| 137 | pullRequest({ | |
| 138 | reviewers: [{ displayName: "Julius", uniqueName: "julius@acme.dev", vote: 10 }], | |
| 139 | }), | |
| 140 | ), | |
| 141 | ), | |
| 142 | ); | |
| 143 | ||
| 144 | expect(detail?.reviewRequestLogins).toEqual(["julius@acme.dev"]); | |
| 145 | expect(detail?.reviewers).toEqual([ | |
| 146 | { login: "julius@acme.dev", name: "Julius", avatarUrl: null }, | |
| 147 | ]); | |
| 148 | }); | |
| 149 | ||
| 150 | it("reads auto-complete from whoever armed it, and its absence as nobody", () => { | |
| 151 | const armed = expectSuccess( | |
| 152 | decodePullRequestJson( | |
| 153 | asJson(pullRequest({ autoCompleteSetBy: { displayName: "Bilal Hassan" } })), | |
| 154 | ), | |
| 155 | ); | |
| 156 | expect(armed?.autoMergeEnabled).toBe(true); | |
| 157 | ||
| 158 | // Azure leaves the field out entirely rather than sending it empty, so its absence is the | |
| 159 | // whole of what it says about auto-complete being off. | |
| 160 | expect(expectSuccess(decodePullRequestJson(asJson(pullRequest())))?.autoMergeEnabled).toBe( | |
| 161 | false, | |
| 162 | ); | |
| 163 | }); | |
| 164 | ||
| 165 | it("keeps the strategy stored with auto-complete", () => { | |
| 166 | const armed = expectSuccess( | |
| 167 | decodePullRequestJson( | |
| 168 | asJson( | |
| 169 | pullRequest({ | |
| 170 | autoCompleteSetBy: { displayName: "Bilal Hassan" }, | |
| 171 | completionOptions: { mergeStrategy: "squash" }, | |
| 172 | }), | |
| 173 | ), | |
| 174 | ), | |
| 175 | ); | |
| 176 | ||
| 177 | expect(armed).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); | |
| 178 | ||
| 179 | const unspecified = expectSuccess( | |
| 180 | decodePullRequestJson( | |
| 181 | asJson( | |
| 182 | pullRequest({ | |
| 183 | autoCompleteSetBy: { displayName: "Bilal Hassan" }, | |
| 184 | completionOptions: { squashMerge: false }, | |
| 185 | }), | |
| 186 | ), | |
| 187 | ), | |
| 188 | ); | |
| 189 | expect(unspecified?.autoMergeEnabled).toBe(true); | |
| 190 | expect(unspecified?.autoMergeMethod).toBeUndefined(); | |
| 191 | }); | |
| 192 | ||
| 193 | it("works out where the repository lives from what Azure returned", () => { | |
| 194 | const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest()))); | |
| 195 | ||
| 196 | expect(detail?.location).toEqual({ project: "platform", repository: "web" }); | |
| 197 | }); | |
| 198 | ||
| 199 | it("reports no repository location when Azure said too little to name one", () => { | |
| 200 | // A web link places the pull request, but with no repository named there is nothing to | |
| 201 | // address the routes that read its files and its conversation. | |
| 202 | const detail = expectSuccess( | |
| 203 | decodePullRequestJson( | |
| 204 | asJson( | |
| 205 | pullRequest({ | |
| 206 | url: null, | |
| 207 | repository: null, | |
| 208 | _links: { | |
| 209 | web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, | |
| 210 | }, | |
| 211 | }), | |
| 212 | ), | |
| 213 | ), | |
| 214 | ); | |
| 215 | ||
| 216 | expect(detail?.location).toBeNull(); | |
| 217 | }); | |
| 218 | ||
| 219 | it("returns nothing when Azure gave no way to place the pull request at all", () => { | |
| 220 | const detail = expectSuccess( | |
| 221 | decodePullRequestJson(asJson(pullRequest({ url: null, repository: null }))), | |
| 222 | ); | |
| 223 | ||
| 224 | expect(detail).toBeNull(); | |
| 225 | }); | |
| 226 | }); | |
| 227 | ||
| 228 | describe("decodeViewerJson", () => { | |
| 229 | it("reads the signed-in account name", () => { | |
| 230 | expect(expectSuccess(decodeViewerJson(asJson({ user: { name: "bilal@acme.dev" } })))).toBe( | |
| 231 | "bilal@acme.dev", | |
| 232 | ); | |
| 233 | }); | |
| 234 | ||
| 235 | it("returns nothing when nobody is signed in", () => { | |
| 236 | expect(expectSuccess(decodeViewerJson(asJson({ user: null })))).toBeNull(); | |
| 237 | }); | |
| 238 | }); | |
| 239 | ||
| 240 | describe("decodeThreadsJson", () => { | |
| 241 | it("takes every real comment of every thread, oldest first", () => { | |
| 242 | const comments = expectSuccess( | |
| 243 | decodeThreadsJson( | |
| 244 | asJson({ | |
| 245 | value: [ | |
| 246 | { | |
| 247 | id: 2, | |
| 248 | comments: [ | |
| 249 | { | |
| 250 | id: 1, | |
| 251 | content: "Second remark.", | |
| 252 | author: { displayName: "Julius", uniqueName: "julius@acme.dev" }, | |
| 253 | publishedDate: "2026-07-03T00:00:00Z", | |
| 254 | }, | |
| 255 | ], | |
| 256 | }, | |
| 257 | { | |
| 258 | id: 1, | |
| 259 | comments: [ | |
| 260 | // Azure's own activity notes are events rather than remarks. | |
| 261 | { id: 1, content: "Bilal voted", commentType: "system", publishedDate: "x" }, | |
| 262 | { | |
| 263 | id: 2, | |
| 264 | content: "First remark.", | |
| 265 | author: { displayName: "Bilal", uniqueName: "bilal@acme.dev" }, | |
| 266 | publishedDate: "2026-07-02T00:00:00Z", | |
| 267 | }, | |
| 268 | ], | |
| 269 | }, | |
| 270 | ], | |
| 271 | }), | |
| 272 | ), | |
| 273 | ); | |
| 274 | ||
| 275 | expect(comments.map((comment) => comment.body)).toEqual(["First remark.", "Second remark."]); | |
| 276 | expect(comments[0]).toMatchObject({ | |
| 277 | kind: "issue-comment", | |
| 278 | author: { login: "bilal@acme.dev" }, | |
| 279 | }); | |
| 280 | }); | |
| 281 | ||
| 282 | it("reads a thread pinned to a file as a review comment", () => { | |
| 283 | const comments = expectSuccess( | |
| 284 | decodeThreadsJson( | |
| 285 | asJson({ | |
| 286 | value: [ | |
| 287 | { | |
| 288 | id: 3, | |
| 289 | threadContext: { filePath: "/src/app.ts" }, | |
| 290 | comments: [{ id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }], | |
| 291 | }, | |
| 292 | ], | |
| 293 | }), | |
| 294 | ), | |
| 295 | ); | |
| 296 | ||
| 297 | expect(comments[0]).toMatchObject({ kind: "review-comment", path: "/src/app.ts" }); | |
| 298 | }); | |
| 299 | ||
| 300 | it("keeps the replies under a thread, which are as much of the conversation", () => { | |
| 301 | const comments = expectSuccess( | |
| 302 | decodeThreadsJson( | |
| 303 | asJson({ | |
| 304 | value: [ | |
| 305 | { | |
| 306 | id: 4, | |
| 307 | threadContext: { filePath: "/src/app.ts" }, | |
| 308 | comments: [ | |
| 309 | { id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }, | |
| 310 | { id: 2, content: "Renamed.", publishedDate: "2026-07-02T01:00:00Z" }, | |
| 311 | { id: 3, content: "Thanks.", publishedDate: "2026-07-02T02:00:00Z" }, | |
| 312 | ], | |
| 313 | }, | |
| 314 | ], | |
| 315 | }), | |
| 316 | ), | |
| 317 | ); | |
| 318 | ||
| 319 | expect(comments.map((comment) => comment.id)).toEqual(["4:1", "4:2", "4:3"]); | |
| 320 | }); | |
| 321 | ||
| 322 | it("drops deleted threads and threads with nothing to show", () => { | |
| 323 | const comments = expectSuccess( | |
| 324 | decodeThreadsJson( | |
| 325 | asJson({ | |
| 326 | value: [ | |
| 327 | { | |
| 328 | id: 1, | |
| 329 | isDeleted: true, | |
| 330 | comments: [{ id: 1, content: "gone", publishedDate: "2026-07-02T00:00:00Z" }], | |
| 331 | }, | |
| 332 | { id: 2, comments: [] }, | |
| 333 | { | |
| 334 | id: 3, | |
| 335 | comments: [{ id: 1, content: " ", publishedDate: "2026-07-02T00:00:00Z" }], | |
| 336 | }, | |
| 337 | ], | |
| 338 | }), | |
| 339 | ), | |
| 340 | ); | |
| 341 | ||
| 342 | expect(comments).toEqual([]); | |
| 343 | }); | |
| 344 | }); | |
| 345 | ||
| 346 | describe("decodeIterationsJson", () => { | |
| 347 | const iteration = (id: number, head: string, base: string) => ({ | |
| 348 | id, | |
| 349 | sourceRefCommit: { commitId: head }, | |
| 350 | commonRefCommit: { commitId: base }, | |
| 351 | targetRefCommit: { commitId: base }, | |
| 352 | }); | |
| 353 | ||
| 354 | it("reads every push in order, oldest first", () => { | |
| 355 | const iterations = expectSuccess( | |
| 356 | decodeIterationsJson( | |
| 357 | asJson({ value: [iteration(2, "bbb", "base"), iteration(1, "aaa", "base")] }), | |
| 358 | ), | |
| 359 | ); | |
| 360 | ||
| 361 | expect(iterations.map((entry) => entry.id)).toEqual([1, 2]); | |
| 362 | expect(iterations.at(-1)).toEqual({ id: 2, headCommit: "bbb", mergeBaseCommit: "base" }); | |
| 363 | }); | |
| 364 | ||
| 365 | it("skips a push Azure could not place both ends of", () => { | |
| 366 | // A patch is taken over a range, and an iteration missing either end names no range at all. | |
| 367 | const iterations = expectSuccess( | |
| 368 | decodeIterationsJson( | |
| 369 | asJson({ | |
| 370 | value: [{ id: 1, sourceRefCommit: { commitId: "aaa" } }, iteration(2, "bbb", "base")], | |
| 371 | }), | |
| 372 | ), | |
| 373 | ); | |
| 374 | ||
| 375 | expect(iterations.map((entry) => entry.id)).toEqual([2]); | |
| 376 | }); | |
| 377 | }); | |
| 378 | ||
| 379 | describe("decodeIterationChangesJson", () => { | |
| 380 | it("names each changed file without the slash Azure leads its paths with", () => { | |
| 381 | const page = expectSuccess( | |
| 382 | decodeIterationChangesJson( | |
| 383 | asJson({ | |
| 384 | changeEntries: [ | |
| 385 | { changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }, | |
| 386 | { | |
| 387 | changeType: "edit", | |
| 388 | item: { path: "/README.md", objectId: "8f80", originalObjectId: "0ca4" }, | |
| 389 | }, | |
| 390 | { changeType: "delete", item: { path: "/OLD.md", originalObjectId: "1111" } }, | |
| 391 | ], | |
| 392 | }), | |
| 393 | ), | |
| 394 | ); | |
| 395 | ||
| 396 | expect(page.changes.map((change) => [change.path, change.changeKind])).toEqual([ | |
| 397 | ["DEMO.md", "new"], | |
| 398 | ["README.md", "change"], | |
| 399 | ["OLD.md", "deleted"], | |
| 400 | ]); | |
| 401 | }); | |
| 402 | ||
| 403 | it("keeps a space at the end of a file's name, which belongs to the name", () => { | |
| 404 | // Git will carry a name that ends in a space, and the patch and the viewed mark are both | |
| 405 | // keyed by it. Tidying it here files the change under a name nothing else uses. | |
| 406 | const page = expectSuccess( | |
| 407 | decodeIterationChangesJson( | |
| 408 | asJson({ | |
| 409 | changeEntries: [ | |
| 410 | { changeType: "edit", item: { path: "/docs/readme.md ", objectId: "ec00" } }, | |
| 411 | { | |
| 412 | changeType: "rename", | |
| 413 | sourceServerItem: "/docs/old.md ", | |
| 414 | item: { path: "/docs/moved.md", objectId: "aaaa", originalObjectId: "aaaa" }, | |
| 415 | }, | |
| 416 | ], | |
| 417 | }), | |
| 418 | ), | |
| 419 | ); | |
| 420 | ||
| 421 | expect(page.changes.map((change) => [change.path, change.oldPath])).toEqual([ | |
| 422 | ["docs/readme.md ", "docs/readme.md "], | |
| 423 | ["docs/moved.md", "docs/old.md "], | |
| 424 | ]); | |
| 425 | }); | |
| 426 | ||
| 427 | it("reads a rename as one file that moved, and says whether it also changed", () => { | |
| 428 | const page = expectSuccess( | |
| 429 | decodeIterationChangesJson( | |
| 430 | asJson({ | |
| 431 | changeEntries: [ | |
| 432 | { | |
| 433 | changeType: "rename", | |
| 434 | sourceServerItem: "/docs/old.md", | |
| 435 | item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, | |
| 436 | }, | |
| 437 | { | |
| 438 | changeType: "edit, rename", | |
| 439 | sourceServerItem: "/src/old.ts", | |
| 440 | item: { path: "/src/new.ts", objectId: "bbbb", originalObjectId: "cccc" }, | |
| 441 | }, | |
| 442 | ], | |
| 443 | }), | |
| 444 | ), | |
| 445 | ); | |
| 446 | ||
| 447 | expect(page.changes).toEqual([ | |
| 448 | { | |
| 449 | path: "docs/new.md", | |
| 450 | oldPath: "docs/old.md", | |
| 451 | changeKind: "rename-pure", | |
| 452 | objectId: "aaaa", | |
| 453 | originalObjectId: "aaaa", | |
| 454 | }, | |
| 455 | { | |
| 456 | path: "src/new.ts", | |
| 457 | oldPath: "src/old.ts", | |
| 458 | changeKind: "rename-changed", | |
| 459 | objectId: "bbbb", | |
| 460 | originalObjectId: "cccc", | |
| 461 | }, | |
| 462 | ]); | |
| 463 | }); | |
| 464 | ||
| 465 | it("drops the folders Azure lists alongside the files that changed", () => { | |
| 466 | // A review shows files, and a folder has no content on either side to show for one. | |
| 467 | const page = expectSuccess( | |
| 468 | decodeIterationChangesJson( | |
| 469 | asJson({ | |
| 470 | changeEntries: [ | |
| 471 | { changeType: "add", item: { path: "/docs", isFolder: true, gitObjectType: "tree" } }, | |
| 472 | { changeType: "add", item: { path: "/docs/page.md", objectId: "dddd" } }, | |
| 473 | ], | |
| 474 | }), | |
| 475 | ), | |
| 476 | ); | |
| 477 | ||
| 478 | expect(page.changes.map((change) => change.path)).toEqual(["docs/page.md"]); | |
| 479 | }); | |
| 480 | ||
| 481 | it("carries where the next page of a long change starts", () => { | |
| 482 | const page = expectSuccess( | |
| 483 | decodeIterationChangesJson( | |
| 484 | asJson({ | |
| 485 | changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], | |
| 486 | nextSkip: 2000, | |
| 487 | }), | |
| 488 | ), | |
| 489 | ); | |
| 490 | ||
| 491 | expect(page.nextSkip).toBe(2000); | |
| 492 | }); | |
| 493 | ||
| 494 | it("reads the last page, which names no page after it, as the end of the change", () => { | |
| 495 | const page = expectSuccess( | |
| 496 | decodeIterationChangesJson( | |
| 497 | asJson({ | |
| 498 | changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], | |
| 499 | }), | |
| 500 | ), | |
| 501 | ); | |
| 502 | ||
| 503 | expect(page.nextSkip).toBeNull(); | |
| 504 | }); | |
| 505 | ||
| 506 | it("reads where a rename came from out of either of the two places Azure names it", () => { | |
| 507 | // The iteration-changes route answers with `originalPath`; the commit routes answer with | |
| 508 | // `sourceServerItem`, and both are the same fact under two names. | |
| 509 | const page = expectSuccess( | |
| 510 | decodeIterationChangesJson( | |
| 511 | asJson({ | |
| 512 | changeEntries: [ | |
| 513 | { | |
| 514 | changeType: "rename", | |
| 515 | originalPath: "/docs/old.md", | |
| 516 | item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, | |
| 517 | }, | |
| 518 | ], | |
| 519 | }), | |
| 520 | ), | |
| 521 | ); | |
| 522 | ||
| 523 | expect(page.changes.at(0)?.oldPath).toBe("docs/old.md"); | |
| 524 | }); | |
| 525 | }); | |
| 526 | ||
| 527 | describe("decodeItemContentJson", () => { | |
| 528 | it("reads the file's text out of the envelope Azure wraps it in", () => { | |
| 529 | expect( | |
| 530 | expectSuccess(decodeItemContentJson(asJson({ path: "/a.md", content: "one\ntwo" }))), | |
| 531 | ).toEqual({ contents: "one\ntwo", isBinary: false }); | |
| 532 | }); | |
| 533 | ||
| 534 | it("reads an empty file as empty rather than as a failure to look", () => { | |
| 535 | expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toEqual({ | |
| 536 | contents: "", | |
| 537 | isBinary: false, | |
| 538 | }); | |
| 539 | }); | |
| 540 | ||
| 541 | it("keeps Azure's own word that a file is binary", () => { | |
| 542 | // Which it answers base64-encoded, so nothing in the text it sent would give it away. | |
| 543 | expect( | |
| 544 | expectSuccess( | |
| 545 | decodeItemContentJson( | |
| 546 | asJson({ path: "/logo.png", content: "b2xk", contentMetadata: { isBinary: true } }), | |
| 547 | ), | |
| 548 | ), | |
| 549 | ).toEqual({ contents: "b2xk", isBinary: true }); | |
| 550 | }); | |
| 551 | }); | |
| 552 | ||
| 553 | /** | |
| 554 | * Every other fixture in this file is written from Azure's published contract. These are the | |
| 555 | * shapes a real organisation answered with, read back through `az devops invoke` rather than | |
| 556 | * `az rest`: the extension hands over the route's own body with one key of its own added, and a | |
| 557 | * live repository leaves out fields the contract documents. | |
| 558 | */ | |
| 559 | describe("what az devops invoke answers with", () => { | |
| 560 | it("reads the envelope the extension adds its own continuation token to", () => { | |
| 561 | // Set on every JSON body it returns, from a response header these routes do not send, so it | |
| 562 | // arrives as null rather than not at all. Nothing reads it, and it must not fail the decode. | |
| 563 | expect( | |
| 564 | expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))), | |
| 565 | ).toEqual([]); | |
| 566 | ||
| 567 | expect( | |
| 568 | expectSuccess( | |
| 569 | decodeIterationsJson( | |
| 570 | asJson({ | |
| 571 | count: 1, | |
| 572 | continuation_token: null, | |
| 573 | value: [ | |
| 574 | { | |
| 575 | id: 1, | |
| 576 | sourceRefCommit: { commitId: "f4031105213c68f197cd46ea303bb5e72acc889a" }, | |
| 577 | commonRefCommit: { commitId: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35" }, | |
| 578 | }, | |
| 579 | ], | |
| 580 | }), | |
| 581 | ), | |
| 582 | ), | |
| 583 | ).toEqual([ | |
| 584 | { | |
| 585 | id: 1, | |
| 586 | headCommit: "f4031105213c68f197cd46ea303bb5e72acc889a", | |
| 587 | mergeBaseCommit: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35", | |
| 588 | }, | |
| 589 | ]); | |
| 590 | }); | |
| 591 | ||
| 592 | it("keeps the files of a change that names neither their object type nor their page after it", () => { | |
| 593 | // A live iteration states `changeType`, `item.path` and `item.objectId` and nothing else: no | |
| 594 | // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. Each of those absences | |
| 595 | // is what the defaults in the decoder are for, and reading any of them as stated would drop | |
| 596 | // every file of every Azure change request. | |
| 597 | const page = expectSuccess( | |
| 598 | decodeIterationChangesJson( | |
| 599 | asJson({ | |
| 600 | continuation_token: null, | |
| 601 | changeEntries: [ | |
| 602 | { changeType: "add", item: { path: "/DEMO.md", objectId: "EC005DB24" } }, | |
| 603 | { changeType: "edit", item: { path: "/README.md", objectId: "8F8047A49" } }, | |
| 604 | ], | |
| 605 | }), | |
| 606 | ), | |
| 607 | ); | |
| 608 | ||
| 609 | expect(page.nextSkip).toBeNull(); | |
| 610 | expect(page.changes).toEqual([ | |
| 611 | { | |
| 612 | path: "DEMO.md", | |
| 613 | oldPath: "DEMO.md", | |
| 614 | changeKind: "new", | |
| 615 | objectId: "EC005DB24", | |
| 616 | originalObjectId: null, | |
| 617 | }, | |
| 618 | { | |
| 619 | path: "README.md", | |
| 620 | oldPath: "README.md", | |
| 621 | changeKind: "change", | |
| 622 | objectId: "8F8047A49", | |
| 623 | originalObjectId: null, | |
| 624 | }, | |
| 625 | ]); | |
| 626 | }); | |
| 627 | ||
| 628 | it("reads a text file whose content type Azure calls a stream", () => { | |
| 629 | // `contentMetadata` comes back for a markdown file with `application/octet-stream` on it and | |
| 630 | // no `isBinary` at all, so the content type is not the field to ask, and its absence is the | |
| 631 | // answer that the text is text. | |
| 632 | expect( | |
| 633 | expectSuccess( | |
| 634 | decodeItemContentJson( | |
| 635 | asJson({ | |
| 636 | path: "/README.md", | |
| 637 | objectId: "8f8047a49", | |
| 638 | gitObjectType: "blob", | |
| 639 | content: "# T3Demo\n", | |
| 640 | contentMetadata: { | |
| 641 | contentType: "application/octet-stream", | |
| 642 | encoding: 65001, | |
| 643 | extension: "md", | |
| 644 | fileName: "README.md", | |
| 645 | }, | |
| 646 | continuation_token: null, | |
| 647 | }), | |
| 648 | ), | |
| 649 | ), | |
| 650 | ).toEqual({ contents: "# T3Demo\n", isBinary: false }); | |
| 651 | }); | |
| 652 | }); |
apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts deleted-574| ... | ... | @@ -1,574 +0,0 @@ |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import * as Exit from "effect/Exit"; | |
| 3 | import * as Result from "effect/Result"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | import type { | |
| 6 | PullRequestActor, | |
| 7 | PullRequestComment, | |
| 8 | PullRequestMergeMethod, | |
| 9 | PullRequestMergeability, | |
| 10 | PullRequestState, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 13 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 14 | ||
| 15 | import { azureDevOpsPullRequestWebUrl } from "../sourceControl/azureDevOpsPullRequests.ts"; | |
| 16 | ||
| 17 | /** | |
| 18 | * Azure's enums are decoded as plain strings and normalized here, in the same tolerant style as | |
| 19 | * the other hosts: a new merge status must not fail a whole payload. Every field beyond the | |
| 20 | * identity is optional, because `az repos pr` returns rather more or less of the REST object | |
| 21 | * depending on the command. | |
| 22 | */ | |
| 23 | const RawIdentitySchema = Schema.Struct({ | |
| 24 | displayName: Schema.optional(Schema.NullOr(Schema.String)), | |
| 25 | /** An email or UPN, which is what `az account show` reports for the signed-in user. */ | |
| 26 | uniqueName: Schema.optional(Schema.NullOr(Schema.String)), | |
| 27 | imageUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 28 | }); | |
| 29 | ||
| 30 | const RawPullRequestSchema = Schema.Struct({ | |
| 31 | pullRequestId: Schema.Int, | |
| 32 | title: Schema.String, | |
| 33 | description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 34 | status: Schema.optional(Schema.NullOr(Schema.String)), | |
| 35 | isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 36 | /** | |
| 37 | * Who armed auto-complete, which is the only thing Azure says about it: the field carries an | |
| 38 | * identity while the pull request is set to complete on its own, and Azure leaves it out | |
| 39 | * entirely once nobody has. So its presence is the answer, and there is no third state. | |
| 40 | */ | |
| 41 | autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), | |
| 42 | completionOptions: Schema.optional( | |
| 43 | Schema.NullOr( | |
| 44 | Schema.Struct({ | |
| 45 | mergeStrategy: Schema.optional(Schema.NullOr(Schema.String)), | |
| 46 | squashMerge: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 47 | }), | |
| 48 | ), | |
| 49 | ), | |
| 50 | mergeStatus: Schema.optional(Schema.NullOr(Schema.String)), | |
| 51 | createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), | |
| 52 | reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))), | |
| 53 | // Required, and required to be non-empty: the wire contract will not carry a change request | |
| 54 | // without a branch or a created time, so a row missing one is skipped rather than breaking the | |
| 55 | // response it travels in. | |
| 56 | sourceRefName: TrimmedNonEmptyString, | |
| 57 | targetRefName: TrimmedNonEmptyString, | |
| 58 | creationDate: TrimmedNonEmptyString, | |
| 59 | closedDate: Schema.optional(Schema.NullOr(Schema.String)), | |
| 60 | url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 61 | repository: Schema.optional( | |
| 62 | Schema.NullOr( | |
| 63 | Schema.Struct({ | |
| 64 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 65 | webUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 66 | project: Schema.optional( | |
| 67 | Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 68 | ), | |
| 69 | }), | |
| 70 | ), | |
| 71 | ), | |
| 72 | _links: Schema.optional( | |
| 73 | Schema.NullOr( | |
| 74 | Schema.Struct({ | |
| 75 | web: Schema.optional( | |
| 76 | Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), | |
| 77 | ), | |
| 78 | }), | |
| 79 | ), | |
| 80 | ), | |
| 81 | }); | |
| 82 | ||
| 83 | /** A pull request thread, which is how Azure keeps its conversation. */ | |
| 84 | const RawThreadSchema = Schema.Struct({ | |
| 85 | id: Schema.Int, | |
| 86 | isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 87 | threadContext: Schema.optional( | |
| 88 | Schema.NullOr(Schema.Struct({ filePath: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 89 | ), | |
| 90 | comments: Schema.optional( | |
| 91 | Schema.NullOr( | |
| 92 | Schema.Array( | |
| 93 | Schema.Struct({ | |
| 94 | id: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 95 | content: Schema.optional(Schema.NullOr(Schema.String)), | |
| 96 | author: Schema.optional(Schema.NullOr(RawIdentitySchema)), | |
| 97 | publishedDate: Schema.optional(Schema.NullOr(Schema.String)), | |
| 98 | isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 99 | /** `system` marks the notes Azure writes itself, which are events, not comments. */ | |
| 100 | commentType: Schema.optional(Schema.NullOr(Schema.String)), | |
| 101 | }), | |
| 102 | ), | |
| 103 | ), | |
| 104 | ), | |
| 105 | }); | |
| 106 | ||
| 107 | const RawThreadPageSchema = Schema.Struct({ | |
| 108 | value: Schema.Array(Schema.Unknown), | |
| 109 | }); | |
| 110 | ||
| 111 | const RawViewerSchema = Schema.Struct({ | |
| 112 | user: Schema.optional( | |
| 113 | Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 114 | ), | |
| 115 | }); | |
| 116 | ||
| 117 | /** | |
| 118 | * Where a repository lives, in the terms Azure's REST routes address it by. They take the project | |
| 119 | * and the repository as separate route parameters rather than as one path, so the pair travels | |
| 120 | * together rather than as a URL that would have to be taken apart again to use. | |
| 121 | */ | |
| 122 | export interface AzureDevOpsRepositoryLocation { | |
| 123 | readonly project: string; | |
| 124 | readonly repository: string; | |
| 125 | } | |
| 126 | ||
| 127 | export interface AzureDevOpsPullRequest { | |
| 128 | readonly number: number; | |
| 129 | readonly title: string; | |
| 130 | readonly url: string; | |
| 131 | readonly author: PullRequestActor | null; | |
| 132 | readonly headBranch: string; | |
| 133 | readonly baseBranch: string; | |
| 134 | readonly state: PullRequestState; | |
| 135 | readonly isDraft: boolean; | |
| 136 | readonly mergeability: PullRequestMergeability; | |
| 137 | readonly createdAt: string; | |
| 138 | /** | |
| 139 | * Azure records no last-touched time on a pull request, so the closing time stands in where | |
| 140 | * there is one and the creation time otherwise. The same fallback the rest of the app uses. | |
| 141 | */ | |
| 142 | readonly updatedAt: string; | |
| 143 | readonly closedAt: string | null; | |
| 144 | readonly body: string; | |
| 145 | readonly reviewRequestLogins: ReadonlyArray<string>; | |
| 146 | readonly reviewers: ReadonlyArray<PullRequestActor>; | |
| 147 | /** Where this pull request lives, when Azure said enough to work it out. */ | |
| 148 | readonly location: AzureDevOpsRepositoryLocation | null; | |
| 149 | /** Whether Azure is set to complete this on its own once its policies pass. */ | |
| 150 | readonly autoMergeEnabled: boolean; | |
| 151 | /** The completion strategy Azure stored with auto-complete, where it reported one. */ | |
| 152 | readonly autoMergeMethod?: PullRequestMergeMethod; | |
| 153 | } | |
| 154 | ||
| 155 | function trimmed(value: string | null | undefined): string | null { | |
| 156 | const text = value?.trim() ?? ""; | |
| 157 | return text.length > 0 ? text : null; | |
| 158 | } | |
| 159 | ||
| 160 | function normalizeRefName(refName: string): string { | |
| 161 | return refName.trim().replace(/^refs\/heads\//, ""); | |
| 162 | } | |
| 163 | ||
| 164 | /** A login has to compare against `az account show`, which reports an email. */ | |
| 165 | function toActor(raw: Schema.Schema.Type<typeof RawIdentitySchema> | null | undefined) { | |
| 166 | const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName); | |
| 167 | return login === null | |
| 168 | ? null | |
| 169 | : { login, name: trimmed(raw?.displayName), avatarUrl: trimmed(raw?.imageUrl) }; | |
| 170 | } | |
| 171 | ||
| 172 | function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState { | |
| 173 | switch (raw.status?.trim().toLowerCase()) { | |
| 174 | case "completed": | |
| 175 | return "merged"; | |
| 176 | case "abandoned": | |
| 177 | return "closed"; | |
| 178 | default: | |
| 179 | return "open"; | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | function toMergeability(value: string | null | undefined): PullRequestMergeability { | |
| 184 | switch (value?.trim().toLowerCase()) { | |
| 185 | case "succeeded": | |
| 186 | return "mergeable"; | |
| 187 | case "conflicts": | |
| 188 | case "failure": | |
| 189 | case "rejectedbypolicy": | |
| 190 | return "conflicting"; | |
| 191 | default: | |
| 192 | // `queued` and `notSet` mean Azure has not finished checking. | |
| 193 | return "unknown"; | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * Where a pull request's own repository sits. Taken from what Azure returned rather than from the | |
| 199 | * local remote, whose shape differs between the modern, legacy and SSH forms. | |
| 200 | */ | |
| 201 | function toLocation( | |
| 202 | raw: Schema.Schema.Type<typeof RawPullRequestSchema>, | |
| 203 | ): AzureDevOpsRepositoryLocation | null { | |
| 204 | const project = trimmed(raw.repository?.project?.name); | |
| 205 | const repository = trimmed(raw.repository?.name); | |
| 206 | if (project === null || repository === null) return null; | |
| 207 | return { project, repository }; | |
| 208 | } | |
| 209 | ||
| 210 | function toAutoMergeMethod( | |
| 211 | raw: Schema.Schema.Type<typeof RawPullRequestSchema>, | |
| 212 | ): PullRequestMergeMethod | undefined { | |
| 213 | if (raw.autoCompleteSetBy == null) return undefined; | |
| 214 | switch (raw.completionOptions?.mergeStrategy?.trim().toLowerCase()) { | |
| 215 | case "squash": | |
| 216 | return "squash"; | |
| 217 | case "rebase": | |
| 218 | case "rebasemerge": | |
| 219 | return "rebase"; | |
| 220 | case "nofastforward": | |
| 221 | return "merge"; | |
| 222 | default: | |
| 223 | return raw.completionOptions?.squashMerge === true ? "squash" : undefined; | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * Null when Azure said too little to place the pull request: a row with no browser url and no | |
| 229 | * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract | |
| 230 | * refuses to carry it either. | |
| 231 | */ | |
| 232 | function toPullRequest( | |
| 233 | raw: Schema.Schema.Type<typeof RawPullRequestSchema>, | |
| 234 | ): AzureDevOpsPullRequest | null { | |
| 235 | const autoMergeMethod = toAutoMergeMethod(raw); | |
| 236 | const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { | |
| 237 | const actor = toActor(reviewer); | |
| 238 | return actor === null ? [] : [actor]; | |
| 239 | }); | |
| 240 | const closedAt = trimmed(raw.closedDate); | |
| 241 | const url = trimmed( | |
| 242 | azureDevOpsPullRequestWebUrl({ | |
| 243 | pullRequestId: raw.pullRequestId, | |
| 244 | webLink: raw._links?.web?.href, | |
| 245 | repositoryWebUrl: raw.repository?.webUrl, | |
| 246 | restApiUrl: raw.url, | |
| 247 | projectName: raw.repository?.project?.name, | |
| 248 | repositoryName: raw.repository?.name, | |
| 249 | }), | |
| 250 | ); | |
| 251 | const headBranch = trimmed(normalizeRefName(raw.sourceRefName)); | |
| 252 | const baseBranch = trimmed(normalizeRefName(raw.targetRefName)); | |
| 253 | if (url === null || headBranch === null || baseBranch === null) return null; | |
| 254 | return { | |
| 255 | number: raw.pullRequestId, | |
| 256 | title: raw.title, | |
| 257 | url, | |
| 258 | author: toActor(raw.createdBy), | |
| 259 | headBranch, | |
| 260 | baseBranch, | |
| 261 | state: toState(raw), | |
| 262 | isDraft: raw.isDraft ?? false, | |
| 263 | mergeability: toMergeability(raw.mergeStatus), | |
| 264 | createdAt: raw.creationDate, | |
| 265 | updatedAt: closedAt ?? raw.creationDate, | |
| 266 | closedAt, | |
| 267 | body: raw.description ?? "", | |
| 268 | reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), | |
| 269 | reviewers, | |
| 270 | location: toLocation(raw), | |
| 271 | autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, | |
| 272 | ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), | |
| 273 | }; | |
| 274 | } | |
| 275 | ||
| 276 | const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); | |
| 277 | const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); | |
| 278 | const decodePullRequest = decodeJsonResult(RawPullRequestSchema); | |
| 279 | const decodeThreadPage = decodeJsonResult(RawThreadPageSchema); | |
| 280 | const decodeThreadEntry = Schema.decodeUnknownExit(RawThreadSchema); | |
| 281 | const decodeViewer = decodeJsonResult(RawViewerSchema); | |
| 282 | ||
| 283 | type DecodeFailure = Cause.Cause<Schema.SchemaError>; | |
| 284 | ||
| 285 | export interface AzureDevOpsPullRequestBatch { | |
| 286 | readonly items: ReadonlyArray<AzureDevOpsPullRequest>; | |
| 287 | /** Zero-based positions of the decoded items in Azure's raw page. */ | |
| 288 | readonly rawIndexes: ReadonlyArray<number>; | |
| 289 | /** Rows Azure returned, counted before decoding, so a skipped row cannot hide a next page. */ | |
| 290 | readonly rawCount: number; | |
| 291 | } | |
| 292 | ||
| 293 | /** Malformed entries are skipped rather than failing the batch, as on the other hosts. */ | |
| 294 | export function decodePullRequestListJson( | |
| 295 | raw: string, | |
| 296 | ): Result.Result<AzureDevOpsPullRequestBatch, DecodeFailure> { | |
| 297 | const decoded = decodeUnknownList(raw); | |
| 298 | if (!Result.isSuccess(decoded)) { | |
| 299 | return Result.fail(decoded.failure); | |
| 300 | } | |
| 301 | const items: AzureDevOpsPullRequest[] = []; | |
| 302 | const rawIndexes: number[] = []; | |
| 303 | for (const [rawIndex, entry] of decoded.success.entries()) { | |
| 304 | const item = decodePullRequestEntry(entry); | |
| 305 | if (Exit.isFailure(item)) continue; | |
| 306 | const pullRequest = toPullRequest(item.value); | |
| 307 | if (pullRequest !== null) { | |
| 308 | items.push(pullRequest); | |
| 309 | rawIndexes.push(rawIndex); | |
| 310 | } | |
| 311 | } | |
| 312 | return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); | |
| 313 | } | |
| 314 | ||
| 315 | /** Null carries "Azure answered, but with too little to use", which the caller reports. */ | |
| 316 | export function decodePullRequestJson( | |
| 317 | raw: string, | |
| 318 | ): Result.Result<AzureDevOpsPullRequest | null, DecodeFailure> { | |
| 319 | const decoded = decodePullRequest(raw); | |
| 320 | return Result.isSuccess(decoded) | |
| 321 | ? Result.succeed(toPullRequest(decoded.success)) | |
| 322 | : Result.fail(decoded.failure); | |
| 323 | } | |
| 324 | ||
| 325 | /** `az account show --query user` reports the signed-in account, whose name is an email. */ | |
| 326 | export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> { | |
| 327 | const decoded = decodeViewer(raw); | |
| 328 | return Result.isSuccess(decoded) | |
| 329 | ? Result.succeed(trimmed(decoded.success.user?.name)) | |
| 330 | : Result.fail(decoded.failure); | |
| 331 | } | |
| 332 | ||
| 333 | /** | |
| 334 | * Azure keeps its conversation as threads of comments, and every one of them is a remark | |
| 335 | * somebody wrote: a reply under a thread is as much of the conversation as the line that opened | |
| 336 | * it. A thread pinned to a file is a line-level review comment. | |
| 337 | * | |
| 338 | * Azure answers the whole thread collection in one response, with no cursor and no page to | |
| 339 | * follow, so what this returns is everything the host has. | |
| 340 | */ | |
| 341 | export function decodeThreadsJson( | |
| 342 | raw: string, | |
| 343 | ): Result.Result<ReadonlyArray<PullRequestComment>, DecodeFailure> { | |
| 344 | const decoded = decodeThreadPage(raw); | |
| 345 | if (!Result.isSuccess(decoded)) { | |
| 346 | return Result.fail(decoded.failure); | |
| 347 | } | |
| 348 | const comments: PullRequestComment[] = []; | |
| 349 | for (const entry of decoded.success.value) { | |
| 350 | const decodedThread = decodeThreadEntry(entry); | |
| 351 | if (Exit.isFailure(decodedThread)) continue; | |
| 352 | const thread = decodedThread.value; | |
| 353 | if (thread.isDeleted === true) continue; | |
| 354 | const path = trimmed(thread.threadContext?.filePath); | |
| 355 | for (const comment of thread.comments ?? []) { | |
| 356 | const publishedDate = trimmed(comment.publishedDate); | |
| 357 | if ( | |
| 358 | comment.isDeleted === true || | |
| 359 | comment.commentType?.trim().toLowerCase() === "system" || | |
| 360 | (comment.content ?? "").trim().length === 0 || | |
| 361 | publishedDate === null | |
| 362 | ) { | |
| 363 | continue; | |
| 364 | } | |
| 365 | comments.push({ | |
| 366 | id: `${thread.id}:${comment.id ?? 0}`, | |
| 367 | kind: path === null ? "issue-comment" : "review-comment", | |
| 368 | author: toActor(comment.author), | |
| 369 | body: comment.content ?? "", | |
| 370 | createdAt: publishedDate, | |
| 371 | url: null, | |
| 372 | path, | |
| 373 | reviewState: null, | |
| 374 | }); | |
| 375 | } | |
| 376 | } | |
| 377 | return Result.succeed( | |
| 378 | comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), | |
| 379 | ); | |
| 380 | } | |
| 381 | ||
| 382 | /** | |
| 383 | * One push's worth of a pull request. Azure records every push as an iteration and keys the whole | |
| 384 | * review off them: the changed files, and the marks a reader leaves on those files, both hang | |
| 385 | * from an iteration rather than from the pull request. | |
| 386 | */ | |
| 387 | const RawIterationSchema = Schema.Struct({ | |
| 388 | id: Schema.Int, | |
| 389 | sourceRefCommit: Schema.optional( | |
| 390 | Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 391 | ), | |
| 392 | commonRefCommit: Schema.optional( | |
| 393 | Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 394 | ), | |
| 395 | }); | |
| 396 | ||
| 397 | const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) }); | |
| 398 | ||
| 399 | const RawChangeEntrySchema = Schema.Struct({ | |
| 400 | changeType: Schema.optional(Schema.NullOr(Schema.String)), | |
| 401 | sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)), | |
| 402 | /** Where a renamed file came from. Azure states it here on an iteration's changes. */ | |
| 403 | originalPath: Schema.optional(Schema.NullOr(Schema.String)), | |
| 404 | item: Schema.optional( | |
| 405 | Schema.NullOr( | |
| 406 | Schema.Struct({ | |
| 407 | path: Schema.optional(Schema.NullOr(Schema.String)), | |
| 408 | objectId: Schema.optional(Schema.NullOr(Schema.String)), | |
| 409 | originalObjectId: Schema.optional(Schema.NullOr(Schema.String)), | |
| 410 | /** Azure marks a directory this way; a review has nothing to show for one. */ | |
| 411 | isFolder: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 412 | gitObjectType: Schema.optional(Schema.NullOr(Schema.String)), | |
| 413 | }), | |
| 414 | ), | |
| 415 | ), | |
| 416 | }); | |
| 417 | ||
| 418 | const RawChangePageSchema = Schema.Struct({ | |
| 419 | changeEntries: Schema.Array(Schema.Unknown), | |
| 420 | /** Where the page after this one starts. Azure leaves it out on the last page. */ | |
| 421 | nextSkip: Schema.optional(Schema.NullOr(Schema.Number)), | |
| 422 | }); | |
| 423 | ||
| 424 | const RawItemContentSchema = Schema.Struct({ | |
| 425 | content: Schema.optional(Schema.NullOr(Schema.String)), | |
| 426 | /** What Azure makes of the file it is handing over, which is where it says it is not text. */ | |
| 427 | contentMetadata: Schema.optional( | |
| 428 | Schema.NullOr(Schema.Struct({ isBinary: Schema.optional(Schema.NullOr(Schema.Boolean)) })), | |
| 429 | ), | |
| 430 | }); | |
| 431 | ||
| 432 | /** The head and the merge base of one iteration, which is the range its patch is taken over. */ | |
| 433 | export interface AzureDevOpsIteration { | |
| 434 | readonly id: number; | |
| 435 | readonly headCommit: string; | |
| 436 | readonly mergeBaseCommit: string; | |
| 437 | } | |
| 438 | ||
| 439 | /** | |
| 440 | * What one file did across an iteration. `oldPath` differs from `path` only for a rename, which | |
| 441 | * Azure reports by naming the file's previous home rather than as a delete and an add. | |
| 442 | */ | |
| 443 | export interface AzureDevOpsChangeEntry { | |
| 444 | readonly path: string; | |
| 445 | readonly oldPath: string; | |
| 446 | readonly changeKind: "new" | "deleted" | "change" | "rename-pure" | "rename-changed"; | |
| 447 | readonly objectId: string | null; | |
| 448 | readonly originalObjectId: string | null; | |
| 449 | } | |
| 450 | ||
| 451 | /** | |
| 452 | * One page of what an iteration changed, and where the next one starts. Azure pages this route | |
| 453 | * rather than answering with the whole change, so a review large enough to be paged is followed | |
| 454 | * to its end instead of being cut off at the first page's worth. | |
| 455 | */ | |
| 456 | export interface AzureDevOpsChangePage { | |
| 457 | readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>; | |
| 458 | readonly nextSkip: number | null; | |
| 459 | } | |
| 460 | ||
| 461 | /** One file's text at one commit, and whether Azure says the text is text at all. */ | |
| 462 | export interface AzureDevOpsItemContent { | |
| 463 | readonly contents: string; | |
| 464 | readonly isBinary: boolean; | |
| 465 | } | |
| 466 | ||
| 467 | const decodeIterationPage = decodeJsonResult(RawIterationPageSchema); | |
| 468 | const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema); | |
| 469 | const decodeChangePage = decodeJsonResult(RawChangePageSchema); | |
| 470 | const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema); | |
| 471 | const decodeItemContent = decodeJsonResult(RawItemContentSchema); | |
| 472 | ||
| 473 | /** | |
| 474 | * Azure leads a path with a slash that every other host and every patch omits. Not trimmed like | |
| 475 | * the rest of this payload, since a leading/trailing space is a legal part of a file's name and | |
| 476 | * the patch and viewed mark are keyed by the untrimmed path. | |
| 477 | */ | |
| 478 | function toRepositoryPath(value: string | null | undefined): string | null { | |
| 479 | if (value === undefined || value === null) return null; | |
| 480 | const path = value.replace(/^\/+/, ""); | |
| 481 | return path.length === 0 ? null : path; | |
| 482 | } | |
| 483 | ||
| 484 | /** | |
| 485 | * Azure names a change with one word or two, and a rename arrives either alone or alongside the | |
| 486 | * edit that came with it. Anything it has added since reads as a plain change, which shows the | |
| 487 | * file rather than dropping it from the review. | |
| 488 | */ | |
| 489 | function toChangeKind( | |
| 490 | raw: string | null | undefined, | |
| 491 | renamed: boolean, | |
| 492 | ): AzureDevOpsChangeEntry["changeKind"] { | |
| 493 | const parts = new Set( | |
| 494 | (raw ?? "") | |
| 495 | .toLowerCase() | |
| 496 | .split(",") | |
| 497 | .map((part) => part.trim()) | |
| 498 | .filter((part) => part.length > 0), | |
| 499 | ); | |
| 500 | if (parts.has("delete")) return "deleted"; | |
| 501 | if (renamed || parts.has("rename")) return parts.has("edit") ? "rename-changed" : "rename-pure"; | |
| 502 | if (parts.has("add")) return "new"; | |
| 503 | return "change"; | |
| 504 | } | |
| 505 | ||
| 506 | export function decodeIterationsJson( | |
| 507 | raw: string, | |
| 508 | ): Result.Result<ReadonlyArray<AzureDevOpsIteration>, DecodeFailure> { | |
| 509 | const decoded = decodeIterationPage(raw); | |
| 510 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 511 | const iterations: AzureDevOpsIteration[] = []; | |
| 512 | for (const entry of decoded.success.value) { | |
| 513 | const decodedIteration = decodeIterationEntry(entry); | |
| 514 | if (Exit.isFailure(decodedIteration)) continue; | |
| 515 | const iteration = decodedIteration.value; | |
| 516 | const headCommit = trimmed(iteration.sourceRefCommit?.commitId); | |
| 517 | const mergeBaseCommit = trimmed(iteration.commonRefCommit?.commitId); | |
| 518 | // An iteration Azure cannot place both ends of names no range, and a patch needs both. | |
| 519 | if (headCommit === null || mergeBaseCommit === null) continue; | |
| 520 | iterations.push({ id: iteration.id, headCommit, mergeBaseCommit }); | |
| 521 | } | |
| 522 | return Result.succeed(iterations.toSorted((left, right) => left.id - right.id)); | |
| 523 | } | |
| 524 | ||
| 525 | export function decodeIterationChangesJson( | |
| 526 | raw: string, | |
| 527 | ): Result.Result<AzureDevOpsChangePage, DecodeFailure> { | |
| 528 | const decoded = decodeChangePage(raw); | |
| 529 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 530 | const changes: AzureDevOpsChangeEntry[] = []; | |
| 531 | for (const entry of decoded.success.changeEntries) { | |
| 532 | const decodedChange = decodeChangeEntry(entry); | |
| 533 | if (Exit.isFailure(decodedChange)) continue; | |
| 534 | const change = decodedChange.value; | |
| 535 | const path = toRepositoryPath(change.item?.path); | |
| 536 | if (path === null) continue; | |
| 537 | // Azure lists the folders a change touched alongside the files themselves. A review shows | |
| 538 | // files, and a folder has no content to show for either side of one. | |
| 539 | if (change.item?.isFolder === true) continue; | |
| 540 | if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue; | |
| 541 | // Azure names where a renamed file came from in either of two places depending on the route | |
| 542 | // and the version, so both are read and the current path stands in when neither is there. | |
| 543 | const oldPath = | |
| 544 | toRepositoryPath(change.sourceServerItem) ?? toRepositoryPath(change.originalPath) ?? path; | |
| 545 | changes.push({ | |
| 546 | path, | |
| 547 | oldPath, | |
| 548 | changeKind: toChangeKind(change.changeType, oldPath !== path), | |
| 549 | objectId: trimmed(change.item?.objectId), | |
| 550 | originalObjectId: trimmed(change.item?.originalObjectId), | |
| 551 | }); | |
| 552 | } | |
| 553 | const nextSkip = decoded.success.nextSkip ?? null; | |
| 554 | return Result.succeed({ | |
| 555 | changes, | |
| 556 | nextSkip: nextSkip !== null && Number.isSafeInteger(nextSkip) && nextSkip > 0 ? nextSkip : null, | |
| 557 | }); | |
| 558 | } | |
| 559 | ||
| 560 | /** | |
| 561 | * Azure answers an absent file with an empty body rather than an error, which reads as empty. | |
| 562 | * Whether the bytes are text is Azure's own call, not this decoder's to guess from content. | |
| 563 | */ | |
| 564 | export function decodeItemContentJson( | |
| 565 | raw: string, | |
| 566 | ): Result.Result<AzureDevOpsItemContent, DecodeFailure> { | |
| 567 | const decoded = decodeItemContent(raw); | |
| 568 | return Result.isSuccess(decoded) | |
| 569 | ? Result.succeed({ | |
| 570 | contents: decoded.success.content ?? "", | |
| 571 | isBinary: decoded.success.contentMetadata?.isBinary === true, | |
| 572 | }) | |
| 573 | : Result.fail(decoded.failure); | |
| 574 | } |
apps/server/src/server.ts-2| ... | ... | @@ -52,7 +52,6 @@ import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; |
| 52 | 52 | import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; |
| 53 | 53 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 54 | 54 | import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; |
| 55 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; | |
| 56 | 55 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 57 | 56 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; |
| 58 | 57 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| ... | ... | @@ -301,7 +300,6 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(Layer.provide(VcsPro |
| 301 | 300 | const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.pipe( |
| 302 | 301 | Layer.provide( |
| 303 | 302 | Layer.mergeAll( |
| 304 | AzureDevOpsCli.layer, | |
| 305 | 303 | BitbucketApi.layer, |
| 306 | 304 | GitHubApi.layerWithDependencies, |
| 307 | 305 | GitLabCli.layer, |
apps/server/src/sourceControl/AzureDevOpsCli.test.ts deleted-448| ... | ... | @@ -1,448 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as PlatformError from "effect/PlatformError"; | |
| 8 | import { ChildProcessSpawner } from "effect/process"; | |
| 9 | import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; | |
| 10 | ||
| 11 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 12 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | |
| 13 | ||
| 14 | const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ | |
| 15 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 16 | stdout, | |
| 17 | stderr: "", | |
| 18 | stdoutTruncated: false, | |
| 19 | stderrTruncated: false, | |
| 20 | }); | |
| 21 | ||
| 22 | const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>(); | |
| 23 | ||
| 24 | const layerSupport = Layer.mergeAll( | |
| 25 | Layer.mock(VcsProcess.VcsProcess)({ | |
| 26 | run: mockRun, | |
| 27 | }), | |
| 28 | NodeServices.layer, | |
| 29 | ); | |
| 30 | const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(layerSupport)), layerSupport); | |
| 31 | ||
| 32 | afterEach(() => { | |
| 33 | mockRun.mockReset(); | |
| 34 | }); | |
| 35 | ||
| 36 | describe("AzureDevOpsCli.layer", () => { | |
| 37 | it.effect("parses pull request view output", () => | |
| 38 | Effect.gen(function* () { | |
| 39 | mockRun.mockReturnValueOnce( | |
| 40 | Effect.succeed( | |
| 41 | processOutput( | |
| 42 | JSON.stringify({ | |
| 43 | pullRequestId: 42, | |
| 44 | title: "Add Azure provider", | |
| 45 | sourceRefName: "refs/heads/feature/source-control", | |
| 46 | targetRefName: "refs/heads/main", | |
| 47 | status: "active", | |
| 48 | creationDate: "2026-01-02T00:00:00.000Z", | |
| 49 | closedDate: null, | |
| 50 | _links: { | |
| 51 | web: { | |
| 52 | href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", | |
| 53 | }, | |
| 54 | }, | |
| 55 | }), | |
| 56 | ), | |
| 57 | ), | |
| 58 | ); | |
| 59 | ||
| 60 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 61 | const result = yield* az.getPullRequest({ | |
| 62 | cwd: "/repo", | |
| 63 | reference: "#42", | |
| 64 | }); | |
| 65 | ||
| 66 | assert.strictEqual(result.number, 42); | |
| 67 | assert.strictEqual(result.title, "Add Azure provider"); | |
| 68 | assert.strictEqual(result.url, "https://dev.azure.com/acme/project/_git/repo/pullrequest/42"); | |
| 69 | assert.strictEqual(result.baseRefName, "main"); | |
| 70 | assert.strictEqual(result.headRefName, "feature/source-control"); | |
| 71 | assert.strictEqual(result.state, "open"); | |
| 72 | assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag); | |
| 73 | assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], { | |
| 74 | operation: "AzureDevOpsCli.execute", | |
| 75 | command: "az", | |
| 76 | args: [ | |
| 77 | "repos", | |
| 78 | "pr", | |
| 79 | "show", | |
| 80 | "--detect", | |
| 81 | "true", | |
| 82 | "--id", | |
| 83 | "42", | |
| 84 | "--only-show-errors", | |
| 85 | "--output", | |
| 86 | "json", | |
| 87 | ], | |
| 88 | cwd: "/repo", | |
| 89 | timeoutMs: 30_000, | |
| 90 | }); | |
| 91 | }).pipe(Effect.provide(layer)), | |
| 92 | ); | |
| 93 | ||
| 94 | it.effect("builds a web URL when Azure returns only the pull request REST URL", () => | |
| 95 | Effect.gen(function* () { | |
| 96 | mockRun.mockReturnValueOnce( | |
| 97 | Effect.succeed( | |
| 98 | processOutput( | |
| 99 | JSON.stringify({ | |
| 100 | pullRequestId: 863, | |
| 101 | title: "Fix Azure link", | |
| 102 | url: "https://dev.azure.com/saplyai/a8fe4088-ad76-4eda-aaaa-e3329713a097/_apis/git/repositories/16108a25-93a3-4eff-b77d-85e894ee0fe4/pullRequests/863", | |
| 103 | repository: { | |
| 104 | name: "CV-engine", | |
| 105 | project: { | |
| 106 | name: "CV-engine", | |
| 107 | }, | |
| 108 | }, | |
| 109 | sourceRefName: "refs/heads/feature/azure-pr-link", | |
| 110 | targetRefName: "refs/heads/main", | |
| 111 | status: "active", | |
| 112 | }), | |
| 113 | ), | |
| 114 | ), | |
| 115 | ); | |
| 116 | ||
| 117 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 118 | const result = yield* az.getPullRequest({ | |
| 119 | cwd: "/repo", | |
| 120 | reference: "863", | |
| 121 | }); | |
| 122 | ||
| 123 | assert.strictEqual( | |
| 124 | result.url, | |
| 125 | "https://dev.azure.com/saplyai/CV-engine/_git/CV-engine/pullrequest/863", | |
| 126 | ); | |
| 127 | }).pipe(Effect.provide(layer)), | |
| 128 | ); | |
| 129 | ||
| 130 | it.effect("lists pull requests with Azure status and source branch arguments", () => | |
| 131 | Effect.gen(function* () { | |
| 132 | mockRun.mockReturnValueOnce( | |
| 133 | Effect.succeed( | |
| 134 | processOutput( | |
| 135 | JSON.stringify([ | |
| 136 | { | |
| 137 | pullRequestId: 7, | |
| 138 | title: "Merged work", | |
| 139 | sourceRefName: "refs/heads/feature/merged", | |
| 140 | targetRefName: "refs/heads/main", | |
| 141 | status: "completed", | |
| 142 | closedDate: "2026-01-03T00:00:00.000Z", | |
| 143 | _links: { | |
| 144 | web: { | |
| 145 | href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7", | |
| 146 | }, | |
| 147 | }, | |
| 148 | }, | |
| 149 | ]), | |
| 150 | ), | |
| 151 | ), | |
| 152 | ); | |
| 153 | ||
| 154 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 155 | const result = yield* az.listPullRequests({ | |
| 156 | cwd: "/repo", | |
| 157 | headSelector: "origin:feature/merged", | |
| 158 | state: "merged", | |
| 159 | limit: 10, | |
| 160 | }); | |
| 161 | ||
| 162 | assert.strictEqual(result[0]?.state, "merged"); | |
| 163 | assert.strictEqual(result[0]?.mergedAt, "2026-01-03T00:00:00.000Z"); | |
| 164 | assert.strictEqual(result[0]?.closedAt, null); | |
| 165 | expect(mockRun).toHaveBeenCalledWith({ | |
| 166 | operation: "AzureDevOpsCli.execute", | |
| 167 | command: "az", | |
| 168 | args: [ | |
| 169 | "repos", | |
| 170 | "pr", | |
| 171 | "list", | |
| 172 | "--detect", | |
| 173 | "true", | |
| 174 | "--source-branch", | |
| 175 | "feature/merged", | |
| 176 | "--status", | |
| 177 | "completed", | |
| 178 | "--top", | |
| 179 | "10", | |
| 180 | "--only-show-errors", | |
| 181 | "--output", | |
| 182 | "json", | |
| 183 | ], | |
| 184 | cwd: "/repo", | |
| 185 | timeoutMs: 30_000, | |
| 186 | }); | |
| 187 | }).pipe(Effect.provide(layer)), | |
| 188 | ); | |
| 189 | ||
| 190 | it.effect("reads repository clone URLs", () => | |
| 191 | Effect.gen(function* () { | |
| 192 | mockRun.mockReturnValueOnce( | |
| 193 | Effect.succeed( | |
| 194 | processOutput( | |
| 195 | JSON.stringify({ | |
| 196 | name: "repo", | |
| 197 | webUrl: "https://dev.azure.com/acme/project/_git/repo", | |
| 198 | remoteUrl: "https://dev.azure.com/acme/project/_git/repo", | |
| 199 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | |
| 200 | project: { | |
| 201 | name: "project", | |
| 202 | }, | |
| 203 | }), | |
| 204 | ), | |
| 205 | ), | |
| 206 | ); | |
| 207 | ||
| 208 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 209 | const result = yield* az.getRepositoryCloneUrls({ | |
| 210 | cwd: "/repo", | |
| 211 | repository: "repo", | |
| 212 | }); | |
| 213 | ||
| 214 | assert.deepStrictEqual(result, { | |
| 215 | nameWithOwner: "project/repo", | |
| 216 | url: "https://dev.azure.com/acme/project/_git/repo", | |
| 217 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | |
| 218 | }); | |
| 219 | }).pipe(Effect.provide(layer)), | |
| 220 | ); | |
| 221 | ||
| 222 | it.effect("creates repositories through Azure Repos", () => | |
| 223 | Effect.gen(function* () { | |
| 224 | mockRun.mockReturnValueOnce( | |
| 225 | Effect.succeed( | |
| 226 | processOutput( | |
| 227 | JSON.stringify({ | |
| 228 | name: "repo", | |
| 229 | webUrl: "https://dev.azure.com/acme/project/_git/repo", | |
| 230 | remoteUrl: "https://dev.azure.com/acme/project/_git/repo", | |
| 231 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | |
| 232 | project: { | |
| 233 | name: "project", | |
| 234 | }, | |
| 235 | }), | |
| 236 | ), | |
| 237 | ), | |
| 238 | ); | |
| 239 | ||
| 240 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 241 | const result = yield* az.createRepository({ | |
| 242 | cwd: "/repo", | |
| 243 | repository: "project/repo", | |
| 244 | visibility: "private", | |
| 245 | }); | |
| 246 | ||
| 247 | assert.deepStrictEqual(result, { | |
| 248 | nameWithOwner: "project/repo", | |
| 249 | url: "https://dev.azure.com/acme/project/_git/repo", | |
| 250 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | |
| 251 | }); | |
| 252 | expect(mockRun).toHaveBeenCalledWith({ | |
| 253 | operation: "AzureDevOpsCli.execute", | |
| 254 | command: "az", | |
| 255 | args: [ | |
| 256 | "repos", | |
| 257 | "create", | |
| 258 | "--detect", | |
| 259 | "true", | |
| 260 | "--name", | |
| 261 | "repo", | |
| 262 | "--project", | |
| 263 | "project", | |
| 264 | "--only-show-errors", | |
| 265 | "--output", | |
| 266 | "json", | |
| 267 | ], | |
| 268 | cwd: "/repo", | |
| 269 | timeoutMs: 30_000, | |
| 270 | }); | |
| 271 | }).pipe(Effect.provide(layer)), | |
| 272 | ); | |
| 273 | ||
| 274 | it.effect("creates pull requests using the body file as the Azure description", () => | |
| 275 | Effect.gen(function* () { | |
| 276 | const fileSystem = yield* FileSystem.FileSystem; | |
| 277 | const bodyFile = `/tmp/t3code-azure-devops-cli-.md`; | |
| 278 | yield* fileSystem.writeFileString(bodyFile, "Generated body"); | |
| 279 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); | |
| 280 | ||
| 281 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 282 | yield* az.createPullRequest({ | |
| 283 | cwd: "/repo", | |
| 284 | baseBranch: "main", | |
| 285 | headSelector: "feature/provider", | |
| 286 | title: "Provider PR", | |
| 287 | bodyFile, | |
| 288 | }); | |
| 289 | ||
| 290 | expect(mockRun).toHaveBeenCalledWith( | |
| 291 | expect.objectContaining({ | |
| 292 | command: "az", | |
| 293 | cwd: "/repo", | |
| 294 | args: expect.arrayContaining(["--description", `@${bodyFile}`]), | |
| 295 | }), | |
| 296 | ); | |
| 297 | expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output"); | |
| 298 | }).pipe(Effect.provide(layer)), | |
| 299 | ); | |
| 300 | ||
| 301 | it.effect("does not force JSON output on checkout side-effect commands", () => | |
| 302 | Effect.gen(function* () { | |
| 303 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); | |
| 304 | ||
| 305 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 306 | yield* az.checkoutPullRequest({ | |
| 307 | cwd: "/repo", | |
| 308 | reference: "42", | |
| 309 | }); | |
| 310 | ||
| 311 | expect(mockRun).toHaveBeenCalledWith({ | |
| 312 | operation: "AzureDevOpsCli.execute", | |
| 313 | command: "az", | |
| 314 | args: [ | |
| 315 | "repos", | |
| 316 | "pr", | |
| 317 | "checkout", | |
| 318 | "--only-show-errors", | |
| 319 | "--detect", | |
| 320 | "true", | |
| 321 | "--id", | |
| 322 | "42", | |
| 323 | "--remote-name", | |
| 324 | "origin", | |
| 325 | ], | |
| 326 | cwd: "/repo", | |
| 327 | timeoutMs: 30_000, | |
| 328 | }); | |
| 329 | }).pipe(Effect.provide(layer)), | |
| 330 | ); | |
| 331 | ||
| 332 | it.effect("forwards explicit output limits to the process boundary", () => | |
| 333 | Effect.gen(function* () { | |
| 334 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); | |
| 335 | ||
| 336 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 337 | yield* az.execute({ | |
| 338 | cwd: "/repo", | |
| 339 | args: ["repos", "pr", "list"], | |
| 340 | maxOutputBytes: 16 * 1024 * 1024, | |
| 341 | }); | |
| 342 | ||
| 343 | expect(mockRun).toHaveBeenCalledWith({ | |
| 344 | operation: "AzureDevOpsCli.execute", | |
| 345 | command: "az", | |
| 346 | args: ["repos", "pr", "list"], | |
| 347 | cwd: "/repo", | |
| 348 | timeoutMs: 30_000, | |
| 349 | maxOutputBytes: 16 * 1024 * 1024, | |
| 350 | }); | |
| 351 | }).pipe(Effect.provide(layer)), | |
| 352 | ); | |
| 353 | ||
| 354 | it.effect("preserves VCS causes without copying upstream details into messages", () => | |
| 355 | Effect.gen(function* () { | |
| 356 | const cause = new VcsProcessExitError({ | |
| 357 | operation: "AzureDevOpsCli.execute", | |
| 358 | command: "az repos list --organization sensitive-upstream-detail", | |
| 359 | cwd: "/repo", | |
| 360 | exitCode: 1, | |
| 361 | detail: "sensitive-upstream-detail", | |
| 362 | }); | |
| 363 | mockRun.mockReturnValueOnce(Effect.fail(cause)); | |
| 364 | ||
| 365 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 366 | const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); | |
| 367 | ||
| 368 | assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); | |
| 369 | assert.strictEqual(error.operation, "execute"); | |
| 370 | assert.strictEqual(error.command, "az"); | |
| 371 | assert.strictEqual(error.cwd, "/repo"); | |
| 372 | assert.strictEqual(error.argumentCount, 2); | |
| 373 | assert.strictEqual(error.detail, "Azure DevOps CLI command failed."); | |
| 374 | assert.strictEqual(error.cause, cause); | |
| 375 | assert.equal(error.message.includes("sensitive-upstream-detail"), false); | |
| 376 | }).pipe(Effect.provide(layer)), | |
| 377 | ); | |
| 378 | ||
| 379 | it.effect("does not report a missing working directory as a missing Azure CLI", () => | |
| 380 | Effect.gen(function* () { | |
| 381 | const cwd = "/missing/repo"; | |
| 382 | const platformCause = PlatformError.systemError({ | |
| 383 | _tag: "NotFound", | |
| 384 | module: "ChildProcess", | |
| 385 | method: "spawn", | |
| 386 | syscall: "chdir", | |
| 387 | pathOrDescriptor: cwd, | |
| 388 | }); | |
| 389 | const cause = new VcsProcessSpawnError({ | |
| 390 | operation: "AzureDevOpsCli.execute", | |
| 391 | command: "az", | |
| 392 | cwd, | |
| 393 | argumentCount: 2, | |
| 394 | cause: platformCause, | |
| 395 | }); | |
| 396 | mockRun.mockReturnValueOnce(Effect.fail(cause)); | |
| 397 | ||
| 398 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 399 | const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip); | |
| 400 | ||
| 401 | assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); | |
| 402 | assert.strictEqual(error.cwd, cwd); | |
| 403 | assert.strictEqual(error.cause, cause); | |
| 404 | }).pipe(Effect.provide(layer)), | |
| 405 | ); | |
| 406 | ||
| 407 | it.effect("keeps invalid pull request output diagnostics structured", () => | |
| 408 | Effect.gen(function* () { | |
| 409 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json"))); | |
| 410 | ||
| 411 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 412 | const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip); | |
| 413 | ||
| 414 | assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError); | |
| 415 | assert.strictEqual(error.operation, "getPullRequest"); | |
| 416 | assert.strictEqual(error.command, "az"); | |
| 417 | assert.strictEqual(error.cwd, "/repo"); | |
| 418 | assert.strictEqual(error.outputLength, 8); | |
| 419 | assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON."); | |
| 420 | assert.exists(error.cause); | |
| 421 | assert.strictEqual( | |
| 422 | error.message, | |
| 423 | "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.", | |
| 424 | ); | |
| 425 | }).pipe(Effect.provide(layer)), | |
| 426 | ); | |
| 427 | ||
| 428 | it.effect("preserves rate-limit failures as a distinct error", () => | |
| 429 | Effect.gen(function* () { | |
| 430 | const cause = new VcsProcessExitError({ | |
| 431 | operation: "AzureDevOpsCli.execute", | |
| 432 | command: "az", | |
| 433 | cwd: "/repo", | |
| 434 | argumentCount: 2, | |
| 435 | exitCode: 1, | |
| 436 | detail: "API rate limit exceeded.", | |
| 437 | failureKind: "rate-limited", | |
| 438 | }); | |
| 439 | mockRun.mockReturnValueOnce(Effect.fail(cause)); | |
| 440 | ||
| 441 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 442 | const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); | |
| 443 | ||
| 444 | assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCliRateLimitError); | |
| 445 | assert.strictEqual(error.cause, cause); | |
| 446 | }).pipe(Effect.provide(layer)), | |
| 447 | ); | |
| 448 | }); |
apps/server/src/sourceControl/AzureDevOpsCli.ts deleted-554| ... | ... | @@ -1,554 +0,0 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as PlatformError from "effect/PlatformError"; | |
| 5 | import * as Result from "effect/Result"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import { | |
| 8 | NonNegativeInt, | |
| 9 | TrimmedNonEmptyString, | |
| 10 | type SourceControlRepositoryVisibility, | |
| 11 | type VcsError, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | ||
| 14 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 15 | import { | |
| 16 | decodeAzureDevOpsPullRequestJson, | |
| 17 | decodeAzureDevOpsPullRequestListJson, | |
| 18 | type NormalizedAzureDevOpsPullRequestRecord, | |
| 19 | } from "./azureDevOpsPullRequests.ts"; | |
| 20 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | |
| 21 | ||
| 22 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 23 | ||
| 24 | const azureDevOpsCommandErrorFields = { | |
| 25 | operation: Schema.Literal("execute"), | |
| 26 | command: Schema.Literal("az"), | |
| 27 | cwd: Schema.String, | |
| 28 | argumentCount: NonNegativeInt, | |
| 29 | cause: Schema.Defect(), | |
| 30 | }; | |
| 31 | ||
| 32 | export class AzureDevOpsCliUnavailableError extends Schema.TaggedError<AzureDevOpsCliUnavailableError>()( | |
| 33 | "AzureDevOpsCliUnavailableError", | |
| 34 | azureDevOpsCommandErrorFields, | |
| 35 | ) { | |
| 36 | get detail(): string { | |
| 37 | return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH."; | |
| 38 | } | |
| 39 | ||
| 40 | override get message(): string { | |
| 41 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | export class AzureDevOpsCliAuthenticationError extends Schema.TaggedError<AzureDevOpsCliAuthenticationError>()( | |
| 46 | "AzureDevOpsCliAuthenticationError", | |
| 47 | azureDevOpsCommandErrorFields, | |
| 48 | ) { | |
| 49 | get detail(): string { | |
| 50 | return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry."; | |
| 51 | } | |
| 52 | ||
| 53 | override get message(): string { | |
| 54 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | export class AzureDevOpsCliRateLimitError extends Schema.TaggedError<AzureDevOpsCliRateLimitError>()( | |
| 59 | "AzureDevOpsCliRateLimitError", | |
| 60 | azureDevOpsCommandErrorFields, | |
| 61 | ) { | |
| 62 | get detail(): string { | |
| 63 | return "Azure DevOps API rate limit exceeded."; | |
| 64 | } | |
| 65 | ||
| 66 | override get message(): string { | |
| 67 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedError<AzureDevOpsPullRequestNotFoundError>()( | |
| 72 | "AzureDevOpsPullRequestNotFoundError", | |
| 73 | azureDevOpsCommandErrorFields, | |
| 74 | ) { | |
| 75 | get detail(): string { | |
| 76 | return "Pull request not found. Check the PR number or URL and try again."; | |
| 77 | } | |
| 78 | ||
| 79 | override get message(): string { | |
| 80 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | export class AzureDevOpsCommandFailedError extends Schema.TaggedError<AzureDevOpsCommandFailedError>()( | |
| 85 | "AzureDevOpsCommandFailedError", | |
| 86 | azureDevOpsCommandErrorFields, | |
| 87 | ) { | |
| 88 | get detail(): string { | |
| 89 | return "Azure DevOps CLI command failed."; | |
| 90 | } | |
| 91 | ||
| 92 | override get message(): string { | |
| 93 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 94 | } | |
| 95 | ||
| 96 | static fromVcsError( | |
| 97 | context: { | |
| 98 | readonly operation: "execute"; | |
| 99 | readonly command: "az"; | |
| 100 | readonly cwd: string; | |
| 101 | readonly argumentCount: number; | |
| 102 | }, | |
| 103 | cause: VcsError, | |
| 104 | ): AzureDevOpsCliError { | |
| 105 | const fields = { ...context, cause }; | |
| 106 | ||
| 107 | if ( | |
| 108 | cause._tag === "VcsProcessSpawnError" && | |
| 109 | cause.cause instanceof PlatformError.PlatformError && | |
| 110 | cause.cause.reason._tag === "NotFound" && | |
| 111 | cause.cause.reason.pathOrDescriptor !== context.cwd && | |
| 112 | cause.cause.reason.syscall !== "chdir" | |
| 113 | ) { | |
| 114 | return new AzureDevOpsCliUnavailableError(fields); | |
| 115 | } | |
| 116 | ||
| 117 | if (cause._tag === "VcsProcessExitError") { | |
| 118 | if (cause.failureKind === "authentication") { | |
| 119 | return new AzureDevOpsCliAuthenticationError(fields); | |
| 120 | } | |
| 121 | if (cause.failureKind === "rate-limited") { | |
| 122 | return new AzureDevOpsCliRateLimitError(fields); | |
| 123 | } | |
| 124 | if (cause.failureKind === "not-found") { | |
| 125 | return new AzureDevOpsPullRequestNotFoundError(fields); | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | return new AzureDevOpsCommandFailedError(fields); | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | const azureDevOpsDecodeErrorFields = { | |
| 134 | command: Schema.Literal("az"), | |
| 135 | cwd: Schema.String, | |
| 136 | outputLength: NonNegativeInt, | |
| 137 | cause: Schema.Defect(), | |
| 138 | }; | |
| 139 | ||
| 140 | export class AzureDevOpsPullRequestListDecodeError extends Schema.TaggedError<AzureDevOpsPullRequestListDecodeError>()( | |
| 141 | "AzureDevOpsPullRequestListDecodeError", | |
| 142 | { | |
| 143 | operation: Schema.Literal("listPullRequests"), | |
| 144 | ...azureDevOpsDecodeErrorFields, | |
| 145 | }, | |
| 146 | ) { | |
| 147 | get detail(): string { | |
| 148 | return "Azure DevOps CLI returned invalid PR list JSON."; | |
| 149 | } | |
| 150 | ||
| 151 | override get message(): string { | |
| 152 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedError<AzureDevOpsPullRequestDecodeError>()( | |
| 157 | "AzureDevOpsPullRequestDecodeError", | |
| 158 | { | |
| 159 | operation: Schema.Literal("getPullRequest"), | |
| 160 | ...azureDevOpsDecodeErrorFields, | |
| 161 | }, | |
| 162 | ) { | |
| 163 | get detail(): string { | |
| 164 | return "Azure DevOps CLI returned invalid pull request JSON."; | |
| 165 | } | |
| 166 | ||
| 167 | override get message(): string { | |
| 168 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 169 | } | |
| 170 | } | |
| 171 | ||
| 172 | const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([ | |
| 173 | "getRepositoryCloneUrls", | |
| 174 | "getDefaultBranch", | |
| 175 | "createRepository", | |
| 176 | ]); | |
| 177 | ||
| 178 | export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedError<AzureDevOpsRepositoryDecodeError>()( | |
| 179 | "AzureDevOpsRepositoryDecodeError", | |
| 180 | { | |
| 181 | operation: AzureDevOpsRepositoryDecodeOperation, | |
| 182 | ...azureDevOpsDecodeErrorFields, | |
| 183 | }, | |
| 184 | ) { | |
| 185 | get detail(): string { | |
| 186 | return "Azure DevOps CLI returned invalid repository JSON."; | |
| 187 | } | |
| 188 | ||
| 189 | override get message(): string { | |
| 190 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | export const AzureDevOpsCliError = Schema.Union([ | |
| 195 | AzureDevOpsCliUnavailableError, | |
| 196 | AzureDevOpsCliAuthenticationError, | |
| 197 | AzureDevOpsCliRateLimitError, | |
| 198 | AzureDevOpsPullRequestNotFoundError, | |
| 199 | AzureDevOpsCommandFailedError, | |
| 200 | AzureDevOpsPullRequestListDecodeError, | |
| 201 | AzureDevOpsPullRequestDecodeError, | |
| 202 | AzureDevOpsRepositoryDecodeError, | |
| 203 | ]); | |
| 204 | export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type; | |
| 205 | ||
| 206 | export interface AzureDevOpsRepositoryCloneUrls { | |
| 207 | readonly nameWithOwner: string; | |
| 208 | readonly url: string; | |
| 209 | readonly sshUrl: string; | |
| 210 | } | |
| 211 | ||
| 212 | export class AzureDevOpsCli extends Context.Service< | |
| 213 | AzureDevOpsCli, | |
| 214 | { | |
| 215 | readonly execute: (input: { | |
| 216 | readonly cwd: string; | |
| 217 | readonly args: ReadonlyArray<string>; | |
| 218 | readonly timeoutMs?: number; | |
| 219 | readonly maxOutputBytes?: number; | |
| 220 | }) => Effect.Effect<VcsProcess.VcsProcessOutput, AzureDevOpsCliError>; | |
| 221 | ||
| 222 | readonly listPullRequests: (input: { | |
| 223 | readonly cwd: string; | |
| 224 | readonly headSelector: string; | |
| 225 | readonly source?: SourceControlProvider.SourceControlRefSelector; | |
| 226 | readonly state: "open" | "closed" | "merged" | "all"; | |
| 227 | readonly limit?: number; | |
| 228 | }) => Effect.Effect<ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, AzureDevOpsCliError>; | |
| 229 | ||
| 230 | readonly getPullRequest: (input: { | |
| 231 | readonly cwd: string; | |
| 232 | readonly reference: string; | |
| 233 | }) => Effect.Effect<NormalizedAzureDevOpsPullRequestRecord, AzureDevOpsCliError>; | |
| 234 | ||
| 235 | readonly getRepositoryCloneUrls: (input: { | |
| 236 | readonly cwd: string; | |
| 237 | readonly repository: string; | |
| 238 | }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>; | |
| 239 | ||
| 240 | readonly createRepository: (input: { | |
| 241 | readonly cwd: string; | |
| 242 | readonly repository: string; | |
| 243 | readonly visibility: SourceControlRepositoryVisibility; | |
| 244 | }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>; | |
| 245 | ||
| 246 | readonly createPullRequest: (input: { | |
| 247 | readonly cwd: string; | |
| 248 | readonly baseBranch: string; | |
| 249 | readonly headSelector: string; | |
| 250 | readonly source?: SourceControlProvider.SourceControlRefSelector; | |
| 251 | readonly target?: SourceControlProvider.SourceControlRefSelector; | |
| 252 | readonly title: string; | |
| 253 | readonly bodyFile: string; | |
| 254 | }) => Effect.Effect<void, AzureDevOpsCliError>; | |
| 255 | ||
| 256 | readonly getDefaultBranch: (input: { | |
| 257 | readonly cwd: string; | |
| 258 | }) => Effect.Effect<string | null, AzureDevOpsCliError>; | |
| 259 | ||
| 260 | readonly checkoutPullRequest: (input: { | |
| 261 | readonly cwd: string; | |
| 262 | readonly reference: string; | |
| 263 | readonly remoteName?: string; | |
| 264 | }) => Effect.Effect<void, AzureDevOpsCliError>; | |
| 265 | } | |
| 266 | >()("t3/sourceControl/AzureDevOpsCli") {} | |
| 267 | ||
| 268 | function normalizeChangeRequestId(reference: string): string { | |
| 269 | const trimmed = reference.trim().replace(/^#/, ""); | |
| 270 | const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); | |
| 271 | return urlMatch?.[1] ?? trimmed; | |
| 272 | } | |
| 273 | ||
| 274 | function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string { | |
| 275 | switch (state) { | |
| 276 | case "open": | |
| 277 | return "active"; | |
| 278 | case "closed": | |
| 279 | return "abandoned"; | |
| 280 | case "merged": | |
| 281 | return "completed"; | |
| 282 | case "all": | |
| 283 | return "all"; | |
| 284 | } | |
| 285 | } | |
| 286 | ||
| 287 | const RawAzureDevOpsRepositorySchema = Schema.Struct({ | |
| 288 | name: TrimmedNonEmptyString, | |
| 289 | webUrl: TrimmedNonEmptyString, | |
| 290 | remoteUrl: TrimmedNonEmptyString, | |
| 291 | sshUrl: TrimmedNonEmptyString, | |
| 292 | project: Schema.optional( | |
| 293 | Schema.Struct({ | |
| 294 | name: TrimmedNonEmptyString, | |
| 295 | }), | |
| 296 | ), | |
| 297 | defaultBranch: Schema.optional(Schema.NullOr(Schema.String)), | |
| 298 | }); | |
| 299 | ||
| 300 | function normalizeDefaultBranch(value: string | null | undefined): string | null { | |
| 301 | const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? ""; | |
| 302 | return trimmed.length > 0 ? trimmed : null; | |
| 303 | } | |
| 304 | ||
| 305 | function normalizeRepositoryCloneUrls( | |
| 306 | raw: Schema.Schema.Type<typeof RawAzureDevOpsRepositorySchema>, | |
| 307 | ): AzureDevOpsRepositoryCloneUrls { | |
| 308 | const projectName = raw.project?.name.trim(); | |
| 309 | return { | |
| 310 | nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name, | |
| 311 | url: raw.remoteUrl, | |
| 312 | sshUrl: raw.sshUrl, | |
| 313 | }; | |
| 314 | } | |
| 315 | ||
| 316 | function parseRepositorySpecifier(repository: string): { | |
| 317 | readonly project: string | null; | |
| 318 | readonly name: string; | |
| 319 | } { | |
| 320 | const parts: Array<string> = []; | |
| 321 | for (const part of repository.split("/")) { | |
| 322 | const trimmed = part.trim(); | |
| 323 | if (trimmed.length > 0) { | |
| 324 | parts.push(trimmed); | |
| 325 | } | |
| 326 | } | |
| 327 | return { | |
| 328 | project: parts.length > 1 ? (parts.at(-2) ?? null) : null, | |
| 329 | name: parts.at(-1) ?? repository.trim(), | |
| 330 | }; | |
| 331 | } | |
| 332 | ||
| 333 | function decodeAzureDevOpsJson<S extends Schema.Top>( | |
| 334 | raw: string, | |
| 335 | schema: S, | |
| 336 | operation: typeof AzureDevOpsRepositoryDecodeOperation.Type, | |
| 337 | cwd: string, | |
| 338 | ): Effect.Effect<S["Type"], AzureDevOpsRepositoryDecodeError, S["DecodingServices"]> { | |
| 339 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( | |
| 340 | Effect.mapError( | |
| 341 | (cause) => | |
| 342 | new AzureDevOpsRepositoryDecodeError({ | |
| 343 | operation, | |
| 344 | command: "az", | |
| 345 | cwd, | |
| 346 | outputLength: raw.length, | |
| 347 | cause, | |
| 348 | }), | |
| 349 | ), | |
| 350 | ); | |
| 351 | } | |
| 352 | ||
| 353 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 354 | export const make = Effect.gen(function* () { | |
| 355 | const process = yield* VcsProcess.VcsProcess; | |
| 356 | ||
| 357 | const execute: AzureDevOpsCli["Service"]["execute"] = (input) => | |
| 358 | process | |
| 359 | .run({ | |
| 360 | operation: "AzureDevOpsCli.execute", | |
| 361 | command: "az", | |
| 362 | args: input.args, | |
| 363 | cwd: input.cwd, | |
| 364 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | |
| 365 | ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), | |
| 366 | }) | |
| 367 | .pipe( | |
| 368 | Effect.mapError((error) => | |
| 369 | AzureDevOpsCommandFailedError.fromVcsError( | |
| 370 | { | |
| 371 | operation: "execute", | |
| 372 | command: "az", | |
| 373 | cwd: input.cwd, | |
| 374 | argumentCount: input.args.length, | |
| 375 | }, | |
| 376 | error, | |
| 377 | ), | |
| 378 | ), | |
| 379 | ); | |
| 380 | ||
| 381 | const executeJson = (input: Parameters<AzureDevOpsCli["Service"]["execute"]>[0]) => | |
| 382 | execute({ | |
| 383 | ...input, | |
| 384 | args: [...input.args, "--only-show-errors", "--output", "json"], | |
| 385 | }); | |
| 386 | ||
| 387 | return AzureDevOpsCli.of({ | |
| 388 | execute, | |
| 389 | listPullRequests: (input) => | |
| 390 | executeJson({ | |
| 391 | cwd: input.cwd, | |
| 392 | args: [ | |
| 393 | "repos", | |
| 394 | "pr", | |
| 395 | "list", | |
| 396 | "--detect", | |
| 397 | "true", | |
| 398 | "--source-branch", | |
| 399 | SourceControlProvider.sourceBranch(input), | |
| 400 | "--status", | |
| 401 | toAzureStatus(input.state), | |
| 402 | "--top", | |
| 403 | String(input.limit ?? 20), | |
| 404 | ], | |
| 405 | }).pipe( | |
| 406 | Effect.map((result) => result.stdout.trim()), | |
| 407 | Effect.flatMap((raw) => | |
| 408 | raw.length === 0 | |
| 409 | ? Effect.succeed([]) | |
| 410 | : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe( | |
| 411 | Effect.flatMap((decoded) => { | |
| 412 | if (!Result.isSuccess(decoded)) { | |
| 413 | return Effect.fail( | |
| 414 | new AzureDevOpsPullRequestListDecodeError({ | |
| 415 | operation: "listPullRequests", | |
| 416 | command: "az", | |
| 417 | cwd: input.cwd, | |
| 418 | outputLength: raw.length, | |
| 419 | cause: decoded.failure, | |
| 420 | }), | |
| 421 | ); | |
| 422 | } | |
| 423 | ||
| 424 | return Effect.succeed(decoded.success); | |
| 425 | }), | |
| 426 | ), | |
| 427 | ), | |
| 428 | ), | |
| 429 | getPullRequest: (input) => | |
| 430 | executeJson({ | |
| 431 | cwd: input.cwd, | |
| 432 | args: [ | |
| 433 | "repos", | |
| 434 | "pr", | |
| 435 | "show", | |
| 436 | "--detect", | |
| 437 | "true", | |
| 438 | "--id", | |
| 439 | normalizeChangeRequestId(input.reference), | |
| 440 | ], | |
| 441 | }).pipe( | |
| 442 | Effect.map((result) => result.stdout.trim()), | |
| 443 | Effect.flatMap((raw) => | |
| 444 | Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe( | |
| 445 | Effect.flatMap((decoded) => { | |
| 446 | if (!Result.isSuccess(decoded)) { | |
| 447 | return Effect.fail( | |
| 448 | new AzureDevOpsPullRequestDecodeError({ | |
| 449 | operation: "getPullRequest", | |
| 450 | command: "az", | |
| 451 | cwd: input.cwd, | |
| 452 | outputLength: raw.length, | |
| 453 | cause: decoded.failure, | |
| 454 | }), | |
| 455 | ); | |
| 456 | } | |
| 457 | ||
| 458 | return Effect.succeed(decoded.success); | |
| 459 | }), | |
| 460 | ), | |
| 461 | ), | |
| 462 | ), | |
| 463 | getRepositoryCloneUrls: (input) => | |
| 464 | executeJson({ | |
| 465 | cwd: input.cwd, | |
| 466 | args: ["repos", "show", "--detect", "true", "--repository", input.repository], | |
| 467 | }).pipe( | |
| 468 | Effect.map((result) => result.stdout.trim()), | |
| 469 | Effect.flatMap((raw) => | |
| 470 | decodeAzureDevOpsJson( | |
| 471 | raw, | |
| 472 | RawAzureDevOpsRepositorySchema, | |
| 473 | "getRepositoryCloneUrls", | |
| 474 | input.cwd, | |
| 475 | ), | |
| 476 | ), | |
| 477 | Effect.map(normalizeRepositoryCloneUrls), | |
| 478 | ), | |
| 479 | createRepository: (input) => { | |
| 480 | const repository = parseRepositorySpecifier(input.repository); | |
| 481 | // Azure Repos access is governed by project/organization permissions. | |
| 482 | // `az repos create` does not expose a per-repository visibility flag, so | |
| 483 | // the generic source-control visibility input is intentionally not | |
| 484 | // translated into CLI args for this provider. | |
| 485 | return executeJson({ | |
| 486 | cwd: input.cwd, | |
| 487 | args: [ | |
| 488 | "repos", | |
| 489 | "create", | |
| 490 | "--detect", | |
| 491 | "true", | |
| 492 | "--name", | |
| 493 | repository.name, | |
| 494 | ...(repository.project ? ["--project", repository.project] : []), | |
| 495 | ], | |
| 496 | }).pipe( | |
| 497 | Effect.map((result) => result.stdout.trim()), | |
| 498 | Effect.flatMap((raw) => | |
| 499 | decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd), | |
| 500 | ), | |
| 501 | Effect.map(normalizeRepositoryCloneUrls), | |
| 502 | ); | |
| 503 | }, | |
| 504 | createPullRequest: (input) => | |
| 505 | execute({ | |
| 506 | cwd: input.cwd, | |
| 507 | args: [ | |
| 508 | "repos", | |
| 509 | "pr", | |
| 510 | "create", | |
| 511 | "--only-show-errors", | |
| 512 | "--detect", | |
| 513 | "true", | |
| 514 | "--target-branch", | |
| 515 | input.target?.refName ?? input.baseBranch, | |
| 516 | "--source-branch", | |
| 517 | SourceControlProvider.sourceBranch(input), | |
| 518 | "--title", | |
| 519 | input.title, | |
| 520 | "--description", | |
| 521 | `@${input.bodyFile}`, | |
| 522 | ], | |
| 523 | }).pipe(Effect.asVoid), | |
| 524 | getDefaultBranch: (input) => | |
| 525 | executeJson({ | |
| 526 | cwd: input.cwd, | |
| 527 | args: ["repos", "show", "--detect", "true"], | |
| 528 | }).pipe( | |
| 529 | Effect.map((result) => result.stdout.trim()), | |
| 530 | Effect.flatMap((raw) => | |
| 531 | decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd), | |
| 532 | ), | |
| 533 | Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), | |
| 534 | ), | |
| 535 | checkoutPullRequest: (input) => | |
| 536 | execute({ | |
| 537 | cwd: input.cwd, | |
| 538 | args: [ | |
| 539 | "repos", | |
| 540 | "pr", | |
| 541 | "checkout", | |
| 542 | "--only-show-errors", | |
| 543 | "--detect", | |
| 544 | "true", | |
| 545 | "--id", | |
| 546 | normalizeChangeRequestId(input.reference), | |
| 547 | "--remote-name", | |
| 548 | input.remoteName ?? "origin", | |
| 549 | ], | |
| 550 | }).pipe(Effect.asVoid), | |
| 551 | }); | |
| 552 | }); | |
| 553 | ||
| 554 | export const layer = Layer.effect(AzureDevOpsCli, make); |
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts deleted-120| ... | ... | @@ -1,120 +0,0 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | ||
| 6 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | |
| 7 | import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; | |
| 8 | ||
| 9 | function makeProvider(azure: Partial<AzureDevOpsCli.AzureDevOpsCli["Service"]>) { | |
| 10 | return AzureDevOpsSourceControlProvider.make.pipe( | |
| 11 | Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), | |
| 12 | ); | |
| 13 | } | |
| 14 | ||
| 15 | it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () => | |
| 16 | Effect.gen(function* () { | |
| 17 | const provider = yield* makeProvider({ | |
| 18 | getPullRequest: () => | |
| 19 | Effect.succeed({ | |
| 20 | number: 42, | |
| 21 | title: "Add Azure provider", | |
| 22 | url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", | |
| 23 | baseRefName: "main", | |
| 24 | headRefName: "feature/source-control", | |
| 25 | state: "closed", | |
| 26 | closedAt: "2026-08-23T10:00:00Z", | |
| 27 | updatedAt: Option.none(), | |
| 28 | }), | |
| 29 | }); | |
| 30 | ||
| 31 | const changeRequest = yield* provider.getChangeRequest({ | |
| 32 | cwd: "/repo", | |
| 33 | reference: "42", | |
| 34 | }); | |
| 35 | ||
| 36 | assert.deepStrictEqual(changeRequest, { | |
| 37 | provider: "azure-devops", | |
| 38 | number: 42, | |
| 39 | title: "Add Azure provider", | |
| 40 | url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", | |
| 41 | baseRefName: "main", | |
| 42 | headRefName: "feature/source-control", | |
| 43 | state: "closed", | |
| 44 | closedAt: "2026-08-23T10:00:00Z", | |
| 45 | mergedAt: null, | |
| 46 | updatedAt: Option.none(), | |
| 47 | isCrossRepository: false, | |
| 48 | }); | |
| 49 | }), | |
| 50 | ); | |
| 51 | ||
| 52 | it.effect("adds change-request context while retaining Azure CLI causes", () => | |
| 53 | Effect.gen(function* () { | |
| 54 | const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ | |
| 55 | operation: "execute", | |
| 56 | command: "az", | |
| 57 | cwd: "/repo", | |
| 58 | argumentCount: 2, | |
| 59 | cause: new Error("raw upstream detail that should remain in the cause"), | |
| 60 | }); | |
| 61 | const provider = yield* makeProvider({ | |
| 62 | checkoutPullRequest: () => Effect.fail(cause), | |
| 63 | }); | |
| 64 | ||
| 65 | const error = yield* provider | |
| 66 | .checkoutChangeRequest({ cwd: "/repo", reference: "#42" }) | |
| 67 | .pipe(Effect.flip); | |
| 68 | ||
| 69 | assert.deepStrictEqual( | |
| 70 | { | |
| 71 | provider: error.provider, | |
| 72 | operation: error.operation, | |
| 73 | command: error.command, | |
| 74 | cwd: error.cwd, | |
| 75 | reference: error.reference, | |
| 76 | detail: error.detail, | |
| 77 | }, | |
| 78 | { | |
| 79 | provider: "azure-devops", | |
| 80 | operation: "checkoutChangeRequest", | |
| 81 | command: "az", | |
| 82 | cwd: "/repo", | |
| 83 | reference: "#42", | |
| 84 | detail: "Azure DevOps CLI command failed.", | |
| 85 | }, | |
| 86 | ); | |
| 87 | assert.strictEqual(error.cause, cause); | |
| 88 | assert.equal(error.message.includes("raw upstream detail"), false); | |
| 89 | }), | |
| 90 | ); | |
| 91 | ||
| 92 | it.effect("creates Azure DevOps PRs through provider-neutral input names", () => | |
| 93 | Effect.gen(function* () { | |
| 94 | let createInput: | |
| 95 | | Parameters<AzureDevOpsCli.AzureDevOpsCli["Service"]["createPullRequest"]>[0] | |
| 96 | | null = null; | |
| 97 | const provider = yield* makeProvider({ | |
| 98 | createPullRequest: (input) => { | |
| 99 | createInput = input; | |
| 100 | return Effect.void; | |
| 101 | }, | |
| 102 | }); | |
| 103 | ||
| 104 | yield* provider.createChangeRequest({ | |
| 105 | cwd: "/repo", | |
| 106 | baseRefName: "main", | |
| 107 | headSelector: "feature/provider", | |
| 108 | title: "Provider PR", | |
| 109 | bodyFile: "/tmp/body.md", | |
| 110 | }); | |
| 111 | ||
| 112 | assert.deepStrictEqual(createInput, { | |
| 113 | cwd: "/repo", | |
| 114 | baseBranch: "main", | |
| 115 | headSelector: "feature/provider", | |
| 116 | title: "Provider PR", | |
| 117 | bodyFile: "/tmp/body.md", | |
| 118 | }); | |
| 119 | }), | |
| 120 | ); |
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts deleted-236| ... | ... | @@ -1,236 +0,0 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | |
| 5 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | |
| 6 | import { | |
| 7 | combinedAuthOutput, | |
| 8 | firstSafeAuthLine, | |
| 9 | providerAuth, | |
| 10 | type SourceControlAuthProbeInput, | |
| 11 | type SourceControlCliDiscoverySpec, | |
| 12 | } from "@t3tools/source-control-core/server/discovery"; | |
| 13 | ||
| 14 | function parseAzureAuth(input: SourceControlAuthProbeInput) { | |
| 15 | const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); | |
| 16 | ||
| 17 | if (input.exitCode !== 0) { | |
| 18 | return providerAuth({ | |
| 19 | status: "unauthenticated", | |
| 20 | detail: | |
| 21 | firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", | |
| 22 | }); | |
| 23 | } | |
| 24 | ||
| 25 | if (account !== undefined && account.length > 0) { | |
| 26 | return providerAuth({ | |
| 27 | status: "authenticated", | |
| 28 | account, | |
| 29 | host: "dev.azure.com", | |
| 30 | }); | |
| 31 | } | |
| 32 | ||
| 33 | return providerAuth({ | |
| 34 | status: "unknown", | |
| 35 | host: "dev.azure.com", | |
| 36 | detail: "Azure CLI account status could not be parsed.", | |
| 37 | }); | |
| 38 | } | |
| 39 | ||
| 40 | export const discovery = { | |
| 41 | type: "cli", | |
| 42 | kind: "azure-devops", | |
| 43 | label: "Azure DevOps", | |
| 44 | executable: "az", | |
| 45 | versionArgs: ["--version"], | |
| 46 | authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], | |
| 47 | // `az` boots a fresh Python interpreter on every invocation, so even `az --version` | |
| 48 | // takes ~6s on Windows and overruns the default budget, leaving the provider reported | |
| 49 | // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s. | |
| 50 | probeTimeoutMs: 20_000, | |
| 51 | parseAuth: parseAzureAuth, | |
| 52 | installHint: | |
| 53 | "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", | |
| 54 | } satisfies SourceControlCliDiscoverySpec; | |
| 55 | ||
| 56 | function toChangeRequest(summary: { | |
| 57 | readonly number: number; | |
| 58 | readonly title: string; | |
| 59 | readonly url: string; | |
| 60 | readonly baseRefName: string; | |
| 61 | readonly headRefName: string; | |
| 62 | readonly state: "open" | "closed" | "merged"; | |
| 63 | readonly isDraft?: boolean; | |
| 64 | readonly closedAt?: string | null; | |
| 65 | readonly mergedAt?: string | null; | |
| 66 | readonly updatedAt: ChangeRequest["updatedAt"]; | |
| 67 | }): ChangeRequest { | |
| 68 | return { | |
| 69 | provider: "azure-devops", | |
| 70 | number: summary.number, | |
| 71 | title: summary.title, | |
| 72 | url: summary.url, | |
| 73 | baseRefName: summary.baseRefName, | |
| 74 | headRefName: summary.headRefName, | |
| 75 | state: summary.state, | |
| 76 | ...(summary.isDraft === true ? { isDraft: true } : {}), | |
| 77 | closedAt: summary.closedAt ?? null, | |
| 78 | mergedAt: summary.mergedAt ?? null, | |
| 79 | updatedAt: summary.updatedAt, | |
| 80 | isCrossRepository: false, | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | export const make = Effect.gen(function* () { | |
| 85 | const azure = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 86 | ||
| 87 | return SourceControlProvider.SourceControlProvider.of({ | |
| 88 | kind: "azure-devops", | |
| 89 | listChangeRequests: (input) => { | |
| 90 | const source = SourceControlProvider.sourceControlRefFromInput(input); | |
| 91 | return azure | |
| 92 | .listPullRequests({ | |
| 93 | cwd: input.cwd, | |
| 94 | headSelector: input.headSelector, | |
| 95 | ...(source !== undefined ? { source } : {}), | |
| 96 | state: input.state, | |
| 97 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | |
| 98 | }) | |
| 99 | .pipe( | |
| 100 | Effect.map((items) => items.map(toChangeRequest)), | |
| 101 | Effect.mapError( | |
| 102 | (error) => | |
| 103 | new SourceControlProviderError({ | |
| 104 | provider: "azure-devops", | |
| 105 | operation: "listChangeRequests", | |
| 106 | command: error.command, | |
| 107 | cwd: input.cwd, | |
| 108 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 109 | input.headSelector, | |
| 110 | ), | |
| 111 | detail: error.detail, | |
| 112 | cause: error, | |
| 113 | }), | |
| 114 | ), | |
| 115 | ); | |
| 116 | }, | |
| 117 | getChangeRequest: (input) => | |
| 118 | azure.getPullRequest(input).pipe( | |
| 119 | Effect.map(toChangeRequest), | |
| 120 | Effect.mapError( | |
| 121 | (error) => | |
| 122 | new SourceControlProviderError({ | |
| 123 | provider: "azure-devops", | |
| 124 | operation: "getChangeRequest", | |
| 125 | command: error.command, | |
| 126 | cwd: input.cwd, | |
| 127 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 128 | input.reference, | |
| 129 | ), | |
| 130 | detail: error.detail, | |
| 131 | cause: error, | |
| 132 | }), | |
| 133 | ), | |
| 134 | ), | |
| 135 | createChangeRequest: (input) => { | |
| 136 | const source = SourceControlProvider.sourceControlRefFromInput(input); | |
| 137 | return azure | |
| 138 | .createPullRequest({ | |
| 139 | cwd: input.cwd, | |
| 140 | baseBranch: input.baseRefName, | |
| 141 | headSelector: input.headSelector, | |
| 142 | ...(source !== undefined ? { source } : {}), | |
| 143 | ...(input.target !== undefined ? { target: input.target } : {}), | |
| 144 | title: input.title, | |
| 145 | bodyFile: input.bodyFile, | |
| 146 | }) | |
| 147 | .pipe( | |
| 148 | Effect.mapError( | |
| 149 | (error) => | |
| 150 | new SourceControlProviderError({ | |
| 151 | provider: "azure-devops", | |
| 152 | operation: "createChangeRequest", | |
| 153 | command: error.command, | |
| 154 | cwd: input.cwd, | |
| 155 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 156 | input.headSelector, | |
| 157 | ), | |
| 158 | detail: error.detail, | |
| 159 | cause: error, | |
| 160 | }), | |
| 161 | ), | |
| 162 | ); | |
| 163 | }, | |
| 164 | getRepositoryCloneUrls: (input) => | |
| 165 | azure.getRepositoryCloneUrls(input).pipe( | |
| 166 | Effect.mapError( | |
| 167 | (error) => | |
| 168 | new SourceControlProviderError({ | |
| 169 | provider: "azure-devops", | |
| 170 | operation: "getRepositoryCloneUrls", | |
| 171 | command: error.command, | |
| 172 | cwd: input.cwd, | |
| 173 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 174 | input.repository, | |
| 175 | ), | |
| 176 | detail: error.detail, | |
| 177 | cause: error, | |
| 178 | }), | |
| 179 | ), | |
| 180 | ), | |
| 181 | createRepository: (input) => | |
| 182 | azure.createRepository(input).pipe( | |
| 183 | Effect.mapError( | |
| 184 | (error) => | |
| 185 | new SourceControlProviderError({ | |
| 186 | provider: "azure-devops", | |
| 187 | operation: "createRepository", | |
| 188 | command: error.command, | |
| 189 | cwd: input.cwd, | |
| 190 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 191 | input.repository, | |
| 192 | ), | |
| 193 | detail: error.detail, | |
| 194 | cause: error, | |
| 195 | }), | |
| 196 | ), | |
| 197 | ), | |
| 198 | getDefaultBranch: (input) => | |
| 199 | azure.getDefaultBranch({ cwd: input.cwd }).pipe( | |
| 200 | Effect.mapError( | |
| 201 | (error) => | |
| 202 | new SourceControlProviderError({ | |
| 203 | provider: "azure-devops", | |
| 204 | operation: "getDefaultBranch", | |
| 205 | command: error.command, | |
| 206 | cwd: input.cwd, | |
| 207 | detail: error.detail, | |
| 208 | cause: error, | |
| 209 | }), | |
| 210 | ), | |
| 211 | ), | |
| 212 | checkoutChangeRequest: (input) => | |
| 213 | azure | |
| 214 | .checkoutPullRequest({ | |
| 215 | cwd: input.cwd, | |
| 216 | reference: input.reference, | |
| 217 | ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), | |
| 218 | }) | |
| 219 | .pipe( | |
| 220 | Effect.mapError( | |
| 221 | (error) => | |
| 222 | new SourceControlProviderError({ | |
| 223 | provider: "azure-devops", | |
| 224 | operation: "checkoutChangeRequest", | |
| 225 | command: error.command, | |
| 226 | cwd: input.cwd, | |
| 227 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 228 | input.reference, | |
| 229 | ), | |
| 230 | detail: error.detail, | |
| 231 | cause: error, | |
| 232 | }), | |
| 233 | ), | |
| 234 | ), | |
| 235 | }); | |
| 236 | }); |
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+3-1| ... | ... | @@ -16,7 +16,8 @@ import * as ServerConfig from "../config.ts"; |
| 16 | 16 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 17 | 17 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 18 | 18 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 19 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | |
| 19 | import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; | |
| 20 | import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; | |
| 20 | 21 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 21 | 22 | import * as GitHubApi from "./GitHubApi.ts"; |
| 22 | 23 | import * as GitLabCli from "./GitLabCli.ts"; |
| ... | ... | @@ -39,6 +40,7 @@ const layerSourceControlProviderRegistryTest = (input: { |
| 39 | 40 | prefix: "t3-source-control-registry-test-", |
| 40 | 41 | }).pipe(Layer.provide(NodeServices.layer)), |
| 41 | 42 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), |
| 43 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), | |
| 42 | 44 | Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), |
| 43 | 45 | ServerSettings.ServerSettingsService.layerTest(), |
| 44 | 46 | Layer.mock(GitHubApi.GitHubApi)({}), |
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+3-1| ... | ... | @@ -13,7 +13,8 @@ import * as ServerConfig from "../config.ts"; |
| 13 | 13 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; |
| 14 | 14 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 15 | 15 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 16 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | |
| 16 | import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; | |
| 17 | import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; | |
| 17 | 18 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 18 | 19 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 19 | 20 | import * as GitHubApi from "./GitHubApi.ts"; |
| ... | ... | @@ -103,6 +104,7 @@ function makeRegistry(input: { |
| 103 | 104 | ), |
| 104 | 105 | ), |
| 105 | 106 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), |
| 107 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), | |
| 106 | 108 | Layer.mock(BitbucketApi.BitbucketApi)({}), |
| 107 | 109 | ServerSettings.ServerSettingsService.layerTest(), |
| 108 | 110 | Layer.mock(GitHubApi.GitHubApi)({ |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+7-9| ... | ... | @@ -11,7 +11,6 @@ import { |
| 11 | 11 | import type { SourceControlProviderKind } from "@t3tools/contracts"; |
| 12 | 12 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 13 | 13 | |
| 14 | import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; | |
| 15 | 14 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; |
| 16 | 15 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 17 | 16 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; |
| ... | ... | @@ -310,7 +309,6 @@ export const make = Effect.gen(function* () { |
| 310 | 309 | const gitlab = yield* GitLabSourceControlProvider.make; |
| 311 | 310 | const bitbucket = yield* BitbucketSourceControlProvider.make; |
| 312 | 311 | const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; |
| 313 | const azureDevOps = yield* AzureDevOpsSourceControlProvider.make; | |
| 314 | 312 | const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => |
| 315 | 313 | driver.make.pipe( |
| 316 | 314 | Effect.map((instance): SourceControlProviderRegistration => ({ |
| ... | ... | @@ -320,7 +318,9 @@ export const make = Effect.gen(function* () { |
| 320 | 318 | })), |
| 321 | 319 | ), |
| 322 | 320 | ); |
| 323 | return yield* makeWithProviders([ | |
| 321 | // Discovery lists hosts in this order, so it is kept while hosts move into packages. | |
| 322 | const order = ["github", "gitlab", "azure-devops", "bitbucket", "forgejo"]; | |
| 323 | const registrations: ReadonlyArray<SourceControlProviderRegistration> = [ | |
| 324 | 324 | { |
| 325 | 325 | kind: "github", |
| 326 | 326 | provider: github, |
| ... | ... | @@ -331,18 +331,16 @@ export const make = Effect.gen(function* () { |
| 331 | 331 | provider: gitlab, |
| 332 | 332 | discovery: GitLabSourceControlProvider.discovery, |
| 333 | 333 | }, |
| 334 | { | |
| 335 | kind: "azure-devops", | |
| 336 | provider: azureDevOps, | |
| 337 | discovery: AzureDevOpsSourceControlProvider.discovery, | |
| 338 | }, | |
| 339 | 334 | { |
| 340 | 335 | kind: "bitbucket", |
| 341 | 336 | provider: bitbucket, |
| 342 | 337 | discovery: bitbucketDiscovery, |
| 343 | 338 | }, |
| 344 | 339 | ...drivers, |
| 345 | ]); | |
| 340 | ]; | |
| 341 | return yield* makeWithProviders( | |
| 342 | registrations.toSorted((left, right) => order.indexOf(left.kind) - order.indexOf(right.kind)), | |
| 343 | ); | |
| 346 | 344 | }); |
| 347 | 345 | |
| 348 | 346 | export const layer = Layer.effect(SourceControlProviderRegistry, make); |
apps/server/src/sourceControl/azureDevOpsPullRequests.ts deleted-222| ... | ... | @@ -1,222 +0,0 @@ |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 3 | import * as Exit from "effect/Exit"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import * as Result from "effect/Result"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 8 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 9 | ||
| 10 | export interface NormalizedAzureDevOpsPullRequestRecord { | |
| 11 | readonly number: number; | |
| 12 | readonly title: string; | |
| 13 | readonly url: string; | |
| 14 | readonly baseRefName: string; | |
| 15 | readonly headRefName: string; | |
| 16 | readonly state: "open" | "closed" | "merged"; | |
| 17 | readonly isDraft?: boolean; | |
| 18 | readonly closedAt?: string | null; | |
| 19 | readonly mergedAt?: string | null; | |
| 20 | readonly updatedAt: Option.Option<DateTime.Utc>; | |
| 21 | } | |
| 22 | ||
| 23 | const AzureDevOpsPullRequestSchema = Schema.Struct({ | |
| 24 | pullRequestId: PositiveInt, | |
| 25 | title: TrimmedNonEmptyString, | |
| 26 | url: Schema.optional(Schema.String), | |
| 27 | repository: Schema.optional( | |
| 28 | Schema.Struct({ | |
| 29 | name: Schema.optional(Schema.String), | |
| 30 | webUrl: Schema.optional(Schema.String), | |
| 31 | project: Schema.optional( | |
| 32 | Schema.Struct({ | |
| 33 | name: Schema.optional(Schema.String), | |
| 34 | }), | |
| 35 | ), | |
| 36 | }), | |
| 37 | ), | |
| 38 | sourceRefName: TrimmedNonEmptyString, | |
| 39 | targetRefName: TrimmedNonEmptyString, | |
| 40 | status: Schema.String, | |
| 41 | isDraft: Schema.optional(Schema.Boolean), | |
| 42 | creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | |
| 43 | closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | |
| 44 | _links: Schema.optional( | |
| 45 | Schema.Struct({ | |
| 46 | web: Schema.optional( | |
| 47 | Schema.Struct({ | |
| 48 | href: Schema.String, | |
| 49 | }), | |
| 50 | ), | |
| 51 | }), | |
| 52 | ), | |
| 53 | }); | |
| 54 | ||
| 55 | function trimOptionalString(value: string | null | undefined): string | null { | |
| 56 | const trimmed = value?.trim() ?? ""; | |
| 57 | return trimmed.length > 0 ? trimmed : null; | |
| 58 | } | |
| 59 | ||
| 60 | function normalizeRefName(refName: string): string { | |
| 61 | return refName.trim().replace(/^refs\/heads\//, ""); | |
| 62 | } | |
| 63 | ||
| 64 | function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" | "merged" { | |
| 65 | switch (status.trim().toLowerCase()) { | |
| 66 | case "completed": | |
| 67 | return "merged"; | |
| 68 | case "abandoned": | |
| 69 | return "closed"; | |
| 70 | default: | |
| 71 | return "open"; | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | function encodeAzureDevOpsPathSegment(segment: string): string { | |
| 76 | return encodeURIComponent(segment); | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * The organization root a REST url belongs to, which is where a browser url has to be hung when | |
| 81 | * Azure answered with neither a web link nor a repository url. Read from what Azure returned | |
| 82 | * rather than from the local remote, whose shape varies. | |
| 83 | */ | |
| 84 | function azureDevOpsOrganizationBaseFromRestApiUrl( | |
| 85 | value: string | null | undefined, | |
| 86 | ): string | null { | |
| 87 | const rawUrl = trimOptionalString(value); | |
| 88 | if (!rawUrl) { | |
| 89 | return null; | |
| 90 | } | |
| 91 | ||
| 92 | try { | |
| 93 | const url = new URL(rawUrl); | |
| 94 | const hostname = url.hostname.toLowerCase(); | |
| 95 | const pathSegments = url.pathname.split("/").filter((segment) => segment.length > 0); | |
| 96 | const isAzureRestUrl = pathSegments.some((segment) => segment.toLowerCase() === "_apis"); | |
| 97 | if (!isAzureRestUrl) { | |
| 98 | return null; | |
| 99 | } | |
| 100 | ||
| 101 | if (hostname === "dev.azure.com") { | |
| 102 | const organization = pathSegments[0]; | |
| 103 | return organization ? `${url.origin}/${organization}` : null; | |
| 104 | } | |
| 105 | ||
| 106 | if (hostname.endsWith(".visualstudio.com")) { | |
| 107 | return url.origin; | |
| 108 | } | |
| 109 | ||
| 110 | return null; | |
| 111 | } catch { | |
| 112 | return null; | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Where a pull request lives in a browser. Azure answers with a web link when asked for one and | |
| 118 | * otherwise leaves it to be assembled, so all three routes are tried in the order they can be | |
| 119 | * trusted. Takes plain fields so both the source control provider and the pull requests page | |
| 120 | * can share it. | |
| 121 | */ | |
| 122 | export function azureDevOpsPullRequestWebUrl(input: { | |
| 123 | readonly pullRequestId: number; | |
| 124 | readonly webLink?: string | null | undefined; | |
| 125 | readonly repositoryWebUrl?: string | null | undefined; | |
| 126 | readonly restApiUrl?: string | null | undefined; | |
| 127 | readonly projectName?: string | null | undefined; | |
| 128 | readonly repositoryName?: string | null | undefined; | |
| 129 | }): string { | |
| 130 | const webLink = trimOptionalString(input.webLink); | |
| 131 | if (webLink) { | |
| 132 | return webLink; | |
| 133 | } | |
| 134 | ||
| 135 | const repositoryWebUrl = trimOptionalString(input.repositoryWebUrl); | |
| 136 | if (repositoryWebUrl) { | |
| 137 | return `${repositoryWebUrl.replace(/\/+$/, "")}/pullrequest/${input.pullRequestId}`; | |
| 138 | } | |
| 139 | ||
| 140 | const organizationBase = azureDevOpsOrganizationBaseFromRestApiUrl(input.restApiUrl); | |
| 141 | const projectName = trimOptionalString(input.projectName); | |
| 142 | const repositoryName = trimOptionalString(input.repositoryName); | |
| 143 | if (organizationBase && projectName && repositoryName) { | |
| 144 | const encodedProjectName = encodeAzureDevOpsPathSegment(projectName); | |
| 145 | const encodedRepositoryName = encodeAzureDevOpsPathSegment(repositoryName); | |
| 146 | return `${organizationBase}/${encodedProjectName}/_git/${encodedRepositoryName}/pullrequest/${input.pullRequestId}`; | |
| 147 | } | |
| 148 | ||
| 149 | return trimOptionalString(input.restApiUrl) ?? ""; | |
| 150 | } | |
| 151 | ||
| 152 | function normalizeAzureDevOpsPullRequestUrl( | |
| 153 | raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>, | |
| 154 | ): string { | |
| 155 | return azureDevOpsPullRequestWebUrl({ | |
| 156 | pullRequestId: raw.pullRequestId, | |
| 157 | webLink: raw._links?.web?.href, | |
| 158 | repositoryWebUrl: raw.repository?.webUrl, | |
| 159 | restApiUrl: raw.url, | |
| 160 | projectName: raw.repository?.project?.name, | |
| 161 | repositoryName: raw.repository?.name, | |
| 162 | }); | |
| 163 | } | |
| 164 | ||
| 165 | function normalizeAzureDevOpsPullRequestRecord( | |
| 166 | raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>, | |
| 167 | ): NormalizedAzureDevOpsPullRequestRecord { | |
| 168 | const state = normalizeAzureDevOpsPullRequestState(raw.status); | |
| 169 | const terminalAt = Option.match(raw.closedDate ?? Option.none(), { | |
| 170 | onNone: () => null, | |
| 171 | onSome: DateTime.formatIso, | |
| 172 | }); | |
| 173 | return { | |
| 174 | number: raw.pullRequestId, | |
| 175 | title: raw.title, | |
| 176 | url: normalizeAzureDevOpsPullRequestUrl(raw), | |
| 177 | baseRefName: normalizeRefName(raw.targetRefName), | |
| 178 | headRefName: normalizeRefName(raw.sourceRefName), | |
| 179 | state, | |
| 180 | ...(raw.isDraft === true ? { isDraft: true } : {}), | |
| 181 | closedAt: state === "closed" ? terminalAt : null, | |
| 182 | mergedAt: state === "merged" ? terminalAt : null, | |
| 183 | updatedAt: (raw.closedDate ?? Option.none()).pipe( | |
| 184 | Option.orElse(() => raw.creationDate ?? Option.none()), | |
| 185 | ), | |
| 186 | }; | |
| 187 | } | |
| 188 | ||
| 189 | const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | |
| 190 | const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema); | |
| 191 | const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema); | |
| 192 | ||
| 193 | export function decodeAzureDevOpsPullRequestListJson( | |
| 194 | raw: string, | |
| 195 | ): Result.Result< | |
| 196 | ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, | |
| 197 | Cause.Cause<Schema.SchemaError> | |
| 198 | > { | |
| 199 | const result = decodeAzureDevOpsPullRequestList(raw); | |
| 200 | if (Result.isSuccess(result)) { | |
| 201 | const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = []; | |
| 202 | for (const entry of result.success) { | |
| 203 | const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry); | |
| 204 | if (Exit.isFailure(decodedEntry)) { | |
| 205 | continue; | |
| 206 | } | |
| 207 | pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value)); | |
| 208 | } | |
| 209 | return Result.succeed(pullRequests); | |
| 210 | } | |
| 211 | return Result.fail(result.failure); | |
| 212 | } | |
| 213 | ||
| 214 | export function decodeAzureDevOpsPullRequestJson( | |
| 215 | raw: string, | |
| 216 | ): Result.Result<NormalizedAzureDevOpsPullRequestRecord, Cause.Cause<Schema.SchemaError>> { | |
| 217 | const result = decodeAzureDevOpsPullRequest(raw); | |
| 218 | if (Result.isSuccess(result)) { | |
| 219 | return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success)); | |
| 220 | } | |
| 221 | return Result.fail(result.failure); | |
| 222 | } |
apps/server/src/sourceControl/builtInDrivers.ts+17-3| ... | ... | @@ -7,14 +7,28 @@ |
| 7 | 7 | * |
| 8 | 8 | * @module sourceControl/builtInDrivers |
| 9 | 9 | */ |
| 10 | import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; | |
| 11 | import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; | |
| 12 | import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; | |
| 10 | 13 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 11 | 14 | import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; |
| 15 | import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; | |
| 12 | 16 | import * as Layer from "effect/Layer"; |
| 13 | 17 | |
| 14 | 18 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; |
| 15 | 19 | |
| 16 | /** Ordered as the hosts appear in discovery, after the ones still built in the server. */ | |
| 17 | export const BUILT_IN_SOURCE_CONTROL_DRIVERS = [ForgejoDriver.driver] as const; | |
| 20 | const drivers = [AzureDevOpsDriver.driver, ForgejoDriver.driver]; | |
| 21 | ||
| 22 | /** Every service a built-in driver's `make` needs; the server's layers must provide them all. */ | |
| 23 | export type BuiltInSourceControlDriversEnv = | |
| 24 | (typeof drivers)[number] extends SourceControlDriver<infer R> ? R : never; | |
| 25 | ||
| 26 | export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< | |
| 27 | SourceControlDriver<BuiltInSourceControlDriversEnv> | |
| 28 | > = drivers; | |
| 18 | 29 | |
| 19 | 30 | /** The services the built-in drivers' packages own, plus the host port they all run against. */ |
| 20 | export const layer = ForgejoCli.layer.pipe(Layer.provideMerge(ServerSourceControlHost.layer)); | |
| 31 | export const layer = Layer.mergeAll( | |
| 32 | AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), | |
| 33 | ForgejoCli.layer, | |
| 34 | ).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); |
apps/server/src/ws.ts-2| ... | ... | @@ -229,7 +229,6 @@ import * as SqlClient from "effect/sql/SqlClient"; |
| 229 | 229 | import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; |
| 230 | 230 | import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; |
| 231 | 231 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 232 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; | |
| 233 | 232 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 234 | 233 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; |
| 235 | 234 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| ... | ... | @@ -3204,7 +3203,6 @@ export const layer = Layer.unwrap( |
| 3204 | 3203 | SourceControlProviderRegistry.layer.pipe( |
| 3205 | 3204 | Layer.provide( |
| 3206 | 3205 | Layer.mergeAll( |
| 3207 | AzureDevOpsCli.layer, | |
| 3208 | 3206 | BitbucketApi.layer, |
| 3209 | 3207 | GitHubApi.layerWithDependencies, |
| 3210 | 3208 | GitLabCli.layer, |
docs/internals/pull-request-file-revisions.md+1-1| ... | ... | @@ -24,7 +24,7 @@ the provider never got to look at. Absence below the boundary does not survive i |
| 24 | 24 | |
| 25 | 25 | What "never got to look at" is belongs to the host, and the contract cannot know any of them: |
| 26 | 26 | |
| 27 | - [Azure DevOps](../../apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts) reads every | |
| 27 | - [Azure DevOps](../../packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts) reads every | |
| 28 | 28 | version off one iteration listing, so a change too long to follow to its end leaves the paths |
| 29 | 29 | past that point out. |
| 30 | 30 | - [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the |
package.json+1-1| ... | ... | @@ -28,7 +28,7 @@ |
| 28 | 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 29 | 29 | "lint": "vp lint --report-unused-disable-directives", |
| 30 | 30 | "knip": "knip --preprocessor ./scripts/knip-schemas.ts", |
| 31 | "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-forgejo --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", | |
| 31 | "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-forgejo --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", | |
| 32 | 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", |
| 33 | 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 34 | 34 | "test": "vp run -r test", |
packages/source-control-azure-devops/package.json created+57| ... | ... | @@ -0,0 +1,57 @@ |
| 1 | { | |
| 2 | "name": "@t3tools/source-control-azure-devops", | |
| 3 | "private": true, | |
| 4 | "type": "module", | |
| 5 | "exports": { | |
| 6 | "./server/AzureDevOpsCli": { | |
| 7 | "types": "./src/server/AzureDevOpsCli.ts", | |
| 8 | "import": "./src/server/AzureDevOpsCli.ts" | |
| 9 | }, | |
| 10 | "./server/azureDevOpsDiff": { | |
| 11 | "types": "./src/server/azureDevOpsDiff.ts", | |
| 12 | "import": "./src/server/azureDevOpsDiff.ts" | |
| 13 | }, | |
| 14 | "./server/AzureDevOpsPullRequestCli": { | |
| 15 | "types": "./src/server/AzureDevOpsPullRequestCli.ts", | |
| 16 | "import": "./src/server/AzureDevOpsPullRequestCli.ts" | |
| 17 | }, | |
| 18 | "./server/azureDevOpsPullRequestJson": { | |
| 19 | "types": "./src/server/azureDevOpsPullRequestJson.ts", | |
| 20 | "import": "./src/server/azureDevOpsPullRequestJson.ts" | |
| 21 | }, | |
| 22 | "./server/AzureDevOpsPullRequestProvider": { | |
| 23 | "types": "./src/server/AzureDevOpsPullRequestProvider.ts", | |
| 24 | "import": "./src/server/AzureDevOpsPullRequestProvider.ts" | |
| 25 | }, | |
| 26 | "./server/azureDevOpsPullRequests": { | |
| 27 | "types": "./src/server/azureDevOpsPullRequests.ts", | |
| 28 | "import": "./src/server/azureDevOpsPullRequests.ts" | |
| 29 | }, | |
| 30 | "./server/AzureDevOpsSourceControlProvider": { | |
| 31 | "types": "./src/server/AzureDevOpsSourceControlProvider.ts", | |
| 32 | "import": "./src/server/AzureDevOpsSourceControlProvider.ts" | |
| 33 | }, | |
| 34 | "./server/driver": { | |
| 35 | "types": "./src/server/driver.ts", | |
| 36 | "import": "./src/server/driver.ts" | |
| 37 | } | |
| 38 | }, | |
| 39 | "scripts": { | |
| 40 | "typecheck": "tsc --noEmit", | |
| 41 | "test": "vp test run --config ../../vite.config.ts --dir ." | |
| 42 | }, | |
| 43 | "dependencies": { | |
| 44 | "@t3tools/contracts": "workspace:*", | |
| 45 | "@t3tools/shared": "workspace:*", | |
| 46 | "@t3tools/source-control-core": "workspace:*", | |
| 47 | "diff": "8.0.3", | |
| 48 | "effect": "catalog:" | |
| 49 | }, | |
| 50 | "devDependencies": { | |
| 51 | "@effect/platform-node": "catalog:", | |
| 52 | "@effect/vitest": "catalog:", | |
| 53 | "@t3tools/source-control-testing": "workspace:*", | |
| 54 | "@types/node": "catalog:", | |
| 55 | "vite-plus": "catalog:" | |
| 56 | } | |
| 57 | } |
packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts created+447| ... | ... | @@ -0,0 +1,447 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as PlatformError from "effect/PlatformError"; | |
| 8 | import { ChildProcessSpawner } from "effect/process"; | |
| 9 | import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; | |
| 10 | ||
| 11 | import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | |
| 12 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; | |
| 13 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | |
| 14 | ||
| 15 | const processOutput = (stdout: string): SourceControlHost.SourceControlProcessOutput => ({ | |
| 16 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 17 | stdout, | |
| 18 | stderr: "", | |
| 19 | stdoutTruncated: false, | |
| 20 | stderrTruncated: false, | |
| 21 | }); | |
| 22 | ||
| 23 | const mockRun = vi.fn<SourceControlHost.SourceControlHost["Service"]["process"]["run"]>(); | |
| 24 | ||
| 25 | const layerSupport = Layer.mergeAll( | |
| 26 | TestSourceControlHost.layer({ process: { run: mockRun } }), | |
| 27 | NodeServices.layer, | |
| 28 | ); | |
| 29 | const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(layerSupport)), layerSupport); | |
| 30 | ||
| 31 | afterEach(() => { | |
| 32 | mockRun.mockReset(); | |
| 33 | }); | |
| 34 | ||
| 35 | describe("AzureDevOpsCli.layer", () => { | |
| 36 | it.effect("parses pull request view output", () => | |
| 37 | Effect.gen(function* () { | |
| 38 | mockRun.mockReturnValueOnce( | |
| 39 | Effect.succeed( | |
| 40 | processOutput( | |
| 41 | JSON.stringify({ | |
| 42 | pullRequestId: 42, | |
| 43 | title: "Add Azure provider", | |
| 44 | sourceRefName: "refs/heads/feature/source-control", | |
| 45 | targetRefName: "refs/heads/main", | |
| 46 | status: "active", | |
| 47 | creationDate: "2026-01-02T00:00:00.000Z", | |
| 48 | closedDate: null, | |
| 49 | _links: { | |
| 50 | web: { | |
| 51 | href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", | |
| 52 | }, | |
| 53 | }, | |
| 54 | }), | |
| 55 | ), | |
| 56 | ), | |
| 57 | ); | |
| 58 | ||
| 59 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 60 | const result = yield* az.getPullRequest({ | |
| 61 | cwd: "/repo", | |
| 62 | reference: "#42", | |
| 63 | }); | |
| 64 | ||
| 65 | assert.strictEqual(result.number, 42); | |
| 66 | assert.strictEqual(result.title, "Add Azure provider"); | |
| 67 | assert.strictEqual(result.url, "https://dev.azure.com/acme/project/_git/repo/pullrequest/42"); | |
| 68 | assert.strictEqual(result.baseRefName, "main"); | |
| 69 | assert.strictEqual(result.headRefName, "feature/source-control"); | |
| 70 | assert.strictEqual(result.state, "open"); | |
| 71 | assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag); | |
| 72 | assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], { | |
| 73 | operation: "AzureDevOpsCli.execute", | |
| 74 | command: "az", | |
| 75 | args: [ | |
| 76 | "repos", | |
| 77 | "pr", | |
| 78 | "show", | |
| 79 | "--detect", | |
| 80 | "true", | |
| 81 | "--id", | |
| 82 | "42", | |
| 83 | "--only-show-errors", | |
| 84 | "--output", | |
| 85 | "json", | |
| 86 | ], | |
| 87 | cwd: "/repo", | |
| 88 | timeoutMs: 30_000, | |
| 89 | }); | |
| 90 | }).pipe(Effect.provide(layer)), | |
| 91 | ); | |
| 92 | ||
| 93 | it.effect("builds a web URL when Azure returns only the pull request REST URL", () => | |
| 94 | Effect.gen(function* () { | |
| 95 | mockRun.mockReturnValueOnce( | |
| 96 | Effect.succeed( | |
| 97 | processOutput( | |
| 98 | JSON.stringify({ | |
| 99 | pullRequestId: 863, | |
| 100 | title: "Fix Azure link", | |
| 101 | url: "https://dev.azure.com/saplyai/a8fe4088-ad76-4eda-aaaa-e3329713a097/_apis/git/repositories/16108a25-93a3-4eff-b77d-85e894ee0fe4/pullRequests/863", | |
| 102 | repository: { | |
| 103 | name: "CV-engine", | |
| 104 | project: { | |
| 105 | name: "CV-engine", | |
| 106 | }, | |
| 107 | }, | |
| 108 | sourceRefName: "refs/heads/feature/azure-pr-link", | |
| 109 | targetRefName: "refs/heads/main", | |
| 110 | status: "active", | |
| 111 | }), | |
| 112 | ), | |
| 113 | ), | |
| 114 | ); | |
| 115 | ||
| 116 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 117 | const result = yield* az.getPullRequest({ | |
| 118 | cwd: "/repo", | |
| 119 | reference: "863", | |
| 120 | }); | |
| 121 | ||
| 122 | assert.strictEqual( | |
| 123 | result.url, | |
| 124 | "https://dev.azure.com/saplyai/CV-engine/_git/CV-engine/pullrequest/863", | |
| 125 | ); | |
| 126 | }).pipe(Effect.provide(layer)), | |
| 127 | ); | |
| 128 | ||
| 129 | it.effect("lists pull requests with Azure status and source branch arguments", () => | |
| 130 | Effect.gen(function* () { | |
| 131 | mockRun.mockReturnValueOnce( | |
| 132 | Effect.succeed( | |
| 133 | processOutput( | |
| 134 | JSON.stringify([ | |
| 135 | { | |
| 136 | pullRequestId: 7, | |
| 137 | title: "Merged work", | |
| 138 | sourceRefName: "refs/heads/feature/merged", | |
| 139 | targetRefName: "refs/heads/main", | |
| 140 | status: "completed", | |
| 141 | closedDate: "2026-01-03T00:00:00.000Z", | |
| 142 | _links: { | |
| 143 | web: { | |
| 144 | href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7", | |
| 145 | }, | |
| 146 | }, | |
| 147 | }, | |
| 148 | ]), | |
| 149 | ), | |
| 150 | ), | |
| 151 | ); | |
| 152 | ||
| 153 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 154 | const result = yield* az.listPullRequests({ | |
| 155 | cwd: "/repo", | |
| 156 | headSelector: "origin:feature/merged", | |
| 157 | state: "merged", | |
| 158 | limit: 10, | |
| 159 | }); | |
| 160 | ||
| 161 | assert.strictEqual(result[0]?.state, "merged"); | |
| 162 | assert.strictEqual(result[0]?.mergedAt, "2026-01-03T00:00:00.000Z"); | |
| 163 | assert.strictEqual(result[0]?.closedAt, null); | |
| 164 | expect(mockRun).toHaveBeenCalledWith({ | |
| 165 | operation: "AzureDevOpsCli.execute", | |
| 166 | command: "az", | |
| 167 | args: [ | |
| 168 | "repos", | |
| 169 | "pr", | |
| 170 | "list", | |
| 171 | "--detect", | |
| 172 | "true", | |
| 173 | "--source-branch", | |
| 174 | "feature/merged", | |
| 175 | "--status", | |
| 176 | "completed", | |
| 177 | "--top", | |
| 178 | "10", | |
| 179 | "--only-show-errors", | |
| 180 | "--output", | |
| 181 | "json", | |
| 182 | ], | |
| 183 | cwd: "/repo", | |
| 184 | timeoutMs: 30_000, | |
| 185 | }); | |
| 186 | }).pipe(Effect.provide(layer)), | |
| 187 | ); | |
| 188 | ||
| 189 | it.effect("reads repository clone URLs", () => | |
| 190 | Effect.gen(function* () { | |
| 191 | mockRun.mockReturnValueOnce( | |
| 192 | Effect.succeed( | |
| 193 | processOutput( | |
| 194 | JSON.stringify({ | |
| 195 | name: "repo", | |
| 196 | webUrl: "https://dev.azure.com/acme/project/_git/repo", | |
| 197 | remoteUrl: "https://dev.azure.com/acme/project/_git/repo", | |
| 198 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | |
| 199 | project: { | |
| 200 | name: "project", | |
| 201 | }, | |
| 202 | }), | |
| 203 | ), | |
| 204 | ), | |
| 205 | ); | |
| 206 | ||
| 207 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 208 | const result = yield* az.getRepositoryCloneUrls({ | |
| 209 | cwd: "/repo", | |
| 210 | repository: "repo", | |
| 211 | }); | |
| 212 | ||
| 213 | assert.deepStrictEqual(result, { | |
| 214 | nameWithOwner: "project/repo", | |
| 215 | url: "https://dev.azure.com/acme/project/_git/repo", | |
| 216 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | |
| 217 | }); | |
| 218 | }).pipe(Effect.provide(layer)), | |
| 219 | ); | |
| 220 | ||
| 221 | it.effect("creates repositories through Azure Repos", () => | |
| 222 | Effect.gen(function* () { | |
| 223 | mockRun.mockReturnValueOnce( | |
| 224 | Effect.succeed( | |
| 225 | processOutput( | |
| 226 | JSON.stringify({ | |
| 227 | name: "repo", | |
| 228 | webUrl: "https://dev.azure.com/acme/project/_git/repo", | |
| 229 | remoteUrl: "https://dev.azure.com/acme/project/_git/repo", | |
| 230 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | |
| 231 | project: { | |
| 232 | name: "project", | |
| 233 | }, | |
| 234 | }), | |
| 235 | ), | |
| 236 | ), | |
| 237 | ); | |
| 238 | ||
| 239 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 240 | const result = yield* az.createRepository({ | |
| 241 | cwd: "/repo", | |
| 242 | repository: "project/repo", | |
| 243 | visibility: "private", | |
| 244 | }); | |
| 245 | ||
| 246 | assert.deepStrictEqual(result, { | |
| 247 | nameWithOwner: "project/repo", | |
| 248 | url: "https://dev.azure.com/acme/project/_git/repo", | |
| 249 | sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", | |
| 250 | }); | |
| 251 | expect(mockRun).toHaveBeenCalledWith({ | |
| 252 | operation: "AzureDevOpsCli.execute", | |
| 253 | command: "az", | |
| 254 | args: [ | |
| 255 | "repos", | |
| 256 | "create", | |
| 257 | "--detect", | |
| 258 | "true", | |
| 259 | "--name", | |
| 260 | "repo", | |
| 261 | "--project", | |
| 262 | "project", | |
| 263 | "--only-show-errors", | |
| 264 | "--output", | |
| 265 | "json", | |
| 266 | ], | |
| 267 | cwd: "/repo", | |
| 268 | timeoutMs: 30_000, | |
| 269 | }); | |
| 270 | }).pipe(Effect.provide(layer)), | |
| 271 | ); | |
| 272 | ||
| 273 | it.effect("creates pull requests using the body file as the Azure description", () => | |
| 274 | Effect.gen(function* () { | |
| 275 | const fileSystem = yield* FileSystem.FileSystem; | |
| 276 | const bodyFile = `/tmp/t3code-azure-devops-cli-.md`; | |
| 277 | yield* fileSystem.writeFileString(bodyFile, "Generated body"); | |
| 278 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); | |
| 279 | ||
| 280 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 281 | yield* az.createPullRequest({ | |
| 282 | cwd: "/repo", | |
| 283 | baseBranch: "main", | |
| 284 | headSelector: "feature/provider", | |
| 285 | title: "Provider PR", | |
| 286 | bodyFile, | |
| 287 | }); | |
| 288 | ||
| 289 | expect(mockRun).toHaveBeenCalledWith( | |
| 290 | expect.objectContaining({ | |
| 291 | command: "az", | |
| 292 | cwd: "/repo", | |
| 293 | args: expect.arrayContaining(["--description", `@${bodyFile}`]), | |
| 294 | }), | |
| 295 | ); | |
| 296 | expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output"); | |
| 297 | }).pipe(Effect.provide(layer)), | |
| 298 | ); | |
| 299 | ||
| 300 | it.effect("does not force JSON output on checkout side-effect commands", () => | |
| 301 | Effect.gen(function* () { | |
| 302 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); | |
| 303 | ||
| 304 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 305 | yield* az.checkoutPullRequest({ | |
| 306 | cwd: "/repo", | |
| 307 | reference: "42", | |
| 308 | }); | |
| 309 | ||
| 310 | expect(mockRun).toHaveBeenCalledWith({ | |
| 311 | operation: "AzureDevOpsCli.execute", | |
| 312 | command: "az", | |
| 313 | args: [ | |
| 314 | "repos", | |
| 315 | "pr", | |
| 316 | "checkout", | |
| 317 | "--only-show-errors", | |
| 318 | "--detect", | |
| 319 | "true", | |
| 320 | "--id", | |
| 321 | "42", | |
| 322 | "--remote-name", | |
| 323 | "origin", | |
| 324 | ], | |
| 325 | cwd: "/repo", | |
| 326 | timeoutMs: 30_000, | |
| 327 | }); | |
| 328 | }).pipe(Effect.provide(layer)), | |
| 329 | ); | |
| 330 | ||
| 331 | it.effect("forwards explicit output limits to the process boundary", () => | |
| 332 | Effect.gen(function* () { | |
| 333 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); | |
| 334 | ||
| 335 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 336 | yield* az.execute({ | |
| 337 | cwd: "/repo", | |
| 338 | args: ["repos", "pr", "list"], | |
| 339 | maxOutputBytes: 16 * 1024 * 1024, | |
| 340 | }); | |
| 341 | ||
| 342 | expect(mockRun).toHaveBeenCalledWith({ | |
| 343 | operation: "AzureDevOpsCli.execute", | |
| 344 | command: "az", | |
| 345 | args: ["repos", "pr", "list"], | |
| 346 | cwd: "/repo", | |
| 347 | timeoutMs: 30_000, | |
| 348 | maxOutputBytes: 16 * 1024 * 1024, | |
| 349 | }); | |
| 350 | }).pipe(Effect.provide(layer)), | |
| 351 | ); | |
| 352 | ||
| 353 | it.effect("preserves VCS causes without copying upstream details into messages", () => | |
| 354 | Effect.gen(function* () { | |
| 355 | const cause = new VcsProcessExitError({ | |
| 356 | operation: "AzureDevOpsCli.execute", | |
| 357 | command: "az repos list --organization sensitive-upstream-detail", | |
| 358 | cwd: "/repo", | |
| 359 | exitCode: 1, | |
| 360 | detail: "sensitive-upstream-detail", | |
| 361 | }); | |
| 362 | mockRun.mockReturnValueOnce(Effect.fail(cause)); | |
| 363 | ||
| 364 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 365 | const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); | |
| 366 | ||
| 367 | assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); | |
| 368 | assert.strictEqual(error.operation, "execute"); | |
| 369 | assert.strictEqual(error.command, "az"); | |
| 370 | assert.strictEqual(error.cwd, "/repo"); | |
| 371 | assert.strictEqual(error.argumentCount, 2); | |
| 372 | assert.strictEqual(error.detail, "Azure DevOps CLI command failed."); | |
| 373 | assert.strictEqual(error.cause, cause); | |
| 374 | assert.equal(error.message.includes("sensitive-upstream-detail"), false); | |
| 375 | }).pipe(Effect.provide(layer)), | |
| 376 | ); | |
| 377 | ||
| 378 | it.effect("does not report a missing working directory as a missing Azure CLI", () => | |
| 379 | Effect.gen(function* () { | |
| 380 | const cwd = "/missing/repo"; | |
| 381 | const platformCause = PlatformError.systemError({ | |
| 382 | _tag: "NotFound", | |
| 383 | module: "ChildProcess", | |
| 384 | method: "spawn", | |
| 385 | syscall: "chdir", | |
| 386 | pathOrDescriptor: cwd, | |
| 387 | }); | |
| 388 | const cause = new VcsProcessSpawnError({ | |
| 389 | operation: "AzureDevOpsCli.execute", | |
| 390 | command: "az", | |
| 391 | cwd, | |
| 392 | argumentCount: 2, | |
| 393 | cause: platformCause, | |
| 394 | }); | |
| 395 | mockRun.mockReturnValueOnce(Effect.fail(cause)); | |
| 396 | ||
| 397 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 398 | const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip); | |
| 399 | ||
| 400 | assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); | |
| 401 | assert.strictEqual(error.cwd, cwd); | |
| 402 | assert.strictEqual(error.cause, cause); | |
| 403 | }).pipe(Effect.provide(layer)), | |
| 404 | ); | |
| 405 | ||
| 406 | it.effect("keeps invalid pull request output diagnostics structured", () => | |
| 407 | Effect.gen(function* () { | |
| 408 | mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json"))); | |
| 409 | ||
| 410 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 411 | const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip); | |
| 412 | ||
| 413 | assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError); | |
| 414 | assert.strictEqual(error.operation, "getPullRequest"); | |
| 415 | assert.strictEqual(error.command, "az"); | |
| 416 | assert.strictEqual(error.cwd, "/repo"); | |
| 417 | assert.strictEqual(error.outputLength, 8); | |
| 418 | assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON."); | |
| 419 | assert.exists(error.cause); | |
| 420 | assert.strictEqual( | |
| 421 | error.message, | |
| 422 | "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.", | |
| 423 | ); | |
| 424 | }).pipe(Effect.provide(layer)), | |
| 425 | ); | |
| 426 | ||
| 427 | it.effect("preserves rate-limit failures as a distinct error", () => | |
| 428 | Effect.gen(function* () { | |
| 429 | const cause = new VcsProcessExitError({ | |
| 430 | operation: "AzureDevOpsCli.execute", | |
| 431 | command: "az", | |
| 432 | cwd: "/repo", | |
| 433 | argumentCount: 2, | |
| 434 | exitCode: 1, | |
| 435 | detail: "API rate limit exceeded.", | |
| 436 | failureKind: "rate-limited", | |
| 437 | }); | |
| 438 | mockRun.mockReturnValueOnce(Effect.fail(cause)); | |
| 439 | ||
| 440 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 441 | const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); | |
| 442 | ||
| 443 | assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCliRateLimitError); | |
| 444 | assert.strictEqual(error.cause, cause); | |
| 445 | }).pipe(Effect.provide(layer)), | |
| 446 | ); | |
| 447 | }); |
packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts created+554| ... | ... | @@ -0,0 +1,554 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as PlatformError from "effect/PlatformError"; | |
| 5 | import * as Result from "effect/Result"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import { | |
| 8 | NonNegativeInt, | |
| 9 | TrimmedNonEmptyString, | |
| 10 | type SourceControlRepositoryVisibility, | |
| 11 | type VcsError, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | ||
| 14 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | |
| 15 | import { | |
| 16 | decodeAzureDevOpsPullRequestJson, | |
| 17 | decodeAzureDevOpsPullRequestListJson, | |
| 18 | type NormalizedAzureDevOpsPullRequestRecord, | |
| 19 | } from "./azureDevOpsPullRequests.ts"; | |
| 20 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | |
| 21 | ||
| 22 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 23 | ||
| 24 | const azureDevOpsCommandErrorFields = { | |
| 25 | operation: Schema.Literal("execute"), | |
| 26 | command: Schema.Literal("az"), | |
| 27 | cwd: Schema.String, | |
| 28 | argumentCount: NonNegativeInt, | |
| 29 | cause: Schema.Defect(), | |
| 30 | }; | |
| 31 | ||
| 32 | export class AzureDevOpsCliUnavailableError extends Schema.TaggedError<AzureDevOpsCliUnavailableError>()( | |
| 33 | "AzureDevOpsCliUnavailableError", | |
| 34 | azureDevOpsCommandErrorFields, | |
| 35 | ) { | |
| 36 | get detail(): string { | |
| 37 | return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH."; | |
| 38 | } | |
| 39 | ||
| 40 | override get message(): string { | |
| 41 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | export class AzureDevOpsCliAuthenticationError extends Schema.TaggedError<AzureDevOpsCliAuthenticationError>()( | |
| 46 | "AzureDevOpsCliAuthenticationError", | |
| 47 | azureDevOpsCommandErrorFields, | |
| 48 | ) { | |
| 49 | get detail(): string { | |
| 50 | return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry."; | |
| 51 | } | |
| 52 | ||
| 53 | override get message(): string { | |
| 54 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | export class AzureDevOpsCliRateLimitError extends Schema.TaggedError<AzureDevOpsCliRateLimitError>()( | |
| 59 | "AzureDevOpsCliRateLimitError", | |
| 60 | azureDevOpsCommandErrorFields, | |
| 61 | ) { | |
| 62 | get detail(): string { | |
| 63 | return "Azure DevOps API rate limit exceeded."; | |
| 64 | } | |
| 65 | ||
| 66 | override get message(): string { | |
| 67 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedError<AzureDevOpsPullRequestNotFoundError>()( | |
| 72 | "AzureDevOpsPullRequestNotFoundError", | |
| 73 | azureDevOpsCommandErrorFields, | |
| 74 | ) { | |
| 75 | get detail(): string { | |
| 76 | return "Pull request not found. Check the PR number or URL and try again."; | |
| 77 | } | |
| 78 | ||
| 79 | override get message(): string { | |
| 80 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | export class AzureDevOpsCommandFailedError extends Schema.TaggedError<AzureDevOpsCommandFailedError>()( | |
| 85 | "AzureDevOpsCommandFailedError", | |
| 86 | azureDevOpsCommandErrorFields, | |
| 87 | ) { | |
| 88 | get detail(): string { | |
| 89 | return "Azure DevOps CLI command failed."; | |
| 90 | } | |
| 91 | ||
| 92 | override get message(): string { | |
| 93 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 94 | } | |
| 95 | ||
| 96 | static fromVcsError( | |
| 97 | context: { | |
| 98 | readonly operation: "execute"; | |
| 99 | readonly command: "az"; | |
| 100 | readonly cwd: string; | |
| 101 | readonly argumentCount: number; | |
| 102 | }, | |
| 103 | cause: VcsError, | |
| 104 | ): AzureDevOpsCliError { | |
| 105 | const fields = { ...context, cause }; | |
| 106 | ||
| 107 | if ( | |
| 108 | cause._tag === "VcsProcessSpawnError" && | |
| 109 | cause.cause instanceof PlatformError.PlatformError && | |
| 110 | cause.cause.reason._tag === "NotFound" && | |
| 111 | cause.cause.reason.pathOrDescriptor !== context.cwd && | |
| 112 | cause.cause.reason.syscall !== "chdir" | |
| 113 | ) { | |
| 114 | return new AzureDevOpsCliUnavailableError(fields); | |
| 115 | } | |
| 116 | ||
| 117 | if (cause._tag === "VcsProcessExitError") { | |
| 118 | if (cause.failureKind === "authentication") { | |
| 119 | return new AzureDevOpsCliAuthenticationError(fields); | |
| 120 | } | |
| 121 | if (cause.failureKind === "rate-limited") { | |
| 122 | return new AzureDevOpsCliRateLimitError(fields); | |
| 123 | } | |
| 124 | if (cause.failureKind === "not-found") { | |
| 125 | return new AzureDevOpsPullRequestNotFoundError(fields); | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | return new AzureDevOpsCommandFailedError(fields); | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | const azureDevOpsDecodeErrorFields = { | |
| 134 | command: Schema.Literal("az"), | |
| 135 | cwd: Schema.String, | |
| 136 | outputLength: NonNegativeInt, | |
| 137 | cause: Schema.Defect(), | |
| 138 | }; | |
| 139 | ||
| 140 | export class AzureDevOpsPullRequestListDecodeError extends Schema.TaggedError<AzureDevOpsPullRequestListDecodeError>()( | |
| 141 | "AzureDevOpsPullRequestListDecodeError", | |
| 142 | { | |
| 143 | operation: Schema.Literal("listPullRequests"), | |
| 144 | ...azureDevOpsDecodeErrorFields, | |
| 145 | }, | |
| 146 | ) { | |
| 147 | get detail(): string { | |
| 148 | return "Azure DevOps CLI returned invalid PR list JSON."; | |
| 149 | } | |
| 150 | ||
| 151 | override get message(): string { | |
| 152 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedError<AzureDevOpsPullRequestDecodeError>()( | |
| 157 | "AzureDevOpsPullRequestDecodeError", | |
| 158 | { | |
| 159 | operation: Schema.Literal("getPullRequest"), | |
| 160 | ...azureDevOpsDecodeErrorFields, | |
| 161 | }, | |
| 162 | ) { | |
| 163 | get detail(): string { | |
| 164 | return "Azure DevOps CLI returned invalid pull request JSON."; | |
| 165 | } | |
| 166 | ||
| 167 | override get message(): string { | |
| 168 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 169 | } | |
| 170 | } | |
| 171 | ||
| 172 | const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([ | |
| 173 | "getRepositoryCloneUrls", | |
| 174 | "getDefaultBranch", | |
| 175 | "createRepository", | |
| 176 | ]); | |
| 177 | ||
| 178 | export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedError<AzureDevOpsRepositoryDecodeError>()( | |
| 179 | "AzureDevOpsRepositoryDecodeError", | |
| 180 | { | |
| 181 | operation: AzureDevOpsRepositoryDecodeOperation, | |
| 182 | ...azureDevOpsDecodeErrorFields, | |
| 183 | }, | |
| 184 | ) { | |
| 185 | get detail(): string { | |
| 186 | return "Azure DevOps CLI returned invalid repository JSON."; | |
| 187 | } | |
| 188 | ||
| 189 | override get message(): string { | |
| 190 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | export const AzureDevOpsCliError = Schema.Union([ | |
| 195 | AzureDevOpsCliUnavailableError, | |
| 196 | AzureDevOpsCliAuthenticationError, | |
| 197 | AzureDevOpsCliRateLimitError, | |
| 198 | AzureDevOpsPullRequestNotFoundError, | |
| 199 | AzureDevOpsCommandFailedError, | |
| 200 | AzureDevOpsPullRequestListDecodeError, | |
| 201 | AzureDevOpsPullRequestDecodeError, | |
| 202 | AzureDevOpsRepositoryDecodeError, | |
| 203 | ]); | |
| 204 | export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type; | |
| 205 | ||
| 206 | export interface AzureDevOpsRepositoryCloneUrls { | |
| 207 | readonly nameWithOwner: string; | |
| 208 | readonly url: string; | |
| 209 | readonly sshUrl: string; | |
| 210 | } | |
| 211 | ||
| 212 | export class AzureDevOpsCli extends Context.Service< | |
| 213 | AzureDevOpsCli, | |
| 214 | { | |
| 215 | readonly execute: (input: { | |
| 216 | readonly cwd: string; | |
| 217 | readonly args: ReadonlyArray<string>; | |
| 218 | readonly timeoutMs?: number; | |
| 219 | readonly maxOutputBytes?: number; | |
| 220 | }) => Effect.Effect<SourceControlHost.SourceControlProcessOutput, AzureDevOpsCliError>; | |
| 221 | ||
| 222 | readonly listPullRequests: (input: { | |
| 223 | readonly cwd: string; | |
| 224 | readonly headSelector: string; | |
| 225 | readonly source?: SourceControlProvider.SourceControlRefSelector; | |
| 226 | readonly state: "open" | "closed" | "merged" | "all"; | |
| 227 | readonly limit?: number; | |
| 228 | }) => Effect.Effect<ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, AzureDevOpsCliError>; | |
| 229 | ||
| 230 | readonly getPullRequest: (input: { | |
| 231 | readonly cwd: string; | |
| 232 | readonly reference: string; | |
| 233 | }) => Effect.Effect<NormalizedAzureDevOpsPullRequestRecord, AzureDevOpsCliError>; | |
| 234 | ||
| 235 | readonly getRepositoryCloneUrls: (input: { | |
| 236 | readonly cwd: string; | |
| 237 | readonly repository: string; | |
| 238 | }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>; | |
| 239 | ||
| 240 | readonly createRepository: (input: { | |
| 241 | readonly cwd: string; | |
| 242 | readonly repository: string; | |
| 243 | readonly visibility: SourceControlRepositoryVisibility; | |
| 244 | }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>; | |
| 245 | ||
| 246 | readonly createPullRequest: (input: { | |
| 247 | readonly cwd: string; | |
| 248 | readonly baseBranch: string; | |
| 249 | readonly headSelector: string; | |
| 250 | readonly source?: SourceControlProvider.SourceControlRefSelector; | |
| 251 | readonly target?: SourceControlProvider.SourceControlRefSelector; | |
| 252 | readonly title: string; | |
| 253 | readonly bodyFile: string; | |
| 254 | }) => Effect.Effect<void, AzureDevOpsCliError>; | |
| 255 | ||
| 256 | readonly getDefaultBranch: (input: { | |
| 257 | readonly cwd: string; | |
| 258 | }) => Effect.Effect<string | null, AzureDevOpsCliError>; | |
| 259 | ||
| 260 | readonly checkoutPullRequest: (input: { | |
| 261 | readonly cwd: string; | |
| 262 | readonly reference: string; | |
| 263 | readonly remoteName?: string; | |
| 264 | }) => Effect.Effect<void, AzureDevOpsCliError>; | |
| 265 | } | |
| 266 | >()("@t3tools/source-control-azure-devops/server/AzureDevOpsCli") {} | |
| 267 | ||
| 268 | function normalizeChangeRequestId(reference: string): string { | |
| 269 | const trimmed = reference.trim().replace(/^#/, ""); | |
| 270 | const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); | |
| 271 | return urlMatch?.[1] ?? trimmed; | |
| 272 | } | |
| 273 | ||
| 274 | function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string { | |
| 275 | switch (state) { | |
| 276 | case "open": | |
| 277 | return "active"; | |
| 278 | case "closed": | |
| 279 | return "abandoned"; | |
| 280 | case "merged": | |
| 281 | return "completed"; | |
| 282 | case "all": | |
| 283 | return "all"; | |
| 284 | } | |
| 285 | } | |
| 286 | ||
| 287 | const RawAzureDevOpsRepositorySchema = Schema.Struct({ | |
| 288 | name: TrimmedNonEmptyString, | |
| 289 | webUrl: TrimmedNonEmptyString, | |
| 290 | remoteUrl: TrimmedNonEmptyString, | |
| 291 | sshUrl: TrimmedNonEmptyString, | |
| 292 | project: Schema.optional( | |
| 293 | Schema.Struct({ | |
| 294 | name: TrimmedNonEmptyString, | |
| 295 | }), | |
| 296 | ), | |
| 297 | defaultBranch: Schema.optional(Schema.NullOr(Schema.String)), | |
| 298 | }); | |
| 299 | ||
| 300 | function normalizeDefaultBranch(value: string | null | undefined): string | null { | |
| 301 | const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? ""; | |
| 302 | return trimmed.length > 0 ? trimmed : null; | |
| 303 | } | |
| 304 | ||
| 305 | function normalizeRepositoryCloneUrls( | |
| 306 | raw: Schema.Schema.Type<typeof RawAzureDevOpsRepositorySchema>, | |
| 307 | ): AzureDevOpsRepositoryCloneUrls { | |
| 308 | const projectName = raw.project?.name.trim(); | |
| 309 | return { | |
| 310 | nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name, | |
| 311 | url: raw.remoteUrl, | |
| 312 | sshUrl: raw.sshUrl, | |
| 313 | }; | |
| 314 | } | |
| 315 | ||
| 316 | function parseRepositorySpecifier(repository: string): { | |
| 317 | readonly project: string | null; | |
| 318 | readonly name: string; | |
| 319 | } { | |
| 320 | const parts: Array<string> = []; | |
| 321 | for (const part of repository.split("/")) { | |
| 322 | const trimmed = part.trim(); | |
| 323 | if (trimmed.length > 0) { | |
| 324 | parts.push(trimmed); | |
| 325 | } | |
| 326 | } | |
| 327 | return { | |
| 328 | project: parts.length > 1 ? (parts.at(-2) ?? null) : null, | |
| 329 | name: parts.at(-1) ?? repository.trim(), | |
| 330 | }; | |
| 331 | } | |
| 332 | ||
| 333 | function decodeAzureDevOpsJson<S extends Schema.Top>( | |
| 334 | raw: string, | |
| 335 | schema: S, | |
| 336 | operation: typeof AzureDevOpsRepositoryDecodeOperation.Type, | |
| 337 | cwd: string, | |
| 338 | ): Effect.Effect<S["Type"], AzureDevOpsRepositoryDecodeError, S["DecodingServices"]> { | |
| 339 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( | |
| 340 | Effect.mapError( | |
| 341 | (cause) => | |
| 342 | new AzureDevOpsRepositoryDecodeError({ | |
| 343 | operation, | |
| 344 | command: "az", | |
| 345 | cwd, | |
| 346 | outputLength: raw.length, | |
| 347 | cause, | |
| 348 | }), | |
| 349 | ), | |
| 350 | ); | |
| 351 | } | |
| 352 | ||
| 353 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 354 | export const make = Effect.gen(function* () { | |
| 355 | const { process } = yield* SourceControlHost.SourceControlHost; | |
| 356 | ||
| 357 | const execute: AzureDevOpsCli["Service"]["execute"] = (input) => | |
| 358 | process | |
| 359 | .run({ | |
| 360 | operation: "AzureDevOpsCli.execute", | |
| 361 | command: "az", | |
| 362 | args: input.args, | |
| 363 | cwd: input.cwd, | |
| 364 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | |
| 365 | ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), | |
| 366 | }) | |
| 367 | .pipe( | |
| 368 | Effect.mapError((error) => | |
| 369 | AzureDevOpsCommandFailedError.fromVcsError( | |
| 370 | { | |
| 371 | operation: "execute", | |
| 372 | command: "az", | |
| 373 | cwd: input.cwd, | |
| 374 | argumentCount: input.args.length, | |
| 375 | }, | |
| 376 | error, | |
| 377 | ), | |
| 378 | ), | |
| 379 | ); | |
| 380 | ||
| 381 | const executeJson = (input: Parameters<AzureDevOpsCli["Service"]["execute"]>[0]) => | |
| 382 | execute({ | |
| 383 | ...input, | |
| 384 | args: [...input.args, "--only-show-errors", "--output", "json"], | |
| 385 | }); | |
| 386 | ||
| 387 | return AzureDevOpsCli.of({ | |
| 388 | execute, | |
| 389 | listPullRequests: (input) => | |
| 390 | executeJson({ | |
| 391 | cwd: input.cwd, | |
| 392 | args: [ | |
| 393 | "repos", | |
| 394 | "pr", | |
| 395 | "list", | |
| 396 | "--detect", | |
| 397 | "true", | |
| 398 | "--source-branch", | |
| 399 | SourceControlProvider.sourceBranch(input), | |
| 400 | "--status", | |
| 401 | toAzureStatus(input.state), | |
| 402 | "--top", | |
| 403 | String(input.limit ?? 20), | |
| 404 | ], | |
| 405 | }).pipe( | |
| 406 | Effect.map((result) => result.stdout.trim()), | |
| 407 | Effect.flatMap((raw) => | |
| 408 | raw.length === 0 | |
| 409 | ? Effect.succeed([]) | |
| 410 | : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe( | |
| 411 | Effect.flatMap((decoded) => { | |
| 412 | if (!Result.isSuccess(decoded)) { | |
| 413 | return Effect.fail( | |
| 414 | new AzureDevOpsPullRequestListDecodeError({ | |
| 415 | operation: "listPullRequests", | |
| 416 | command: "az", | |
| 417 | cwd: input.cwd, | |
| 418 | outputLength: raw.length, | |
| 419 | cause: decoded.failure, | |
| 420 | }), | |
| 421 | ); | |
| 422 | } | |
| 423 | ||
| 424 | return Effect.succeed(decoded.success); | |
| 425 | }), | |
| 426 | ), | |
| 427 | ), | |
| 428 | ), | |
| 429 | getPullRequest: (input) => | |
| 430 | executeJson({ | |
| 431 | cwd: input.cwd, | |
| 432 | args: [ | |
| 433 | "repos", | |
| 434 | "pr", | |
| 435 | "show", | |
| 436 | "--detect", | |
| 437 | "true", | |
| 438 | "--id", | |
| 439 | normalizeChangeRequestId(input.reference), | |
| 440 | ], | |
| 441 | }).pipe( | |
| 442 | Effect.map((result) => result.stdout.trim()), | |
| 443 | Effect.flatMap((raw) => | |
| 444 | Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe( | |
| 445 | Effect.flatMap((decoded) => { | |
| 446 | if (!Result.isSuccess(decoded)) { | |
| 447 | return Effect.fail( | |
| 448 | new AzureDevOpsPullRequestDecodeError({ | |
| 449 | operation: "getPullRequest", | |
| 450 | command: "az", | |
| 451 | cwd: input.cwd, | |
| 452 | outputLength: raw.length, | |
| 453 | cause: decoded.failure, | |
| 454 | }), | |
| 455 | ); | |
| 456 | } | |
| 457 | ||
| 458 | return Effect.succeed(decoded.success); | |
| 459 | }), | |
| 460 | ), | |
| 461 | ), | |
| 462 | ), | |
| 463 | getRepositoryCloneUrls: (input) => | |
| 464 | executeJson({ | |
| 465 | cwd: input.cwd, | |
| 466 | args: ["repos", "show", "--detect", "true", "--repository", input.repository], | |
| 467 | }).pipe( | |
| 468 | Effect.map((result) => result.stdout.trim()), | |
| 469 | Effect.flatMap((raw) => | |
| 470 | decodeAzureDevOpsJson( | |
| 471 | raw, | |
| 472 | RawAzureDevOpsRepositorySchema, | |
| 473 | "getRepositoryCloneUrls", | |
| 474 | input.cwd, | |
| 475 | ), | |
| 476 | ), | |
| 477 | Effect.map(normalizeRepositoryCloneUrls), | |
| 478 | ), | |
| 479 | createRepository: (input) => { | |
| 480 | const repository = parseRepositorySpecifier(input.repository); | |
| 481 | // Azure Repos access is governed by project/organization permissions. | |
| 482 | // `az repos create` does not expose a per-repository visibility flag, so | |
| 483 | // the generic source-control visibility input is intentionally not | |
| 484 | // translated into CLI args for this provider. | |
| 485 | return executeJson({ | |
| 486 | cwd: input.cwd, | |
| 487 | args: [ | |
| 488 | "repos", | |
| 489 | "create", | |
| 490 | "--detect", | |
| 491 | "true", | |
| 492 | "--name", | |
| 493 | repository.name, | |
| 494 | ...(repository.project ? ["--project", repository.project] : []), | |
| 495 | ], | |
| 496 | }).pipe( | |
| 497 | Effect.map((result) => result.stdout.trim()), | |
| 498 | Effect.flatMap((raw) => | |
| 499 | decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd), | |
| 500 | ), | |
| 501 | Effect.map(normalizeRepositoryCloneUrls), | |
| 502 | ); | |
| 503 | }, | |
| 504 | createPullRequest: (input) => | |
| 505 | execute({ | |
| 506 | cwd: input.cwd, | |
| 507 | args: [ | |
| 508 | "repos", | |
| 509 | "pr", | |
| 510 | "create", | |
| 511 | "--only-show-errors", | |
| 512 | "--detect", | |
| 513 | "true", | |
| 514 | "--target-branch", | |
| 515 | input.target?.refName ?? input.baseBranch, | |
| 516 | "--source-branch", | |
| 517 | SourceControlProvider.sourceBranch(input), | |
| 518 | "--title", | |
| 519 | input.title, | |
| 520 | "--description", | |
| 521 | `@${input.bodyFile}`, | |
| 522 | ], | |
| 523 | }).pipe(Effect.asVoid), | |
| 524 | getDefaultBranch: (input) => | |
| 525 | executeJson({ | |
| 526 | cwd: input.cwd, | |
| 527 | args: ["repos", "show", "--detect", "true"], | |
| 528 | }).pipe( | |
| 529 | Effect.map((result) => result.stdout.trim()), | |
| 530 | Effect.flatMap((raw) => | |
| 531 | decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd), | |
| 532 | ), | |
| 533 | Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), | |
| 534 | ), | |
| 535 | checkoutPullRequest: (input) => | |
| 536 | execute({ | |
| 537 | cwd: input.cwd, | |
| 538 | args: [ | |
| 539 | "repos", | |
| 540 | "pr", | |
| 541 | "checkout", | |
| 542 | "--only-show-errors", | |
| 543 | "--detect", | |
| 544 | "true", | |
| 545 | "--id", | |
| 546 | normalizeChangeRequestId(input.reference), | |
| 547 | "--remote-name", | |
| 548 | input.remoteName ?? "origin", | |
| 549 | ], | |
| 550 | }).pipe(Effect.asVoid), | |
| 551 | }); | |
| 552 | }); | |
| 553 | ||
| 554 | export const layer = Layer.effect(AzureDevOpsCli, make); |
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts created+1462| ... | ... | @@ -0,0 +1,1462 @@ |
| 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import { ChildProcessSpawner } from "effect/process"; | |
| 5 | ||
| 6 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | |
| 7 | import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; | |
| 8 | import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; | |
| 9 | ||
| 10 | const mockedExecute = vi.fn<AzureDevOpsCli.AzureDevOpsCli["Service"]["execute"]>(); | |
| 11 | ||
| 12 | const layer = it.layer( | |
| 13 | AzureDevOpsPullRequestCli.layer.pipe( | |
| 14 | Layer.provide( | |
| 15 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({ | |
| 16 | execute: mockedExecute, | |
| 17 | }), | |
| 18 | ), | |
| 19 | ), | |
| 20 | ); | |
| 21 | ||
| 22 | function output(stdout: string) { | |
| 23 | return { | |
| 24 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 25 | stdout, | |
| 26 | stderr: "", | |
| 27 | stdoutTruncated: false, | |
| 28 | stderrTruncated: false, | |
| 29 | }; | |
| 30 | } | |
| 31 | ||
| 32 | /** What VcsProcess allows a read that asked for no ceiling of its own. */ | |
| 33 | const VCS_DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 34 | ||
| 35 | /** | |
| 36 | * The runner as it really behaves: it cuts stdout at the ceiling its caller asked for, and cuts | |
| 37 | * it at the process default when the caller asked for none. A read whose response is larger than | |
| 38 | * its ceiling gets JSON that stops mid-string, which is the whole cost of an unset ceiling. | |
| 39 | */ | |
| 40 | function outputWithin(maxOutputBytes: number | undefined, response: string) { | |
| 41 | const ceiling = maxOutputBytes ?? VCS_DEFAULT_MAX_OUTPUT_BYTES; | |
| 42 | return ceiling >= Buffer.byteLength(response) | |
| 43 | ? output(response) | |
| 44 | : { ...output(response.slice(0, ceiling)), stdoutTruncated: true }; | |
| 45 | } | |
| 46 | ||
| 47 | /** A fixture's own shape, spelled the way `az` would answer with it. */ | |
| 48 | const json = (value: Record<string, unknown>) => JSON.stringify(value); | |
| 49 | ||
| 50 | const pullRequestRow = { | |
| 51 | pullRequestId: 42, | |
| 52 | title: "Add the page", | |
| 53 | status: "active", | |
| 54 | sourceRefName: "refs/heads/feat/page", | |
| 55 | targetRefName: "refs/heads/main", | |
| 56 | creationDate: "2026-07-01T00:00:00Z", | |
| 57 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 58 | repository: { name: "web", project: { name: "platform" } }, | |
| 59 | }; | |
| 60 | ||
| 61 | const oneIteration = { | |
| 62 | value: [ | |
| 63 | { | |
| 64 | id: 1, | |
| 65 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 66 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 67 | }, | |
| 68 | ], | |
| 69 | }; | |
| 70 | ||
| 71 | function pullRequestRows( | |
| 72 | count: number, | |
| 73 | firstNumber: number, | |
| 74 | ): ReadonlyArray<Record<string, unknown>> { | |
| 75 | return Array.from({ length: count }, (_, index) => ({ | |
| 76 | pullRequestId: firstNumber + index, | |
| 77 | title: `Pull request ${firstNumber + index}`, | |
| 78 | status: "active", | |
| 79 | sourceRefName: "refs/heads/feat/page", | |
| 80 | targetRefName: "refs/heads/main", | |
| 81 | creationDate: "2026-07-01T00:00:00Z", | |
| 82 | repository: { name: "web", project: { name: "platform" } }, | |
| 83 | url: `https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/${firstNumber + index}`, | |
| 84 | })); | |
| 85 | } | |
| 86 | ||
| 87 | function pullRequests(count: number, firstNumber: number): string { | |
| 88 | return JSON.stringify(pullRequestRows(count, firstNumber)); | |
| 89 | } | |
| 90 | ||
| 91 | /** The arguments of the nth az invocation. */ | |
| 92 | function argsOfCall(index: number): ReadonlyArray<string> { | |
| 93 | const call = mockedExecute.mock.calls[index]; | |
| 94 | assert.isDefined(call); | |
| 95 | return call[0].args; | |
| 96 | } | |
| 97 | ||
| 98 | /** The output ceiling the nth az invocation asked for, if it asked for one at all. */ | |
| 99 | function maxOutputBytesOfCall(index: number) { | |
| 100 | const call = mockedExecute.mock.calls[index]; | |
| 101 | assert.isDefined(call); | |
| 102 | return call[0].maxOutputBytes; | |
| 103 | } | |
| 104 | ||
| 105 | /** A page of change entries the size Azure really answers with, url and object ids and all. */ | |
| 106 | function changeEntries(count: number): ReadonlyArray<Record<string, unknown>> { | |
| 107 | const commit = "c".repeat(40); | |
| 108 | return Array.from({ length: count }, (_, index) => { | |
| 109 | const path = `/apps/server/src/generated/module-${index}/persisted-projection-${index}.ts`; | |
| 110 | return { | |
| 111 | changeType: "edit", | |
| 112 | item: { | |
| 113 | path, | |
| 114 | objectId: "a".repeat(40), | |
| 115 | originalObjectId: "b".repeat(40), | |
| 116 | commitId: commit, | |
| 117 | gitObjectType: "blob", | |
| 118 | url: `https://dev.azure.com/acme/platform/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/items${path}?versionType=Commit&version=${commit}`, | |
| 119 | }, | |
| 120 | }; | |
| 121 | }); | |
| 122 | } | |
| 123 | ||
| 124 | /** An Azure identity, which rides along with every comment and every push Azure answers with. */ | |
| 125 | function identity(name: string) { | |
| 126 | const id = "6f9c9b7f-0000-0000-0000-000000000000"; | |
| 127 | return { | |
| 128 | displayName: name, | |
| 129 | id, | |
| 130 | uniqueName: `${name.toLowerCase().replace(/ /g, ".")}@acme.com`, | |
| 131 | descriptor: `aad.${"z".repeat(52)}`, | |
| 132 | imageUrl: `https://dev.azure.com/acme/_api/_common/identityImage?id=${id}`, | |
| 133 | url: `https://spsprodweu1.vssps.visualstudio.com/A${id}/_apis/Identities/${id}`, | |
| 134 | _links: { | |
| 135 | avatar: { | |
| 136 | href: `https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.${"z".repeat(52)}`, | |
| 137 | }, | |
| 138 | }, | |
| 139 | }; | |
| 140 | } | |
| 141 | ||
| 142 | /** A review's threads the shape Azure answers with, system threads and identities and all. */ | |
| 143 | function threadRows(count: number): ReadonlyArray<Record<string, unknown>> { | |
| 144 | return Array.from({ length: count }, (_, index) => ({ | |
| 145 | id: index + 1, | |
| 146 | publishedDate: "2026-07-02T00:00:00Z", | |
| 147 | lastUpdatedDate: "2026-07-02T00:00:00Z", | |
| 148 | status: "active", | |
| 149 | threadContext: { filePath: `/apps/server/src/generated/module-${index}.ts` }, | |
| 150 | identities: { 1: identity("Reviewer One") }, | |
| 151 | isDeleted: false, | |
| 152 | comments: [ | |
| 153 | { | |
| 154 | id: 1, | |
| 155 | parentCommentId: 0, | |
| 156 | author: identity("Reviewer One"), | |
| 157 | content: `Comment ${index}: ${"this needs another look. ".repeat(20)}`, | |
| 158 | publishedDate: "2026-07-02T00:00:00Z", | |
| 159 | lastUpdatedDate: "2026-07-02T00:00:00Z", | |
| 160 | commentType: "text", | |
| 161 | usersLiked: [], | |
| 162 | }, | |
| 163 | ], | |
| 164 | })); | |
| 165 | } | |
| 166 | ||
| 167 | /** A review's iterations the shape Azure answers with, one per push. */ | |
| 168 | function iterationRows(count: number): ReadonlyArray<Record<string, unknown>> { | |
| 169 | return Array.from({ length: count }, (_, index) => ({ | |
| 170 | id: index + 1, | |
| 171 | description: `Pushed ${index} commits`, | |
| 172 | author: identity("Author One"), | |
| 173 | createdDate: "2026-07-02T00:00:00Z", | |
| 174 | updatedDate: "2026-07-02T00:00:00Z", | |
| 175 | sourceRefCommit: { commitId: `${index}`.padStart(40, "a") }, | |
| 176 | targetRefCommit: { commitId: "b".repeat(40) }, | |
| 177 | commonRefCommit: { commitId: "c".repeat(40) }, | |
| 178 | hasMultipleCommits: true, | |
| 179 | reason: "push", | |
| 180 | push: { pushId: index + 1, date: "2026-07-02T00:00:00Z", pushedBy: identity("Author One") }, | |
| 181 | })); | |
| 182 | } | |
| 183 | ||
| 184 | afterEach(() => { | |
| 185 | mockedExecute.mockReset(); | |
| 186 | }); | |
| 187 | ||
| 188 | layer("AzureDevOpsPullRequestCli.layer", (it) => { | |
| 189 | it.effect("asks for one row more than the page, to probe for a next page", () => | |
| 190 | Effect.gen(function* () { | |
| 191 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); | |
| 192 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 193 | ||
| 194 | const batch = yield* cli.listPullRequests({ | |
| 195 | cwd: "/w", | |
| 196 | repository: "web", | |
| 197 | state: "open", | |
| 198 | involvement: "all", | |
| 199 | viewer: "bilal@acme.dev", | |
| 200 | limit: 10, | |
| 201 | }); | |
| 202 | ||
| 203 | assert.strictEqual(batch.items.length, 3); | |
| 204 | assert.isFalse(batch.truncated); | |
| 205 | expect(argsOfCall(0)).toEqual([ | |
| 206 | "repos", | |
| 207 | "pr", | |
| 208 | "list", | |
| 209 | "--detect", | |
| 210 | "true", | |
| 211 | "--repository", | |
| 212 | "web", | |
| 213 | "--status", | |
| 214 | "active", | |
| 215 | "--include-links", | |
| 216 | "--top", | |
| 217 | "11", | |
| 218 | "--only-show-errors", | |
| 219 | "--output", | |
| 220 | "json", | |
| 221 | ]); | |
| 222 | }), | |
| 223 | ); | |
| 224 | ||
| 225 | it.effect("reads an Azure pull request page larger than the VCS default output limit", () => | |
| 226 | Effect.gen(function* () { | |
| 227 | const rows = pullRequestRows(100, 1).map((row) => ({ | |
| 228 | ...row, | |
| 229 | description: "x".repeat(10_000), | |
| 230 | })); | |
| 231 | const response = JSON.stringify(rows); | |
| 232 | expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000); | |
| 233 | ||
| 234 | mockedExecute.mockImplementationOnce((input) => | |
| 235 | Effect.succeed(outputWithin(input.maxOutputBytes, response)), | |
| 236 | ); | |
| 237 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 238 | ||
| 239 | const batch = yield* cli.listPullRequests({ | |
| 240 | cwd: "/w", | |
| 241 | repository: "web", | |
| 242 | state: "merged", | |
| 243 | involvement: "all", | |
| 244 | viewer: "bilal@acme.dev", | |
| 245 | limit: 99, | |
| 246 | }); | |
| 247 | ||
| 248 | assert.strictEqual(batch.items.length, 99); | |
| 249 | assert.isTrue(batch.truncated); | |
| 250 | }), | |
| 251 | ); | |
| 252 | ||
| 253 | it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () => | |
| 254 | Effect.gen(function* () { | |
| 255 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); | |
| 256 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 257 | ||
| 258 | const page = yield* provider.listChangeRequests({ | |
| 259 | cwd: "/w", | |
| 260 | repository: "web", | |
| 261 | host: "dev.azure.com", | |
| 262 | state: "open", | |
| 263 | involvement: "all", | |
| 264 | viewer: "bilal@acme.dev", | |
| 265 | limit: 10, | |
| 266 | query: "page", | |
| 267 | }); | |
| 268 | ||
| 269 | // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at | |
| 270 | // all. The rows come back as they would have without a search, for the caller to narrow; | |
| 271 | // nothing of the search reaches the command, where it could only mean the wrong thing. | |
| 272 | assert.strictEqual(page.items.length, 3); | |
| 273 | expect(argsOfCall(0)).toEqual([ | |
| 274 | "repos", | |
| 275 | "pr", | |
| 276 | "list", | |
| 277 | "--detect", | |
| 278 | "true", | |
| 279 | "--repository", | |
| 280 | "web", | |
| 281 | "--status", | |
| 282 | "active", | |
| 283 | "--include-links", | |
| 284 | "--top", | |
| 285 | "11", | |
| 286 | "--only-show-errors", | |
| 287 | "--output", | |
| 288 | "json", | |
| 289 | ]); | |
| 290 | }), | |
| 291 | ); | |
| 292 | ||
| 293 | it.effect("steps over what it has already handed over, which is all Azure can be told", () => | |
| 294 | Effect.gen(function* () { | |
| 295 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); | |
| 296 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 297 | ||
| 298 | yield* cli.listPullRequests({ | |
| 299 | cwd: "/w", | |
| 300 | repository: "web", | |
| 301 | state: "open", | |
| 302 | involvement: "all", | |
| 303 | viewer: "bilal@acme.dev", | |
| 304 | limit: 10, | |
| 305 | // The instant is the same cursor every other host reads; Azure has no filter for it and | |
| 306 | // takes the count instead. | |
| 307 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 20 }, | |
| 308 | }); | |
| 309 | ||
| 310 | const args = argsOfCall(0); | |
| 311 | expect(args).toContain("--skip"); | |
| 312 | assert.strictEqual(args[args.indexOf("--skip") + 1], "20"); | |
| 313 | expect(args).not.toContain("2026-07-02T00:00:00Z"); | |
| 314 | }), | |
| 315 | ); | |
| 316 | ||
| 317 | it.effect("reports truncation from the extra row", () => | |
| 318 | Effect.gen(function* () { | |
| 319 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); | |
| 320 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 321 | ||
| 322 | const batch = yield* cli.listPullRequests({ | |
| 323 | cwd: "/w", | |
| 324 | repository: "web", | |
| 325 | state: "open", | |
| 326 | involvement: "all", | |
| 327 | viewer: "bilal@acme.dev", | |
| 328 | limit: 10, | |
| 329 | }); | |
| 330 | ||
| 331 | assert.strictEqual(batch.items.length, 10); | |
| 332 | assert.isTrue(batch.truncated); | |
| 333 | assert.strictEqual(batch.cursorAdvance, 10); | |
| 334 | }), | |
| 335 | ); | |
| 336 | ||
| 337 | it.effect("advances by malformed raw rows and keeps reading until the page is full", () => | |
| 338 | Effect.gen(function* () { | |
| 339 | mockedExecute | |
| 340 | .mockReturnValueOnce( | |
| 341 | Effect.succeed( | |
| 342 | output( | |
| 343 | JSON.stringify([ | |
| 344 | { pullRequestId: "malformed" }, | |
| 345 | pullRequestRows(1, 1)[0], | |
| 346 | { pullRequestId: "also malformed" }, | |
| 347 | ]), | |
| 348 | ), | |
| 349 | ), | |
| 350 | ) | |
| 351 | .mockReturnValueOnce(Effect.succeed(output(pullRequests(2, 2)))); | |
| 352 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 353 | ||
| 354 | const batch = yield* cli.listPullRequests({ | |
| 355 | cwd: "/w", | |
| 356 | repository: "web", | |
| 357 | state: "open", | |
| 358 | involvement: "all", | |
| 359 | viewer: "bilal@acme.dev", | |
| 360 | limit: 2, | |
| 361 | }); | |
| 362 | ||
| 363 | expect(batch.items.map((item) => item.number)).toEqual([1, 2]); | |
| 364 | assert.isTrue(batch.truncated); | |
| 365 | // Three raw rows from the first request and one from the second produced this page. | |
| 366 | assert.strictEqual(batch.cursorAdvance, 4); | |
| 367 | const secondArgs = argsOfCall(1); | |
| 368 | assert.strictEqual(secondArgs[secondArgs.indexOf("--skip") + 1], "3"); | |
| 369 | assert.strictEqual(secondArgs[secondArgs.indexOf("--top") + 1], "2"); | |
| 370 | }), | |
| 371 | ); | |
| 372 | ||
| 373 | it.effect("narrows to the author on the authored tab", () => | |
| 374 | Effect.gen(function* () { | |
| 375 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 376 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 377 | ||
| 378 | yield* cli.listPullRequests({ | |
| 379 | cwd: "/w", | |
| 380 | repository: "web", | |
| 381 | state: "closed", | |
| 382 | involvement: "authored", | |
| 383 | viewer: "bilal@acme.dev", | |
| 384 | limit: 10, | |
| 385 | }); | |
| 386 | ||
| 387 | expect(argsOfCall(0)).toContain("--creator"); | |
| 388 | expect(argsOfCall(0)).toContain("bilal@acme.dev"); | |
| 389 | // Azure calls a closed pull request abandoned. | |
| 390 | expect(argsOfCall(0)).toContain("abandoned"); | |
| 391 | }), | |
| 392 | ); | |
| 393 | ||
| 394 | it.effect("asks Azure for every status on the All tab", () => | |
| 395 | Effect.gen(function* () { | |
| 396 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 397 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 398 | ||
| 399 | yield* cli.listPullRequests({ | |
| 400 | cwd: "/w", | |
| 401 | repository: "web", | |
| 402 | state: "all", | |
| 403 | involvement: "all", | |
| 404 | viewer: "bilal@acme.dev", | |
| 405 | limit: 10, | |
| 406 | }); | |
| 407 | ||
| 408 | expect(argsOfCall(0)).toContain("--status"); | |
| 409 | expect(argsOfCall(0)).toContain("all"); | |
| 410 | }), | |
| 411 | ); | |
| 412 | ||
| 413 | it.effect("narrows to the reviewer on the reviewing tab", () => | |
| 414 | Effect.gen(function* () { | |
| 415 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 416 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 417 | ||
| 418 | yield* cli.listPullRequests({ | |
| 419 | cwd: "/w", | |
| 420 | repository: "web", | |
| 421 | state: "open", | |
| 422 | involvement: "reviewing", | |
| 423 | viewer: "bilal@acme.dev", | |
| 424 | limit: 10, | |
| 425 | }); | |
| 426 | ||
| 427 | expect(argsOfCall(0)).toContain("--reviewer"); | |
| 428 | }), | |
| 429 | ); | |
| 430 | ||
| 431 | it.effect("reads the signed-in account, which az reports as a bare value", () => | |
| 432 | Effect.gen(function* () { | |
| 433 | // `--query user` unwraps the object, so the wrapper has to put it back. | |
| 434 | mockedExecute.mockReturnValueOnce( | |
| 435 | Effect.succeed(output(JSON.stringify({ name: "bilal@acme.dev", type: "user" }))), | |
| 436 | ); | |
| 437 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 438 | ||
| 439 | const viewer = yield* cli.getViewer({ cwd: "/w" }); | |
| 440 | ||
| 441 | assert.strictEqual(viewer, "bilal@acme.dev"); | |
| 442 | expect(argsOfCall(0)).toEqual([ | |
| 443 | "account", | |
| 444 | "show", | |
| 445 | "--query", | |
| 446 | "user", | |
| 447 | "--only-show-errors", | |
| 448 | "--output", | |
| 449 | "json", | |
| 450 | ]); | |
| 451 | }), | |
| 452 | ); | |
| 453 | ||
| 454 | it.effect("fails when nobody is signed in", () => | |
| 455 | Effect.gen(function* () { | |
| 456 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); | |
| 457 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 458 | ||
| 459 | const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); | |
| 460 | ||
| 461 | assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError"); | |
| 462 | }), | |
| 463 | ); | |
| 464 | ||
| 465 | it.effect("completes a pull request to merge it, squashing only when asked", () => | |
| 466 | Effect.gen(function* () { | |
| 467 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 468 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 469 | ||
| 470 | yield* cli.runPullRequestAction({ | |
| 471 | cwd: "/w", | |
| 472 | number: 42, | |
| 473 | action: "merge", | |
| 474 | mergeMethod: "squash", | |
| 475 | }); | |
| 476 | ||
| 477 | expect(argsOfCall(0)).toEqual([ | |
| 478 | "repos", | |
| 479 | "pr", | |
| 480 | "update", | |
| 481 | "--detect", | |
| 482 | "true", | |
| 483 | "--id", | |
| 484 | "42", | |
| 485 | "--status", | |
| 486 | "completed", | |
| 487 | "--squash", | |
| 488 | "true", | |
| 489 | "--only-show-errors", | |
| 490 | "--output", | |
| 491 | "json", | |
| 492 | ]); | |
| 493 | }), | |
| 494 | ); | |
| 495 | ||
| 496 | it.effect("stores the squash choice with an auto-completion, as a merge now does", () => | |
| 497 | Effect.gen(function* () { | |
| 498 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 499 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 500 | ||
| 501 | yield* cli.runPullRequestAction({ | |
| 502 | cwd: "/w", | |
| 503 | number: 42, | |
| 504 | action: "enable-auto-merge", | |
| 505 | mergeMethod: "squash", | |
| 506 | }); | |
| 507 | ||
| 508 | expect(argsOfCall(0)).toEqual([ | |
| 509 | "repos", | |
| 510 | "pr", | |
| 511 | "update", | |
| 512 | "--detect", | |
| 513 | "true", | |
| 514 | "--id", | |
| 515 | "42", | |
| 516 | "--auto-complete", | |
| 517 | "true", | |
| 518 | "--squash", | |
| 519 | "true", | |
| 520 | "--only-show-errors", | |
| 521 | "--output", | |
| 522 | "json", | |
| 523 | ]); | |
| 524 | }), | |
| 525 | ); | |
| 526 | ||
| 527 | it.effect.each([ | |
| 528 | { action: "enable-auto-merge", expected: ["--auto-complete", "true"] }, | |
| 529 | { action: "disable-auto-merge", expected: ["--auto-complete", "false"] }, | |
| 530 | { action: "draft", expected: ["--draft", "true"] }, | |
| 531 | { action: "ready", expected: ["--draft", "false"] }, | |
| 532 | { action: "close", expected: ["--status", "abandoned"] }, | |
| 533 | { action: "reopen", expected: ["--status", "active"] }, | |
| 534 | ] as const)("moves a pull request with $action", ({ action, expected }) => | |
| 535 | Effect.gen(function* () { | |
| 536 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 537 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 538 | ||
| 539 | yield* cli.runPullRequestAction({ cwd: "/w", number: 42, action }); | |
| 540 | ||
| 541 | expect(argsOfCall(0)).toEqual([ | |
| 542 | "repos", | |
| 543 | "pr", | |
| 544 | "update", | |
| 545 | "--detect", | |
| 546 | "true", | |
| 547 | "--id", | |
| 548 | "42", | |
| 549 | ...expected, | |
| 550 | "--only-show-errors", | |
| 551 | "--output", | |
| 552 | "json", | |
| 553 | ]); | |
| 554 | }), | |
| 555 | ); | |
| 556 | ||
| 557 | it.effect.each([ | |
| 558 | { name: "a title", rewrite: { title: "Add the page" }, expected: ["--title=Add the page"] }, | |
| 559 | { | |
| 560 | name: "a description", | |
| 561 | rewrite: { body: "Why the page changed" }, | |
| 562 | expected: ["--description=Why the page changed"], | |
| 563 | }, | |
| 564 | { | |
| 565 | name: "both", | |
| 566 | rewrite: { title: "Add the page", body: "Why the page changed" }, | |
| 567 | expected: ["--title=Add the page", "--description=Why the page changed"], | |
| 568 | }, | |
| 569 | ] as const)("rewrites $name, sending nothing it was not given", ({ rewrite, expected }) => | |
| 570 | Effect.gen(function* () { | |
| 571 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 572 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 573 | ||
| 574 | yield* cli.updatePullRequest({ cwd: "/w", number: 42, ...rewrite }); | |
| 575 | ||
| 576 | expect(argsOfCall(0)).toEqual([ | |
| 577 | "repos", | |
| 578 | "pr", | |
| 579 | "update", | |
| 580 | "--detect", | |
| 581 | "true", | |
| 582 | "--id", | |
| 583 | "42", | |
| 584 | ...expected, | |
| 585 | "--only-show-errors", | |
| 586 | "--output", | |
| 587 | "json", | |
| 588 | ]); | |
| 589 | }), | |
| 590 | ); | |
| 591 | ||
| 592 | it.effect("sends a description that starts with a dash as one value, not as a flag", () => | |
| 593 | Effect.gen(function* () { | |
| 594 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 595 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 596 | ||
| 597 | yield* cli.updatePullRequest({ | |
| 598 | cwd: "/w", | |
| 599 | number: 42, | |
| 600 | body: "- rewrote the page\n- kept the rest", | |
| 601 | }); | |
| 602 | ||
| 603 | // One argument, so the leading dash of an ordinary bullet list never reaches az as a flag, | |
| 604 | // and the whole text stays together where `--description` would otherwise take several. | |
| 605 | expect(argsOfCall(0)).toContain("--description=- rewrote the page\n- kept the rest"); | |
| 606 | }), | |
| 607 | ); | |
| 608 | ||
| 609 | it.effect("rewrites through the provider, which says it takes one", () => | |
| 610 | Effect.gen(function* () { | |
| 611 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | |
| 612 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 613 | ||
| 614 | // False for a remark because nothing here can post one, so there is none to rewrite. | |
| 615 | expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: false }); | |
| 616 | assert.isDefined(provider.updateChangeRequest); | |
| 617 | yield* provider.updateChangeRequest({ | |
| 618 | cwd: "/w", | |
| 619 | repository: "web", | |
| 620 | host: "dev.azure.com", | |
| 621 | number: 42, | |
| 622 | title: "Add the page", | |
| 623 | }); | |
| 624 | ||
| 625 | expect(argsOfCall(0)).toContain("--title=Add the page"); | |
| 626 | expect(argsOfCall(0)).not.toContain("--description"); | |
| 627 | }), | |
| 628 | ); | |
| 629 | ||
| 630 | it.effect("names the head's blob as what a cleared file was cleared at", () => | |
| 631 | Effect.gen(function* () { | |
| 632 | mockedExecute | |
| 633 | .mockReturnValueOnce( | |
| 634 | Effect.succeed( | |
| 635 | output( | |
| 636 | JSON.stringify({ | |
| 637 | pullRequestId: 42, | |
| 638 | title: "Add the page", | |
| 639 | status: "active", | |
| 640 | sourceRefName: "refs/heads/feat/page", | |
| 641 | targetRefName: "refs/heads/main", | |
| 642 | creationDate: "2026-07-01T00:00:00Z", | |
| 643 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 644 | repository: { name: "web", project: { name: "platform" } }, | |
| 645 | }), | |
| 646 | ), | |
| 647 | ), | |
| 648 | ) | |
| 649 | .mockReturnValueOnce( | |
| 650 | Effect.succeed( | |
| 651 | output( | |
| 652 | JSON.stringify({ | |
| 653 | value: [ | |
| 654 | { | |
| 655 | id: 1, | |
| 656 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 657 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 658 | }, | |
| 659 | { | |
| 660 | id: 2, | |
| 661 | sourceRefCommit: { commitId: "c".repeat(40) }, | |
| 662 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 663 | }, | |
| 664 | ], | |
| 665 | }), | |
| 666 | ), | |
| 667 | ), | |
| 668 | ) | |
| 669 | .mockReturnValueOnce( | |
| 670 | Effect.succeed( | |
| 671 | output( | |
| 672 | JSON.stringify({ | |
| 673 | changeEntries: [ | |
| 674 | { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, | |
| 675 | { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, | |
| 676 | ], | |
| 677 | }), | |
| 678 | ), | |
| 679 | ), | |
| 680 | ); | |
| 681 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 682 | ||
| 683 | // Kept here rather than on Azure: its own record of what a reader has read is behind an | |
| 684 | // undocumented endpoint, so the marks belong to this environment and need a revision of | |
| 685 | // their own to tell a re-push from a file still as it was read. | |
| 686 | assert.strictEqual(provider.capabilities.viewedFiles, "environment"); | |
| 687 | assert.isDefined(provider.getFileRevisions); | |
| 688 | const answer = yield* provider.getFileRevisions({ | |
| 689 | cwd: "/w", | |
| 690 | repository: "web", | |
| 691 | host: "dev.azure.com", | |
| 692 | number: 42, | |
| 693 | paths: ["README.md"], | |
| 694 | }); | |
| 695 | ||
| 696 | // The latest push, since an iteration's changes are reported against the merge base rather | |
| 697 | // than against the push before it. | |
| 698 | expect(argsOfCall(2)).toContain("iterationId=2"); | |
| 699 | // Only what was asked for. DEMO.md changed too, and nobody has marked it. | |
| 700 | expect([...answer.revisions]).toEqual([["README.md", "8f80"]]); | |
| 701 | }), | |
| 702 | ); | |
| 703 | ||
| 704 | it.effect("reads where a pull request lives once, however often it is asked about", () => | |
| 705 | Effect.gen(function* () { | |
| 706 | // A pull request cannot move repositories, and the marks would otherwise pay for a whole | |
| 707 | // pull request read every time they checked whether a file had been pushed to. | |
| 708 | const pullRequest = Effect.succeed( | |
| 709 | output( | |
| 710 | JSON.stringify({ | |
| 711 | pullRequestId: 42, | |
| 712 | title: "Add the page", | |
| 713 | status: "active", | |
| 714 | sourceRefName: "refs/heads/feat/page", | |
| 715 | targetRefName: "refs/heads/main", | |
| 716 | creationDate: "2026-07-01T00:00:00Z", | |
| 717 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 718 | repository: { name: "web", project: { name: "platform" } }, | |
| 719 | }), | |
| 720 | ), | |
| 721 | ); | |
| 722 | const iterations = () => | |
| 723 | Effect.succeed( | |
| 724 | output( | |
| 725 | JSON.stringify({ | |
| 726 | value: [ | |
| 727 | { | |
| 728 | id: 1, | |
| 729 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 730 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 731 | }, | |
| 732 | ], | |
| 733 | }), | |
| 734 | ), | |
| 735 | ); | |
| 736 | const changes = () => | |
| 737 | Effect.succeed( | |
| 738 | output( | |
| 739 | JSON.stringify({ | |
| 740 | changeEntries: [ | |
| 741 | { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, | |
| 742 | ], | |
| 743 | }), | |
| 744 | ), | |
| 745 | ); | |
| 746 | mockedExecute | |
| 747 | .mockReturnValueOnce(pullRequest) | |
| 748 | .mockReturnValueOnce(iterations()) | |
| 749 | .mockReturnValueOnce(changes()) | |
| 750 | .mockReturnValueOnce(iterations()) | |
| 751 | .mockReturnValueOnce(changes()); | |
| 752 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 753 | const read = provider.getFileRevisions; | |
| 754 | assert.isDefined(read); | |
| 755 | const ask = () => | |
| 756 | read({ | |
| 757 | cwd: "/w", | |
| 758 | repository: "web", | |
| 759 | host: "dev.azure.com", | |
| 760 | number: 42, | |
| 761 | paths: ["README.md"], | |
| 762 | }); | |
| 763 | ||
| 764 | yield* ask(); | |
| 765 | const again = yield* ask(); | |
| 766 | ||
| 767 | assert.strictEqual(mockedExecute.mock.calls.length, 5); | |
| 768 | // The second read goes straight to the pushes, and still answers with the head's blob. | |
| 769 | expect(argsOfCall(3)).toContain("pullRequestIterations"); | |
| 770 | expect([...again.revisions]).toEqual([["README.md", "8f80"]]); | |
| 771 | }), | |
| 772 | ); | |
| 773 | ||
| 774 | it.effect( | |
| 775 | "answers for a marked file the pull request no longer changes as the empty version", | |
| 776 | () => | |
| 777 | Effect.gen(function* () { | |
| 778 | // Which is what was stored for it when it was ticked with nothing on the head, so a file | |
| 779 | // the pull request deletes is cleared once and stays cleared. | |
| 780 | mockedExecute | |
| 781 | .mockReturnValueOnce( | |
| 782 | Effect.succeed( | |
| 783 | output( | |
| 784 | json({ | |
| 785 | pullRequestId: 42, | |
| 786 | title: "Add the page", | |
| 787 | status: "active", | |
| 788 | sourceRefName: "refs/heads/feat/page", | |
| 789 | targetRefName: "refs/heads/main", | |
| 790 | creationDate: "2026-07-01T00:00:00Z", | |
| 791 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 792 | repository: { name: "web", project: { name: "platform" } }, | |
| 793 | }), | |
| 794 | ), | |
| 795 | ), | |
| 796 | ) | |
| 797 | .mockReturnValueOnce( | |
| 798 | Effect.succeed( | |
| 799 | output( | |
| 800 | json({ | |
| 801 | value: [ | |
| 802 | { | |
| 803 | id: 1, | |
| 804 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 805 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 806 | }, | |
| 807 | ], | |
| 808 | }), | |
| 809 | ), | |
| 810 | ), | |
| 811 | ) | |
| 812 | .mockReturnValueOnce(Effect.succeed(output('{"changeEntries":[]}'))); | |
| 813 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 814 | assert.isDefined(provider.getFileRevisions); | |
| 815 | ||
| 816 | const answer = yield* provider.getFileRevisions({ | |
| 817 | cwd: "/w", | |
| 818 | repository: "web", | |
| 819 | host: "dev.azure.com", | |
| 820 | number: 42, | |
| 821 | paths: ["GONE.md"], | |
| 822 | }); | |
| 823 | ||
| 824 | expect([...answer.revisions]).toEqual([["GONE.md", ""]]); | |
| 825 | }), | |
| 826 | ); | |
| 827 | ||
| 828 | it.effect("says nothing about the files past the end of a change it gave up following", () => | |
| 829 | Effect.gen(function* () { | |
| 830 | // Every page is an `az` process of its own, so a change past the ceiling stops being | |
| 831 | // followed. A path nobody looked at must not be answered for as deleted. | |
| 832 | const entries = (from: number, count: number) => | |
| 833 | Array.from({ length: count }, (_, index) => ({ | |
| 834 | changeType: "edit", | |
| 835 | item: { path: `/src/f${from + index}.ts`, objectId: `blob-${from + index}` }, | |
| 836 | })); | |
| 837 | mockedExecute | |
| 838 | .mockReturnValueOnce( | |
| 839 | Effect.succeed( | |
| 840 | output( | |
| 841 | json({ | |
| 842 | pullRequestId: 42, | |
| 843 | title: "Add the page", | |
| 844 | status: "active", | |
| 845 | sourceRefName: "refs/heads/feat/page", | |
| 846 | targetRefName: "refs/heads/main", | |
| 847 | creationDate: "2026-07-01T00:00:00Z", | |
| 848 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 849 | repository: { name: "web", project: { name: "platform" } }, | |
| 850 | }), | |
| 851 | ), | |
| 852 | ), | |
| 853 | ) | |
| 854 | .mockReturnValueOnce( | |
| 855 | Effect.succeed( | |
| 856 | output( | |
| 857 | json({ | |
| 858 | value: [ | |
| 859 | { | |
| 860 | id: 1, | |
| 861 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 862 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 863 | }, | |
| 864 | ], | |
| 865 | }), | |
| 866 | ), | |
| 867 | ), | |
| 868 | ) | |
| 869 | .mockReturnValueOnce( | |
| 870 | Effect.succeed(output(json({ changeEntries: entries(0, 5_000), nextSkip: 5_000 }))), | |
| 871 | ) | |
| 872 | .mockReturnValueOnce( | |
| 873 | Effect.succeed(output(json({ changeEntries: entries(5_000, 5_000), nextSkip: 10_000 }))), | |
| 874 | ); | |
| 875 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 876 | assert.isDefined(provider.getFileRevisions); | |
| 877 | ||
| 878 | const answer = yield* provider.getFileRevisions({ | |
| 879 | cwd: "/w", | |
| 880 | repository: "web", | |
| 881 | host: "dev.azure.com", | |
| 882 | number: 42, | |
| 883 | paths: ["src/f1.ts", "src/f5001.ts", "src/past-the-cut.ts"], | |
| 884 | }); | |
| 885 | ||
| 886 | // The second page picks up where the first said it ended. | |
| 887 | expect(argsOfCall(3)).toContain("$skip=5000"); | |
| 888 | expect([...answer.revisions]).toEqual([ | |
| 889 | ["src/f1.ts", "blob-1"], | |
| 890 | ["src/f5001.ts", "blob-5001"], | |
| 891 | ]); | |
| 892 | }), | |
| 893 | ); | |
| 894 | ||
| 895 | it.effect("stops following pages by what Azure counts, not by what survives decoding", () => | |
| 896 | Effect.gen(function* () { | |
| 897 | mockedExecute | |
| 898 | .mockReturnValueOnce( | |
| 899 | Effect.succeed( | |
| 900 | output( | |
| 901 | json({ | |
| 902 | pullRequestId: 42, | |
| 903 | title: "Add the page", | |
| 904 | status: "active", | |
| 905 | sourceRefName: "refs/heads/feat/page", | |
| 906 | targetRefName: "refs/heads/main", | |
| 907 | creationDate: "2026-07-01T00:00:00Z", | |
| 908 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 909 | repository: { name: "web", project: { name: "platform" } }, | |
| 910 | }), | |
| 911 | ), | |
| 912 | ), | |
| 913 | ) | |
| 914 | .mockReturnValueOnce( | |
| 915 | Effect.succeed( | |
| 916 | output( | |
| 917 | json({ | |
| 918 | value: [ | |
| 919 | { | |
| 920 | id: 1, | |
| 921 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 922 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 923 | }, | |
| 924 | ], | |
| 925 | }), | |
| 926 | ), | |
| 927 | ), | |
| 928 | ) | |
| 929 | // Nothing a review can show, so every page decodes to nothing at all and a ceiling counted | |
| 930 | // in files would never be reached however long the walk went on. | |
| 931 | .mockImplementation((command) => { | |
| 932 | const skip = command.args.find((arg) => arg.startsWith("$skip=")); | |
| 933 | const from = Number(skip?.slice("$skip=".length) ?? 0); | |
| 934 | return Effect.succeed( | |
| 935 | output( | |
| 936 | json({ | |
| 937 | changeEntries: [{ changeType: "add", item: { path: "/src", isFolder: true } }], | |
| 938 | nextSkip: from + 2_000, | |
| 939 | }), | |
| 940 | ), | |
| 941 | ); | |
| 942 | }); | |
| 943 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 944 | assert.isDefined(provider.getFileRevisions); | |
| 945 | ||
| 946 | const answer = yield* provider.getFileRevisions({ | |
| 947 | cwd: "/w", | |
| 948 | repository: "web", | |
| 949 | host: "dev.azure.com", | |
| 950 | number: 42, | |
| 951 | paths: ["src/page.ts"], | |
| 952 | }); | |
| 953 | ||
| 954 | // The pull request, its pushes, and five pages: the walk gives up on Azure's own offset | |
| 955 | // rather than spending an `az` process a page for as long as Azure keeps paging. | |
| 956 | assert.strictEqual(mockedExecute.mock.calls.length, 7); | |
| 957 | // And it read part of a change, so it says nothing about the file it never saw. | |
| 958 | assert.strictEqual(answer.revisions.size, 0); | |
| 959 | }), | |
| 960 | ); | |
| 961 | ||
| 962 | it.effect("leaves one file the host would not hand over listed without its hunks", () => | |
| 963 | Effect.gen(function* () { | |
| 964 | mockedExecute | |
| 965 | .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))) | |
| 966 | .mockReturnValueOnce(Effect.succeed(output(json(oneIteration)))) | |
| 967 | .mockReturnValueOnce( | |
| 968 | Effect.succeed( | |
| 969 | output( | |
| 970 | json({ | |
| 971 | changeEntries: [ | |
| 972 | { changeType: "add", item: { path: "/huge.bin", objectId: "8f80" } }, | |
| 973 | { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, | |
| 974 | ], | |
| 975 | }), | |
| 976 | ), | |
| 977 | ), | |
| 978 | ) | |
| 979 | .mockReturnValueOnce( | |
| 980 | Effect.fail( | |
| 981 | new AzureDevOpsCli.AzureDevOpsCommandFailedError({ | |
| 982 | operation: "execute", | |
| 983 | command: "az", | |
| 984 | cwd: "/w", | |
| 985 | argumentCount: 1, | |
| 986 | cause: "the blob is past what the route will carry", | |
| 987 | }), | |
| 988 | ), | |
| 989 | ) | |
| 990 | .mockReturnValueOnce(Effect.succeed(output(json({ content: "hello\n" })))); | |
| 991 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 992 | ||
| 993 | const slice = yield* provider.getDiff({ | |
| 994 | cwd: "/w", | |
| 995 | repository: "web", | |
| 996 | host: "dev.azure.com", | |
| 997 | number: 42, | |
| 998 | }); | |
| 999 | ||
| 1000 | assert.isTrue(slice.truncated); | |
| 1001 | expect(slice.patch).toContain("diff --git a/huge.bin b/huge.bin"); | |
| 1002 | // And the file behind it still renders, which is the point of giving up on one file. | |
| 1003 | expect(slice.patch).toContain("+hello"); | |
| 1004 | }), | |
| 1005 | ); | |
| 1006 | ||
| 1007 | it.effect("fails the whole read when it is the connection that would not answer", () => | |
| 1008 | Effect.gen(function* () { | |
| 1009 | // A rate limit is not this file's problem, and answering with a change full of files listed | |
| 1010 | // without their hunks would read as a change nobody can see rather than as a host to wait | |
| 1011 | // for. | |
| 1012 | mockedExecute | |
| 1013 | .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))) | |
| 1014 | .mockReturnValueOnce(Effect.succeed(output(json(oneIteration)))) | |
| 1015 | .mockReturnValueOnce( | |
| 1016 | Effect.succeed( | |
| 1017 | output( | |
| 1018 | json({ | |
| 1019 | changeEntries: [ | |
| 1020 | { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, | |
| 1021 | ], | |
| 1022 | }), | |
| 1023 | ), | |
| 1024 | ), | |
| 1025 | ) | |
| 1026 | .mockReturnValueOnce( | |
| 1027 | Effect.fail( | |
| 1028 | new AzureDevOpsCli.AzureDevOpsCliRateLimitError({ | |
| 1029 | operation: "execute", | |
| 1030 | command: "az", | |
| 1031 | cwd: "/w", | |
| 1032 | argumentCount: 1, | |
| 1033 | cause: "429", | |
| 1034 | }), | |
| 1035 | ), | |
| 1036 | ); | |
| 1037 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 1038 | ||
| 1039 | const error = yield* Effect.flip( | |
| 1040 | provider.getDiff({ cwd: "/w", repository: "web", host: "dev.azure.com", number: 42 }), | |
| 1041 | ); | |
| 1042 | ||
| 1043 | assert.strictEqual(error.reason, "rate-limited"); | |
| 1044 | }), | |
| 1045 | ); | |
| 1046 | ||
| 1047 | it.effect("takes Azure's own word on a file it will not spell out", () => | |
| 1048 | Effect.gen(function* () { | |
| 1049 | mockedExecute | |
| 1050 | .mockReturnValueOnce( | |
| 1051 | Effect.succeed( | |
| 1052 | output( | |
| 1053 | json({ | |
| 1054 | pullRequestId: 42, | |
| 1055 | title: "Add the page", | |
| 1056 | status: "active", | |
| 1057 | sourceRefName: "refs/heads/feat/page", | |
| 1058 | targetRefName: "refs/heads/main", | |
| 1059 | creationDate: "2026-07-01T00:00:00Z", | |
| 1060 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 1061 | repository: { name: "web", project: { name: "platform" } }, | |
| 1062 | }), | |
| 1063 | ), | |
| 1064 | ), | |
| 1065 | ) | |
| 1066 | .mockReturnValueOnce( | |
| 1067 | Effect.succeed( | |
| 1068 | output( | |
| 1069 | json({ | |
| 1070 | value: [ | |
| 1071 | { | |
| 1072 | id: 1, | |
| 1073 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 1074 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 1075 | }, | |
| 1076 | ], | |
| 1077 | }), | |
| 1078 | ), | |
| 1079 | ), | |
| 1080 | ) | |
| 1081 | .mockReturnValueOnce( | |
| 1082 | Effect.succeed( | |
| 1083 | output( | |
| 1084 | json({ | |
| 1085 | changeEntries: [ | |
| 1086 | { changeType: "add", item: { path: "/logo.png", objectId: "8f80" } }, | |
| 1087 | ], | |
| 1088 | }), | |
| 1089 | ), | |
| 1090 | ), | |
| 1091 | ) | |
| 1092 | .mockReturnValueOnce( | |
| 1093 | Effect.succeed( | |
| 1094 | output(json({ content: "iVBORw0KGgo=", contentMetadata: { isBinary: true } })), | |
| 1095 | ), | |
| 1096 | ); | |
| 1097 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 1098 | ||
| 1099 | const slice = yield* provider.getDiff({ | |
| 1100 | cwd: "/w", | |
| 1101 | repository: "web", | |
| 1102 | host: "dev.azure.com", | |
| 1103 | number: 42, | |
| 1104 | }); | |
| 1105 | ||
| 1106 | // Azure leaves the metadata out unless it is asked for, and without it every file reads as | |
| 1107 | // text however it was stored. | |
| 1108 | expect(argsOfCall(3)).toContain("includeContentMetadata=true"); | |
| 1109 | expect(slice.patch).toContain("Binary files a/logo.png and b/logo.png differ"); | |
| 1110 | assert.isTrue(slice.truncated); | |
| 1111 | }), | |
| 1112 | ); | |
| 1113 | ||
| 1114 | it.effect("stops following pages when one of them does not move the cursor on", () => | |
| 1115 | Effect.gen(function* () { | |
| 1116 | mockedExecute | |
| 1117 | .mockReturnValueOnce( | |
| 1118 | Effect.succeed( | |
| 1119 | output( | |
| 1120 | json({ | |
| 1121 | pullRequestId: 42, | |
| 1122 | title: "Add the page", | |
| 1123 | status: "active", | |
| 1124 | sourceRefName: "refs/heads/feat/page", | |
| 1125 | targetRefName: "refs/heads/main", | |
| 1126 | creationDate: "2026-07-01T00:00:00Z", | |
| 1127 | url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", | |
| 1128 | repository: { name: "web", project: { name: "platform" } }, | |
| 1129 | }), | |
| 1130 | ), | |
| 1131 | ), | |
| 1132 | ) | |
| 1133 | .mockReturnValueOnce( | |
| 1134 | Effect.succeed( | |
| 1135 | output( | |
| 1136 | json({ | |
| 1137 | value: [ | |
| 1138 | { | |
| 1139 | id: 1, | |
| 1140 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 1141 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 1142 | }, | |
| 1143 | ], | |
| 1144 | }), | |
| 1145 | ), | |
| 1146 | ), | |
| 1147 | ) | |
| 1148 | .mockReturnValueOnce( | |
| 1149 | Effect.succeed( | |
| 1150 | output( | |
| 1151 | json({ | |
| 1152 | changeEntries: [{ changeType: "edit", item: { path: "/a.ts", objectId: "8f80" } }], | |
| 1153 | nextSkip: 2_000, | |
| 1154 | }), | |
| 1155 | ), | |
| 1156 | ), | |
| 1157 | ) | |
| 1158 | .mockReturnValueOnce( | |
| 1159 | Effect.succeed( | |
| 1160 | output( | |
| 1161 | json({ | |
| 1162 | changeEntries: [{ changeType: "edit", item: { path: "/b.ts", objectId: "0ca4" } }], | |
| 1163 | nextSkip: 2_000, | |
| 1164 | }), | |
| 1165 | ), | |
| 1166 | ), | |
| 1167 | ); | |
| 1168 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 1169 | assert.isDefined(provider.getFileRevisions); | |
| 1170 | ||
| 1171 | const answer = yield* provider.getFileRevisions({ | |
| 1172 | cwd: "/w", | |
| 1173 | repository: "web", | |
| 1174 | host: "dev.azure.com", | |
| 1175 | number: 42, | |
| 1176 | paths: ["a.ts", "b.ts", "unlisted.ts"], | |
| 1177 | }); | |
| 1178 | ||
| 1179 | // Four reads and no more: a page pointing at where it already is would be read forever. | |
| 1180 | assert.strictEqual(mockedExecute.mock.calls.length, 4); | |
| 1181 | // And what was read is not the whole change, so the file nobody listed is left unanswered | |
| 1182 | // rather than reported as gone from the change request. | |
| 1183 | expect([...answer.revisions]).toEqual([ | |
| 1184 | ["a.ts", "8f80"], | |
| 1185 | ["b.ts", "0ca4"], | |
| 1186 | ]); | |
| 1187 | }), | |
| 1188 | ); | |
| 1189 | ||
| 1190 | it.effect("asks Azure nothing when no file has been ticked off", () => | |
| 1191 | Effect.gen(function* () { | |
| 1192 | mockedExecute.mockReturnValue(Effect.succeed(output('{"changeEntries":[]}'))); | |
| 1193 | const provider = yield* AzureDevOpsPullRequestProvider.make; | |
| 1194 | assert.isDefined(provider.getFileRevisions); | |
| 1195 | ||
| 1196 | const answer = yield* provider.getFileRevisions({ | |
| 1197 | cwd: "/w", | |
| 1198 | repository: "web", | |
| 1199 | host: "dev.azure.com", | |
| 1200 | number: 42, | |
| 1201 | paths: [], | |
| 1202 | }); | |
| 1203 | ||
| 1204 | expect(answer.revisions.size).toBe(0); | |
| 1205 | // Nothing was marked, so Azure was not asked at all. | |
| 1206 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 1207 | }), | |
| 1208 | ); | |
| 1209 | ||
| 1210 | it.effect("reads the conversation through the REST API, pinned to a version", () => | |
| 1211 | Effect.gen(function* () { | |
| 1212 | mockedExecute.mockReturnValueOnce( | |
| 1213 | Effect.succeed( | |
| 1214 | output( | |
| 1215 | JSON.stringify({ | |
| 1216 | value: [ | |
| 1217 | { | |
| 1218 | id: 1, | |
| 1219 | comments: [ | |
| 1220 | { id: 1, content: "Looks good.", publishedDate: "2026-07-02T00:00:00Z" }, | |
| 1221 | ], | |
| 1222 | }, | |
| 1223 | ], | |
| 1224 | }), | |
| 1225 | ), | |
| 1226 | ), | |
| 1227 | ); | |
| 1228 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1229 | ||
| 1230 | const comments = yield* cli.listThreads({ | |
| 1231 | cwd: "/w", | |
| 1232 | location: { project: "platform", repository: "web" }, | |
| 1233 | number: 42, | |
| 1234 | }); | |
| 1235 | ||
| 1236 | assert.strictEqual(comments.length, 1); | |
| 1237 | // `az devops invoke` rather than `az rest`: it signs in the way the azure-devops extension | |
| 1238 | // does, and `az rest` mints its own token against whichever tenant `az` defaults to. | |
| 1239 | expect(argsOfCall(0)).toContain("invoke"); | |
| 1240 | expect(argsOfCall(0)).toContain("pullRequestThreads"); | |
| 1241 | expect(argsOfCall(0)).toContain("project=platform"); | |
| 1242 | expect(argsOfCall(0)).toContain("repositoryId=web"); | |
| 1243 | expect(argsOfCall(0)).toContain("pullRequestId=42"); | |
| 1244 | // A review's threads grow with how long it ran, and this route does not page, so the read | |
| 1245 | // asks for more than the process default rather than taking whatever it is given. | |
| 1246 | expect(maxOutputBytesOfCall(0)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); | |
| 1247 | }), | |
| 1248 | ); | |
| 1249 | ||
| 1250 | it.effect("reads a long review's threads, which are past the default output limit", () => | |
| 1251 | Effect.gen(function* () { | |
| 1252 | const response = json({ value: threadRows(800) }); | |
| 1253 | // Azure opens a thread per vote and per ref update beside the ones people wrote, and every | |
| 1254 | // comment carries a full identity, so a review argued over for weeks outgrows the default. | |
| 1255 | expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); | |
| 1256 | mockedExecute.mockImplementationOnce((input) => | |
| 1257 | Effect.succeed(outputWithin(input.maxOutputBytes, response)), | |
| 1258 | ); | |
| 1259 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1260 | ||
| 1261 | const comments = yield* cli.listThreads({ | |
| 1262 | cwd: "/w", | |
| 1263 | location: { project: "platform", repository: "web" }, | |
| 1264 | number: 42, | |
| 1265 | }); | |
| 1266 | ||
| 1267 | assert.strictEqual(comments.length, 800); | |
| 1268 | }), | |
| 1269 | ); | |
| 1270 | ||
| 1271 | it.effect("reads a long review's iterations, which are past the default output limit", () => | |
| 1272 | Effect.gen(function* () { | |
| 1273 | const response = json({ value: iterationRows(1_200) }); | |
| 1274 | // This route does not page, so the whole history arrives at once. Cut at the default it is | |
| 1275 | // JSON stopping mid-string, and every diff and file revision read on this host fails with | |
| 1276 | // it, since each of them starts by asking which iteration is the latest. | |
| 1277 | expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); | |
| 1278 | mockedExecute.mockImplementationOnce((input) => | |
| 1279 | Effect.succeed(outputWithin(input.maxOutputBytes, response)), | |
| 1280 | ); | |
| 1281 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1282 | ||
| 1283 | const iterations = yield* cli.listIterations({ | |
| 1284 | cwd: "/w", | |
| 1285 | location: { project: "platform", repository: "web" }, | |
| 1286 | number: 42, | |
| 1287 | }); | |
| 1288 | ||
| 1289 | assert.strictEqual(iterations.length, 1_200); | |
| 1290 | assert.strictEqual(iterations.at(-1)?.id, 1_200); | |
| 1291 | }), | |
| 1292 | ); | |
| 1293 | ||
| 1294 | it.effect("reads a full page of change entries, which is past the default output limit", () => | |
| 1295 | Effect.gen(function* () { | |
| 1296 | const response = json({ changeEntries: changeEntries(2_000) }); | |
| 1297 | // Azure's own maximum for this route, and every entry carries a path, a url and three | |
| 1298 | // object ids, so an ordinary page of a large change already outgrows the default. | |
| 1299 | expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); | |
| 1300 | mockedExecute.mockImplementationOnce((input) => | |
| 1301 | Effect.succeed(outputWithin(input.maxOutputBytes, response)), | |
| 1302 | ); | |
| 1303 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1304 | ||
| 1305 | const page = yield* cli.listIterationChanges({ | |
| 1306 | cwd: "/w", | |
| 1307 | location: { project: "platform", repository: "web" }, | |
| 1308 | number: 42, | |
| 1309 | iterationId: 1, | |
| 1310 | }); | |
| 1311 | ||
| 1312 | // Cut at the default this would arrive as JSON stopping mid-string, and a perfectly | |
| 1313 | // ordinary page would be reported as a host answering with nonsense. | |
| 1314 | assert.strictEqual(page.changes.length, 2_000); | |
| 1315 | assert.isFalse(page.truncated); | |
| 1316 | }), | |
| 1317 | ); | |
| 1318 | ||
| 1319 | it.effect("reads a file whose JSON envelope is past the default output limit", () => | |
| 1320 | Effect.gen(function* () { | |
| 1321 | // Under a megabyte as bytes on the host, so this is a file the other hosts hand over. | |
| 1322 | const file = "const value = 1;\n".repeat(57_000); | |
| 1323 | const response = json({ content: file }); | |
| 1324 | expect(Buffer.byteLength(file)).toBeLessThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); | |
| 1325 | // And past it once Azure wraps it, because there is no route here that serves the bytes. | |
| 1326 | expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); | |
| 1327 | mockedExecute.mockImplementationOnce((input) => | |
| 1328 | Effect.succeed(outputWithin(input.maxOutputBytes, response)), | |
| 1329 | ); | |
| 1330 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1331 | ||
| 1332 | const item = yield* cli.readItemContent({ | |
| 1333 | cwd: "/w", | |
| 1334 | location: { project: "platform", repository: "web" }, | |
| 1335 | path: "src/generated/schema.ts", | |
| 1336 | commit: "a".repeat(40), | |
| 1337 | }); | |
| 1338 | ||
| 1339 | assert.strictEqual(item.contents, file); | |
| 1340 | assert.isFalse(item.isBinary); | |
| 1341 | }), | |
| 1342 | ); | |
| 1343 | ||
| 1344 | it.effect("asks for a file by Azure's own spelling of its path", () => | |
| 1345 | Effect.gen(function* () { | |
| 1346 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(json({ content: "const a = 1;" })))); | |
| 1347 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1348 | ||
| 1349 | // The path carried around here has had Azure's leading slash taken off so it matches the | |
| 1350 | // patch and the viewed mark. The items route is documented in Azure's spelling, so it goes | |
| 1351 | // back on the way out rather than being sent as the shorter name. | |
| 1352 | yield* cli.readItemContent({ | |
| 1353 | cwd: "/w", | |
| 1354 | location: { project: "platform", repository: "web" }, | |
| 1355 | path: "src/app.ts", | |
| 1356 | commit: "a".repeat(40), | |
| 1357 | }); | |
| 1358 | ||
| 1359 | expect(argsOfCall(0)).toContain("path=/src/app.ts"); | |
| 1360 | }), | |
| 1361 | ); | |
| 1362 | ||
| 1363 | it.effect("reports a pull request it cannot place as its own outcome", () => | |
| 1364 | Effect.gen(function* () { | |
| 1365 | mockedExecute.mockReturnValueOnce( | |
| 1366 | Effect.succeed( | |
| 1367 | output( | |
| 1368 | // Well-formed, but with nothing to build a link from: not a decode failure. | |
| 1369 | JSON.stringify({ | |
| 1370 | pullRequestId: 42, | |
| 1371 | title: "Add the page", | |
| 1372 | sourceRefName: "refs/heads/feat/page", | |
| 1373 | targetRefName: "refs/heads/main", | |
| 1374 | creationDate: "2026-07-01T00:00:00Z", | |
| 1375 | }), | |
| 1376 | ), | |
| 1377 | ), | |
| 1378 | ); | |
| 1379 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1380 | ||
| 1381 | const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 })); | |
| 1382 | ||
| 1383 | assert.strictEqual(error._tag, "AzureDevOpsPullRequestIncompleteError"); | |
| 1384 | }), | |
| 1385 | ); | |
| 1386 | ||
| 1387 | it.effect("fails the read when az returns something unreadable", () => | |
| 1388 | Effect.gen(function* () { | |
| 1389 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); | |
| 1390 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1391 | ||
| 1392 | const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 })); | |
| 1393 | ||
| 1394 | assert.strictEqual(error._tag, "AzureDevOpsPullRequestReadError"); | |
| 1395 | }), | |
| 1396 | ); | |
| 1397 | ||
| 1398 | it.effect("adds reviewers with the one command Azure has for it", () => | |
| 1399 | Effect.gen(function* () { | |
| 1400 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 1401 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1402 | ||
| 1403 | yield* cli.setPullRequestReviewers({ | |
| 1404 | cwd: "/w", | |
| 1405 | number: 42, | |
| 1406 | reviewers: ["octocat@acme.test", "hubot@acme.test"], | |
| 1407 | requested: true, | |
| 1408 | }); | |
| 1409 | ||
| 1410 | expect(argsOfCall(0)).toEqual([ | |
| 1411 | "repos", | |
| 1412 | "pr", | |
| 1413 | "reviewer", | |
| 1414 | "add", | |
| 1415 | "--detect", | |
| 1416 | "true", | |
| 1417 | "--id", | |
| 1418 | "42", | |
| 1419 | "--reviewers", | |
| 1420 | "octocat@acme.test", | |
| 1421 | "hubot@acme.test", | |
| 1422 | "--only-show-errors", | |
| 1423 | "--output", | |
| 1424 | "json", | |
| 1425 | ]); | |
| 1426 | }), | |
| 1427 | ); | |
| 1428 | ||
| 1429 | it.effect("takes a reviewer off the pull request with the same command's counterpart", () => | |
| 1430 | Effect.gen(function* () { | |
| 1431 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 1432 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1433 | ||
| 1434 | yield* cli.setPullRequestReviewers({ | |
| 1435 | cwd: "/w", | |
| 1436 | number: 42, | |
| 1437 | reviewers: ["octocat@acme.test"], | |
| 1438 | requested: false, | |
| 1439 | }); | |
| 1440 | ||
| 1441 | expect(argsOfCall(0)).toContain("remove"); | |
| 1442 | }), | |
| 1443 | ); | |
| 1444 | ||
| 1445 | it.effect("refuses a reviewer az would read as a flag, before running anything", () => | |
| 1446 | Effect.gen(function* () { | |
| 1447 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 1448 | ||
| 1449 | const error = yield* Effect.flip( | |
| 1450 | cli.setPullRequestReviewers({ | |
| 1451 | cwd: "/w", | |
| 1452 | number: 42, | |
| 1453 | reviewers: ["--query"], | |
| 1454 | requested: true, | |
| 1455 | }), | |
| 1456 | ); | |
| 1457 | ||
| 1458 | assert.strictEqual(error._tag, "AzureDevOpsReviewerNameError"); | |
| 1459 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | |
| 1460 | }), | |
| 1461 | ); | |
| 1462 | }); |
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts created+744| ... | ... | @@ -0,0 +1,744 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Result from "effect/Result"; | |
| 5 | import * as Schema from "effect/Schema"; | |
| 6 | import type { | |
| 7 | PullRequestAction, | |
| 8 | PullRequestComment, | |
| 9 | PullRequestInvolvement, | |
| 10 | PullRequestListState, | |
| 11 | PullRequestMergeMethod, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | ||
| 14 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | |
| 15 | import { | |
| 16 | decodeItemContentJson, | |
| 17 | decodeIterationChangesJson, | |
| 18 | decodeIterationsJson, | |
| 19 | decodePullRequestJson, | |
| 20 | decodePullRequestListJson, | |
| 21 | decodeThreadsJson, | |
| 22 | decodeViewerJson, | |
| 23 | type AzureDevOpsChangeEntry, | |
| 24 | type AzureDevOpsItemContent, | |
| 25 | type AzureDevOpsIteration, | |
| 26 | type AzureDevOpsPullRequest, | |
| 27 | type AzureDevOpsRepositoryLocation, | |
| 28 | } from "./azureDevOpsPullRequestJson.ts"; | |
| 29 | import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 30 | ||
| 31 | /** | |
| 32 | * Names the read that produced unusable output, so a failure reports the call it came from | |
| 33 | * rather than borrowing another operation's message. | |
| 34 | */ | |
| 35 | export class AzureDevOpsPullRequestReadError extends Schema.TaggedError<AzureDevOpsPullRequestReadError>()( | |
| 36 | "AzureDevOpsPullRequestReadError", | |
| 37 | { | |
| 38 | command: Schema.Literal("az"), | |
| 39 | cwd: Schema.String, | |
| 40 | operation: Schema.String, | |
| 41 | cause: Schema.Defect(), | |
| 42 | }, | |
| 43 | ) { | |
| 44 | get detail(): string { | |
| 45 | return `Azure CLI returned an unreadable ${this.operation} response.`; | |
| 46 | } | |
| 47 | ||
| 48 | override get message(): string { | |
| 49 | return `Azure CLI failed in ${this.operation}: ${this.detail}`; | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | /** Not a decode failure: az answered, the account it answered for just has no name. */ | |
| 54 | export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError<AzureDevOpsViewerUnavailableError>()( | |
| 55 | "AzureDevOpsViewerUnavailableError", | |
| 56 | { | |
| 57 | command: Schema.Literal("az"), | |
| 58 | cwd: Schema.String, | |
| 59 | }, | |
| 60 | ) { | |
| 61 | get detail(): string { | |
| 62 | return "Azure CLI returned no account for the current sign-in."; | |
| 63 | } | |
| 64 | ||
| 65 | override get message(): string { | |
| 66 | return `Azure CLI failed in getViewer: ${this.detail}`; | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * Not a decode failure either: az answered with a well-formed pull request that simply carries | |
| 72 | * no branch or link, which is a response this cannot place rather than one it cannot read. | |
| 73 | */ | |
| 74 | export class AzureDevOpsPullRequestIncompleteError extends Schema.TaggedError<AzureDevOpsPullRequestIncompleteError>()( | |
| 75 | "AzureDevOpsPullRequestIncompleteError", | |
| 76 | { | |
| 77 | command: Schema.Literal("az"), | |
| 78 | cwd: Schema.String, | |
| 79 | number: Schema.Int, | |
| 80 | }, | |
| 81 | ) { | |
| 82 | get detail(): string { | |
| 83 | return "Azure DevOps returned no branch or link for the pull request."; | |
| 84 | } | |
| 85 | ||
| 86 | override get message(): string { | |
| 87 | return `Azure CLI failed in getPullRequest: ${this.detail}`; | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * Not a decode failure: the reader named a reviewer `az` would read as a flag of its own. The | |
| 93 | * reviewers travel as argv rather than in a request body — `az repos pr reviewer` takes them no | |
| 94 | * other way — so anything that could leave the value position is refused rather than sent. | |
| 95 | */ | |
| 96 | export class AzureDevOpsReviewerNameError extends Schema.TaggedError<AzureDevOpsReviewerNameError>()( | |
| 97 | "AzureDevOpsReviewerNameError", | |
| 98 | { | |
| 99 | command: Schema.Literal("az"), | |
| 100 | cwd: Schema.String, | |
| 101 | }, | |
| 102 | ) { | |
| 103 | get detail(): string { | |
| 104 | return "A reviewer is named by an email address or an identity id."; | |
| 105 | } | |
| 106 | ||
| 107 | override get message(): string { | |
| 108 | return `Azure CLI failed in setPullRequestReviewers: ${this.detail}`; | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | export type AzureDevOpsPullRequestCliError = | |
| 113 | | AzureDevOpsCli.AzureDevOpsCliError | |
| 114 | | AzureDevOpsPullRequestReadError | |
| 115 | | AzureDevOpsPullRequestIncompleteError | |
| 116 | | AzureDevOpsReviewerNameError | |
| 117 | | AzureDevOpsViewerUnavailableError; | |
| 118 | ||
| 119 | /** The version every REST call below is pinned to, so a new default cannot reshape a response. */ | |
| 120 | const REST_API_VERSION = "7.1"; | |
| 121 | const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; | |
| 122 | /** | |
| 123 | * A full page of change entries is two thousand files, each carrying its path, its url and | |
| 124 | * several object ids, which is past the megabyte a read is given by default. Output cut at that | |
| 125 | * ceiling arrives here as JSON that will not parse, so a change large enough to be paged would | |
| 126 | * report itself as a host returning nonsense rather than as the ordinary page it is. | |
| 127 | */ | |
| 128 | const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | |
| 129 | /** | |
| 130 | * Four times the megabyte of file the other hosts hand over, because Azure has no route that | |
| 131 | * serves the bytes themselves: the file arrives inside a JSON envelope, escaped if it is text and | |
| 132 | * base64 if it is not, and both are larger than the file they carry. | |
| 133 | */ | |
| 134 | const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; | |
| 135 | /** | |
| 136 | * What a review's own history is given. Neither route pages, so each answers with the whole of it | |
| 137 | * at once and grows with how long the review ran rather than with how large the change is. Cut at | |
| 138 | * the default, both arrive as JSON that stops mid-string, and a long review would report its host | |
| 139 | * as answering with nonsense. | |
| 140 | */ | |
| 141 | const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | |
| 142 | ||
| 143 | /** Azure's own ceiling for one page of an iteration's changes. */ | |
| 144 | const CHANGE_ENTRIES_PER_PAGE = 2000; | |
| 145 | ||
| 146 | /** | |
| 147 | * Where following the pages stops, counted in the entries Azure was asked to skip rather than in | |
| 148 | * the files that survived decoding: a page can be entirely folders and other entries a review has | |
| 149 | * nothing to show for, and bounding on what was kept would follow such a change forever. Every | |
| 150 | * page is an `az` process of its own, so the read gives up and reports itself incomplete rather | |
| 151 | * than presenting five pages as the whole change. | |
| 152 | */ | |
| 153 | const MAX_CHANGE_ENTRIES = 10_000; | |
| 154 | ||
| 155 | /** What an iteration changed, and whether following its pages reached the end of it. */ | |
| 156 | export interface AzureDevOpsIterationChanges { | |
| 157 | readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>; | |
| 158 | readonly truncated: boolean; | |
| 159 | } | |
| 160 | ||
| 161 | export class AzureDevOpsPullRequestCli extends Context.Service< | |
| 162 | AzureDevOpsPullRequestCli, | |
| 163 | { | |
| 164 | readonly getViewer: (input: { | |
| 165 | readonly cwd: string; | |
| 166 | }) => Effect.Effect<string, AzureDevOpsPullRequestCliError>; | |
| 167 | ||
| 168 | readonly listPullRequests: (input: { | |
| 169 | readonly cwd: string; | |
| 170 | readonly repository: string; | |
| 171 | readonly state: PullRequestListState; | |
| 172 | readonly involvement: PullRequestInvolvement; | |
| 173 | readonly viewer: string; | |
| 174 | readonly limit: number; | |
| 175 | /** | |
| 176 | * Where to carry on from. Azure has no date filter for a pull request listing, so the only | |
| 177 | * part of a cursor it can use is how many rows have already been handed over. | |
| 178 | */ | |
| 179 | readonly cursor?: ProviderListCursor | undefined; | |
| 180 | }) => Effect.Effect< | |
| 181 | { | |
| 182 | readonly items: ReadonlyArray<AzureDevOpsPullRequest>; | |
| 183 | readonly truncated: boolean; | |
| 184 | /** Raw Azure rows consumed to produce this page, including malformed rows. */ | |
| 185 | readonly cursorAdvance: number; | |
| 186 | }, | |
| 187 | AzureDevOpsPullRequestCliError | |
| 188 | >; | |
| 189 | ||
| 190 | readonly getPullRequest: (input: { | |
| 191 | readonly cwd: string; | |
| 192 | readonly number: number; | |
| 193 | }) => Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError>; | |
| 194 | ||
| 195 | /** Threads are not reachable through `az repos pr`, so they come from the REST API. */ | |
| 196 | readonly listThreads: (input: { | |
| 197 | readonly cwd: string; | |
| 198 | readonly location: AzureDevOpsRepositoryLocation; | |
| 199 | readonly number: number; | |
| 200 | }) => Effect.Effect<ReadonlyArray<PullRequestComment>, AzureDevOpsPullRequestCliError>; | |
| 201 | ||
| 202 | /** | |
| 203 | * The pushes a pull request has had, oldest first. Azure hangs the changed files off an | |
| 204 | * iteration rather than off the pull request, so reading a diff starts here. | |
| 205 | */ | |
| 206 | readonly listIterations: (input: { | |
| 207 | readonly cwd: string; | |
| 208 | readonly location: AzureDevOpsRepositoryLocation; | |
| 209 | readonly number: number; | |
| 210 | }) => Effect.Effect<ReadonlyArray<AzureDevOpsIteration>, AzureDevOpsPullRequestCliError>; | |
| 211 | ||
| 212 | /** | |
| 213 | * What one iteration changed, against the merge base rather than against the previous push, | |
| 214 | * which is the whole of the pull request rather than the latest slice of it. | |
| 215 | */ | |
| 216 | readonly listIterationChanges: (input: { | |
| 217 | readonly cwd: string; | |
| 218 | readonly location: AzureDevOpsRepositoryLocation; | |
| 219 | readonly number: number; | |
| 220 | readonly iterationId: number; | |
| 221 | }) => Effect.Effect<AzureDevOpsIterationChanges, AzureDevOpsPullRequestCliError>; | |
| 222 | ||
| 223 | /** | |
| 224 | * One file's text at one commit. Azure has no diff route that carries content, so both sides | |
| 225 | * of every changed file are read this way and the patch is made from them here. | |
| 226 | */ | |
| 227 | readonly readItemContent: (input: { | |
| 228 | readonly cwd: string; | |
| 229 | readonly location: AzureDevOpsRepositoryLocation; | |
| 230 | readonly path: string; | |
| 231 | readonly commit: string; | |
| 232 | }) => Effect.Effect<AzureDevOpsItemContent, AzureDevOpsPullRequestCliError>; | |
| 233 | ||
| 234 | readonly runPullRequestAction: (input: { | |
| 235 | readonly cwd: string; | |
| 236 | readonly number: number; | |
| 237 | readonly action: PullRequestAction; | |
| 238 | readonly mergeMethod?: PullRequestMergeMethod; | |
| 239 | }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>; | |
| 240 | ||
| 241 | /** Rewrites the pull request's own words, through the same command that moves it. */ | |
| 242 | readonly updatePullRequest: (input: { | |
| 243 | readonly cwd: string; | |
| 244 | readonly number: number; | |
| 245 | readonly title?: string | undefined; | |
| 246 | readonly body?: string | undefined; | |
| 247 | }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>; | |
| 248 | ||
| 249 | /** | |
| 250 | * Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole | |
| 251 | * of what Azure offers here: it adds and removes named identities, and has no counterpart that | |
| 252 | * says who could be named. | |
| 253 | */ | |
| 254 | readonly setPullRequestReviewers: (input: { | |
| 255 | readonly cwd: string; | |
| 256 | readonly number: number; | |
| 257 | readonly reviewers: ReadonlyArray<string>; | |
| 258 | readonly requested: boolean; | |
| 259 | }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>; | |
| 260 | } | |
| 261 | >()("@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli") {} | |
| 262 | ||
| 263 | function statusArgs(state: PullRequestListState): ReadonlyArray<string> { | |
| 264 | switch (state) { | |
| 265 | case "open": | |
| 266 | return ["--status", "active"]; | |
| 267 | case "merged": | |
| 268 | return ["--status", "completed"]; | |
| 269 | case "closed": | |
| 270 | return ["--status", "abandoned"]; | |
| 271 | case "all": | |
| 272 | return ["--status", "all"]; | |
| 273 | } | |
| 274 | } | |
| 275 | ||
| 276 | function involvementArgs(input: { | |
| 277 | readonly involvement: PullRequestInvolvement; | |
| 278 | readonly viewer: string; | |
| 279 | }): ReadonlyArray<string> { | |
| 280 | switch (input.involvement) { | |
| 281 | case "authored": | |
| 282 | return ["--creator", input.viewer]; | |
| 283 | case "reviewing": | |
| 284 | return ["--reviewer", input.viewer]; | |
| 285 | case "all": | |
| 286 | return []; | |
| 287 | } | |
| 288 | } | |
| 289 | ||
| 290 | /** | |
| 291 | * Azure moves a pull request by setting its state rather than by named commands: completing it | |
| 292 | * is the merge, abandoning it is the close, and reactivating it is the reopen. Squashing is a | |
| 293 | * completion option rather than a strategy of its own. | |
| 294 | */ | |
| 295 | function actionArgs( | |
| 296 | action: PullRequestAction, | |
| 297 | mergeMethod: PullRequestMergeMethod | undefined, | |
| 298 | ): ReadonlyArray<string> { | |
| 299 | switch (action) { | |
| 300 | case "merge": | |
| 301 | return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"]; | |
| 302 | // Auto-complete is Azure's own name for it: the pull request stays active and Azure completes | |
| 303 | // it once its policies pass. The squash choice is stored with it, as it is for a merge now. | |
| 304 | case "enable-auto-merge": | |
| 305 | return [ | |
| 306 | "--auto-complete", | |
| 307 | "true", | |
| 308 | ...(mergeMethod === undefined | |
| 309 | ? [] | |
| 310 | : ["--squash", mergeMethod === "squash" ? "true" : "false"]), | |
| 311 | ]; | |
| 312 | case "disable-auto-merge": | |
| 313 | return ["--auto-complete", "false"]; | |
| 314 | case "ready": | |
| 315 | return ["--draft", "false"]; | |
| 316 | case "draft": | |
| 317 | return ["--draft", "true"]; | |
| 318 | case "close": | |
| 319 | return ["--status", "abandoned"]; | |
| 320 | // Never reached: this host does not declare the action, so nothing offers it. | |
| 321 | case "update-branch": | |
| 322 | return []; | |
| 323 | case "reopen": | |
| 324 | return ["--status", "active"]; | |
| 325 | // Never reached: this host does not declare the action, so the service refuses it first. | |
| 326 | case "revert": | |
| 327 | case "approve-workflows": | |
| 328 | throw new Error(`Azure DevOps pull request action ${action} is unsupported`); | |
| 329 | } | |
| 330 | } | |
| 331 | ||
| 332 | /** | |
| 333 | * A reviewer Azure could be given: an email address, a display name or an identity guid, and | |
| 334 | * nothing that starts with a dash. The dash is the whole point — these are argv, and a value that | |
| 335 | * looks like a flag stops being a value. | |
| 336 | */ | |
| 337 | function isReviewerName(value: string): boolean { | |
| 338 | const name = value.trim(); | |
| 339 | return name.length > 0 && !name.startsWith("-"); | |
| 340 | } | |
| 341 | ||
| 342 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 343 | export const make = Effect.gen(function* () { | |
| 344 | const azure = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 345 | ||
| 346 | // Every command resolves the organization, project and repository from the checkout, which is | |
| 347 | // what the rest of the Azure wrapper does. The remote takes three shapes and only `az` knows | |
| 348 | // how to read all of them. | |
| 349 | const detectArgs = ["--detect", "true"] as const; | |
| 350 | ||
| 351 | const executeJson = (input: { | |
| 352 | readonly cwd: string; | |
| 353 | readonly args: ReadonlyArray<string>; | |
| 354 | readonly maxOutputBytes?: number; | |
| 355 | }) => | |
| 356 | azure.execute({ | |
| 357 | cwd: input.cwd, | |
| 358 | args: [...input.args, "--only-show-errors", "--output", "json"], | |
| 359 | ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), | |
| 360 | }); | |
| 361 | ||
| 362 | /** | |
| 363 | * A REST route reached through `az devops invoke`, which addresses it by area, resource and | |
| 364 | * route parameters rather than by URL. Used in place of `az rest` because it signs in the way | |
| 365 | * the azure-devops extension does: `az rest` mints its own token against the tenant `az` | |
| 366 | * defaults to, and an organisation in any other tenant answers that with a sign-in page, which | |
| 367 | * arrives here as unreadable output rather than as a failure. | |
| 368 | */ | |
| 369 | const invoke = <A>(input: { | |
| 370 | readonly cwd: string; | |
| 371 | readonly operation: string; | |
| 372 | readonly resource: string; | |
| 373 | readonly routeParameters: ReadonlyArray<string>; | |
| 374 | readonly queryParameters?: ReadonlyArray<string>; | |
| 375 | readonly maxOutputBytes?: number; | |
| 376 | readonly decode: (raw: string) => Result.Result<A, unknown>; | |
| 377 | }): Effect.Effect<A, AzureDevOpsPullRequestCliError> => | |
| 378 | executeJson({ | |
| 379 | cwd: input.cwd, | |
| 380 | ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), | |
| 381 | args: [ | |
| 382 | "devops", | |
| 383 | "invoke", | |
| 384 | ...detectArgs, | |
| 385 | "--area", | |
| 386 | "git", | |
| 387 | "--resource", | |
| 388 | input.resource, | |
| 389 | "--api-version", | |
| 390 | REST_API_VERSION, | |
| 391 | "--route-parameters", | |
| 392 | ...input.routeParameters, | |
| 393 | ...(input.queryParameters === undefined | |
| 394 | ? [] | |
| 395 | : ["--query-parameters", ...input.queryParameters]), | |
| 396 | ], | |
| 397 | }).pipe( | |
| 398 | Effect.flatMap((result) => { | |
| 399 | const decoded = input.decode(result.stdout.trim()); | |
| 400 | return Result.isSuccess(decoded) | |
| 401 | ? Effect.succeed(decoded.success) | |
| 402 | : Effect.fail( | |
| 403 | new AzureDevOpsPullRequestReadError({ | |
| 404 | command: "az", | |
| 405 | cwd: input.cwd, | |
| 406 | operation: input.operation, | |
| 407 | cause: decoded.failure, | |
| 408 | }), | |
| 409 | ); | |
| 410 | }), | |
| 411 | ); | |
| 412 | ||
| 413 | /** | |
| 414 | * Azure names its own items with a leading slash, which the repository paths carried around | |
| 415 | * here have had taken off so they match the patch and the viewed mark. Put it back on the way | |
| 416 | * out, because the items route is documented in Azure's own spelling. | |
| 417 | */ | |
| 418 | const toItemPath = (path: string) => (path.startsWith("/") ? path : `/${path}`); | |
| 419 | ||
| 420 | const repositoryRoute = (location: AzureDevOpsRepositoryLocation): ReadonlyArray<string> => [ | |
| 421 | `project=${location.project}`, | |
| 422 | `repositoryId=${location.repository}`, | |
| 423 | ]; | |
| 424 | ||
| 425 | const pullRequestRoute = (input: { | |
| 426 | readonly location: AzureDevOpsRepositoryLocation; | |
| 427 | readonly number: number; | |
| 428 | }): ReadonlyArray<string> => [ | |
| 429 | ...repositoryRoute(input.location), | |
| 430 | `pullRequestId=${input.number}`, | |
| 431 | ]; | |
| 432 | ||
| 433 | /** | |
| 434 | * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and | |
| 435 | * retain the raw count so the next public cursor skips every row this walk consumed. | |
| 436 | */ | |
| 437 | const listPullRequestPage = (input: { | |
| 438 | readonly cwd: string; | |
| 439 | readonly repository: string; | |
| 440 | readonly state: PullRequestListState; | |
| 441 | readonly involvement: PullRequestInvolvement; | |
| 442 | readonly viewer: string; | |
| 443 | readonly limit: number; | |
| 444 | readonly skip: number; | |
| 445 | readonly cursorAdvance: number; | |
| 446 | readonly items: ReadonlyArray<AzureDevOpsPullRequest>; | |
| 447 | }): Effect.Effect< | |
| 448 | { | |
| 449 | readonly items: ReadonlyArray<AzureDevOpsPullRequest>; | |
| 450 | readonly truncated: boolean; | |
| 451 | readonly cursorAdvance: number; | |
| 452 | }, | |
| 453 | AzureDevOpsPullRequestCliError | |
| 454 | > => { | |
| 455 | const remaining = input.limit - input.items.length; | |
| 456 | const top = remaining + 1; | |
| 457 | return executeJson({ | |
| 458 | cwd: input.cwd, | |
| 459 | maxOutputBytes: PULL_REQUEST_LIST_MAX_OUTPUT_BYTES, | |
| 460 | args: [ | |
| 461 | "repos", | |
| 462 | "pr", | |
| 463 | "list", | |
| 464 | ...detectArgs, | |
| 465 | "--repository", | |
| 466 | input.repository, | |
| 467 | ...statusArgs(input.state), | |
| 468 | ...involvementArgs(input), | |
| 469 | // A web link per row, which is the only url that needs no assembling. | |
| 470 | "--include-links", | |
| 471 | ...(input.skip === 0 ? [] : ["--skip", String(input.skip)]), | |
| 472 | "--top", | |
| 473 | String(top), | |
| 474 | ], | |
| 475 | }).pipe( | |
| 476 | Effect.flatMap((result) => { | |
| 477 | const raw = result.stdout.trim(); | |
| 478 | if (raw.length === 0) { | |
| 479 | return Effect.succeed({ | |
| 480 | items: input.items, | |
| 481 | truncated: false, | |
| 482 | cursorAdvance: input.cursorAdvance, | |
| 483 | }); | |
| 484 | } | |
| 485 | const decoded = decodePullRequestListJson(raw); | |
| 486 | if (!Result.isSuccess(decoded)) { | |
| 487 | return Effect.fail( | |
| 488 | new AzureDevOpsPullRequestReadError({ | |
| 489 | command: "az", | |
| 490 | cwd: input.cwd, | |
| 491 | operation: "listPullRequests", | |
| 492 | cause: decoded.failure, | |
| 493 | }), | |
| 494 | ); | |
| 495 | } | |
| 496 | ||
| 497 | const lastItemIndex = decoded.success.rawIndexes[remaining - 1]; | |
| 498 | if (lastItemIndex !== undefined) { | |
| 499 | const consumed = lastItemIndex + 1; | |
| 500 | return Effect.succeed({ | |
| 501 | items: [...input.items, ...decoded.success.items.slice(0, remaining)], | |
| 502 | // A full raw response may have more rows even when malformed entries used the probe. | |
| 503 | truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top, | |
| 504 | cursorAdvance: input.cursorAdvance + consumed, | |
| 505 | }); | |
| 506 | } | |
| 507 | ||
| 508 | const items = [...input.items, ...decoded.success.items]; | |
| 509 | if (decoded.success.rawCount < top) { | |
| 510 | return Effect.succeed({ | |
| 511 | items, | |
| 512 | truncated: false, | |
| 513 | cursorAdvance: input.cursorAdvance + decoded.success.rawCount, | |
| 514 | }); | |
| 515 | } | |
| 516 | return listPullRequestPage({ | |
| 517 | ...input, | |
| 518 | skip: input.skip + decoded.success.rawCount, | |
| 519 | cursorAdvance: input.cursorAdvance + decoded.success.rawCount, | |
| 520 | items, | |
| 521 | }); | |
| 522 | }), | |
| 523 | ); | |
| 524 | }; | |
| 525 | ||
| 526 | return AzureDevOpsPullRequestCli.of({ | |
| 527 | getViewer: (input) => | |
| 528 | executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe( | |
| 529 | Effect.flatMap((result): Effect.Effect<string, AzureDevOpsPullRequestCliError> => { | |
| 530 | // `--query user` narrows the payload to the account, so it is nested back under the | |
| 531 | // key the decoder reads to keep one shape for the signed-in user. | |
| 532 | const decoded = decodeViewerJson(`{"user":${result.stdout.trim() || "null"}}`); | |
| 533 | if (!Result.isSuccess(decoded)) { | |
| 534 | return Effect.fail( | |
| 535 | new AzureDevOpsPullRequestReadError({ | |
| 536 | command: "az", | |
| 537 | cwd: input.cwd, | |
| 538 | operation: "getViewer", | |
| 539 | cause: decoded.failure, | |
| 540 | }), | |
| 541 | ); | |
| 542 | } | |
| 543 | return decoded.success === null | |
| 544 | ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd })) | |
| 545 | : Effect.succeed(decoded.success); | |
| 546 | }), | |
| 547 | ), | |
| 548 | ||
| 549 | listPullRequests: (input) => | |
| 550 | listPullRequestPage({ | |
| 551 | cwd: input.cwd, | |
| 552 | repository: input.repository, | |
| 553 | state: input.state, | |
| 554 | involvement: input.involvement, | |
| 555 | viewer: input.viewer, | |
| 556 | limit: input.limit, | |
| 557 | // Azure counts rather than filters, so a slice carries on by stepping over every raw row | |
| 558 | // the prior slice consumed. That is an offset into a list that can shift underneath it: | |
| 559 | // a pull request opened between two slices moves everything down one, and the row on the | |
| 560 | // seam is the one that pays for it. | |
| 561 | skip: input.cursor?.delivered ?? 0, | |
| 562 | cursorAdvance: 0, | |
| 563 | items: [], | |
| 564 | }), | |
| 565 | ||
| 566 | getPullRequest: (input) => | |
| 567 | executeJson({ | |
| 568 | cwd: input.cwd, | |
| 569 | args: ["repos", "pr", "show", ...detectArgs, "--id", String(input.number)], | |
| 570 | }).pipe( | |
| 571 | Effect.flatMap( | |
| 572 | (result): Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError> => { | |
| 573 | const decoded = decodePullRequestJson(result.stdout.trim()); | |
| 574 | if (!Result.isSuccess(decoded)) { | |
| 575 | return Effect.fail( | |
| 576 | new AzureDevOpsPullRequestReadError({ | |
| 577 | command: "az", | |
| 578 | cwd: input.cwd, | |
| 579 | operation: "getPullRequest", | |
| 580 | cause: decoded.failure, | |
| 581 | }), | |
| 582 | ); | |
| 583 | } | |
| 584 | // Null means Azure answered with too little to place the pull request. Nothing | |
| 585 | // failed underneath it, so it is its own outcome rather than a decode failure. | |
| 586 | return decoded.success === null | |
| 587 | ? Effect.fail( | |
| 588 | new AzureDevOpsPullRequestIncompleteError({ | |
| 589 | command: "az", | |
| 590 | cwd: input.cwd, | |
| 591 | number: input.number, | |
| 592 | }), | |
| 593 | ) | |
| 594 | : Effect.succeed(decoded.success); | |
| 595 | }, | |
| 596 | ), | |
| 597 | ), | |
| 598 | ||
| 599 | listThreads: (input) => | |
| 600 | invoke({ | |
| 601 | cwd: input.cwd, | |
| 602 | operation: "listThreads", | |
| 603 | resource: "pullRequestThreads", | |
| 604 | routeParameters: pullRequestRoute(input), | |
| 605 | maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, | |
| 606 | decode: decodeThreadsJson, | |
| 607 | }), | |
| 608 | ||
| 609 | listIterations: (input) => | |
| 610 | invoke({ | |
| 611 | cwd: input.cwd, | |
| 612 | operation: "listIterations", | |
| 613 | resource: "pullRequestIterations", | |
| 614 | routeParameters: pullRequestRoute(input), | |
| 615 | maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, | |
| 616 | decode: decodeIterationsJson, | |
| 617 | }), | |
| 618 | ||
| 619 | listIterationChanges: (input) => { | |
| 620 | const page = (skip: number) => | |
| 621 | invoke({ | |
| 622 | cwd: input.cwd, | |
| 623 | operation: "listIterationChanges", | |
| 624 | resource: "pullRequestIterationChanges", | |
| 625 | routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`], | |
| 626 | // Azure pages this route at 1000 entries by default; this is its own maximum per page, | |
| 627 | // and it names where the next page starts rather than answering with the whole change. | |
| 628 | queryParameters: [`$top=${CHANGE_ENTRIES_PER_PAGE}`, `$skip=${skip}`], | |
| 629 | maxOutputBytes: CHANGE_ENTRIES_MAX_OUTPUT_BYTES, | |
| 630 | decode: decodeIterationChangesJson, | |
| 631 | }); | |
| 632 | const from = ( | |
| 633 | skip: number, | |
| 634 | collected: ReadonlyArray<AzureDevOpsChangeEntry>, | |
| 635 | ): Effect.Effect<AzureDevOpsIterationChanges, AzureDevOpsPullRequestCliError> => | |
| 636 | page(skip).pipe( | |
| 637 | Effect.flatMap((answer) => { | |
| 638 | const changes = [...collected, ...answer.changes]; | |
| 639 | // The last page names no page after it, and only that is the end of the change. | |
| 640 | if (answer.nextSkip === null) return Effect.succeed({ changes, truncated: false }); | |
| 641 | // A page pointing at where the read already is would be followed forever, and one | |
| 642 | // past the ceiling is a change nobody will read to the end of. Both stop the read | |
| 643 | // and both say so, rather than presenting part of a change as the whole of it. | |
| 644 | return answer.nextSkip <= skip || answer.nextSkip >= MAX_CHANGE_ENTRIES | |
| 645 | ? Effect.succeed({ changes, truncated: true }) | |
| 646 | : from(answer.nextSkip, changes); | |
| 647 | }), | |
| 648 | ); | |
| 649 | return from(0, []); | |
| 650 | }, | |
| 651 | ||
| 652 | readItemContent: (input) => | |
| 653 | invoke({ | |
| 654 | cwd: input.cwd, | |
| 655 | operation: "readItemContent", | |
| 656 | resource: "items", | |
| 657 | routeParameters: repositoryRoute(input.location), | |
| 658 | queryParameters: [ | |
| 659 | `path=${toItemPath(input.path)}`, | |
| 660 | "versionDescriptor.versionType=commit", | |
| 661 | `versionDescriptor.version=${input.commit}`, | |
| 662 | "includeContent=true", | |
| 663 | // Azure leaves `contentMetadata` out unless this is asked for, and with it goes its own | |
| 664 | // word on whether the file is binary, which is the only reliable one, since a binary | |
| 665 | // file arrives encoded rather than as the bytes it is on the host. | |
| 666 | "includeContentMetadata=true", | |
| 667 | // Without this Azure answers with the file's own bytes rather than with a JSON | |
| 668 | // envelope, and `az devops invoke` refuses anything it cannot parse as JSON. | |
| 669 | "$format=json", | |
| 670 | ], | |
| 671 | maxOutputBytes: ITEM_CONTENT_MAX_OUTPUT_BYTES, | |
| 672 | decode: decodeItemContentJson, | |
| 673 | }), | |
| 674 | ||
| 675 | setPullRequestReviewers: (input) => | |
| 676 | input.reviewers.some((reviewer) => !isReviewerName(reviewer)) | |
| 677 | ? Effect.fail(new AzureDevOpsReviewerNameError({ command: "az", cwd: input.cwd })) | |
| 678 | : azure | |
| 679 | .execute({ | |
| 680 | cwd: input.cwd, | |
| 681 | args: [ | |
| 682 | "repos", | |
| 683 | "pr", | |
| 684 | "reviewer", | |
| 685 | input.requested ? "add" : "remove", | |
| 686 | ...detectArgs, | |
| 687 | "--id", | |
| 688 | String(input.number), | |
| 689 | // One `--reviewers` takes them all, because az reads the flag as a list and a | |
| 690 | // second one would replace the first rather than add to it. | |
| 691 | "--reviewers", | |
| 692 | ...input.reviewers, | |
| 693 | "--only-show-errors", | |
| 694 | "--output", | |
| 695 | "json", | |
| 696 | ], | |
| 697 | }) | |
| 698 | .pipe(Effect.asVoid), | |
| 699 | ||
| 700 | runPullRequestAction: (input) => | |
| 701 | azure | |
| 702 | .execute({ | |
| 703 | cwd: input.cwd, | |
| 704 | args: [ | |
| 705 | "repos", | |
| 706 | "pr", | |
| 707 | "update", | |
| 708 | ...detectArgs, | |
| 709 | "--id", | |
| 710 | String(input.number), | |
| 711 | ...actionArgs(input.action, input.mergeMethod), | |
| 712 | "--only-show-errors", | |
| 713 | "--output", | |
| 714 | "json", | |
| 715 | ], | |
| 716 | }) | |
| 717 | .pipe(Effect.asVoid), | |
| 718 | ||
| 719 | updatePullRequest: (input) => | |
| 720 | azure | |
| 721 | .execute({ | |
| 722 | cwd: input.cwd, | |
| 723 | args: [ | |
| 724 | "repos", | |
| 725 | "pr", | |
| 726 | "update", | |
| 727 | ...detectArgs, | |
| 728 | "--id", | |
| 729 | String(input.number), | |
| 730 | // One argument rather than a flag and a value beside it: a description usually opens | |
| 731 | // with a bullet, and az reads a dash in the next argv slot as a flag of its own. | |
| 732 | // `--description` also takes several strings, and this keeps the whole text as one. | |
| 733 | ...(input.title === undefined ? [] : [`--title=${input.title}`]), | |
| 734 | ...(input.body === undefined ? [] : [`--description=${input.body}`]), | |
| 735 | "--only-show-errors", | |
| 736 | "--output", | |
| 737 | "json", | |
| 738 | ], | |
| 739 | }) | |
| 740 | .pipe(Effect.asVoid), | |
| 741 | }); | |
| 742 | }); | |
| 743 | ||
| 744 | export const layer = Layer.effect(AzureDevOpsPullRequestCli, make); |
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts created+420| ... | ... | @@ -0,0 +1,420 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | ||
| 5 | import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; | |
| 6 | import { | |
| 7 | LOCATION_CACHE_CAPACITY, | |
| 8 | make, | |
| 9 | MAX_DIFF_SPAWNS, | |
| 10 | } from "./AzureDevOpsPullRequestProvider.ts"; | |
| 11 | import { | |
| 12 | byteLength, | |
| 13 | MAX_DIFF_SLICE_BYTES, | |
| 14 | MAX_DIFF_SLICE_FILES, | |
| 15 | MAX_FILE_DIFF_EDITS, | |
| 16 | parseAzureDevOpsDiffCursor, | |
| 17 | } from "./azureDevOpsDiff.ts"; | |
| 18 | import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; | |
| 19 | ||
| 20 | const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" }; | |
| 21 | ||
| 22 | const PULL_REQUEST = { | |
| 23 | number: 7, | |
| 24 | title: "Pull request 7", | |
| 25 | url: "https://dev.azure.com/acme/web/_git/web/pullrequest/7", | |
| 26 | author: null, | |
| 27 | headBranch: "feat/page", | |
| 28 | baseBranch: "main", | |
| 29 | state: "open" as const, | |
| 30 | isDraft: false, | |
| 31 | mergeability: "mergeable" as const, | |
| 32 | createdAt: "2026-07-01T00:00:00Z", | |
| 33 | updatedAt: "2026-07-02T00:00:00Z", | |
| 34 | closedAt: null, | |
| 35 | body: "", | |
| 36 | reviewRequestLogins: [], | |
| 37 | reviewers: [], | |
| 38 | location: { project: "acme", repository: "web" }, | |
| 39 | autoMergeEnabled: false, | |
| 40 | }; | |
| 41 | ||
| 42 | function change( | |
| 43 | path: string, | |
| 44 | changeKind: AzureDevOpsChangeEntry["changeKind"] = "change", | |
| 45 | ): AzureDevOpsChangeEntry { | |
| 46 | return { path, oldPath: path, changeKind, objectId: "8f80", originalObjectId: "0ca4" }; | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * A file whose two sides share no line, so its patch is `lines` removals and `lines` additions of | |
| 51 | * `width` characters each: the diff work and the patch bytes one file costs are both dialled from | |
| 52 | * here, and they are what a slice is bounded by. Each line carries its own number and a prefix | |
| 53 | * as well, so `width` is a floor on how long a line is rather than its byte count. | |
| 54 | */ | |
| 55 | function side(prefix: string, lines: number, width: number): string { | |
| 56 | const pad = "z".repeat(width); | |
| 57 | return `${Array.from({ length: lines }, (_, line) => `${prefix} ${line} ${pad}`).join("\n")}\n`; | |
| 58 | } | |
| 59 | ||
| 60 | const readSlice = (input: { | |
| 61 | readonly paths: ReadonlyArray<string>; | |
| 62 | readonly lines: number; | |
| 63 | readonly width: number; | |
| 64 | /** Paths the host refuses, which is one file's problem rather than the read's. */ | |
| 65 | readonly refused?: ReadonlyArray<string>; | |
| 66 | /** Paths the change creates, so the host has nothing to hand back for their old side. */ | |
| 67 | readonly created?: ReadonlyArray<string>; | |
| 68 | readonly cursor?: string; | |
| 69 | }) => | |
| 70 | Effect.gen(function* () { | |
| 71 | const reads: string[] = []; | |
| 72 | const refused = new Set(input.refused ?? []); | |
| 73 | const created = new Set(input.created ?? []); | |
| 74 | let inFlight = 0; | |
| 75 | let peakInFlight = 0; | |
| 76 | ||
| 77 | const provider = yield* make.pipe( | |
| 78 | Effect.provide( | |
| 79 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ | |
| 80 | getPullRequest: () => Effect.succeed(PULL_REQUEST), | |
| 81 | listIterations: () => Effect.succeed([ITERATION]), | |
| 82 | listIterationChanges: () => | |
| 83 | Effect.succeed({ | |
| 84 | changes: input.paths.map((path) => | |
| 85 | change(path, created.has(path) ? "new" : "change"), | |
| 86 | ), | |
| 87 | truncated: false, | |
| 88 | }), | |
| 89 | readItemContent: (item) => | |
| 90 | Effect.gen(function* () { | |
| 91 | reads.push(item.path); | |
| 92 | inFlight += 1; | |
| 93 | peakInFlight = Math.max(peakInFlight, inFlight); | |
| 94 | // Every read suspends before it answers, as a subprocess would, so what runs at | |
| 95 | // once is the scheduler's answer rather than an artefact of resolving inline. The | |
| 96 | // later a file is listed the sooner it answers, to leave the assembled patch | |
| 97 | // nothing but the change list to take its order from. | |
| 98 | const answersAfter = input.paths.length - input.paths.indexOf(item.path); | |
| 99 | for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow; | |
| 100 | inFlight -= 1; | |
| 101 | if (refused.has(item.path)) { | |
| 102 | return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({ | |
| 103 | command: "az", | |
| 104 | cwd: "/w", | |
| 105 | operation: "readItemContent", | |
| 106 | cause: "refused", | |
| 107 | }); | |
| 108 | } | |
| 109 | const isOldSide = item.commit !== ITERATION.headCommit; | |
| 110 | if (isOldSide && created.has(item.path)) return { contents: "", isBinary: false }; | |
| 111 | return { | |
| 112 | contents: side(isOldSide ? "old" : "new", input.lines, input.width), | |
| 113 | isBinary: false, | |
| 114 | }; | |
| 115 | }), | |
| 116 | }), | |
| 117 | ), | |
| 118 | ); | |
| 119 | ||
| 120 | const slice = yield* provider.getDiff({ | |
| 121 | cwd: "/w", | |
| 122 | repository: "acme/web", | |
| 123 | host: "dev.azure.com", | |
| 124 | number: 7, | |
| 125 | ...(input.cursor === undefined ? {} : { cursor: input.cursor }), | |
| 126 | }); | |
| 127 | return { slice, reads, peakInFlight }; | |
| 128 | }); | |
| 129 | ||
| 130 | /** Which files the patch carries a section for, in the order it carries them. */ | |
| 131 | function patchedPaths(patch: string): ReadonlyArray<string> { | |
| 132 | return [...patch.matchAll(/^diff --git a\/(?<path>\S+) b\//gmu)].map( | |
| 133 | (match) => match.groups?.path ?? "", | |
| 134 | ); | |
| 135 | } | |
| 136 | ||
| 137 | describe("getChangeRequestSummary", () => { | |
| 138 | it.effect("costs the one pull request read, not the iterations changedFiles needs", () => | |
| 139 | Effect.gen(function* () { | |
| 140 | let pullRequestReads = 0; | |
| 141 | ||
| 142 | const provider = yield* make.pipe( | |
| 143 | Effect.provide( | |
| 144 | // listIterations and listIterationChanges are left unimplemented here, so a summary | |
| 145 | // that reached for either would die with UnimplementedError instead of this passing. | |
| 146 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ | |
| 147 | getPullRequest: () => { | |
| 148 | pullRequestReads += 1; | |
| 149 | return Effect.succeed(PULL_REQUEST); | |
| 150 | }, | |
| 151 | }), | |
| 152 | ), | |
| 153 | ); | |
| 154 | ||
| 155 | const readSummary = provider.getChangeRequestSummary; | |
| 156 | if (readSummary === undefined) return yield* Effect.die("summary read was not implemented"); | |
| 157 | const summary = yield* readSummary({ | |
| 158 | cwd: "/w", | |
| 159 | repository: "acme/web", | |
| 160 | host: "dev.azure.com", | |
| 161 | number: 7, | |
| 162 | }); | |
| 163 | ||
| 164 | expect(pullRequestReads).toBe(1); | |
| 165 | expect(summary.title).toBe(PULL_REQUEST.title); | |
| 166 | expect(summary.changedFiles).toBeUndefined(); | |
| 167 | }), | |
| 168 | ); | |
| 169 | }); | |
| 170 | ||
| 171 | describe("getChangeRequest", () => { | |
| 172 | it.effect("still reports the file count the detail panel needs", () => | |
| 173 | Effect.gen(function* () { | |
| 174 | const provider = yield* make.pipe( | |
| 175 | Effect.provide( | |
| 176 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ | |
| 177 | getPullRequest: () => Effect.succeed(PULL_REQUEST), | |
| 178 | listIterations: () => Effect.succeed([ITERATION]), | |
| 179 | listIterationChanges: () => | |
| 180 | Effect.succeed({ changes: [change("a.ts"), change("b.ts")], truncated: false }), | |
| 181 | }), | |
| 182 | ), | |
| 183 | ); | |
| 184 | ||
| 185 | const detail = yield* provider.getChangeRequest({ | |
| 186 | cwd: "/w", | |
| 187 | repository: "acme/web", | |
| 188 | host: "dev.azure.com", | |
| 189 | number: 7, | |
| 190 | }); | |
| 191 | ||
| 192 | expect(detail.changedFiles).toBe(2); | |
| 193 | }), | |
| 194 | ); | |
| 195 | }); | |
| 196 | ||
| 197 | describe("getDiff reads", () => { | |
| 198 | it.effect("holds every reader together to one request's worth of processes", () => | |
| 199 | Effect.gen(function* () { | |
| 200 | // The fan-out inside a read bounds one Code tab. Two people opening two Azure reviews at | |
| 201 | // once are two reads, so without a ceiling above them both they are twice a request's | |
| 202 | // processes, each paying a Python interpreter's start-up on the same machine. | |
| 203 | const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; | |
| 204 | let inFlight = 0; | |
| 205 | let peakInFlight = 0; | |
| 206 | ||
| 207 | const provider = yield* make.pipe( | |
| 208 | Effect.provide( | |
| 209 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ | |
| 210 | getPullRequest: () => Effect.succeed(PULL_REQUEST), | |
| 211 | listIterations: () => Effect.succeed([ITERATION]), | |
| 212 | listIterationChanges: () => | |
| 213 | Effect.succeed({ changes: paths.map((path) => change(path)), truncated: false }), | |
| 214 | readItemContent: () => | |
| 215 | Effect.gen(function* () { | |
| 216 | inFlight += 1; | |
| 217 | peakInFlight = Math.max(peakInFlight, inFlight); | |
| 218 | // Suspends before answering, as a subprocess would, so what is out at once is the | |
| 219 | // scheduler's answer rather than an artefact of resolving inline. | |
| 220 | yield* Effect.yieldNow; | |
| 221 | yield* Effect.yieldNow; | |
| 222 | inFlight -= 1; | |
| 223 | return { contents: side("new", 2, 4), isBinary: false }; | |
| 224 | }), | |
| 225 | }), | |
| 226 | ), | |
| 227 | ); | |
| 228 | ||
| 229 | const readDiff = (number: number) => | |
| 230 | provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number }); | |
| 231 | ||
| 232 | yield* Effect.all([readDiff(7), readDiff(8)], { concurrency: 2 }); | |
| 233 | ||
| 234 | expect(peakInFlight).toBeLessThanOrEqual(MAX_DIFF_SPAWNS); | |
| 235 | }), | |
| 236 | ); | |
| 237 | ||
| 238 | it.effect("leaves a run of files it could not diff at all for the next slice", () => | |
| 239 | Effect.gen(function* () { | |
| 240 | // A binary, oversize, purely renamed or unreadable entry is a header apiece, a couple of | |
| 241 | // hundred bytes with no edits in it, so a change made of them spends neither budget: the | |
| 242 | // byte one would take well over a thousand of them, and the edit one never fills at all. | |
| 243 | // A listing holds up to ten thousand entries and each still costs its two reads, which is | |
| 244 | // what a file count is here to bound. | |
| 245 | const paths = Array.from({ length: MAX_DIFF_SLICE_FILES + 20 }, (_, at) => `gen/a${at}.bin`); | |
| 246 | const read = yield* readSlice({ paths, lines: 2, width: 4, refused: paths }); | |
| 247 | ||
| 248 | expect(patchedPaths(read.slice.patch)).toHaveLength(MAX_DIFF_SLICE_FILES); | |
| 249 | // Well inside the byte budget, so the file count is what stopped it rather than either of | |
| 250 | // the budgets that were already there. | |
| 251 | expect(byteLength(read.slice.patch)).toBeLessThan(MAX_DIFF_SLICE_BYTES); | |
| 252 | expect(parseAzureDevOpsDiffCursor(read.slice.nextCursor)?.fileIndex).toBe( | |
| 253 | MAX_DIFF_SLICE_FILES, | |
| 254 | ); | |
| 255 | }), | |
| 256 | ); | |
| 257 | ||
| 258 | it.effect("asks for both sides of several files at once rather than one side at a time", () => | |
| 259 | Effect.gen(function* () { | |
| 260 | // Each file is two `az` invocations, each paying a Python interpreter's start-up, so a | |
| 261 | // slice read one side after another is most of what the Code tab waits for. | |
| 262 | const read = yield* readSlice({ | |
| 263 | paths: ["a.ts", "b.ts", "c.ts", "d.ts"], | |
| 264 | lines: 2, | |
| 265 | width: 4, | |
| 266 | }); | |
| 267 | ||
| 268 | expect(read.peakInFlight).toBeGreaterThan(2); | |
| 269 | }), | |
| 270 | ); | |
| 271 | ||
| 272 | it.effect("holds the number of files it reads at once down", () => | |
| 273 | Effect.gen(function* () { | |
| 274 | // The host throttles, and `az` is a process on the same machine the reader runs agents on, | |
| 275 | // so a long change is read in batches rather than all at once. | |
| 276 | const paths = Array.from({ length: 24 }, (_, file) => `file-${file}.ts`); | |
| 277 | const read = yield* readSlice({ paths, lines: 2, width: 4 }); | |
| 278 | ||
| 279 | expect(read.reads).toHaveLength(paths.length * 2); | |
| 280 | expect(read.peakInFlight).toBeLessThanOrEqual(8); | |
| 281 | }), | |
| 282 | ); | |
| 283 | ||
| 284 | it.effect("keeps the patch in the order the change was listed, whoever answered first", () => | |
| 285 | Effect.gen(function* () { | |
| 286 | const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"]; | |
| 287 | const read = yield* readSlice({ paths, lines: 2, width: 4 }); | |
| 288 | ||
| 289 | expect(patchedPaths(read.slice.patch)).toEqual(paths); | |
| 290 | expect(read.slice.nextCursor).toBeNull(); | |
| 291 | }), | |
| 292 | ); | |
| 293 | ||
| 294 | it.effect("leaves a file the host refused listed without its hunks, in its place", () => | |
| 295 | Effect.gen(function* () { | |
| 296 | const paths = ["a.ts", "b.ts", "c.ts"]; | |
| 297 | const read = yield* readSlice({ paths, lines: 2, width: 4, refused: ["b.ts"] }); | |
| 298 | ||
| 299 | expect(patchedPaths(read.slice.patch)).toEqual(paths); | |
| 300 | expect(read.slice.truncated).toBe(true); | |
| 301 | // Its section ends at its header, and the files around it still carry their hunks. | |
| 302 | expect(read.slice.patch).toContain("+++ b/b.ts\ndiff --git a/c.ts"); | |
| 303 | expect(read.slice.patch.match(/^@@ /gmu)).toHaveLength(2); | |
| 304 | }), | |
| 305 | ); | |
| 306 | }); | |
| 307 | ||
| 308 | describe("what one diff slice spends", () => { | |
| 309 | it.effect("stops on the byte ceiling without carrying what it read past it", () => | |
| 310 | Effect.gen(function* () { | |
| 311 | // Two of these fill the slice, and the batch they were read in reached two files further. | |
| 312 | // Those two belong to the next slice: carrying them would put the request past a ceiling | |
| 313 | // that is there to bound what one answer weighs. | |
| 314 | const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; | |
| 315 | const read = yield* readSlice({ paths, lines: 100, width: 900 }); | |
| 316 | ||
| 317 | expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES); | |
| 318 | expect(patchedPaths(read.slice.patch)).toEqual(["a.ts", "b.ts"]); | |
| 319 | expect(read.slice.nextCursor).toBe(`${ITERATION.id}:2`); | |
| 320 | expect(new Set(read.reads)).toEqual(new Set(["a.ts", "b.ts", "c.ts", "d.ts"])); | |
| 321 | }), | |
| 322 | ); | |
| 323 | ||
| 324 | it.effect("narrows what it reads at once as the slice fills", () => | |
| 325 | Effect.gen(function* () { | |
| 326 | // Four files fit inside the budget and the fifth is past half of what is left of it, so | |
| 327 | // reading four more would throw most of them away and read them again next slice. Every | |
| 328 | // file is two `az` invocations, so the batch is judged against what the files before it | |
| 329 | // weighed rather than left at its full width to the last file. | |
| 330 | const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts", "g.ts", "h.ts"]; | |
| 331 | const read = yield* readSlice({ paths, lines: 50, width: 450 }); | |
| 332 | ||
| 333 | expect(read.slice.nextCursor).not.toBeNull(); | |
| 334 | expect(new Set(read.reads)).toEqual(new Set(patchedPaths(read.slice.patch))); | |
| 335 | }), | |
| 336 | ); | |
| 337 | ||
| 338 | it.effect("stops once the diff work one request may do is spent", () => | |
| 339 | Effect.gen(function* () { | |
| 340 | // Short lines are cheap on the wire and dear to diff, so the byte ceiling alone would let | |
| 341 | // one request hold the thread through a dozen of them. Each of these is half of what one | |
| 342 | // file is allowed, and the slice ends while there is still room for another. | |
| 343 | const paths = Array.from({ length: 12 }, (_, file) => `file-${file}.ts`); | |
| 344 | const read = yield* readSlice({ paths, lines: MAX_FILE_DIFF_EDITS / 4, width: 1 }); | |
| 345 | ||
| 346 | expect(read.slice.patch.length).toBeLessThan(MAX_DIFF_SLICE_BYTES); | |
| 347 | expect(patchedPaths(read.slice.patch)).toEqual(paths.slice(0, 5)); | |
| 348 | expect(read.slice.nextCursor).toBe(`${ITERATION.id}:5`); | |
| 349 | }), | |
| 350 | ); | |
| 351 | ||
| 352 | it.effect("carries a whole new file and stops the slice on what it weighed", () => | |
| 353 | Effect.gen(function* () { | |
| 354 | // A creation has no edit distance to search out, so no edit bound applies to it and its | |
| 355 | // section is the whole file. What keeps a run of them from filling one answer is the bytes | |
| 356 | // they weighed, which the slice has to be charged for. | |
| 357 | const paths = ["new.ts", "b.ts", "c.ts", "d.ts"]; | |
| 358 | const read = yield* readSlice({ paths, lines: 8_000, width: 30, created: ["new.ts"] }); | |
| 359 | ||
| 360 | expect(patchedPaths(read.slice.patch)).toEqual(["new.ts"]); | |
| 361 | expect(read.slice.patch).toContain("--- /dev/null"); | |
| 362 | expect(read.slice.patch).toContain("@@ -0,0 +1,8000 @@"); | |
| 363 | expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES); | |
| 364 | expect(read.slice.nextCursor).toBe(`${ITERATION.id}:1`); | |
| 365 | }), | |
| 366 | ); | |
| 367 | ||
| 368 | it.effect("carries on from where the last slice stopped", () => | |
| 369 | Effect.gen(function* () { | |
| 370 | const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; | |
| 371 | const read = yield* readSlice({ | |
| 372 | paths, | |
| 373 | lines: 100, | |
| 374 | width: 900, | |
| 375 | cursor: `${ITERATION.id}:2`, | |
| 376 | }); | |
| 377 | ||
| 378 | expect(patchedPaths(read.slice.patch)).toEqual(["c.ts", "d.ts"]); | |
| 379 | expect(read.slice.nextCursor).toBe(`${ITERATION.id}:4`); | |
| 380 | }), | |
| 381 | ); | |
| 382 | it.effect("keeps the pull request being read, not the one looked up first", () => | |
| 383 | Effect.gen(function* () { | |
| 384 | // Where a pull request lives is read from the pull request itself, so an evicted entry | |
| 385 | // costs a whole pull request read before any file can be asked for. Ordered by insertion | |
| 386 | // alone a hit does not renew its entry, so the review being worked through is the first | |
| 387 | // thing dropped once a listing has walked a cache's worth of cold pull requests. | |
| 388 | const HOT = 7; | |
| 389 | const readsOf = new Map<number, number>(); | |
| 390 | ||
| 391 | const provider = yield* make.pipe( | |
| 392 | Effect.provide( | |
| 393 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ | |
| 394 | getPullRequest: (input) => | |
| 395 | Effect.sync(() => { | |
| 396 | readsOf.set(input.number, (readsOf.get(input.number) ?? 0) + 1); | |
| 397 | return { ...PULL_REQUEST, number: input.number }; | |
| 398 | }), | |
| 399 | listIterations: () => Effect.succeed([ITERATION]), | |
| 400 | listIterationChanges: () => | |
| 401 | Effect.succeed({ changes: [change("a.ts")], truncated: false }), | |
| 402 | readItemContent: () => Effect.succeed({ contents: side("new", 2, 4), isBinary: false }), | |
| 403 | }), | |
| 404 | ), | |
| 405 | ); | |
| 406 | ||
| 407 | const readDiff = (number: number) => | |
| 408 | provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number }); | |
| 409 | ||
| 410 | yield* readDiff(HOT); | |
| 411 | // A cache's worth of cold pull requests, with the open one read in between each of them. | |
| 412 | for (let filled = 0; filled < LOCATION_CACHE_CAPACITY; filled += 1) { | |
| 413 | yield* readDiff(HOT + 1 + filled); | |
| 414 | yield* readDiff(HOT); | |
| 415 | } | |
| 416 | ||
| 417 | expect(readsOf.get(HOT)).toBe(1); | |
| 418 | }), | |
| 419 | ); | |
| 420 | }); |
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts created+647| ... | ... | @@ -0,0 +1,647 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Semaphore from "effect/Semaphore"; | |
| 3 | import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; | |
| 6 | import { | |
| 7 | azureDevOpsFilePatch, | |
| 8 | azureDevOpsUnreadableFilePatch, | |
| 9 | formatAzureDevOpsDiffCursor, | |
| 10 | parseAzureDevOpsDiffCursor, | |
| 11 | MAX_DIFF_SLICE_BYTES, | |
| 12 | MAX_DIFF_SLICE_EDITS, | |
| 13 | MAX_DIFF_SLICE_FILES, | |
| 14 | byteLength, | |
| 15 | MAX_FILE_DIFF_EDITS, | |
| 16 | type AzureDevOpsFileTexts, | |
| 17 | } from "./azureDevOpsDiff.ts"; | |
| 18 | import { | |
| 19 | PullRequestProviderError, | |
| 20 | type PullRequestProviderFailure, | |
| 21 | type ProviderChangeRequest, | |
| 22 | type ProviderChangeRequestActivity, | |
| 23 | type ProviderChangeRequestDetail, | |
| 24 | type ProviderChangeRequestSummary, | |
| 25 | type ProviderDiffSlice, | |
| 26 | type PullRequestProviderApi, | |
| 27 | } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 28 | import type { | |
| 29 | AzureDevOpsChangeEntry, | |
| 30 | AzureDevOpsItemContent, | |
| 31 | AzureDevOpsIteration, | |
| 32 | AzureDevOpsPullRequest, | |
| 33 | AzureDevOpsRepositoryLocation, | |
| 34 | } from "./azureDevOpsPullRequestJson.ts"; | |
| 35 | ||
| 36 | /** | |
| 37 | * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a | |
| 38 | * Python interpreter's start-up, so reading them one after another is most of what the Code tab | |
| 39 | * waits for. Four files means eight processes at once: the fan-out the GitHub CLI reads its | |
| 40 | * per-file stats with here, and low enough not to swamp the host's throttling or the machine. | |
| 41 | */ | |
| 42 | const DIFF_FILE_CONCURRENCY = 4; | |
| 43 | ||
| 44 | /** | |
| 45 | * How many `az` processes this build will have out at once, counted across every reader rather | |
| 46 | * than per request. The fan-out above bounds one Code tab, so two people opening two Azure reviews | |
| 47 | * had sixteen Python interpreters starting at once and nothing above them. Held at what one | |
| 48 | * request at full width spends, so a second reader waits behind the first instead of adding to it. | |
| 49 | */ | |
| 50 | export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY; | |
| 51 | ||
| 52 | /** How many pull requests' repository locations one provider remembers at once. */ | |
| 53 | export const LOCATION_CACHE_CAPACITY = 128; | |
| 54 | ||
| 55 | const CAPABILITIES: PullRequestCapabilities = { | |
| 56 | // Azure serves no patch of its own, so the one the Code tab reads is built here out of the | |
| 57 | // files an iteration changed and both sides of each of them. | |
| 58 | diff: true, | |
| 59 | // Reading a conversation is a plain REST read, but posting one is not something this can | |
| 60 | // claim without having run it, so the composer stays hidden. | |
| 61 | comment: false, | |
| 62 | actions: [ | |
| 63 | "merge", | |
| 64 | "ready", | |
| 65 | "draft", | |
| 66 | "close", | |
| 67 | "reopen", | |
| 68 | "enable-auto-merge", | |
| 69 | "disable-auto-merge", | |
| 70 | ], | |
| 71 | // Azure squashes as a completion option; it has no rebase strategy of its own. | |
| 72 | mergeMethods: ["merge", "squash"], | |
| 73 | // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. | |
| 74 | search: false, | |
| 75 | reactions: false, | |
| 76 | // The patch has lines to write against, but writing a remark at all is what Azure is not | |
| 77 | // offered for here, so nothing in a review is either. | |
| 78 | review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, | |
| 79 | // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` | |
| 80 | // lists the ones this repository could name — that lives behind the identity and graph APIs, a | |
| 81 | // different service with its own permissions. So the page takes a name here rather than being | |
| 82 | // handed a menu built out of a guess. | |
| 83 | reviewers: { request: true, listCandidates: false }, | |
| 84 | // A new title and description travel on the same `az repos pr update` that moves a pull request. | |
| 85 | // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on | |
| 86 | // Azure DevOps at all, so there is nothing here it could rewrite either. | |
| 87 | edit: { changeRequest: true, comment: false }, | |
| 88 | // Azure does keep a viewed record of its own, but only behind the undocumented contribution | |
| 89 | // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway. | |
| 90 | // So they are kept here instead, and the client says whose they are rather than implying the | |
| 91 | // Azure DevOps page will show them. | |
| 92 | viewedFiles: "environment", | |
| 93 | }; | |
| 94 | ||
| 95 | /** | |
| 96 | * Everything this host offers, granted to whoever is signed in. Azure DevOps states no permission | |
| 97 | * anywhere `az repos pr show` or `az repos pr list` reach: the answer lives in the security | |
| 98 | * namespaces, behind identity descriptors and token paths that would be several calls per pull | |
| 99 | * request to resolve. | |
| 100 | * | |
| 101 | * So the actions stay live and a viewer who may not take one is told so by Azure, at the moment | |
| 102 | * they try. That is the safer half of an unknown: hiding a control from someone entitled to it | |
| 103 | * leaves them no way through and no reason given. | |
| 104 | */ | |
| 105 | const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { | |
| 106 | actions: CAPABILITIES.actions, | |
| 107 | comment: CAPABILITIES.comment, | |
| 108 | resolve: CAPABILITIES.review.resolve, | |
| 109 | verdicts: CAPABILITIES.review.verdicts, | |
| 110 | requestReviewers: CAPABILITIES.reviewers.request, | |
| 111 | }; | |
| 112 | ||
| 113 | /** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ | |
| 114 | export function azureDevOpsProviderFailure( | |
| 115 | error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError, | |
| 116 | ): PullRequestProviderFailure { | |
| 117 | if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" }; | |
| 118 | if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" }; | |
| 119 | if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" }; | |
| 120 | if (error._tag === "AzureDevOpsPullRequestNotFoundError") return { reason: "not-found" }; | |
| 121 | return { reason: "failed" }; | |
| 122 | } | |
| 123 | ||
| 124 | function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeRequest { | |
| 125 | return { | |
| 126 | number: pullRequest.number, | |
| 127 | title: pullRequest.title, | |
| 128 | url: pullRequest.url, | |
| 129 | author: pullRequest.author, | |
| 130 | headBranch: pullRequest.headBranch, | |
| 131 | baseBranch: pullRequest.baseBranch, | |
| 132 | state: pullRequest.state, | |
| 133 | isDraft: pullRequest.isDraft, | |
| 134 | mergeability: pullRequest.mergeability, | |
| 135 | // Azure counts a pull request's files but never its lines, and counting them here would mean | |
| 136 | // reading every file on both sides of every row of a listing. | |
| 137 | additions: 0, | |
| 138 | deletions: 0, | |
| 139 | createdAt: pullRequest.createdAt, | |
| 140 | closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, | |
| 141 | mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, | |
| 142 | updatedAt: pullRequest.updatedAt, | |
| 143 | reviewRequestLogins: pullRequest.reviewRequestLogins, | |
| 144 | // Azure keeps labels on work items rather than on the pull request. | |
| 145 | labels: [], | |
| 146 | }; | |
| 147 | } | |
| 148 | ||
| 149 | export const make = Effect.gen(function* () { | |
| 150 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; | |
| 151 | // Made once with the provider, which the registry builds once, so this is the whole build's | |
| 152 | // allowance rather than one request's. | |
| 153 | const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS); | |
| 154 | const readItemContent = (input: Parameters<typeof cli.readItemContent>[0]) => | |
| 155 | diffSpawns.withPermits(1)(cli.readItemContent(input)); | |
| 156 | ||
| 157 | const fail = | |
| 158 | (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) => | |
| 159 | new PullRequestProviderError({ | |
| 160 | provider: "azure-devops", | |
| 161 | operation, | |
| 162 | ...azureDevOpsProviderFailure(error), | |
| 163 | detail: error.detail, | |
| 164 | cause: error, | |
| 165 | }); | |
| 166 | ||
| 167 | /** Refuses what the capabilities already say this host cannot do. */ | |
| 168 | const unsupported = (operation: string) => | |
| 169 | Effect.fail( | |
| 170 | new PullRequestProviderError({ | |
| 171 | provider: "azure-devops", | |
| 172 | operation, | |
| 173 | reason: "failed", | |
| 174 | detail: "Azure DevOps reviews cannot be written from here yet.", | |
| 175 | }), | |
| 176 | ); | |
| 177 | ||
| 178 | /** A pull request Azure could not place has no diff to read, which reads as an empty one. */ | |
| 179 | const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null }; | |
| 180 | ||
| 181 | /** | |
| 182 | * Where a pull request's repository lives, which is the route every other read of it needs and | |
| 183 | * the one thing only the pull request itself states. A pull request cannot move between | |
| 184 | * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for | |
| 185 | * a whole pull request read every time they checked whether a file had been pushed to. Least | |
| 186 | * recently used, so a listing walking cold pull requests cannot evict the one being read. | |
| 187 | */ | |
| 188 | const locations = new Map<string, AzureDevOpsRepositoryLocation>(); | |
| 189 | ||
| 190 | const locationOf = (input: { readonly cwd: string; readonly number: number }) => { | |
| 191 | const key = `${input.cwd} ${input.number}`; | |
| 192 | const held = locations.get(key); | |
| 193 | if (held !== undefined) { | |
| 194 | locations.delete(key); | |
| 195 | locations.set(key, held); | |
| 196 | return Effect.succeed(held); | |
| 197 | } | |
| 198 | return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( | |
| 199 | Effect.map((pullRequest) => { | |
| 200 | const location = pullRequest.location; | |
| 201 | if (location === null) return null; | |
| 202 | if (locations.size >= LOCATION_CACHE_CAPACITY) { | |
| 203 | const oldest = locations.keys().next().value; | |
| 204 | if (oldest !== undefined) locations.delete(oldest); | |
| 205 | } | |
| 206 | locations.set(key, location); | |
| 207 | return location; | |
| 208 | }), | |
| 209 | ); | |
| 210 | }; | |
| 211 | ||
| 212 | /** | |
| 213 | * Everything a diff read needs before it can ask for a file: where the repository lives, and | |
| 214 | * which pushes the pull request has had. A client names neither, and the iterations are read | |
| 215 | * afresh every time because the newest one is what a push adds. | |
| 216 | */ | |
| 217 | const diffScope = (input: { readonly cwd: string; readonly number: number }) => | |
| 218 | Effect.gen(function* () { | |
| 219 | const location = yield* locationOf(input); | |
| 220 | if (location === null) return null; | |
| 221 | const iterations = yield* cli.listIterations({ | |
| 222 | cwd: input.cwd, | |
| 223 | location, | |
| 224 | number: input.number, | |
| 225 | }); | |
| 226 | return { location, iterations }; | |
| 227 | }); | |
| 228 | ||
| 229 | const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; | |
| 230 | ||
| 231 | /** | |
| 232 | * Both sides of one changed file, read at once because neither answer depends on the other and | |
| 233 | * `az` pays a Python interpreter's start-up for each. Only the sides a change actually has are | |
| 234 | * asked for: Azure answers for a file that is not at a commit with a failure rather than with | |
| 235 | * nothing. | |
| 236 | */ | |
| 237 | const readTexts = (input: { | |
| 238 | readonly cwd: string; | |
| 239 | readonly location: AzureDevOpsRepositoryLocation; | |
| 240 | readonly iteration: AzureDevOpsIteration; | |
| 241 | readonly change: Pick<AzureDevOpsChangeEntry, "changeKind" | "path" | "oldPath">; | |
| 242 | }) => | |
| 243 | Effect.all( | |
| 244 | [ | |
| 245 | input.change.changeKind === "new" | |
| 246 | ? Effect.succeed(EMPTY_ITEM) | |
| 247 | : readItemContent({ | |
| 248 | cwd: input.cwd, | |
| 249 | location: input.location, | |
| 250 | path: input.change.oldPath, | |
| 251 | commit: input.iteration.mergeBaseCommit, | |
| 252 | }), | |
| 253 | input.change.changeKind === "deleted" | |
| 254 | ? Effect.succeed(EMPTY_ITEM) | |
| 255 | : readItemContent({ | |
| 256 | cwd: input.cwd, | |
| 257 | location: input.location, | |
| 258 | path: input.change.path, | |
| 259 | commit: input.iteration.headCommit, | |
| 260 | }), | |
| 261 | ], | |
| 262 | { concurrency: 2 }, | |
| 263 | ).pipe( | |
| 264 | Effect.map(([oldItem, newItem]): AzureDevOpsFileTexts => ({ | |
| 265 | oldContents: oldItem.contents, | |
| 266 | newContents: newItem.contents, | |
| 267 | // Azure hands a file it calls binary over in an encoding of its own, so its own word on | |
| 268 | // that is taken rather than looked for in bytes it may never have sent verbatim. | |
| 269 | binary: oldItem.isBinary || newItem.isBinary, | |
| 270 | })), | |
| 271 | ); | |
| 272 | ||
| 273 | /** | |
| 274 | * What the whole pull request changed, taken from its latest push. An iteration's changes are | |
| 275 | * reported against the merge base rather than against the push before it, so the newest one is | |
| 276 | * the whole of the change rather than the last slice of it. | |
| 277 | */ | |
| 278 | const listLatestChanges = (input: { | |
| 279 | readonly cwd: string; | |
| 280 | readonly location: AzureDevOpsRepositoryLocation; | |
| 281 | readonly number: number; | |
| 282 | readonly iterations: ReadonlyArray<AzureDevOpsIteration>; | |
| 283 | }) => { | |
| 284 | const latest = input.iterations.at(-1); | |
| 285 | return latest === undefined | |
| 286 | ? Effect.succeed({ | |
| 287 | changes: [], | |
| 288 | truncated: false, | |
| 289 | } as AzureDevOpsPullRequestCli.AzureDevOpsIterationChanges) | |
| 290 | : cli.listIterationChanges({ | |
| 291 | cwd: input.cwd, | |
| 292 | location: input.location, | |
| 293 | number: input.number, | |
| 294 | iterationId: latest.id, | |
| 295 | }); | |
| 296 | }; | |
| 297 | ||
| 298 | const provider: PullRequestProviderApi = { | |
| 299 | kind: "azure-devops", | |
| 300 | capabilities: CAPABILITIES, | |
| 301 | ||
| 302 | getViewer: (input) => | |
| 303 | cli.getViewer({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), | |
| 304 | ||
| 305 | // `input.query` is deliberately dropped: `az repos pr list` filters by status, creator, | |
| 306 | // reviewer and branch, and has nothing that matches text. Sending it as one of those would | |
| 307 | // narrow by the wrong thing, so the page comes back unnarrowed and the caller filters it. | |
| 308 | listChangeRequests: (input) => | |
| 309 | cli | |
| 310 | .listPullRequests({ | |
| 311 | cwd: input.cwd, | |
| 312 | repository: input.repository, | |
| 313 | state: input.state, | |
| 314 | involvement: input.involvement, | |
| 315 | viewer: input.viewer, | |
| 316 | limit: input.limit, | |
| 317 | cursor: input.cursor, | |
| 318 | }) | |
| 319 | .pipe( | |
| 320 | Effect.mapError(fail("listChangeRequests")), | |
| 321 | Effect.map((batch) => ({ | |
| 322 | items: batch.items.map(toChangeRequest), | |
| 323 | truncated: batch.truncated, | |
| 324 | cursorAdvance: batch.cursorAdvance, | |
| 325 | // Azure answers in one order whether or not it is being carried on from, so a slice | |
| 326 | // can always be stepped past — by counting, which is all Azure offers. | |
| 327 | continues: true, | |
| 328 | })), | |
| 329 | ), | |
| 330 | ||
| 331 | // The polled path a linked thread's row stays live on: one `az` read, no iterations or | |
| 332 | // changes behind it, since the file count that would cost is not shown here. | |
| 333 | getChangeRequestSummary: (input) => | |
| 334 | cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( | |
| 335 | Effect.mapError(fail("getChangeRequestSummary")), | |
| 336 | Effect.map((pullRequest): ProviderChangeRequestSummary => ({ | |
| 337 | number: pullRequest.number, | |
| 338 | title: pullRequest.title, | |
| 339 | url: pullRequest.url, | |
| 340 | author: pullRequest.author, | |
| 341 | headBranch: pullRequest.headBranch, | |
| 342 | baseBranch: pullRequest.baseBranch, | |
| 343 | state: pullRequest.state, | |
| 344 | isDraft: pullRequest.isDraft, | |
| 345 | mergeability: pullRequest.mergeability, | |
| 346 | closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, | |
| 347 | mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, | |
| 348 | updatedAt: pullRequest.updatedAt, | |
| 349 | })), | |
| 350 | ), | |
| 351 | ||
| 352 | getChangeRequest: (input) => | |
| 353 | Effect.gen(function* () { | |
| 354 | const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); | |
| 355 | const location = pullRequest.location; | |
| 356 | // The file count is two reads past the pull request itself, and it is the only thing | |
| 357 | // riding on them, so a failure leaves it unknown rather than losing the whole detail. | |
| 358 | const changedFiles = | |
| 359 | location === null | |
| 360 | ? 0 | |
| 361 | : yield* cli.listIterations({ cwd: input.cwd, location, number: input.number }).pipe( | |
| 362 | Effect.flatMap((iterations) => | |
| 363 | listLatestChanges({ | |
| 364 | cwd: input.cwd, | |
| 365 | location, | |
| 366 | number: input.number, | |
| 367 | iterations, | |
| 368 | }), | |
| 369 | ), | |
| 370 | Effect.map((listed) => listed.changes.length), | |
| 371 | Effect.orElseSucceed(() => 0), | |
| 372 | ); | |
| 373 | const detail: ProviderChangeRequestDetail = { | |
| 374 | ...toChangeRequest(pullRequest), | |
| 375 | body: pullRequest.body, | |
| 376 | changedFiles, | |
| 377 | mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, | |
| 378 | closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, | |
| 379 | reviewers: pullRequest.reviewers, | |
| 380 | checks: [], | |
| 381 | mergeCapabilities: { merge: true, squash: true, rebase: false }, | |
| 382 | viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, | |
| 383 | autoMergeEnabled: pullRequest.autoMergeEnabled, | |
| 384 | ...(pullRequest.autoMergeMethod === undefined | |
| 385 | ? {} | |
| 386 | : { autoMergeMethod: pullRequest.autoMergeMethod }), | |
| 387 | }; | |
| 388 | return detail; | |
| 389 | }).pipe(Effect.mapError(fail("getChangeRequest"))), | |
| 390 | ||
| 391 | getChangeRequestActivity: (input) => | |
| 392 | cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( | |
| 393 | Effect.mapError(fail("getChangeRequestActivity")), | |
| 394 | Effect.flatMap((pullRequest) => | |
| 395 | (pullRequest.location === null | |
| 396 | ? Effect.succeed({ comments: [], truncated: true }) | |
| 397 | : cli | |
| 398 | .listThreads({ | |
| 399 | cwd: input.cwd, | |
| 400 | location: pullRequest.location, | |
| 401 | number: input.number, | |
| 402 | }) | |
| 403 | .pipe( | |
| 404 | Effect.map((comments) => ({ comments, truncated: false })), | |
| 405 | Effect.orElseSucceed(() => ({ comments: [], truncated: true })), | |
| 406 | ) | |
| 407 | ).pipe( | |
| 408 | Effect.map((conversation): ProviderChangeRequestActivity => ({ | |
| 409 | comments: conversation.comments, | |
| 410 | commentCount: conversation.comments.length, | |
| 411 | commentsTruncated: conversation.truncated, | |
| 412 | reviewThreads: [], | |
| 413 | commits: [], | |
| 414 | })), | |
| 415 | ), | |
| 416 | ), | |
| 417 | ), | |
| 418 | ||
| 419 | // No request at all: Azure has nothing to say about the viewer that a pull request read can | |
| 420 | // reach, so the answer is the same constant the detail carries. | |
| 421 | getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS), | |
| 422 | ||
| 423 | // `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so | |
| 424 | // the Code tab has nothing to scope itself to and always asks for the whole change. | |
| 425 | getDiff: (input) => | |
| 426 | Effect.gen(function* () { | |
| 427 | const scope = yield* diffScope(input); | |
| 428 | if (scope === null) return EMPTY_DIFF_SLICE; | |
| 429 | const cursor = parseAzureDevOpsDiffCursor(input.cursor); | |
| 430 | // Reading on stays with the push the first slice was taken against. A push landing | |
| 431 | // mid-read would otherwise renumber the files and hand the reader one twice, or none. | |
| 432 | const iteration = | |
| 433 | cursor === null | |
| 434 | ? scope.iterations.at(-1) | |
| 435 | : scope.iterations.find((candidate) => candidate.id === cursor.iterationId); | |
| 436 | if (iteration === undefined) return EMPTY_DIFF_SLICE; | |
| 437 | const listed = yield* cli.listIterationChanges({ | |
| 438 | cwd: input.cwd, | |
| 439 | location: scope.location, | |
| 440 | number: input.number, | |
| 441 | iterationId: iteration.id, | |
| 442 | }); | |
| 443 | const changes = listed.changes; | |
| 444 | ||
| 445 | const sections: string[] = []; | |
| 446 | let truncated = listed.truncated; | |
| 447 | let bytes = 0; | |
| 448 | let edits = 0; | |
| 449 | let index = cursor?.fileIndex ?? 0; | |
| 450 | let full = false; | |
| 451 | // How many files to read at once. Every file costs a request a side, and everything read | |
| 452 | // past the point the slice fills is thrown away and read again by the next slice, so the | |
| 453 | // batch narrows as the budgets do: what is left of each, over what a file has spent of it | |
| 454 | // on average so far, which is the only estimate there is before a file is read. At least | |
| 455 | // one, so a file heavier than the whole budget still moves the cursor. | |
| 456 | const batchWidth = () => { | |
| 457 | if (sections.length === 0) return DIFF_FILE_CONCURRENCY; | |
| 458 | const admits = (left: number, spent: number) => | |
| 459 | Math.ceil(left / Math.max(1, spent / sections.length)); | |
| 460 | return Math.max( | |
| 461 | 1, | |
| 462 | Math.min( | |
| 463 | DIFF_FILE_CONCURRENCY, | |
| 464 | // The file budget needs no estimate: a file spends exactly one of it. | |
| 465 | MAX_DIFF_SLICE_FILES - sections.length, | |
| 466 | admits(MAX_DIFF_SLICE_BYTES - bytes, bytes), | |
| 467 | admits(MAX_DIFF_SLICE_EDITS - MAX_FILE_DIFF_EDITS - edits, edits), | |
| 468 | ), | |
| 469 | ); | |
| 470 | }; | |
| 471 | while (!full && index < changes.length) { | |
| 472 | const batch = changes.slice(index, index + batchWidth()); | |
| 473 | const read = yield* Effect.forEach( | |
| 474 | batch, | |
| 475 | (change) => | |
| 476 | // A pair Azure refuses is one file rather than the whole slice: an oversize blob or | |
| 477 | // a path `az` will not carry through leaves that file listed without its hunks, and | |
| 478 | // everything around it still renders. | |
| 479 | readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).pipe( | |
| 480 | // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az` | |
| 481 | // at all is the read failing rather than the file, and belongs to the caller, | |
| 482 | // which pauses the host rather than showing every file as unreadable. | |
| 483 | Effect.catchTags({ | |
| 484 | AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null), | |
| 485 | AzureDevOpsCommandFailedError: () => Effect.succeed(null), | |
| 486 | AzureDevOpsPullRequestReadError: () => Effect.succeed(null), | |
| 487 | }), | |
| 488 | Effect.map((texts) => ({ change, texts })), | |
| 489 | ), | |
| 490 | { concurrency: DIFF_FILE_CONCURRENCY }, | |
| 491 | ); | |
| 492 | for (const { change, texts } of read) { | |
| 493 | // The diff is synchronous and the reads no longer stand between one file and the next | |
| 494 | // to let anything else on the server run, so the thread is handed back here. | |
| 495 | yield* Effect.yieldNow; | |
| 496 | const file = | |
| 497 | texts === null | |
| 498 | ? azureDevOpsUnreadableFilePatch(change) | |
| 499 | : azureDevOpsFilePatch({ change, texts }); | |
| 500 | sections.push(file.section); | |
| 501 | bytes += byteLength(file.section); | |
| 502 | edits += file.edits; | |
| 503 | truncated = truncated || file.truncated; | |
| 504 | index += 1; | |
| 505 | // A file whose diff was given up on spent the whole of what one file is allowed and | |
| 506 | // has only a header to show for it, and a file the diff never ran on at all weighs | |
| 507 | // almost nothing in either budget and still costs its two reads. So the file count | |
| 508 | // bounds the request alongside the bytes. | |
| 509 | // Checked after the file is added rather than before it, so every slice carries at | |
| 510 | // least one: a section heavier than the whole budget would otherwise never be added, | |
| 511 | // and the read would answer the same slice forever without moving the cursor. | |
| 512 | if ( | |
| 513 | bytes >= MAX_DIFF_SLICE_BYTES || | |
| 514 | edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS || | |
| 515 | sections.length >= MAX_DIFF_SLICE_FILES || | |
| 516 | file.abandoned | |
| 517 | ) { | |
| 518 | full = true; | |
| 519 | break; | |
| 520 | } | |
| 521 | } | |
| 522 | } | |
| 523 | ||
| 524 | const slice: ProviderDiffSlice = { | |
| 525 | patch: sections.join(""), | |
| 526 | truncated, | |
| 527 | nextCursor: | |
| 528 | index >= changes.length | |
| 529 | ? null | |
| 530 | : formatAzureDevOpsDiffCursor({ iterationId: iteration.id, fileIndex: index }), | |
| 531 | }; | |
| 532 | return slice; | |
| 533 | }).pipe(Effect.mapError(fail("getDiff"))), | |
| 534 | ||
| 535 | // The patch is built from whole files, so opening the lines around a hunk is the same two | |
| 536 | // reads over again rather than a wider request. Read against the latest iteration: nothing in | |
| 537 | // the request says which push the reader is looking at, and expansion is stale after a | |
| 538 | // mid-review push on every host here anyway. | |
| 539 | getDiffFileContents: (input) => | |
| 540 | Effect.gen(function* () { | |
| 541 | const scope = yield* diffScope(input); | |
| 542 | const iteration = scope?.iterations.at(-1); | |
| 543 | if (scope === null || iteration === undefined) return { oldContents: "", newContents: "" }; | |
| 544 | return yield* readTexts({ | |
| 545 | cwd: input.cwd, | |
| 546 | location: scope.location, | |
| 547 | iteration, | |
| 548 | change: { | |
| 549 | changeKind: input.changeType, | |
| 550 | path: input.newPath, | |
| 551 | oldPath: input.oldPath, | |
| 552 | }, | |
| 553 | }); | |
| 554 | }).pipe(Effect.mapError(fail("getDiffFileContents"))), | |
| 555 | ||
| 556 | /** | |
| 557 | * What the head has of each marked file, which is the blob Azure already names on the change | |
| 558 | * it reports. One read covers every path, since the latest iteration lists the whole change. | |
| 559 | * | |
| 560 | * A path the change does not carry is at the empty revision, which is where a file the pull | |
| 561 | * request deletes sits. When the change was too long to follow to its end, those paths are | |
| 562 | * left out instead: reporting them as deleted would clear a file nobody has read. | |
| 563 | */ | |
| 564 | getFileRevisions: (input) => | |
| 565 | Effect.gen(function* () { | |
| 566 | const revisions = new Map<string, string>(); | |
| 567 | if (input.paths.length === 0) return { revisions }; | |
| 568 | const scope = yield* diffScope(input); | |
| 569 | if (scope === null) return { revisions }; | |
| 570 | const listed = yield* listLatestChanges({ | |
| 571 | ...scope, | |
| 572 | cwd: input.cwd, | |
| 573 | number: input.number, | |
| 574 | }); | |
| 575 | const marked = new Set(input.paths); | |
| 576 | for (const change of listed.changes) { | |
| 577 | if (!marked.has(change.path) || change.objectId === null) continue; | |
| 578 | revisions.set(change.path, change.objectId); | |
| 579 | } | |
| 580 | if (!listed.truncated) { | |
| 581 | for (const path of input.paths) { | |
| 582 | if (!revisions.has(path)) revisions.set(path, ""); | |
| 583 | } | |
| 584 | } | |
| 585 | return { revisions }; | |
| 586 | }).pipe(Effect.mapError(fail("getFileRevisions"))), | |
| 587 | ||
| 588 | runAction: (input) => | |
| 589 | cli | |
| 590 | .runPullRequestAction({ | |
| 591 | cwd: input.cwd, | |
| 592 | number: input.number, | |
| 593 | action: input.action, | |
| 594 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), | |
| 595 | }) | |
| 596 | .pipe(Effect.mapError(fail("runAction"))), | |
| 597 | ||
| 598 | updateChangeRequest: (input) => | |
| 599 | cli | |
| 600 | .updatePullRequest({ | |
| 601 | cwd: input.cwd, | |
| 602 | number: input.number, | |
| 603 | title: input.title, | |
| 604 | body: input.body, | |
| 605 | }) | |
| 606 | .pipe(Effect.mapError(fail("updateChangeRequest"))), | |
| 607 | ||
| 608 | // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the | |
| 609 | // list without it. | |
| 610 | listReviewerCandidates: () => | |
| 611 | Effect.fail( | |
| 612 | new PullRequestProviderError({ | |
| 613 | provider: "azure-devops", | |
| 614 | operation: "listReviewerCandidates", | |
| 615 | reason: "failed", | |
| 616 | detail: "Azure DevOps cannot say who may review a pull request.", | |
| 617 | }), | |
| 618 | ), | |
| 619 | ||
| 620 | setReviewerRequest: (input) => | |
| 621 | cli | |
| 622 | .setPullRequestReviewers({ | |
| 623 | cwd: input.cwd, | |
| 624 | number: input.number, | |
| 625 | // Azure names an identity by an email address or a guid, and has no team to ask, so a | |
| 626 | // candidate's id is the whole of what it takes. | |
| 627 | reviewers: input.reviewers.map((reviewer) => reviewer.id), | |
| 628 | requested: input.requested, | |
| 629 | }) | |
| 630 | .pipe(Effect.mapError(fail("setReviewerRequest"))), | |
| 631 | ||
| 632 | // Never called: `capabilities.comment` is false, and the service refuses a comment without it. | |
| 633 | comment: () => unsupported("comment"), | |
| 634 | ||
| 635 | // Declared unsupported above, so the service refuses these before a provider is reached. | |
| 636 | // They exist because every provider answers the whole port. | |
| 637 | submitReview: () => unsupported("submitReview"), | |
| 638 | ||
| 639 | replyToThread: () => unsupported("replyToThread"), | |
| 640 | ||
| 641 | setThreadResolution: () => unsupported("setThreadResolution"), | |
| 642 | ||
| 643 | setReaction: () => unsupported("setReaction"), | |
| 644 | }; | |
| 645 | ||
| 646 | return provider; | |
| 647 | }); |
packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts created+120| ... | ... | @@ -0,0 +1,120 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | ||
| 6 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | |
| 7 | import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; | |
| 8 | ||
| 9 | function makeProvider(azure: Partial<AzureDevOpsCli.AzureDevOpsCli["Service"]>) { | |
| 10 | return AzureDevOpsSourceControlProvider.make.pipe( | |
| 11 | Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), | |
| 12 | ); | |
| 13 | } | |
| 14 | ||
| 15 | it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () => | |
| 16 | Effect.gen(function* () { | |
| 17 | const provider = yield* makeProvider({ | |
| 18 | getPullRequest: () => | |
| 19 | Effect.succeed({ | |
| 20 | number: 42, | |
| 21 | title: "Add Azure provider", | |
| 22 | url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", | |
| 23 | baseRefName: "main", | |
| 24 | headRefName: "feature/source-control", | |
| 25 | state: "closed", | |
| 26 | closedAt: "2026-08-23T10:00:00Z", | |
| 27 | updatedAt: Option.none(), | |
| 28 | }), | |
| 29 | }); | |
| 30 | ||
| 31 | const changeRequest = yield* provider.getChangeRequest({ | |
| 32 | cwd: "/repo", | |
| 33 | reference: "42", | |
| 34 | }); | |
| 35 | ||
| 36 | assert.deepStrictEqual(changeRequest, { | |
| 37 | provider: "azure-devops", | |
| 38 | number: 42, | |
| 39 | title: "Add Azure provider", | |
| 40 | url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", | |
| 41 | baseRefName: "main", | |
| 42 | headRefName: "feature/source-control", | |
| 43 | state: "closed", | |
| 44 | closedAt: "2026-08-23T10:00:00Z", | |
| 45 | mergedAt: null, | |
| 46 | updatedAt: Option.none(), | |
| 47 | isCrossRepository: false, | |
| 48 | }); | |
| 49 | }), | |
| 50 | ); | |
| 51 | ||
| 52 | it.effect("adds change-request context while retaining Azure CLI causes", () => | |
| 53 | Effect.gen(function* () { | |
| 54 | const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ | |
| 55 | operation: "execute", | |
| 56 | command: "az", | |
| 57 | cwd: "/repo", | |
| 58 | argumentCount: 2, | |
| 59 | cause: new Error("raw upstream detail that should remain in the cause"), | |
| 60 | }); | |
| 61 | const provider = yield* makeProvider({ | |
| 62 | checkoutPullRequest: () => Effect.fail(cause), | |
| 63 | }); | |
| 64 | ||
| 65 | const error = yield* provider | |
| 66 | .checkoutChangeRequest({ cwd: "/repo", reference: "#42" }) | |
| 67 | .pipe(Effect.flip); | |
| 68 | ||
| 69 | assert.deepStrictEqual( | |
| 70 | { | |
| 71 | provider: error.provider, | |
| 72 | operation: error.operation, | |
| 73 | command: error.command, | |
| 74 | cwd: error.cwd, | |
| 75 | reference: error.reference, | |
| 76 | detail: error.detail, | |
| 77 | }, | |
| 78 | { | |
| 79 | provider: "azure-devops", | |
| 80 | operation: "checkoutChangeRequest", | |
| 81 | command: "az", | |
| 82 | cwd: "/repo", | |
| 83 | reference: "#42", | |
| 84 | detail: "Azure DevOps CLI command failed.", | |
| 85 | }, | |
| 86 | ); | |
| 87 | assert.strictEqual(error.cause, cause); | |
| 88 | assert.equal(error.message.includes("raw upstream detail"), false); | |
| 89 | }), | |
| 90 | ); | |
| 91 | ||
| 92 | it.effect("creates Azure DevOps PRs through provider-neutral input names", () => | |
| 93 | Effect.gen(function* () { | |
| 94 | let createInput: | |
| 95 | | Parameters<AzureDevOpsCli.AzureDevOpsCli["Service"]["createPullRequest"]>[0] | |
| 96 | | null = null; | |
| 97 | const provider = yield* makeProvider({ | |
| 98 | createPullRequest: (input) => { | |
| 99 | createInput = input; | |
| 100 | return Effect.void; | |
| 101 | }, | |
| 102 | }); | |
| 103 | ||
| 104 | yield* provider.createChangeRequest({ | |
| 105 | cwd: "/repo", | |
| 106 | baseRefName: "main", | |
| 107 | headSelector: "feature/provider", | |
| 108 | title: "Provider PR", | |
| 109 | bodyFile: "/tmp/body.md", | |
| 110 | }); | |
| 111 | ||
| 112 | assert.deepStrictEqual(createInput, { | |
| 113 | cwd: "/repo", | |
| 114 | baseBranch: "main", | |
| 115 | headSelector: "feature/provider", | |
| 116 | title: "Provider PR", | |
| 117 | bodyFile: "/tmp/body.md", | |
| 118 | }); | |
| 119 | }), | |
| 120 | ); |
packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts created+236| ... | ... | @@ -0,0 +1,236 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | |
| 5 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | |
| 6 | import { | |
| 7 | combinedAuthOutput, | |
| 8 | firstSafeAuthLine, | |
| 9 | providerAuth, | |
| 10 | type SourceControlAuthProbeInput, | |
| 11 | type SourceControlCliDiscoverySpec, | |
| 12 | } from "@t3tools/source-control-core/server/discovery"; | |
| 13 | ||
| 14 | function parseAzureAuth(input: SourceControlAuthProbeInput) { | |
| 15 | const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); | |
| 16 | ||
| 17 | if (input.exitCode !== 0) { | |
| 18 | return providerAuth({ | |
| 19 | status: "unauthenticated", | |
| 20 | detail: | |
| 21 | firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", | |
| 22 | }); | |
| 23 | } | |
| 24 | ||
| 25 | if (account !== undefined && account.length > 0) { | |
| 26 | return providerAuth({ | |
| 27 | status: "authenticated", | |
| 28 | account, | |
| 29 | host: "dev.azure.com", | |
| 30 | }); | |
| 31 | } | |
| 32 | ||
| 33 | return providerAuth({ | |
| 34 | status: "unknown", | |
| 35 | host: "dev.azure.com", | |
| 36 | detail: "Azure CLI account status could not be parsed.", | |
| 37 | }); | |
| 38 | } | |
| 39 | ||
| 40 | export const discovery = { | |
| 41 | type: "cli", | |
| 42 | kind: "azure-devops", | |
| 43 | label: "Azure DevOps", | |
| 44 | executable: "az", | |
| 45 | versionArgs: ["--version"], | |
| 46 | authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], | |
| 47 | // `az` boots a fresh Python interpreter on every invocation, so even `az --version` | |
| 48 | // takes ~6s on Windows and overruns the default budget, leaving the provider reported | |
| 49 | // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s. | |
| 50 | probeTimeoutMs: 20_000, | |
| 51 | parseAuth: parseAzureAuth, | |
| 52 | installHint: | |
| 53 | "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", | |
| 54 | } satisfies SourceControlCliDiscoverySpec; | |
| 55 | ||
| 56 | function toChangeRequest(summary: { | |
| 57 | readonly number: number; | |
| 58 | readonly title: string; | |
| 59 | readonly url: string; | |
| 60 | readonly baseRefName: string; | |
| 61 | readonly headRefName: string; | |
| 62 | readonly state: "open" | "closed" | "merged"; | |
| 63 | readonly isDraft?: boolean; | |
| 64 | readonly closedAt?: string | null; | |
| 65 | readonly mergedAt?: string | null; | |
| 66 | readonly updatedAt: ChangeRequest["updatedAt"]; | |
| 67 | }): ChangeRequest { | |
| 68 | return { | |
| 69 | provider: "azure-devops", | |
| 70 | number: summary.number, | |
| 71 | title: summary.title, | |
| 72 | url: summary.url, | |
| 73 | baseRefName: summary.baseRefName, | |
| 74 | headRefName: summary.headRefName, | |
| 75 | state: summary.state, | |
| 76 | ...(summary.isDraft === true ? { isDraft: true } : {}), | |
| 77 | closedAt: summary.closedAt ?? null, | |
| 78 | mergedAt: summary.mergedAt ?? null, | |
| 79 | updatedAt: summary.updatedAt, | |
| 80 | isCrossRepository: false, | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | export const make = Effect.gen(function* () { | |
| 85 | const azure = yield* AzureDevOpsCli.AzureDevOpsCli; | |
| 86 | ||
| 87 | return SourceControlProvider.SourceControlProvider.of({ | |
| 88 | kind: "azure-devops", | |
| 89 | listChangeRequests: (input) => { | |
| 90 | const source = SourceControlProvider.sourceControlRefFromInput(input); | |
| 91 | return azure | |
| 92 | .listPullRequests({ | |
| 93 | cwd: input.cwd, | |
| 94 | headSelector: input.headSelector, | |
| 95 | ...(source !== undefined ? { source } : {}), | |
| 96 | state: input.state, | |
| 97 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | |
| 98 | }) | |
| 99 | .pipe( | |
| 100 | Effect.map((items) => items.map(toChangeRequest)), | |
| 101 | Effect.mapError( | |
| 102 | (error) => | |
| 103 | new SourceControlProviderError({ | |
| 104 | provider: "azure-devops", | |
| 105 | operation: "listChangeRequests", | |
| 106 | command: error.command, | |
| 107 | cwd: input.cwd, | |
| 108 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 109 | input.headSelector, | |
| 110 | ), | |
| 111 | detail: error.detail, | |
| 112 | cause: error, | |
| 113 | }), | |
| 114 | ), | |
| 115 | ); | |
| 116 | }, | |
| 117 | getChangeRequest: (input) => | |
| 118 | azure.getPullRequest(input).pipe( | |
| 119 | Effect.map(toChangeRequest), | |
| 120 | Effect.mapError( | |
| 121 | (error) => | |
| 122 | new SourceControlProviderError({ | |
| 123 | provider: "azure-devops", | |
| 124 | operation: "getChangeRequest", | |
| 125 | command: error.command, | |
| 126 | cwd: input.cwd, | |
| 127 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 128 | input.reference, | |
| 129 | ), | |
| 130 | detail: error.detail, | |
| 131 | cause: error, | |
| 132 | }), | |
| 133 | ), | |
| 134 | ), | |
| 135 | createChangeRequest: (input) => { | |
| 136 | const source = SourceControlProvider.sourceControlRefFromInput(input); | |
| 137 | return azure | |
| 138 | .createPullRequest({ | |
| 139 | cwd: input.cwd, | |
| 140 | baseBranch: input.baseRefName, | |
| 141 | headSelector: input.headSelector, | |
| 142 | ...(source !== undefined ? { source } : {}), | |
| 143 | ...(input.target !== undefined ? { target: input.target } : {}), | |
| 144 | title: input.title, | |
| 145 | bodyFile: input.bodyFile, | |
| 146 | }) | |
| 147 | .pipe( | |
| 148 | Effect.mapError( | |
| 149 | (error) => | |
| 150 | new SourceControlProviderError({ | |
| 151 | provider: "azure-devops", | |
| 152 | operation: "createChangeRequest", | |
| 153 | command: error.command, | |
| 154 | cwd: input.cwd, | |
| 155 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 156 | input.headSelector, | |
| 157 | ), | |
| 158 | detail: error.detail, | |
| 159 | cause: error, | |
| 160 | }), | |
| 161 | ), | |
| 162 | ); | |
| 163 | }, | |
| 164 | getRepositoryCloneUrls: (input) => | |
| 165 | azure.getRepositoryCloneUrls(input).pipe( | |
| 166 | Effect.mapError( | |
| 167 | (error) => | |
| 168 | new SourceControlProviderError({ | |
| 169 | provider: "azure-devops", | |
| 170 | operation: "getRepositoryCloneUrls", | |
| 171 | command: error.command, | |
| 172 | cwd: input.cwd, | |
| 173 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 174 | input.repository, | |
| 175 | ), | |
| 176 | detail: error.detail, | |
| 177 | cause: error, | |
| 178 | }), | |
| 179 | ), | |
| 180 | ), | |
| 181 | createRepository: (input) => | |
| 182 | azure.createRepository(input).pipe( | |
| 183 | Effect.mapError( | |
| 184 | (error) => | |
| 185 | new SourceControlProviderError({ | |
| 186 | provider: "azure-devops", | |
| 187 | operation: "createRepository", | |
| 188 | command: error.command, | |
| 189 | cwd: input.cwd, | |
| 190 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 191 | input.repository, | |
| 192 | ), | |
| 193 | detail: error.detail, | |
| 194 | cause: error, | |
| 195 | }), | |
| 196 | ), | |
| 197 | ), | |
| 198 | getDefaultBranch: (input) => | |
| 199 | azure.getDefaultBranch({ cwd: input.cwd }).pipe( | |
| 200 | Effect.mapError( | |
| 201 | (error) => | |
| 202 | new SourceControlProviderError({ | |
| 203 | provider: "azure-devops", | |
| 204 | operation: "getDefaultBranch", | |
| 205 | command: error.command, | |
| 206 | cwd: input.cwd, | |
| 207 | detail: error.detail, | |
| 208 | cause: error, | |
| 209 | }), | |
| 210 | ), | |
| 211 | ), | |
| 212 | checkoutChangeRequest: (input) => | |
| 213 | azure | |
| 214 | .checkoutPullRequest({ | |
| 215 | cwd: input.cwd, | |
| 216 | reference: input.reference, | |
| 217 | ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), | |
| 218 | }) | |
| 219 | .pipe( | |
| 220 | Effect.mapError( | |
| 221 | (error) => | |
| 222 | new SourceControlProviderError({ | |
| 223 | provider: "azure-devops", | |
| 224 | operation: "checkoutChangeRequest", | |
| 225 | command: error.command, | |
| 226 | cwd: input.cwd, | |
| 227 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 228 | input.reference, | |
| 229 | ), | |
| 230 | detail: error.detail, | |
| 231 | cause: error, | |
| 232 | }), | |
| 233 | ), | |
| 234 | ), | |
| 235 | }); | |
| 236 | }); |
packages/source-control-azure-devops/src/server/azureDevOpsDiff.test.ts created+459| ... | ... | @@ -0,0 +1,459 @@ |
| 1 | import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | azureDevOpsFilePatch, | |
| 6 | azureDevOpsUnreadableFilePatch, | |
| 7 | byteLength, | |
| 8 | formatAzureDevOpsDiffCursor, | |
| 9 | MAX_FILE_DIFF_EDITS, | |
| 10 | parseAzureDevOpsDiffCursor, | |
| 11 | } from "./azureDevOpsDiff.ts"; | |
| 12 | import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; | |
| 13 | ||
| 14 | function change(overrides: Partial<AzureDevOpsChangeEntry> = {}): AzureDevOpsChangeEntry { | |
| 15 | return { | |
| 16 | path: "README.md", | |
| 17 | oldPath: "README.md", | |
| 18 | changeKind: "change", | |
| 19 | objectId: "8f80", | |
| 20 | originalObjectId: "0ca4", | |
| 21 | ...overrides, | |
| 22 | }; | |
| 23 | } | |
| 24 | ||
| 25 | function texts(oldContents: string, newContents: string, binary = false) { | |
| 26 | return { oldContents, newContents, binary }; | |
| 27 | } | |
| 28 | ||
| 29 | describe("azureDevOpsFilePatch", () => { | |
| 30 | it("writes a changed file as the unified patch every diff viewer already reads", () => { | |
| 31 | const patch = azureDevOpsFilePatch({ | |
| 32 | change: change(), | |
| 33 | texts: texts("one\ntwo\nthree\n", "one\ntwo again\nthree\n"), | |
| 34 | }); | |
| 35 | ||
| 36 | expect(patch.truncated).toBe(false); | |
| 37 | expect(patch.section).toBe( | |
| 38 | [ | |
| 39 | "diff --git a/README.md b/README.md", | |
| 40 | "--- a/README.md", | |
| 41 | "+++ b/README.md", | |
| 42 | "@@ -1,3 +1,3 @@", | |
| 43 | " one", | |
| 44 | "-two", | |
| 45 | "+two again", | |
| 46 | " three", | |
| 47 | "", | |
| 48 | ].join("\n"), | |
| 49 | ); | |
| 50 | }); | |
| 51 | ||
| 52 | it("names the side a new file does not have as /dev/null", () => { | |
| 53 | const patch = azureDevOpsFilePatch({ | |
| 54 | change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), | |
| 55 | texts: texts("", "hello\n"), | |
| 56 | }); | |
| 57 | ||
| 58 | expect(patch.section).toContain("new file mode 100644"); | |
| 59 | expect(patch.section).toContain("--- /dev/null"); | |
| 60 | expect(patch.section).toContain("+++ b/DEMO.md"); | |
| 61 | // Git points the range a new file does not have at line zero, not at line one. | |
| 62 | expect(patch.section).toContain("@@ -0,0 +1 @@"); | |
| 63 | expect(patch.section).toContain("+hello"); | |
| 64 | }); | |
| 65 | ||
| 66 | it("names the side a deleted file no longer has as /dev/null", () => { | |
| 67 | const patch = azureDevOpsFilePatch({ | |
| 68 | change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), | |
| 69 | texts: texts("gone\n", ""), | |
| 70 | }); | |
| 71 | ||
| 72 | expect(patch.section).toContain("deleted file mode 100644"); | |
| 73 | expect(patch.section).toContain("--- a/OLD.md"); | |
| 74 | expect(patch.section).toContain("+++ /dev/null"); | |
| 75 | expect(patch.section).toContain("@@ -1 +0,0 @@"); | |
| 76 | expect(patch.section).toContain("-gone"); | |
| 77 | }); | |
| 78 | ||
| 79 | it("keeps the carriage returns of a file with Windows line endings", () => { | |
| 80 | // They are part of the line rather than around it, so a patch that dropped them would ask | |
| 81 | // the reader to look at a change that is not the one on the host. | |
| 82 | const patch = azureDevOpsFilePatch({ | |
| 83 | change: change(), | |
| 84 | texts: texts("one\r\ntwo\r\n", "one\r\ntwo again\r\n"), | |
| 85 | }); | |
| 86 | ||
| 87 | expect(patch.section).toContain("-two\r"); | |
| 88 | expect(patch.section).toContain("+two again\r"); | |
| 89 | }); | |
| 90 | ||
| 91 | it("keeps a file that only moved, which has no hunks to give", () => { | |
| 92 | const patch = azureDevOpsFilePatch({ | |
| 93 | change: change({ path: "docs/new.md", oldPath: "docs/old.md", changeKind: "rename-pure" }), | |
| 94 | texts: texts("same\n", "same\n"), | |
| 95 | }); | |
| 96 | ||
| 97 | expect(patch.truncated).toBe(false); | |
| 98 | expect(patch.section).toBe( | |
| 99 | [ | |
| 100 | "diff --git a/docs/old.md b/docs/new.md", | |
| 101 | "rename from docs/old.md", | |
| 102 | "rename to docs/new.md", | |
| 103 | "--- a/docs/old.md", | |
| 104 | "+++ b/docs/new.md", | |
| 105 | "", | |
| 106 | ].join("\n"), | |
| 107 | ); | |
| 108 | }); | |
| 109 | ||
| 110 | it("reports a binary file as changed rather than spelling it out", () => { | |
| 111 | const patch = azureDevOpsFilePatch({ | |
| 112 | change: change({ path: "logo.png", oldPath: "logo.png" }), | |
| 113 | texts: texts("PNG\u0000old", "PNG\u0000new"), | |
| 114 | }); | |
| 115 | ||
| 116 | expect(patch.truncated).toBe(true); | |
| 117 | expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ"); | |
| 118 | }); | |
| 119 | ||
| 120 | it("shows an overlong file as changed without its hunks", () => { | |
| 121 | const patch = azureDevOpsFilePatch({ | |
| 122 | change: change({ path: "bundle.js", oldPath: "bundle.js" }), | |
| 123 | texts: texts("a\n".repeat(400_000), "b\n".repeat(400_000)), | |
| 124 | }); | |
| 125 | ||
| 126 | expect(patch.truncated).toBe(true); | |
| 127 | expect(patch.section).toBe( | |
| 128 | ["diff --git a/bundle.js b/bundle.js", "--- a/bundle.js", "+++ b/bundle.js", ""].join("\n"), | |
| 129 | ); | |
| 130 | }); | |
| 131 | ||
| 132 | it("takes the host's word that a file is binary, whatever its bytes look like", () => { | |
| 133 | // Azure hands such a file over base64-encoded, so nothing in the text it sent gives it away. | |
| 134 | const patch = azureDevOpsFilePatch({ | |
| 135 | change: change({ path: "logo.png", oldPath: "logo.png" }), | |
| 136 | texts: texts("b2xk", "bmV3", true), | |
| 137 | }); | |
| 138 | ||
| 139 | expect(patch.truncated).toBe(true); | |
| 140 | expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ"); | |
| 141 | }); | |
| 142 | ||
| 143 | it("counts an overlong file in bytes rather than in characters", () => { | |
| 144 | // Three bytes each, so a ceiling counted in code units would let three times the size through. | |
| 145 | const patch = azureDevOpsFilePatch({ | |
| 146 | change: change({ path: "notes.md", oldPath: "notes.md" }), | |
| 147 | texts: texts("\u4e00".repeat(200_000), "\u4e8c".repeat(200_000)), | |
| 148 | }); | |
| 149 | ||
| 150 | expect(patch.truncated).toBe(true); | |
| 151 | expect(patch.section).toBe( | |
| 152 | ["diff --git a/notes.md b/notes.md", "--- a/notes.md", "+++ b/notes.md", ""].join("\n"), | |
| 153 | ); | |
| 154 | }); | |
| 155 | ||
| 156 | const lineRange = (count: number, prefix: string) => | |
| 157 | Array.from({ length: count }, (_, line) => `${prefix} ${line}`).join("\n"); | |
| 158 | ||
| 159 | it("lists a file too far apart to diff without its hunks", () => { | |
| 160 | // Sharing no line at all costs one edit per line on each side, so this pair is twice the | |
| 161 | // ceiling apart. Left to itself the search costs about the square of that and would hold the | |
| 162 | // whole server, every websocket client with it, while it worked out a patch nobody reads. | |
| 163 | // Writing both sides out instead would read as a genuine rewrite: the ceiling is a distance | |
| 164 | // rather than a proportion, so a long file reaches it having changed in one corner, and that | |
| 165 | // corner would be buried in a wall of red and green. | |
| 166 | const lines = (prefix: string) => | |
| 167 | Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n"); | |
| 168 | const patch = azureDevOpsFilePatch({ | |
| 169 | change: change({ path: "generated.ts", oldPath: "generated.ts" }), | |
| 170 | texts: texts(`${lines("old")}\n`, `${lines("new")}\n`), | |
| 171 | }); | |
| 172 | ||
| 173 | expect(patch.truncated).toBe(true); | |
| 174 | // And it says the search was given up on, because the reader of a run of files is meant to | |
| 175 | // stop rather than spend that work again on each of the ones behind it. | |
| 176 | expect(patch.abandoned).toBe(true); | |
| 177 | expect(patch.section).toBe( | |
| 178 | [ | |
| 179 | "diff --git a/generated.ts b/generated.ts", | |
| 180 | "--- a/generated.ts", | |
| 181 | "+++ b/generated.ts", | |
| 182 | "", | |
| 183 | ].join("\n"), | |
| 184 | ); | |
| 185 | }); | |
| 186 | ||
| 187 | it("writes out a wholly new file however many lines it has", () => { | |
| 188 | // Nothing on the old side means there was no edit distance to search out, so this is the | |
| 189 | // minimal patch and not a stand-in for one. Fifteen thousand lines of thirty bytes is the | |
| 190 | // shape this used to lose: inside the byte ceiling that gates every file, and well past every | |
| 191 | // bound a two-sided file answers to, none of which is protecting against anything here. | |
| 192 | const contents = `${Array.from({ length: 15_000 }, () => "x".repeat(29)).join("\n")}\n`; | |
| 193 | const patch = azureDevOpsFilePatch({ | |
| 194 | change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), | |
| 195 | texts: texts("", contents), | |
| 196 | }); | |
| 197 | ||
| 198 | expect(byteLength(contents)).toBeLessThan(512 * 1024); | |
| 199 | expect(patch.truncated).toBe(false); | |
| 200 | expect(patch.abandoned).toBe(false); | |
| 201 | expect(patch.edits).toBe(15_000); | |
| 202 | expect(patch.section).toContain("@@ -0,0 +1,15000 @@"); | |
| 203 | // Only the `+++` of the header on top of the file's own lines, so nothing was dropped out of | |
| 204 | // the middle. | |
| 205 | expect(patch.section.match(/^\+/gmu)).toHaveLength(15_001); | |
| 206 | }); | |
| 207 | ||
| 208 | it("keeps a wholly new file too heavy to write out listed without its hunks", () => { | |
| 209 | // Every line carries a marker, so a side of very short lines answers with up to twice its own | |
| 210 | // bytes: these 200,000 one-character lines fit the ceiling each side is read under and weigh | |
| 211 | // about 600KB written out, more than twice what a whole slice may carry. There is no smaller | |
| 212 | // true patch for a creation to fall back to, so it is listed without its hunks, the same as a | |
| 213 | // side too big to read at all, rather than sent at a size the slice budget exists to prevent. | |
| 214 | const contents = `${Array.from({ length: 200_000 }, () => "x").join("\n")}\n`; | |
| 215 | const patch = azureDevOpsFilePatch({ | |
| 216 | change: change({ path: "bundle.min.js", oldPath: "bundle.min.js", changeKind: "new" }), | |
| 217 | texts: texts("", contents), | |
| 218 | }); | |
| 219 | ||
| 220 | expect(byteLength(contents)).toBeLessThan(512 * 1024); | |
| 221 | expect(patch.truncated).toBe(true); | |
| 222 | expect(patch.abandoned).toBe(false); | |
| 223 | // It still cost the walk over its lines, which is what the slice is charged for. | |
| 224 | expect(patch.edits).toBe(200_000); | |
| 225 | expect(patch.section).toBe( | |
| 226 | [ | |
| 227 | "diff --git a/bundle.min.js b/bundle.min.js", | |
| 228 | "new file mode 100644", | |
| 229 | "--- /dev/null", | |
| 230 | "+++ b/bundle.min.js", | |
| 231 | "", | |
| 232 | ].join("\n"), | |
| 233 | ); | |
| 234 | }); | |
| 235 | ||
| 236 | it("writes out a wholly deleted file however many lines it had", () => { | |
| 237 | const contents = `${lineRange(20_000, "line")}\n`; | |
| 238 | const patch = azureDevOpsFilePatch({ | |
| 239 | change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), | |
| 240 | texts: texts(contents, ""), | |
| 241 | }); | |
| 242 | ||
| 243 | expect(patch.truncated).toBe(false); | |
| 244 | expect(patch.abandoned).toBe(false); | |
| 245 | expect(patch.edits).toBe(20_000); | |
| 246 | expect(patch.section).toContain("@@ -1,20000 +0,0 @@"); | |
| 247 | expect(patch.section.match(/^-line /gmu)).toHaveLength(20_000); | |
| 248 | expect(patch.section.match(/^-/gmu)).toHaveLength(20_001); | |
| 249 | }); | |
| 250 | ||
| 251 | it("gives an empty new file no hunk to read", () => { | |
| 252 | // A file with nothing on either side has no lines to claim were replaced, and git writes it | |
| 253 | // as a header alone. | |
| 254 | const patch = azureDevOpsFilePatch({ | |
| 255 | change: change({ path: "EMPTY.md", oldPath: "EMPTY.md", changeKind: "new" }), | |
| 256 | texts: texts("", ""), | |
| 257 | }); | |
| 258 | ||
| 259 | expect(patch.edits).toBe(0); | |
| 260 | expect(patch.section).not.toContain("@@"); | |
| 261 | }); | |
| 262 | ||
| 263 | it("marks a wholly new file whose last line has no newline after it", () => { | |
| 264 | const patch = azureDevOpsFilePatch({ | |
| 265 | change: change({ path: "NOTES.md", oldPath: "NOTES.md", changeKind: "new" }), | |
| 266 | texts: texts("", "one\ntwo"), | |
| 267 | }); | |
| 268 | ||
| 269 | expect(patch.section).toBe( | |
| 270 | [ | |
| 271 | "diff --git a/NOTES.md b/NOTES.md", | |
| 272 | "new file mode 100644", | |
| 273 | "--- /dev/null", | |
| 274 | "+++ b/NOTES.md", | |
| 275 | "@@ -0,0 +1,2 @@", | |
| 276 | "+one", | |
| 277 | "+two", | |
| 278 | "\\ No newline at end of file", | |
| 279 | "", | |
| 280 | ].join("\n"), | |
| 281 | ); | |
| 282 | }); | |
| 283 | ||
| 284 | it("keeps a file it did diff out of the giving up", () => { | |
| 285 | const patch = azureDevOpsFilePatch({ | |
| 286 | change: change(), | |
| 287 | texts: texts("one\ntwo\n", "one\ntwo again\n"), | |
| 288 | }); | |
| 289 | ||
| 290 | expect(patch.abandoned).toBe(false); | |
| 291 | }); | |
| 292 | ||
| 293 | it("counts what the diff worked out, which is what the file cost to diff", () => { | |
| 294 | // The caller spends a budget of these across a slice, so they have to be the edits the search | |
| 295 | // actually made: one line replaced is a removal and an addition, and the three lines of | |
| 296 | // context around them cost nothing. | |
| 297 | const patch = azureDevOpsFilePatch({ | |
| 298 | change: change(), | |
| 299 | texts: texts("one\ntwo\nthree\nfour\n", "one\ntwo again\nthree\nfour\n"), | |
| 300 | }); | |
| 301 | ||
| 302 | expect(patch.edits).toBe(2); | |
| 303 | }); | |
| 304 | ||
| 305 | it("counts nothing for a file it never diffed", () => { | |
| 306 | const patch = azureDevOpsFilePatch({ | |
| 307 | change: change({ path: "logo.png", oldPath: "logo.png" }), | |
| 308 | texts: texts("PNG\u0000old", "PNG\u0000new"), | |
| 309 | }); | |
| 310 | ||
| 311 | expect(patch.edits).toBe(0); | |
| 312 | }); | |
| 313 | ||
| 314 | it("lists a file whose hunks outweigh its sides without them", () => { | |
| 315 | // A handful of very long lines is a few edits and nowhere near the edit ceiling, and the | |
| 316 | // patch carries both sides in full with three lines of context around each hunk, so the | |
| 317 | // section comes out heavier than either side was. What one file weighs is what a slice's | |
| 318 | // budget is spent in, so the edit ceiling alone does not bound this. | |
| 319 | const line = `${"a".repeat(400 * 1024)}\n`; | |
| 320 | const patch = azureDevOpsFilePatch({ | |
| 321 | change: change({ path: "min.js", oldPath: "min.js" }), | |
| 322 | texts: texts(line, `${"b".repeat(400 * 1024)}\n`), | |
| 323 | }); | |
| 324 | ||
| 325 | expect(patch.edits).toBeLessThan(MAX_FILE_DIFF_EDITS); | |
| 326 | expect(patch.truncated).toBe(true); | |
| 327 | expect(patch.section).toBe( | |
| 328 | ["diff --git a/min.js b/min.js", "--- a/min.js", "+++ b/min.js", ""].join("\n"), | |
| 329 | ); | |
| 330 | expect(byteLength(patch.section)).toBeLessThan(byteLength(line)); | |
| 331 | }); | |
| 332 | ||
| 333 | it("marks a file that does not end in a newline, as git does", () => { | |
| 334 | const patch = azureDevOpsFilePatch({ | |
| 335 | change: change(), | |
| 336 | texts: texts("one\n", "two"), | |
| 337 | }); | |
| 338 | ||
| 339 | expect(patch.section).toContain("\\ No newline at end of file"); | |
| 340 | }); | |
| 341 | }); | |
| 342 | ||
| 343 | describe("azureDevOpsUnreadableFilePatch", () => { | |
| 344 | it("keeps a file the host would not hand over, listed without its hunks", () => { | |
| 345 | const patch = azureDevOpsUnreadableFilePatch(change({ path: "huge.bin", oldPath: "huge.bin" })); | |
| 346 | ||
| 347 | expect(patch.truncated).toBe(true); | |
| 348 | expect(patch.section).toBe( | |
| 349 | ["diff --git a/huge.bin b/huge.bin", "--- a/huge.bin", "+++ b/huge.bin", ""].join("\n"), | |
| 350 | ); | |
| 351 | }); | |
| 352 | }); | |
| 353 | ||
| 354 | describe("a file Azure names something a patch header cannot carry plainly", () => { | |
| 355 | it("writes each side as git's quoted form, the side letter inside the quotes", () => { | |
| 356 | const patch = azureDevOpsFilePatch({ | |
| 357 | change: change({ path: "notes\treadme.md", oldPath: "notes\treadme.md" }), | |
| 358 | texts: texts("one\n", "two\n"), | |
| 359 | }); | |
| 360 | ||
| 361 | expect(patch.section).toBe( | |
| 362 | [ | |
| 363 | 'diff --git "a/notes\\treadme.md" "b/notes\\treadme.md"', | |
| 364 | '--- "a/notes\\treadme.md"', | |
| 365 | '+++ "b/notes\\treadme.md"', | |
| 366 | "@@ -1 +1 @@", | |
| 367 | "-one", | |
| 368 | "+two", | |
| 369 | "", | |
| 370 | ].join("\n"), | |
| 371 | ); | |
| 372 | }); | |
| 373 | ||
| 374 | it("keeps a name holding a newline on the one header line it belongs to", () => { | |
| 375 | const patch = azureDevOpsFilePatch({ | |
| 376 | change: change({ path: "line\nfile.txt", oldPath: "line\nfile.txt" }), | |
| 377 | texts: texts("one\n", "two\n"), | |
| 378 | }); | |
| 379 | ||
| 380 | // Written as itself the name would start a line of its own, and a reader would take what | |
| 381 | // followed for a header the patch never had. | |
| 382 | expect(patch.section.split("\n").slice(0, 3)).toEqual([ | |
| 383 | 'diff --git "a/line\\nfile.txt" "b/line\\nfile.txt"', | |
| 384 | '--- "a/line\\nfile.txt"', | |
| 385 | '+++ "b/line\\nfile.txt"', | |
| 386 | ]); | |
| 387 | }); | |
| 388 | ||
| 389 | it("quotes the names a rename states, which carry no side letter", () => { | |
| 390 | const patch = azureDevOpsFilePatch({ | |
| 391 | change: change({ | |
| 392 | path: "docs/new\tname.md", | |
| 393 | oldPath: "docs/old\tname.md", | |
| 394 | changeKind: "rename-pure", | |
| 395 | }), | |
| 396 | texts: texts("same\n", "same\n"), | |
| 397 | }); | |
| 398 | ||
| 399 | expect(patch.section).toBe( | |
| 400 | [ | |
| 401 | 'diff --git "a/docs/old\\tname.md" "b/docs/new\\tname.md"', | |
| 402 | 'rename from "docs/old\\tname.md"', | |
| 403 | 'rename to "docs/new\\tname.md"', | |
| 404 | '--- "a/docs/old\\tname.md"', | |
| 405 | '+++ "b/docs/new\\tname.md"', | |
| 406 | "", | |
| 407 | ].join("\n"), | |
| 408 | ); | |
| 409 | }); | |
| 410 | ||
| 411 | it("quotes the sides of the one line a binary file gets", () => { | |
| 412 | const patch = azureDevOpsFilePatch({ | |
| 413 | change: change({ path: "logo\tmark.png", oldPath: "logo\tmark.png" }), | |
| 414 | texts: texts("PNG\u0000old", "PNG\u0000new"), | |
| 415 | }); | |
| 416 | ||
| 417 | expect(patch.section).toContain( | |
| 418 | 'Binary files "a/logo\\tmark.png" and "b/logo\\tmark.png" differ', | |
| 419 | ); | |
| 420 | }); | |
| 421 | ||
| 422 | it("hands a reader of the header back the name Azure gave", () => { | |
| 423 | const path = 'every\t\n"kind"\\of.md'; | |
| 424 | const patch = azureDevOpsFilePatch({ | |
| 425 | change: change({ path, oldPath: path }), | |
| 426 | texts: texts("one\n", "two\n"), | |
| 427 | }); | |
| 428 | const [header, oldLine, newLine] = patch.section.split("\n"); | |
| 429 | ||
| 430 | expect(header).not.toContain("\t"); | |
| 431 | expect(unquoteGitPatchPath(oldLine?.slice(4) ?? "")).toBe(`a/${path}`); | |
| 432 | expect(unquoteGitPatchPath(newLine?.slice(4) ?? "")).toBe(`b/${path}`); | |
| 433 | }); | |
| 434 | }); | |
| 435 | ||
| 436 | describe("a diff cursor", () => { | |
| 437 | it("carries the push it was taken against back to the next slice", () => { | |
| 438 | const cursor = formatAzureDevOpsDiffCursor({ iterationId: 3, fileIndex: 12 }); | |
| 439 | ||
| 440 | expect(parseAzureDevOpsDiffCursor(cursor)).toEqual({ iterationId: 3, fileIndex: 12 }); | |
| 441 | }); | |
| 442 | ||
| 443 | it("reads anything it did not write as no position at all", () => { | |
| 444 | // Which starts the read from the top rather than failing it: a cursor is the client's to | |
| 445 | // hand back, and nothing downstream is worth refusing a whole diff over. | |
| 446 | for (const raw of [undefined, null, "", "abc", "1", "0:4", "1:-2", "1:2:3"]) { | |
| 447 | expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); | |
| 448 | } | |
| 449 | }); | |
| 450 | ||
| 451 | it("refuses a half it did not write rather than reading it as the first file", () => { | |
| 452 | // `Number` is wider than the cursor: an empty, padded or hex half would otherwise pass as a | |
| 453 | // position, and the read would resume against an iteration the client never saw instead of | |
| 454 | // starting again from the latest one. | |
| 455 | for (const raw of ["1:", ":4", "1: ", " 1:4", "1:0x2", "0x1:2", "1e2:0", "1:4.0"]) { | |
| 456 | expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); | |
| 457 | } | |
| 458 | }); | |
| 459 | }); |
packages/source-control-azure-devops/src/server/azureDevOpsDiff.ts created+273| ... | ... | @@ -0,0 +1,273 @@ |
| 1 | import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | |
| 2 | import { structuredPatch } from "diff"; | |
| 3 | ||
| 4 | import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; | |
| 5 | ||
| 6 | /** | |
| 7 | * How far a diff read got, and which push it was reading. Azure hangs a pull request's changed | |
| 8 | * files off an iteration, so a push landing mid-read would renumber the list under a cursor that | |
| 9 | * did not also pin the iteration. | |
| 10 | */ | |
| 11 | export interface AzureDevOpsDiffCursor { | |
| 12 | readonly iterationId: number; | |
| 13 | readonly fileIndex: number; | |
| 14 | } | |
| 15 | ||
| 16 | const CURSOR_SEPARATOR = ":"; | |
| 17 | ||
| 18 | /** | |
| 19 | * Restricts each half to plain decimal, so `Number` cannot read a foreign cursor's `0x3` as | |
| 20 | * three. | |
| 21 | */ | |
| 22 | const CURSOR_COMPONENT = /^\d+$/; | |
| 23 | ||
| 24 | export function formatAzureDevOpsDiffCursor(cursor: AzureDevOpsDiffCursor): string { | |
| 25 | return `${cursor.iterationId}${CURSOR_SEPARATOR}${cursor.fileIndex}`; | |
| 26 | } | |
| 27 | ||
| 28 | /** Null for anything this did not write, which starts the read from the top rather than failing. */ | |
| 29 | export function parseAzureDevOpsDiffCursor( | |
| 30 | raw: string | null | undefined, | |
| 31 | ): AzureDevOpsDiffCursor | null { | |
| 32 | if (raw === null || raw === undefined) return null; | |
| 33 | const [iteration, file, ...rest] = raw.split(CURSOR_SEPARATOR); | |
| 34 | if (rest.length > 0) return null; | |
| 35 | if (iteration === undefined || file === undefined) return null; | |
| 36 | if (!CURSOR_COMPONENT.test(iteration) || !CURSOR_COMPONENT.test(file)) return null; | |
| 37 | const iterationId = Number(iteration); | |
| 38 | const fileIndex = Number(file); | |
| 39 | if (!Number.isSafeInteger(iterationId) || iterationId <= 0) return null; | |
| 40 | if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) return null; | |
| 41 | return { iterationId, fileIndex }; | |
| 42 | } | |
| 43 | ||
| 44 | /** The two texts of one changed file, empty on whichever side the change does not have. */ | |
| 45 | export interface AzureDevOpsFileTexts { | |
| 46 | readonly oldContents: string; | |
| 47 | readonly newContents: string; | |
| 48 | /** Azure's own flag for a file it hands back base64-encoded instead of as text. */ | |
| 49 | readonly binary: boolean; | |
| 50 | } | |
| 51 | ||
| 52 | export interface AzureDevOpsFilePatch { | |
| 53 | readonly section: string; | |
| 54 | /** The file changed but its hunks are not in the section, so the patch has a hole in it. */ | |
| 55 | readonly truncated: boolean; | |
| 56 | /** | |
| 57 | * The diff was given up on partway, having spent the whole edit budget, so a caller reading a | |
| 58 | * run of files stops here rather than paying that again for each one behind it. | |
| 59 | */ | |
| 60 | readonly abandoned: boolean; | |
| 61 | /** | |
| 62 | * Lines added or removed, which is the edit distance the diff had to search out, and what a | |
| 63 | * slice's budget is spent in: a file of short lines is cheap on the wire and dear to diff. | |
| 64 | */ | |
| 65 | readonly edits: number; | |
| 66 | } | |
| 67 | ||
| 68 | /** | |
| 69 | * Beyond this a file is shown as changed without its hunks. Azure hands back whole files rather | |
| 70 | * than a patch, so an oversize file is paid for twice over before anything can be diffed. | |
| 71 | */ | |
| 72 | const MAX_FILE_BYTES = 512 * 1024; | |
| 73 | ||
| 74 | /** Git's own default, and what the hunks from this repo's other hosts are already cut to. */ | |
| 75 | const PATCH_CONTEXT_LINES = 3; | |
| 76 | ||
| 77 | /** | |
| 78 | * How far apart one file's two sides may be before it is listed without its hunks. The line diff | |
| 79 | * costs about the square of the edit distance, so an unbounded pair sharing almost nothing could | |
| 80 | * hold the whole server. Bounded in edits rather than milliseconds so a change slices the same | |
| 81 | * way on every machine. | |
| 82 | */ | |
| 83 | export const MAX_FILE_DIFF_EDITS = 2_000; | |
| 84 | ||
| 85 | /** | |
| 86 | * A backstop for a machine slower than the edit ceiling was tuned for. The ceiling rather than | |
| 87 | * this is what decides a patch's shape: a timeout that fires would slice the same change one way | |
| 88 | * here and another on a busier host. | |
| 89 | */ | |
| 90 | const MAX_FILE_DIFF_MILLIS = 2_000; | |
| 91 | ||
| 92 | /** How much diff work one slice does before the rest is left for the next request. */ | |
| 93 | export const MAX_DIFF_SLICE_EDITS = 6_000; | |
| 94 | ||
| 95 | /** How much patch one slice carries before the rest is left for the next request. */ | |
| 96 | export const MAX_DIFF_SLICE_BYTES = 256 * 1024; | |
| 97 | ||
| 98 | /** | |
| 99 | * How many files one slice carries however little each one weighs. A binary, oversize, or | |
| 100 | * unreadable entry is just a header, so neither budget above stops a run of thousands of them. | |
| 101 | */ | |
| 102 | export const MAX_DIFF_SLICE_FILES = 300; | |
| 103 | ||
| 104 | /** Git's own note for a side whose last line has no newline after it. */ | |
| 105 | const NO_NEWLINE_MARKER = "\\ No newline at end of file"; | |
| 106 | ||
| 107 | /** A text's lines, without the empty one that a trailing newline leaves behind a split. */ | |
| 108 | function contentLines(contents: string): ReadonlyArray<string> { | |
| 109 | if (contents === "") return []; | |
| 110 | const lines = contents.split("\n"); | |
| 111 | if (lines.at(-1) === "") lines.pop(); | |
| 112 | return lines; | |
| 113 | } | |
| 114 | ||
| 115 | /** A NUL byte is git's own test for it, and it survives Azure's JSON envelope intact. */ | |
| 116 | function isBinary(contents: string): boolean { | |
| 117 | return contents.includes("\u0000"); | |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * What a file costs on the wire, which is its bytes rather than its code units: a ceiling counted | |
| 122 | * in characters lets a file of three-byte glyphs through at three times the intended size. | |
| 123 | */ | |
| 124 | export const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8"); | |
| 125 | ||
| 126 | /** | |
| 127 | * Git points an empty range at the line before it, which is line zero for a file that is wholly | |
| 128 | * new or wholly gone, and writes a single line as its number alone. | |
| 129 | */ | |
| 130 | function hunkRange(start: number, lines: number): string { | |
| 131 | if (lines === 0) return `${start - 1},0`; | |
| 132 | return lines === 1 ? String(start) : `${start},${lines}`; | |
| 133 | } | |
| 134 | ||
| 135 | /** | |
| 136 | * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file | |
| 137 | * mode, so the ordinary one stands in. Names are quoted the way git quotes them, prefix inside the | |
| 138 | * quoting: a header reader stops a bare name at its first tab or newline, so a path holding either | |
| 139 | * would be truncated and its viewed mark would land on the wrong file. | |
| 140 | */ | |
| 141 | function patchHeader(change: AzureDevOpsChangeEntry): string { | |
| 142 | const oldSide = quoteGitPatchPath(`a/${change.oldPath}`); | |
| 143 | const newSide = quoteGitPatchPath(`b/${change.path}`); | |
| 144 | const lines = [`diff --git ${oldSide} ${newSide}`]; | |
| 145 | if (change.changeKind === "new") lines.push("new file mode 100644"); | |
| 146 | if (change.changeKind === "deleted") lines.push("deleted file mode 100644"); | |
| 147 | if (change.changeKind === "rename-pure" || change.changeKind === "rename-changed") { | |
| 148 | lines.push( | |
| 149 | `rename from ${quoteGitPatchPath(change.oldPath)}`, | |
| 150 | `rename to ${quoteGitPatchPath(change.path)}`, | |
| 151 | ); | |
| 152 | } | |
| 153 | lines.push( | |
| 154 | `--- ${change.changeKind === "new" ? "/dev/null" : oldSide}`, | |
| 155 | `+++ ${change.changeKind === "deleted" ? "/dev/null" : newSide}`, | |
| 156 | ); | |
| 157 | return lines.join("\n"); | |
| 158 | } | |
| 159 | ||
| 160 | /** | |
| 161 | * A file written out as wholly replaced: every old line gone, every new line arrived, in one | |
| 162 | * hunk, which needs no edit-distance search at all. | |
| 163 | */ | |
| 164 | function replacementSection(header: string, texts: AzureDevOpsFileTexts): string { | |
| 165 | const oldLines = contentLines(texts.oldContents); | |
| 166 | const newLines = contentLines(texts.newContents); | |
| 167 | const noNewline = (contents: string, lines: ReadonlyArray<string>) => | |
| 168 | lines.length > 0 && !contents.endsWith("\n") ? [NO_NEWLINE_MARKER] : []; | |
| 169 | return [ | |
| 170 | header, | |
| 171 | `@@ -${hunkRange(1, oldLines.length)} +${hunkRange(1, newLines.length)} @@`, | |
| 172 | ...oldLines.map((line) => `-${line}`), | |
| 173 | ...noNewline(texts.oldContents, oldLines), | |
| 174 | ...newLines.map((line) => `+${line}`), | |
| 175 | ...noNewline(texts.newContents, newLines), | |
| 176 | "", | |
| 177 | ].join("\n"); | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * One file's section of a unified patch, built here because Azure has no route that returns one: | |
| 182 | * its diff routes name the files that changed, and the contents are a separate read per side. | |
| 183 | */ | |
| 184 | export function azureDevOpsFilePatch(input: { | |
| 185 | readonly change: AzureDevOpsChangeEntry; | |
| 186 | readonly texts: AzureDevOpsFileTexts; | |
| 187 | }): AzureDevOpsFilePatch { | |
| 188 | const header = patchHeader(input.change); | |
| 189 | const { oldContents, newContents } = input.texts; | |
| 190 | ||
| 191 | if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { | |
| 192 | // Git's own wording for a file it will not spell out. | |
| 193 | const oldSide = quoteGitPatchPath(`a/${input.change.oldPath}`); | |
| 194 | const newSide = quoteGitPatchPath(`b/${input.change.path}`); | |
| 195 | const binary = `Binary files ${oldSide} and ${newSide} differ`; | |
| 196 | return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false, edits: 0 }; | |
| 197 | } | |
| 198 | if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) { | |
| 199 | return { section: `${header}\n`, truncated: true, abandoned: false, edits: 0 }; | |
| 200 | } | |
| 201 | ||
| 202 | // A creation or deletion has nothing on one side, so writing both sides in full is already the | |
| 203 | // minimal patch and needs no diff search. | |
| 204 | const created = oldContents === "" && newContents !== ""; | |
| 205 | const deleted = newContents === "" && oldContents !== ""; | |
| 206 | if (created || deleted) { | |
| 207 | const contents = created ? newContents : oldContents; | |
| 208 | const lines = contentLines(contents); | |
| 209 | // A `+`/`-` marker on every line can put a side that just fits the size ceiling over it. | |
| 210 | // Checked against bytes-plus-marker-count rather than building the section, since that upper | |
| 211 | // bound is cheaper and this is exactly the file shape that would cost the most to build. | |
| 212 | if (byteLength(contents) + lines.length > MAX_FILE_BYTES) { | |
| 213 | return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; | |
| 214 | } | |
| 215 | const section = replacementSection(header, input.texts); | |
| 216 | if (byteLength(section) > MAX_FILE_BYTES) { | |
| 217 | return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; | |
| 218 | } | |
| 219 | return { section, truncated: false, abandoned: false, edits: lines.length }; | |
| 220 | } | |
| 221 | ||
| 222 | const patch = structuredPatch( | |
| 223 | `a/${input.change.oldPath}`, | |
| 224 | `b/${input.change.path}`, | |
| 225 | oldContents, | |
| 226 | newContents, | |
| 227 | undefined, | |
| 228 | undefined, | |
| 229 | { | |
| 230 | context: PATCH_CONTEXT_LINES, | |
| 231 | maxEditLength: MAX_FILE_DIFF_EDITS, | |
| 232 | timeout: MAX_FILE_DIFF_MILLIS, | |
| 233 | }, | |
| 234 | ); | |
| 235 | // Hitting the edit ceiling lists the file without hunks rather than falling back to a full | |
| 236 | // replacement: the ceiling is a distance, not a proportion, so a long file can reach it having | |
| 237 | // changed in one small corner, and both sides in full would bury that corner in a wall of text. | |
| 238 | if (patch === undefined) { | |
| 239 | return { | |
| 240 | section: `${header}\n`, | |
| 241 | truncated: true, | |
| 242 | abandoned: true, | |
| 243 | edits: MAX_FILE_DIFF_EDITS, | |
| 244 | }; | |
| 245 | } | |
| 246 | ||
| 247 | let edits = 0; | |
| 248 | const hunks = patch.hunks.map((hunk) => { | |
| 249 | for (const line of hunk.lines) { | |
| 250 | if (line.startsWith("+") || line.startsWith("-")) edits += 1; | |
| 251 | } | |
| 252 | return [ | |
| 253 | `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`, | |
| 254 | ...hunk.lines, | |
| 255 | ].join("\n"); | |
| 256 | }); | |
| 257 | // A pure rename has no hunks to give but is still listed, since dropping it would take the file | |
| 258 | // out of the change altogether. | |
| 259 | const section = hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`; | |
| 260 | // The edit ceiling bounds edit distance, not the hunks' size: a handful of very long lines plus | |
| 261 | // context can still exceed the size ceiling even well inside the edit ceiling. | |
| 262 | if (byteLength(section) > MAX_FILE_BYTES) { | |
| 263 | return { section: `${header}\n`, truncated: true, abandoned: false, edits }; | |
| 264 | } | |
| 265 | return { section, truncated: false, abandoned: false, edits }; | |
| 266 | } | |
| 267 | ||
| 268 | /** A file listed without its hunks, for when the host would not hand one of its two sides over. */ | |
| 269 | export function azureDevOpsUnreadableFilePatch( | |
| 270 | change: AzureDevOpsChangeEntry, | |
| 271 | ): AzureDevOpsFilePatch { | |
| 272 | return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false, edits: 0 }; | |
| 273 | } |
packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts created+652| ... | ... | @@ -0,0 +1,652 @@ |
| 1 | import * as Result from "effect/Result"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | decodeItemContentJson, | |
| 6 | decodeIterationChangesJson, | |
| 7 | decodeIterationsJson, | |
| 8 | decodePullRequestJson, | |
| 9 | decodePullRequestListJson, | |
| 10 | decodeThreadsJson, | |
| 11 | decodeViewerJson, | |
| 12 | } from "./azureDevOpsPullRequestJson.ts"; | |
| 13 | ||
| 14 | const REST_URL = | |
| 15 | "https://dev.azure.com/acme/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/pullRequests/42"; | |
| 16 | ||
| 17 | /** Shaped after Azure's `GitPullRequest`, trimmed to the fields that are read. */ | |
| 18 | function pullRequest(overrides: Record<string, unknown> = {}): Record<string, unknown> { | |
| 19 | return { | |
| 20 | pullRequestId: 42, | |
| 21 | title: "Add the change requests page", | |
| 22 | description: "Ships the page.", | |
| 23 | status: "active", | |
| 24 | isDraft: false, | |
| 25 | mergeStatus: "succeeded", | |
| 26 | createdBy: { displayName: "Bilal Hassan", uniqueName: "bilal@acme.dev" }, | |
| 27 | sourceRefName: "refs/heads/feat/page", | |
| 28 | targetRefName: "refs/heads/main", | |
| 29 | creationDate: "2026-07-01T00:00:00Z", | |
| 30 | url: REST_URL, | |
| 31 | repository: { name: "web", project: { name: "platform" } }, | |
| 32 | ...overrides, | |
| 33 | }; | |
| 34 | } | |
| 35 | ||
| 36 | function expectSuccess<A>(result: Result.Result<A, unknown>): A { | |
| 37 | expect(Result.isSuccess(result)).toBe(true); | |
| 38 | if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); | |
| 39 | return result.success; | |
| 40 | } | |
| 41 | ||
| 42 | const asJson = (value: unknown) => JSON.stringify(value); | |
| 43 | ||
| 44 | describe("decodePullRequestListJson", () => { | |
| 45 | it("reads a pull request as a change request", () => { | |
| 46 | const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); | |
| 47 | ||
| 48 | expect(batch.items).toHaveLength(1); | |
| 49 | expect(batch.items[0]).toMatchObject({ | |
| 50 | number: 42, | |
| 51 | title: "Add the change requests page", | |
| 52 | // The login is an email, because that is what `az account show` reports to compare with. | |
| 53 | author: { login: "bilal@acme.dev", name: "Bilal Hassan" }, | |
| 54 | // Azure prefixes its refs, which no other host does. | |
| 55 | headBranch: "feat/page", | |
| 56 | baseBranch: "main", | |
| 57 | state: "open", | |
| 58 | isDraft: false, | |
| 59 | mergeability: "mergeable", | |
| 60 | }); | |
| 61 | }); | |
| 62 | ||
| 63 | it("assembles a browser url when Azure reports no web link", () => { | |
| 64 | const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); | |
| 65 | ||
| 66 | expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); | |
| 67 | }); | |
| 68 | ||
| 69 | it("prefers the web link Azure sends when asked for one", () => { | |
| 70 | const batch = expectSuccess( | |
| 71 | decodePullRequestListJson( | |
| 72 | asJson([ | |
| 73 | pullRequest({ | |
| 74 | _links: { | |
| 75 | web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, | |
| 76 | }, | |
| 77 | }), | |
| 78 | ]), | |
| 79 | ), | |
| 80 | ); | |
| 81 | ||
| 82 | expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); | |
| 83 | }); | |
| 84 | ||
| 85 | it.each([ | |
| 86 | ["active", "open"], | |
| 87 | ["completed", "merged"], | |
| 88 | ["abandoned", "closed"], | |
| 89 | ["something new", "open"], | |
| 90 | ])("reads the %s status as %s", (status, expected) => { | |
| 91 | const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ status })]))); | |
| 92 | ||
| 93 | expect(batch.items[0]?.state).toBe(expected); | |
| 94 | }); | |
| 95 | ||
| 96 | it.each([ | |
| 97 | ["succeeded", "mergeable"], | |
| 98 | ["conflicts", "conflicting"], | |
| 99 | ["rejectedByPolicy", "conflicting"], | |
| 100 | ["queued", "unknown"], | |
| 101 | ["notSet", "unknown"], | |
| 102 | ])("reads the %s merge status as %s", (mergeStatus, expected) => { | |
| 103 | const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ mergeStatus })]))); | |
| 104 | ||
| 105 | expect(batch.items[0]?.mergeability).toBe(expected); | |
| 106 | }); | |
| 107 | ||
| 108 | it("stands the closing time in for a last-touched time Azure does not keep", () => { | |
| 109 | const batch = expectSuccess( | |
| 110 | decodePullRequestListJson( | |
| 111 | asJson([pullRequest({ status: "completed", closedDate: "2026-07-05T00:00:00Z" })]), | |
| 112 | ), | |
| 113 | ); | |
| 114 | ||
| 115 | expect(batch.items[0]).toMatchObject({ | |
| 116 | createdAt: "2026-07-01T00:00:00Z", | |
| 117 | updatedAt: "2026-07-05T00:00:00Z", | |
| 118 | }); | |
| 119 | }); | |
| 120 | ||
| 121 | it("skips a malformed row but still counts it, so paging does not stop early", () => { | |
| 122 | const batch = expectSuccess( | |
| 123 | decodePullRequestListJson(asJson([{ pullRequestId: "nope" }, pullRequest()])), | |
| 124 | ); | |
| 125 | ||
| 126 | expect(batch.items).toHaveLength(1); | |
| 127 | expect(batch.rawCount).toBe(2); | |
| 128 | expect(batch.rawIndexes).toEqual([1]); | |
| 129 | }); | |
| 130 | }); | |
| 131 | ||
| 132 | describe("decodePullRequestJson", () => { | |
| 133 | it("reads reviewers as review requests", () => { | |
| 134 | const detail = expectSuccess( | |
| 135 | decodePullRequestJson( | |
| 136 | asJson( | |
| 137 | pullRequest({ | |
| 138 | reviewers: [{ displayName: "Julius", uniqueName: "julius@acme.dev", vote: 10 }], | |
| 139 | }), | |
| 140 | ), | |
| 141 | ), | |
| 142 | ); | |
| 143 | ||
| 144 | expect(detail?.reviewRequestLogins).toEqual(["julius@acme.dev"]); | |
| 145 | expect(detail?.reviewers).toEqual([ | |
| 146 | { login: "julius@acme.dev", name: "Julius", avatarUrl: null }, | |
| 147 | ]); | |
| 148 | }); | |
| 149 | ||
| 150 | it("reads auto-complete from whoever armed it, and its absence as nobody", () => { | |
| 151 | const armed = expectSuccess( | |
| 152 | decodePullRequestJson( | |
| 153 | asJson(pullRequest({ autoCompleteSetBy: { displayName: "Bilal Hassan" } })), | |
| 154 | ), | |
| 155 | ); | |
| 156 | expect(armed?.autoMergeEnabled).toBe(true); | |
| 157 | ||
| 158 | // Azure leaves the field out entirely rather than sending it empty, so its absence is the | |
| 159 | // whole of what it says about auto-complete being off. | |
| 160 | expect(expectSuccess(decodePullRequestJson(asJson(pullRequest())))?.autoMergeEnabled).toBe( | |
| 161 | false, | |
| 162 | ); | |
| 163 | }); | |
| 164 | ||
| 165 | it("keeps the strategy stored with auto-complete", () => { | |
| 166 | const armed = expectSuccess( | |
| 167 | decodePullRequestJson( | |
| 168 | asJson( | |
| 169 | pullRequest({ | |
| 170 | autoCompleteSetBy: { displayName: "Bilal Hassan" }, | |
| 171 | completionOptions: { mergeStrategy: "squash" }, | |
| 172 | }), | |
| 173 | ), | |
| 174 | ), | |
| 175 | ); | |
| 176 | ||
| 177 | expect(armed).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); | |
| 178 | ||
| 179 | const unspecified = expectSuccess( | |
| 180 | decodePullRequestJson( | |
| 181 | asJson( | |
| 182 | pullRequest({ | |
| 183 | autoCompleteSetBy: { displayName: "Bilal Hassan" }, | |
| 184 | completionOptions: { squashMerge: false }, | |
| 185 | }), | |
| 186 | ), | |
| 187 | ), | |
| 188 | ); | |
| 189 | expect(unspecified?.autoMergeEnabled).toBe(true); | |
| 190 | expect(unspecified?.autoMergeMethod).toBeUndefined(); | |
| 191 | }); | |
| 192 | ||
| 193 | it("works out where the repository lives from what Azure returned", () => { | |
| 194 | const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest()))); | |
| 195 | ||
| 196 | expect(detail?.location).toEqual({ project: "platform", repository: "web" }); | |
| 197 | }); | |
| 198 | ||
| 199 | it("reports no repository location when Azure said too little to name one", () => { | |
| 200 | // A web link places the pull request, but with no repository named there is nothing to | |
| 201 | // address the routes that read its files and its conversation. | |
| 202 | const detail = expectSuccess( | |
| 203 | decodePullRequestJson( | |
| 204 | asJson( | |
| 205 | pullRequest({ | |
| 206 | url: null, | |
| 207 | repository: null, | |
| 208 | _links: { | |
| 209 | web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, | |
| 210 | }, | |
| 211 | }), | |
| 212 | ), | |
| 213 | ), | |
| 214 | ); | |
| 215 | ||
| 216 | expect(detail?.location).toBeNull(); | |
| 217 | }); | |
| 218 | ||
| 219 | it("returns nothing when Azure gave no way to place the pull request at all", () => { | |
| 220 | const detail = expectSuccess( | |
| 221 | decodePullRequestJson(asJson(pullRequest({ url: null, repository: null }))), | |
| 222 | ); | |
| 223 | ||
| 224 | expect(detail).toBeNull(); | |
| 225 | }); | |
| 226 | }); | |
| 227 | ||
| 228 | describe("decodeViewerJson", () => { | |
| 229 | it("reads the signed-in account name", () => { | |
| 230 | expect(expectSuccess(decodeViewerJson(asJson({ user: { name: "bilal@acme.dev" } })))).toBe( | |
| 231 | "bilal@acme.dev", | |
| 232 | ); | |
| 233 | }); | |
| 234 | ||
| 235 | it("returns nothing when nobody is signed in", () => { | |
| 236 | expect(expectSuccess(decodeViewerJson(asJson({ user: null })))).toBeNull(); | |
| 237 | }); | |
| 238 | }); | |
| 239 | ||
| 240 | describe("decodeThreadsJson", () => { | |
| 241 | it("takes every real comment of every thread, oldest first", () => { | |
| 242 | const comments = expectSuccess( | |
| 243 | decodeThreadsJson( | |
| 244 | asJson({ | |
| 245 | value: [ | |
| 246 | { | |
| 247 | id: 2, | |
| 248 | comments: [ | |
| 249 | { | |
| 250 | id: 1, | |
| 251 | content: "Second remark.", | |
| 252 | author: { displayName: "Julius", uniqueName: "julius@acme.dev" }, | |
| 253 | publishedDate: "2026-07-03T00:00:00Z", | |
| 254 | }, | |
| 255 | ], | |
| 256 | }, | |
| 257 | { | |
| 258 | id: 1, | |
| 259 | comments: [ | |
| 260 | // Azure's own activity notes are events rather than remarks. | |
| 261 | { id: 1, content: "Bilal voted", commentType: "system", publishedDate: "x" }, | |
| 262 | { | |
| 263 | id: 2, | |
| 264 | content: "First remark.", | |
| 265 | author: { displayName: "Bilal", uniqueName: "bilal@acme.dev" }, | |
| 266 | publishedDate: "2026-07-02T00:00:00Z", | |
| 267 | }, | |
| 268 | ], | |
| 269 | }, | |
| 270 | ], | |
| 271 | }), | |
| 272 | ), | |
| 273 | ); | |
| 274 | ||
| 275 | expect(comments.map((comment) => comment.body)).toEqual(["First remark.", "Second remark."]); | |
| 276 | expect(comments[0]).toMatchObject({ | |
| 277 | kind: "issue-comment", | |
| 278 | author: { login: "bilal@acme.dev" }, | |
| 279 | }); | |
| 280 | }); | |
| 281 | ||
| 282 | it("reads a thread pinned to a file as a review comment", () => { | |
| 283 | const comments = expectSuccess( | |
| 284 | decodeThreadsJson( | |
| 285 | asJson({ | |
| 286 | value: [ | |
| 287 | { | |
| 288 | id: 3, | |
| 289 | threadContext: { filePath: "/src/app.ts" }, | |
| 290 | comments: [{ id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }], | |
| 291 | }, | |
| 292 | ], | |
| 293 | }), | |
| 294 | ), | |
| 295 | ); | |
| 296 | ||
| 297 | expect(comments[0]).toMatchObject({ kind: "review-comment", path: "/src/app.ts" }); | |
| 298 | }); | |
| 299 | ||
| 300 | it("keeps the replies under a thread, which are as much of the conversation", () => { | |
| 301 | const comments = expectSuccess( | |
| 302 | decodeThreadsJson( | |
| 303 | asJson({ | |
| 304 | value: [ | |
| 305 | { | |
| 306 | id: 4, | |
| 307 | threadContext: { filePath: "/src/app.ts" }, | |
| 308 | comments: [ | |
| 309 | { id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }, | |
| 310 | { id: 2, content: "Renamed.", publishedDate: "2026-07-02T01:00:00Z" }, | |
| 311 | { id: 3, content: "Thanks.", publishedDate: "2026-07-02T02:00:00Z" }, | |
| 312 | ], | |
| 313 | }, | |
| 314 | ], | |
| 315 | }), | |
| 316 | ), | |
| 317 | ); | |
| 318 | ||
| 319 | expect(comments.map((comment) => comment.id)).toEqual(["4:1", "4:2", "4:3"]); | |
| 320 | }); | |
| 321 | ||
| 322 | it("drops deleted threads and threads with nothing to show", () => { | |
| 323 | const comments = expectSuccess( | |
| 324 | decodeThreadsJson( | |
| 325 | asJson({ | |
| 326 | value: [ | |
| 327 | { | |
| 328 | id: 1, | |
| 329 | isDeleted: true, | |
| 330 | comments: [{ id: 1, content: "gone", publishedDate: "2026-07-02T00:00:00Z" }], | |
| 331 | }, | |
| 332 | { id: 2, comments: [] }, | |
| 333 | { | |
| 334 | id: 3, | |
| 335 | comments: [{ id: 1, content: " ", publishedDate: "2026-07-02T00:00:00Z" }], | |
| 336 | }, | |
| 337 | ], | |
| 338 | }), | |
| 339 | ), | |
| 340 | ); | |
| 341 | ||
| 342 | expect(comments).toEqual([]); | |
| 343 | }); | |
| 344 | }); | |
| 345 | ||
| 346 | describe("decodeIterationsJson", () => { | |
| 347 | const iteration = (id: number, head: string, base: string) => ({ | |
| 348 | id, | |
| 349 | sourceRefCommit: { commitId: head }, | |
| 350 | commonRefCommit: { commitId: base }, | |
| 351 | targetRefCommit: { commitId: base }, | |
| 352 | }); | |
| 353 | ||
| 354 | it("reads every push in order, oldest first", () => { | |
| 355 | const iterations = expectSuccess( | |
| 356 | decodeIterationsJson( | |
| 357 | asJson({ value: [iteration(2, "bbb", "base"), iteration(1, "aaa", "base")] }), | |
| 358 | ), | |
| 359 | ); | |
| 360 | ||
| 361 | expect(iterations.map((entry) => entry.id)).toEqual([1, 2]); | |
| 362 | expect(iterations.at(-1)).toEqual({ id: 2, headCommit: "bbb", mergeBaseCommit: "base" }); | |
| 363 | }); | |
| 364 | ||
| 365 | it("skips a push Azure could not place both ends of", () => { | |
| 366 | // A patch is taken over a range, and an iteration missing either end names no range at all. | |
| 367 | const iterations = expectSuccess( | |
| 368 | decodeIterationsJson( | |
| 369 | asJson({ | |
| 370 | value: [{ id: 1, sourceRefCommit: { commitId: "aaa" } }, iteration(2, "bbb", "base")], | |
| 371 | }), | |
| 372 | ), | |
| 373 | ); | |
| 374 | ||
| 375 | expect(iterations.map((entry) => entry.id)).toEqual([2]); | |
| 376 | }); | |
| 377 | }); | |
| 378 | ||
| 379 | describe("decodeIterationChangesJson", () => { | |
| 380 | it("names each changed file without the slash Azure leads its paths with", () => { | |
| 381 | const page = expectSuccess( | |
| 382 | decodeIterationChangesJson( | |
| 383 | asJson({ | |
| 384 | changeEntries: [ | |
| 385 | { changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }, | |
| 386 | { | |
| 387 | changeType: "edit", | |
| 388 | item: { path: "/README.md", objectId: "8f80", originalObjectId: "0ca4" }, | |
| 389 | }, | |
| 390 | { changeType: "delete", item: { path: "/OLD.md", originalObjectId: "1111" } }, | |
| 391 | ], | |
| 392 | }), | |
| 393 | ), | |
| 394 | ); | |
| 395 | ||
| 396 | expect(page.changes.map((change) => [change.path, change.changeKind])).toEqual([ | |
| 397 | ["DEMO.md", "new"], | |
| 398 | ["README.md", "change"], | |
| 399 | ["OLD.md", "deleted"], | |
| 400 | ]); | |
| 401 | }); | |
| 402 | ||
| 403 | it("keeps a space at the end of a file's name, which belongs to the name", () => { | |
| 404 | // Git will carry a name that ends in a space, and the patch and the viewed mark are both | |
| 405 | // keyed by it. Tidying it here files the change under a name nothing else uses. | |
| 406 | const page = expectSuccess( | |
| 407 | decodeIterationChangesJson( | |
| 408 | asJson({ | |
| 409 | changeEntries: [ | |
| 410 | { changeType: "edit", item: { path: "/docs/readme.md ", objectId: "ec00" } }, | |
| 411 | { | |
| 412 | changeType: "rename", | |
| 413 | sourceServerItem: "/docs/old.md ", | |
| 414 | item: { path: "/docs/moved.md", objectId: "aaaa", originalObjectId: "aaaa" }, | |
| 415 | }, | |
| 416 | ], | |
| 417 | }), | |
| 418 | ), | |
| 419 | ); | |
| 420 | ||
| 421 | expect(page.changes.map((change) => [change.path, change.oldPath])).toEqual([ | |
| 422 | ["docs/readme.md ", "docs/readme.md "], | |
| 423 | ["docs/moved.md", "docs/old.md "], | |
| 424 | ]); | |
| 425 | }); | |
| 426 | ||
| 427 | it("reads a rename as one file that moved, and says whether it also changed", () => { | |
| 428 | const page = expectSuccess( | |
| 429 | decodeIterationChangesJson( | |
| 430 | asJson({ | |
| 431 | changeEntries: [ | |
| 432 | { | |
| 433 | changeType: "rename", | |
| 434 | sourceServerItem: "/docs/old.md", | |
| 435 | item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, | |
| 436 | }, | |
| 437 | { | |
| 438 | changeType: "edit, rename", | |
| 439 | sourceServerItem: "/src/old.ts", | |
| 440 | item: { path: "/src/new.ts", objectId: "bbbb", originalObjectId: "cccc" }, | |
| 441 | }, | |
| 442 | ], | |
| 443 | }), | |
| 444 | ), | |
| 445 | ); | |
| 446 | ||
| 447 | expect(page.changes).toEqual([ | |
| 448 | { | |
| 449 | path: "docs/new.md", | |
| 450 | oldPath: "docs/old.md", | |
| 451 | changeKind: "rename-pure", | |
| 452 | objectId: "aaaa", | |
| 453 | originalObjectId: "aaaa", | |
| 454 | }, | |
| 455 | { | |
| 456 | path: "src/new.ts", | |
| 457 | oldPath: "src/old.ts", | |
| 458 | changeKind: "rename-changed", | |
| 459 | objectId: "bbbb", | |
| 460 | originalObjectId: "cccc", | |
| 461 | }, | |
| 462 | ]); | |
| 463 | }); | |
| 464 | ||
| 465 | it("drops the folders Azure lists alongside the files that changed", () => { | |
| 466 | // A review shows files, and a folder has no content on either side to show for one. | |
| 467 | const page = expectSuccess( | |
| 468 | decodeIterationChangesJson( | |
| 469 | asJson({ | |
| 470 | changeEntries: [ | |
| 471 | { changeType: "add", item: { path: "/docs", isFolder: true, gitObjectType: "tree" } }, | |
| 472 | { changeType: "add", item: { path: "/docs/page.md", objectId: "dddd" } }, | |
| 473 | ], | |
| 474 | }), | |
| 475 | ), | |
| 476 | ); | |
| 477 | ||
| 478 | expect(page.changes.map((change) => change.path)).toEqual(["docs/page.md"]); | |
| 479 | }); | |
| 480 | ||
| 481 | it("carries where the next page of a long change starts", () => { | |
| 482 | const page = expectSuccess( | |
| 483 | decodeIterationChangesJson( | |
| 484 | asJson({ | |
| 485 | changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], | |
| 486 | nextSkip: 2000, | |
| 487 | }), | |
| 488 | ), | |
| 489 | ); | |
| 490 | ||
| 491 | expect(page.nextSkip).toBe(2000); | |
| 492 | }); | |
| 493 | ||
| 494 | it("reads the last page, which names no page after it, as the end of the change", () => { | |
| 495 | const page = expectSuccess( | |
| 496 | decodeIterationChangesJson( | |
| 497 | asJson({ | |
| 498 | changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], | |
| 499 | }), | |
| 500 | ), | |
| 501 | ); | |
| 502 | ||
| 503 | expect(page.nextSkip).toBeNull(); | |
| 504 | }); | |
| 505 | ||
| 506 | it("reads where a rename came from out of either of the two places Azure names it", () => { | |
| 507 | // The iteration-changes route answers with `originalPath`; the commit routes answer with | |
| 508 | // `sourceServerItem`, and both are the same fact under two names. | |
| 509 | const page = expectSuccess( | |
| 510 | decodeIterationChangesJson( | |
| 511 | asJson({ | |
| 512 | changeEntries: [ | |
| 513 | { | |
| 514 | changeType: "rename", | |
| 515 | originalPath: "/docs/old.md", | |
| 516 | item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, | |
| 517 | }, | |
| 518 | ], | |
| 519 | }), | |
| 520 | ), | |
| 521 | ); | |
| 522 | ||
| 523 | expect(page.changes.at(0)?.oldPath).toBe("docs/old.md"); | |
| 524 | }); | |
| 525 | }); | |
| 526 | ||
| 527 | describe("decodeItemContentJson", () => { | |
| 528 | it("reads the file's text out of the envelope Azure wraps it in", () => { | |
| 529 | expect( | |
| 530 | expectSuccess(decodeItemContentJson(asJson({ path: "/a.md", content: "one\ntwo" }))), | |
| 531 | ).toEqual({ contents: "one\ntwo", isBinary: false }); | |
| 532 | }); | |
| 533 | ||
| 534 | it("reads an empty file as empty rather than as a failure to look", () => { | |
| 535 | expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toEqual({ | |
| 536 | contents: "", | |
| 537 | isBinary: false, | |
| 538 | }); | |
| 539 | }); | |
| 540 | ||
| 541 | it("keeps Azure's own word that a file is binary", () => { | |
| 542 | // Which it answers base64-encoded, so nothing in the text it sent would give it away. | |
| 543 | expect( | |
| 544 | expectSuccess( | |
| 545 | decodeItemContentJson( | |
| 546 | asJson({ path: "/logo.png", content: "b2xk", contentMetadata: { isBinary: true } }), | |
| 547 | ), | |
| 548 | ), | |
| 549 | ).toEqual({ contents: "b2xk", isBinary: true }); | |
| 550 | }); | |
| 551 | }); | |
| 552 | ||
| 553 | /** | |
| 554 | * Every other fixture in this file is written from Azure's published contract. These are the | |
| 555 | * shapes a real organisation answered with, read back through `az devops invoke` rather than | |
| 556 | * `az rest`: the extension hands over the route's own body with one key of its own added, and a | |
| 557 | * live repository leaves out fields the contract documents. | |
| 558 | */ | |
| 559 | describe("what az devops invoke answers with", () => { | |
| 560 | it("reads the envelope the extension adds its own continuation token to", () => { | |
| 561 | // Set on every JSON body it returns, from a response header these routes do not send, so it | |
| 562 | // arrives as null rather than not at all. Nothing reads it, and it must not fail the decode. | |
| 563 | expect( | |
| 564 | expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))), | |
| 565 | ).toEqual([]); | |
| 566 | ||
| 567 | expect( | |
| 568 | expectSuccess( | |
| 569 | decodeIterationsJson( | |
| 570 | asJson({ | |
| 571 | count: 1, | |
| 572 | continuation_token: null, | |
| 573 | value: [ | |
| 574 | { | |
| 575 | id: 1, | |
| 576 | sourceRefCommit: { commitId: "f4031105213c68f197cd46ea303bb5e72acc889a" }, | |
| 577 | commonRefCommit: { commitId: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35" }, | |
| 578 | }, | |
| 579 | ], | |
| 580 | }), | |
| 581 | ), | |
| 582 | ), | |
| 583 | ).toEqual([ | |
| 584 | { | |
| 585 | id: 1, | |
| 586 | headCommit: "f4031105213c68f197cd46ea303bb5e72acc889a", | |
| 587 | mergeBaseCommit: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35", | |
| 588 | }, | |
| 589 | ]); | |
| 590 | }); | |
| 591 | ||
| 592 | it("keeps the files of a change that names neither their object type nor their page after it", () => { | |
| 593 | // A live iteration states `changeType`, `item.path` and `item.objectId` and nothing else: no | |
| 594 | // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. Each of those absences | |
| 595 | // is what the defaults in the decoder are for, and reading any of them as stated would drop | |
| 596 | // every file of every Azure change request. | |
| 597 | const page = expectSuccess( | |
| 598 | decodeIterationChangesJson( | |
| 599 | asJson({ | |
| 600 | continuation_token: null, | |
| 601 | changeEntries: [ | |
| 602 | { changeType: "add", item: { path: "/DEMO.md", objectId: "EC005DB24" } }, | |
| 603 | { changeType: "edit", item: { path: "/README.md", objectId: "8F8047A49" } }, | |
| 604 | ], | |
| 605 | }), | |
| 606 | ), | |
| 607 | ); | |
| 608 | ||
| 609 | expect(page.nextSkip).toBeNull(); | |
| 610 | expect(page.changes).toEqual([ | |
| 611 | { | |
| 612 | path: "DEMO.md", | |
| 613 | oldPath: "DEMO.md", | |
| 614 | changeKind: "new", | |
| 615 | objectId: "EC005DB24", | |
| 616 | originalObjectId: null, | |
| 617 | }, | |
| 618 | { | |
| 619 | path: "README.md", | |
| 620 | oldPath: "README.md", | |
| 621 | changeKind: "change", | |
| 622 | objectId: "8F8047A49", | |
| 623 | originalObjectId: null, | |
| 624 | }, | |
| 625 | ]); | |
| 626 | }); | |
| 627 | ||
| 628 | it("reads a text file whose content type Azure calls a stream", () => { | |
| 629 | // `contentMetadata` comes back for a markdown file with `application/octet-stream` on it and | |
| 630 | // no `isBinary` at all, so the content type is not the field to ask, and its absence is the | |
| 631 | // answer that the text is text. | |
| 632 | expect( | |
| 633 | expectSuccess( | |
| 634 | decodeItemContentJson( | |
| 635 | asJson({ | |
| 636 | path: "/README.md", | |
| 637 | objectId: "8f8047a49", | |
| 638 | gitObjectType: "blob", | |
| 639 | content: "# T3Demo\n", | |
| 640 | contentMetadata: { | |
| 641 | contentType: "application/octet-stream", | |
| 642 | encoding: 65001, | |
| 643 | extension: "md", | |
| 644 | fileName: "README.md", | |
| 645 | }, | |
| 646 | continuation_token: null, | |
| 647 | }), | |
| 648 | ), | |
| 649 | ), | |
| 650 | ).toEqual({ contents: "# T3Demo\n", isBinary: false }); | |
| 651 | }); | |
| 652 | }); |
packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts created+574| ... | ... | @@ -0,0 +1,574 @@ |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import * as Exit from "effect/Exit"; | |
| 3 | import * as Result from "effect/Result"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | import type { | |
| 6 | PullRequestActor, | |
| 7 | PullRequestComment, | |
| 8 | PullRequestMergeMethod, | |
| 9 | PullRequestMergeability, | |
| 10 | PullRequestState, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 13 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 14 | ||
| 15 | import { azureDevOpsPullRequestWebUrl } from "./azureDevOpsPullRequests.ts"; | |
| 16 | ||
| 17 | /** | |
| 18 | * Azure's enums are decoded as plain strings and normalized here, in the same tolerant style as | |
| 19 | * the other hosts: a new merge status must not fail a whole payload. Every field beyond the | |
| 20 | * identity is optional, because `az repos pr` returns rather more or less of the REST object | |
| 21 | * depending on the command. | |
| 22 | */ | |
| 23 | const RawIdentitySchema = Schema.Struct({ | |
| 24 | displayName: Schema.optional(Schema.NullOr(Schema.String)), | |
| 25 | /** An email or UPN, which is what `az account show` reports for the signed-in user. */ | |
| 26 | uniqueName: Schema.optional(Schema.NullOr(Schema.String)), | |
| 27 | imageUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 28 | }); | |
| 29 | ||
| 30 | const RawPullRequestSchema = Schema.Struct({ | |
| 31 | pullRequestId: Schema.Int, | |
| 32 | title: Schema.String, | |
| 33 | description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 34 | status: Schema.optional(Schema.NullOr(Schema.String)), | |
| 35 | isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 36 | /** | |
| 37 | * Who armed auto-complete, which is the only thing Azure says about it: the field carries an | |
| 38 | * identity while the pull request is set to complete on its own, and Azure leaves it out | |
| 39 | * entirely once nobody has. So its presence is the answer, and there is no third state. | |
| 40 | */ | |
| 41 | autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), | |
| 42 | completionOptions: Schema.optional( | |
| 43 | Schema.NullOr( | |
| 44 | Schema.Struct({ | |
| 45 | mergeStrategy: Schema.optional(Schema.NullOr(Schema.String)), | |
| 46 | squashMerge: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 47 | }), | |
| 48 | ), | |
| 49 | ), | |
| 50 | mergeStatus: Schema.optional(Schema.NullOr(Schema.String)), | |
| 51 | createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), | |
| 52 | reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))), | |
| 53 | // Required, and required to be non-empty: the wire contract will not carry a change request | |
| 54 | // without a branch or a created time, so a row missing one is skipped rather than breaking the | |
| 55 | // response it travels in. | |
| 56 | sourceRefName: TrimmedNonEmptyString, | |
| 57 | targetRefName: TrimmedNonEmptyString, | |
| 58 | creationDate: TrimmedNonEmptyString, | |
| 59 | closedDate: Schema.optional(Schema.NullOr(Schema.String)), | |
| 60 | url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 61 | repository: Schema.optional( | |
| 62 | Schema.NullOr( | |
| 63 | Schema.Struct({ | |
| 64 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 65 | webUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 66 | project: Schema.optional( | |
| 67 | Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 68 | ), | |
| 69 | }), | |
| 70 | ), | |
| 71 | ), | |
| 72 | _links: Schema.optional( | |
| 73 | Schema.NullOr( | |
| 74 | Schema.Struct({ | |
| 75 | web: Schema.optional( | |
| 76 | Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), | |
| 77 | ), | |
| 78 | }), | |
| 79 | ), | |
| 80 | ), | |
| 81 | }); | |
| 82 | ||
| 83 | /** A pull request thread, which is how Azure keeps its conversation. */ | |
| 84 | const RawThreadSchema = Schema.Struct({ | |
| 85 | id: Schema.Int, | |
| 86 | isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 87 | threadContext: Schema.optional( | |
| 88 | Schema.NullOr(Schema.Struct({ filePath: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 89 | ), | |
| 90 | comments: Schema.optional( | |
| 91 | Schema.NullOr( | |
| 92 | Schema.Array( | |
| 93 | Schema.Struct({ | |
| 94 | id: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 95 | content: Schema.optional(Schema.NullOr(Schema.String)), | |
| 96 | author: Schema.optional(Schema.NullOr(RawIdentitySchema)), | |
| 97 | publishedDate: Schema.optional(Schema.NullOr(Schema.String)), | |
| 98 | isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 99 | /** `system` marks the notes Azure writes itself, which are events, not comments. */ | |
| 100 | commentType: Schema.optional(Schema.NullOr(Schema.String)), | |
| 101 | }), | |
| 102 | ), | |
| 103 | ), | |
| 104 | ), | |
| 105 | }); | |
| 106 | ||
| 107 | const RawThreadPageSchema = Schema.Struct({ | |
| 108 | value: Schema.Array(Schema.Unknown), | |
| 109 | }); | |
| 110 | ||
| 111 | const RawViewerSchema = Schema.Struct({ | |
| 112 | user: Schema.optional( | |
| 113 | Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 114 | ), | |
| 115 | }); | |
| 116 | ||
| 117 | /** | |
| 118 | * Where a repository lives, in the terms Azure's REST routes address it by. They take the project | |
| 119 | * and the repository as separate route parameters rather than as one path, so the pair travels | |
| 120 | * together rather than as a URL that would have to be taken apart again to use. | |
| 121 | */ | |
| 122 | export interface AzureDevOpsRepositoryLocation { | |
| 123 | readonly project: string; | |
| 124 | readonly repository: string; | |
| 125 | } | |
| 126 | ||
| 127 | export interface AzureDevOpsPullRequest { | |
| 128 | readonly number: number; | |
| 129 | readonly title: string; | |
| 130 | readonly url: string; | |
| 131 | readonly author: PullRequestActor | null; | |
| 132 | readonly headBranch: string; | |
| 133 | readonly baseBranch: string; | |
| 134 | readonly state: PullRequestState; | |
| 135 | readonly isDraft: boolean; | |
| 136 | readonly mergeability: PullRequestMergeability; | |
| 137 | readonly createdAt: string; | |
| 138 | /** | |
| 139 | * Azure records no last-touched time on a pull request, so the closing time stands in where | |
| 140 | * there is one and the creation time otherwise. The same fallback the rest of the app uses. | |
| 141 | */ | |
| 142 | readonly updatedAt: string; | |
| 143 | readonly closedAt: string | null; | |
| 144 | readonly body: string; | |
| 145 | readonly reviewRequestLogins: ReadonlyArray<string>; | |
| 146 | readonly reviewers: ReadonlyArray<PullRequestActor>; | |
| 147 | /** Where this pull request lives, when Azure said enough to work it out. */ | |
| 148 | readonly location: AzureDevOpsRepositoryLocation | null; | |
| 149 | /** Whether Azure is set to complete this on its own once its policies pass. */ | |
| 150 | readonly autoMergeEnabled: boolean; | |
| 151 | /** The completion strategy Azure stored with auto-complete, where it reported one. */ | |
| 152 | readonly autoMergeMethod?: PullRequestMergeMethod; | |
| 153 | } | |
| 154 | ||
| 155 | function trimmed(value: string | null | undefined): string | null { | |
| 156 | const text = value?.trim() ?? ""; | |
| 157 | return text.length > 0 ? text : null; | |
| 158 | } | |
| 159 | ||
| 160 | function normalizeRefName(refName: string): string { | |
| 161 | return refName.trim().replace(/^refs\/heads\//, ""); | |
| 162 | } | |
| 163 | ||
| 164 | /** A login has to compare against `az account show`, which reports an email. */ | |
| 165 | function toActor(raw: Schema.Schema.Type<typeof RawIdentitySchema> | null | undefined) { | |
| 166 | const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName); | |
| 167 | return login === null | |
| 168 | ? null | |
| 169 | : { login, name: trimmed(raw?.displayName), avatarUrl: trimmed(raw?.imageUrl) }; | |
| 170 | } | |
| 171 | ||
| 172 | function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState { | |
| 173 | switch (raw.status?.trim().toLowerCase()) { | |
| 174 | case "completed": | |
| 175 | return "merged"; | |
| 176 | case "abandoned": | |
| 177 | return "closed"; | |
| 178 | default: | |
| 179 | return "open"; | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | function toMergeability(value: string | null | undefined): PullRequestMergeability { | |
| 184 | switch (value?.trim().toLowerCase()) { | |
| 185 | case "succeeded": | |
| 186 | return "mergeable"; | |
| 187 | case "conflicts": | |
| 188 | case "failure": | |
| 189 | case "rejectedbypolicy": | |
| 190 | return "conflicting"; | |
| 191 | default: | |
| 192 | // `queued` and `notSet` mean Azure has not finished checking. | |
| 193 | return "unknown"; | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * Where a pull request's own repository sits. Taken from what Azure returned rather than from the | |
| 199 | * local remote, whose shape differs between the modern, legacy and SSH forms. | |
| 200 | */ | |
| 201 | function toLocation( | |
| 202 | raw: Schema.Schema.Type<typeof RawPullRequestSchema>, | |
| 203 | ): AzureDevOpsRepositoryLocation | null { | |
| 204 | const project = trimmed(raw.repository?.project?.name); | |
| 205 | const repository = trimmed(raw.repository?.name); | |
| 206 | if (project === null || repository === null) return null; | |
| 207 | return { project, repository }; | |
| 208 | } | |
| 209 | ||
| 210 | function toAutoMergeMethod( | |
| 211 | raw: Schema.Schema.Type<typeof RawPullRequestSchema>, | |
| 212 | ): PullRequestMergeMethod | undefined { | |
| 213 | if (raw.autoCompleteSetBy == null) return undefined; | |
| 214 | switch (raw.completionOptions?.mergeStrategy?.trim().toLowerCase()) { | |
| 215 | case "squash": | |
| 216 | return "squash"; | |
| 217 | case "rebase": | |
| 218 | case "rebasemerge": | |
| 219 | return "rebase"; | |
| 220 | case "nofastforward": | |
| 221 | return "merge"; | |
| 222 | default: | |
| 223 | return raw.completionOptions?.squashMerge === true ? "squash" : undefined; | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * Null when Azure said too little to place the pull request: a row with no browser url and no | |
| 229 | * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract | |
| 230 | * refuses to carry it either. | |
| 231 | */ | |
| 232 | function toPullRequest( | |
| 233 | raw: Schema.Schema.Type<typeof RawPullRequestSchema>, | |
| 234 | ): AzureDevOpsPullRequest | null { | |
| 235 | const autoMergeMethod = toAutoMergeMethod(raw); | |
| 236 | const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { | |
| 237 | const actor = toActor(reviewer); | |
| 238 | return actor === null ? [] : [actor]; | |
| 239 | }); | |
| 240 | const closedAt = trimmed(raw.closedDate); | |
| 241 | const url = trimmed( | |
| 242 | azureDevOpsPullRequestWebUrl({ | |
| 243 | pullRequestId: raw.pullRequestId, | |
| 244 | webLink: raw._links?.web?.href, | |
| 245 | repositoryWebUrl: raw.repository?.webUrl, | |
| 246 | restApiUrl: raw.url, | |
| 247 | projectName: raw.repository?.project?.name, | |
| 248 | repositoryName: raw.repository?.name, | |
| 249 | }), | |
| 250 | ); | |
| 251 | const headBranch = trimmed(normalizeRefName(raw.sourceRefName)); | |
| 252 | const baseBranch = trimmed(normalizeRefName(raw.targetRefName)); | |
| 253 | if (url === null || headBranch === null || baseBranch === null) return null; | |
| 254 | return { | |
| 255 | number: raw.pullRequestId, | |
| 256 | title: raw.title, | |
| 257 | url, | |
| 258 | author: toActor(raw.createdBy), | |
| 259 | headBranch, | |
| 260 | baseBranch, | |
| 261 | state: toState(raw), | |
| 262 | isDraft: raw.isDraft ?? false, | |
| 263 | mergeability: toMergeability(raw.mergeStatus), | |
| 264 | createdAt: raw.creationDate, | |
| 265 | updatedAt: closedAt ?? raw.creationDate, | |
| 266 | closedAt, | |
| 267 | body: raw.description ?? "", | |
| 268 | reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), | |
| 269 | reviewers, | |
| 270 | location: toLocation(raw), | |
| 271 | autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, | |
| 272 | ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), | |
| 273 | }; | |
| 274 | } | |
| 275 | ||
| 276 | const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); | |
| 277 | const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); | |
| 278 | const decodePullRequest = decodeJsonResult(RawPullRequestSchema); | |
| 279 | const decodeThreadPage = decodeJsonResult(RawThreadPageSchema); | |
| 280 | const decodeThreadEntry = Schema.decodeUnknownExit(RawThreadSchema); | |
| 281 | const decodeViewer = decodeJsonResult(RawViewerSchema); | |
| 282 | ||
| 283 | type DecodeFailure = Cause.Cause<Schema.SchemaError>; | |
| 284 | ||
| 285 | export interface AzureDevOpsPullRequestBatch { | |
| 286 | readonly items: ReadonlyArray<AzureDevOpsPullRequest>; | |
| 287 | /** Zero-based positions of the decoded items in Azure's raw page. */ | |
| 288 | readonly rawIndexes: ReadonlyArray<number>; | |
| 289 | /** Rows Azure returned, counted before decoding, so a skipped row cannot hide a next page. */ | |
| 290 | readonly rawCount: number; | |
| 291 | } | |
| 292 | ||
| 293 | /** Malformed entries are skipped rather than failing the batch, as on the other hosts. */ | |
| 294 | export function decodePullRequestListJson( | |
| 295 | raw: string, | |
| 296 | ): Result.Result<AzureDevOpsPullRequestBatch, DecodeFailure> { | |
| 297 | const decoded = decodeUnknownList(raw); | |
| 298 | if (!Result.isSuccess(decoded)) { | |
| 299 | return Result.fail(decoded.failure); | |
| 300 | } | |
| 301 | const items: AzureDevOpsPullRequest[] = []; | |
| 302 | const rawIndexes: number[] = []; | |
| 303 | for (const [rawIndex, entry] of decoded.success.entries()) { | |
| 304 | const item = decodePullRequestEntry(entry); | |
| 305 | if (Exit.isFailure(item)) continue; | |
| 306 | const pullRequest = toPullRequest(item.value); | |
| 307 | if (pullRequest !== null) { | |
| 308 | items.push(pullRequest); | |
| 309 | rawIndexes.push(rawIndex); | |
| 310 | } | |
| 311 | } | |
| 312 | return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); | |
| 313 | } | |
| 314 | ||
| 315 | /** Null carries "Azure answered, but with too little to use", which the caller reports. */ | |
| 316 | export function decodePullRequestJson( | |
| 317 | raw: string, | |
| 318 | ): Result.Result<AzureDevOpsPullRequest | null, DecodeFailure> { | |
| 319 | const decoded = decodePullRequest(raw); | |
| 320 | return Result.isSuccess(decoded) | |
| 321 | ? Result.succeed(toPullRequest(decoded.success)) | |
| 322 | : Result.fail(decoded.failure); | |
| 323 | } | |
| 324 | ||
| 325 | /** `az account show --query user` reports the signed-in account, whose name is an email. */ | |
| 326 | export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> { | |
| 327 | const decoded = decodeViewer(raw); | |
| 328 | return Result.isSuccess(decoded) | |
| 329 | ? Result.succeed(trimmed(decoded.success.user?.name)) | |
| 330 | : Result.fail(decoded.failure); | |
| 331 | } | |
| 332 | ||
| 333 | /** | |
| 334 | * Azure keeps its conversation as threads of comments, and every one of them is a remark | |
| 335 | * somebody wrote: a reply under a thread is as much of the conversation as the line that opened | |
| 336 | * it. A thread pinned to a file is a line-level review comment. | |
| 337 | * | |
| 338 | * Azure answers the whole thread collection in one response, with no cursor and no page to | |
| 339 | * follow, so what this returns is everything the host has. | |
| 340 | */ | |
| 341 | export function decodeThreadsJson( | |
| 342 | raw: string, | |
| 343 | ): Result.Result<ReadonlyArray<PullRequestComment>, DecodeFailure> { | |
| 344 | const decoded = decodeThreadPage(raw); | |
| 345 | if (!Result.isSuccess(decoded)) { | |
| 346 | return Result.fail(decoded.failure); | |
| 347 | } | |
| 348 | const comments: PullRequestComment[] = []; | |
| 349 | for (const entry of decoded.success.value) { | |
| 350 | const decodedThread = decodeThreadEntry(entry); | |
| 351 | if (Exit.isFailure(decodedThread)) continue; | |
| 352 | const thread = decodedThread.value; | |
| 353 | if (thread.isDeleted === true) continue; | |
| 354 | const path = trimmed(thread.threadContext?.filePath); | |
| 355 | for (const comment of thread.comments ?? []) { | |
| 356 | const publishedDate = trimmed(comment.publishedDate); | |
| 357 | if ( | |
| 358 | comment.isDeleted === true || | |
| 359 | comment.commentType?.trim().toLowerCase() === "system" || | |
| 360 | (comment.content ?? "").trim().length === 0 || | |
| 361 | publishedDate === null | |
| 362 | ) { | |
| 363 | continue; | |
| 364 | } | |
| 365 | comments.push({ | |
| 366 | id: `${thread.id}:${comment.id ?? 0}`, | |
| 367 | kind: path === null ? "issue-comment" : "review-comment", | |
| 368 | author: toActor(comment.author), | |
| 369 | body: comment.content ?? "", | |
| 370 | createdAt: publishedDate, | |
| 371 | url: null, | |
| 372 | path, | |
| 373 | reviewState: null, | |
| 374 | }); | |
| 375 | } | |
| 376 | } | |
| 377 | return Result.succeed( | |
| 378 | comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), | |
| 379 | ); | |
| 380 | } | |
| 381 | ||
| 382 | /** | |
| 383 | * One push's worth of a pull request. Azure records every push as an iteration and keys the whole | |
| 384 | * review off them: the changed files, and the marks a reader leaves on those files, both hang | |
| 385 | * from an iteration rather than from the pull request. | |
| 386 | */ | |
| 387 | const RawIterationSchema = Schema.Struct({ | |
| 388 | id: Schema.Int, | |
| 389 | sourceRefCommit: Schema.optional( | |
| 390 | Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 391 | ), | |
| 392 | commonRefCommit: Schema.optional( | |
| 393 | Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 394 | ), | |
| 395 | }); | |
| 396 | ||
| 397 | const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) }); | |
| 398 | ||
| 399 | const RawChangeEntrySchema = Schema.Struct({ | |
| 400 | changeType: Schema.optional(Schema.NullOr(Schema.String)), | |
| 401 | sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)), | |
| 402 | /** Where a renamed file came from. Azure states it here on an iteration's changes. */ | |
| 403 | originalPath: Schema.optional(Schema.NullOr(Schema.String)), | |
| 404 | item: Schema.optional( | |
| 405 | Schema.NullOr( | |
| 406 | Schema.Struct({ | |
| 407 | path: Schema.optional(Schema.NullOr(Schema.String)), | |
| 408 | objectId: Schema.optional(Schema.NullOr(Schema.String)), | |
| 409 | originalObjectId: Schema.optional(Schema.NullOr(Schema.String)), | |
| 410 | /** Azure marks a directory this way; a review has nothing to show for one. */ | |
| 411 | isFolder: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 412 | gitObjectType: Schema.optional(Schema.NullOr(Schema.String)), | |
| 413 | }), | |
| 414 | ), | |
| 415 | ), | |
| 416 | }); | |
| 417 | ||
| 418 | const RawChangePageSchema = Schema.Struct({ | |
| 419 | changeEntries: Schema.Array(Schema.Unknown), | |
| 420 | /** Where the page after this one starts. Azure leaves it out on the last page. */ | |
| 421 | nextSkip: Schema.optional(Schema.NullOr(Schema.Number)), | |
| 422 | }); | |
| 423 | ||
| 424 | const RawItemContentSchema = Schema.Struct({ | |
| 425 | content: Schema.optional(Schema.NullOr(Schema.String)), | |
| 426 | /** What Azure makes of the file it is handing over, which is where it says it is not text. */ | |
| 427 | contentMetadata: Schema.optional( | |
| 428 | Schema.NullOr(Schema.Struct({ isBinary: Schema.optional(Schema.NullOr(Schema.Boolean)) })), | |
| 429 | ), | |
| 430 | }); | |
| 431 | ||
| 432 | /** The head and the merge base of one iteration, which is the range its patch is taken over. */ | |
| 433 | export interface AzureDevOpsIteration { | |
| 434 | readonly id: number; | |
| 435 | readonly headCommit: string; | |
| 436 | readonly mergeBaseCommit: string; | |
| 437 | } | |
| 438 | ||
| 439 | /** | |
| 440 | * What one file did across an iteration. `oldPath` differs from `path` only for a rename, which | |
| 441 | * Azure reports by naming the file's previous home rather than as a delete and an add. | |
| 442 | */ | |
| 443 | export interface AzureDevOpsChangeEntry { | |
| 444 | readonly path: string; | |
| 445 | readonly oldPath: string; | |
| 446 | readonly changeKind: "new" | "deleted" | "change" | "rename-pure" | "rename-changed"; | |
| 447 | readonly objectId: string | null; | |
| 448 | readonly originalObjectId: string | null; | |
| 449 | } | |
| 450 | ||
| 451 | /** | |
| 452 | * One page of what an iteration changed, and where the next one starts. Azure pages this route | |
| 453 | * rather than answering with the whole change, so a review large enough to be paged is followed | |
| 454 | * to its end instead of being cut off at the first page's worth. | |
| 455 | */ | |
| 456 | export interface AzureDevOpsChangePage { | |
| 457 | readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>; | |
| 458 | readonly nextSkip: number | null; | |
| 459 | } | |
| 460 | ||
| 461 | /** One file's text at one commit, and whether Azure says the text is text at all. */ | |
| 462 | export interface AzureDevOpsItemContent { | |
| 463 | readonly contents: string; | |
| 464 | readonly isBinary: boolean; | |
| 465 | } | |
| 466 | ||
| 467 | const decodeIterationPage = decodeJsonResult(RawIterationPageSchema); | |
| 468 | const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema); | |
| 469 | const decodeChangePage = decodeJsonResult(RawChangePageSchema); | |
| 470 | const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema); | |
| 471 | const decodeItemContent = decodeJsonResult(RawItemContentSchema); | |
| 472 | ||
| 473 | /** | |
| 474 | * Azure leads a path with a slash that every other host and every patch omits. Not trimmed like | |
| 475 | * the rest of this payload, since a leading/trailing space is a legal part of a file's name and | |
| 476 | * the patch and viewed mark are keyed by the untrimmed path. | |
| 477 | */ | |
| 478 | function toRepositoryPath(value: string | null | undefined): string | null { | |
| 479 | if (value === undefined || value === null) return null; | |
| 480 | const path = value.replace(/^\/+/, ""); | |
| 481 | return path.length === 0 ? null : path; | |
| 482 | } | |
| 483 | ||
| 484 | /** | |
| 485 | * Azure names a change with one word or two, and a rename arrives either alone or alongside the | |
| 486 | * edit that came with it. Anything it has added since reads as a plain change, which shows the | |
| 487 | * file rather than dropping it from the review. | |
| 488 | */ | |
| 489 | function toChangeKind( | |
| 490 | raw: string | null | undefined, | |
| 491 | renamed: boolean, | |
| 492 | ): AzureDevOpsChangeEntry["changeKind"] { | |
| 493 | const parts = new Set( | |
| 494 | (raw ?? "") | |
| 495 | .toLowerCase() | |
| 496 | .split(",") | |
| 497 | .map((part) => part.trim()) | |
| 498 | .filter((part) => part.length > 0), | |
| 499 | ); | |
| 500 | if (parts.has("delete")) return "deleted"; | |
| 501 | if (renamed || parts.has("rename")) return parts.has("edit") ? "rename-changed" : "rename-pure"; | |
| 502 | if (parts.has("add")) return "new"; | |
| 503 | return "change"; | |
| 504 | } | |
| 505 | ||
| 506 | export function decodeIterationsJson( | |
| 507 | raw: string, | |
| 508 | ): Result.Result<ReadonlyArray<AzureDevOpsIteration>, DecodeFailure> { | |
| 509 | const decoded = decodeIterationPage(raw); | |
| 510 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 511 | const iterations: AzureDevOpsIteration[] = []; | |
| 512 | for (const entry of decoded.success.value) { | |
| 513 | const decodedIteration = decodeIterationEntry(entry); | |
| 514 | if (Exit.isFailure(decodedIteration)) continue; | |
| 515 | const iteration = decodedIteration.value; | |
| 516 | const headCommit = trimmed(iteration.sourceRefCommit?.commitId); | |
| 517 | const mergeBaseCommit = trimmed(iteration.commonRefCommit?.commitId); | |
| 518 | // An iteration Azure cannot place both ends of names no range, and a patch needs both. | |
| 519 | if (headCommit === null || mergeBaseCommit === null) continue; | |
| 520 | iterations.push({ id: iteration.id, headCommit, mergeBaseCommit }); | |
| 521 | } | |
| 522 | return Result.succeed(iterations.toSorted((left, right) => left.id - right.id)); | |
| 523 | } | |
| 524 | ||
| 525 | export function decodeIterationChangesJson( | |
| 526 | raw: string, | |
| 527 | ): Result.Result<AzureDevOpsChangePage, DecodeFailure> { | |
| 528 | const decoded = decodeChangePage(raw); | |
| 529 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 530 | const changes: AzureDevOpsChangeEntry[] = []; | |
| 531 | for (const entry of decoded.success.changeEntries) { | |
| 532 | const decodedChange = decodeChangeEntry(entry); | |
| 533 | if (Exit.isFailure(decodedChange)) continue; | |
| 534 | const change = decodedChange.value; | |
| 535 | const path = toRepositoryPath(change.item?.path); | |
| 536 | if (path === null) continue; | |
| 537 | // Azure lists the folders a change touched alongside the files themselves. A review shows | |
| 538 | // files, and a folder has no content to show for either side of one. | |
| 539 | if (change.item?.isFolder === true) continue; | |
| 540 | if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue; | |
| 541 | // Azure names where a renamed file came from in either of two places depending on the route | |
| 542 | // and the version, so both are read and the current path stands in when neither is there. | |
| 543 | const oldPath = | |
| 544 | toRepositoryPath(change.sourceServerItem) ?? toRepositoryPath(change.originalPath) ?? path; | |
| 545 | changes.push({ | |
| 546 | path, | |
| 547 | oldPath, | |
| 548 | changeKind: toChangeKind(change.changeType, oldPath !== path), | |
| 549 | objectId: trimmed(change.item?.objectId), | |
| 550 | originalObjectId: trimmed(change.item?.originalObjectId), | |
| 551 | }); | |
| 552 | } | |
| 553 | const nextSkip = decoded.success.nextSkip ?? null; | |
| 554 | return Result.succeed({ | |
| 555 | changes, | |
| 556 | nextSkip: nextSkip !== null && Number.isSafeInteger(nextSkip) && nextSkip > 0 ? nextSkip : null, | |
| 557 | }); | |
| 558 | } | |
| 559 | ||
| 560 | /** | |
| 561 | * Azure answers an absent file with an empty body rather than an error, which reads as empty. | |
| 562 | * Whether the bytes are text is Azure's own call, not this decoder's to guess from content. | |
| 563 | */ | |
| 564 | export function decodeItemContentJson( | |
| 565 | raw: string, | |
| 566 | ): Result.Result<AzureDevOpsItemContent, DecodeFailure> { | |
| 567 | const decoded = decodeItemContent(raw); | |
| 568 | return Result.isSuccess(decoded) | |
| 569 | ? Result.succeed({ | |
| 570 | contents: decoded.success.content ?? "", | |
| 571 | isBinary: decoded.success.contentMetadata?.isBinary === true, | |
| 572 | }) | |
| 573 | : Result.fail(decoded.failure); | |
| 574 | } |
packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts created+222| ... | ... | @@ -0,0 +1,222 @@ |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 3 | import * as Exit from "effect/Exit"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import * as Result from "effect/Result"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 8 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 9 | ||
| 10 | export interface NormalizedAzureDevOpsPullRequestRecord { | |
| 11 | readonly number: number; | |
| 12 | readonly title: string; | |
| 13 | readonly url: string; | |
| 14 | readonly baseRefName: string; | |
| 15 | readonly headRefName: string; | |
| 16 | readonly state: "open" | "closed" | "merged"; | |
| 17 | readonly isDraft?: boolean; | |
| 18 | readonly closedAt?: string | null; | |
| 19 | readonly mergedAt?: string | null; | |
| 20 | readonly updatedAt: Option.Option<DateTime.Utc>; | |
| 21 | } | |
| 22 | ||
| 23 | const AzureDevOpsPullRequestSchema = Schema.Struct({ | |
| 24 | pullRequestId: PositiveInt, | |
| 25 | title: TrimmedNonEmptyString, | |
| 26 | url: Schema.optional(Schema.String), | |
| 27 | repository: Schema.optional( | |
| 28 | Schema.Struct({ | |
| 29 | name: Schema.optional(Schema.String), | |
| 30 | webUrl: Schema.optional(Schema.String), | |
| 31 | project: Schema.optional( | |
| 32 | Schema.Struct({ | |
| 33 | name: Schema.optional(Schema.String), | |
| 34 | }), | |
| 35 | ), | |
| 36 | }), | |
| 37 | ), | |
| 38 | sourceRefName: TrimmedNonEmptyString, | |
| 39 | targetRefName: TrimmedNonEmptyString, | |
| 40 | status: Schema.String, | |
| 41 | isDraft: Schema.optional(Schema.Boolean), | |
| 42 | creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | |
| 43 | closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | |
| 44 | _links: Schema.optional( | |
| 45 | Schema.Struct({ | |
| 46 | web: Schema.optional( | |
| 47 | Schema.Struct({ | |
| 48 | href: Schema.String, | |
| 49 | }), | |
| 50 | ), | |
| 51 | }), | |
| 52 | ), | |
| 53 | }); | |
| 54 | ||
| 55 | function trimOptionalString(value: string | null | undefined): string | null { | |
| 56 | const trimmed = value?.trim() ?? ""; | |
| 57 | return trimmed.length > 0 ? trimmed : null; | |
| 58 | } | |
| 59 | ||
| 60 | function normalizeRefName(refName: string): string { | |
| 61 | return refName.trim().replace(/^refs\/heads\//, ""); | |
| 62 | } | |
| 63 | ||
| 64 | function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" | "merged" { | |
| 65 | switch (status.trim().toLowerCase()) { | |
| 66 | case "completed": | |
| 67 | return "merged"; | |
| 68 | case "abandoned": | |
| 69 | return "closed"; | |
| 70 | default: | |
| 71 | return "open"; | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | function encodeAzureDevOpsPathSegment(segment: string): string { | |
| 76 | return encodeURIComponent(segment); | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * The organization root a REST url belongs to, which is where a browser url has to be hung when | |
| 81 | * Azure answered with neither a web link nor a repository url. Read from what Azure returned | |
| 82 | * rather than from the local remote, whose shape varies. | |
| 83 | */ | |
| 84 | function azureDevOpsOrganizationBaseFromRestApiUrl( | |
| 85 | value: string | null | undefined, | |
| 86 | ): string | null { | |
| 87 | const rawUrl = trimOptionalString(value); | |
| 88 | if (!rawUrl) { | |
| 89 | return null; | |
| 90 | } | |
| 91 | ||
| 92 | try { | |
| 93 | const url = new URL(rawUrl); | |
| 94 | const hostname = url.hostname.toLowerCase(); | |
| 95 | const pathSegments = url.pathname.split("/").filter((segment) => segment.length > 0); | |
| 96 | const isAzureRestUrl = pathSegments.some((segment) => segment.toLowerCase() === "_apis"); | |
| 97 | if (!isAzureRestUrl) { | |
| 98 | return null; | |
| 99 | } | |
| 100 | ||
| 101 | if (hostname === "dev.azure.com") { | |
| 102 | const organization = pathSegments[0]; | |
| 103 | return organization ? `${url.origin}/${organization}` : null; | |
| 104 | } | |
| 105 | ||
| 106 | if (hostname.endsWith(".visualstudio.com")) { | |
| 107 | return url.origin; | |
| 108 | } | |
| 109 | ||
| 110 | return null; | |
| 111 | } catch { | |
| 112 | return null; | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Where a pull request lives in a browser. Azure answers with a web link when asked for one and | |
| 118 | * otherwise leaves it to be assembled, so all three routes are tried in the order they can be | |
| 119 | * trusted. Takes plain fields so both the source control provider and the pull requests page | |
| 120 | * can share it. | |
| 121 | */ | |
| 122 | export function azureDevOpsPullRequestWebUrl(input: { | |
| 123 | readonly pullRequestId: number; | |
| 124 | readonly webLink?: string | null | undefined; | |
| 125 | readonly repositoryWebUrl?: string | null | undefined; | |
| 126 | readonly restApiUrl?: string | null | undefined; | |
| 127 | readonly projectName?: string | null | undefined; | |
| 128 | readonly repositoryName?: string | null | undefined; | |
| 129 | }): string { | |
| 130 | const webLink = trimOptionalString(input.webLink); | |
| 131 | if (webLink) { | |
| 132 | return webLink; | |
| 133 | } | |
| 134 | ||
| 135 | const repositoryWebUrl = trimOptionalString(input.repositoryWebUrl); | |
| 136 | if (repositoryWebUrl) { | |
| 137 | return `${repositoryWebUrl.replace(/\/+$/, "")}/pullrequest/${input.pullRequestId}`; | |
| 138 | } | |
| 139 | ||
| 140 | const organizationBase = azureDevOpsOrganizationBaseFromRestApiUrl(input.restApiUrl); | |
| 141 | const projectName = trimOptionalString(input.projectName); | |
| 142 | const repositoryName = trimOptionalString(input.repositoryName); | |
| 143 | if (organizationBase && projectName && repositoryName) { | |
| 144 | const encodedProjectName = encodeAzureDevOpsPathSegment(projectName); | |
| 145 | const encodedRepositoryName = encodeAzureDevOpsPathSegment(repositoryName); | |
| 146 | return `${organizationBase}/${encodedProjectName}/_git/${encodedRepositoryName}/pullrequest/${input.pullRequestId}`; | |
| 147 | } | |
| 148 | ||
| 149 | return trimOptionalString(input.restApiUrl) ?? ""; | |
| 150 | } | |
| 151 | ||
| 152 | function normalizeAzureDevOpsPullRequestUrl( | |
| 153 | raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>, | |
| 154 | ): string { | |
| 155 | return azureDevOpsPullRequestWebUrl({ | |
| 156 | pullRequestId: raw.pullRequestId, | |
| 157 | webLink: raw._links?.web?.href, | |
| 158 | repositoryWebUrl: raw.repository?.webUrl, | |
| 159 | restApiUrl: raw.url, | |
| 160 | projectName: raw.repository?.project?.name, | |
| 161 | repositoryName: raw.repository?.name, | |
| 162 | }); | |
| 163 | } | |
| 164 | ||
| 165 | function normalizeAzureDevOpsPullRequestRecord( | |
| 166 | raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>, | |
| 167 | ): NormalizedAzureDevOpsPullRequestRecord { | |
| 168 | const state = normalizeAzureDevOpsPullRequestState(raw.status); | |
| 169 | const terminalAt = Option.match(raw.closedDate ?? Option.none(), { | |
| 170 | onNone: () => null, | |
| 171 | onSome: DateTime.formatIso, | |
| 172 | }); | |
| 173 | return { | |
| 174 | number: raw.pullRequestId, | |
| 175 | title: raw.title, | |
| 176 | url: normalizeAzureDevOpsPullRequestUrl(raw), | |
| 177 | baseRefName: normalizeRefName(raw.targetRefName), | |
| 178 | headRefName: normalizeRefName(raw.sourceRefName), | |
| 179 | state, | |
| 180 | ...(raw.isDraft === true ? { isDraft: true } : {}), | |
| 181 | closedAt: state === "closed" ? terminalAt : null, | |
| 182 | mergedAt: state === "merged" ? terminalAt : null, | |
| 183 | updatedAt: (raw.closedDate ?? Option.none()).pipe( | |
| 184 | Option.orElse(() => raw.creationDate ?? Option.none()), | |
| 185 | ), | |
| 186 | }; | |
| 187 | } | |
| 188 | ||
| 189 | const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | |
| 190 | const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema); | |
| 191 | const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema); | |
| 192 | ||
| 193 | export function decodeAzureDevOpsPullRequestListJson( | |
| 194 | raw: string, | |
| 195 | ): Result.Result< | |
| 196 | ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, | |
| 197 | Cause.Cause<Schema.SchemaError> | |
| 198 | > { | |
| 199 | const result = decodeAzureDevOpsPullRequestList(raw); | |
| 200 | if (Result.isSuccess(result)) { | |
| 201 | const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = []; | |
| 202 | for (const entry of result.success) { | |
| 203 | const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry); | |
| 204 | if (Exit.isFailure(decodedEntry)) { | |
| 205 | continue; | |
| 206 | } | |
| 207 | pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value)); | |
| 208 | } | |
| 209 | return Result.succeed(pullRequests); | |
| 210 | } | |
| 211 | return Result.fail(result.failure); | |
| 212 | } | |
| 213 | ||
| 214 | export function decodeAzureDevOpsPullRequestJson( | |
| 215 | raw: string, | |
| 216 | ): Result.Result<NormalizedAzureDevOpsPullRequestRecord, Cause.Cause<Schema.SchemaError>> { | |
| 217 | const result = decodeAzureDevOpsPullRequest(raw); | |
| 218 | if (Result.isSuccess(result)) { | |
| 219 | return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success)); | |
| 220 | } | |
| 221 | return Result.fail(result.failure); | |
| 222 | } |
packages/source-control-azure-devops/src/server/driver.ts created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | /** | |
| 2 | * The Azure DevOps driver: repository operations, discovery, and pull requests through `az`. | |
| 3 | * | |
| 4 | * @module source-control-azure-devops/server/driver | |
| 5 | */ | |
| 6 | import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | ||
| 9 | import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; | |
| 10 | import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; | |
| 11 | ||
| 12 | export const driver = defineSourceControlDriver({ | |
| 13 | kind: "azure-devops", | |
| 14 | make: Effect.all({ | |
| 15 | sourceControl: AzureDevOpsSourceControlProvider.make, | |
| 16 | discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery), | |
| 17 | pullRequests: AzureDevOpsPullRequestProvider.make, | |
| 18 | }), | |
| 19 | }); |
packages/source-control-azure-devops/tsconfig.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "extends": "../../tsconfig.base.json", | |
| 3 | "compilerOptions": { | |
| 4 | "types": ["node"] | |
| 5 | }, | |
| 6 | "include": ["src"] | |
| 7 | } |
pnpm-lock.yaml+37-3| ... | ... | @@ -648,15 +648,15 @@ importers: |
| 648 | 648 | '@t3tools/provider-pi': |
| 649 | 649 | specifier: workspace:* |
| 650 | 650 | version: link:../../packages/provider-pi |
| 651 | '@t3tools/source-control-azure-devops': | |
| 652 | specifier: workspace:* | |
| 653 | version: link:../../packages/source-control-azure-devops | |
| 651 | 654 | '@t3tools/source-control-core': |
| 652 | 655 | specifier: workspace:* |
| 653 | 656 | version: link:../../packages/source-control-core |
| 654 | 657 | '@t3tools/source-control-forgejo': |
| 655 | 658 | specifier: workspace:* |
| 656 | 659 | version: link:../../packages/source-control-forgejo |
| 657 | diff: | |
| 658 | specifier: 8.0.3 | |
| 659 | version: 8.0.3 | |
| 660 | 660 | effect: |
| 661 | 661 | specifier: 4.0.2 |
| 662 | 662 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) |
| ... | ... | @@ -1516,6 +1516,40 @@ importers: |
| 1516 | 1516 | specifier: 1.0.0 |
| 1517 | 1517 | version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) |
| 1518 | 1518 | |
| 1519 | packages/source-control-azure-devops: | |
| 1520 | dependencies: | |
| 1521 | '@t3tools/contracts': | |
| 1522 | specifier: workspace:* | |
| 1523 | version: link:../contracts | |
| 1524 | '@t3tools/shared': | |
| 1525 | specifier: workspace:* | |
| 1526 | version: link:../shared | |
| 1527 | '@t3tools/source-control-core': | |
| 1528 | specifier: workspace:* | |
| 1529 | version: link:../source-control-core | |
| 1530 | diff: | |
| 1531 | specifier: 8.0.3 | |
| 1532 | version: 8.0.3 | |
| 1533 | effect: | |
| 1534 | specifier: 4.0.2 | |
| 1535 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) | |
| 1536 | devDependencies: | |
| 1537 | '@effect/platform-node': | |
| 1538 | specifier: 4.0.2 | |
| 1539 | version: 4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(redis@6.2.1)(utf-8-validate@6.0.6) | |
| 1540 | '@effect/vitest': | |
| 1541 | specifier: 4.0.2 | |
| 1542 | version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | |
| 1543 | '@t3tools/source-control-testing': | |
| 1544 | specifier: workspace:* | |
| 1545 | version: link:../source-control-testing | |
| 1546 | '@types/node': | |
| 1547 | specifier: 24.12.4 | |
| 1548 | version: 24.12.4 | |
| 1549 | vite-plus: | |
| 1550 | specifier: 1.0.0 | |
| 1551 | version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | |
| 1552 | ||
| 1519 | 1553 | packages/source-control-core: |
| 1520 | 1554 | dependencies: |
| 1521 | 1555 | '@t3tools/contracts': |
scripts/release-smoke.ts+1| ... | ... | @@ -37,6 +37,7 @@ const workspaceFiles = [ |
| 37 | 37 | "packages/provider-grok/package.json", |
| 38 | 38 | "packages/provider-muse/package.json", |
| 39 | 39 | "packages/provider-opencode/package.json", |
| 40 | "packages/source-control-azure-devops/package.json", | |
| 40 | 41 | "packages/source-control-core/package.json", |
| 41 | 42 | "packages/source-control-forgejo/package.json", |
| 42 | 43 | "packages/source-control-testing/package.json", |