diff --git a/apps/server/package.json b/apps/server/package.json index 17b77a22244a07cc958d6adf464502c5a2844133..232a99960f96ebd4dc971f5bfc63743a9f66a624 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -45,9 +45,9 @@ "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", + "@t3tools/source-control-azure-devops": "workspace:*", "@t3tools/source-control-core": "workspace:*", "@t3tools/source-control-forgejo": "workspace:*", - "diff": "8.0.3", "effect": "catalog:", "jose": "catalog:", "node-pty": "^1.2.0-beta.15", diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 3ebd7cbaf8d5e5e77049fcd8f6cba6073a13cd63..92313a0869c7019f0d33393481a3fc21e6191cc2 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -26,7 +26,6 @@ import * as SourceControlProviderRegistry from "../src/sourceControl/SourceContr import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts"; -import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; import * as ServerSettings from "../src/serverSettings.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; @@ -155,7 +154,6 @@ await Effect.runPromise( GitHubApi.layerWithDependencies, GitLabCli.layer, SourceControlBuiltInDrivers.layer, - AzureDevOpsCli.layer, // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. BitbucketApi.layer, ), diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts deleted file mode 100644 index be8354c6444c9fcaf38d6d873c60ee3eb4dad95d..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ /dev/null @@ -1,1462 +0,0 @@ -import { afterEach, assert, expect, it, vi } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { ChildProcessSpawner } from "effect/process"; - -import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; -import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; -import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; - -const mockedExecute = vi.fn(); - -const layer = it.layer( - AzureDevOpsPullRequestCli.layer.pipe( - Layer.provide( - Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({ - execute: mockedExecute, - }), - ), - ), -); - -function output(stdout: string) { - return { - exitCode: ChildProcessSpawner.ExitCode(0), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }; -} - -/** What VcsProcess allows a read that asked for no ceiling of its own. */ -const VCS_DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; - -/** - * The runner as it really behaves: it cuts stdout at the ceiling its caller asked for, and cuts - * it at the process default when the caller asked for none. A read whose response is larger than - * its ceiling gets JSON that stops mid-string, which is the whole cost of an unset ceiling. - */ -function outputWithin(maxOutputBytes: number | undefined, response: string) { - const ceiling = maxOutputBytes ?? VCS_DEFAULT_MAX_OUTPUT_BYTES; - return ceiling >= Buffer.byteLength(response) - ? output(response) - : { ...output(response.slice(0, ceiling)), stdoutTruncated: true }; -} - -/** A fixture's own shape, spelled the way `az` would answer with it. */ -const json = (value: Record) => JSON.stringify(value); - -const pullRequestRow = { - pullRequestId: 42, - title: "Add the page", - status: "active", - sourceRefName: "refs/heads/feat/page", - targetRefName: "refs/heads/main", - creationDate: "2026-07-01T00:00:00Z", - url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", - repository: { name: "web", project: { name: "platform" } }, -}; - -const oneIteration = { - value: [ - { - id: 1, - sourceRefCommit: { commitId: "a".repeat(40) }, - commonRefCommit: { commitId: "b".repeat(40) }, - }, - ], -}; - -function pullRequestRows( - count: number, - firstNumber: number, -): ReadonlyArray> { - return Array.from({ length: count }, (_, index) => ({ - pullRequestId: firstNumber + index, - title: `Pull request ${firstNumber + index}`, - status: "active", - sourceRefName: "refs/heads/feat/page", - targetRefName: "refs/heads/main", - creationDate: "2026-07-01T00:00:00Z", - repository: { name: "web", project: { name: "platform" } }, - url: `https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/${firstNumber + index}`, - })); -} - -function pullRequests(count: number, firstNumber: number): string { - return JSON.stringify(pullRequestRows(count, firstNumber)); -} - -/** The arguments of the nth az invocation. */ -function argsOfCall(index: number): ReadonlyArray { - const call = mockedExecute.mock.calls[index]; - assert.isDefined(call); - return call[0].args; -} - -/** The output ceiling the nth az invocation asked for, if it asked for one at all. */ -function maxOutputBytesOfCall(index: number) { - const call = mockedExecute.mock.calls[index]; - assert.isDefined(call); - return call[0].maxOutputBytes; -} - -/** A page of change entries the size Azure really answers with, url and object ids and all. */ -function changeEntries(count: number): ReadonlyArray> { - const commit = "c".repeat(40); - return Array.from({ length: count }, (_, index) => { - const path = `/apps/server/src/generated/module-${index}/persisted-projection-${index}.ts`; - return { - changeType: "edit", - item: { - path, - objectId: "a".repeat(40), - originalObjectId: "b".repeat(40), - commitId: commit, - gitObjectType: "blob", - url: `https://dev.azure.com/acme/platform/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/items${path}?versionType=Commit&version=${commit}`, - }, - }; - }); -} - -/** An Azure identity, which rides along with every comment and every push Azure answers with. */ -function identity(name: string) { - const id = "6f9c9b7f-0000-0000-0000-000000000000"; - return { - displayName: name, - id, - uniqueName: `${name.toLowerCase().replace(/ /g, ".")}@acme.com`, - descriptor: `aad.${"z".repeat(52)}`, - imageUrl: `https://dev.azure.com/acme/_api/_common/identityImage?id=${id}`, - url: `https://spsprodweu1.vssps.visualstudio.com/A${id}/_apis/Identities/${id}`, - _links: { - avatar: { - href: `https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.${"z".repeat(52)}`, - }, - }, - }; -} - -/** A review's threads the shape Azure answers with, system threads and identities and all. */ -function threadRows(count: number): ReadonlyArray> { - return Array.from({ length: count }, (_, index) => ({ - id: index + 1, - publishedDate: "2026-07-02T00:00:00Z", - lastUpdatedDate: "2026-07-02T00:00:00Z", - status: "active", - threadContext: { filePath: `/apps/server/src/generated/module-${index}.ts` }, - identities: { 1: identity("Reviewer One") }, - isDeleted: false, - comments: [ - { - id: 1, - parentCommentId: 0, - author: identity("Reviewer One"), - content: `Comment ${index}: ${"this needs another look. ".repeat(20)}`, - publishedDate: "2026-07-02T00:00:00Z", - lastUpdatedDate: "2026-07-02T00:00:00Z", - commentType: "text", - usersLiked: [], - }, - ], - })); -} - -/** A review's iterations the shape Azure answers with, one per push. */ -function iterationRows(count: number): ReadonlyArray> { - return Array.from({ length: count }, (_, index) => ({ - id: index + 1, - description: `Pushed ${index} commits`, - author: identity("Author One"), - createdDate: "2026-07-02T00:00:00Z", - updatedDate: "2026-07-02T00:00:00Z", - sourceRefCommit: { commitId: `${index}`.padStart(40, "a") }, - targetRefCommit: { commitId: "b".repeat(40) }, - commonRefCommit: { commitId: "c".repeat(40) }, - hasMultipleCommits: true, - reason: "push", - push: { pushId: index + 1, date: "2026-07-02T00:00:00Z", pushedBy: identity("Author One") }, - })); -} - -afterEach(() => { - mockedExecute.mockReset(); -}); - -layer("AzureDevOpsPullRequestCli.layer", (it) => { - it.effect("asks for one row more than the page, to probe for a next page", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "web", - state: "open", - involvement: "all", - viewer: "bilal@acme.dev", - limit: 10, - }); - - assert.strictEqual(batch.items.length, 3); - assert.isFalse(batch.truncated); - expect(argsOfCall(0)).toEqual([ - "repos", - "pr", - "list", - "--detect", - "true", - "--repository", - "web", - "--status", - "active", - "--include-links", - "--top", - "11", - "--only-show-errors", - "--output", - "json", - ]); - }), - ); - - it.effect("reads an Azure pull request page larger than the VCS default output limit", () => - Effect.gen(function* () { - const rows = pullRequestRows(100, 1).map((row) => ({ - ...row, - description: "x".repeat(10_000), - })); - const response = JSON.stringify(rows); - expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000); - - mockedExecute.mockImplementationOnce((input) => - Effect.succeed(outputWithin(input.maxOutputBytes, response)), - ); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "web", - state: "merged", - involvement: "all", - viewer: "bilal@acme.dev", - limit: 99, - }); - - assert.strictEqual(batch.items.length, 99); - assert.isTrue(batch.truncated); - }), - ); - - it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); - const provider = yield* AzureDevOpsPullRequestProvider.make; - - const page = yield* provider.listChangeRequests({ - cwd: "/w", - repository: "web", - host: "dev.azure.com", - state: "open", - involvement: "all", - viewer: "bilal@acme.dev", - limit: 10, - query: "page", - }); - - // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at - // all. The rows come back as they would have without a search, for the caller to narrow; - // nothing of the search reaches the command, where it could only mean the wrong thing. - assert.strictEqual(page.items.length, 3); - expect(argsOfCall(0)).toEqual([ - "repos", - "pr", - "list", - "--detect", - "true", - "--repository", - "web", - "--status", - "active", - "--include-links", - "--top", - "11", - "--only-show-errors", - "--output", - "json", - ]); - }), - ); - - it.effect("steps over what it has already handed over, which is all Azure can be told", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "web", - state: "open", - involvement: "all", - viewer: "bilal@acme.dev", - limit: 10, - // The instant is the same cursor every other host reads; Azure has no filter for it and - // takes the count instead. - cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 20 }, - }); - - const args = argsOfCall(0); - expect(args).toContain("--skip"); - assert.strictEqual(args[args.indexOf("--skip") + 1], "20"); - expect(args).not.toContain("2026-07-02T00:00:00Z"); - }), - ); - - it.effect("reports truncation from the extra row", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "web", - state: "open", - involvement: "all", - viewer: "bilal@acme.dev", - limit: 10, - }); - - assert.strictEqual(batch.items.length, 10); - assert.isTrue(batch.truncated); - assert.strictEqual(batch.cursorAdvance, 10); - }), - ); - - it.effect("advances by malformed raw rows and keeps reading until the page is full", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify([ - { pullRequestId: "malformed" }, - pullRequestRows(1, 1)[0], - { pullRequestId: "also malformed" }, - ]), - ), - ), - ) - .mockReturnValueOnce(Effect.succeed(output(pullRequests(2, 2)))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const batch = yield* cli.listPullRequests({ - cwd: "/w", - repository: "web", - state: "open", - involvement: "all", - viewer: "bilal@acme.dev", - limit: 2, - }); - - expect(batch.items.map((item) => item.number)).toEqual([1, 2]); - assert.isTrue(batch.truncated); - // Three raw rows from the first request and one from the second produced this page. - assert.strictEqual(batch.cursorAdvance, 4); - const secondArgs = argsOfCall(1); - assert.strictEqual(secondArgs[secondArgs.indexOf("--skip") + 1], "3"); - assert.strictEqual(secondArgs[secondArgs.indexOf("--top") + 1], "2"); - }), - ); - - it.effect("narrows to the author on the authored tab", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "web", - state: "closed", - involvement: "authored", - viewer: "bilal@acme.dev", - limit: 10, - }); - - expect(argsOfCall(0)).toContain("--creator"); - expect(argsOfCall(0)).toContain("bilal@acme.dev"); - // Azure calls a closed pull request abandoned. - expect(argsOfCall(0)).toContain("abandoned"); - }), - ); - - it.effect("asks Azure for every status on the All tab", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "web", - state: "all", - involvement: "all", - viewer: "bilal@acme.dev", - limit: 10, - }); - - expect(argsOfCall(0)).toContain("--status"); - expect(argsOfCall(0)).toContain("all"); - }), - ); - - it.effect("narrows to the reviewer on the reviewing tab", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - yield* cli.listPullRequests({ - cwd: "/w", - repository: "web", - state: "open", - involvement: "reviewing", - viewer: "bilal@acme.dev", - limit: 10, - }); - - expect(argsOfCall(0)).toContain("--reviewer"); - }), - ); - - it.effect("reads the signed-in account, which az reports as a bare value", () => - Effect.gen(function* () { - // `--query user` unwraps the object, so the wrapper has to put it back. - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(JSON.stringify({ name: "bilal@acme.dev", type: "user" }))), - ); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const viewer = yield* cli.getViewer({ cwd: "/w" }); - - assert.strictEqual(viewer, "bilal@acme.dev"); - expect(argsOfCall(0)).toEqual([ - "account", - "show", - "--query", - "user", - "--only-show-errors", - "--output", - "json", - ]); - }), - ); - - it.effect("fails when nobody is signed in", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); - - assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError"); - }), - ); - - it.effect("completes a pull request to merge it, squashing only when asked", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - yield* cli.runPullRequestAction({ - cwd: "/w", - number: 42, - action: "merge", - mergeMethod: "squash", - }); - - expect(argsOfCall(0)).toEqual([ - "repos", - "pr", - "update", - "--detect", - "true", - "--id", - "42", - "--status", - "completed", - "--squash", - "true", - "--only-show-errors", - "--output", - "json", - ]); - }), - ); - - it.effect("stores the squash choice with an auto-completion, as a merge now does", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - yield* cli.runPullRequestAction({ - cwd: "/w", - number: 42, - action: "enable-auto-merge", - mergeMethod: "squash", - }); - - expect(argsOfCall(0)).toEqual([ - "repos", - "pr", - "update", - "--detect", - "true", - "--id", - "42", - "--auto-complete", - "true", - "--squash", - "true", - "--only-show-errors", - "--output", - "json", - ]); - }), - ); - - it.effect.each([ - { action: "enable-auto-merge", expected: ["--auto-complete", "true"] }, - { action: "disable-auto-merge", expected: ["--auto-complete", "false"] }, - { action: "draft", expected: ["--draft", "true"] }, - { action: "ready", expected: ["--draft", "false"] }, - { action: "close", expected: ["--status", "abandoned"] }, - { action: "reopen", expected: ["--status", "active"] }, - ] as const)("moves a pull request with $action", ({ action, expected }) => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - yield* cli.runPullRequestAction({ cwd: "/w", number: 42, action }); - - expect(argsOfCall(0)).toEqual([ - "repos", - "pr", - "update", - "--detect", - "true", - "--id", - "42", - ...expected, - "--only-show-errors", - "--output", - "json", - ]); - }), - ); - - it.effect.each([ - { name: "a title", rewrite: { title: "Add the page" }, expected: ["--title=Add the page"] }, - { - name: "a description", - rewrite: { body: "Why the page changed" }, - expected: ["--description=Why the page changed"], - }, - { - name: "both", - rewrite: { title: "Add the page", body: "Why the page changed" }, - expected: ["--title=Add the page", "--description=Why the page changed"], - }, - ] as const)("rewrites $name, sending nothing it was not given", ({ rewrite, expected }) => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - yield* cli.updatePullRequest({ cwd: "/w", number: 42, ...rewrite }); - - expect(argsOfCall(0)).toEqual([ - "repos", - "pr", - "update", - "--detect", - "true", - "--id", - "42", - ...expected, - "--only-show-errors", - "--output", - "json", - ]); - }), - ); - - it.effect("sends a description that starts with a dash as one value, not as a flag", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - yield* cli.updatePullRequest({ - cwd: "/w", - number: 42, - body: "- rewrote the page\n- kept the rest", - }); - - // One argument, so the leading dash of an ordinary bullet list never reaches az as a flag, - // and the whole text stays together where `--description` would otherwise take several. - expect(argsOfCall(0)).toContain("--description=- rewrote the page\n- kept the rest"); - }), - ); - - it.effect("rewrites through the provider, which says it takes one", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const provider = yield* AzureDevOpsPullRequestProvider.make; - - // False for a remark because nothing here can post one, so there is none to rewrite. - expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: false }); - assert.isDefined(provider.updateChangeRequest); - yield* provider.updateChangeRequest({ - cwd: "/w", - repository: "web", - host: "dev.azure.com", - number: 42, - title: "Add the page", - }); - - expect(argsOfCall(0)).toContain("--title=Add the page"); - expect(argsOfCall(0)).not.toContain("--description"); - }), - ); - - it.effect("names the head's blob as what a cleared file was cleared at", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - pullRequestId: 42, - title: "Add the page", - status: "active", - sourceRefName: "refs/heads/feat/page", - targetRefName: "refs/heads/main", - creationDate: "2026-07-01T00:00:00Z", - url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", - repository: { name: "web", project: { name: "platform" } }, - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - value: [ - { - id: 1, - sourceRefCommit: { commitId: "a".repeat(40) }, - commonRefCommit: { commitId: "b".repeat(40) }, - }, - { - id: 2, - sourceRefCommit: { commitId: "c".repeat(40) }, - commonRefCommit: { commitId: "b".repeat(40) }, - }, - ], - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - changeEntries: [ - { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, - { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, - ], - }), - ), - ), - ); - const provider = yield* AzureDevOpsPullRequestProvider.make; - - // Kept here rather than on Azure: its own record of what a reader has read is behind an - // undocumented endpoint, so the marks belong to this environment and need a revision of - // their own to tell a re-push from a file still as it was read. - assert.strictEqual(provider.capabilities.viewedFiles, "environment"); - assert.isDefined(provider.getFileRevisions); - const answer = yield* provider.getFileRevisions({ - cwd: "/w", - repository: "web", - host: "dev.azure.com", - number: 42, - paths: ["README.md"], - }); - - // The latest push, since an iteration's changes are reported against the merge base rather - // than against the push before it. - expect(argsOfCall(2)).toContain("iterationId=2"); - // Only what was asked for. DEMO.md changed too, and nobody has marked it. - expect([...answer.revisions]).toEqual([["README.md", "8f80"]]); - }), - ); - - it.effect("reads where a pull request lives once, however often it is asked about", () => - Effect.gen(function* () { - // A pull request cannot move repositories, and the marks would otherwise pay for a whole - // pull request read every time they checked whether a file had been pushed to. - const pullRequest = Effect.succeed( - output( - JSON.stringify({ - pullRequestId: 42, - title: "Add the page", - status: "active", - sourceRefName: "refs/heads/feat/page", - targetRefName: "refs/heads/main", - creationDate: "2026-07-01T00:00:00Z", - url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", - repository: { name: "web", project: { name: "platform" } }, - }), - ), - ); - const iterations = () => - Effect.succeed( - output( - JSON.stringify({ - value: [ - { - id: 1, - sourceRefCommit: { commitId: "a".repeat(40) }, - commonRefCommit: { commitId: "b".repeat(40) }, - }, - ], - }), - ), - ); - const changes = () => - Effect.succeed( - output( - JSON.stringify({ - changeEntries: [ - { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, - ], - }), - ), - ); - mockedExecute - .mockReturnValueOnce(pullRequest) - .mockReturnValueOnce(iterations()) - .mockReturnValueOnce(changes()) - .mockReturnValueOnce(iterations()) - .mockReturnValueOnce(changes()); - const provider = yield* AzureDevOpsPullRequestProvider.make; - const read = provider.getFileRevisions; - assert.isDefined(read); - const ask = () => - read({ - cwd: "/w", - repository: "web", - host: "dev.azure.com", - number: 42, - paths: ["README.md"], - }); - - yield* ask(); - const again = yield* ask(); - - assert.strictEqual(mockedExecute.mock.calls.length, 5); - // The second read goes straight to the pushes, and still answers with the head's blob. - expect(argsOfCall(3)).toContain("pullRequestIterations"); - expect([...again.revisions]).toEqual([["README.md", "8f80"]]); - }), - ); - - it.effect( - "answers for a marked file the pull request no longer changes as the empty version", - () => - Effect.gen(function* () { - // Which is what was stored for it when it was ticked with nothing on the head, so a file - // the pull request deletes is cleared once and stays cleared. - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - pullRequestId: 42, - title: "Add the page", - status: "active", - sourceRefName: "refs/heads/feat/page", - targetRefName: "refs/heads/main", - creationDate: "2026-07-01T00:00:00Z", - url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", - repository: { name: "web", project: { name: "platform" } }, - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - value: [ - { - id: 1, - sourceRefCommit: { commitId: "a".repeat(40) }, - commonRefCommit: { commitId: "b".repeat(40) }, - }, - ], - }), - ), - ), - ) - .mockReturnValueOnce(Effect.succeed(output('{"changeEntries":[]}'))); - const provider = yield* AzureDevOpsPullRequestProvider.make; - assert.isDefined(provider.getFileRevisions); - - const answer = yield* provider.getFileRevisions({ - cwd: "/w", - repository: "web", - host: "dev.azure.com", - number: 42, - paths: ["GONE.md"], - }); - - expect([...answer.revisions]).toEqual([["GONE.md", ""]]); - }), - ); - - it.effect("says nothing about the files past the end of a change it gave up following", () => - Effect.gen(function* () { - // Every page is an `az` process of its own, so a change past the ceiling stops being - // followed. A path nobody looked at must not be answered for as deleted. - const entries = (from: number, count: number) => - Array.from({ length: count }, (_, index) => ({ - changeType: "edit", - item: { path: `/src/f${from + index}.ts`, objectId: `blob-${from + index}` }, - })); - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - pullRequestId: 42, - title: "Add the page", - status: "active", - sourceRefName: "refs/heads/feat/page", - targetRefName: "refs/heads/main", - creationDate: "2026-07-01T00:00:00Z", - url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", - repository: { name: "web", project: { name: "platform" } }, - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - value: [ - { - id: 1, - sourceRefCommit: { commitId: "a".repeat(40) }, - commonRefCommit: { commitId: "b".repeat(40) }, - }, - ], - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed(output(json({ changeEntries: entries(0, 5_000), nextSkip: 5_000 }))), - ) - .mockReturnValueOnce( - Effect.succeed(output(json({ changeEntries: entries(5_000, 5_000), nextSkip: 10_000 }))), - ); - const provider = yield* AzureDevOpsPullRequestProvider.make; - assert.isDefined(provider.getFileRevisions); - - const answer = yield* provider.getFileRevisions({ - cwd: "/w", - repository: "web", - host: "dev.azure.com", - number: 42, - paths: ["src/f1.ts", "src/f5001.ts", "src/past-the-cut.ts"], - }); - - // The second page picks up where the first said it ended. - expect(argsOfCall(3)).toContain("$skip=5000"); - expect([...answer.revisions]).toEqual([ - ["src/f1.ts", "blob-1"], - ["src/f5001.ts", "blob-5001"], - ]); - }), - ); - - it.effect("stops following pages by what Azure counts, not by what survives decoding", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - pullRequestId: 42, - title: "Add the page", - status: "active", - sourceRefName: "refs/heads/feat/page", - targetRefName: "refs/heads/main", - creationDate: "2026-07-01T00:00:00Z", - url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", - repository: { name: "web", project: { name: "platform" } }, - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - value: [ - { - id: 1, - sourceRefCommit: { commitId: "a".repeat(40) }, - commonRefCommit: { commitId: "b".repeat(40) }, - }, - ], - }), - ), - ), - ) - // Nothing a review can show, so every page decodes to nothing at all and a ceiling counted - // in files would never be reached however long the walk went on. - .mockImplementation((command) => { - const skip = command.args.find((arg) => arg.startsWith("$skip=")); - const from = Number(skip?.slice("$skip=".length) ?? 0); - return Effect.succeed( - output( - json({ - changeEntries: [{ changeType: "add", item: { path: "/src", isFolder: true } }], - nextSkip: from + 2_000, - }), - ), - ); - }); - const provider = yield* AzureDevOpsPullRequestProvider.make; - assert.isDefined(provider.getFileRevisions); - - const answer = yield* provider.getFileRevisions({ - cwd: "/w", - repository: "web", - host: "dev.azure.com", - number: 42, - paths: ["src/page.ts"], - }); - - // The pull request, its pushes, and five pages: the walk gives up on Azure's own offset - // rather than spending an `az` process a page for as long as Azure keeps paging. - assert.strictEqual(mockedExecute.mock.calls.length, 7); - // And it read part of a change, so it says nothing about the file it never saw. - assert.strictEqual(answer.revisions.size, 0); - }), - ); - - it.effect("leaves one file the host would not hand over listed without its hunks", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))) - .mockReturnValueOnce(Effect.succeed(output(json(oneIteration)))) - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - changeEntries: [ - { changeType: "add", item: { path: "/huge.bin", objectId: "8f80" } }, - { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, - ], - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.fail( - new AzureDevOpsCli.AzureDevOpsCommandFailedError({ - operation: "execute", - command: "az", - cwd: "/w", - argumentCount: 1, - cause: "the blob is past what the route will carry", - }), - ), - ) - .mockReturnValueOnce(Effect.succeed(output(json({ content: "hello\n" })))); - const provider = yield* AzureDevOpsPullRequestProvider.make; - - const slice = yield* provider.getDiff({ - cwd: "/w", - repository: "web", - host: "dev.azure.com", - number: 42, - }); - - assert.isTrue(slice.truncated); - expect(slice.patch).toContain("diff --git a/huge.bin b/huge.bin"); - // And the file behind it still renders, which is the point of giving up on one file. - expect(slice.patch).toContain("+hello"); - }), - ); - - it.effect("fails the whole read when it is the connection that would not answer", () => - Effect.gen(function* () { - // A rate limit is not this file's problem, and answering with a change full of files listed - // without their hunks would read as a change nobody can see rather than as a host to wait - // for. - mockedExecute - .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))) - .mockReturnValueOnce(Effect.succeed(output(json(oneIteration)))) - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - changeEntries: [ - { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, - ], - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.fail( - new AzureDevOpsCli.AzureDevOpsCliRateLimitError({ - operation: "execute", - command: "az", - cwd: "/w", - argumentCount: 1, - cause: "429", - }), - ), - ); - const provider = yield* AzureDevOpsPullRequestProvider.make; - - const error = yield* Effect.flip( - provider.getDiff({ cwd: "/w", repository: "web", host: "dev.azure.com", number: 42 }), - ); - - assert.strictEqual(error.reason, "rate-limited"); - }), - ); - - it.effect("takes Azure's own word on a file it will not spell out", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - pullRequestId: 42, - title: "Add the page", - status: "active", - sourceRefName: "refs/heads/feat/page", - targetRefName: "refs/heads/main", - creationDate: "2026-07-01T00:00:00Z", - url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", - repository: { name: "web", project: { name: "platform" } }, - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - value: [ - { - id: 1, - sourceRefCommit: { commitId: "a".repeat(40) }, - commonRefCommit: { commitId: "b".repeat(40) }, - }, - ], - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - changeEntries: [ - { changeType: "add", item: { path: "/logo.png", objectId: "8f80" } }, - ], - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - output(json({ content: "iVBORw0KGgo=", contentMetadata: { isBinary: true } })), - ), - ); - const provider = yield* AzureDevOpsPullRequestProvider.make; - - const slice = yield* provider.getDiff({ - cwd: "/w", - repository: "web", - host: "dev.azure.com", - number: 42, - }); - - // Azure leaves the metadata out unless it is asked for, and without it every file reads as - // text however it was stored. - expect(argsOfCall(3)).toContain("includeContentMetadata=true"); - expect(slice.patch).toContain("Binary files a/logo.png and b/logo.png differ"); - assert.isTrue(slice.truncated); - }), - ); - - it.effect("stops following pages when one of them does not move the cursor on", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - pullRequestId: 42, - title: "Add the page", - status: "active", - sourceRefName: "refs/heads/feat/page", - targetRefName: "refs/heads/main", - creationDate: "2026-07-01T00:00:00Z", - url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", - repository: { name: "web", project: { name: "platform" } }, - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - value: [ - { - id: 1, - sourceRefCommit: { commitId: "a".repeat(40) }, - commonRefCommit: { commitId: "b".repeat(40) }, - }, - ], - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - changeEntries: [{ changeType: "edit", item: { path: "/a.ts", objectId: "8f80" } }], - nextSkip: 2_000, - }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - output( - json({ - changeEntries: [{ changeType: "edit", item: { path: "/b.ts", objectId: "0ca4" } }], - nextSkip: 2_000, - }), - ), - ), - ); - const provider = yield* AzureDevOpsPullRequestProvider.make; - assert.isDefined(provider.getFileRevisions); - - const answer = yield* provider.getFileRevisions({ - cwd: "/w", - repository: "web", - host: "dev.azure.com", - number: 42, - paths: ["a.ts", "b.ts", "unlisted.ts"], - }); - - // Four reads and no more: a page pointing at where it already is would be read forever. - assert.strictEqual(mockedExecute.mock.calls.length, 4); - // And what was read is not the whole change, so the file nobody listed is left unanswered - // rather than reported as gone from the change request. - expect([...answer.revisions]).toEqual([ - ["a.ts", "8f80"], - ["b.ts", "0ca4"], - ]); - }), - ); - - it.effect("asks Azure nothing when no file has been ticked off", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output('{"changeEntries":[]}'))); - const provider = yield* AzureDevOpsPullRequestProvider.make; - assert.isDefined(provider.getFileRevisions); - - const answer = yield* provider.getFileRevisions({ - cwd: "/w", - repository: "web", - host: "dev.azure.com", - number: 42, - paths: [], - }); - - expect(answer.revisions.size).toBe(0); - // Nothing was marked, so Azure was not asked at all. - assert.strictEqual(mockedExecute.mock.calls.length, 0); - }), - ); - - it.effect("reads the conversation through the REST API, pinned to a version", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - value: [ - { - id: 1, - comments: [ - { id: 1, content: "Looks good.", publishedDate: "2026-07-02T00:00:00Z" }, - ], - }, - ], - }), - ), - ), - ); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const comments = yield* cli.listThreads({ - cwd: "/w", - location: { project: "platform", repository: "web" }, - number: 42, - }); - - assert.strictEqual(comments.length, 1); - // `az devops invoke` rather than `az rest`: it signs in the way the azure-devops extension - // does, and `az rest` mints its own token against whichever tenant `az` defaults to. - expect(argsOfCall(0)).toContain("invoke"); - expect(argsOfCall(0)).toContain("pullRequestThreads"); - expect(argsOfCall(0)).toContain("project=platform"); - expect(argsOfCall(0)).toContain("repositoryId=web"); - expect(argsOfCall(0)).toContain("pullRequestId=42"); - // A review's threads grow with how long it ran, and this route does not page, so the read - // asks for more than the process default rather than taking whatever it is given. - expect(maxOutputBytesOfCall(0)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); - }), - ); - - it.effect("reads a long review's threads, which are past the default output limit", () => - Effect.gen(function* () { - const response = json({ value: threadRows(800) }); - // Azure opens a thread per vote and per ref update beside the ones people wrote, and every - // comment carries a full identity, so a review argued over for weeks outgrows the default. - expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); - mockedExecute.mockImplementationOnce((input) => - Effect.succeed(outputWithin(input.maxOutputBytes, response)), - ); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const comments = yield* cli.listThreads({ - cwd: "/w", - location: { project: "platform", repository: "web" }, - number: 42, - }); - - assert.strictEqual(comments.length, 800); - }), - ); - - it.effect("reads a long review's iterations, which are past the default output limit", () => - Effect.gen(function* () { - const response = json({ value: iterationRows(1_200) }); - // This route does not page, so the whole history arrives at once. Cut at the default it is - // JSON stopping mid-string, and every diff and file revision read on this host fails with - // it, since each of them starts by asking which iteration is the latest. - expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); - mockedExecute.mockImplementationOnce((input) => - Effect.succeed(outputWithin(input.maxOutputBytes, response)), - ); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const iterations = yield* cli.listIterations({ - cwd: "/w", - location: { project: "platform", repository: "web" }, - number: 42, - }); - - assert.strictEqual(iterations.length, 1_200); - assert.strictEqual(iterations.at(-1)?.id, 1_200); - }), - ); - - it.effect("reads a full page of change entries, which is past the default output limit", () => - Effect.gen(function* () { - const response = json({ changeEntries: changeEntries(2_000) }); - // Azure's own maximum for this route, and every entry carries a path, a url and three - // object ids, so an ordinary page of a large change already outgrows the default. - expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); - mockedExecute.mockImplementationOnce((input) => - Effect.succeed(outputWithin(input.maxOutputBytes, response)), - ); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const page = yield* cli.listIterationChanges({ - cwd: "/w", - location: { project: "platform", repository: "web" }, - number: 42, - iterationId: 1, - }); - - // Cut at the default this would arrive as JSON stopping mid-string, and a perfectly - // ordinary page would be reported as a host answering with nonsense. - assert.strictEqual(page.changes.length, 2_000); - assert.isFalse(page.truncated); - }), - ); - - it.effect("reads a file whose JSON envelope is past the default output limit", () => - Effect.gen(function* () { - // Under a megabyte as bytes on the host, so this is a file the other hosts hand over. - const file = "const value = 1;\n".repeat(57_000); - const response = json({ content: file }); - expect(Buffer.byteLength(file)).toBeLessThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); - // And past it once Azure wraps it, because there is no route here that serves the bytes. - expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); - mockedExecute.mockImplementationOnce((input) => - Effect.succeed(outputWithin(input.maxOutputBytes, response)), - ); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const item = yield* cli.readItemContent({ - cwd: "/w", - location: { project: "platform", repository: "web" }, - path: "src/generated/schema.ts", - commit: "a".repeat(40), - }); - - assert.strictEqual(item.contents, file); - assert.isFalse(item.isBinary); - }), - ); - - it.effect("asks for a file by Azure's own spelling of its path", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(json({ content: "const a = 1;" })))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - // The path carried around here has had Azure's leading slash taken off so it matches the - // patch and the viewed mark. The items route is documented in Azure's spelling, so it goes - // back on the way out rather than being sent as the shorter name. - yield* cli.readItemContent({ - cwd: "/w", - location: { project: "platform", repository: "web" }, - path: "src/app.ts", - commit: "a".repeat(40), - }); - - expect(argsOfCall(0)).toContain("path=/src/app.ts"); - }), - ); - - it.effect("reports a pull request it cannot place as its own outcome", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - // Well-formed, but with nothing to build a link from: not a decode failure. - JSON.stringify({ - pullRequestId: 42, - title: "Add the page", - sourceRefName: "refs/heads/feat/page", - targetRefName: "refs/heads/main", - creationDate: "2026-07-01T00:00:00Z", - }), - ), - ), - ); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 })); - - assert.strictEqual(error._tag, "AzureDevOpsPullRequestIncompleteError"); - }), - ); - - it.effect("fails the read when az returns something unreadable", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 })); - - assert.strictEqual(error._tag, "AzureDevOpsPullRequestReadError"); - }), - ); - - it.effect("adds reviewers with the one command Azure has for it", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - yield* cli.setPullRequestReviewers({ - cwd: "/w", - number: 42, - reviewers: ["octocat@acme.test", "hubot@acme.test"], - requested: true, - }); - - expect(argsOfCall(0)).toEqual([ - "repos", - "pr", - "reviewer", - "add", - "--detect", - "true", - "--id", - "42", - "--reviewers", - "octocat@acme.test", - "hubot@acme.test", - "--only-show-errors", - "--output", - "json", - ]); - }), - ); - - it.effect("takes a reviewer off the pull request with the same command's counterpart", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - yield* cli.setPullRequestReviewers({ - cwd: "/w", - number: 42, - reviewers: ["octocat@acme.test"], - requested: false, - }); - - expect(argsOfCall(0)).toContain("remove"); - }), - ); - - it.effect("refuses a reviewer az would read as a flag, before running anything", () => - Effect.gen(function* () { - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - - const error = yield* Effect.flip( - cli.setPullRequestReviewers({ - cwd: "/w", - number: 42, - reviewers: ["--query"], - requested: true, - }), - ); - - assert.strictEqual(error._tag, "AzureDevOpsReviewerNameError"); - assert.strictEqual(mockedExecute.mock.calls.length, 0); - }), - ); -}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts deleted file mode 100644 index d00d08b7d05f7649044270ff9bd33599db3b8bf1..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ /dev/null @@ -1,744 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import type { - PullRequestAction, - PullRequestComment, - PullRequestInvolvement, - PullRequestListState, - PullRequestMergeMethod, -} from "@t3tools/contracts"; - -import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; -import { - decodeItemContentJson, - decodeIterationChangesJson, - decodeIterationsJson, - decodePullRequestJson, - decodePullRequestListJson, - decodeThreadsJson, - decodeViewerJson, - type AzureDevOpsChangeEntry, - type AzureDevOpsItemContent, - type AzureDevOpsIteration, - type AzureDevOpsPullRequest, - type AzureDevOpsRepositoryLocation, -} from "./azureDevOpsPullRequestJson.ts"; -import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; - -/** - * Names the read that produced unusable output, so a failure reports the call it came from - * rather than borrowing another operation's message. - */ -export class AzureDevOpsPullRequestReadError extends Schema.TaggedError()( - "AzureDevOpsPullRequestReadError", - { - command: Schema.Literal("az"), - cwd: Schema.String, - operation: Schema.String, - cause: Schema.Defect(), - }, -) { - get detail(): string { - return `Azure CLI returned an unreadable ${this.operation} response.`; - } - - override get message(): string { - return `Azure CLI failed in ${this.operation}: ${this.detail}`; - } -} - -/** Not a decode failure: az answered, the account it answered for just has no name. */ -export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError()( - "AzureDevOpsViewerUnavailableError", - { - command: Schema.Literal("az"), - cwd: Schema.String, - }, -) { - get detail(): string { - return "Azure CLI returned no account for the current sign-in."; - } - - override get message(): string { - return `Azure CLI failed in getViewer: ${this.detail}`; - } -} - -/** - * Not a decode failure either: az answered with a well-formed pull request that simply carries - * no branch or link, which is a response this cannot place rather than one it cannot read. - */ -export class AzureDevOpsPullRequestIncompleteError extends Schema.TaggedError()( - "AzureDevOpsPullRequestIncompleteError", - { - command: Schema.Literal("az"), - cwd: Schema.String, - number: Schema.Int, - }, -) { - get detail(): string { - return "Azure DevOps returned no branch or link for the pull request."; - } - - override get message(): string { - return `Azure CLI failed in getPullRequest: ${this.detail}`; - } -} - -/** - * Not a decode failure: the reader named a reviewer `az` would read as a flag of its own. The - * reviewers travel as argv rather than in a request body — `az repos pr reviewer` takes them no - * other way — so anything that could leave the value position is refused rather than sent. - */ -export class AzureDevOpsReviewerNameError extends Schema.TaggedError()( - "AzureDevOpsReviewerNameError", - { - command: Schema.Literal("az"), - cwd: Schema.String, - }, -) { - get detail(): string { - return "A reviewer is named by an email address or an identity id."; - } - - override get message(): string { - return `Azure CLI failed in setPullRequestReviewers: ${this.detail}`; - } -} - -export type AzureDevOpsPullRequestCliError = - | AzureDevOpsCli.AzureDevOpsCliError - | AzureDevOpsPullRequestReadError - | AzureDevOpsPullRequestIncompleteError - | AzureDevOpsReviewerNameError - | AzureDevOpsViewerUnavailableError; - -/** The version every REST call below is pinned to, so a new default cannot reshape a response. */ -const REST_API_VERSION = "7.1"; -const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; -/** - * A full page of change entries is two thousand files, each carrying its path, its url and - * several object ids, which is past the megabyte a read is given by default. Output cut at that - * ceiling arrives here as JSON that will not parse, so a change large enough to be paged would - * report itself as a host returning nonsense rather than as the ordinary page it is. - */ -const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; -/** - * Four times the megabyte of file the other hosts hand over, because Azure has no route that - * serves the bytes themselves: the file arrives inside a JSON envelope, escaped if it is text and - * base64 if it is not, and both are larger than the file they carry. - */ -const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; -/** - * What a review's own history is given. Neither route pages, so each answers with the whole of it - * at once and grows with how long the review ran rather than with how large the change is. Cut at - * the default, both arrive as JSON that stops mid-string, and a long review would report its host - * as answering with nonsense. - */ -const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; - -/** Azure's own ceiling for one page of an iteration's changes. */ -const CHANGE_ENTRIES_PER_PAGE = 2000; - -/** - * Where following the pages stops, counted in the entries Azure was asked to skip rather than in - * the files that survived decoding: a page can be entirely folders and other entries a review has - * nothing to show for, and bounding on what was kept would follow such a change forever. Every - * page is an `az` process of its own, so the read gives up and reports itself incomplete rather - * than presenting five pages as the whole change. - */ -const MAX_CHANGE_ENTRIES = 10_000; - -/** What an iteration changed, and whether following its pages reached the end of it. */ -export interface AzureDevOpsIterationChanges { - readonly changes: ReadonlyArray; - readonly truncated: boolean; -} - -export class AzureDevOpsPullRequestCli extends Context.Service< - AzureDevOpsPullRequestCli, - { - readonly getViewer: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly listPullRequests: (input: { - readonly cwd: string; - readonly repository: string; - readonly state: PullRequestListState; - readonly involvement: PullRequestInvolvement; - readonly viewer: string; - readonly limit: number; - /** - * Where to carry on from. Azure has no date filter for a pull request listing, so the only - * part of a cursor it can use is how many rows have already been handed over. - */ - readonly cursor?: ProviderListCursor | undefined; - }) => Effect.Effect< - { - readonly items: ReadonlyArray; - readonly truncated: boolean; - /** Raw Azure rows consumed to produce this page, including malformed rows. */ - readonly cursorAdvance: number; - }, - AzureDevOpsPullRequestCliError - >; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly number: number; - }) => Effect.Effect; - - /** Threads are not reachable through `az repos pr`, so they come from the REST API. */ - readonly listThreads: (input: { - readonly cwd: string; - readonly location: AzureDevOpsRepositoryLocation; - readonly number: number; - }) => Effect.Effect, AzureDevOpsPullRequestCliError>; - - /** - * The pushes a pull request has had, oldest first. Azure hangs the changed files off an - * iteration rather than off the pull request, so reading a diff starts here. - */ - readonly listIterations: (input: { - readonly cwd: string; - readonly location: AzureDevOpsRepositoryLocation; - readonly number: number; - }) => Effect.Effect, AzureDevOpsPullRequestCliError>; - - /** - * What one iteration changed, against the merge base rather than against the previous push, - * which is the whole of the pull request rather than the latest slice of it. - */ - readonly listIterationChanges: (input: { - readonly cwd: string; - readonly location: AzureDevOpsRepositoryLocation; - readonly number: number; - readonly iterationId: number; - }) => Effect.Effect; - - /** - * One file's text at one commit. Azure has no diff route that carries content, so both sides - * of every changed file are read this way and the patch is made from them here. - */ - readonly readItemContent: (input: { - readonly cwd: string; - readonly location: AzureDevOpsRepositoryLocation; - readonly path: string; - readonly commit: string; - }) => Effect.Effect; - - readonly runPullRequestAction: (input: { - readonly cwd: string; - readonly number: number; - readonly action: PullRequestAction; - readonly mergeMethod?: PullRequestMergeMethod; - }) => Effect.Effect; - - /** Rewrites the pull request's own words, through the same command that moves it. */ - readonly updatePullRequest: (input: { - readonly cwd: string; - readonly number: number; - readonly title?: string | undefined; - readonly body?: string | undefined; - }) => Effect.Effect; - - /** - * Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole - * of what Azure offers here: it adds and removes named identities, and has no counterpart that - * says who could be named. - */ - readonly setPullRequestReviewers: (input: { - readonly cwd: string; - readonly number: number; - readonly reviewers: ReadonlyArray; - readonly requested: boolean; - }) => Effect.Effect; - } ->()("t3/pullRequest/AzureDevOpsPullRequestCli") {} - -function statusArgs(state: PullRequestListState): ReadonlyArray { - switch (state) { - case "open": - return ["--status", "active"]; - case "merged": - return ["--status", "completed"]; - case "closed": - return ["--status", "abandoned"]; - case "all": - return ["--status", "all"]; - } -} - -function involvementArgs(input: { - readonly involvement: PullRequestInvolvement; - readonly viewer: string; -}): ReadonlyArray { - switch (input.involvement) { - case "authored": - return ["--creator", input.viewer]; - case "reviewing": - return ["--reviewer", input.viewer]; - case "all": - return []; - } -} - -/** - * Azure moves a pull request by setting its state rather than by named commands: completing it - * is the merge, abandoning it is the close, and reactivating it is the reopen. Squashing is a - * completion option rather than a strategy of its own. - */ -function actionArgs( - action: PullRequestAction, - mergeMethod: PullRequestMergeMethod | undefined, -): ReadonlyArray { - switch (action) { - case "merge": - return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"]; - // Auto-complete is Azure's own name for it: the pull request stays active and Azure completes - // it once its policies pass. The squash choice is stored with it, as it is for a merge now. - case "enable-auto-merge": - return [ - "--auto-complete", - "true", - ...(mergeMethod === undefined - ? [] - : ["--squash", mergeMethod === "squash" ? "true" : "false"]), - ]; - case "disable-auto-merge": - return ["--auto-complete", "false"]; - case "ready": - return ["--draft", "false"]; - case "draft": - return ["--draft", "true"]; - case "close": - return ["--status", "abandoned"]; - // Never reached: this host does not declare the action, so nothing offers it. - case "update-branch": - return []; - case "reopen": - return ["--status", "active"]; - // Never reached: this host does not declare the action, so the service refuses it first. - case "revert": - case "approve-workflows": - throw new Error(`Azure DevOps pull request action ${action} is unsupported`); - } -} - -/** - * A reviewer Azure could be given: an email address, a display name or an identity guid, and - * nothing that starts with a dash. The dash is the whole point — these are argv, and a value that - * looks like a flag stops being a value. - */ -function isReviewerName(value: string): boolean { - const name = value.trim(); - return name.length > 0 && !name.startsWith("-"); -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const azure = yield* AzureDevOpsCli.AzureDevOpsCli; - - // Every command resolves the organization, project and repository from the checkout, which is - // what the rest of the Azure wrapper does. The remote takes three shapes and only `az` knows - // how to read all of them. - const detectArgs = ["--detect", "true"] as const; - - const executeJson = (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly maxOutputBytes?: number; - }) => - azure.execute({ - cwd: input.cwd, - args: [...input.args, "--only-show-errors", "--output", "json"], - ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), - }); - - /** - * A REST route reached through `az devops invoke`, which addresses it by area, resource and - * route parameters rather than by URL. Used in place of `az rest` because it signs in the way - * the azure-devops extension does: `az rest` mints its own token against the tenant `az` - * defaults to, and an organisation in any other tenant answers that with a sign-in page, which - * arrives here as unreadable output rather than as a failure. - */ - const invoke = (input: { - readonly cwd: string; - readonly operation: string; - readonly resource: string; - readonly routeParameters: ReadonlyArray; - readonly queryParameters?: ReadonlyArray; - readonly maxOutputBytes?: number; - readonly decode: (raw: string) => Result.Result; - }): Effect.Effect => - executeJson({ - cwd: input.cwd, - ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), - args: [ - "devops", - "invoke", - ...detectArgs, - "--area", - "git", - "--resource", - input.resource, - "--api-version", - REST_API_VERSION, - "--route-parameters", - ...input.routeParameters, - ...(input.queryParameters === undefined - ? [] - : ["--query-parameters", ...input.queryParameters]), - ], - }).pipe( - Effect.flatMap((result) => { - const decoded = input.decode(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new AzureDevOpsPullRequestReadError({ - command: "az", - cwd: input.cwd, - operation: input.operation, - cause: decoded.failure, - }), - ); - }), - ); - - /** - * Azure names its own items with a leading slash, which the repository paths carried around - * here have had taken off so they match the patch and the viewed mark. Put it back on the way - * out, because the items route is documented in Azure's own spelling. - */ - const toItemPath = (path: string) => (path.startsWith("/") ? path : `/${path}`); - - const repositoryRoute = (location: AzureDevOpsRepositoryLocation): ReadonlyArray => [ - `project=${location.project}`, - `repositoryId=${location.repository}`, - ]; - - const pullRequestRoute = (input: { - readonly location: AzureDevOpsRepositoryLocation; - readonly number: number; - }): ReadonlyArray => [ - ...repositoryRoute(input.location), - `pullRequestId=${input.number}`, - ]; - - /** - * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and - * retain the raw count so the next public cursor skips every row this walk consumed. - */ - const listPullRequestPage = (input: { - readonly cwd: string; - readonly repository: string; - readonly state: PullRequestListState; - readonly involvement: PullRequestInvolvement; - readonly viewer: string; - readonly limit: number; - readonly skip: number; - readonly cursorAdvance: number; - readonly items: ReadonlyArray; - }): Effect.Effect< - { - readonly items: ReadonlyArray; - readonly truncated: boolean; - readonly cursorAdvance: number; - }, - AzureDevOpsPullRequestCliError - > => { - const remaining = input.limit - input.items.length; - const top = remaining + 1; - return executeJson({ - cwd: input.cwd, - maxOutputBytes: PULL_REQUEST_LIST_MAX_OUTPUT_BYTES, - args: [ - "repos", - "pr", - "list", - ...detectArgs, - "--repository", - input.repository, - ...statusArgs(input.state), - ...involvementArgs(input), - // A web link per row, which is the only url that needs no assembling. - "--include-links", - ...(input.skip === 0 ? [] : ["--skip", String(input.skip)]), - "--top", - String(top), - ], - }).pipe( - Effect.flatMap((result) => { - const raw = result.stdout.trim(); - if (raw.length === 0) { - return Effect.succeed({ - items: input.items, - truncated: false, - cursorAdvance: input.cursorAdvance, - }); - } - const decoded = decodePullRequestListJson(raw); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new AzureDevOpsPullRequestReadError({ - command: "az", - cwd: input.cwd, - operation: "listPullRequests", - cause: decoded.failure, - }), - ); - } - - const lastItemIndex = decoded.success.rawIndexes[remaining - 1]; - if (lastItemIndex !== undefined) { - const consumed = lastItemIndex + 1; - return Effect.succeed({ - items: [...input.items, ...decoded.success.items.slice(0, remaining)], - // A full raw response may have more rows even when malformed entries used the probe. - truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top, - cursorAdvance: input.cursorAdvance + consumed, - }); - } - - const items = [...input.items, ...decoded.success.items]; - if (decoded.success.rawCount < top) { - return Effect.succeed({ - items, - truncated: false, - cursorAdvance: input.cursorAdvance + decoded.success.rawCount, - }); - } - return listPullRequestPage({ - ...input, - skip: input.skip + decoded.success.rawCount, - cursorAdvance: input.cursorAdvance + decoded.success.rawCount, - items, - }); - }), - ); - }; - - return AzureDevOpsPullRequestCli.of({ - getViewer: (input) => - executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe( - Effect.flatMap((result): Effect.Effect => { - // `--query user` narrows the payload to the account, so it is nested back under the - // key the decoder reads to keep one shape for the signed-in user. - const decoded = decodeViewerJson(`{"user":${result.stdout.trim() || "null"}}`); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new AzureDevOpsPullRequestReadError({ - command: "az", - cwd: input.cwd, - operation: "getViewer", - cause: decoded.failure, - }), - ); - } - return decoded.success === null - ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd })) - : Effect.succeed(decoded.success); - }), - ), - - listPullRequests: (input) => - listPullRequestPage({ - cwd: input.cwd, - repository: input.repository, - state: input.state, - involvement: input.involvement, - viewer: input.viewer, - limit: input.limit, - // Azure counts rather than filters, so a slice carries on by stepping over every raw row - // the prior slice consumed. That is an offset into a list that can shift underneath it: - // a pull request opened between two slices moves everything down one, and the row on the - // seam is the one that pays for it. - skip: input.cursor?.delivered ?? 0, - cursorAdvance: 0, - items: [], - }), - - getPullRequest: (input) => - executeJson({ - cwd: input.cwd, - args: ["repos", "pr", "show", ...detectArgs, "--id", String(input.number)], - }).pipe( - Effect.flatMap( - (result): Effect.Effect => { - const decoded = decodePullRequestJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new AzureDevOpsPullRequestReadError({ - command: "az", - cwd: input.cwd, - operation: "getPullRequest", - cause: decoded.failure, - }), - ); - } - // Null means Azure answered with too little to place the pull request. Nothing - // failed underneath it, so it is its own outcome rather than a decode failure. - return decoded.success === null - ? Effect.fail( - new AzureDevOpsPullRequestIncompleteError({ - command: "az", - cwd: input.cwd, - number: input.number, - }), - ) - : Effect.succeed(decoded.success); - }, - ), - ), - - listThreads: (input) => - invoke({ - cwd: input.cwd, - operation: "listThreads", - resource: "pullRequestThreads", - routeParameters: pullRequestRoute(input), - maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, - decode: decodeThreadsJson, - }), - - listIterations: (input) => - invoke({ - cwd: input.cwd, - operation: "listIterations", - resource: "pullRequestIterations", - routeParameters: pullRequestRoute(input), - maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, - decode: decodeIterationsJson, - }), - - listIterationChanges: (input) => { - const page = (skip: number) => - invoke({ - cwd: input.cwd, - operation: "listIterationChanges", - resource: "pullRequestIterationChanges", - routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`], - // Azure pages this route at 1000 entries by default; this is its own maximum per page, - // and it names where the next page starts rather than answering with the whole change. - queryParameters: [`$top=${CHANGE_ENTRIES_PER_PAGE}`, `$skip=${skip}`], - maxOutputBytes: CHANGE_ENTRIES_MAX_OUTPUT_BYTES, - decode: decodeIterationChangesJson, - }); - const from = ( - skip: number, - collected: ReadonlyArray, - ): Effect.Effect => - page(skip).pipe( - Effect.flatMap((answer) => { - const changes = [...collected, ...answer.changes]; - // The last page names no page after it, and only that is the end of the change. - if (answer.nextSkip === null) return Effect.succeed({ changes, truncated: false }); - // A page pointing at where the read already is would be followed forever, and one - // past the ceiling is a change nobody will read to the end of. Both stop the read - // and both say so, rather than presenting part of a change as the whole of it. - return answer.nextSkip <= skip || answer.nextSkip >= MAX_CHANGE_ENTRIES - ? Effect.succeed({ changes, truncated: true }) - : from(answer.nextSkip, changes); - }), - ); - return from(0, []); - }, - - readItemContent: (input) => - invoke({ - cwd: input.cwd, - operation: "readItemContent", - resource: "items", - routeParameters: repositoryRoute(input.location), - queryParameters: [ - `path=${toItemPath(input.path)}`, - "versionDescriptor.versionType=commit", - `versionDescriptor.version=${input.commit}`, - "includeContent=true", - // Azure leaves `contentMetadata` out unless this is asked for, and with it goes its own - // word on whether the file is binary, which is the only reliable one, since a binary - // file arrives encoded rather than as the bytes it is on the host. - "includeContentMetadata=true", - // Without this Azure answers with the file's own bytes rather than with a JSON - // envelope, and `az devops invoke` refuses anything it cannot parse as JSON. - "$format=json", - ], - maxOutputBytes: ITEM_CONTENT_MAX_OUTPUT_BYTES, - decode: decodeItemContentJson, - }), - - setPullRequestReviewers: (input) => - input.reviewers.some((reviewer) => !isReviewerName(reviewer)) - ? Effect.fail(new AzureDevOpsReviewerNameError({ command: "az", cwd: input.cwd })) - : azure - .execute({ - cwd: input.cwd, - args: [ - "repos", - "pr", - "reviewer", - input.requested ? "add" : "remove", - ...detectArgs, - "--id", - String(input.number), - // One `--reviewers` takes them all, because az reads the flag as a list and a - // second one would replace the first rather than add to it. - "--reviewers", - ...input.reviewers, - "--only-show-errors", - "--output", - "json", - ], - }) - .pipe(Effect.asVoid), - - runPullRequestAction: (input) => - azure - .execute({ - cwd: input.cwd, - args: [ - "repos", - "pr", - "update", - ...detectArgs, - "--id", - String(input.number), - ...actionArgs(input.action, input.mergeMethod), - "--only-show-errors", - "--output", - "json", - ], - }) - .pipe(Effect.asVoid), - - updatePullRequest: (input) => - azure - .execute({ - cwd: input.cwd, - args: [ - "repos", - "pr", - "update", - ...detectArgs, - "--id", - String(input.number), - // One argument rather than a flag and a value beside it: a description usually opens - // with a bullet, and az reads a dash in the next argv slot as a flag of its own. - // `--description` also takes several strings, and this keeps the whole text as one. - ...(input.title === undefined ? [] : [`--title=${input.title}`]), - ...(input.body === undefined ? [] : [`--description=${input.body}`]), - "--only-show-errors", - "--output", - "json", - ], - }) - .pipe(Effect.asVoid), - }); -}); - -export const layer = Layer.effect(AzureDevOpsPullRequestCli, make); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts deleted file mode 100644 index 1b96984cf76b2f75ef5b9d394c963355a8f0f5f1..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; -import { - LOCATION_CACHE_CAPACITY, - make, - MAX_DIFF_SPAWNS, -} from "./AzureDevOpsPullRequestProvider.ts"; -import { - byteLength, - MAX_DIFF_SLICE_BYTES, - MAX_DIFF_SLICE_FILES, - MAX_FILE_DIFF_EDITS, - parseAzureDevOpsDiffCursor, -} from "./azureDevOpsDiff.ts"; -import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; - -const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" }; - -const PULL_REQUEST = { - number: 7, - title: "Pull request 7", - url: "https://dev.azure.com/acme/web/_git/web/pullrequest/7", - author: null, - headBranch: "feat/page", - baseBranch: "main", - state: "open" as const, - isDraft: false, - mergeability: "mergeable" as const, - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - closedAt: null, - body: "", - reviewRequestLogins: [], - reviewers: [], - location: { project: "acme", repository: "web" }, - autoMergeEnabled: false, -}; - -function change( - path: string, - changeKind: AzureDevOpsChangeEntry["changeKind"] = "change", -): AzureDevOpsChangeEntry { - return { path, oldPath: path, changeKind, objectId: "8f80", originalObjectId: "0ca4" }; -} - -/** - * A file whose two sides share no line, so its patch is `lines` removals and `lines` additions of - * `width` characters each: the diff work and the patch bytes one file costs are both dialled from - * here, and they are what a slice is bounded by. Each line carries its own number and a prefix - * as well, so `width` is a floor on how long a line is rather than its byte count. - */ -function side(prefix: string, lines: number, width: number): string { - const pad = "z".repeat(width); - return `${Array.from({ length: lines }, (_, line) => `${prefix} ${line} ${pad}`).join("\n")}\n`; -} - -const readSlice = (input: { - readonly paths: ReadonlyArray; - readonly lines: number; - readonly width: number; - /** Paths the host refuses, which is one file's problem rather than the read's. */ - readonly refused?: ReadonlyArray; - /** Paths the change creates, so the host has nothing to hand back for their old side. */ - readonly created?: ReadonlyArray; - readonly cursor?: string; -}) => - Effect.gen(function* () { - const reads: string[] = []; - const refused = new Set(input.refused ?? []); - const created = new Set(input.created ?? []); - let inFlight = 0; - let peakInFlight = 0; - - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ - getPullRequest: () => Effect.succeed(PULL_REQUEST), - listIterations: () => Effect.succeed([ITERATION]), - listIterationChanges: () => - Effect.succeed({ - changes: input.paths.map((path) => - change(path, created.has(path) ? "new" : "change"), - ), - truncated: false, - }), - readItemContent: (item) => - Effect.gen(function* () { - reads.push(item.path); - inFlight += 1; - peakInFlight = Math.max(peakInFlight, inFlight); - // Every read suspends before it answers, as a subprocess would, so what runs at - // once is the scheduler's answer rather than an artefact of resolving inline. The - // later a file is listed the sooner it answers, to leave the assembled patch - // nothing but the change list to take its order from. - const answersAfter = input.paths.length - input.paths.indexOf(item.path); - for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow; - inFlight -= 1; - if (refused.has(item.path)) { - return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({ - command: "az", - cwd: "/w", - operation: "readItemContent", - cause: "refused", - }); - } - const isOldSide = item.commit !== ITERATION.headCommit; - if (isOldSide && created.has(item.path)) return { contents: "", isBinary: false }; - return { - contents: side(isOldSide ? "old" : "new", input.lines, input.width), - isBinary: false, - }; - }), - }), - ), - ); - - const slice = yield* provider.getDiff({ - cwd: "/w", - repository: "acme/web", - host: "dev.azure.com", - number: 7, - ...(input.cursor === undefined ? {} : { cursor: input.cursor }), - }); - return { slice, reads, peakInFlight }; - }); - -/** Which files the patch carries a section for, in the order it carries them. */ -function patchedPaths(patch: string): ReadonlyArray { - return [...patch.matchAll(/^diff --git a\/(?\S+) b\//gmu)].map( - (match) => match.groups?.path ?? "", - ); -} - -describe("getChangeRequestSummary", () => { - it.effect("costs the one pull request read, not the iterations changedFiles needs", () => - Effect.gen(function* () { - let pullRequestReads = 0; - - const provider = yield* make.pipe( - Effect.provide( - // listIterations and listIterationChanges are left unimplemented here, so a summary - // that reached for either would die with UnimplementedError instead of this passing. - Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ - getPullRequest: () => { - pullRequestReads += 1; - return Effect.succeed(PULL_REQUEST); - }, - }), - ), - ); - - const readSummary = provider.getChangeRequestSummary; - if (readSummary === undefined) return yield* Effect.die("summary read was not implemented"); - const summary = yield* readSummary({ - cwd: "/w", - repository: "acme/web", - host: "dev.azure.com", - number: 7, - }); - - expect(pullRequestReads).toBe(1); - expect(summary.title).toBe(PULL_REQUEST.title); - expect(summary.changedFiles).toBeUndefined(); - }), - ); -}); - -describe("getChangeRequest", () => { - it.effect("still reports the file count the detail panel needs", () => - Effect.gen(function* () { - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ - getPullRequest: () => Effect.succeed(PULL_REQUEST), - listIterations: () => Effect.succeed([ITERATION]), - listIterationChanges: () => - Effect.succeed({ changes: [change("a.ts"), change("b.ts")], truncated: false }), - }), - ), - ); - - const detail = yield* provider.getChangeRequest({ - cwd: "/w", - repository: "acme/web", - host: "dev.azure.com", - number: 7, - }); - - expect(detail.changedFiles).toBe(2); - }), - ); -}); - -describe("getDiff reads", () => { - it.effect("holds every reader together to one request's worth of processes", () => - Effect.gen(function* () { - // The fan-out inside a read bounds one Code tab. Two people opening two Azure reviews at - // once are two reads, so without a ceiling above them both they are twice a request's - // processes, each paying a Python interpreter's start-up on the same machine. - const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; - let inFlight = 0; - let peakInFlight = 0; - - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ - getPullRequest: () => Effect.succeed(PULL_REQUEST), - listIterations: () => Effect.succeed([ITERATION]), - listIterationChanges: () => - Effect.succeed({ changes: paths.map((path) => change(path)), truncated: false }), - readItemContent: () => - Effect.gen(function* () { - inFlight += 1; - peakInFlight = Math.max(peakInFlight, inFlight); - // Suspends before answering, as a subprocess would, so what is out at once is the - // scheduler's answer rather than an artefact of resolving inline. - yield* Effect.yieldNow; - yield* Effect.yieldNow; - inFlight -= 1; - return { contents: side("new", 2, 4), isBinary: false }; - }), - }), - ), - ); - - const readDiff = (number: number) => - provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number }); - - yield* Effect.all([readDiff(7), readDiff(8)], { concurrency: 2 }); - - expect(peakInFlight).toBeLessThanOrEqual(MAX_DIFF_SPAWNS); - }), - ); - - it.effect("leaves a run of files it could not diff at all for the next slice", () => - Effect.gen(function* () { - // A binary, oversize, purely renamed or unreadable entry is a header apiece, a couple of - // hundred bytes with no edits in it, so a change made of them spends neither budget: the - // byte one would take well over a thousand of them, and the edit one never fills at all. - // A listing holds up to ten thousand entries and each still costs its two reads, which is - // what a file count is here to bound. - const paths = Array.from({ length: MAX_DIFF_SLICE_FILES + 20 }, (_, at) => `gen/a${at}.bin`); - const read = yield* readSlice({ paths, lines: 2, width: 4, refused: paths }); - - expect(patchedPaths(read.slice.patch)).toHaveLength(MAX_DIFF_SLICE_FILES); - // Well inside the byte budget, so the file count is what stopped it rather than either of - // the budgets that were already there. - expect(byteLength(read.slice.patch)).toBeLessThan(MAX_DIFF_SLICE_BYTES); - expect(parseAzureDevOpsDiffCursor(read.slice.nextCursor)?.fileIndex).toBe( - MAX_DIFF_SLICE_FILES, - ); - }), - ); - - it.effect("asks for both sides of several files at once rather than one side at a time", () => - Effect.gen(function* () { - // Each file is two `az` invocations, each paying a Python interpreter's start-up, so a - // slice read one side after another is most of what the Code tab waits for. - const read = yield* readSlice({ - paths: ["a.ts", "b.ts", "c.ts", "d.ts"], - lines: 2, - width: 4, - }); - - expect(read.peakInFlight).toBeGreaterThan(2); - }), - ); - - it.effect("holds the number of files it reads at once down", () => - Effect.gen(function* () { - // The host throttles, and `az` is a process on the same machine the reader runs agents on, - // so a long change is read in batches rather than all at once. - const paths = Array.from({ length: 24 }, (_, file) => `file-${file}.ts`); - const read = yield* readSlice({ paths, lines: 2, width: 4 }); - - expect(read.reads).toHaveLength(paths.length * 2); - expect(read.peakInFlight).toBeLessThanOrEqual(8); - }), - ); - - it.effect("keeps the patch in the order the change was listed, whoever answered first", () => - Effect.gen(function* () { - const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"]; - const read = yield* readSlice({ paths, lines: 2, width: 4 }); - - expect(patchedPaths(read.slice.patch)).toEqual(paths); - expect(read.slice.nextCursor).toBeNull(); - }), - ); - - it.effect("leaves a file the host refused listed without its hunks, in its place", () => - Effect.gen(function* () { - const paths = ["a.ts", "b.ts", "c.ts"]; - const read = yield* readSlice({ paths, lines: 2, width: 4, refused: ["b.ts"] }); - - expect(patchedPaths(read.slice.patch)).toEqual(paths); - expect(read.slice.truncated).toBe(true); - // Its section ends at its header, and the files around it still carry their hunks. - expect(read.slice.patch).toContain("+++ b/b.ts\ndiff --git a/c.ts"); - expect(read.slice.patch.match(/^@@ /gmu)).toHaveLength(2); - }), - ); -}); - -describe("what one diff slice spends", () => { - it.effect("stops on the byte ceiling without carrying what it read past it", () => - Effect.gen(function* () { - // Two of these fill the slice, and the batch they were read in reached two files further. - // Those two belong to the next slice: carrying them would put the request past a ceiling - // that is there to bound what one answer weighs. - const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; - const read = yield* readSlice({ paths, lines: 100, width: 900 }); - - expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES); - expect(patchedPaths(read.slice.patch)).toEqual(["a.ts", "b.ts"]); - expect(read.slice.nextCursor).toBe(`${ITERATION.id}:2`); - expect(new Set(read.reads)).toEqual(new Set(["a.ts", "b.ts", "c.ts", "d.ts"])); - }), - ); - - it.effect("narrows what it reads at once as the slice fills", () => - Effect.gen(function* () { - // Four files fit inside the budget and the fifth is past half of what is left of it, so - // reading four more would throw most of them away and read them again next slice. Every - // file is two `az` invocations, so the batch is judged against what the files before it - // weighed rather than left at its full width to the last file. - const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts", "g.ts", "h.ts"]; - const read = yield* readSlice({ paths, lines: 50, width: 450 }); - - expect(read.slice.nextCursor).not.toBeNull(); - expect(new Set(read.reads)).toEqual(new Set(patchedPaths(read.slice.patch))); - }), - ); - - it.effect("stops once the diff work one request may do is spent", () => - Effect.gen(function* () { - // Short lines are cheap on the wire and dear to diff, so the byte ceiling alone would let - // one request hold the thread through a dozen of them. Each of these is half of what one - // file is allowed, and the slice ends while there is still room for another. - const paths = Array.from({ length: 12 }, (_, file) => `file-${file}.ts`); - const read = yield* readSlice({ paths, lines: MAX_FILE_DIFF_EDITS / 4, width: 1 }); - - expect(read.slice.patch.length).toBeLessThan(MAX_DIFF_SLICE_BYTES); - expect(patchedPaths(read.slice.patch)).toEqual(paths.slice(0, 5)); - expect(read.slice.nextCursor).toBe(`${ITERATION.id}:5`); - }), - ); - - it.effect("carries a whole new file and stops the slice on what it weighed", () => - Effect.gen(function* () { - // A creation has no edit distance to search out, so no edit bound applies to it and its - // section is the whole file. What keeps a run of them from filling one answer is the bytes - // they weighed, which the slice has to be charged for. - const paths = ["new.ts", "b.ts", "c.ts", "d.ts"]; - const read = yield* readSlice({ paths, lines: 8_000, width: 30, created: ["new.ts"] }); - - expect(patchedPaths(read.slice.patch)).toEqual(["new.ts"]); - expect(read.slice.patch).toContain("--- /dev/null"); - expect(read.slice.patch).toContain("@@ -0,0 +1,8000 @@"); - expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES); - expect(read.slice.nextCursor).toBe(`${ITERATION.id}:1`); - }), - ); - - it.effect("carries on from where the last slice stopped", () => - Effect.gen(function* () { - const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; - const read = yield* readSlice({ - paths, - lines: 100, - width: 900, - cursor: `${ITERATION.id}:2`, - }); - - expect(patchedPaths(read.slice.patch)).toEqual(["c.ts", "d.ts"]); - expect(read.slice.nextCursor).toBe(`${ITERATION.id}:4`); - }), - ); - it.effect("keeps the pull request being read, not the one looked up first", () => - Effect.gen(function* () { - // Where a pull request lives is read from the pull request itself, so an evicted entry - // costs a whole pull request read before any file can be asked for. Ordered by insertion - // alone a hit does not renew its entry, so the review being worked through is the first - // thing dropped once a listing has walked a cache's worth of cold pull requests. - const HOT = 7; - const readsOf = new Map(); - - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ - getPullRequest: (input) => - Effect.sync(() => { - readsOf.set(input.number, (readsOf.get(input.number) ?? 0) + 1); - return { ...PULL_REQUEST, number: input.number }; - }), - listIterations: () => Effect.succeed([ITERATION]), - listIterationChanges: () => - Effect.succeed({ changes: [change("a.ts")], truncated: false }), - readItemContent: () => Effect.succeed({ contents: side("new", 2, 4), isBinary: false }), - }), - ), - ); - - const readDiff = (number: number) => - provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number }); - - yield* readDiff(HOT); - // A cache's worth of cold pull requests, with the open one read in between each of them. - for (let filled = 0; filled < LOCATION_CACHE_CAPACITY; filled += 1) { - yield* readDiff(HOT + 1 + filled); - yield* readDiff(HOT); - } - - expect(readsOf.get(HOT)).toBe(1); - }), - ); -}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts deleted file mode 100644 index afd0456369473ec9bdbd53f61673d4f7c672c3b6..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ /dev/null @@ -1,647 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Semaphore from "effect/Semaphore"; -import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; - -import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; -import { - azureDevOpsFilePatch, - azureDevOpsUnreadableFilePatch, - formatAzureDevOpsDiffCursor, - parseAzureDevOpsDiffCursor, - MAX_DIFF_SLICE_BYTES, - MAX_DIFF_SLICE_EDITS, - MAX_DIFF_SLICE_FILES, - byteLength, - MAX_FILE_DIFF_EDITS, - type AzureDevOpsFileTexts, -} from "./azureDevOpsDiff.ts"; -import { - PullRequestProviderError, - type PullRequestProviderFailure, - type ProviderChangeRequest, - type ProviderChangeRequestActivity, - type ProviderChangeRequestDetail, - type ProviderChangeRequestSummary, - type ProviderDiffSlice, - type PullRequestProviderApi, -} from "@t3tools/source-control-core/server/PullRequestProvider"; -import type { - AzureDevOpsChangeEntry, - AzureDevOpsItemContent, - AzureDevOpsIteration, - AzureDevOpsPullRequest, - AzureDevOpsRepositoryLocation, -} from "./azureDevOpsPullRequestJson.ts"; - -/** - * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a - * Python interpreter's start-up, so reading them one after another is most of what the Code tab - * waits for. Four files means eight processes at once: the fan-out the GitHub CLI reads its - * per-file stats with here, and low enough not to swamp the host's throttling or the machine. - */ -const DIFF_FILE_CONCURRENCY = 4; - -/** - * How many `az` processes this build will have out at once, counted across every reader rather - * than per request. The fan-out above bounds one Code tab, so two people opening two Azure reviews - * had sixteen Python interpreters starting at once and nothing above them. Held at what one - * request at full width spends, so a second reader waits behind the first instead of adding to it. - */ -export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY; - -/** How many pull requests' repository locations one provider remembers at once. */ -export const LOCATION_CACHE_CAPACITY = 128; - -const CAPABILITIES: PullRequestCapabilities = { - // Azure serves no patch of its own, so the one the Code tab reads is built here out of the - // files an iteration changed and both sides of each of them. - diff: true, - // Reading a conversation is a plain REST read, but posting one is not something this can - // claim without having run it, so the composer stays hidden. - comment: false, - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "enable-auto-merge", - "disable-auto-merge", - ], - // Azure squashes as a completion option; it has no rebase strategy of its own. - mergeMethods: ["merge", "squash"], - // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. - search: false, - reactions: false, - // The patch has lines to write against, but writing a remark at all is what Azure is not - // offered for here, so nothing in a review is either. - review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, - // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` - // lists the ones this repository could name — that lives behind the identity and graph APIs, a - // different service with its own permissions. So the page takes a name here rather than being - // handed a menu built out of a guess. - reviewers: { request: true, listCandidates: false }, - // A new title and description travel on the same `az repos pr update` that moves a pull request. - // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on - // Azure DevOps at all, so there is nothing here it could rewrite either. - edit: { changeRequest: true, comment: false }, - // Azure does keep a viewed record of its own, but only behind the undocumented contribution - // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway. - // So they are kept here instead, and the client says whose they are rather than implying the - // Azure DevOps page will show them. - viewedFiles: "environment", -}; - -/** - * Everything this host offers, granted to whoever is signed in. Azure DevOps states no permission - * anywhere `az repos pr show` or `az repos pr list` reach: the answer lives in the security - * namespaces, behind identity descriptors and token paths that would be several calls per pull - * request to resolve. - * - * So the actions stay live and a viewer who may not take one is told so by Azure, at the moment - * they try. That is the safer half of an unknown: hiding a control from someone entitled to it - * leaves them no way through and no reason given. - */ -const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { - actions: CAPABILITIES.actions, - comment: CAPABILITIES.comment, - resolve: CAPABILITIES.review.resolve, - verdicts: CAPABILITIES.review.verdicts, - requestReviewers: CAPABILITIES.reviewers.request, -}; - -/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ -export function azureDevOpsProviderFailure( - error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError, -): PullRequestProviderFailure { - if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" }; - if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" }; - if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" }; - if (error._tag === "AzureDevOpsPullRequestNotFoundError") return { reason: "not-found" }; - return { reason: "failed" }; -} - -function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeRequest { - return { - number: pullRequest.number, - title: pullRequest.title, - url: pullRequest.url, - author: pullRequest.author, - headBranch: pullRequest.headBranch, - baseBranch: pullRequest.baseBranch, - state: pullRequest.state, - isDraft: pullRequest.isDraft, - mergeability: pullRequest.mergeability, - // Azure counts a pull request's files but never its lines, and counting them here would mean - // reading every file on both sides of every row of a listing. - additions: 0, - deletions: 0, - createdAt: pullRequest.createdAt, - closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, - mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, - updatedAt: pullRequest.updatedAt, - reviewRequestLogins: pullRequest.reviewRequestLogins, - // Azure keeps labels on work items rather than on the pull request. - labels: [], - }; -} - -export const make = Effect.gen(function* () { - const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - // Made once with the provider, which the registry builds once, so this is the whole build's - // allowance rather than one request's. - const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS); - const readItemContent = (input: Parameters[0]) => - diffSpawns.withPermits(1)(cli.readItemContent(input)); - - const fail = - (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) => - new PullRequestProviderError({ - provider: "azure-devops", - operation, - ...azureDevOpsProviderFailure(error), - detail: error.detail, - cause: error, - }); - - /** Refuses what the capabilities already say this host cannot do. */ - const unsupported = (operation: string) => - Effect.fail( - new PullRequestProviderError({ - provider: "azure-devops", - operation, - reason: "failed", - detail: "Azure DevOps reviews cannot be written from here yet.", - }), - ); - - /** A pull request Azure could not place has no diff to read, which reads as an empty one. */ - const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null }; - - /** - * Where a pull request's repository lives, which is the route every other read of it needs and - * the one thing only the pull request itself states. A pull request cannot move between - * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for - * a whole pull request read every time they checked whether a file had been pushed to. Least - * recently used, so a listing walking cold pull requests cannot evict the one being read. - */ - const locations = new Map(); - - const locationOf = (input: { readonly cwd: string; readonly number: number }) => { - const key = `${input.cwd} ${input.number}`; - const held = locations.get(key); - if (held !== undefined) { - locations.delete(key); - locations.set(key, held); - return Effect.succeed(held); - } - return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( - Effect.map((pullRequest) => { - const location = pullRequest.location; - if (location === null) return null; - if (locations.size >= LOCATION_CACHE_CAPACITY) { - const oldest = locations.keys().next().value; - if (oldest !== undefined) locations.delete(oldest); - } - locations.set(key, location); - return location; - }), - ); - }; - - /** - * Everything a diff read needs before it can ask for a file: where the repository lives, and - * which pushes the pull request has had. A client names neither, and the iterations are read - * afresh every time because the newest one is what a push adds. - */ - const diffScope = (input: { readonly cwd: string; readonly number: number }) => - Effect.gen(function* () { - const location = yield* locationOf(input); - if (location === null) return null; - const iterations = yield* cli.listIterations({ - cwd: input.cwd, - location, - number: input.number, - }); - return { location, iterations }; - }); - - const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; - - /** - * Both sides of one changed file, read at once because neither answer depends on the other and - * `az` pays a Python interpreter's start-up for each. Only the sides a change actually has are - * asked for: Azure answers for a file that is not at a commit with a failure rather than with - * nothing. - */ - const readTexts = (input: { - readonly cwd: string; - readonly location: AzureDevOpsRepositoryLocation; - readonly iteration: AzureDevOpsIteration; - readonly change: Pick; - }) => - Effect.all( - [ - input.change.changeKind === "new" - ? Effect.succeed(EMPTY_ITEM) - : readItemContent({ - cwd: input.cwd, - location: input.location, - path: input.change.oldPath, - commit: input.iteration.mergeBaseCommit, - }), - input.change.changeKind === "deleted" - ? Effect.succeed(EMPTY_ITEM) - : readItemContent({ - cwd: input.cwd, - location: input.location, - path: input.change.path, - commit: input.iteration.headCommit, - }), - ], - { concurrency: 2 }, - ).pipe( - Effect.map(([oldItem, newItem]): AzureDevOpsFileTexts => ({ - oldContents: oldItem.contents, - newContents: newItem.contents, - // Azure hands a file it calls binary over in an encoding of its own, so its own word on - // that is taken rather than looked for in bytes it may never have sent verbatim. - binary: oldItem.isBinary || newItem.isBinary, - })), - ); - - /** - * What the whole pull request changed, taken from its latest push. An iteration's changes are - * reported against the merge base rather than against the push before it, so the newest one is - * the whole of the change rather than the last slice of it. - */ - const listLatestChanges = (input: { - readonly cwd: string; - readonly location: AzureDevOpsRepositoryLocation; - readonly number: number; - readonly iterations: ReadonlyArray; - }) => { - const latest = input.iterations.at(-1); - return latest === undefined - ? Effect.succeed({ - changes: [], - truncated: false, - } as AzureDevOpsPullRequestCli.AzureDevOpsIterationChanges) - : cli.listIterationChanges({ - cwd: input.cwd, - location: input.location, - number: input.number, - iterationId: latest.id, - }); - }; - - const provider: PullRequestProviderApi = { - kind: "azure-devops", - capabilities: CAPABILITIES, - - getViewer: (input) => - cli.getViewer({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), - - // `input.query` is deliberately dropped: `az repos pr list` filters by status, creator, - // reviewer and branch, and has nothing that matches text. Sending it as one of those would - // narrow by the wrong thing, so the page comes back unnarrowed and the caller filters it. - listChangeRequests: (input) => - cli - .listPullRequests({ - cwd: input.cwd, - repository: input.repository, - state: input.state, - involvement: input.involvement, - viewer: input.viewer, - limit: input.limit, - cursor: input.cursor, - }) - .pipe( - Effect.mapError(fail("listChangeRequests")), - Effect.map((batch) => ({ - items: batch.items.map(toChangeRequest), - truncated: batch.truncated, - cursorAdvance: batch.cursorAdvance, - // Azure answers in one order whether or not it is being carried on from, so a slice - // can always be stepped past — by counting, which is all Azure offers. - continues: true, - })), - ), - - // The polled path a linked thread's row stays live on: one `az` read, no iterations or - // changes behind it, since the file count that would cost is not shown here. - getChangeRequestSummary: (input) => - cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( - Effect.mapError(fail("getChangeRequestSummary")), - Effect.map((pullRequest): ProviderChangeRequestSummary => ({ - number: pullRequest.number, - title: pullRequest.title, - url: pullRequest.url, - author: pullRequest.author, - headBranch: pullRequest.headBranch, - baseBranch: pullRequest.baseBranch, - state: pullRequest.state, - isDraft: pullRequest.isDraft, - mergeability: pullRequest.mergeability, - closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, - mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, - updatedAt: pullRequest.updatedAt, - })), - ), - - getChangeRequest: (input) => - Effect.gen(function* () { - const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); - const location = pullRequest.location; - // The file count is two reads past the pull request itself, and it is the only thing - // riding on them, so a failure leaves it unknown rather than losing the whole detail. - const changedFiles = - location === null - ? 0 - : yield* cli.listIterations({ cwd: input.cwd, location, number: input.number }).pipe( - Effect.flatMap((iterations) => - listLatestChanges({ - cwd: input.cwd, - location, - number: input.number, - iterations, - }), - ), - Effect.map((listed) => listed.changes.length), - Effect.orElseSucceed(() => 0), - ); - const detail: ProviderChangeRequestDetail = { - ...toChangeRequest(pullRequest), - body: pullRequest.body, - changedFiles, - mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, - closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, - reviewers: pullRequest.reviewers, - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: false }, - viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, - autoMergeEnabled: pullRequest.autoMergeEnabled, - ...(pullRequest.autoMergeMethod === undefined - ? {} - : { autoMergeMethod: pullRequest.autoMergeMethod }), - }; - return detail; - }).pipe(Effect.mapError(fail("getChangeRequest"))), - - getChangeRequestActivity: (input) => - cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( - Effect.mapError(fail("getChangeRequestActivity")), - Effect.flatMap((pullRequest) => - (pullRequest.location === null - ? Effect.succeed({ comments: [], truncated: true }) - : cli - .listThreads({ - cwd: input.cwd, - location: pullRequest.location, - number: input.number, - }) - .pipe( - Effect.map((comments) => ({ comments, truncated: false })), - Effect.orElseSucceed(() => ({ comments: [], truncated: true })), - ) - ).pipe( - Effect.map((conversation): ProviderChangeRequestActivity => ({ - comments: conversation.comments, - commentCount: conversation.comments.length, - commentsTruncated: conversation.truncated, - reviewThreads: [], - commits: [], - })), - ), - ), - ), - - // No request at all: Azure has nothing to say about the viewer that a pull request read can - // reach, so the answer is the same constant the detail carries. - getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS), - - // `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so - // the Code tab has nothing to scope itself to and always asks for the whole change. - getDiff: (input) => - Effect.gen(function* () { - const scope = yield* diffScope(input); - if (scope === null) return EMPTY_DIFF_SLICE; - const cursor = parseAzureDevOpsDiffCursor(input.cursor); - // Reading on stays with the push the first slice was taken against. A push landing - // mid-read would otherwise renumber the files and hand the reader one twice, or none. - const iteration = - cursor === null - ? scope.iterations.at(-1) - : scope.iterations.find((candidate) => candidate.id === cursor.iterationId); - if (iteration === undefined) return EMPTY_DIFF_SLICE; - const listed = yield* cli.listIterationChanges({ - cwd: input.cwd, - location: scope.location, - number: input.number, - iterationId: iteration.id, - }); - const changes = listed.changes; - - const sections: string[] = []; - let truncated = listed.truncated; - let bytes = 0; - let edits = 0; - let index = cursor?.fileIndex ?? 0; - let full = false; - // How many files to read at once. Every file costs a request a side, and everything read - // past the point the slice fills is thrown away and read again by the next slice, so the - // batch narrows as the budgets do: what is left of each, over what a file has spent of it - // on average so far, which is the only estimate there is before a file is read. At least - // one, so a file heavier than the whole budget still moves the cursor. - const batchWidth = () => { - if (sections.length === 0) return DIFF_FILE_CONCURRENCY; - const admits = (left: number, spent: number) => - Math.ceil(left / Math.max(1, spent / sections.length)); - return Math.max( - 1, - Math.min( - DIFF_FILE_CONCURRENCY, - // The file budget needs no estimate: a file spends exactly one of it. - MAX_DIFF_SLICE_FILES - sections.length, - admits(MAX_DIFF_SLICE_BYTES - bytes, bytes), - admits(MAX_DIFF_SLICE_EDITS - MAX_FILE_DIFF_EDITS - edits, edits), - ), - ); - }; - while (!full && index < changes.length) { - const batch = changes.slice(index, index + batchWidth()); - const read = yield* Effect.forEach( - batch, - (change) => - // A pair Azure refuses is one file rather than the whole slice: an oversize blob or - // a path `az` will not carry through leaves that file listed without its hunks, and - // everything around it still renders. - readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).pipe( - // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az` - // at all is the read failing rather than the file, and belongs to the caller, - // which pauses the host rather than showing every file as unreadable. - Effect.catchTags({ - AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null), - AzureDevOpsCommandFailedError: () => Effect.succeed(null), - AzureDevOpsPullRequestReadError: () => Effect.succeed(null), - }), - Effect.map((texts) => ({ change, texts })), - ), - { concurrency: DIFF_FILE_CONCURRENCY }, - ); - for (const { change, texts } of read) { - // The diff is synchronous and the reads no longer stand between one file and the next - // to let anything else on the server run, so the thread is handed back here. - yield* Effect.yieldNow; - const file = - texts === null - ? azureDevOpsUnreadableFilePatch(change) - : azureDevOpsFilePatch({ change, texts }); - sections.push(file.section); - bytes += byteLength(file.section); - edits += file.edits; - truncated = truncated || file.truncated; - index += 1; - // A file whose diff was given up on spent the whole of what one file is allowed and - // has only a header to show for it, and a file the diff never ran on at all weighs - // almost nothing in either budget and still costs its two reads. So the file count - // bounds the request alongside the bytes. - // Checked after the file is added rather than before it, so every slice carries at - // least one: a section heavier than the whole budget would otherwise never be added, - // and the read would answer the same slice forever without moving the cursor. - if ( - bytes >= MAX_DIFF_SLICE_BYTES || - edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS || - sections.length >= MAX_DIFF_SLICE_FILES || - file.abandoned - ) { - full = true; - break; - } - } - } - - const slice: ProviderDiffSlice = { - patch: sections.join(""), - truncated, - nextCursor: - index >= changes.length - ? null - : formatAzureDevOpsDiffCursor({ iterationId: iteration.id, fileIndex: index }), - }; - return slice; - }).pipe(Effect.mapError(fail("getDiff"))), - - // The patch is built from whole files, so opening the lines around a hunk is the same two - // reads over again rather than a wider request. Read against the latest iteration: nothing in - // the request says which push the reader is looking at, and expansion is stale after a - // mid-review push on every host here anyway. - getDiffFileContents: (input) => - Effect.gen(function* () { - const scope = yield* diffScope(input); - const iteration = scope?.iterations.at(-1); - if (scope === null || iteration === undefined) return { oldContents: "", newContents: "" }; - return yield* readTexts({ - cwd: input.cwd, - location: scope.location, - iteration, - change: { - changeKind: input.changeType, - path: input.newPath, - oldPath: input.oldPath, - }, - }); - }).pipe(Effect.mapError(fail("getDiffFileContents"))), - - /** - * What the head has of each marked file, which is the blob Azure already names on the change - * it reports. One read covers every path, since the latest iteration lists the whole change. - * - * A path the change does not carry is at the empty revision, which is where a file the pull - * request deletes sits. When the change was too long to follow to its end, those paths are - * left out instead: reporting them as deleted would clear a file nobody has read. - */ - getFileRevisions: (input) => - Effect.gen(function* () { - const revisions = new Map(); - if (input.paths.length === 0) return { revisions }; - const scope = yield* diffScope(input); - if (scope === null) return { revisions }; - const listed = yield* listLatestChanges({ - ...scope, - cwd: input.cwd, - number: input.number, - }); - const marked = new Set(input.paths); - for (const change of listed.changes) { - if (!marked.has(change.path) || change.objectId === null) continue; - revisions.set(change.path, change.objectId); - } - if (!listed.truncated) { - for (const path of input.paths) { - if (!revisions.has(path)) revisions.set(path, ""); - } - } - return { revisions }; - }).pipe(Effect.mapError(fail("getFileRevisions"))), - - runAction: (input) => - cli - .runPullRequestAction({ - cwd: input.cwd, - number: input.number, - action: input.action, - ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), - }) - .pipe(Effect.mapError(fail("runAction"))), - - updateChangeRequest: (input) => - cli - .updatePullRequest({ - cwd: input.cwd, - number: input.number, - title: input.title, - body: input.body, - }) - .pipe(Effect.mapError(fail("updateChangeRequest"))), - - // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the - // list without it. - listReviewerCandidates: () => - Effect.fail( - new PullRequestProviderError({ - provider: "azure-devops", - operation: "listReviewerCandidates", - reason: "failed", - detail: "Azure DevOps cannot say who may review a pull request.", - }), - ), - - setReviewerRequest: (input) => - cli - .setPullRequestReviewers({ - cwd: input.cwd, - number: input.number, - // Azure names an identity by an email address or a guid, and has no team to ask, so a - // candidate's id is the whole of what it takes. - reviewers: input.reviewers.map((reviewer) => reviewer.id), - requested: input.requested, - }) - .pipe(Effect.mapError(fail("setReviewerRequest"))), - - // Never called: `capabilities.comment` is false, and the service refuses a comment without it. - comment: () => unsupported("comment"), - - // Declared unsupported above, so the service refuses these before a provider is reached. - // They exist because every provider answers the whole port. - submitReview: () => unsupported("submitReview"), - - replyToThread: () => unsupported("replyToThread"), - - setThreadResolution: () => unsupported("setThreadResolution"), - - setReaction: () => unsupported("setReaction"), - }; - - return provider; -}); diff --git a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts index ca12da2b8979113bd8561c445da87d99b277db77..38f58735398001c68af44c7abecceb9e5c1e6a56 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts @@ -1,11 +1,11 @@ import { assert, it } from "@effect/vitest"; -import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; +import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; -import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts"; +import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider"; import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts"; import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts"; import { gitLabProviderFailure } from "./GitLabPullRequestProvider.ts"; diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 3c2ad367d4463a7fb14c4275c794dad6a2537eaf..009d3d3f8b4f1c99c459f135b03f46efbc0de980 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -3,12 +3,9 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type { SourceControlProviderKind } from "@t3tools/contracts"; -import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; -import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; -import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; @@ -49,7 +46,6 @@ export const make = Effect.gen(function* () { GitHubPullRequestProvider.make, GitLabPullRequestProvider.make, BitbucketPullRequestProvider.make, - AzureDevOpsPullRequestProvider.make, ]); const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => driver.make.pipe(Effect.map((instance) => instance.pullRequests)), @@ -72,5 +68,4 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), Layer.provide(BuiltInDrivers.layer), Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), - Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))), ); diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts deleted file mode 100644 index 30d575755199aeb47ca9932c7fa515a5f360bbd5..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts +++ /dev/null @@ -1,459 +0,0 @@ -import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; -import { describe, expect, it } from "vite-plus/test"; - -import { - azureDevOpsFilePatch, - azureDevOpsUnreadableFilePatch, - byteLength, - formatAzureDevOpsDiffCursor, - MAX_FILE_DIFF_EDITS, - parseAzureDevOpsDiffCursor, -} from "./azureDevOpsDiff.ts"; -import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; - -function change(overrides: Partial = {}): AzureDevOpsChangeEntry { - return { - path: "README.md", - oldPath: "README.md", - changeKind: "change", - objectId: "8f80", - originalObjectId: "0ca4", - ...overrides, - }; -} - -function texts(oldContents: string, newContents: string, binary = false) { - return { oldContents, newContents, binary }; -} - -describe("azureDevOpsFilePatch", () => { - it("writes a changed file as the unified patch every diff viewer already reads", () => { - const patch = azureDevOpsFilePatch({ - change: change(), - texts: texts("one\ntwo\nthree\n", "one\ntwo again\nthree\n"), - }); - - expect(patch.truncated).toBe(false); - expect(patch.section).toBe( - [ - "diff --git a/README.md b/README.md", - "--- a/README.md", - "+++ b/README.md", - "@@ -1,3 +1,3 @@", - " one", - "-two", - "+two again", - " three", - "", - ].join("\n"), - ); - }); - - it("names the side a new file does not have as /dev/null", () => { - const patch = azureDevOpsFilePatch({ - change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), - texts: texts("", "hello\n"), - }); - - expect(patch.section).toContain("new file mode 100644"); - expect(patch.section).toContain("--- /dev/null"); - expect(patch.section).toContain("+++ b/DEMO.md"); - // Git points the range a new file does not have at line zero, not at line one. - expect(patch.section).toContain("@@ -0,0 +1 @@"); - expect(patch.section).toContain("+hello"); - }); - - it("names the side a deleted file no longer has as /dev/null", () => { - const patch = azureDevOpsFilePatch({ - change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), - texts: texts("gone\n", ""), - }); - - expect(patch.section).toContain("deleted file mode 100644"); - expect(patch.section).toContain("--- a/OLD.md"); - expect(patch.section).toContain("+++ /dev/null"); - expect(patch.section).toContain("@@ -1 +0,0 @@"); - expect(patch.section).toContain("-gone"); - }); - - it("keeps the carriage returns of a file with Windows line endings", () => { - // They are part of the line rather than around it, so a patch that dropped them would ask - // the reader to look at a change that is not the one on the host. - const patch = azureDevOpsFilePatch({ - change: change(), - texts: texts("one\r\ntwo\r\n", "one\r\ntwo again\r\n"), - }); - - expect(patch.section).toContain("-two\r"); - expect(patch.section).toContain("+two again\r"); - }); - - it("keeps a file that only moved, which has no hunks to give", () => { - const patch = azureDevOpsFilePatch({ - change: change({ path: "docs/new.md", oldPath: "docs/old.md", changeKind: "rename-pure" }), - texts: texts("same\n", "same\n"), - }); - - expect(patch.truncated).toBe(false); - expect(patch.section).toBe( - [ - "diff --git a/docs/old.md b/docs/new.md", - "rename from docs/old.md", - "rename to docs/new.md", - "--- a/docs/old.md", - "+++ b/docs/new.md", - "", - ].join("\n"), - ); - }); - - it("reports a binary file as changed rather than spelling it out", () => { - const patch = azureDevOpsFilePatch({ - change: change({ path: "logo.png", oldPath: "logo.png" }), - texts: texts("PNG\u0000old", "PNG\u0000new"), - }); - - expect(patch.truncated).toBe(true); - expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ"); - }); - - it("shows an overlong file as changed without its hunks", () => { - const patch = azureDevOpsFilePatch({ - change: change({ path: "bundle.js", oldPath: "bundle.js" }), - texts: texts("a\n".repeat(400_000), "b\n".repeat(400_000)), - }); - - expect(patch.truncated).toBe(true); - expect(patch.section).toBe( - ["diff --git a/bundle.js b/bundle.js", "--- a/bundle.js", "+++ b/bundle.js", ""].join("\n"), - ); - }); - - it("takes the host's word that a file is binary, whatever its bytes look like", () => { - // Azure hands such a file over base64-encoded, so nothing in the text it sent gives it away. - const patch = azureDevOpsFilePatch({ - change: change({ path: "logo.png", oldPath: "logo.png" }), - texts: texts("b2xk", "bmV3", true), - }); - - expect(patch.truncated).toBe(true); - expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ"); - }); - - it("counts an overlong file in bytes rather than in characters", () => { - // Three bytes each, so a ceiling counted in code units would let three times the size through. - const patch = azureDevOpsFilePatch({ - change: change({ path: "notes.md", oldPath: "notes.md" }), - texts: texts("\u4e00".repeat(200_000), "\u4e8c".repeat(200_000)), - }); - - expect(patch.truncated).toBe(true); - expect(patch.section).toBe( - ["diff --git a/notes.md b/notes.md", "--- a/notes.md", "+++ b/notes.md", ""].join("\n"), - ); - }); - - const lineRange = (count: number, prefix: string) => - Array.from({ length: count }, (_, line) => `${prefix} ${line}`).join("\n"); - - it("lists a file too far apart to diff without its hunks", () => { - // Sharing no line at all costs one edit per line on each side, so this pair is twice the - // ceiling apart. Left to itself the search costs about the square of that and would hold the - // whole server, every websocket client with it, while it worked out a patch nobody reads. - // Writing both sides out instead would read as a genuine rewrite: the ceiling is a distance - // rather than a proportion, so a long file reaches it having changed in one corner, and that - // corner would be buried in a wall of red and green. - const lines = (prefix: string) => - Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n"); - const patch = azureDevOpsFilePatch({ - change: change({ path: "generated.ts", oldPath: "generated.ts" }), - texts: texts(`${lines("old")}\n`, `${lines("new")}\n`), - }); - - expect(patch.truncated).toBe(true); - // And it says the search was given up on, because the reader of a run of files is meant to - // stop rather than spend that work again on each of the ones behind it. - expect(patch.abandoned).toBe(true); - expect(patch.section).toBe( - [ - "diff --git a/generated.ts b/generated.ts", - "--- a/generated.ts", - "+++ b/generated.ts", - "", - ].join("\n"), - ); - }); - - it("writes out a wholly new file however many lines it has", () => { - // Nothing on the old side means there was no edit distance to search out, so this is the - // minimal patch and not a stand-in for one. Fifteen thousand lines of thirty bytes is the - // shape this used to lose: inside the byte ceiling that gates every file, and well past every - // bound a two-sided file answers to, none of which is protecting against anything here. - const contents = `${Array.from({ length: 15_000 }, () => "x".repeat(29)).join("\n")}\n`; - const patch = azureDevOpsFilePatch({ - change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), - texts: texts("", contents), - }); - - expect(byteLength(contents)).toBeLessThan(512 * 1024); - expect(patch.truncated).toBe(false); - expect(patch.abandoned).toBe(false); - expect(patch.edits).toBe(15_000); - expect(patch.section).toContain("@@ -0,0 +1,15000 @@"); - // Only the `+++` of the header on top of the file's own lines, so nothing was dropped out of - // the middle. - expect(patch.section.match(/^\+/gmu)).toHaveLength(15_001); - }); - - it("keeps a wholly new file too heavy to write out listed without its hunks", () => { - // Every line carries a marker, so a side of very short lines answers with up to twice its own - // bytes: these 200,000 one-character lines fit the ceiling each side is read under and weigh - // about 600KB written out, more than twice what a whole slice may carry. There is no smaller - // true patch for a creation to fall back to, so it is listed without its hunks, the same as a - // side too big to read at all, rather than sent at a size the slice budget exists to prevent. - const contents = `${Array.from({ length: 200_000 }, () => "x").join("\n")}\n`; - const patch = azureDevOpsFilePatch({ - change: change({ path: "bundle.min.js", oldPath: "bundle.min.js", changeKind: "new" }), - texts: texts("", contents), - }); - - expect(byteLength(contents)).toBeLessThan(512 * 1024); - expect(patch.truncated).toBe(true); - expect(patch.abandoned).toBe(false); - // It still cost the walk over its lines, which is what the slice is charged for. - expect(patch.edits).toBe(200_000); - expect(patch.section).toBe( - [ - "diff --git a/bundle.min.js b/bundle.min.js", - "new file mode 100644", - "--- /dev/null", - "+++ b/bundle.min.js", - "", - ].join("\n"), - ); - }); - - it("writes out a wholly deleted file however many lines it had", () => { - const contents = `${lineRange(20_000, "line")}\n`; - const patch = azureDevOpsFilePatch({ - change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), - texts: texts(contents, ""), - }); - - expect(patch.truncated).toBe(false); - expect(patch.abandoned).toBe(false); - expect(patch.edits).toBe(20_000); - expect(patch.section).toContain("@@ -1,20000 +0,0 @@"); - expect(patch.section.match(/^-line /gmu)).toHaveLength(20_000); - expect(patch.section.match(/^-/gmu)).toHaveLength(20_001); - }); - - it("gives an empty new file no hunk to read", () => { - // A file with nothing on either side has no lines to claim were replaced, and git writes it - // as a header alone. - const patch = azureDevOpsFilePatch({ - change: change({ path: "EMPTY.md", oldPath: "EMPTY.md", changeKind: "new" }), - texts: texts("", ""), - }); - - expect(patch.edits).toBe(0); - expect(patch.section).not.toContain("@@"); - }); - - it("marks a wholly new file whose last line has no newline after it", () => { - const patch = azureDevOpsFilePatch({ - change: change({ path: "NOTES.md", oldPath: "NOTES.md", changeKind: "new" }), - texts: texts("", "one\ntwo"), - }); - - expect(patch.section).toBe( - [ - "diff --git a/NOTES.md b/NOTES.md", - "new file mode 100644", - "--- /dev/null", - "+++ b/NOTES.md", - "@@ -0,0 +1,2 @@", - "+one", - "+two", - "\\ No newline at end of file", - "", - ].join("\n"), - ); - }); - - it("keeps a file it did diff out of the giving up", () => { - const patch = azureDevOpsFilePatch({ - change: change(), - texts: texts("one\ntwo\n", "one\ntwo again\n"), - }); - - expect(patch.abandoned).toBe(false); - }); - - it("counts what the diff worked out, which is what the file cost to diff", () => { - // The caller spends a budget of these across a slice, so they have to be the edits the search - // actually made: one line replaced is a removal and an addition, and the three lines of - // context around them cost nothing. - const patch = azureDevOpsFilePatch({ - change: change(), - texts: texts("one\ntwo\nthree\nfour\n", "one\ntwo again\nthree\nfour\n"), - }); - - expect(patch.edits).toBe(2); - }); - - it("counts nothing for a file it never diffed", () => { - const patch = azureDevOpsFilePatch({ - change: change({ path: "logo.png", oldPath: "logo.png" }), - texts: texts("PNG\u0000old", "PNG\u0000new"), - }); - - expect(patch.edits).toBe(0); - }); - - it("lists a file whose hunks outweigh its sides without them", () => { - // A handful of very long lines is a few edits and nowhere near the edit ceiling, and the - // patch carries both sides in full with three lines of context around each hunk, so the - // section comes out heavier than either side was. What one file weighs is what a slice's - // budget is spent in, so the edit ceiling alone does not bound this. - const line = `${"a".repeat(400 * 1024)}\n`; - const patch = azureDevOpsFilePatch({ - change: change({ path: "min.js", oldPath: "min.js" }), - texts: texts(line, `${"b".repeat(400 * 1024)}\n`), - }); - - expect(patch.edits).toBeLessThan(MAX_FILE_DIFF_EDITS); - expect(patch.truncated).toBe(true); - expect(patch.section).toBe( - ["diff --git a/min.js b/min.js", "--- a/min.js", "+++ b/min.js", ""].join("\n"), - ); - expect(byteLength(patch.section)).toBeLessThan(byteLength(line)); - }); - - it("marks a file that does not end in a newline, as git does", () => { - const patch = azureDevOpsFilePatch({ - change: change(), - texts: texts("one\n", "two"), - }); - - expect(patch.section).toContain("\\ No newline at end of file"); - }); -}); - -describe("azureDevOpsUnreadableFilePatch", () => { - it("keeps a file the host would not hand over, listed without its hunks", () => { - const patch = azureDevOpsUnreadableFilePatch(change({ path: "huge.bin", oldPath: "huge.bin" })); - - expect(patch.truncated).toBe(true); - expect(patch.section).toBe( - ["diff --git a/huge.bin b/huge.bin", "--- a/huge.bin", "+++ b/huge.bin", ""].join("\n"), - ); - }); -}); - -describe("a file Azure names something a patch header cannot carry plainly", () => { - it("writes each side as git's quoted form, the side letter inside the quotes", () => { - const patch = azureDevOpsFilePatch({ - change: change({ path: "notes\treadme.md", oldPath: "notes\treadme.md" }), - texts: texts("one\n", "two\n"), - }); - - expect(patch.section).toBe( - [ - 'diff --git "a/notes\\treadme.md" "b/notes\\treadme.md"', - '--- "a/notes\\treadme.md"', - '+++ "b/notes\\treadme.md"', - "@@ -1 +1 @@", - "-one", - "+two", - "", - ].join("\n"), - ); - }); - - it("keeps a name holding a newline on the one header line it belongs to", () => { - const patch = azureDevOpsFilePatch({ - change: change({ path: "line\nfile.txt", oldPath: "line\nfile.txt" }), - texts: texts("one\n", "two\n"), - }); - - // Written as itself the name would start a line of its own, and a reader would take what - // followed for a header the patch never had. - expect(patch.section.split("\n").slice(0, 3)).toEqual([ - 'diff --git "a/line\\nfile.txt" "b/line\\nfile.txt"', - '--- "a/line\\nfile.txt"', - '+++ "b/line\\nfile.txt"', - ]); - }); - - it("quotes the names a rename states, which carry no side letter", () => { - const patch = azureDevOpsFilePatch({ - change: change({ - path: "docs/new\tname.md", - oldPath: "docs/old\tname.md", - changeKind: "rename-pure", - }), - texts: texts("same\n", "same\n"), - }); - - expect(patch.section).toBe( - [ - 'diff --git "a/docs/old\\tname.md" "b/docs/new\\tname.md"', - 'rename from "docs/old\\tname.md"', - 'rename to "docs/new\\tname.md"', - '--- "a/docs/old\\tname.md"', - '+++ "b/docs/new\\tname.md"', - "", - ].join("\n"), - ); - }); - - it("quotes the sides of the one line a binary file gets", () => { - const patch = azureDevOpsFilePatch({ - change: change({ path: "logo\tmark.png", oldPath: "logo\tmark.png" }), - texts: texts("PNG\u0000old", "PNG\u0000new"), - }); - - expect(patch.section).toContain( - 'Binary files "a/logo\\tmark.png" and "b/logo\\tmark.png" differ', - ); - }); - - it("hands a reader of the header back the name Azure gave", () => { - const path = 'every\t\n"kind"\\of.md'; - const patch = azureDevOpsFilePatch({ - change: change({ path, oldPath: path }), - texts: texts("one\n", "two\n"), - }); - const [header, oldLine, newLine] = patch.section.split("\n"); - - expect(header).not.toContain("\t"); - expect(unquoteGitPatchPath(oldLine?.slice(4) ?? "")).toBe(`a/${path}`); - expect(unquoteGitPatchPath(newLine?.slice(4) ?? "")).toBe(`b/${path}`); - }); -}); - -describe("a diff cursor", () => { - it("carries the push it was taken against back to the next slice", () => { - const cursor = formatAzureDevOpsDiffCursor({ iterationId: 3, fileIndex: 12 }); - - expect(parseAzureDevOpsDiffCursor(cursor)).toEqual({ iterationId: 3, fileIndex: 12 }); - }); - - it("reads anything it did not write as no position at all", () => { - // Which starts the read from the top rather than failing it: a cursor is the client's to - // hand back, and nothing downstream is worth refusing a whole diff over. - for (const raw of [undefined, null, "", "abc", "1", "0:4", "1:-2", "1:2:3"]) { - expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); - } - }); - - it("refuses a half it did not write rather than reading it as the first file", () => { - // `Number` is wider than the cursor: an empty, padded or hex half would otherwise pass as a - // position, and the read would resume against an iteration the client never saw instead of - // starting again from the latest one. - for (const raw of ["1:", ":4", "1: ", " 1:4", "1:0x2", "0x1:2", "1e2:0", "1:4.0"]) { - expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); - } - }); -}); diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts deleted file mode 100644 index aa5323c32fa649c347c75200024b478494d1ee4e..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; -import { structuredPatch } from "diff"; - -import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; - -/** - * How far a diff read got, and which push it was reading. Azure hangs a pull request's changed - * files off an iteration, so a push landing mid-read would renumber the list under a cursor that - * did not also pin the iteration. - */ -export interface AzureDevOpsDiffCursor { - readonly iterationId: number; - readonly fileIndex: number; -} - -const CURSOR_SEPARATOR = ":"; - -/** - * Restricts each half to plain decimal, so `Number` cannot read a foreign cursor's `0x3` as - * three. - */ -const CURSOR_COMPONENT = /^\d+$/; - -export function formatAzureDevOpsDiffCursor(cursor: AzureDevOpsDiffCursor): string { - return `${cursor.iterationId}${CURSOR_SEPARATOR}${cursor.fileIndex}`; -} - -/** Null for anything this did not write, which starts the read from the top rather than failing. */ -export function parseAzureDevOpsDiffCursor( - raw: string | null | undefined, -): AzureDevOpsDiffCursor | null { - if (raw === null || raw === undefined) return null; - const [iteration, file, ...rest] = raw.split(CURSOR_SEPARATOR); - if (rest.length > 0) return null; - if (iteration === undefined || file === undefined) return null; - if (!CURSOR_COMPONENT.test(iteration) || !CURSOR_COMPONENT.test(file)) return null; - const iterationId = Number(iteration); - const fileIndex = Number(file); - if (!Number.isSafeInteger(iterationId) || iterationId <= 0) return null; - if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) return null; - return { iterationId, fileIndex }; -} - -/** The two texts of one changed file, empty on whichever side the change does not have. */ -export interface AzureDevOpsFileTexts { - readonly oldContents: string; - readonly newContents: string; - /** Azure's own flag for a file it hands back base64-encoded instead of as text. */ - readonly binary: boolean; -} - -export interface AzureDevOpsFilePatch { - readonly section: string; - /** The file changed but its hunks are not in the section, so the patch has a hole in it. */ - readonly truncated: boolean; - /** - * The diff was given up on partway, having spent the whole edit budget, so a caller reading a - * run of files stops here rather than paying that again for each one behind it. - */ - readonly abandoned: boolean; - /** - * Lines added or removed, which is the edit distance the diff had to search out, and what a - * slice's budget is spent in: a file of short lines is cheap on the wire and dear to diff. - */ - readonly edits: number; -} - -/** - * Beyond this a file is shown as changed without its hunks. Azure hands back whole files rather - * than a patch, so an oversize file is paid for twice over before anything can be diffed. - */ -const MAX_FILE_BYTES = 512 * 1024; - -/** Git's own default, and what the hunks from this repo's other hosts are already cut to. */ -const PATCH_CONTEXT_LINES = 3; - -/** - * How far apart one file's two sides may be before it is listed without its hunks. The line diff - * costs about the square of the edit distance, so an unbounded pair sharing almost nothing could - * hold the whole server. Bounded in edits rather than milliseconds so a change slices the same - * way on every machine. - */ -export const MAX_FILE_DIFF_EDITS = 2_000; - -/** - * A backstop for a machine slower than the edit ceiling was tuned for. The ceiling rather than - * this is what decides a patch's shape: a timeout that fires would slice the same change one way - * here and another on a busier host. - */ -const MAX_FILE_DIFF_MILLIS = 2_000; - -/** How much diff work one slice does before the rest is left for the next request. */ -export const MAX_DIFF_SLICE_EDITS = 6_000; - -/** How much patch one slice carries before the rest is left for the next request. */ -export const MAX_DIFF_SLICE_BYTES = 256 * 1024; - -/** - * How many files one slice carries however little each one weighs. A binary, oversize, or - * unreadable entry is just a header, so neither budget above stops a run of thousands of them. - */ -export const MAX_DIFF_SLICE_FILES = 300; - -/** Git's own note for a side whose last line has no newline after it. */ -const NO_NEWLINE_MARKER = "\\ No newline at end of file"; - -/** A text's lines, without the empty one that a trailing newline leaves behind a split. */ -function contentLines(contents: string): ReadonlyArray { - if (contents === "") return []; - const lines = contents.split("\n"); - if (lines.at(-1) === "") lines.pop(); - return lines; -} - -/** A NUL byte is git's own test for it, and it survives Azure's JSON envelope intact. */ -function isBinary(contents: string): boolean { - return contents.includes("\u0000"); -} - -/** - * What a file costs on the wire, which is its bytes rather than its code units: a ceiling counted - * in characters lets a file of three-byte glyphs through at three times the intended size. - */ -export const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8"); - -/** - * Git points an empty range at the line before it, which is line zero for a file that is wholly - * new or wholly gone, and writes a single line as its number alone. - */ -function hunkRange(start: number, lines: number): string { - if (lines === 0) return `${start - 1},0`; - return lines === 1 ? String(start) : `${start},${lines}`; -} - -/** - * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file - * mode, so the ordinary one stands in. Names are quoted the way git quotes them, prefix inside the - * quoting: a header reader stops a bare name at its first tab or newline, so a path holding either - * would be truncated and its viewed mark would land on the wrong file. - */ -function patchHeader(change: AzureDevOpsChangeEntry): string { - const oldSide = quoteGitPatchPath(`a/${change.oldPath}`); - const newSide = quoteGitPatchPath(`b/${change.path}`); - const lines = [`diff --git ${oldSide} ${newSide}`]; - if (change.changeKind === "new") lines.push("new file mode 100644"); - if (change.changeKind === "deleted") lines.push("deleted file mode 100644"); - if (change.changeKind === "rename-pure" || change.changeKind === "rename-changed") { - lines.push( - `rename from ${quoteGitPatchPath(change.oldPath)}`, - `rename to ${quoteGitPatchPath(change.path)}`, - ); - } - lines.push( - `--- ${change.changeKind === "new" ? "/dev/null" : oldSide}`, - `+++ ${change.changeKind === "deleted" ? "/dev/null" : newSide}`, - ); - return lines.join("\n"); -} - -/** - * A file written out as wholly replaced: every old line gone, every new line arrived, in one - * hunk, which needs no edit-distance search at all. - */ -function replacementSection(header: string, texts: AzureDevOpsFileTexts): string { - const oldLines = contentLines(texts.oldContents); - const newLines = contentLines(texts.newContents); - const noNewline = (contents: string, lines: ReadonlyArray) => - lines.length > 0 && !contents.endsWith("\n") ? [NO_NEWLINE_MARKER] : []; - return [ - header, - `@@ -${hunkRange(1, oldLines.length)} +${hunkRange(1, newLines.length)} @@`, - ...oldLines.map((line) => `-${line}`), - ...noNewline(texts.oldContents, oldLines), - ...newLines.map((line) => `+${line}`), - ...noNewline(texts.newContents, newLines), - "", - ].join("\n"); -} - -/** - * One file's section of a unified patch, built here because Azure has no route that returns one: - * its diff routes name the files that changed, and the contents are a separate read per side. - */ -export function azureDevOpsFilePatch(input: { - readonly change: AzureDevOpsChangeEntry; - readonly texts: AzureDevOpsFileTexts; -}): AzureDevOpsFilePatch { - const header = patchHeader(input.change); - const { oldContents, newContents } = input.texts; - - if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { - // Git's own wording for a file it will not spell out. - const oldSide = quoteGitPatchPath(`a/${input.change.oldPath}`); - const newSide = quoteGitPatchPath(`b/${input.change.path}`); - const binary = `Binary files ${oldSide} and ${newSide} differ`; - return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false, edits: 0 }; - } - if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) { - return { section: `${header}\n`, truncated: true, abandoned: false, edits: 0 }; - } - - // A creation or deletion has nothing on one side, so writing both sides in full is already the - // minimal patch and needs no diff search. - const created = oldContents === "" && newContents !== ""; - const deleted = newContents === "" && oldContents !== ""; - if (created || deleted) { - const contents = created ? newContents : oldContents; - const lines = contentLines(contents); - // A `+`/`-` marker on every line can put a side that just fits the size ceiling over it. - // Checked against bytes-plus-marker-count rather than building the section, since that upper - // bound is cheaper and this is exactly the file shape that would cost the most to build. - if (byteLength(contents) + lines.length > MAX_FILE_BYTES) { - return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; - } - const section = replacementSection(header, input.texts); - if (byteLength(section) > MAX_FILE_BYTES) { - return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; - } - return { section, truncated: false, abandoned: false, edits: lines.length }; - } - - const patch = structuredPatch( - `a/${input.change.oldPath}`, - `b/${input.change.path}`, - oldContents, - newContents, - undefined, - undefined, - { - context: PATCH_CONTEXT_LINES, - maxEditLength: MAX_FILE_DIFF_EDITS, - timeout: MAX_FILE_DIFF_MILLIS, - }, - ); - // Hitting the edit ceiling lists the file without hunks rather than falling back to a full - // replacement: the ceiling is a distance, not a proportion, so a long file can reach it having - // changed in one small corner, and both sides in full would bury that corner in a wall of text. - if (patch === undefined) { - return { - section: `${header}\n`, - truncated: true, - abandoned: true, - edits: MAX_FILE_DIFF_EDITS, - }; - } - - let edits = 0; - const hunks = patch.hunks.map((hunk) => { - for (const line of hunk.lines) { - if (line.startsWith("+") || line.startsWith("-")) edits += 1; - } - return [ - `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`, - ...hunk.lines, - ].join("\n"); - }); - // A pure rename has no hunks to give but is still listed, since dropping it would take the file - // out of the change altogether. - const section = hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`; - // The edit ceiling bounds edit distance, not the hunks' size: a handful of very long lines plus - // context can still exceed the size ceiling even well inside the edit ceiling. - if (byteLength(section) > MAX_FILE_BYTES) { - return { section: `${header}\n`, truncated: true, abandoned: false, edits }; - } - return { section, truncated: false, abandoned: false, edits }; -} - -/** A file listed without its hunks, for when the host would not hand one of its two sides over. */ -export function azureDevOpsUnreadableFilePatch( - change: AzureDevOpsChangeEntry, -): AzureDevOpsFilePatch { - return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false, edits: 0 }; -} diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts deleted file mode 100644 index 8e863b8751d9d14576e4a319db357b1516ab0f66..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts +++ /dev/null @@ -1,652 +0,0 @@ -import * as Result from "effect/Result"; -import { describe, expect, it } from "vite-plus/test"; - -import { - decodeItemContentJson, - decodeIterationChangesJson, - decodeIterationsJson, - decodePullRequestJson, - decodePullRequestListJson, - decodeThreadsJson, - decodeViewerJson, -} from "./azureDevOpsPullRequestJson.ts"; - -const REST_URL = - "https://dev.azure.com/acme/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/pullRequests/42"; - -/** Shaped after Azure's `GitPullRequest`, trimmed to the fields that are read. */ -function pullRequest(overrides: Record = {}): Record { - return { - pullRequestId: 42, - title: "Add the change requests page", - description: "Ships the page.", - status: "active", - isDraft: false, - mergeStatus: "succeeded", - createdBy: { displayName: "Bilal Hassan", uniqueName: "bilal@acme.dev" }, - sourceRefName: "refs/heads/feat/page", - targetRefName: "refs/heads/main", - creationDate: "2026-07-01T00:00:00Z", - url: REST_URL, - repository: { name: "web", project: { name: "platform" } }, - ...overrides, - }; -} - -function expectSuccess(result: Result.Result): A { - expect(Result.isSuccess(result)).toBe(true); - if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); - return result.success; -} - -const asJson = (value: unknown) => JSON.stringify(value); - -describe("decodePullRequestListJson", () => { - it("reads a pull request as a change request", () => { - const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); - - expect(batch.items).toHaveLength(1); - expect(batch.items[0]).toMatchObject({ - number: 42, - title: "Add the change requests page", - // The login is an email, because that is what `az account show` reports to compare with. - author: { login: "bilal@acme.dev", name: "Bilal Hassan" }, - // Azure prefixes its refs, which no other host does. - headBranch: "feat/page", - baseBranch: "main", - state: "open", - isDraft: false, - mergeability: "mergeable", - }); - }); - - it("assembles a browser url when Azure reports no web link", () => { - const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); - - expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); - }); - - it("prefers the web link Azure sends when asked for one", () => { - const batch = expectSuccess( - decodePullRequestListJson( - asJson([ - pullRequest({ - _links: { - web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, - }, - }), - ]), - ), - ); - - expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); - }); - - it.each([ - ["active", "open"], - ["completed", "merged"], - ["abandoned", "closed"], - ["something new", "open"], - ])("reads the %s status as %s", (status, expected) => { - const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ status })]))); - - expect(batch.items[0]?.state).toBe(expected); - }); - - it.each([ - ["succeeded", "mergeable"], - ["conflicts", "conflicting"], - ["rejectedByPolicy", "conflicting"], - ["queued", "unknown"], - ["notSet", "unknown"], - ])("reads the %s merge status as %s", (mergeStatus, expected) => { - const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ mergeStatus })]))); - - expect(batch.items[0]?.mergeability).toBe(expected); - }); - - it("stands the closing time in for a last-touched time Azure does not keep", () => { - const batch = expectSuccess( - decodePullRequestListJson( - asJson([pullRequest({ status: "completed", closedDate: "2026-07-05T00:00:00Z" })]), - ), - ); - - expect(batch.items[0]).toMatchObject({ - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-05T00:00:00Z", - }); - }); - - it("skips a malformed row but still counts it, so paging does not stop early", () => { - const batch = expectSuccess( - decodePullRequestListJson(asJson([{ pullRequestId: "nope" }, pullRequest()])), - ); - - expect(batch.items).toHaveLength(1); - expect(batch.rawCount).toBe(2); - expect(batch.rawIndexes).toEqual([1]); - }); -}); - -describe("decodePullRequestJson", () => { - it("reads reviewers as review requests", () => { - const detail = expectSuccess( - decodePullRequestJson( - asJson( - pullRequest({ - reviewers: [{ displayName: "Julius", uniqueName: "julius@acme.dev", vote: 10 }], - }), - ), - ), - ); - - expect(detail?.reviewRequestLogins).toEqual(["julius@acme.dev"]); - expect(detail?.reviewers).toEqual([ - { login: "julius@acme.dev", name: "Julius", avatarUrl: null }, - ]); - }); - - it("reads auto-complete from whoever armed it, and its absence as nobody", () => { - const armed = expectSuccess( - decodePullRequestJson( - asJson(pullRequest({ autoCompleteSetBy: { displayName: "Bilal Hassan" } })), - ), - ); - expect(armed?.autoMergeEnabled).toBe(true); - - // Azure leaves the field out entirely rather than sending it empty, so its absence is the - // whole of what it says about auto-complete being off. - expect(expectSuccess(decodePullRequestJson(asJson(pullRequest())))?.autoMergeEnabled).toBe( - false, - ); - }); - - it("keeps the strategy stored with auto-complete", () => { - const armed = expectSuccess( - decodePullRequestJson( - asJson( - pullRequest({ - autoCompleteSetBy: { displayName: "Bilal Hassan" }, - completionOptions: { mergeStrategy: "squash" }, - }), - ), - ), - ); - - expect(armed).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); - - const unspecified = expectSuccess( - decodePullRequestJson( - asJson( - pullRequest({ - autoCompleteSetBy: { displayName: "Bilal Hassan" }, - completionOptions: { squashMerge: false }, - }), - ), - ), - ); - expect(unspecified?.autoMergeEnabled).toBe(true); - expect(unspecified?.autoMergeMethod).toBeUndefined(); - }); - - it("works out where the repository lives from what Azure returned", () => { - const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest()))); - - expect(detail?.location).toEqual({ project: "platform", repository: "web" }); - }); - - it("reports no repository location when Azure said too little to name one", () => { - // A web link places the pull request, but with no repository named there is nothing to - // address the routes that read its files and its conversation. - const detail = expectSuccess( - decodePullRequestJson( - asJson( - pullRequest({ - url: null, - repository: null, - _links: { - web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, - }, - }), - ), - ), - ); - - expect(detail?.location).toBeNull(); - }); - - it("returns nothing when Azure gave no way to place the pull request at all", () => { - const detail = expectSuccess( - decodePullRequestJson(asJson(pullRequest({ url: null, repository: null }))), - ); - - expect(detail).toBeNull(); - }); -}); - -describe("decodeViewerJson", () => { - it("reads the signed-in account name", () => { - expect(expectSuccess(decodeViewerJson(asJson({ user: { name: "bilal@acme.dev" } })))).toBe( - "bilal@acme.dev", - ); - }); - - it("returns nothing when nobody is signed in", () => { - expect(expectSuccess(decodeViewerJson(asJson({ user: null })))).toBeNull(); - }); -}); - -describe("decodeThreadsJson", () => { - it("takes every real comment of every thread, oldest first", () => { - const comments = expectSuccess( - decodeThreadsJson( - asJson({ - value: [ - { - id: 2, - comments: [ - { - id: 1, - content: "Second remark.", - author: { displayName: "Julius", uniqueName: "julius@acme.dev" }, - publishedDate: "2026-07-03T00:00:00Z", - }, - ], - }, - { - id: 1, - comments: [ - // Azure's own activity notes are events rather than remarks. - { id: 1, content: "Bilal voted", commentType: "system", publishedDate: "x" }, - { - id: 2, - content: "First remark.", - author: { displayName: "Bilal", uniqueName: "bilal@acme.dev" }, - publishedDate: "2026-07-02T00:00:00Z", - }, - ], - }, - ], - }), - ), - ); - - expect(comments.map((comment) => comment.body)).toEqual(["First remark.", "Second remark."]); - expect(comments[0]).toMatchObject({ - kind: "issue-comment", - author: { login: "bilal@acme.dev" }, - }); - }); - - it("reads a thread pinned to a file as a review comment", () => { - const comments = expectSuccess( - decodeThreadsJson( - asJson({ - value: [ - { - id: 3, - threadContext: { filePath: "/src/app.ts" }, - comments: [{ id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }], - }, - ], - }), - ), - ); - - expect(comments[0]).toMatchObject({ kind: "review-comment", path: "/src/app.ts" }); - }); - - it("keeps the replies under a thread, which are as much of the conversation", () => { - const comments = expectSuccess( - decodeThreadsJson( - asJson({ - value: [ - { - id: 4, - threadContext: { filePath: "/src/app.ts" }, - comments: [ - { id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }, - { id: 2, content: "Renamed.", publishedDate: "2026-07-02T01:00:00Z" }, - { id: 3, content: "Thanks.", publishedDate: "2026-07-02T02:00:00Z" }, - ], - }, - ], - }), - ), - ); - - expect(comments.map((comment) => comment.id)).toEqual(["4:1", "4:2", "4:3"]); - }); - - it("drops deleted threads and threads with nothing to show", () => { - const comments = expectSuccess( - decodeThreadsJson( - asJson({ - value: [ - { - id: 1, - isDeleted: true, - comments: [{ id: 1, content: "gone", publishedDate: "2026-07-02T00:00:00Z" }], - }, - { id: 2, comments: [] }, - { - id: 3, - comments: [{ id: 1, content: " ", publishedDate: "2026-07-02T00:00:00Z" }], - }, - ], - }), - ), - ); - - expect(comments).toEqual([]); - }); -}); - -describe("decodeIterationsJson", () => { - const iteration = (id: number, head: string, base: string) => ({ - id, - sourceRefCommit: { commitId: head }, - commonRefCommit: { commitId: base }, - targetRefCommit: { commitId: base }, - }); - - it("reads every push in order, oldest first", () => { - const iterations = expectSuccess( - decodeIterationsJson( - asJson({ value: [iteration(2, "bbb", "base"), iteration(1, "aaa", "base")] }), - ), - ); - - expect(iterations.map((entry) => entry.id)).toEqual([1, 2]); - expect(iterations.at(-1)).toEqual({ id: 2, headCommit: "bbb", mergeBaseCommit: "base" }); - }); - - it("skips a push Azure could not place both ends of", () => { - // A patch is taken over a range, and an iteration missing either end names no range at all. - const iterations = expectSuccess( - decodeIterationsJson( - asJson({ - value: [{ id: 1, sourceRefCommit: { commitId: "aaa" } }, iteration(2, "bbb", "base")], - }), - ), - ); - - expect(iterations.map((entry) => entry.id)).toEqual([2]); - }); -}); - -describe("decodeIterationChangesJson", () => { - it("names each changed file without the slash Azure leads its paths with", () => { - const page = expectSuccess( - decodeIterationChangesJson( - asJson({ - changeEntries: [ - { changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }, - { - changeType: "edit", - item: { path: "/README.md", objectId: "8f80", originalObjectId: "0ca4" }, - }, - { changeType: "delete", item: { path: "/OLD.md", originalObjectId: "1111" } }, - ], - }), - ), - ); - - expect(page.changes.map((change) => [change.path, change.changeKind])).toEqual([ - ["DEMO.md", "new"], - ["README.md", "change"], - ["OLD.md", "deleted"], - ]); - }); - - it("keeps a space at the end of a file's name, which belongs to the name", () => { - // Git will carry a name that ends in a space, and the patch and the viewed mark are both - // keyed by it. Tidying it here files the change under a name nothing else uses. - const page = expectSuccess( - decodeIterationChangesJson( - asJson({ - changeEntries: [ - { changeType: "edit", item: { path: "/docs/readme.md ", objectId: "ec00" } }, - { - changeType: "rename", - sourceServerItem: "/docs/old.md ", - item: { path: "/docs/moved.md", objectId: "aaaa", originalObjectId: "aaaa" }, - }, - ], - }), - ), - ); - - expect(page.changes.map((change) => [change.path, change.oldPath])).toEqual([ - ["docs/readme.md ", "docs/readme.md "], - ["docs/moved.md", "docs/old.md "], - ]); - }); - - it("reads a rename as one file that moved, and says whether it also changed", () => { - const page = expectSuccess( - decodeIterationChangesJson( - asJson({ - changeEntries: [ - { - changeType: "rename", - sourceServerItem: "/docs/old.md", - item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, - }, - { - changeType: "edit, rename", - sourceServerItem: "/src/old.ts", - item: { path: "/src/new.ts", objectId: "bbbb", originalObjectId: "cccc" }, - }, - ], - }), - ), - ); - - expect(page.changes).toEqual([ - { - path: "docs/new.md", - oldPath: "docs/old.md", - changeKind: "rename-pure", - objectId: "aaaa", - originalObjectId: "aaaa", - }, - { - path: "src/new.ts", - oldPath: "src/old.ts", - changeKind: "rename-changed", - objectId: "bbbb", - originalObjectId: "cccc", - }, - ]); - }); - - it("drops the folders Azure lists alongside the files that changed", () => { - // A review shows files, and a folder has no content on either side to show for one. - const page = expectSuccess( - decodeIterationChangesJson( - asJson({ - changeEntries: [ - { changeType: "add", item: { path: "/docs", isFolder: true, gitObjectType: "tree" } }, - { changeType: "add", item: { path: "/docs/page.md", objectId: "dddd" } }, - ], - }), - ), - ); - - expect(page.changes.map((change) => change.path)).toEqual(["docs/page.md"]); - }); - - it("carries where the next page of a long change starts", () => { - const page = expectSuccess( - decodeIterationChangesJson( - asJson({ - changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], - nextSkip: 2000, - }), - ), - ); - - expect(page.nextSkip).toBe(2000); - }); - - it("reads the last page, which names no page after it, as the end of the change", () => { - const page = expectSuccess( - decodeIterationChangesJson( - asJson({ - changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], - }), - ), - ); - - expect(page.nextSkip).toBeNull(); - }); - - it("reads where a rename came from out of either of the two places Azure names it", () => { - // The iteration-changes route answers with `originalPath`; the commit routes answer with - // `sourceServerItem`, and both are the same fact under two names. - const page = expectSuccess( - decodeIterationChangesJson( - asJson({ - changeEntries: [ - { - changeType: "rename", - originalPath: "/docs/old.md", - item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, - }, - ], - }), - ), - ); - - expect(page.changes.at(0)?.oldPath).toBe("docs/old.md"); - }); -}); - -describe("decodeItemContentJson", () => { - it("reads the file's text out of the envelope Azure wraps it in", () => { - expect( - expectSuccess(decodeItemContentJson(asJson({ path: "/a.md", content: "one\ntwo" }))), - ).toEqual({ contents: "one\ntwo", isBinary: false }); - }); - - it("reads an empty file as empty rather than as a failure to look", () => { - expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toEqual({ - contents: "", - isBinary: false, - }); - }); - - it("keeps Azure's own word that a file is binary", () => { - // Which it answers base64-encoded, so nothing in the text it sent would give it away. - expect( - expectSuccess( - decodeItemContentJson( - asJson({ path: "/logo.png", content: "b2xk", contentMetadata: { isBinary: true } }), - ), - ), - ).toEqual({ contents: "b2xk", isBinary: true }); - }); -}); - -/** - * Every other fixture in this file is written from Azure's published contract. These are the - * shapes a real organisation answered with, read back through `az devops invoke` rather than - * `az rest`: the extension hands over the route's own body with one key of its own added, and a - * live repository leaves out fields the contract documents. - */ -describe("what az devops invoke answers with", () => { - it("reads the envelope the extension adds its own continuation token to", () => { - // Set on every JSON body it returns, from a response header these routes do not send, so it - // arrives as null rather than not at all. Nothing reads it, and it must not fail the decode. - expect( - expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))), - ).toEqual([]); - - expect( - expectSuccess( - decodeIterationsJson( - asJson({ - count: 1, - continuation_token: null, - value: [ - { - id: 1, - sourceRefCommit: { commitId: "f4031105213c68f197cd46ea303bb5e72acc889a" }, - commonRefCommit: { commitId: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35" }, - }, - ], - }), - ), - ), - ).toEqual([ - { - id: 1, - headCommit: "f4031105213c68f197cd46ea303bb5e72acc889a", - mergeBaseCommit: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35", - }, - ]); - }); - - it("keeps the files of a change that names neither their object type nor their page after it", () => { - // A live iteration states `changeType`, `item.path` and `item.objectId` and nothing else: no - // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. Each of those absences - // is what the defaults in the decoder are for, and reading any of them as stated would drop - // every file of every Azure change request. - const page = expectSuccess( - decodeIterationChangesJson( - asJson({ - continuation_token: null, - changeEntries: [ - { changeType: "add", item: { path: "/DEMO.md", objectId: "EC005DB24" } }, - { changeType: "edit", item: { path: "/README.md", objectId: "8F8047A49" } }, - ], - }), - ), - ); - - expect(page.nextSkip).toBeNull(); - expect(page.changes).toEqual([ - { - path: "DEMO.md", - oldPath: "DEMO.md", - changeKind: "new", - objectId: "EC005DB24", - originalObjectId: null, - }, - { - path: "README.md", - oldPath: "README.md", - changeKind: "change", - objectId: "8F8047A49", - originalObjectId: null, - }, - ]); - }); - - it("reads a text file whose content type Azure calls a stream", () => { - // `contentMetadata` comes back for a markdown file with `application/octet-stream` on it and - // no `isBinary` at all, so the content type is not the field to ask, and its absence is the - // answer that the text is text. - expect( - expectSuccess( - decodeItemContentJson( - asJson({ - path: "/README.md", - objectId: "8f8047a49", - gitObjectType: "blob", - content: "# T3Demo\n", - contentMetadata: { - contentType: "application/octet-stream", - encoding: 65001, - extension: "md", - fileName: "README.md", - }, - continuation_token: null, - }), - ), - ), - ).toEqual({ contents: "# T3Demo\n", isBinary: false }); - }); -}); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts deleted file mode 100644 index 2e949e103959f292eb369bc085293a3eb698263c..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts +++ /dev/null @@ -1,574 +0,0 @@ -import * as Cause from "effect/Cause"; -import * as Exit from "effect/Exit"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import type { - PullRequestActor, - PullRequestComment, - PullRequestMergeMethod, - PullRequestMergeability, - PullRequestState, -} from "@t3tools/contracts"; -import { TrimmedNonEmptyString } from "@t3tools/contracts"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; - -import { azureDevOpsPullRequestWebUrl } from "../sourceControl/azureDevOpsPullRequests.ts"; - -/** - * Azure's enums are decoded as plain strings and normalized here, in the same tolerant style as - * the other hosts: a new merge status must not fail a whole payload. Every field beyond the - * identity is optional, because `az repos pr` returns rather more or less of the REST object - * depending on the command. - */ -const RawIdentitySchema = Schema.Struct({ - displayName: Schema.optional(Schema.NullOr(Schema.String)), - /** An email or UPN, which is what `az account show` reports for the signed-in user. */ - uniqueName: Schema.optional(Schema.NullOr(Schema.String)), - imageUrl: Schema.optional(Schema.NullOr(Schema.String)), -}); - -const RawPullRequestSchema = Schema.Struct({ - pullRequestId: Schema.Int, - title: Schema.String, - description: Schema.optional(Schema.NullOr(Schema.String)), - status: Schema.optional(Schema.NullOr(Schema.String)), - isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), - /** - * Who armed auto-complete, which is the only thing Azure says about it: the field carries an - * identity while the pull request is set to complete on its own, and Azure leaves it out - * entirely once nobody has. So its presence is the answer, and there is no third state. - */ - autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), - completionOptions: Schema.optional( - Schema.NullOr( - Schema.Struct({ - mergeStrategy: Schema.optional(Schema.NullOr(Schema.String)), - squashMerge: Schema.optional(Schema.NullOr(Schema.Boolean)), - }), - ), - ), - mergeStatus: Schema.optional(Schema.NullOr(Schema.String)), - createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), - reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))), - // Required, and required to be non-empty: the wire contract will not carry a change request - // without a branch or a created time, so a row missing one is skipped rather than breaking the - // response it travels in. - sourceRefName: TrimmedNonEmptyString, - targetRefName: TrimmedNonEmptyString, - creationDate: TrimmedNonEmptyString, - closedDate: Schema.optional(Schema.NullOr(Schema.String)), - url: Schema.optional(Schema.NullOr(Schema.String)), - repository: Schema.optional( - Schema.NullOr( - Schema.Struct({ - name: Schema.optional(Schema.NullOr(Schema.String)), - webUrl: Schema.optional(Schema.NullOr(Schema.String)), - project: Schema.optional( - Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), - ), - }), - ), - ), - _links: Schema.optional( - Schema.NullOr( - Schema.Struct({ - web: Schema.optional( - Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), - ), - }), - ), - ), -}); - -/** A pull request thread, which is how Azure keeps its conversation. */ -const RawThreadSchema = Schema.Struct({ - id: Schema.Int, - isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), - threadContext: Schema.optional( - Schema.NullOr(Schema.Struct({ filePath: Schema.optional(Schema.NullOr(Schema.String)) })), - ), - comments: Schema.optional( - Schema.NullOr( - Schema.Array( - Schema.Struct({ - id: Schema.optional(Schema.NullOr(Schema.Int)), - content: Schema.optional(Schema.NullOr(Schema.String)), - author: Schema.optional(Schema.NullOr(RawIdentitySchema)), - publishedDate: Schema.optional(Schema.NullOr(Schema.String)), - isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), - /** `system` marks the notes Azure writes itself, which are events, not comments. */ - commentType: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), - ), -}); - -const RawThreadPageSchema = Schema.Struct({ - value: Schema.Array(Schema.Unknown), -}); - -const RawViewerSchema = Schema.Struct({ - user: Schema.optional( - Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), - ), -}); - -/** - * Where a repository lives, in the terms Azure's REST routes address it by. They take the project - * and the repository as separate route parameters rather than as one path, so the pair travels - * together rather than as a URL that would have to be taken apart again to use. - */ -export interface AzureDevOpsRepositoryLocation { - readonly project: string; - readonly repository: string; -} - -export interface AzureDevOpsPullRequest { - readonly number: number; - readonly title: string; - readonly url: string; - readonly author: PullRequestActor | null; - readonly headBranch: string; - readonly baseBranch: string; - readonly state: PullRequestState; - readonly isDraft: boolean; - readonly mergeability: PullRequestMergeability; - readonly createdAt: string; - /** - * Azure records no last-touched time on a pull request, so the closing time stands in where - * there is one and the creation time otherwise. The same fallback the rest of the app uses. - */ - readonly updatedAt: string; - readonly closedAt: string | null; - readonly body: string; - readonly reviewRequestLogins: ReadonlyArray; - readonly reviewers: ReadonlyArray; - /** Where this pull request lives, when Azure said enough to work it out. */ - readonly location: AzureDevOpsRepositoryLocation | null; - /** Whether Azure is set to complete this on its own once its policies pass. */ - readonly autoMergeEnabled: boolean; - /** The completion strategy Azure stored with auto-complete, where it reported one. */ - readonly autoMergeMethod?: PullRequestMergeMethod; -} - -function trimmed(value: string | null | undefined): string | null { - const text = value?.trim() ?? ""; - return text.length > 0 ? text : null; -} - -function normalizeRefName(refName: string): string { - return refName.trim().replace(/^refs\/heads\//, ""); -} - -/** A login has to compare against `az account show`, which reports an email. */ -function toActor(raw: Schema.Schema.Type | null | undefined) { - const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName); - return login === null - ? null - : { login, name: trimmed(raw?.displayName), avatarUrl: trimmed(raw?.imageUrl) }; -} - -function toState(raw: Schema.Schema.Type): PullRequestState { - switch (raw.status?.trim().toLowerCase()) { - case "completed": - return "merged"; - case "abandoned": - return "closed"; - default: - return "open"; - } -} - -function toMergeability(value: string | null | undefined): PullRequestMergeability { - switch (value?.trim().toLowerCase()) { - case "succeeded": - return "mergeable"; - case "conflicts": - case "failure": - case "rejectedbypolicy": - return "conflicting"; - default: - // `queued` and `notSet` mean Azure has not finished checking. - return "unknown"; - } -} - -/** - * Where a pull request's own repository sits. Taken from what Azure returned rather than from the - * local remote, whose shape differs between the modern, legacy and SSH forms. - */ -function toLocation( - raw: Schema.Schema.Type, -): AzureDevOpsRepositoryLocation | null { - const project = trimmed(raw.repository?.project?.name); - const repository = trimmed(raw.repository?.name); - if (project === null || repository === null) return null; - return { project, repository }; -} - -function toAutoMergeMethod( - raw: Schema.Schema.Type, -): PullRequestMergeMethod | undefined { - if (raw.autoCompleteSetBy == null) return undefined; - switch (raw.completionOptions?.mergeStrategy?.trim().toLowerCase()) { - case "squash": - return "squash"; - case "rebase": - case "rebasemerge": - return "rebase"; - case "nofastforward": - return "merge"; - default: - return raw.completionOptions?.squashMerge === true ? "squash" : undefined; - } -} - -/** - * Null when Azure said too little to place the pull request: a row with no browser url and no - * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract - * refuses to carry it either. - */ -function toPullRequest( - raw: Schema.Schema.Type, -): AzureDevOpsPullRequest | null { - const autoMergeMethod = toAutoMergeMethod(raw); - const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { - const actor = toActor(reviewer); - return actor === null ? [] : [actor]; - }); - const closedAt = trimmed(raw.closedDate); - const url = trimmed( - azureDevOpsPullRequestWebUrl({ - pullRequestId: raw.pullRequestId, - webLink: raw._links?.web?.href, - repositoryWebUrl: raw.repository?.webUrl, - restApiUrl: raw.url, - projectName: raw.repository?.project?.name, - repositoryName: raw.repository?.name, - }), - ); - const headBranch = trimmed(normalizeRefName(raw.sourceRefName)); - const baseBranch = trimmed(normalizeRefName(raw.targetRefName)); - if (url === null || headBranch === null || baseBranch === null) return null; - return { - number: raw.pullRequestId, - title: raw.title, - url, - author: toActor(raw.createdBy), - headBranch, - baseBranch, - state: toState(raw), - isDraft: raw.isDraft ?? false, - mergeability: toMergeability(raw.mergeStatus), - createdAt: raw.creationDate, - updatedAt: closedAt ?? raw.creationDate, - closedAt, - body: raw.description ?? "", - reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), - reviewers, - location: toLocation(raw), - autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, - ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), - }; -} - -const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); -const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); -const decodePullRequest = decodeJsonResult(RawPullRequestSchema); -const decodeThreadPage = decodeJsonResult(RawThreadPageSchema); -const decodeThreadEntry = Schema.decodeUnknownExit(RawThreadSchema); -const decodeViewer = decodeJsonResult(RawViewerSchema); - -type DecodeFailure = Cause.Cause; - -export interface AzureDevOpsPullRequestBatch { - readonly items: ReadonlyArray; - /** Zero-based positions of the decoded items in Azure's raw page. */ - readonly rawIndexes: ReadonlyArray; - /** Rows Azure returned, counted before decoding, so a skipped row cannot hide a next page. */ - readonly rawCount: number; -} - -/** Malformed entries are skipped rather than failing the batch, as on the other hosts. */ -export function decodePullRequestListJson( - raw: string, -): Result.Result { - const decoded = decodeUnknownList(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const items: AzureDevOpsPullRequest[] = []; - const rawIndexes: number[] = []; - for (const [rawIndex, entry] of decoded.success.entries()) { - const item = decodePullRequestEntry(entry); - if (Exit.isFailure(item)) continue; - const pullRequest = toPullRequest(item.value); - if (pullRequest !== null) { - items.push(pullRequest); - rawIndexes.push(rawIndex); - } - } - return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); -} - -/** Null carries "Azure answered, but with too little to use", which the caller reports. */ -export function decodePullRequestJson( - raw: string, -): Result.Result { - const decoded = decodePullRequest(raw); - return Result.isSuccess(decoded) - ? Result.succeed(toPullRequest(decoded.success)) - : Result.fail(decoded.failure); -} - -/** `az account show --query user` reports the signed-in account, whose name is an email. */ -export function decodeViewerJson(raw: string): Result.Result { - const decoded = decodeViewer(raw); - return Result.isSuccess(decoded) - ? Result.succeed(trimmed(decoded.success.user?.name)) - : Result.fail(decoded.failure); -} - -/** - * Azure keeps its conversation as threads of comments, and every one of them is a remark - * somebody wrote: a reply under a thread is as much of the conversation as the line that opened - * it. A thread pinned to a file is a line-level review comment. - * - * Azure answers the whole thread collection in one response, with no cursor and no page to - * follow, so what this returns is everything the host has. - */ -export function decodeThreadsJson( - raw: string, -): Result.Result, DecodeFailure> { - const decoded = decodeThreadPage(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const comments: PullRequestComment[] = []; - for (const entry of decoded.success.value) { - const decodedThread = decodeThreadEntry(entry); - if (Exit.isFailure(decodedThread)) continue; - const thread = decodedThread.value; - if (thread.isDeleted === true) continue; - const path = trimmed(thread.threadContext?.filePath); - for (const comment of thread.comments ?? []) { - const publishedDate = trimmed(comment.publishedDate); - if ( - comment.isDeleted === true || - comment.commentType?.trim().toLowerCase() === "system" || - (comment.content ?? "").trim().length === 0 || - publishedDate === null - ) { - continue; - } - comments.push({ - id: `${thread.id}:${comment.id ?? 0}`, - kind: path === null ? "issue-comment" : "review-comment", - author: toActor(comment.author), - body: comment.content ?? "", - createdAt: publishedDate, - url: null, - path, - reviewState: null, - }); - } - } - return Result.succeed( - comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), - ); -} - -/** - * One push's worth of a pull request. Azure records every push as an iteration and keys the whole - * review off them: the changed files, and the marks a reader leaves on those files, both hang - * from an iteration rather than from the pull request. - */ -const RawIterationSchema = Schema.Struct({ - id: Schema.Int, - sourceRefCommit: Schema.optional( - Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), - ), - commonRefCommit: Schema.optional( - Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), - ), -}); - -const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) }); - -const RawChangeEntrySchema = Schema.Struct({ - changeType: Schema.optional(Schema.NullOr(Schema.String)), - sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)), - /** Where a renamed file came from. Azure states it here on an iteration's changes. */ - originalPath: Schema.optional(Schema.NullOr(Schema.String)), - item: Schema.optional( - Schema.NullOr( - Schema.Struct({ - path: Schema.optional(Schema.NullOr(Schema.String)), - objectId: Schema.optional(Schema.NullOr(Schema.String)), - originalObjectId: Schema.optional(Schema.NullOr(Schema.String)), - /** Azure marks a directory this way; a review has nothing to show for one. */ - isFolder: Schema.optional(Schema.NullOr(Schema.Boolean)), - gitObjectType: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), -}); - -const RawChangePageSchema = Schema.Struct({ - changeEntries: Schema.Array(Schema.Unknown), - /** Where the page after this one starts. Azure leaves it out on the last page. */ - nextSkip: Schema.optional(Schema.NullOr(Schema.Number)), -}); - -const RawItemContentSchema = Schema.Struct({ - content: Schema.optional(Schema.NullOr(Schema.String)), - /** What Azure makes of the file it is handing over, which is where it says it is not text. */ - contentMetadata: Schema.optional( - Schema.NullOr(Schema.Struct({ isBinary: Schema.optional(Schema.NullOr(Schema.Boolean)) })), - ), -}); - -/** The head and the merge base of one iteration, which is the range its patch is taken over. */ -export interface AzureDevOpsIteration { - readonly id: number; - readonly headCommit: string; - readonly mergeBaseCommit: string; -} - -/** - * What one file did across an iteration. `oldPath` differs from `path` only for a rename, which - * Azure reports by naming the file's previous home rather than as a delete and an add. - */ -export interface AzureDevOpsChangeEntry { - readonly path: string; - readonly oldPath: string; - readonly changeKind: "new" | "deleted" | "change" | "rename-pure" | "rename-changed"; - readonly objectId: string | null; - readonly originalObjectId: string | null; -} - -/** - * One page of what an iteration changed, and where the next one starts. Azure pages this route - * rather than answering with the whole change, so a review large enough to be paged is followed - * to its end instead of being cut off at the first page's worth. - */ -export interface AzureDevOpsChangePage { - readonly changes: ReadonlyArray; - readonly nextSkip: number | null; -} - -/** One file's text at one commit, and whether Azure says the text is text at all. */ -export interface AzureDevOpsItemContent { - readonly contents: string; - readonly isBinary: boolean; -} - -const decodeIterationPage = decodeJsonResult(RawIterationPageSchema); -const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema); -const decodeChangePage = decodeJsonResult(RawChangePageSchema); -const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema); -const decodeItemContent = decodeJsonResult(RawItemContentSchema); - -/** - * Azure leads a path with a slash that every other host and every patch omits. Not trimmed like - * the rest of this payload, since a leading/trailing space is a legal part of a file's name and - * the patch and viewed mark are keyed by the untrimmed path. - */ -function toRepositoryPath(value: string | null | undefined): string | null { - if (value === undefined || value === null) return null; - const path = value.replace(/^\/+/, ""); - return path.length === 0 ? null : path; -} - -/** - * Azure names a change with one word or two, and a rename arrives either alone or alongside the - * edit that came with it. Anything it has added since reads as a plain change, which shows the - * file rather than dropping it from the review. - */ -function toChangeKind( - raw: string | null | undefined, - renamed: boolean, -): AzureDevOpsChangeEntry["changeKind"] { - const parts = new Set( - (raw ?? "") - .toLowerCase() - .split(",") - .map((part) => part.trim()) - .filter((part) => part.length > 0), - ); - if (parts.has("delete")) return "deleted"; - if (renamed || parts.has("rename")) return parts.has("edit") ? "rename-changed" : "rename-pure"; - if (parts.has("add")) return "new"; - return "change"; -} - -export function decodeIterationsJson( - raw: string, -): Result.Result, DecodeFailure> { - const decoded = decodeIterationPage(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const iterations: AzureDevOpsIteration[] = []; - for (const entry of decoded.success.value) { - const decodedIteration = decodeIterationEntry(entry); - if (Exit.isFailure(decodedIteration)) continue; - const iteration = decodedIteration.value; - const headCommit = trimmed(iteration.sourceRefCommit?.commitId); - const mergeBaseCommit = trimmed(iteration.commonRefCommit?.commitId); - // An iteration Azure cannot place both ends of names no range, and a patch needs both. - if (headCommit === null || mergeBaseCommit === null) continue; - iterations.push({ id: iteration.id, headCommit, mergeBaseCommit }); - } - return Result.succeed(iterations.toSorted((left, right) => left.id - right.id)); -} - -export function decodeIterationChangesJson( - raw: string, -): Result.Result { - const decoded = decodeChangePage(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const changes: AzureDevOpsChangeEntry[] = []; - for (const entry of decoded.success.changeEntries) { - const decodedChange = decodeChangeEntry(entry); - if (Exit.isFailure(decodedChange)) continue; - const change = decodedChange.value; - const path = toRepositoryPath(change.item?.path); - if (path === null) continue; - // Azure lists the folders a change touched alongside the files themselves. A review shows - // files, and a folder has no content to show for either side of one. - if (change.item?.isFolder === true) continue; - if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue; - // Azure names where a renamed file came from in either of two places depending on the route - // and the version, so both are read and the current path stands in when neither is there. - const oldPath = - toRepositoryPath(change.sourceServerItem) ?? toRepositoryPath(change.originalPath) ?? path; - changes.push({ - path, - oldPath, - changeKind: toChangeKind(change.changeType, oldPath !== path), - objectId: trimmed(change.item?.objectId), - originalObjectId: trimmed(change.item?.originalObjectId), - }); - } - const nextSkip = decoded.success.nextSkip ?? null; - return Result.succeed({ - changes, - nextSkip: nextSkip !== null && Number.isSafeInteger(nextSkip) && nextSkip > 0 ? nextSkip : null, - }); -} - -/** - * Azure answers an absent file with an empty body rather than an error, which reads as empty. - * Whether the bytes are text is Azure's own call, not this decoder's to guess from content. - */ -export function decodeItemContentJson( - raw: string, -): Result.Result { - const decoded = decodeItemContent(raw); - return Result.isSuccess(decoded) - ? Result.succeed({ - contents: decoded.success.content ?? "", - isBinary: decoded.success.contentMetadata?.isBinary === true, - }) - : Result.fail(decoded.failure); -} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b1ac0b12dbd33c198d8ebdd0bbae538673244e37..82c5de077ef75e1411f17d5ba5927db26ea6c02f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -52,7 +52,6 @@ import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; -import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; @@ -301,7 +300,6 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(Layer.provide(VcsPro const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - AzureDevOpsCli.layer, BitbucketApi.layer, GitHubApi.layerWithDependencies, GitLabCli.layer, diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts deleted file mode 100644 index 24939782460967afa6da1eaff76c836a88bd5eea..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ /dev/null @@ -1,448 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; -import { ChildProcessSpawner } from "effect/process"; -import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; - -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; - -const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, -}); - -const mockRun = vi.fn(); - -const layerSupport = Layer.mergeAll( - Layer.mock(VcsProcess.VcsProcess)({ - run: mockRun, - }), - NodeServices.layer, -); -const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(layerSupport)), layerSupport); - -afterEach(() => { - mockRun.mockReset(); -}); - -describe("AzureDevOpsCli.layer", () => { - it.effect("parses pull request view output", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - JSON.stringify({ - pullRequestId: 42, - title: "Add Azure provider", - sourceRefName: "refs/heads/feature/source-control", - targetRefName: "refs/heads/main", - status: "active", - creationDate: "2026-01-02T00:00:00.000Z", - closedDate: null, - _links: { - web: { - href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", - }, - }, - }), - ), - ), - ); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - const result = yield* az.getPullRequest({ - cwd: "/repo", - reference: "#42", - }); - - assert.strictEqual(result.number, 42); - assert.strictEqual(result.title, "Add Azure provider"); - assert.strictEqual(result.url, "https://dev.azure.com/acme/project/_git/repo/pullrequest/42"); - assert.strictEqual(result.baseRefName, "main"); - assert.strictEqual(result.headRefName, "feature/source-control"); - assert.strictEqual(result.state, "open"); - assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag); - assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], { - operation: "AzureDevOpsCli.execute", - command: "az", - args: [ - "repos", - "pr", - "show", - "--detect", - "true", - "--id", - "42", - "--only-show-errors", - "--output", - "json", - ], - cwd: "/repo", - timeoutMs: 30_000, - }); - }).pipe(Effect.provide(layer)), - ); - - it.effect("builds a web URL when Azure returns only the pull request REST URL", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - JSON.stringify({ - pullRequestId: 863, - title: "Fix Azure link", - url: "https://dev.azure.com/saplyai/a8fe4088-ad76-4eda-aaaa-e3329713a097/_apis/git/repositories/16108a25-93a3-4eff-b77d-85e894ee0fe4/pullRequests/863", - repository: { - name: "CV-engine", - project: { - name: "CV-engine", - }, - }, - sourceRefName: "refs/heads/feature/azure-pr-link", - targetRefName: "refs/heads/main", - status: "active", - }), - ), - ), - ); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - const result = yield* az.getPullRequest({ - cwd: "/repo", - reference: "863", - }); - - assert.strictEqual( - result.url, - "https://dev.azure.com/saplyai/CV-engine/_git/CV-engine/pullrequest/863", - ); - }).pipe(Effect.provide(layer)), - ); - - it.effect("lists pull requests with Azure status and source branch arguments", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - JSON.stringify([ - { - pullRequestId: 7, - title: "Merged work", - sourceRefName: "refs/heads/feature/merged", - targetRefName: "refs/heads/main", - status: "completed", - closedDate: "2026-01-03T00:00:00.000Z", - _links: { - web: { - href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7", - }, - }, - }, - ]), - ), - ), - ); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - const result = yield* az.listPullRequests({ - cwd: "/repo", - headSelector: "origin:feature/merged", - state: "merged", - limit: 10, - }); - - assert.strictEqual(result[0]?.state, "merged"); - assert.strictEqual(result[0]?.mergedAt, "2026-01-03T00:00:00.000Z"); - assert.strictEqual(result[0]?.closedAt, null); - expect(mockRun).toHaveBeenCalledWith({ - operation: "AzureDevOpsCli.execute", - command: "az", - args: [ - "repos", - "pr", - "list", - "--detect", - "true", - "--source-branch", - "feature/merged", - "--status", - "completed", - "--top", - "10", - "--only-show-errors", - "--output", - "json", - ], - cwd: "/repo", - timeoutMs: 30_000, - }); - }).pipe(Effect.provide(layer)), - ); - - it.effect("reads repository clone URLs", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - JSON.stringify({ - name: "repo", - webUrl: "https://dev.azure.com/acme/project/_git/repo", - remoteUrl: "https://dev.azure.com/acme/project/_git/repo", - sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", - project: { - name: "project", - }, - }), - ), - ), - ); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - const result = yield* az.getRepositoryCloneUrls({ - cwd: "/repo", - repository: "repo", - }); - - assert.deepStrictEqual(result, { - nameWithOwner: "project/repo", - url: "https://dev.azure.com/acme/project/_git/repo", - sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", - }); - }).pipe(Effect.provide(layer)), - ); - - it.effect("creates repositories through Azure Repos", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - JSON.stringify({ - name: "repo", - webUrl: "https://dev.azure.com/acme/project/_git/repo", - remoteUrl: "https://dev.azure.com/acme/project/_git/repo", - sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", - project: { - name: "project", - }, - }), - ), - ), - ); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - const result = yield* az.createRepository({ - cwd: "/repo", - repository: "project/repo", - visibility: "private", - }); - - assert.deepStrictEqual(result, { - nameWithOwner: "project/repo", - url: "https://dev.azure.com/acme/project/_git/repo", - sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", - }); - expect(mockRun).toHaveBeenCalledWith({ - operation: "AzureDevOpsCli.execute", - command: "az", - args: [ - "repos", - "create", - "--detect", - "true", - "--name", - "repo", - "--project", - "project", - "--only-show-errors", - "--output", - "json", - ], - cwd: "/repo", - timeoutMs: 30_000, - }); - }).pipe(Effect.provide(layer)), - ); - - it.effect("creates pull requests using the body file as the Azure description", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const bodyFile = `/tmp/t3code-azure-devops-cli-.md`; - yield* fileSystem.writeFileString(bodyFile, "Generated body"); - mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - yield* az.createPullRequest({ - cwd: "/repo", - baseBranch: "main", - headSelector: "feature/provider", - title: "Provider PR", - bodyFile, - }); - - expect(mockRun).toHaveBeenCalledWith( - expect.objectContaining({ - command: "az", - cwd: "/repo", - args: expect.arrayContaining(["--description", `@${bodyFile}`]), - }), - ); - expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output"); - }).pipe(Effect.provide(layer)), - ); - - it.effect("does not force JSON output on checkout side-effect commands", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - yield* az.checkoutPullRequest({ - cwd: "/repo", - reference: "42", - }); - - expect(mockRun).toHaveBeenCalledWith({ - operation: "AzureDevOpsCli.execute", - command: "az", - args: [ - "repos", - "pr", - "checkout", - "--only-show-errors", - "--detect", - "true", - "--id", - "42", - "--remote-name", - "origin", - ], - cwd: "/repo", - timeoutMs: 30_000, - }); - }).pipe(Effect.provide(layer)), - ); - - it.effect("forwards explicit output limits to the process boundary", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - yield* az.execute({ - cwd: "/repo", - args: ["repos", "pr", "list"], - maxOutputBytes: 16 * 1024 * 1024, - }); - - expect(mockRun).toHaveBeenCalledWith({ - operation: "AzureDevOpsCli.execute", - command: "az", - args: ["repos", "pr", "list"], - cwd: "/repo", - timeoutMs: 30_000, - maxOutputBytes: 16 * 1024 * 1024, - }); - }).pipe(Effect.provide(layer)), - ); - - it.effect("preserves VCS causes without copying upstream details into messages", () => - Effect.gen(function* () { - const cause = new VcsProcessExitError({ - operation: "AzureDevOpsCli.execute", - command: "az repos list --organization sensitive-upstream-detail", - cwd: "/repo", - exitCode: 1, - detail: "sensitive-upstream-detail", - }); - mockRun.mockReturnValueOnce(Effect.fail(cause)); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); - - assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); - assert.strictEqual(error.operation, "execute"); - assert.strictEqual(error.command, "az"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.argumentCount, 2); - assert.strictEqual(error.detail, "Azure DevOps CLI command failed."); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes("sensitive-upstream-detail"), false); - }).pipe(Effect.provide(layer)), - ); - - it.effect("does not report a missing working directory as a missing Azure CLI", () => - Effect.gen(function* () { - const cwd = "/missing/repo"; - const platformCause = PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - syscall: "chdir", - pathOrDescriptor: cwd, - }); - const cause = new VcsProcessSpawnError({ - operation: "AzureDevOpsCli.execute", - command: "az", - cwd, - argumentCount: 2, - cause: platformCause, - }); - mockRun.mockReturnValueOnce(Effect.fail(cause)); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip); - - assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); - assert.strictEqual(error.cwd, cwd); - assert.strictEqual(error.cause, cause); - }).pipe(Effect.provide(layer)), - ); - - it.effect("keeps invalid pull request output diagnostics structured", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json"))); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip); - - assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError); - assert.strictEqual(error.operation, "getPullRequest"); - assert.strictEqual(error.command, "az"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.outputLength, 8); - assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON."); - assert.exists(error.cause); - assert.strictEqual( - error.message, - "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.", - ); - }).pipe(Effect.provide(layer)), - ); - - it.effect("preserves rate-limit failures as a distinct error", () => - Effect.gen(function* () { - const cause = new VcsProcessExitError({ - operation: "AzureDevOpsCli.execute", - command: "az", - cwd: "/repo", - argumentCount: 2, - exitCode: 1, - detail: "API rate limit exceeded.", - failureKind: "rate-limited", - }); - mockRun.mockReturnValueOnce(Effect.fail(cause)); - - const az = yield* AzureDevOpsCli.AzureDevOpsCli; - const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); - - assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCliRateLimitError); - assert.strictEqual(error.cause, cause); - }).pipe(Effect.provide(layer)), - ); -}); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts deleted file mode 100644 index 7fd44de3c40b9a901329ea61bd98d1f61e5a7882..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ /dev/null @@ -1,554 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import { - NonNegativeInt, - TrimmedNonEmptyString, - type SourceControlRepositoryVisibility, - type VcsError, -} from "@t3tools/contracts"; - -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { - decodeAzureDevOpsPullRequestJson, - decodeAzureDevOpsPullRequestListJson, - type NormalizedAzureDevOpsPullRequestRecord, -} from "./azureDevOpsPullRequests.ts"; -import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; - -const DEFAULT_TIMEOUT_MS = 30_000; - -const azureDevOpsCommandErrorFields = { - operation: Schema.Literal("execute"), - command: Schema.Literal("az"), - cwd: Schema.String, - argumentCount: NonNegativeInt, - cause: Schema.Defect(), -}; - -export class AzureDevOpsCliUnavailableError extends Schema.TaggedError()( - "AzureDevOpsCliUnavailableError", - azureDevOpsCommandErrorFields, -) { - get detail(): string { - return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH."; - } - - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class AzureDevOpsCliAuthenticationError extends Schema.TaggedError()( - "AzureDevOpsCliAuthenticationError", - azureDevOpsCommandErrorFields, -) { - get detail(): string { - return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry."; - } - - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class AzureDevOpsCliRateLimitError extends Schema.TaggedError()( - "AzureDevOpsCliRateLimitError", - azureDevOpsCommandErrorFields, -) { - get detail(): string { - return "Azure DevOps API rate limit exceeded."; - } - - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedError()( - "AzureDevOpsPullRequestNotFoundError", - azureDevOpsCommandErrorFields, -) { - get detail(): string { - return "Pull request not found. Check the PR number or URL and try again."; - } - - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class AzureDevOpsCommandFailedError extends Schema.TaggedError()( - "AzureDevOpsCommandFailedError", - azureDevOpsCommandErrorFields, -) { - get detail(): string { - return "Azure DevOps CLI command failed."; - } - - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; - } - - static fromVcsError( - context: { - readonly operation: "execute"; - readonly command: "az"; - readonly cwd: string; - readonly argumentCount: number; - }, - cause: VcsError, - ): AzureDevOpsCliError { - const fields = { ...context, cause }; - - if ( - cause._tag === "VcsProcessSpawnError" && - cause.cause instanceof PlatformError.PlatformError && - cause.cause.reason._tag === "NotFound" && - cause.cause.reason.pathOrDescriptor !== context.cwd && - cause.cause.reason.syscall !== "chdir" - ) { - return new AzureDevOpsCliUnavailableError(fields); - } - - if (cause._tag === "VcsProcessExitError") { - if (cause.failureKind === "authentication") { - return new AzureDevOpsCliAuthenticationError(fields); - } - if (cause.failureKind === "rate-limited") { - return new AzureDevOpsCliRateLimitError(fields); - } - if (cause.failureKind === "not-found") { - return new AzureDevOpsPullRequestNotFoundError(fields); - } - } - - return new AzureDevOpsCommandFailedError(fields); - } -} - -const azureDevOpsDecodeErrorFields = { - command: Schema.Literal("az"), - cwd: Schema.String, - outputLength: NonNegativeInt, - cause: Schema.Defect(), -}; - -export class AzureDevOpsPullRequestListDecodeError extends Schema.TaggedError()( - "AzureDevOpsPullRequestListDecodeError", - { - operation: Schema.Literal("listPullRequests"), - ...azureDevOpsDecodeErrorFields, - }, -) { - get detail(): string { - return "Azure DevOps CLI returned invalid PR list JSON."; - } - - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedError()( - "AzureDevOpsPullRequestDecodeError", - { - operation: Schema.Literal("getPullRequest"), - ...azureDevOpsDecodeErrorFields, - }, -) { - get detail(): string { - return "Azure DevOps CLI returned invalid pull request JSON."; - } - - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; - } -} - -const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([ - "getRepositoryCloneUrls", - "getDefaultBranch", - "createRepository", -]); - -export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedError()( - "AzureDevOpsRepositoryDecodeError", - { - operation: AzureDevOpsRepositoryDecodeOperation, - ...azureDevOpsDecodeErrorFields, - }, -) { - get detail(): string { - return "Azure DevOps CLI returned invalid repository JSON."; - } - - override get message(): string { - return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export const AzureDevOpsCliError = Schema.Union([ - AzureDevOpsCliUnavailableError, - AzureDevOpsCliAuthenticationError, - AzureDevOpsCliRateLimitError, - AzureDevOpsPullRequestNotFoundError, - AzureDevOpsCommandFailedError, - AzureDevOpsPullRequestListDecodeError, - AzureDevOpsPullRequestDecodeError, - AzureDevOpsRepositoryDecodeError, -]); -export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type; - -export interface AzureDevOpsRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; -} - -export class AzureDevOpsCli extends Context.Service< - AzureDevOpsCli, - { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - readonly maxOutputBytes?: number; - }) => Effect.Effect; - - readonly listPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect, AzureDevOpsCliError>; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly remoteName?: string; - }) => Effect.Effect; - } ->()("t3/sourceControl/AzureDevOpsCli") {} - -function normalizeChangeRequestId(reference: string): string { - const trimmed = reference.trim().replace(/^#/, ""); - const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); - return urlMatch?.[1] ?? trimmed; -} - -function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string { - switch (state) { - case "open": - return "active"; - case "closed": - return "abandoned"; - case "merged": - return "completed"; - case "all": - return "all"; - } -} - -const RawAzureDevOpsRepositorySchema = Schema.Struct({ - name: TrimmedNonEmptyString, - webUrl: TrimmedNonEmptyString, - remoteUrl: TrimmedNonEmptyString, - sshUrl: TrimmedNonEmptyString, - project: Schema.optional( - Schema.Struct({ - name: TrimmedNonEmptyString, - }), - ), - defaultBranch: Schema.optional(Schema.NullOr(Schema.String)), -}); - -function normalizeDefaultBranch(value: string | null | undefined): string | null { - const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? ""; - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeRepositoryCloneUrls( - raw: Schema.Schema.Type, -): AzureDevOpsRepositoryCloneUrls { - const projectName = raw.project?.name.trim(); - return { - nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name, - url: raw.remoteUrl, - sshUrl: raw.sshUrl, - }; -} - -function parseRepositorySpecifier(repository: string): { - readonly project: string | null; - readonly name: string; -} { - const parts: Array = []; - for (const part of repository.split("/")) { - const trimmed = part.trim(); - if (trimmed.length > 0) { - parts.push(trimmed); - } - } - return { - project: parts.length > 1 ? (parts.at(-2) ?? null) : null, - name: parts.at(-1) ?? repository.trim(), - }; -} - -function decodeAzureDevOpsJson( - raw: string, - schema: S, - operation: typeof AzureDevOpsRepositoryDecodeOperation.Type, - cwd: string, -): Effect.Effect { - return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( - Effect.mapError( - (cause) => - new AzureDevOpsRepositoryDecodeError({ - operation, - command: "az", - cwd, - outputLength: raw.length, - cause, - }), - ), - ); -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; - - const execute: AzureDevOpsCli["Service"]["execute"] = (input) => - process - .run({ - operation: "AzureDevOpsCli.execute", - command: "az", - args: input.args, - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), - }) - .pipe( - Effect.mapError((error) => - AzureDevOpsCommandFailedError.fromVcsError( - { - operation: "execute", - command: "az", - cwd: input.cwd, - argumentCount: input.args.length, - }, - error, - ), - ), - ); - - const executeJson = (input: Parameters[0]) => - execute({ - ...input, - args: [...input.args, "--only-show-errors", "--output", "json"], - }); - - return AzureDevOpsCli.of({ - execute, - listPullRequests: (input) => - executeJson({ - cwd: input.cwd, - args: [ - "repos", - "pr", - "list", - "--detect", - "true", - "--source-branch", - SourceControlProvider.sourceBranch(input), - "--status", - toAzureStatus(input.state), - "--top", - String(input.limit ?? 20), - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - raw.length === 0 - ? Effect.succeed([]) - : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new AzureDevOpsPullRequestListDecodeError({ - operation: "listPullRequests", - command: "az", - cwd: input.cwd, - outputLength: raw.length, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed(decoded.success); - }), - ), - ), - ), - getPullRequest: (input) => - executeJson({ - cwd: input.cwd, - args: [ - "repos", - "pr", - "show", - "--detect", - "true", - "--id", - normalizeChangeRequestId(input.reference), - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new AzureDevOpsPullRequestDecodeError({ - operation: "getPullRequest", - command: "az", - cwd: input.cwd, - outputLength: raw.length, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed(decoded.success); - }), - ), - ), - ), - getRepositoryCloneUrls: (input) => - executeJson({ - cwd: input.cwd, - args: ["repos", "show", "--detect", "true", "--repository", input.repository], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeAzureDevOpsJson( - raw, - RawAzureDevOpsRepositorySchema, - "getRepositoryCloneUrls", - input.cwd, - ), - ), - Effect.map(normalizeRepositoryCloneUrls), - ), - createRepository: (input) => { - const repository = parseRepositorySpecifier(input.repository); - // Azure Repos access is governed by project/organization permissions. - // `az repos create` does not expose a per-repository visibility flag, so - // the generic source-control visibility input is intentionally not - // translated into CLI args for this provider. - return executeJson({ - cwd: input.cwd, - args: [ - "repos", - "create", - "--detect", - "true", - "--name", - repository.name, - ...(repository.project ? ["--project", repository.project] : []), - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd), - ), - Effect.map(normalizeRepositoryCloneUrls), - ); - }, - createPullRequest: (input) => - execute({ - cwd: input.cwd, - args: [ - "repos", - "pr", - "create", - "--only-show-errors", - "--detect", - "true", - "--target-branch", - input.target?.refName ?? input.baseBranch, - "--source-branch", - SourceControlProvider.sourceBranch(input), - "--title", - input.title, - "--description", - `@${input.bodyFile}`, - ], - }).pipe(Effect.asVoid), - getDefaultBranch: (input) => - executeJson({ - cwd: input.cwd, - args: ["repos", "show", "--detect", "true"], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd), - ), - Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), - ), - checkoutPullRequest: (input) => - execute({ - cwd: input.cwd, - args: [ - "repos", - "pr", - "checkout", - "--only-show-errors", - "--detect", - "true", - "--id", - normalizeChangeRequestId(input.reference), - "--remote-name", - input.remoteName ?? "origin", - ], - }).pipe(Effect.asVoid), - }); -}); - -export const layer = Layer.effect(AzureDevOpsCli, make); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts deleted file mode 100644 index 8e55d453b224b411a0986b4cd153c588a8159f49..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; -import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; - -function makeProvider(azure: Partial) { - return AzureDevOpsSourceControlProvider.make.pipe( - Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), - ); -} - -it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () => - Effect.gen(function* () { - const provider = yield* makeProvider({ - getPullRequest: () => - Effect.succeed({ - number: 42, - title: "Add Azure provider", - url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "closed", - closedAt: "2026-08-23T10:00:00Z", - updatedAt: Option.none(), - }), - }); - - const changeRequest = yield* provider.getChangeRequest({ - cwd: "/repo", - reference: "42", - }); - - assert.deepStrictEqual(changeRequest, { - provider: "azure-devops", - number: 42, - title: "Add Azure provider", - url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "closed", - closedAt: "2026-08-23T10:00:00Z", - mergedAt: null, - updatedAt: Option.none(), - isCrossRepository: false, - }); - }), -); - -it.effect("adds change-request context while retaining Azure CLI causes", () => - Effect.gen(function* () { - const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ - operation: "execute", - command: "az", - cwd: "/repo", - argumentCount: 2, - cause: new Error("raw upstream detail that should remain in the cause"), - }); - const provider = yield* makeProvider({ - checkoutPullRequest: () => Effect.fail(cause), - }); - - const error = yield* provider - .checkoutChangeRequest({ cwd: "/repo", reference: "#42" }) - .pipe(Effect.flip); - - assert.deepStrictEqual( - { - provider: error.provider, - operation: error.operation, - command: error.command, - cwd: error.cwd, - reference: error.reference, - detail: error.detail, - }, - { - provider: "azure-devops", - operation: "checkoutChangeRequest", - command: "az", - cwd: "/repo", - reference: "#42", - detail: "Azure DevOps CLI command failed.", - }, - ); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes("raw upstream detail"), false); - }), -); - -it.effect("creates Azure DevOps PRs through provider-neutral input names", () => - Effect.gen(function* () { - let createInput: - | Parameters[0] - | null = null; - const provider = yield* makeProvider({ - createPullRequest: (input) => { - createInput = input; - return Effect.void; - }, - }); - - yield* provider.createChangeRequest({ - cwd: "/repo", - baseRefName: "main", - headSelector: "feature/provider", - title: "Provider PR", - bodyFile: "/tmp/body.md", - }); - - assert.deepStrictEqual(createInput, { - cwd: "/repo", - baseBranch: "main", - headSelector: "feature/provider", - title: "Provider PR", - bodyFile: "/tmp/body.md", - }); - }), -); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts deleted file mode 100644 index dcb07e0632f3c5646f44df552f25c02aaac495e2..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ /dev/null @@ -1,236 +0,0 @@ -import * as Effect from "effect/Effect"; -import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; - -import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; -import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; -import { - combinedAuthOutput, - firstSafeAuthLine, - providerAuth, - type SourceControlAuthProbeInput, - type SourceControlCliDiscoverySpec, -} from "@t3tools/source-control-core/server/discovery"; - -function parseAzureAuth(input: SourceControlAuthProbeInput) { - const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); - - if (input.exitCode !== 0) { - return providerAuth({ - status: "unauthenticated", - detail: - firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", - }); - } - - if (account !== undefined && account.length > 0) { - return providerAuth({ - status: "authenticated", - account, - host: "dev.azure.com", - }); - } - - return providerAuth({ - status: "unknown", - host: "dev.azure.com", - detail: "Azure CLI account status could not be parsed.", - }); -} - -export const discovery = { - type: "cli", - kind: "azure-devops", - label: "Azure DevOps", - executable: "az", - versionArgs: ["--version"], - authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], - // `az` boots a fresh Python interpreter on every invocation, so even `az --version` - // takes ~6s on Windows and overruns the default budget, leaving the provider reported - // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s. - probeTimeoutMs: 20_000, - parseAuth: parseAzureAuth, - installHint: - "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", -} satisfies SourceControlCliDiscoverySpec; - -function toChangeRequest(summary: { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly state: "open" | "closed" | "merged"; - readonly isDraft?: boolean; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt: ChangeRequest["updatedAt"]; -}): ChangeRequest { - return { - provider: "azure-devops", - number: summary.number, - title: summary.title, - url: summary.url, - baseRefName: summary.baseRefName, - headRefName: summary.headRefName, - state: summary.state, - ...(summary.isDraft === true ? { isDraft: true } : {}), - closedAt: summary.closedAt ?? null, - mergedAt: summary.mergedAt ?? null, - updatedAt: summary.updatedAt, - isCrossRepository: false, - }; -} - -export const make = Effect.gen(function* () { - const azure = yield* AzureDevOpsCli.AzureDevOpsCli; - - return SourceControlProvider.SourceControlProvider.of({ - kind: "azure-devops", - listChangeRequests: (input) => { - const source = SourceControlProvider.sourceControlRefFromInput(input); - return azure - .listPullRequests({ - cwd: input.cwd, - headSelector: input.headSelector, - ...(source !== undefined ? { source } : {}), - state: input.state, - ...(input.limit !== undefined ? { limit: input.limit } : {}), - }) - .pipe( - Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "listChangeRequests", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), - ), - ); - }, - getChangeRequest: (input) => - azure.getPullRequest(input).pipe( - Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "getChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), - ), - ), - createChangeRequest: (input) => { - const source = SourceControlProvider.sourceControlRefFromInput(input); - return azure - .createPullRequest({ - cwd: input.cwd, - baseBranch: input.baseRefName, - headSelector: input.headSelector, - ...(source !== undefined ? { source } : {}), - ...(input.target !== undefined ? { target: input.target } : {}), - title: input.title, - bodyFile: input.bodyFile, - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "createChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), - ), - ); - }, - getRepositoryCloneUrls: (input) => - azure.getRepositoryCloneUrls(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "getRepositoryCloneUrls", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), - ), - ), - createRepository: (input) => - azure.createRepository(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "createRepository", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), - ), - ), - getDefaultBranch: (input) => - azure.getDefaultBranch({ cwd: input.cwd }).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "getDefaultBranch", - command: error.command, - cwd: input.cwd, - detail: error.detail, - cause: error, - }), - ), - ), - checkoutChangeRequest: (input) => - azure - .checkoutPullRequest({ - cwd: input.cwd, - reference: input.reference, - ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "checkoutChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), - ), - ), - }); -}); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index fe7694cef5458b787eaa37e612f0e048339d367d..e818a92746359495c7b54a31c000d1dcfa01f266 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -16,7 +16,8 @@ import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; +import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; @@ -39,6 +40,7 @@ const layerSourceControlProviderRegistryTest = (input: { prefix: "t3-source-control-registry-test-", }).pipe(Layer.provide(NodeServices.layer)), Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), ServerSettings.ServerSettingsService.layerTest(), Layer.mock(GitHubApi.GitHubApi)({}), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 2e2969488d3d845636888ded014bc9b2d08ee25e..5f48bae18588eea281644217c774312612ce715c 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -13,7 +13,8 @@ import * as ServerConfig from "../config.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; +import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitHubApi from "./GitHubApi.ts"; @@ -103,6 +104,7 @@ function makeRegistry(input: { ), ), Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), ServerSettings.ServerSettingsService.layerTest(), Layer.mock(GitHubApi.GitHubApi)({ diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 22516853b836303488003b74c479ca32d1d262c5..948d583296faf8974a1994d34543e8d84c3893db 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -11,7 +11,6 @@ import { import type { SourceControlProviderKind } from "@t3tools/contracts"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; @@ -310,7 +309,6 @@ export const make = Effect.gen(function* () { const gitlab = yield* GitLabSourceControlProvider.make; const bitbucket = yield* BitbucketSourceControlProvider.make; const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; - const azureDevOps = yield* AzureDevOpsSourceControlProvider.make; const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => driver.make.pipe( Effect.map((instance): SourceControlProviderRegistration => ({ @@ -320,7 +318,9 @@ export const make = Effect.gen(function* () { })), ), ); - return yield* makeWithProviders([ + // Discovery lists hosts in this order, so it is kept while hosts move into packages. + const order = ["github", "gitlab", "azure-devops", "bitbucket", "forgejo"]; + const registrations: ReadonlyArray = [ { kind: "github", provider: github, @@ -331,18 +331,16 @@ export const make = Effect.gen(function* () { provider: gitlab, discovery: GitLabSourceControlProvider.discovery, }, - { - kind: "azure-devops", - provider: azureDevOps, - discovery: AzureDevOpsSourceControlProvider.discovery, - }, { kind: "bitbucket", provider: bitbucket, discovery: bitbucketDiscovery, }, ...drivers, - ]); + ]; + return yield* makeWithProviders( + registrations.toSorted((left, right) => order.indexOf(left.kind) - order.indexOf(right.kind)), + ); }); export const layer = Layer.effect(SourceControlProviderRegistry, make); diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts deleted file mode 100644 index 6179a61aa65d093829577491d1216c3a3e514436..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ /dev/null @@ -1,222 +0,0 @@ -import * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; - -export interface NormalizedAzureDevOpsPullRequestRecord { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly state: "open" | "closed" | "merged"; - readonly isDraft?: boolean; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt: Option.Option; -} - -const AzureDevOpsPullRequestSchema = Schema.Struct({ - pullRequestId: PositiveInt, - title: TrimmedNonEmptyString, - url: Schema.optional(Schema.String), - repository: Schema.optional( - Schema.Struct({ - name: Schema.optional(Schema.String), - webUrl: Schema.optional(Schema.String), - project: Schema.optional( - Schema.Struct({ - name: Schema.optional(Schema.String), - }), - ), - }), - ), - sourceRefName: TrimmedNonEmptyString, - targetRefName: TrimmedNonEmptyString, - status: Schema.String, - isDraft: Schema.optional(Schema.Boolean), - creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), - closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), - _links: Schema.optional( - Schema.Struct({ - web: Schema.optional( - Schema.Struct({ - href: Schema.String, - }), - ), - }), - ), -}); - -function trimOptionalString(value: string | null | undefined): string | null { - const trimmed = value?.trim() ?? ""; - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeRefName(refName: string): string { - return refName.trim().replace(/^refs\/heads\//, ""); -} - -function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" | "merged" { - switch (status.trim().toLowerCase()) { - case "completed": - return "merged"; - case "abandoned": - return "closed"; - default: - return "open"; - } -} - -function encodeAzureDevOpsPathSegment(segment: string): string { - return encodeURIComponent(segment); -} - -/** - * The organization root a REST url belongs to, which is where a browser url has to be hung when - * Azure answered with neither a web link nor a repository url. Read from what Azure returned - * rather than from the local remote, whose shape varies. - */ -function azureDevOpsOrganizationBaseFromRestApiUrl( - value: string | null | undefined, -): string | null { - const rawUrl = trimOptionalString(value); - if (!rawUrl) { - return null; - } - - try { - const url = new URL(rawUrl); - const hostname = url.hostname.toLowerCase(); - const pathSegments = url.pathname.split("/").filter((segment) => segment.length > 0); - const isAzureRestUrl = pathSegments.some((segment) => segment.toLowerCase() === "_apis"); - if (!isAzureRestUrl) { - return null; - } - - if (hostname === "dev.azure.com") { - const organization = pathSegments[0]; - return organization ? `${url.origin}/${organization}` : null; - } - - if (hostname.endsWith(".visualstudio.com")) { - return url.origin; - } - - return null; - } catch { - return null; - } -} - -/** - * Where a pull request lives in a browser. Azure answers with a web link when asked for one and - * otherwise leaves it to be assembled, so all three routes are tried in the order they can be - * trusted. Takes plain fields so both the source control provider and the pull requests page - * can share it. - */ -export function azureDevOpsPullRequestWebUrl(input: { - readonly pullRequestId: number; - readonly webLink?: string | null | undefined; - readonly repositoryWebUrl?: string | null | undefined; - readonly restApiUrl?: string | null | undefined; - readonly projectName?: string | null | undefined; - readonly repositoryName?: string | null | undefined; -}): string { - const webLink = trimOptionalString(input.webLink); - if (webLink) { - return webLink; - } - - const repositoryWebUrl = trimOptionalString(input.repositoryWebUrl); - if (repositoryWebUrl) { - return `${repositoryWebUrl.replace(/\/+$/, "")}/pullrequest/${input.pullRequestId}`; - } - - const organizationBase = azureDevOpsOrganizationBaseFromRestApiUrl(input.restApiUrl); - const projectName = trimOptionalString(input.projectName); - const repositoryName = trimOptionalString(input.repositoryName); - if (organizationBase && projectName && repositoryName) { - const encodedProjectName = encodeAzureDevOpsPathSegment(projectName); - const encodedRepositoryName = encodeAzureDevOpsPathSegment(repositoryName); - return `${organizationBase}/${encodedProjectName}/_git/${encodedRepositoryName}/pullrequest/${input.pullRequestId}`; - } - - return trimOptionalString(input.restApiUrl) ?? ""; -} - -function normalizeAzureDevOpsPullRequestUrl( - raw: Schema.Schema.Type, -): string { - return azureDevOpsPullRequestWebUrl({ - pullRequestId: raw.pullRequestId, - webLink: raw._links?.web?.href, - repositoryWebUrl: raw.repository?.webUrl, - restApiUrl: raw.url, - projectName: raw.repository?.project?.name, - repositoryName: raw.repository?.name, - }); -} - -function normalizeAzureDevOpsPullRequestRecord( - raw: Schema.Schema.Type, -): NormalizedAzureDevOpsPullRequestRecord { - const state = normalizeAzureDevOpsPullRequestState(raw.status); - const terminalAt = Option.match(raw.closedDate ?? Option.none(), { - onNone: () => null, - onSome: DateTime.formatIso, - }); - return { - number: raw.pullRequestId, - title: raw.title, - url: normalizeAzureDevOpsPullRequestUrl(raw), - baseRefName: normalizeRefName(raw.targetRefName), - headRefName: normalizeRefName(raw.sourceRefName), - state, - ...(raw.isDraft === true ? { isDraft: true } : {}), - closedAt: state === "closed" ? terminalAt : null, - mergedAt: state === "merged" ? terminalAt : null, - updatedAt: (raw.closedDate ?? Option.none()).pipe( - Option.orElse(() => raw.creationDate ?? Option.none()), - ), - }; -} - -const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); -const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema); -const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema); - -export function decodeAzureDevOpsPullRequestListJson( - raw: string, -): Result.Result< - ReadonlyArray, - Cause.Cause -> { - const result = decodeAzureDevOpsPullRequestList(raw); - if (Result.isSuccess(result)) { - const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = []; - for (const entry of result.success) { - const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry); - if (Exit.isFailure(decodedEntry)) { - continue; - } - pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value)); - } - return Result.succeed(pullRequests); - } - return Result.fail(result.failure); -} - -export function decodeAzureDevOpsPullRequestJson( - raw: string, -): Result.Result> { - const result = decodeAzureDevOpsPullRequest(raw); - if (Result.isSuccess(result)) { - return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success)); - } - return Result.fail(result.failure); -} diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index c49319ba0a1f8ff7c920dd4bc1f5f82774cdd27b..584bf139c0fb64205d60428c66d987e18e1ea0a1 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -7,14 +7,28 @@ * * @module sourceControl/builtInDrivers */ +import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; +import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; +import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Layer from "effect/Layer"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; -/** Ordered as the hosts appear in discovery, after the ones still built in the server. */ -export const BUILT_IN_SOURCE_CONTROL_DRIVERS = [ForgejoDriver.driver] as const; +const drivers = [AzureDevOpsDriver.driver, ForgejoDriver.driver]; + +/** Every service a built-in driver's `make` needs; the server's layers must provide them all. */ +export type BuiltInSourceControlDriversEnv = + (typeof drivers)[number] extends SourceControlDriver ? R : never; + +export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< + SourceControlDriver +> = drivers; /** The services the built-in drivers' packages own, plus the host port they all run against. */ -export const layer = ForgejoCli.layer.pipe(Layer.provideMerge(ServerSourceControlHost.layer)); +export const layer = Layer.mergeAll( + AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), + ForgejoCli.layer, +).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d3e27ea0bc81267657b86b8779993b6f5c84fca7..899f021eb937838dd213e273d8a58592fb3ec699 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -229,7 +229,6 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; -import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; @@ -3204,7 +3203,6 @@ export const layer = Layer.unwrap( SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - AzureDevOpsCli.layer, BitbucketApi.layer, GitHubApi.layerWithDependencies, GitLabCli.layer, diff --git a/docs/internals/pull-request-file-revisions.md b/docs/internals/pull-request-file-revisions.md index 22eeb63848eaf6156db5e68cedaccf4d48a51c40..ceb80de8efe03f5d6e519b0645502e320cfd6d1a 100644 --- a/docs/internals/pull-request-file-revisions.md +++ b/docs/internals/pull-request-file-revisions.md @@ -24,7 +24,7 @@ the provider never got to look at. Absence below the boundary does not survive i What "never got to look at" is belongs to the host, and the contract cannot know any of them: -- [Azure DevOps](../../apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts) reads every +- [Azure DevOps](../../packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts) reads every version off one iteration listing, so a change too long to follow to its end leaves the paths past that point out. - [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the diff --git a/package.json b/package.json index ed9f936dfbf9e706532d18c4df9eea1e47bfedfa..bb1d183efb891058eab579b338e39679d10eeecc 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "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", + "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", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/source-control-azure-devops/package.json b/packages/source-control-azure-devops/package.json new file mode 100644 index 0000000000000000000000000000000000000000..10347850bf19c9d1d7ca7f001f99049df7bab0cb --- /dev/null +++ b/packages/source-control-azure-devops/package.json @@ -0,0 +1,57 @@ +{ + "name": "@t3tools/source-control-azure-devops", + "private": true, + "type": "module", + "exports": { + "./server/AzureDevOpsCli": { + "types": "./src/server/AzureDevOpsCli.ts", + "import": "./src/server/AzureDevOpsCli.ts" + }, + "./server/azureDevOpsDiff": { + "types": "./src/server/azureDevOpsDiff.ts", + "import": "./src/server/azureDevOpsDiff.ts" + }, + "./server/AzureDevOpsPullRequestCli": { + "types": "./src/server/AzureDevOpsPullRequestCli.ts", + "import": "./src/server/AzureDevOpsPullRequestCli.ts" + }, + "./server/azureDevOpsPullRequestJson": { + "types": "./src/server/azureDevOpsPullRequestJson.ts", + "import": "./src/server/azureDevOpsPullRequestJson.ts" + }, + "./server/AzureDevOpsPullRequestProvider": { + "types": "./src/server/AzureDevOpsPullRequestProvider.ts", + "import": "./src/server/AzureDevOpsPullRequestProvider.ts" + }, + "./server/azureDevOpsPullRequests": { + "types": "./src/server/azureDevOpsPullRequests.ts", + "import": "./src/server/azureDevOpsPullRequests.ts" + }, + "./server/AzureDevOpsSourceControlProvider": { + "types": "./src/server/AzureDevOpsSourceControlProvider.ts", + "import": "./src/server/AzureDevOpsSourceControlProvider.ts" + }, + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "diff": "8.0.3", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/source-control-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4fca9c5ffae0338c69d7732fa6d2046f4f1de68 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts @@ -0,0 +1,447 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/process"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; + +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; + +const processOutput = (stdout: string): SourceControlHost.SourceControlProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const mockRun = vi.fn(); + +const layerSupport = Layer.mergeAll( + TestSourceControlHost.layer({ process: { run: mockRun } }), + NodeServices.layer, +); +const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(layerSupport)), layerSupport); + +afterEach(() => { + mockRun.mockReset(); +}); + +describe("AzureDevOpsCli.layer", () => { + it.effect("parses pull request view output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + pullRequestId: 42, + title: "Add Azure provider", + sourceRefName: "refs/heads/feature/source-control", + targetRefName: "refs/heads/main", + status: "active", + creationDate: "2026-01-02T00:00:00.000Z", + closedDate: null, + _links: { + web: { + href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + }, + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.strictEqual(result.number, 42); + assert.strictEqual(result.title, "Add Azure provider"); + assert.strictEqual(result.url, "https://dev.azure.com/acme/project/_git/repo/pullrequest/42"); + assert.strictEqual(result.baseRefName, "main"); + assert.strictEqual(result.headRefName, "feature/source-control"); + assert.strictEqual(result.state, "open"); + assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag); + assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], { + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "show", + "--detect", + "true", + "--id", + "42", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("builds a web URL when Azure returns only the pull request REST URL", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + pullRequestId: 863, + title: "Fix Azure link", + url: "https://dev.azure.com/saplyai/a8fe4088-ad76-4eda-aaaa-e3329713a097/_apis/git/repositories/16108a25-93a3-4eff-b77d-85e894ee0fe4/pullRequests/863", + repository: { + name: "CV-engine", + project: { + name: "CV-engine", + }, + }, + sourceRefName: "refs/heads/feature/azure-pr-link", + targetRefName: "refs/heads/main", + status: "active", + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ + cwd: "/repo", + reference: "863", + }); + + assert.strictEqual( + result.url, + "https://dev.azure.com/saplyai/CV-engine/_git/CV-engine/pullrequest/863", + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("lists pull requests with Azure status and source branch arguments", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify([ + { + pullRequestId: 7, + title: "Merged work", + sourceRefName: "refs/heads/feature/merged", + targetRefName: "refs/heads/main", + status: "completed", + closedDate: "2026-01-03T00:00:00.000Z", + _links: { + web: { + href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7", + }, + }, + }, + ]), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.listPullRequests({ + cwd: "/repo", + headSelector: "origin:feature/merged", + state: "merged", + limit: 10, + }); + + assert.strictEqual(result[0]?.state, "merged"); + assert.strictEqual(result[0]?.mergedAt, "2026-01-03T00:00:00.000Z"); + assert.strictEqual(result[0]?.closedAt, null); + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "list", + "--detect", + "true", + "--source-branch", + "feature/merged", + "--status", + "completed", + "--top", + "10", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads repository clone URLs", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + name: "repo", + webUrl: "https://dev.azure.com/acme/project/_git/repo", + remoteUrl: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + project: { + name: "project", + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "repo", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "project/repo", + url: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("creates repositories through Azure Repos", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + name: "repo", + webUrl: "https://dev.azure.com/acme/project/_git/repo", + remoteUrl: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + project: { + name: "project", + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.createRepository({ + cwd: "/repo", + repository: "project/repo", + visibility: "private", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "project/repo", + url: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "create", + "--detect", + "true", + "--name", + "repo", + "--project", + "project", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("creates pull requests using the body file as the Azure description", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const bodyFile = `/tmp/t3code-azure-devops-cli-.md`; + yield* fileSystem.writeFileString(bodyFile, "Generated body"); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/provider", + title: "Provider PR", + bodyFile, + }); + + expect(mockRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "az", + cwd: "/repo", + args: expect.arrayContaining(["--description", `@${bodyFile}`]), + }), + ); + expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("does not force JSON output on checkout side-effect commands", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.checkoutPullRequest({ + cwd: "/repo", + reference: "42", + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "checkout", + "--only-show-errors", + "--detect", + "true", + "--id", + "42", + "--remote-name", + "origin", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("forwards explicit output limits to the process boundary", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.execute({ + cwd: "/repo", + args: ["repos", "pr", "list"], + maxOutputBytes: 16 * 1024 * 1024, + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: ["repos", "pr", "list"], + cwd: "/repo", + timeoutMs: 30_000, + maxOutputBytes: 16 * 1024 * 1024, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("preserves VCS causes without copying upstream details into messages", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "AzureDevOpsCli.execute", + command: "az repos list --organization sensitive-upstream-detail", + cwd: "/repo", + exitCode: 1, + detail: "sensitive-upstream-detail", + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); + assert.strictEqual(error.operation, "execute"); + assert.strictEqual(error.command, "az"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.argumentCount, 2); + assert.strictEqual(error.detail, "Azure DevOps CLI command failed."); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("sensitive-upstream-detail"), false); + }).pipe(Effect.provide(layer)), + ); + + it.effect("does not report a missing working directory as a missing Azure CLI", () => + Effect.gen(function* () { + const cwd = "/missing/repo"; + const platformCause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + syscall: "chdir", + pathOrDescriptor: cwd, + }); + const cause = new VcsProcessSpawnError({ + operation: "AzureDevOpsCli.execute", + command: "az", + cwd, + argumentCount: 2, + cause: platformCause, + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); + assert.strictEqual(error.cwd, cwd); + assert.strictEqual(error.cause, cause); + }).pipe(Effect.provide(layer)), + ); + + it.effect("keeps invalid pull request output diagnostics structured", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.command, "az"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.outputLength, 8); + assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON."); + assert.exists(error.cause); + assert.strictEqual( + error.message, + "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.", + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("preserves rate-limit failures as a distinct error", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "AzureDevOpsCli.execute", + command: "az", + cwd: "/repo", + argumentCount: 2, + exitCode: 1, + detail: "API rate limit exceeded.", + failureKind: "rate-limited", + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCliRateLimitError); + assert.strictEqual(error.cause, cause); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts new file mode 100644 index 0000000000000000000000000000000000000000..c333a220c6d32c449df72118ece5bf391a46aae6 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts @@ -0,0 +1,554 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { + NonNegativeInt, + TrimmedNonEmptyString, + type SourceControlRepositoryVisibility, + type VcsError, +} from "@t3tools/contracts"; + +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { + decodeAzureDevOpsPullRequestJson, + decodeAzureDevOpsPullRequestListJson, + type NormalizedAzureDevOpsPullRequestRecord, +} from "./azureDevOpsPullRequests.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +const azureDevOpsCommandErrorFields = { + operation: Schema.Literal("execute"), + command: Schema.Literal("az"), + cwd: Schema.String, + argumentCount: NonNegativeInt, + cause: Schema.Defect(), +}; + +export class AzureDevOpsCliUnavailableError extends Schema.TaggedError()( + "AzureDevOpsCliUnavailableError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsCliAuthenticationError extends Schema.TaggedError()( + "AzureDevOpsCliAuthenticationError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsCliRateLimitError extends Schema.TaggedError()( + "AzureDevOpsCliRateLimitError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure DevOps API rate limit exceeded."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedError()( + "AzureDevOpsPullRequestNotFoundError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Pull request not found. Check the PR number or URL and try again."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsCommandFailedError extends Schema.TaggedError()( + "AzureDevOpsCommandFailedError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure DevOps CLI command failed."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "az"; + readonly cwd: string; + readonly argumentCount: number; + }, + cause: VcsError, + ): AzureDevOpsCliError { + const fields = { ...context, cause }; + + if ( + cause._tag === "VcsProcessSpawnError" && + cause.cause instanceof PlatformError.PlatformError && + cause.cause.reason._tag === "NotFound" && + cause.cause.reason.pathOrDescriptor !== context.cwd && + cause.cause.reason.syscall !== "chdir" + ) { + return new AzureDevOpsCliUnavailableError(fields); + } + + if (cause._tag === "VcsProcessExitError") { + if (cause.failureKind === "authentication") { + return new AzureDevOpsCliAuthenticationError(fields); + } + if (cause.failureKind === "rate-limited") { + return new AzureDevOpsCliRateLimitError(fields); + } + if (cause.failureKind === "not-found") { + return new AzureDevOpsPullRequestNotFoundError(fields); + } + } + + return new AzureDevOpsCommandFailedError(fields); + } +} + +const azureDevOpsDecodeErrorFields = { + command: Schema.Literal("az"), + cwd: Schema.String, + outputLength: NonNegativeInt, + cause: Schema.Defect(), +}; + +export class AzureDevOpsPullRequestListDecodeError extends Schema.TaggedError()( + "AzureDevOpsPullRequestListDecodeError", + { + operation: Schema.Literal("listPullRequests"), + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid PR list JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedError()( + "AzureDevOpsPullRequestDecodeError", + { + operation: Schema.Literal("getPullRequest"), + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid pull request JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([ + "getRepositoryCloneUrls", + "getDefaultBranch", + "createRepository", +]); + +export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedError()( + "AzureDevOpsRepositoryDecodeError", + { + operation: AzureDevOpsRepositoryDecodeOperation, + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid repository JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export const AzureDevOpsCliError = Schema.Union([ + AzureDevOpsCliUnavailableError, + AzureDevOpsCliAuthenticationError, + AzureDevOpsCliRateLimitError, + AzureDevOpsPullRequestNotFoundError, + AzureDevOpsCommandFailedError, + AzureDevOpsPullRequestListDecodeError, + AzureDevOpsPullRequestDecodeError, + AzureDevOpsRepositoryDecodeError, +]); +export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type; + +export interface AzureDevOpsRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export class AzureDevOpsCli extends Context.Service< + AzureDevOpsCli, + { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, AzureDevOpsCliError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly remoteName?: string; + }) => Effect.Effect; + } +>()("@t3tools/source-control-azure-devops/server/AzureDevOpsCli") {} + +function normalizeChangeRequestId(reference: string): string { + const trimmed = reference.trim().replace(/^#/, ""); + const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); + return urlMatch?.[1] ?? trimmed; +} + +function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string { + switch (state) { + case "open": + return "active"; + case "closed": + return "abandoned"; + case "merged": + return "completed"; + case "all": + return "all"; + } +} + +const RawAzureDevOpsRepositorySchema = Schema.Struct({ + name: TrimmedNonEmptyString, + webUrl: TrimmedNonEmptyString, + remoteUrl: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, + project: Schema.optional( + Schema.Struct({ + name: TrimmedNonEmptyString, + }), + ), + defaultBranch: Schema.optional(Schema.NullOr(Schema.String)), +}); + +function normalizeDefaultBranch(value: string | null | undefined): string | null { + const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): AzureDevOpsRepositoryCloneUrls { + const projectName = raw.project?.name.trim(); + return { + nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name, + url: raw.remoteUrl, + sshUrl: raw.sshUrl, + }; +} + +function parseRepositorySpecifier(repository: string): { + readonly project: string | null; + readonly name: string; +} { + const parts: Array = []; + for (const part of repository.split("/")) { + const trimmed = part.trim(); + if (trimmed.length > 0) { + parts.push(trimmed); + } + } + return { + project: parts.length > 1 ? (parts.at(-2) ?? null) : null, + name: parts.at(-1) ?? repository.trim(), + }; +} + +function decodeAzureDevOpsJson( + raw: string, + schema: S, + operation: typeof AzureDevOpsRepositoryDecodeOperation.Type, + cwd: string, +): Effect.Effect { + return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError( + (cause) => + new AzureDevOpsRepositoryDecodeError({ + operation, + command: "az", + cwd, + outputLength: raw.length, + cause, + }), + ), + ); +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const { process } = yield* SourceControlHost.SourceControlHost; + + const execute: AzureDevOpsCli["Service"]["execute"] = (input) => + process + .run({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + }) + .pipe( + Effect.mapError((error) => + AzureDevOpsCommandFailedError.fromVcsError( + { + operation: "execute", + command: "az", + cwd: input.cwd, + argumentCount: input.args.length, + }, + error, + ), + ), + ); + + const executeJson = (input: Parameters[0]) => + execute({ + ...input, + args: [...input.args, "--only-show-errors", "--output", "json"], + }); + + return AzureDevOpsCli.of({ + execute, + listPullRequests: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "list", + "--detect", + "true", + "--source-branch", + SourceControlProvider.sourceBranch(input), + "--status", + toAzureStatus(input.state), + "--top", + String(input.limit ?? 20), + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsPullRequestListDecodeError({ + operation: "listPullRequests", + command: "az", + cwd: input.cwd, + outputLength: raw.length, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success); + }), + ), + ), + ), + getPullRequest: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "show", + "--detect", + "true", + "--id", + normalizeChangeRequestId(input.reference), + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsPullRequestDecodeError({ + operation: "getPullRequest", + command: "az", + cwd: input.cwd, + outputLength: raw.length, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success); + }), + ), + ), + ), + getRepositoryCloneUrls: (input) => + executeJson({ + cwd: input.cwd, + args: ["repos", "show", "--detect", "true", "--repository", input.repository], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson( + raw, + RawAzureDevOpsRepositorySchema, + "getRepositoryCloneUrls", + input.cwd, + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createRepository: (input) => { + const repository = parseRepositorySpecifier(input.repository); + // Azure Repos access is governed by project/organization permissions. + // `az repos create` does not expose a per-repository visibility flag, so + // the generic source-control visibility input is intentionally not + // translated into CLI args for this provider. + return executeJson({ + cwd: input.cwd, + args: [ + "repos", + "create", + "--detect", + "true", + "--name", + repository.name, + ...(repository.project ? ["--project", repository.project] : []), + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd), + ), + Effect.map(normalizeRepositoryCloneUrls), + ); + }, + createPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "create", + "--only-show-errors", + "--detect", + "true", + "--target-branch", + input.target?.refName ?? input.baseBranch, + "--source-branch", + SourceControlProvider.sourceBranch(input), + "--title", + input.title, + "--description", + `@${input.bodyFile}`, + ], + }).pipe(Effect.asVoid), + getDefaultBranch: (input) => + executeJson({ + cwd: input.cwd, + args: ["repos", "show", "--detect", "true"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd), + ), + Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), + ), + checkoutPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "checkout", + "--only-show-errors", + "--detect", + "true", + "--id", + normalizeChangeRequestId(input.reference), + "--remote-name", + input.remoteName ?? "origin", + ], + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(AzureDevOpsCli, make); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3afaca037159b1b1b15d3f098a5dde75d8aecb2 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts @@ -0,0 +1,1462 @@ +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/process"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; + +const mockedExecute = vi.fn(); + +const layer = it.layer( + AzureDevOpsPullRequestCli.layer.pipe( + Layer.provide( + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({ + execute: mockedExecute, + }), + ), + ), +); + +function output(stdout: string) { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; +} + +/** What VcsProcess allows a read that asked for no ceiling of its own. */ +const VCS_DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; + +/** + * The runner as it really behaves: it cuts stdout at the ceiling its caller asked for, and cuts + * it at the process default when the caller asked for none. A read whose response is larger than + * its ceiling gets JSON that stops mid-string, which is the whole cost of an unset ceiling. + */ +function outputWithin(maxOutputBytes: number | undefined, response: string) { + const ceiling = maxOutputBytes ?? VCS_DEFAULT_MAX_OUTPUT_BYTES; + return ceiling >= Buffer.byteLength(response) + ? output(response) + : { ...output(response.slice(0, ceiling)), stdoutTruncated: true }; +} + +/** A fixture's own shape, spelled the way `az` would answer with it. */ +const json = (value: Record) => JSON.stringify(value); + +const pullRequestRow = { + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, +}; + +const oneIteration = { + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], +}; + +function pullRequestRows( + count: number, + firstNumber: number, +): ReadonlyArray> { + return Array.from({ length: count }, (_, index) => ({ + pullRequestId: firstNumber + index, + title: `Pull request ${firstNumber + index}`, + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + repository: { name: "web", project: { name: "platform" } }, + url: `https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/${firstNumber + index}`, + })); +} + +function pullRequests(count: number, firstNumber: number): string { + return JSON.stringify(pullRequestRows(count, firstNumber)); +} + +/** The arguments of the nth az invocation. */ +function argsOfCall(index: number): ReadonlyArray { + const call = mockedExecute.mock.calls[index]; + assert.isDefined(call); + return call[0].args; +} + +/** The output ceiling the nth az invocation asked for, if it asked for one at all. */ +function maxOutputBytesOfCall(index: number) { + const call = mockedExecute.mock.calls[index]; + assert.isDefined(call); + return call[0].maxOutputBytes; +} + +/** A page of change entries the size Azure really answers with, url and object ids and all. */ +function changeEntries(count: number): ReadonlyArray> { + const commit = "c".repeat(40); + return Array.from({ length: count }, (_, index) => { + const path = `/apps/server/src/generated/module-${index}/persisted-projection-${index}.ts`; + return { + changeType: "edit", + item: { + path, + objectId: "a".repeat(40), + originalObjectId: "b".repeat(40), + commitId: commit, + gitObjectType: "blob", + url: `https://dev.azure.com/acme/platform/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/items${path}?versionType=Commit&version=${commit}`, + }, + }; + }); +} + +/** An Azure identity, which rides along with every comment and every push Azure answers with. */ +function identity(name: string) { + const id = "6f9c9b7f-0000-0000-0000-000000000000"; + return { + displayName: name, + id, + uniqueName: `${name.toLowerCase().replace(/ /g, ".")}@acme.com`, + descriptor: `aad.${"z".repeat(52)}`, + imageUrl: `https://dev.azure.com/acme/_api/_common/identityImage?id=${id}`, + url: `https://spsprodweu1.vssps.visualstudio.com/A${id}/_apis/Identities/${id}`, + _links: { + avatar: { + href: `https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.${"z".repeat(52)}`, + }, + }, + }; +} + +/** A review's threads the shape Azure answers with, system threads and identities and all. */ +function threadRows(count: number): ReadonlyArray> { + return Array.from({ length: count }, (_, index) => ({ + id: index + 1, + publishedDate: "2026-07-02T00:00:00Z", + lastUpdatedDate: "2026-07-02T00:00:00Z", + status: "active", + threadContext: { filePath: `/apps/server/src/generated/module-${index}.ts` }, + identities: { 1: identity("Reviewer One") }, + isDeleted: false, + comments: [ + { + id: 1, + parentCommentId: 0, + author: identity("Reviewer One"), + content: `Comment ${index}: ${"this needs another look. ".repeat(20)}`, + publishedDate: "2026-07-02T00:00:00Z", + lastUpdatedDate: "2026-07-02T00:00:00Z", + commentType: "text", + usersLiked: [], + }, + ], + })); +} + +/** A review's iterations the shape Azure answers with, one per push. */ +function iterationRows(count: number): ReadonlyArray> { + return Array.from({ length: count }, (_, index) => ({ + id: index + 1, + description: `Pushed ${index} commits`, + author: identity("Author One"), + createdDate: "2026-07-02T00:00:00Z", + updatedDate: "2026-07-02T00:00:00Z", + sourceRefCommit: { commitId: `${index}`.padStart(40, "a") }, + targetRefCommit: { commitId: "b".repeat(40) }, + commonRefCommit: { commitId: "c".repeat(40) }, + hasMultipleCommits: true, + reason: "push", + push: { pushId: index + 1, date: "2026-07-02T00:00:00Z", pushedBy: identity("Author One") }, + })); +} + +afterEach(() => { + mockedExecute.mockReset(); +}); + +layer("AzureDevOpsPullRequestCli.layer", (it) => { + it.effect("asks for one row more than the page, to probe for a next page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 3); + assert.isFalse(batch.truncated); + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "list", + "--detect", + "true", + "--repository", + "web", + "--status", + "active", + "--include-links", + "--top", + "11", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("reads an Azure pull request page larger than the VCS default output limit", () => + Effect.gen(function* () { + const rows = pullRequestRows(100, 1).map((row) => ({ + ...row, + description: "x".repeat(10_000), + })); + const response = JSON.stringify(rows); + expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000); + + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "merged", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 99, + }); + + assert.strictEqual(batch.items.length, 99); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + const page = yield* provider.listChangeRequests({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + query: "page", + }); + + // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at + // all. The rows come back as they would have without a search, for the caller to narrow; + // nothing of the search reaches the command, where it could only mean the wrong thing. + assert.strictEqual(page.items.length, 3); + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "list", + "--detect", + "true", + "--repository", + "web", + "--status", + "active", + "--include-links", + "--top", + "11", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("steps over what it has already handed over, which is all Azure can be told", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + // The instant is the same cursor every other host reads; Azure has no filter for it and + // takes the count instead. + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 20 }, + }); + + const args = argsOfCall(0); + expect(args).toContain("--skip"); + assert.strictEqual(args[args.indexOf("--skip") + 1], "20"); + expect(args).not.toContain("2026-07-02T00:00:00Z"); + }), + ); + + it.effect("reports truncation from the extra row", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 10); + assert.isTrue(batch.truncated); + assert.strictEqual(batch.cursorAdvance, 10); + }), + ); + + it.effect("advances by malformed raw rows and keeps reading until the page is full", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify([ + { pullRequestId: "malformed" }, + pullRequestRows(1, 1)[0], + { pullRequestId: "also malformed" }, + ]), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(pullRequests(2, 2)))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 2, + }); + + expect(batch.items.map((item) => item.number)).toEqual([1, 2]); + assert.isTrue(batch.truncated); + // Three raw rows from the first request and one from the second produced this page. + assert.strictEqual(batch.cursorAdvance, 4); + const secondArgs = argsOfCall(1); + assert.strictEqual(secondArgs[secondArgs.indexOf("--skip") + 1], "3"); + assert.strictEqual(secondArgs[secondArgs.indexOf("--top") + 1], "2"); + }), + ); + + it.effect("narrows to the author on the authored tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "closed", + involvement: "authored", + viewer: "bilal@acme.dev", + limit: 10, + }); + + expect(argsOfCall(0)).toContain("--creator"); + expect(argsOfCall(0)).toContain("bilal@acme.dev"); + // Azure calls a closed pull request abandoned. + expect(argsOfCall(0)).toContain("abandoned"); + }), + ); + + it.effect("asks Azure for every status on the All tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "all", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + }); + + expect(argsOfCall(0)).toContain("--status"); + expect(argsOfCall(0)).toContain("all"); + }), + ); + + it.effect("narrows to the reviewer on the reviewing tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "reviewing", + viewer: "bilal@acme.dev", + limit: 10, + }); + + expect(argsOfCall(0)).toContain("--reviewer"); + }), + ); + + it.effect("reads the signed-in account, which az reports as a bare value", () => + Effect.gen(function* () { + // `--query user` unwraps the object, so the wrapper has to put it back. + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(JSON.stringify({ name: "bilal@acme.dev", type: "user" }))), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const viewer = yield* cli.getViewer({ cwd: "/w" }); + + assert.strictEqual(viewer, "bilal@acme.dev"); + expect(argsOfCall(0)).toEqual([ + "account", + "show", + "--query", + "user", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("fails when nobody is signed in", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); + + assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError"); + }), + ); + + it.effect("completes a pull request to merge it, squashing only when asked", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + number: 42, + action: "merge", + mergeMethod: "squash", + }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "42", + "--status", + "completed", + "--squash", + "true", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("stores the squash choice with an auto-completion, as a merge now does", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + number: 42, + action: "enable-auto-merge", + mergeMethod: "squash", + }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "42", + "--auto-complete", + "true", + "--squash", + "true", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect.each([ + { action: "enable-auto-merge", expected: ["--auto-complete", "true"] }, + { action: "disable-auto-merge", expected: ["--auto-complete", "false"] }, + { action: "draft", expected: ["--draft", "true"] }, + { action: "ready", expected: ["--draft", "false"] }, + { action: "close", expected: ["--status", "abandoned"] }, + { action: "reopen", expected: ["--status", "active"] }, + ] as const)("moves a pull request with $action", ({ action, expected }) => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.runPullRequestAction({ cwd: "/w", number: 42, action }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "42", + ...expected, + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect.each([ + { name: "a title", rewrite: { title: "Add the page" }, expected: ["--title=Add the page"] }, + { + name: "a description", + rewrite: { body: "Why the page changed" }, + expected: ["--description=Why the page changed"], + }, + { + name: "both", + rewrite: { title: "Add the page", body: "Why the page changed" }, + expected: ["--title=Add the page", "--description=Why the page changed"], + }, + ] as const)("rewrites $name, sending nothing it was not given", ({ rewrite, expected }) => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.updatePullRequest({ cwd: "/w", number: 42, ...rewrite }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "42", + ...expected, + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("sends a description that starts with a dash as one value, not as a flag", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.updatePullRequest({ + cwd: "/w", + number: 42, + body: "- rewrote the page\n- kept the rest", + }); + + // One argument, so the leading dash of an ordinary bullet list never reaches az as a flag, + // and the whole text stays together where `--description` would otherwise take several. + expect(argsOfCall(0)).toContain("--description=- rewrote the page\n- kept the rest"); + }), + ); + + it.effect("rewrites through the provider, which says it takes one", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + // False for a remark because nothing here can post one, so there is none to rewrite. + expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: false }); + assert.isDefined(provider.updateChangeRequest); + yield* provider.updateChangeRequest({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + title: "Add the page", + }); + + expect(argsOfCall(0)).toContain("--title=Add the page"); + expect(argsOfCall(0)).not.toContain("--description"); + }), + ); + + it.effect("names the head's blob as what a cleared file was cleared at", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + { + id: 2, + sourceRefCommit: { commitId: "c".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + changeEntries: [ + { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, + { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, + ], + }), + ), + ), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + // Kept here rather than on Azure: its own record of what a reader has read is behind an + // undocumented endpoint, so the marks belong to this environment and need a revision of + // their own to tell a re-push from a file still as it was read. + assert.strictEqual(provider.capabilities.viewedFiles, "environment"); + assert.isDefined(provider.getFileRevisions); + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["README.md"], + }); + + // The latest push, since an iteration's changes are reported against the merge base rather + // than against the push before it. + expect(argsOfCall(2)).toContain("iterationId=2"); + // Only what was asked for. DEMO.md changed too, and nobody has marked it. + expect([...answer.revisions]).toEqual([["README.md", "8f80"]]); + }), + ); + + it.effect("reads where a pull request lives once, however often it is asked about", () => + Effect.gen(function* () { + // A pull request cannot move repositories, and the marks would otherwise pay for a whole + // pull request read every time they checked whether a file had been pushed to. + const pullRequest = Effect.succeed( + output( + JSON.stringify({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ); + const iterations = () => + Effect.succeed( + output( + JSON.stringify({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ); + const changes = () => + Effect.succeed( + output( + JSON.stringify({ + changeEntries: [ + { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, + ], + }), + ), + ); + mockedExecute + .mockReturnValueOnce(pullRequest) + .mockReturnValueOnce(iterations()) + .mockReturnValueOnce(changes()) + .mockReturnValueOnce(iterations()) + .mockReturnValueOnce(changes()); + const provider = yield* AzureDevOpsPullRequestProvider.make; + const read = provider.getFileRevisions; + assert.isDefined(read); + const ask = () => + read({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["README.md"], + }); + + yield* ask(); + const again = yield* ask(); + + assert.strictEqual(mockedExecute.mock.calls.length, 5); + // The second read goes straight to the pushes, and still answers with the head's blob. + expect(argsOfCall(3)).toContain("pullRequestIterations"); + expect([...again.revisions]).toEqual([["README.md", "8f80"]]); + }), + ); + + it.effect( + "answers for a marked file the pull request no longer changes as the empty version", + () => + Effect.gen(function* () { + // Which is what was stored for it when it was ticked with nothing on the head, so a file + // the pull request deletes is cleared once and stays cleared. + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output('{"changeEntries":[]}'))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["GONE.md"], + }); + + expect([...answer.revisions]).toEqual([["GONE.md", ""]]); + }), + ); + + it.effect("says nothing about the files past the end of a change it gave up following", () => + Effect.gen(function* () { + // Every page is an `az` process of its own, so a change past the ceiling stops being + // followed. A path nobody looked at must not be answered for as deleted. + const entries = (from: number, count: number) => + Array.from({ length: count }, (_, index) => ({ + changeType: "edit", + item: { path: `/src/f${from + index}.ts`, objectId: `blob-${from + index}` }, + })); + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed(output(json({ changeEntries: entries(0, 5_000), nextSkip: 5_000 }))), + ) + .mockReturnValueOnce( + Effect.succeed(output(json({ changeEntries: entries(5_000, 5_000), nextSkip: 10_000 }))), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["src/f1.ts", "src/f5001.ts", "src/past-the-cut.ts"], + }); + + // The second page picks up where the first said it ended. + expect(argsOfCall(3)).toContain("$skip=5000"); + expect([...answer.revisions]).toEqual([ + ["src/f1.ts", "blob-1"], + ["src/f5001.ts", "blob-5001"], + ]); + }), + ); + + it.effect("stops following pages by what Azure counts, not by what survives decoding", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + // Nothing a review can show, so every page decodes to nothing at all and a ceiling counted + // in files would never be reached however long the walk went on. + .mockImplementation((command) => { + const skip = command.args.find((arg) => arg.startsWith("$skip=")); + const from = Number(skip?.slice("$skip=".length) ?? 0); + return Effect.succeed( + output( + json({ + changeEntries: [{ changeType: "add", item: { path: "/src", isFolder: true } }], + nextSkip: from + 2_000, + }), + ), + ); + }); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["src/page.ts"], + }); + + // The pull request, its pushes, and five pages: the walk gives up on Azure's own offset + // rather than spending an `az` process a page for as long as Azure keeps paging. + assert.strictEqual(mockedExecute.mock.calls.length, 7); + // And it read part of a change, so it says nothing about the file it never saw. + assert.strictEqual(answer.revisions.size, 0); + }), + ); + + it.effect("leaves one file the host would not hand over listed without its hunks", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))) + .mockReturnValueOnce(Effect.succeed(output(json(oneIteration)))) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [ + { changeType: "add", item: { path: "/huge.bin", objectId: "8f80" } }, + { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.fail( + new AzureDevOpsCli.AzureDevOpsCommandFailedError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 1, + cause: "the blob is past what the route will carry", + }), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(json({ content: "hello\n" })))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + const slice = yield* provider.getDiff({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + }); + + assert.isTrue(slice.truncated); + expect(slice.patch).toContain("diff --git a/huge.bin b/huge.bin"); + // And the file behind it still renders, which is the point of giving up on one file. + expect(slice.patch).toContain("+hello"); + }), + ); + + it.effect("fails the whole read when it is the connection that would not answer", () => + Effect.gen(function* () { + // A rate limit is not this file's problem, and answering with a change full of files listed + // without their hunks would read as a change nobody can see rather than as a host to wait + // for. + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))) + .mockReturnValueOnce(Effect.succeed(output(json(oneIteration)))) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [ + { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.fail( + new AzureDevOpsCli.AzureDevOpsCliRateLimitError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 1, + cause: "429", + }), + ), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + const error = yield* Effect.flip( + provider.getDiff({ cwd: "/w", repository: "web", host: "dev.azure.com", number: 42 }), + ); + + assert.strictEqual(error.reason, "rate-limited"); + }), + ); + + it.effect("takes Azure's own word on a file it will not spell out", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [ + { changeType: "add", item: { path: "/logo.png", objectId: "8f80" } }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output(json({ content: "iVBORw0KGgo=", contentMetadata: { isBinary: true } })), + ), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + const slice = yield* provider.getDiff({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + }); + + // Azure leaves the metadata out unless it is asked for, and without it every file reads as + // text however it was stored. + expect(argsOfCall(3)).toContain("includeContentMetadata=true"); + expect(slice.patch).toContain("Binary files a/logo.png and b/logo.png differ"); + assert.isTrue(slice.truncated); + }), + ); + + it.effect("stops following pages when one of them does not move the cursor on", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [{ changeType: "edit", item: { path: "/a.ts", objectId: "8f80" } }], + nextSkip: 2_000, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [{ changeType: "edit", item: { path: "/b.ts", objectId: "0ca4" } }], + nextSkip: 2_000, + }), + ), + ), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["a.ts", "b.ts", "unlisted.ts"], + }); + + // Four reads and no more: a page pointing at where it already is would be read forever. + assert.strictEqual(mockedExecute.mock.calls.length, 4); + // And what was read is not the whole change, so the file nobody listed is left unanswered + // rather than reported as gone from the change request. + expect([...answer.revisions]).toEqual([ + ["a.ts", "8f80"], + ["b.ts", "0ca4"], + ]); + }), + ); + + it.effect("asks Azure nothing when no file has been ticked off", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output('{"changeEntries":[]}'))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: [], + }); + + expect(answer.revisions.size).toBe(0); + // Nothing was marked, so Azure was not asked at all. + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("reads the conversation through the REST API, pinned to a version", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + value: [ + { + id: 1, + comments: [ + { id: 1, content: "Looks good.", publishedDate: "2026-07-02T00:00:00Z" }, + ], + }, + ], + }), + ), + ), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const comments = yield* cli.listThreads({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + number: 42, + }); + + assert.strictEqual(comments.length, 1); + // `az devops invoke` rather than `az rest`: it signs in the way the azure-devops extension + // does, and `az rest` mints its own token against whichever tenant `az` defaults to. + expect(argsOfCall(0)).toContain("invoke"); + expect(argsOfCall(0)).toContain("pullRequestThreads"); + expect(argsOfCall(0)).toContain("project=platform"); + expect(argsOfCall(0)).toContain("repositoryId=web"); + expect(argsOfCall(0)).toContain("pullRequestId=42"); + // A review's threads grow with how long it ran, and this route does not page, so the read + // asks for more than the process default rather than taking whatever it is given. + expect(maxOutputBytesOfCall(0)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + }), + ); + + it.effect("reads a long review's threads, which are past the default output limit", () => + Effect.gen(function* () { + const response = json({ value: threadRows(800) }); + // Azure opens a thread per vote and per ref update beside the ones people wrote, and every + // comment carries a full identity, so a review argued over for weeks outgrows the default. + expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const comments = yield* cli.listThreads({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + number: 42, + }); + + assert.strictEqual(comments.length, 800); + }), + ); + + it.effect("reads a long review's iterations, which are past the default output limit", () => + Effect.gen(function* () { + const response = json({ value: iterationRows(1_200) }); + // This route does not page, so the whole history arrives at once. Cut at the default it is + // JSON stopping mid-string, and every diff and file revision read on this host fails with + // it, since each of them starts by asking which iteration is the latest. + expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const iterations = yield* cli.listIterations({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + number: 42, + }); + + assert.strictEqual(iterations.length, 1_200); + assert.strictEqual(iterations.at(-1)?.id, 1_200); + }), + ); + + it.effect("reads a full page of change entries, which is past the default output limit", () => + Effect.gen(function* () { + const response = json({ changeEntries: changeEntries(2_000) }); + // Azure's own maximum for this route, and every entry carries a path, a url and three + // object ids, so an ordinary page of a large change already outgrows the default. + expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const page = yield* cli.listIterationChanges({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + number: 42, + iterationId: 1, + }); + + // Cut at the default this would arrive as JSON stopping mid-string, and a perfectly + // ordinary page would be reported as a host answering with nonsense. + assert.strictEqual(page.changes.length, 2_000); + assert.isFalse(page.truncated); + }), + ); + + it.effect("reads a file whose JSON envelope is past the default output limit", () => + Effect.gen(function* () { + // Under a megabyte as bytes on the host, so this is a file the other hosts hand over. + const file = "const value = 1;\n".repeat(57_000); + const response = json({ content: file }); + expect(Buffer.byteLength(file)).toBeLessThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + // And past it once Azure wraps it, because there is no route here that serves the bytes. + expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const item = yield* cli.readItemContent({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + path: "src/generated/schema.ts", + commit: "a".repeat(40), + }); + + assert.strictEqual(item.contents, file); + assert.isFalse(item.isBinary); + }), + ); + + it.effect("asks for a file by Azure's own spelling of its path", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(json({ content: "const a = 1;" })))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + // The path carried around here has had Azure's leading slash taken off so it matches the + // patch and the viewed mark. The items route is documented in Azure's spelling, so it goes + // back on the way out rather than being sent as the shorter name. + yield* cli.readItemContent({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + path: "src/app.ts", + commit: "a".repeat(40), + }); + + expect(argsOfCall(0)).toContain("path=/src/app.ts"); + }), + ); + + it.effect("reports a pull request it cannot place as its own outcome", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // Well-formed, but with nothing to build a link from: not a decode failure. + JSON.stringify({ + pullRequestId: 42, + title: "Add the page", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + }), + ), + ), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 })); + + assert.strictEqual(error._tag, "AzureDevOpsPullRequestIncompleteError"); + }), + ); + + it.effect("fails the read when az returns something unreadable", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 })); + + assert.strictEqual(error._tag, "AzureDevOpsPullRequestReadError"); + }), + ); + + it.effect("adds reviewers with the one command Azure has for it", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.setPullRequestReviewers({ + cwd: "/w", + number: 42, + reviewers: ["octocat@acme.test", "hubot@acme.test"], + requested: true, + }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "reviewer", + "add", + "--detect", + "true", + "--id", + "42", + "--reviewers", + "octocat@acme.test", + "hubot@acme.test", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("takes a reviewer off the pull request with the same command's counterpart", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.setPullRequestReviewers({ + cwd: "/w", + number: 42, + reviewers: ["octocat@acme.test"], + requested: false, + }); + + expect(argsOfCall(0)).toContain("remove"); + }), + ); + + it.effect("refuses a reviewer az would read as a flag, before running anything", () => + Effect.gen(function* () { + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip( + cli.setPullRequestReviewers({ + cwd: "/w", + number: 42, + reviewers: ["--query"], + requested: true, + }), + ); + + assert.strictEqual(error._tag, "AzureDevOpsReviewerNameError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); +}); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts new file mode 100644 index 0000000000000000000000000000000000000000..70fe78b34bf9eb5e1dc39e31d5639e5f3e51b5e3 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts @@ -0,0 +1,744 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestAction, + PullRequestComment, + PullRequestInvolvement, + PullRequestListState, + PullRequestMergeMethod, +} from "@t3tools/contracts"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import { + decodeItemContentJson, + decodeIterationChangesJson, + decodeIterationsJson, + decodePullRequestJson, + decodePullRequestListJson, + decodeThreadsJson, + decodeViewerJson, + type AzureDevOpsChangeEntry, + type AzureDevOpsItemContent, + type AzureDevOpsIteration, + type AzureDevOpsPullRequest, + type AzureDevOpsRepositoryLocation, +} from "./azureDevOpsPullRequestJson.ts"; +import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +export class AzureDevOpsPullRequestReadError extends Schema.TaggedError()( + "AzureDevOpsPullRequestReadError", + { + command: Schema.Literal("az"), + cwd: Schema.String, + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `Azure CLI returned an unreadable ${this.operation} response.`; + } + + override get message(): string { + return `Azure CLI failed in ${this.operation}: ${this.detail}`; + } +} + +/** Not a decode failure: az answered, the account it answered for just has no name. */ +export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError()( + "AzureDevOpsViewerUnavailableError", + { + command: Schema.Literal("az"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "Azure CLI returned no account for the current sign-in."; + } + + override get message(): string { + return `Azure CLI failed in getViewer: ${this.detail}`; + } +} + +/** + * Not a decode failure either: az answered with a well-formed pull request that simply carries + * no branch or link, which is a response this cannot place rather than one it cannot read. + */ +export class AzureDevOpsPullRequestIncompleteError extends Schema.TaggedError()( + "AzureDevOpsPullRequestIncompleteError", + { + command: Schema.Literal("az"), + cwd: Schema.String, + number: Schema.Int, + }, +) { + get detail(): string { + return "Azure DevOps returned no branch or link for the pull request."; + } + + override get message(): string { + return `Azure CLI failed in getPullRequest: ${this.detail}`; + } +} + +/** + * Not a decode failure: the reader named a reviewer `az` would read as a flag of its own. The + * reviewers travel as argv rather than in a request body — `az repos pr reviewer` takes them no + * other way — so anything that could leave the value position is refused rather than sent. + */ +export class AzureDevOpsReviewerNameError extends Schema.TaggedError()( + "AzureDevOpsReviewerNameError", + { + command: Schema.Literal("az"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "A reviewer is named by an email address or an identity id."; + } + + override get message(): string { + return `Azure CLI failed in setPullRequestReviewers: ${this.detail}`; + } +} + +export type AzureDevOpsPullRequestCliError = + | AzureDevOpsCli.AzureDevOpsCliError + | AzureDevOpsPullRequestReadError + | AzureDevOpsPullRequestIncompleteError + | AzureDevOpsReviewerNameError + | AzureDevOpsViewerUnavailableError; + +/** The version every REST call below is pinned to, so a new default cannot reshape a response. */ +const REST_API_VERSION = "7.1"; +const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +/** + * A full page of change entries is two thousand files, each carrying its path, its url and + * several object ids, which is past the megabyte a read is given by default. Output cut at that + * ceiling arrives here as JSON that will not parse, so a change large enough to be paged would + * report itself as a host returning nonsense rather than as the ordinary page it is. + */ +const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +/** + * Four times the megabyte of file the other hosts hand over, because Azure has no route that + * serves the bytes themselves: the file arrives inside a JSON envelope, escaped if it is text and + * base64 if it is not, and both are larger than the file they carry. + */ +const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; +/** + * What a review's own history is given. Neither route pages, so each answers with the whole of it + * at once and grows with how long the review ran rather than with how large the change is. Cut at + * the default, both arrive as JSON that stops mid-string, and a long review would report its host + * as answering with nonsense. + */ +const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; + +/** Azure's own ceiling for one page of an iteration's changes. */ +const CHANGE_ENTRIES_PER_PAGE = 2000; + +/** + * Where following the pages stops, counted in the entries Azure was asked to skip rather than in + * the files that survived decoding: a page can be entirely folders and other entries a review has + * nothing to show for, and bounding on what was kept would follow such a change forever. Every + * page is an `az` process of its own, so the read gives up and reports itself incomplete rather + * than presenting five pages as the whole change. + */ +const MAX_CHANGE_ENTRIES = 10_000; + +/** What an iteration changed, and whether following its pages reached the end of it. */ +export interface AzureDevOpsIterationChanges { + readonly changes: ReadonlyArray; + readonly truncated: boolean; +} + +export class AzureDevOpsPullRequestCli extends Context.Service< + AzureDevOpsPullRequestCli, + { + readonly getViewer: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + /** + * Where to carry on from. Azure has no date filter for a pull request listing, so the only + * part of a cursor it can use is how many rows have already been handed over. + */ + readonly cursor?: ProviderListCursor | undefined; + }) => Effect.Effect< + { + readonly items: ReadonlyArray; + readonly truncated: boolean; + /** Raw Azure rows consumed to produce this page, including malformed rows. */ + readonly cursorAdvance: number; + }, + AzureDevOpsPullRequestCliError + >; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect; + + /** Threads are not reachable through `az repos pr`, so they come from the REST API. */ + readonly listThreads: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + + /** + * The pushes a pull request has had, oldest first. Azure hangs the changed files off an + * iteration rather than off the pull request, so reading a diff starts here. + */ + readonly listIterations: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + + /** + * What one iteration changed, against the merge base rather than against the previous push, + * which is the whole of the pull request rather than the latest slice of it. + */ + readonly listIterationChanges: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + readonly iterationId: number; + }) => Effect.Effect; + + /** + * One file's text at one commit. Azure has no diff route that carries content, so both sides + * of every changed file are read this way and the patch is made from them here. + */ + readonly readItemContent: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly path: string; + readonly commit: string; + }) => Effect.Effect; + + readonly runPullRequestAction: (input: { + readonly cwd: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }) => Effect.Effect; + + /** Rewrites the pull request's own words, through the same command that moves it. */ + readonly updatePullRequest: (input: { + readonly cwd: string; + readonly number: number; + readonly title?: string | undefined; + readonly body?: string | undefined; + }) => Effect.Effect; + + /** + * Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole + * of what Azure offers here: it adds and removes named identities, and has no counterpart that + * says who could be named. + */ + readonly setPullRequestReviewers: (input: { + readonly cwd: string; + readonly number: number; + readonly reviewers: ReadonlyArray; + readonly requested: boolean; + }) => Effect.Effect; + } +>()("@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli") {} + +function statusArgs(state: PullRequestListState): ReadonlyArray { + switch (state) { + case "open": + return ["--status", "active"]; + case "merged": + return ["--status", "completed"]; + case "closed": + return ["--status", "abandoned"]; + case "all": + return ["--status", "all"]; + } +} + +function involvementArgs(input: { + readonly involvement: PullRequestInvolvement; + readonly viewer: string; +}): ReadonlyArray { + switch (input.involvement) { + case "authored": + return ["--creator", input.viewer]; + case "reviewing": + return ["--reviewer", input.viewer]; + case "all": + return []; + } +} + +/** + * Azure moves a pull request by setting its state rather than by named commands: completing it + * is the merge, abandoning it is the close, and reactivating it is the reopen. Squashing is a + * completion option rather than a strategy of its own. + */ +function actionArgs( + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod | undefined, +): ReadonlyArray { + switch (action) { + case "merge": + return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"]; + // Auto-complete is Azure's own name for it: the pull request stays active and Azure completes + // it once its policies pass. The squash choice is stored with it, as it is for a merge now. + case "enable-auto-merge": + return [ + "--auto-complete", + "true", + ...(mergeMethod === undefined + ? [] + : ["--squash", mergeMethod === "squash" ? "true" : "false"]), + ]; + case "disable-auto-merge": + return ["--auto-complete", "false"]; + case "ready": + return ["--draft", "false"]; + case "draft": + return ["--draft", "true"]; + case "close": + return ["--status", "abandoned"]; + // Never reached: this host does not declare the action, so nothing offers it. + case "update-branch": + return []; + case "reopen": + return ["--status", "active"]; + // Never reached: this host does not declare the action, so the service refuses it first. + case "revert": + case "approve-workflows": + throw new Error(`Azure DevOps pull request action ${action} is unsupported`); + } +} + +/** + * A reviewer Azure could be given: an email address, a display name or an identity guid, and + * nothing that starts with a dash. The dash is the whole point — these are argv, and a value that + * looks like a flag stops being a value. + */ +function isReviewerName(value: string): boolean { + const name = value.trim(); + return name.length > 0 && !name.startsWith("-"); +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const azure = yield* AzureDevOpsCli.AzureDevOpsCli; + + // Every command resolves the organization, project and repository from the checkout, which is + // what the rest of the Azure wrapper does. The remote takes three shapes and only `az` knows + // how to read all of them. + const detectArgs = ["--detect", "true"] as const; + + const executeJson = (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly maxOutputBytes?: number; + }) => + azure.execute({ + cwd: input.cwd, + args: [...input.args, "--only-show-errors", "--output", "json"], + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + }); + + /** + * A REST route reached through `az devops invoke`, which addresses it by area, resource and + * route parameters rather than by URL. Used in place of `az rest` because it signs in the way + * the azure-devops extension does: `az rest` mints its own token against the tenant `az` + * defaults to, and an organisation in any other tenant answers that with a sign-in page, which + * arrives here as unreadable output rather than as a failure. + */ + const invoke = (input: { + readonly cwd: string; + readonly operation: string; + readonly resource: string; + readonly routeParameters: ReadonlyArray; + readonly queryParameters?: ReadonlyArray; + readonly maxOutputBytes?: number; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + executeJson({ + cwd: input.cwd, + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + args: [ + "devops", + "invoke", + ...detectArgs, + "--area", + "git", + "--resource", + input.resource, + "--api-version", + REST_API_VERSION, + "--route-parameters", + ...input.routeParameters, + ...(input.queryParameters === undefined + ? [] + : ["--query-parameters", ...input.queryParameters]), + ], + }).pipe( + Effect.flatMap((result) => { + const decoded = input.decode(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: input.operation, + cause: decoded.failure, + }), + ); + }), + ); + + /** + * Azure names its own items with a leading slash, which the repository paths carried around + * here have had taken off so they match the patch and the viewed mark. Put it back on the way + * out, because the items route is documented in Azure's own spelling. + */ + const toItemPath = (path: string) => (path.startsWith("/") ? path : `/${path}`); + + const repositoryRoute = (location: AzureDevOpsRepositoryLocation): ReadonlyArray => [ + `project=${location.project}`, + `repositoryId=${location.repository}`, + ]; + + const pullRequestRoute = (input: { + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + }): ReadonlyArray => [ + ...repositoryRoute(input.location), + `pullRequestId=${input.number}`, + ]; + + /** + * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and + * retain the raw count so the next public cursor skips every row this walk consumed. + */ + const listPullRequestPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly skip: number; + readonly cursorAdvance: number; + readonly items: ReadonlyArray; + }): Effect.Effect< + { + readonly items: ReadonlyArray; + readonly truncated: boolean; + readonly cursorAdvance: number; + }, + AzureDevOpsPullRequestCliError + > => { + const remaining = input.limit - input.items.length; + const top = remaining + 1; + return executeJson({ + cwd: input.cwd, + maxOutputBytes: PULL_REQUEST_LIST_MAX_OUTPUT_BYTES, + args: [ + "repos", + "pr", + "list", + ...detectArgs, + "--repository", + input.repository, + ...statusArgs(input.state), + ...involvementArgs(input), + // A web link per row, which is the only url that needs no assembling. + "--include-links", + ...(input.skip === 0 ? [] : ["--skip", String(input.skip)]), + "--top", + String(top), + ], + }).pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed({ + items: input.items, + truncated: false, + cursorAdvance: input.cursorAdvance, + }); + } + const decoded = decodePullRequestListJson(raw); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "listPullRequests", + cause: decoded.failure, + }), + ); + } + + const lastItemIndex = decoded.success.rawIndexes[remaining - 1]; + if (lastItemIndex !== undefined) { + const consumed = lastItemIndex + 1; + return Effect.succeed({ + items: [...input.items, ...decoded.success.items.slice(0, remaining)], + // A full raw response may have more rows even when malformed entries used the probe. + truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top, + cursorAdvance: input.cursorAdvance + consumed, + }); + } + + const items = [...input.items, ...decoded.success.items]; + if (decoded.success.rawCount < top) { + return Effect.succeed({ + items, + truncated: false, + cursorAdvance: input.cursorAdvance + decoded.success.rawCount, + }); + } + return listPullRequestPage({ + ...input, + skip: input.skip + decoded.success.rawCount, + cursorAdvance: input.cursorAdvance + decoded.success.rawCount, + items, + }); + }), + ); + }; + + return AzureDevOpsPullRequestCli.of({ + getViewer: (input) => + executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe( + Effect.flatMap((result): Effect.Effect => { + // `--query user` narrows the payload to the account, so it is nested back under the + // key the decoder reads to keep one shape for the signed-in user. + const decoded = decodeViewerJson(`{"user":${result.stdout.trim() || "null"}}`); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "getViewer", + cause: decoded.failure, + }), + ); + } + return decoded.success === null + ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd })) + : Effect.succeed(decoded.success); + }), + ), + + listPullRequests: (input) => + listPullRequestPage({ + cwd: input.cwd, + repository: input.repository, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + // Azure counts rather than filters, so a slice carries on by stepping over every raw row + // the prior slice consumed. That is an offset into a list that can shift underneath it: + // a pull request opened between two slices moves everything down one, and the row on the + // seam is the one that pays for it. + skip: input.cursor?.delivered ?? 0, + cursorAdvance: 0, + items: [], + }), + + getPullRequest: (input) => + executeJson({ + cwd: input.cwd, + args: ["repos", "pr", "show", ...detectArgs, "--id", String(input.number)], + }).pipe( + Effect.flatMap( + (result): Effect.Effect => { + const decoded = decodePullRequestJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "getPullRequest", + cause: decoded.failure, + }), + ); + } + // Null means Azure answered with too little to place the pull request. Nothing + // failed underneath it, so it is its own outcome rather than a decode failure. + return decoded.success === null + ? Effect.fail( + new AzureDevOpsPullRequestIncompleteError({ + command: "az", + cwd: input.cwd, + number: input.number, + }), + ) + : Effect.succeed(decoded.success); + }, + ), + ), + + listThreads: (input) => + invoke({ + cwd: input.cwd, + operation: "listThreads", + resource: "pullRequestThreads", + routeParameters: pullRequestRoute(input), + maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, + decode: decodeThreadsJson, + }), + + listIterations: (input) => + invoke({ + cwd: input.cwd, + operation: "listIterations", + resource: "pullRequestIterations", + routeParameters: pullRequestRoute(input), + maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, + decode: decodeIterationsJson, + }), + + listIterationChanges: (input) => { + const page = (skip: number) => + invoke({ + cwd: input.cwd, + operation: "listIterationChanges", + resource: "pullRequestIterationChanges", + routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`], + // Azure pages this route at 1000 entries by default; this is its own maximum per page, + // and it names where the next page starts rather than answering with the whole change. + queryParameters: [`$top=${CHANGE_ENTRIES_PER_PAGE}`, `$skip=${skip}`], + maxOutputBytes: CHANGE_ENTRIES_MAX_OUTPUT_BYTES, + decode: decodeIterationChangesJson, + }); + const from = ( + skip: number, + collected: ReadonlyArray, + ): Effect.Effect => + page(skip).pipe( + Effect.flatMap((answer) => { + const changes = [...collected, ...answer.changes]; + // The last page names no page after it, and only that is the end of the change. + if (answer.nextSkip === null) return Effect.succeed({ changes, truncated: false }); + // A page pointing at where the read already is would be followed forever, and one + // past the ceiling is a change nobody will read to the end of. Both stop the read + // and both say so, rather than presenting part of a change as the whole of it. + return answer.nextSkip <= skip || answer.nextSkip >= MAX_CHANGE_ENTRIES + ? Effect.succeed({ changes, truncated: true }) + : from(answer.nextSkip, changes); + }), + ); + return from(0, []); + }, + + readItemContent: (input) => + invoke({ + cwd: input.cwd, + operation: "readItemContent", + resource: "items", + routeParameters: repositoryRoute(input.location), + queryParameters: [ + `path=${toItemPath(input.path)}`, + "versionDescriptor.versionType=commit", + `versionDescriptor.version=${input.commit}`, + "includeContent=true", + // Azure leaves `contentMetadata` out unless this is asked for, and with it goes its own + // word on whether the file is binary, which is the only reliable one, since a binary + // file arrives encoded rather than as the bytes it is on the host. + "includeContentMetadata=true", + // Without this Azure answers with the file's own bytes rather than with a JSON + // envelope, and `az devops invoke` refuses anything it cannot parse as JSON. + "$format=json", + ], + maxOutputBytes: ITEM_CONTENT_MAX_OUTPUT_BYTES, + decode: decodeItemContentJson, + }), + + setPullRequestReviewers: (input) => + input.reviewers.some((reviewer) => !isReviewerName(reviewer)) + ? Effect.fail(new AzureDevOpsReviewerNameError({ command: "az", cwd: input.cwd })) + : azure + .execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "reviewer", + input.requested ? "add" : "remove", + ...detectArgs, + "--id", + String(input.number), + // One `--reviewers` takes them all, because az reads the flag as a list and a + // second one would replace the first rather than add to it. + "--reviewers", + ...input.reviewers, + "--only-show-errors", + "--output", + "json", + ], + }) + .pipe(Effect.asVoid), + + runPullRequestAction: (input) => + azure + .execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "update", + ...detectArgs, + "--id", + String(input.number), + ...actionArgs(input.action, input.mergeMethod), + "--only-show-errors", + "--output", + "json", + ], + }) + .pipe(Effect.asVoid), + + updatePullRequest: (input) => + azure + .execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "update", + ...detectArgs, + "--id", + String(input.number), + // One argument rather than a flag and a value beside it: a description usually opens + // with a bullet, and az reads a dash in the next argv slot as a flag of its own. + // `--description` also takes several strings, and this keeps the whole text as one. + ...(input.title === undefined ? [] : [`--title=${input.title}`]), + ...(input.body === undefined ? [] : [`--description=${input.body}`]), + "--only-show-errors", + "--output", + "json", + ], + }) + .pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(AzureDevOpsPullRequestCli, make); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b96984cf76b2f75ef5b9d394c963355a8f0f5f1 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts @@ -0,0 +1,420 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import { + LOCATION_CACHE_CAPACITY, + make, + MAX_DIFF_SPAWNS, +} from "./AzureDevOpsPullRequestProvider.ts"; +import { + byteLength, + MAX_DIFF_SLICE_BYTES, + MAX_DIFF_SLICE_FILES, + MAX_FILE_DIFF_EDITS, + parseAzureDevOpsDiffCursor, +} from "./azureDevOpsDiff.ts"; +import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; + +const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" }; + +const PULL_REQUEST = { + number: 7, + title: "Pull request 7", + url: "https://dev.azure.com/acme/web/_git/web/pullrequest/7", + author: null, + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + closedAt: null, + body: "", + reviewRequestLogins: [], + reviewers: [], + location: { project: "acme", repository: "web" }, + autoMergeEnabled: false, +}; + +function change( + path: string, + changeKind: AzureDevOpsChangeEntry["changeKind"] = "change", +): AzureDevOpsChangeEntry { + return { path, oldPath: path, changeKind, objectId: "8f80", originalObjectId: "0ca4" }; +} + +/** + * A file whose two sides share no line, so its patch is `lines` removals and `lines` additions of + * `width` characters each: the diff work and the patch bytes one file costs are both dialled from + * here, and they are what a slice is bounded by. Each line carries its own number and a prefix + * as well, so `width` is a floor on how long a line is rather than its byte count. + */ +function side(prefix: string, lines: number, width: number): string { + const pad = "z".repeat(width); + return `${Array.from({ length: lines }, (_, line) => `${prefix} ${line} ${pad}`).join("\n")}\n`; +} + +const readSlice = (input: { + readonly paths: ReadonlyArray; + readonly lines: number; + readonly width: number; + /** Paths the host refuses, which is one file's problem rather than the read's. */ + readonly refused?: ReadonlyArray; + /** Paths the change creates, so the host has nothing to hand back for their old side. */ + readonly created?: ReadonlyArray; + readonly cursor?: string; +}) => + Effect.gen(function* () { + const reads: string[] = []; + const refused = new Set(input.refused ?? []); + const created = new Set(input.created ?? []); + let inFlight = 0; + let peakInFlight = 0; + + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ + changes: input.paths.map((path) => + change(path, created.has(path) ? "new" : "change"), + ), + truncated: false, + }), + readItemContent: (item) => + Effect.gen(function* () { + reads.push(item.path); + inFlight += 1; + peakInFlight = Math.max(peakInFlight, inFlight); + // Every read suspends before it answers, as a subprocess would, so what runs at + // once is the scheduler's answer rather than an artefact of resolving inline. The + // later a file is listed the sooner it answers, to leave the assembled patch + // nothing but the change list to take its order from. + const answersAfter = input.paths.length - input.paths.indexOf(item.path); + for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow; + inFlight -= 1; + if (refused.has(item.path)) { + return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({ + command: "az", + cwd: "/w", + operation: "readItemContent", + cause: "refused", + }); + } + const isOldSide = item.commit !== ITERATION.headCommit; + if (isOldSide && created.has(item.path)) return { contents: "", isBinary: false }; + return { + contents: side(isOldSide ? "old" : "new", input.lines, input.width), + isBinary: false, + }; + }), + }), + ), + ); + + const slice = yield* provider.getDiff({ + cwd: "/w", + repository: "acme/web", + host: "dev.azure.com", + number: 7, + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + }); + return { slice, reads, peakInFlight }; + }); + +/** Which files the patch carries a section for, in the order it carries them. */ +function patchedPaths(patch: string): ReadonlyArray { + return [...patch.matchAll(/^diff --git a\/(?\S+) b\//gmu)].map( + (match) => match.groups?.path ?? "", + ); +} + +describe("getChangeRequestSummary", () => { + it.effect("costs the one pull request read, not the iterations changedFiles needs", () => + Effect.gen(function* () { + let pullRequestReads = 0; + + const provider = yield* make.pipe( + Effect.provide( + // listIterations and listIterationChanges are left unimplemented here, so a summary + // that reached for either would die with UnimplementedError instead of this passing. + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => { + pullRequestReads += 1; + return Effect.succeed(PULL_REQUEST); + }, + }), + ), + ); + + const readSummary = provider.getChangeRequestSummary; + if (readSummary === undefined) return yield* Effect.die("summary read was not implemented"); + const summary = yield* readSummary({ + cwd: "/w", + repository: "acme/web", + host: "dev.azure.com", + number: 7, + }); + + expect(pullRequestReads).toBe(1); + expect(summary.title).toBe(PULL_REQUEST.title); + expect(summary.changedFiles).toBeUndefined(); + }), + ); +}); + +describe("getChangeRequest", () => { + it.effect("still reports the file count the detail panel needs", () => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ changes: [change("a.ts"), change("b.ts")], truncated: false }), + }), + ), + ); + + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "dev.azure.com", + number: 7, + }); + + expect(detail.changedFiles).toBe(2); + }), + ); +}); + +describe("getDiff reads", () => { + it.effect("holds every reader together to one request's worth of processes", () => + Effect.gen(function* () { + // The fan-out inside a read bounds one Code tab. Two people opening two Azure reviews at + // once are two reads, so without a ceiling above them both they are twice a request's + // processes, each paying a Python interpreter's start-up on the same machine. + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; + let inFlight = 0; + let peakInFlight = 0; + + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ changes: paths.map((path) => change(path)), truncated: false }), + readItemContent: () => + Effect.gen(function* () { + inFlight += 1; + peakInFlight = Math.max(peakInFlight, inFlight); + // Suspends before answering, as a subprocess would, so what is out at once is the + // scheduler's answer rather than an artefact of resolving inline. + yield* Effect.yieldNow; + yield* Effect.yieldNow; + inFlight -= 1; + return { contents: side("new", 2, 4), isBinary: false }; + }), + }), + ), + ); + + const readDiff = (number: number) => + provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number }); + + yield* Effect.all([readDiff(7), readDiff(8)], { concurrency: 2 }); + + expect(peakInFlight).toBeLessThanOrEqual(MAX_DIFF_SPAWNS); + }), + ); + + it.effect("leaves a run of files it could not diff at all for the next slice", () => + Effect.gen(function* () { + // A binary, oversize, purely renamed or unreadable entry is a header apiece, a couple of + // hundred bytes with no edits in it, so a change made of them spends neither budget: the + // byte one would take well over a thousand of them, and the edit one never fills at all. + // A listing holds up to ten thousand entries and each still costs its two reads, which is + // what a file count is here to bound. + const paths = Array.from({ length: MAX_DIFF_SLICE_FILES + 20 }, (_, at) => `gen/a${at}.bin`); + const read = yield* readSlice({ paths, lines: 2, width: 4, refused: paths }); + + expect(patchedPaths(read.slice.patch)).toHaveLength(MAX_DIFF_SLICE_FILES); + // Well inside the byte budget, so the file count is what stopped it rather than either of + // the budgets that were already there. + expect(byteLength(read.slice.patch)).toBeLessThan(MAX_DIFF_SLICE_BYTES); + expect(parseAzureDevOpsDiffCursor(read.slice.nextCursor)?.fileIndex).toBe( + MAX_DIFF_SLICE_FILES, + ); + }), + ); + + it.effect("asks for both sides of several files at once rather than one side at a time", () => + Effect.gen(function* () { + // Each file is two `az` invocations, each paying a Python interpreter's start-up, so a + // slice read one side after another is most of what the Code tab waits for. + const read = yield* readSlice({ + paths: ["a.ts", "b.ts", "c.ts", "d.ts"], + lines: 2, + width: 4, + }); + + expect(read.peakInFlight).toBeGreaterThan(2); + }), + ); + + it.effect("holds the number of files it reads at once down", () => + Effect.gen(function* () { + // The host throttles, and `az` is a process on the same machine the reader runs agents on, + // so a long change is read in batches rather than all at once. + const paths = Array.from({ length: 24 }, (_, file) => `file-${file}.ts`); + const read = yield* readSlice({ paths, lines: 2, width: 4 }); + + expect(read.reads).toHaveLength(paths.length * 2); + expect(read.peakInFlight).toBeLessThanOrEqual(8); + }), + ); + + it.effect("keeps the patch in the order the change was listed, whoever answered first", () => + Effect.gen(function* () { + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"]; + const read = yield* readSlice({ paths, lines: 2, width: 4 }); + + expect(patchedPaths(read.slice.patch)).toEqual(paths); + expect(read.slice.nextCursor).toBeNull(); + }), + ); + + it.effect("leaves a file the host refused listed without its hunks, in its place", () => + Effect.gen(function* () { + const paths = ["a.ts", "b.ts", "c.ts"]; + const read = yield* readSlice({ paths, lines: 2, width: 4, refused: ["b.ts"] }); + + expect(patchedPaths(read.slice.patch)).toEqual(paths); + expect(read.slice.truncated).toBe(true); + // Its section ends at its header, and the files around it still carry their hunks. + expect(read.slice.patch).toContain("+++ b/b.ts\ndiff --git a/c.ts"); + expect(read.slice.patch.match(/^@@ /gmu)).toHaveLength(2); + }), + ); +}); + +describe("what one diff slice spends", () => { + it.effect("stops on the byte ceiling without carrying what it read past it", () => + Effect.gen(function* () { + // Two of these fill the slice, and the batch they were read in reached two files further. + // Those two belong to the next slice: carrying them would put the request past a ceiling + // that is there to bound what one answer weighs. + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; + const read = yield* readSlice({ paths, lines: 100, width: 900 }); + + expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES); + expect(patchedPaths(read.slice.patch)).toEqual(["a.ts", "b.ts"]); + expect(read.slice.nextCursor).toBe(`${ITERATION.id}:2`); + expect(new Set(read.reads)).toEqual(new Set(["a.ts", "b.ts", "c.ts", "d.ts"])); + }), + ); + + it.effect("narrows what it reads at once as the slice fills", () => + Effect.gen(function* () { + // Four files fit inside the budget and the fifth is past half of what is left of it, so + // reading four more would throw most of them away and read them again next slice. Every + // file is two `az` invocations, so the batch is judged against what the files before it + // weighed rather than left at its full width to the last file. + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts", "g.ts", "h.ts"]; + const read = yield* readSlice({ paths, lines: 50, width: 450 }); + + expect(read.slice.nextCursor).not.toBeNull(); + expect(new Set(read.reads)).toEqual(new Set(patchedPaths(read.slice.patch))); + }), + ); + + it.effect("stops once the diff work one request may do is spent", () => + Effect.gen(function* () { + // Short lines are cheap on the wire and dear to diff, so the byte ceiling alone would let + // one request hold the thread through a dozen of them. Each of these is half of what one + // file is allowed, and the slice ends while there is still room for another. + const paths = Array.from({ length: 12 }, (_, file) => `file-${file}.ts`); + const read = yield* readSlice({ paths, lines: MAX_FILE_DIFF_EDITS / 4, width: 1 }); + + expect(read.slice.patch.length).toBeLessThan(MAX_DIFF_SLICE_BYTES); + expect(patchedPaths(read.slice.patch)).toEqual(paths.slice(0, 5)); + expect(read.slice.nextCursor).toBe(`${ITERATION.id}:5`); + }), + ); + + it.effect("carries a whole new file and stops the slice on what it weighed", () => + Effect.gen(function* () { + // A creation has no edit distance to search out, so no edit bound applies to it and its + // section is the whole file. What keeps a run of them from filling one answer is the bytes + // they weighed, which the slice has to be charged for. + const paths = ["new.ts", "b.ts", "c.ts", "d.ts"]; + const read = yield* readSlice({ paths, lines: 8_000, width: 30, created: ["new.ts"] }); + + expect(patchedPaths(read.slice.patch)).toEqual(["new.ts"]); + expect(read.slice.patch).toContain("--- /dev/null"); + expect(read.slice.patch).toContain("@@ -0,0 +1,8000 @@"); + expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES); + expect(read.slice.nextCursor).toBe(`${ITERATION.id}:1`); + }), + ); + + it.effect("carries on from where the last slice stopped", () => + Effect.gen(function* () { + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; + const read = yield* readSlice({ + paths, + lines: 100, + width: 900, + cursor: `${ITERATION.id}:2`, + }); + + expect(patchedPaths(read.slice.patch)).toEqual(["c.ts", "d.ts"]); + expect(read.slice.nextCursor).toBe(`${ITERATION.id}:4`); + }), + ); + it.effect("keeps the pull request being read, not the one looked up first", () => + Effect.gen(function* () { + // Where a pull request lives is read from the pull request itself, so an evicted entry + // costs a whole pull request read before any file can be asked for. Ordered by insertion + // alone a hit does not renew its entry, so the review being worked through is the first + // thing dropped once a listing has walked a cache's worth of cold pull requests. + const HOT = 7; + const readsOf = new Map(); + + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: (input) => + Effect.sync(() => { + readsOf.set(input.number, (readsOf.get(input.number) ?? 0) + 1); + return { ...PULL_REQUEST, number: input.number }; + }), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ changes: [change("a.ts")], truncated: false }), + readItemContent: () => Effect.succeed({ contents: side("new", 2, 4), isBinary: false }), + }), + ), + ); + + const readDiff = (number: number) => + provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number }); + + yield* readDiff(HOT); + // A cache's worth of cold pull requests, with the open one read in between each of them. + for (let filled = 0; filled < LOCATION_CACHE_CAPACITY; filled += 1) { + yield* readDiff(HOT + 1 + filled); + yield* readDiff(HOT); + } + + expect(readsOf.get(HOT)).toBe(1); + }), + ); +}); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..afd0456369473ec9bdbd53f61673d4f7c672c3b6 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts @@ -0,0 +1,647 @@ +import * as Effect from "effect/Effect"; +import * as Semaphore from "effect/Semaphore"; +import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; + +import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import { + azureDevOpsFilePatch, + azureDevOpsUnreadableFilePatch, + formatAzureDevOpsDiffCursor, + parseAzureDevOpsDiffCursor, + MAX_DIFF_SLICE_BYTES, + MAX_DIFF_SLICE_EDITS, + MAX_DIFF_SLICE_FILES, + byteLength, + MAX_FILE_DIFF_EDITS, + type AzureDevOpsFileTexts, +} from "./azureDevOpsDiff.ts"; +import { + PullRequestProviderError, + type PullRequestProviderFailure, + type ProviderChangeRequest, + type ProviderChangeRequestActivity, + type ProviderChangeRequestDetail, + type ProviderChangeRequestSummary, + type ProviderDiffSlice, + type PullRequestProviderApi, +} from "@t3tools/source-control-core/server/PullRequestProvider"; +import type { + AzureDevOpsChangeEntry, + AzureDevOpsItemContent, + AzureDevOpsIteration, + AzureDevOpsPullRequest, + AzureDevOpsRepositoryLocation, +} from "./azureDevOpsPullRequestJson.ts"; + +/** + * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a + * Python interpreter's start-up, so reading them one after another is most of what the Code tab + * waits for. Four files means eight processes at once: the fan-out the GitHub CLI reads its + * per-file stats with here, and low enough not to swamp the host's throttling or the machine. + */ +const DIFF_FILE_CONCURRENCY = 4; + +/** + * How many `az` processes this build will have out at once, counted across every reader rather + * than per request. The fan-out above bounds one Code tab, so two people opening two Azure reviews + * had sixteen Python interpreters starting at once and nothing above them. Held at what one + * request at full width spends, so a second reader waits behind the first instead of adding to it. + */ +export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY; + +/** How many pull requests' repository locations one provider remembers at once. */ +export const LOCATION_CACHE_CAPACITY = 128; + +const CAPABILITIES: PullRequestCapabilities = { + // Azure serves no patch of its own, so the one the Code tab reads is built here out of the + // files an iteration changed and both sides of each of them. + diff: true, + // Reading a conversation is a plain REST read, but posting one is not something this can + // claim without having run it, so the composer stays hidden. + comment: false, + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "enable-auto-merge", + "disable-auto-merge", + ], + // Azure squashes as a completion option; it has no rebase strategy of its own. + mergeMethods: ["merge", "squash"], + // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. + search: false, + reactions: false, + // The patch has lines to write against, but writing a remark at all is what Azure is not + // offered for here, so nothing in a review is either. + review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` + // lists the ones this repository could name — that lives behind the identity and graph APIs, a + // different service with its own permissions. So the page takes a name here rather than being + // handed a menu built out of a guess. + reviewers: { request: true, listCandidates: false }, + // A new title and description travel on the same `az repos pr update` that moves a pull request. + // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on + // Azure DevOps at all, so there is nothing here it could rewrite either. + edit: { changeRequest: true, comment: false }, + // Azure does keep a viewed record of its own, but only behind the undocumented contribution + // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway. + // So they are kept here instead, and the client says whose they are rather than implying the + // Azure DevOps page will show them. + viewedFiles: "environment", +}; + +/** + * Everything this host offers, granted to whoever is signed in. Azure DevOps states no permission + * anywhere `az repos pr show` or `az repos pr list` reach: the answer lives in the security + * namespaces, behind identity descriptors and token paths that would be several calls per pull + * request to resolve. + * + * So the actions stay live and a viewer who may not take one is told so by Azure, at the moment + * they try. That is the safer half of an unknown: hiding a control from someone entitled to it + * leaves them no way through and no reason given. + */ +const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { + actions: CAPABILITIES.actions, + comment: CAPABILITIES.comment, + resolve: CAPABILITIES.review.resolve, + verdicts: CAPABILITIES.review.verdicts, + requestReviewers: CAPABILITIES.reviewers.request, +}; + +/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ +export function azureDevOpsProviderFailure( + error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError, +): PullRequestProviderFailure { + if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" }; + if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" }; + if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" }; + if (error._tag === "AzureDevOpsPullRequestNotFoundError") return { reason: "not-found" }; + return { reason: "failed" }; +} + +function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeRequest { + return { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + author: pullRequest.author, + headBranch: pullRequest.headBranch, + baseBranch: pullRequest.baseBranch, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + mergeability: pullRequest.mergeability, + // Azure counts a pull request's files but never its lines, and counting them here would mean + // reading every file on both sides of every row of a listing. + additions: 0, + deletions: 0, + createdAt: pullRequest.createdAt, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, + updatedAt: pullRequest.updatedAt, + reviewRequestLogins: pullRequest.reviewRequestLogins, + // Azure keeps labels on work items rather than on the pull request. + labels: [], + }; +} + +export const make = Effect.gen(function* () { + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + // Made once with the provider, which the registry builds once, so this is the whole build's + // allowance rather than one request's. + const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS); + const readItemContent = (input: Parameters[0]) => + diffSpawns.withPermits(1)(cli.readItemContent(input)); + + const fail = + (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) => + new PullRequestProviderError({ + provider: "azure-devops", + operation, + ...azureDevOpsProviderFailure(error), + detail: error.detail, + cause: error, + }); + + /** Refuses what the capabilities already say this host cannot do. */ + const unsupported = (operation: string) => + Effect.fail( + new PullRequestProviderError({ + provider: "azure-devops", + operation, + reason: "failed", + detail: "Azure DevOps reviews cannot be written from here yet.", + }), + ); + + /** A pull request Azure could not place has no diff to read, which reads as an empty one. */ + const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null }; + + /** + * Where a pull request's repository lives, which is the route every other read of it needs and + * the one thing only the pull request itself states. A pull request cannot move between + * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for + * a whole pull request read every time they checked whether a file had been pushed to. Least + * recently used, so a listing walking cold pull requests cannot evict the one being read. + */ + const locations = new Map(); + + const locationOf = (input: { readonly cwd: string; readonly number: number }) => { + const key = `${input.cwd} ${input.number}`; + const held = locations.get(key); + if (held !== undefined) { + locations.delete(key); + locations.set(key, held); + return Effect.succeed(held); + } + return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( + Effect.map((pullRequest) => { + const location = pullRequest.location; + if (location === null) return null; + if (locations.size >= LOCATION_CACHE_CAPACITY) { + const oldest = locations.keys().next().value; + if (oldest !== undefined) locations.delete(oldest); + } + locations.set(key, location); + return location; + }), + ); + }; + + /** + * Everything a diff read needs before it can ask for a file: where the repository lives, and + * which pushes the pull request has had. A client names neither, and the iterations are read + * afresh every time because the newest one is what a push adds. + */ + const diffScope = (input: { readonly cwd: string; readonly number: number }) => + Effect.gen(function* () { + const location = yield* locationOf(input); + if (location === null) return null; + const iterations = yield* cli.listIterations({ + cwd: input.cwd, + location, + number: input.number, + }); + return { location, iterations }; + }); + + const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; + + /** + * Both sides of one changed file, read at once because neither answer depends on the other and + * `az` pays a Python interpreter's start-up for each. Only the sides a change actually has are + * asked for: Azure answers for a file that is not at a commit with a failure rather than with + * nothing. + */ + const readTexts = (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly iteration: AzureDevOpsIteration; + readonly change: Pick; + }) => + Effect.all( + [ + input.change.changeKind === "new" + ? Effect.succeed(EMPTY_ITEM) + : readItemContent({ + cwd: input.cwd, + location: input.location, + path: input.change.oldPath, + commit: input.iteration.mergeBaseCommit, + }), + input.change.changeKind === "deleted" + ? Effect.succeed(EMPTY_ITEM) + : readItemContent({ + cwd: input.cwd, + location: input.location, + path: input.change.path, + commit: input.iteration.headCommit, + }), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([oldItem, newItem]): AzureDevOpsFileTexts => ({ + oldContents: oldItem.contents, + newContents: newItem.contents, + // Azure hands a file it calls binary over in an encoding of its own, so its own word on + // that is taken rather than looked for in bytes it may never have sent verbatim. + binary: oldItem.isBinary || newItem.isBinary, + })), + ); + + /** + * What the whole pull request changed, taken from its latest push. An iteration's changes are + * reported against the merge base rather than against the push before it, so the newest one is + * the whole of the change rather than the last slice of it. + */ + const listLatestChanges = (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + readonly iterations: ReadonlyArray; + }) => { + const latest = input.iterations.at(-1); + return latest === undefined + ? Effect.succeed({ + changes: [], + truncated: false, + } as AzureDevOpsPullRequestCli.AzureDevOpsIterationChanges) + : cli.listIterationChanges({ + cwd: input.cwd, + location: input.location, + number: input.number, + iterationId: latest.id, + }); + }; + + const provider: PullRequestProviderApi = { + kind: "azure-devops", + capabilities: CAPABILITIES, + + getViewer: (input) => + cli.getViewer({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), + + // `input.query` is deliberately dropped: `az repos pr list` filters by status, creator, + // reviewer and branch, and has nothing that matches text. Sending it as one of those would + // narrow by the wrong thing, so the page comes back unnarrowed and the caller filters it. + listChangeRequests: (input) => + cli + .listPullRequests({ + cwd: input.cwd, + repository: input.repository, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + cursor: input.cursor, + }) + .pipe( + Effect.mapError(fail("listChangeRequests")), + Effect.map((batch) => ({ + items: batch.items.map(toChangeRequest), + truncated: batch.truncated, + cursorAdvance: batch.cursorAdvance, + // Azure answers in one order whether or not it is being carried on from, so a slice + // can always be stepped past — by counting, which is all Azure offers. + continues: true, + })), + ), + + // The polled path a linked thread's row stays live on: one `az` read, no iterations or + // changes behind it, since the file count that would cost is not shown here. + getChangeRequestSummary: (input) => + cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( + Effect.mapError(fail("getChangeRequestSummary")), + Effect.map((pullRequest): ProviderChangeRequestSummary => ({ + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + author: pullRequest.author, + headBranch: pullRequest.headBranch, + baseBranch: pullRequest.baseBranch, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + mergeability: pullRequest.mergeability, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, + updatedAt: pullRequest.updatedAt, + })), + ), + + getChangeRequest: (input) => + Effect.gen(function* () { + const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); + const location = pullRequest.location; + // The file count is two reads past the pull request itself, and it is the only thing + // riding on them, so a failure leaves it unknown rather than losing the whole detail. + const changedFiles = + location === null + ? 0 + : yield* cli.listIterations({ cwd: input.cwd, location, number: input.number }).pipe( + Effect.flatMap((iterations) => + listLatestChanges({ + cwd: input.cwd, + location, + number: input.number, + iterations, + }), + ), + Effect.map((listed) => listed.changes.length), + Effect.orElseSucceed(() => 0), + ); + const detail: ProviderChangeRequestDetail = { + ...toChangeRequest(pullRequest), + body: pullRequest.body, + changedFiles, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + reviewers: pullRequest.reviewers, + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: false }, + viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, + autoMergeEnabled: pullRequest.autoMergeEnabled, + ...(pullRequest.autoMergeMethod === undefined + ? {} + : { autoMergeMethod: pullRequest.autoMergeMethod }), + }; + return detail; + }).pipe(Effect.mapError(fail("getChangeRequest"))), + + getChangeRequestActivity: (input) => + cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( + Effect.mapError(fail("getChangeRequestActivity")), + Effect.flatMap((pullRequest) => + (pullRequest.location === null + ? Effect.succeed({ comments: [], truncated: true }) + : cli + .listThreads({ + cwd: input.cwd, + location: pullRequest.location, + number: input.number, + }) + .pipe( + Effect.map((comments) => ({ comments, truncated: false })), + Effect.orElseSucceed(() => ({ comments: [], truncated: true })), + ) + ).pipe( + Effect.map((conversation): ProviderChangeRequestActivity => ({ + comments: conversation.comments, + commentCount: conversation.comments.length, + commentsTruncated: conversation.truncated, + reviewThreads: [], + commits: [], + })), + ), + ), + ), + + // No request at all: Azure has nothing to say about the viewer that a pull request read can + // reach, so the answer is the same constant the detail carries. + getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS), + + // `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so + // the Code tab has nothing to scope itself to and always asks for the whole change. + getDiff: (input) => + Effect.gen(function* () { + const scope = yield* diffScope(input); + if (scope === null) return EMPTY_DIFF_SLICE; + const cursor = parseAzureDevOpsDiffCursor(input.cursor); + // Reading on stays with the push the first slice was taken against. A push landing + // mid-read would otherwise renumber the files and hand the reader one twice, or none. + const iteration = + cursor === null + ? scope.iterations.at(-1) + : scope.iterations.find((candidate) => candidate.id === cursor.iterationId); + if (iteration === undefined) return EMPTY_DIFF_SLICE; + const listed = yield* cli.listIterationChanges({ + cwd: input.cwd, + location: scope.location, + number: input.number, + iterationId: iteration.id, + }); + const changes = listed.changes; + + const sections: string[] = []; + let truncated = listed.truncated; + let bytes = 0; + let edits = 0; + let index = cursor?.fileIndex ?? 0; + let full = false; + // How many files to read at once. Every file costs a request a side, and everything read + // past the point the slice fills is thrown away and read again by the next slice, so the + // batch narrows as the budgets do: what is left of each, over what a file has spent of it + // on average so far, which is the only estimate there is before a file is read. At least + // one, so a file heavier than the whole budget still moves the cursor. + const batchWidth = () => { + if (sections.length === 0) return DIFF_FILE_CONCURRENCY; + const admits = (left: number, spent: number) => + Math.ceil(left / Math.max(1, spent / sections.length)); + return Math.max( + 1, + Math.min( + DIFF_FILE_CONCURRENCY, + // The file budget needs no estimate: a file spends exactly one of it. + MAX_DIFF_SLICE_FILES - sections.length, + admits(MAX_DIFF_SLICE_BYTES - bytes, bytes), + admits(MAX_DIFF_SLICE_EDITS - MAX_FILE_DIFF_EDITS - edits, edits), + ), + ); + }; + while (!full && index < changes.length) { + const batch = changes.slice(index, index + batchWidth()); + const read = yield* Effect.forEach( + batch, + (change) => + // A pair Azure refuses is one file rather than the whole slice: an oversize blob or + // a path `az` will not carry through leaves that file listed without its hunks, and + // everything around it still renders. + readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).pipe( + // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az` + // at all is the read failing rather than the file, and belongs to the caller, + // which pauses the host rather than showing every file as unreadable. + Effect.catchTags({ + AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null), + AzureDevOpsCommandFailedError: () => Effect.succeed(null), + AzureDevOpsPullRequestReadError: () => Effect.succeed(null), + }), + Effect.map((texts) => ({ change, texts })), + ), + { concurrency: DIFF_FILE_CONCURRENCY }, + ); + for (const { change, texts } of read) { + // The diff is synchronous and the reads no longer stand between one file and the next + // to let anything else on the server run, so the thread is handed back here. + yield* Effect.yieldNow; + const file = + texts === null + ? azureDevOpsUnreadableFilePatch(change) + : azureDevOpsFilePatch({ change, texts }); + sections.push(file.section); + bytes += byteLength(file.section); + edits += file.edits; + truncated = truncated || file.truncated; + index += 1; + // A file whose diff was given up on spent the whole of what one file is allowed and + // has only a header to show for it, and a file the diff never ran on at all weighs + // almost nothing in either budget and still costs its two reads. So the file count + // bounds the request alongside the bytes. + // Checked after the file is added rather than before it, so every slice carries at + // least one: a section heavier than the whole budget would otherwise never be added, + // and the read would answer the same slice forever without moving the cursor. + if ( + bytes >= MAX_DIFF_SLICE_BYTES || + edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS || + sections.length >= MAX_DIFF_SLICE_FILES || + file.abandoned + ) { + full = true; + break; + } + } + } + + const slice: ProviderDiffSlice = { + patch: sections.join(""), + truncated, + nextCursor: + index >= changes.length + ? null + : formatAzureDevOpsDiffCursor({ iterationId: iteration.id, fileIndex: index }), + }; + return slice; + }).pipe(Effect.mapError(fail("getDiff"))), + + // The patch is built from whole files, so opening the lines around a hunk is the same two + // reads over again rather than a wider request. Read against the latest iteration: nothing in + // the request says which push the reader is looking at, and expansion is stale after a + // mid-review push on every host here anyway. + getDiffFileContents: (input) => + Effect.gen(function* () { + const scope = yield* diffScope(input); + const iteration = scope?.iterations.at(-1); + if (scope === null || iteration === undefined) return { oldContents: "", newContents: "" }; + return yield* readTexts({ + cwd: input.cwd, + location: scope.location, + iteration, + change: { + changeKind: input.changeType, + path: input.newPath, + oldPath: input.oldPath, + }, + }); + }).pipe(Effect.mapError(fail("getDiffFileContents"))), + + /** + * What the head has of each marked file, which is the blob Azure already names on the change + * it reports. One read covers every path, since the latest iteration lists the whole change. + * + * A path the change does not carry is at the empty revision, which is where a file the pull + * request deletes sits. When the change was too long to follow to its end, those paths are + * left out instead: reporting them as deleted would clear a file nobody has read. + */ + getFileRevisions: (input) => + Effect.gen(function* () { + const revisions = new Map(); + if (input.paths.length === 0) return { revisions }; + const scope = yield* diffScope(input); + if (scope === null) return { revisions }; + const listed = yield* listLatestChanges({ + ...scope, + cwd: input.cwd, + number: input.number, + }); + const marked = new Set(input.paths); + for (const change of listed.changes) { + if (!marked.has(change.path) || change.objectId === null) continue; + revisions.set(change.path, change.objectId); + } + if (!listed.truncated) { + for (const path of input.paths) { + if (!revisions.has(path)) revisions.set(path, ""); + } + } + return { revisions }; + }).pipe(Effect.mapError(fail("getFileRevisions"))), + + runAction: (input) => + cli + .runPullRequestAction({ + cwd: input.cwd, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + updateChangeRequest: (input) => + cli + .updatePullRequest({ + cwd: input.cwd, + number: input.number, + title: input.title, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateChangeRequest"))), + + // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the + // list without it. + listReviewerCandidates: () => + Effect.fail( + new PullRequestProviderError({ + provider: "azure-devops", + operation: "listReviewerCandidates", + reason: "failed", + detail: "Azure DevOps cannot say who may review a pull request.", + }), + ), + + setReviewerRequest: (input) => + cli + .setPullRequestReviewers({ + cwd: input.cwd, + number: input.number, + // Azure names an identity by an email address or a guid, and has no team to ask, so a + // candidate's id is the whole of what it takes. + reviewers: input.reviewers.map((reviewer) => reviewer.id), + requested: input.requested, + }) + .pipe(Effect.mapError(fail("setReviewerRequest"))), + + // Never called: `capabilities.comment` is false, and the service refuses a comment without it. + comment: () => unsupported("comment"), + + // Declared unsupported above, so the service refuses these before a provider is reached. + // They exist because every provider answers the whole port. + submitReview: () => unsupported("submitReview"), + + replyToThread: () => unsupported("replyToThread"), + + setThreadResolution: () => unsupported("setThreadResolution"), + + setReaction: () => unsupported("setReaction"), + }; + + return provider; +}); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e55d453b224b411a0986b4cd153c588a8159f49 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts @@ -0,0 +1,120 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; + +function makeProvider(azure: Partial) { + return AzureDevOpsSourceControlProvider.make.pipe( + Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), + ); +} + +it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getPullRequest: () => + Effect.succeed({ + number: 42, + title: "Add Azure provider", + url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + updatedAt: Option.none(), + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "azure-devops", + number: 42, + title: "Add Azure provider", + url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, + updatedAt: Option.none(), + isCrossRepository: false, + }); + }), +); + +it.effect("adds change-request context while retaining Azure CLI causes", () => + Effect.gen(function* () { + const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ + operation: "execute", + command: "az", + cwd: "/repo", + argumentCount: 2, + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + checkoutPullRequest: () => Effect.fail(cause), + }); + + const error = yield* provider + .checkoutChangeRequest({ cwd: "/repo", reference: "#42" }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + reference: error.reference, + detail: error.detail, + }, + { + provider: "azure-devops", + operation: "checkoutChangeRequest", + command: "az", + cwd: "/repo", + reference: "#42", + detail: "Azure DevOps CLI command failed.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + +it.effect("creates Azure DevOps PRs through provider-neutral input names", () => + Effect.gen(function* () { + let createInput: + | Parameters[0] + | null = null; + const provider = yield* makeProvider({ + createPullRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + }), +); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..dcb07e0632f3c5646f44df552f25c02aaac495e2 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts @@ -0,0 +1,236 @@ +import * as Effect from "effect/Effect"; +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; +import { + combinedAuthOutput, + firstSafeAuthLine, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, +} from "@t3tools/source-control-core/server/discovery"; + +function parseAzureAuth(input: SourceControlAuthProbeInput) { + const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + detail: + firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", + }); + } + + if (account !== undefined && account.length > 0) { + return providerAuth({ + status: "authenticated", + account, + host: "dev.azure.com", + }); + } + + return providerAuth({ + status: "unknown", + host: "dev.azure.com", + detail: "Azure CLI account status could not be parsed.", + }); +} + +export const discovery = { + type: "cli", + kind: "azure-devops", + label: "Azure DevOps", + executable: "az", + versionArgs: ["--version"], + authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], + // `az` boots a fresh Python interpreter on every invocation, so even `az --version` + // takes ~6s on Windows and overruns the default budget, leaving the provider reported + // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s. + probeTimeoutMs: 20_000, + parseAuth: parseAzureAuth, + installHint: + "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", +} satisfies SourceControlCliDiscoverySpec; + +function toChangeRequest(summary: { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt: ChangeRequest["updatedAt"]; +}): ChangeRequest { + return { + provider: "azure-devops", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state, + ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + updatedAt: summary.updatedAt, + isCrossRepository: false, + }; +} + +export const make = Effect.gen(function* () { + const azure = yield* AzureDevOpsCli.AzureDevOpsCli; + + return SourceControlProvider.SourceControlProvider.of({ + kind: "azure-devops", + listChangeRequests: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return azure + .listPullRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(source !== undefined ? { source } : {}), + state: input.state, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); + }, + getChangeRequest: (input) => + azure.getPullRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), + createChangeRequest: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return azure + .createPullRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + ...(source !== undefined ? { source } : {}), + ...(input.target !== undefined ? { target: input.target } : {}), + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); + }, + getRepositoryCloneUrls: (input) => + azure.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), + createRepository: (input) => + azure.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), + getDefaultBranch: (input) => + azure.getDefaultBranch({ cwd: input.cwd }).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), + checkoutChangeRequest: (input) => + azure + .checkoutPullRequest({ + cwd: input.cwd, + reference: input.reference, + ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), + }); +}); diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsDiff.test.ts b/packages/source-control-azure-devops/src/server/azureDevOpsDiff.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..30d575755199aeb47ca9932c7fa515a5f360bbd5 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/azureDevOpsDiff.test.ts @@ -0,0 +1,459 @@ +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; +import { describe, expect, it } from "vite-plus/test"; + +import { + azureDevOpsFilePatch, + azureDevOpsUnreadableFilePatch, + byteLength, + formatAzureDevOpsDiffCursor, + MAX_FILE_DIFF_EDITS, + parseAzureDevOpsDiffCursor, +} from "./azureDevOpsDiff.ts"; +import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; + +function change(overrides: Partial = {}): AzureDevOpsChangeEntry { + return { + path: "README.md", + oldPath: "README.md", + changeKind: "change", + objectId: "8f80", + originalObjectId: "0ca4", + ...overrides, + }; +} + +function texts(oldContents: string, newContents: string, binary = false) { + return { oldContents, newContents, binary }; +} + +describe("azureDevOpsFilePatch", () => { + it("writes a changed file as the unified patch every diff viewer already reads", () => { + const patch = azureDevOpsFilePatch({ + change: change(), + texts: texts("one\ntwo\nthree\n", "one\ntwo again\nthree\n"), + }); + + expect(patch.truncated).toBe(false); + expect(patch.section).toBe( + [ + "diff --git a/README.md b/README.md", + "--- a/README.md", + "+++ b/README.md", + "@@ -1,3 +1,3 @@", + " one", + "-two", + "+two again", + " three", + "", + ].join("\n"), + ); + }); + + it("names the side a new file does not have as /dev/null", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), + texts: texts("", "hello\n"), + }); + + expect(patch.section).toContain("new file mode 100644"); + expect(patch.section).toContain("--- /dev/null"); + expect(patch.section).toContain("+++ b/DEMO.md"); + // Git points the range a new file does not have at line zero, not at line one. + expect(patch.section).toContain("@@ -0,0 +1 @@"); + expect(patch.section).toContain("+hello"); + }); + + it("names the side a deleted file no longer has as /dev/null", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), + texts: texts("gone\n", ""), + }); + + expect(patch.section).toContain("deleted file mode 100644"); + expect(patch.section).toContain("--- a/OLD.md"); + expect(patch.section).toContain("+++ /dev/null"); + expect(patch.section).toContain("@@ -1 +0,0 @@"); + expect(patch.section).toContain("-gone"); + }); + + it("keeps the carriage returns of a file with Windows line endings", () => { + // They are part of the line rather than around it, so a patch that dropped them would ask + // the reader to look at a change that is not the one on the host. + const patch = azureDevOpsFilePatch({ + change: change(), + texts: texts("one\r\ntwo\r\n", "one\r\ntwo again\r\n"), + }); + + expect(patch.section).toContain("-two\r"); + expect(patch.section).toContain("+two again\r"); + }); + + it("keeps a file that only moved, which has no hunks to give", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "docs/new.md", oldPath: "docs/old.md", changeKind: "rename-pure" }), + texts: texts("same\n", "same\n"), + }); + + expect(patch.truncated).toBe(false); + expect(patch.section).toBe( + [ + "diff --git a/docs/old.md b/docs/new.md", + "rename from docs/old.md", + "rename to docs/new.md", + "--- a/docs/old.md", + "+++ b/docs/new.md", + "", + ].join("\n"), + ); + }); + + it("reports a binary file as changed rather than spelling it out", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "logo.png", oldPath: "logo.png" }), + texts: texts("PNG\u0000old", "PNG\u0000new"), + }); + + expect(patch.truncated).toBe(true); + expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ"); + }); + + it("shows an overlong file as changed without its hunks", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "bundle.js", oldPath: "bundle.js" }), + texts: texts("a\n".repeat(400_000), "b\n".repeat(400_000)), + }); + + expect(patch.truncated).toBe(true); + expect(patch.section).toBe( + ["diff --git a/bundle.js b/bundle.js", "--- a/bundle.js", "+++ b/bundle.js", ""].join("\n"), + ); + }); + + it("takes the host's word that a file is binary, whatever its bytes look like", () => { + // Azure hands such a file over base64-encoded, so nothing in the text it sent gives it away. + const patch = azureDevOpsFilePatch({ + change: change({ path: "logo.png", oldPath: "logo.png" }), + texts: texts("b2xk", "bmV3", true), + }); + + expect(patch.truncated).toBe(true); + expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ"); + }); + + it("counts an overlong file in bytes rather than in characters", () => { + // Three bytes each, so a ceiling counted in code units would let three times the size through. + const patch = azureDevOpsFilePatch({ + change: change({ path: "notes.md", oldPath: "notes.md" }), + texts: texts("\u4e00".repeat(200_000), "\u4e8c".repeat(200_000)), + }); + + expect(patch.truncated).toBe(true); + expect(patch.section).toBe( + ["diff --git a/notes.md b/notes.md", "--- a/notes.md", "+++ b/notes.md", ""].join("\n"), + ); + }); + + const lineRange = (count: number, prefix: string) => + Array.from({ length: count }, (_, line) => `${prefix} ${line}`).join("\n"); + + it("lists a file too far apart to diff without its hunks", () => { + // Sharing no line at all costs one edit per line on each side, so this pair is twice the + // ceiling apart. Left to itself the search costs about the square of that and would hold the + // whole server, every websocket client with it, while it worked out a patch nobody reads. + // Writing both sides out instead would read as a genuine rewrite: the ceiling is a distance + // rather than a proportion, so a long file reaches it having changed in one corner, and that + // corner would be buried in a wall of red and green. + const lines = (prefix: string) => + Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n"); + const patch = azureDevOpsFilePatch({ + change: change({ path: "generated.ts", oldPath: "generated.ts" }), + texts: texts(`${lines("old")}\n`, `${lines("new")}\n`), + }); + + expect(patch.truncated).toBe(true); + // And it says the search was given up on, because the reader of a run of files is meant to + // stop rather than spend that work again on each of the ones behind it. + expect(patch.abandoned).toBe(true); + expect(patch.section).toBe( + [ + "diff --git a/generated.ts b/generated.ts", + "--- a/generated.ts", + "+++ b/generated.ts", + "", + ].join("\n"), + ); + }); + + it("writes out a wholly new file however many lines it has", () => { + // Nothing on the old side means there was no edit distance to search out, so this is the + // minimal patch and not a stand-in for one. Fifteen thousand lines of thirty bytes is the + // shape this used to lose: inside the byte ceiling that gates every file, and well past every + // bound a two-sided file answers to, none of which is protecting against anything here. + const contents = `${Array.from({ length: 15_000 }, () => "x".repeat(29)).join("\n")}\n`; + const patch = azureDevOpsFilePatch({ + change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), + texts: texts("", contents), + }); + + expect(byteLength(contents)).toBeLessThan(512 * 1024); + expect(patch.truncated).toBe(false); + expect(patch.abandoned).toBe(false); + expect(patch.edits).toBe(15_000); + expect(patch.section).toContain("@@ -0,0 +1,15000 @@"); + // Only the `+++` of the header on top of the file's own lines, so nothing was dropped out of + // the middle. + expect(patch.section.match(/^\+/gmu)).toHaveLength(15_001); + }); + + it("keeps a wholly new file too heavy to write out listed without its hunks", () => { + // Every line carries a marker, so a side of very short lines answers with up to twice its own + // bytes: these 200,000 one-character lines fit the ceiling each side is read under and weigh + // about 600KB written out, more than twice what a whole slice may carry. There is no smaller + // true patch for a creation to fall back to, so it is listed without its hunks, the same as a + // side too big to read at all, rather than sent at a size the slice budget exists to prevent. + const contents = `${Array.from({ length: 200_000 }, () => "x").join("\n")}\n`; + const patch = azureDevOpsFilePatch({ + change: change({ path: "bundle.min.js", oldPath: "bundle.min.js", changeKind: "new" }), + texts: texts("", contents), + }); + + expect(byteLength(contents)).toBeLessThan(512 * 1024); + expect(patch.truncated).toBe(true); + expect(patch.abandoned).toBe(false); + // It still cost the walk over its lines, which is what the slice is charged for. + expect(patch.edits).toBe(200_000); + expect(patch.section).toBe( + [ + "diff --git a/bundle.min.js b/bundle.min.js", + "new file mode 100644", + "--- /dev/null", + "+++ b/bundle.min.js", + "", + ].join("\n"), + ); + }); + + it("writes out a wholly deleted file however many lines it had", () => { + const contents = `${lineRange(20_000, "line")}\n`; + const patch = azureDevOpsFilePatch({ + change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), + texts: texts(contents, ""), + }); + + expect(patch.truncated).toBe(false); + expect(patch.abandoned).toBe(false); + expect(patch.edits).toBe(20_000); + expect(patch.section).toContain("@@ -1,20000 +0,0 @@"); + expect(patch.section.match(/^-line /gmu)).toHaveLength(20_000); + expect(patch.section.match(/^-/gmu)).toHaveLength(20_001); + }); + + it("gives an empty new file no hunk to read", () => { + // A file with nothing on either side has no lines to claim were replaced, and git writes it + // as a header alone. + const patch = azureDevOpsFilePatch({ + change: change({ path: "EMPTY.md", oldPath: "EMPTY.md", changeKind: "new" }), + texts: texts("", ""), + }); + + expect(patch.edits).toBe(0); + expect(patch.section).not.toContain("@@"); + }); + + it("marks a wholly new file whose last line has no newline after it", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "NOTES.md", oldPath: "NOTES.md", changeKind: "new" }), + texts: texts("", "one\ntwo"), + }); + + expect(patch.section).toBe( + [ + "diff --git a/NOTES.md b/NOTES.md", + "new file mode 100644", + "--- /dev/null", + "+++ b/NOTES.md", + "@@ -0,0 +1,2 @@", + "+one", + "+two", + "\\ No newline at end of file", + "", + ].join("\n"), + ); + }); + + it("keeps a file it did diff out of the giving up", () => { + const patch = azureDevOpsFilePatch({ + change: change(), + texts: texts("one\ntwo\n", "one\ntwo again\n"), + }); + + expect(patch.abandoned).toBe(false); + }); + + it("counts what the diff worked out, which is what the file cost to diff", () => { + // The caller spends a budget of these across a slice, so they have to be the edits the search + // actually made: one line replaced is a removal and an addition, and the three lines of + // context around them cost nothing. + const patch = azureDevOpsFilePatch({ + change: change(), + texts: texts("one\ntwo\nthree\nfour\n", "one\ntwo again\nthree\nfour\n"), + }); + + expect(patch.edits).toBe(2); + }); + + it("counts nothing for a file it never diffed", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "logo.png", oldPath: "logo.png" }), + texts: texts("PNG\u0000old", "PNG\u0000new"), + }); + + expect(patch.edits).toBe(0); + }); + + it("lists a file whose hunks outweigh its sides without them", () => { + // A handful of very long lines is a few edits and nowhere near the edit ceiling, and the + // patch carries both sides in full with three lines of context around each hunk, so the + // section comes out heavier than either side was. What one file weighs is what a slice's + // budget is spent in, so the edit ceiling alone does not bound this. + const line = `${"a".repeat(400 * 1024)}\n`; + const patch = azureDevOpsFilePatch({ + change: change({ path: "min.js", oldPath: "min.js" }), + texts: texts(line, `${"b".repeat(400 * 1024)}\n`), + }); + + expect(patch.edits).toBeLessThan(MAX_FILE_DIFF_EDITS); + expect(patch.truncated).toBe(true); + expect(patch.section).toBe( + ["diff --git a/min.js b/min.js", "--- a/min.js", "+++ b/min.js", ""].join("\n"), + ); + expect(byteLength(patch.section)).toBeLessThan(byteLength(line)); + }); + + it("marks a file that does not end in a newline, as git does", () => { + const patch = azureDevOpsFilePatch({ + change: change(), + texts: texts("one\n", "two"), + }); + + expect(patch.section).toContain("\\ No newline at end of file"); + }); +}); + +describe("azureDevOpsUnreadableFilePatch", () => { + it("keeps a file the host would not hand over, listed without its hunks", () => { + const patch = azureDevOpsUnreadableFilePatch(change({ path: "huge.bin", oldPath: "huge.bin" })); + + expect(patch.truncated).toBe(true); + expect(patch.section).toBe( + ["diff --git a/huge.bin b/huge.bin", "--- a/huge.bin", "+++ b/huge.bin", ""].join("\n"), + ); + }); +}); + +describe("a file Azure names something a patch header cannot carry plainly", () => { + it("writes each side as git's quoted form, the side letter inside the quotes", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "notes\treadme.md", oldPath: "notes\treadme.md" }), + texts: texts("one\n", "two\n"), + }); + + expect(patch.section).toBe( + [ + 'diff --git "a/notes\\treadme.md" "b/notes\\treadme.md"', + '--- "a/notes\\treadme.md"', + '+++ "b/notes\\treadme.md"', + "@@ -1 +1 @@", + "-one", + "+two", + "", + ].join("\n"), + ); + }); + + it("keeps a name holding a newline on the one header line it belongs to", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "line\nfile.txt", oldPath: "line\nfile.txt" }), + texts: texts("one\n", "two\n"), + }); + + // Written as itself the name would start a line of its own, and a reader would take what + // followed for a header the patch never had. + expect(patch.section.split("\n").slice(0, 3)).toEqual([ + 'diff --git "a/line\\nfile.txt" "b/line\\nfile.txt"', + '--- "a/line\\nfile.txt"', + '+++ "b/line\\nfile.txt"', + ]); + }); + + it("quotes the names a rename states, which carry no side letter", () => { + const patch = azureDevOpsFilePatch({ + change: change({ + path: "docs/new\tname.md", + oldPath: "docs/old\tname.md", + changeKind: "rename-pure", + }), + texts: texts("same\n", "same\n"), + }); + + expect(patch.section).toBe( + [ + 'diff --git "a/docs/old\\tname.md" "b/docs/new\\tname.md"', + 'rename from "docs/old\\tname.md"', + 'rename to "docs/new\\tname.md"', + '--- "a/docs/old\\tname.md"', + '+++ "b/docs/new\\tname.md"', + "", + ].join("\n"), + ); + }); + + it("quotes the sides of the one line a binary file gets", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "logo\tmark.png", oldPath: "logo\tmark.png" }), + texts: texts("PNG\u0000old", "PNG\u0000new"), + }); + + expect(patch.section).toContain( + 'Binary files "a/logo\\tmark.png" and "b/logo\\tmark.png" differ', + ); + }); + + it("hands a reader of the header back the name Azure gave", () => { + const path = 'every\t\n"kind"\\of.md'; + const patch = azureDevOpsFilePatch({ + change: change({ path, oldPath: path }), + texts: texts("one\n", "two\n"), + }); + const [header, oldLine, newLine] = patch.section.split("\n"); + + expect(header).not.toContain("\t"); + expect(unquoteGitPatchPath(oldLine?.slice(4) ?? "")).toBe(`a/${path}`); + expect(unquoteGitPatchPath(newLine?.slice(4) ?? "")).toBe(`b/${path}`); + }); +}); + +describe("a diff cursor", () => { + it("carries the push it was taken against back to the next slice", () => { + const cursor = formatAzureDevOpsDiffCursor({ iterationId: 3, fileIndex: 12 }); + + expect(parseAzureDevOpsDiffCursor(cursor)).toEqual({ iterationId: 3, fileIndex: 12 }); + }); + + it("reads anything it did not write as no position at all", () => { + // Which starts the read from the top rather than failing it: a cursor is the client's to + // hand back, and nothing downstream is worth refusing a whole diff over. + for (const raw of [undefined, null, "", "abc", "1", "0:4", "1:-2", "1:2:3"]) { + expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); + } + }); + + it("refuses a half it did not write rather than reading it as the first file", () => { + // `Number` is wider than the cursor: an empty, padded or hex half would otherwise pass as a + // position, and the read would resume against an iteration the client never saw instead of + // starting again from the latest one. + for (const raw of ["1:", ":4", "1: ", " 1:4", "1:0x2", "0x1:2", "1e2:0", "1:4.0"]) { + expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); + } + }); +}); diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsDiff.ts b/packages/source-control-azure-devops/src/server/azureDevOpsDiff.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa5323c32fa649c347c75200024b478494d1ee4e --- /dev/null +++ b/packages/source-control-azure-devops/src/server/azureDevOpsDiff.ts @@ -0,0 +1,273 @@ +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; +import { structuredPatch } from "diff"; + +import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; + +/** + * How far a diff read got, and which push it was reading. Azure hangs a pull request's changed + * files off an iteration, so a push landing mid-read would renumber the list under a cursor that + * did not also pin the iteration. + */ +export interface AzureDevOpsDiffCursor { + readonly iterationId: number; + readonly fileIndex: number; +} + +const CURSOR_SEPARATOR = ":"; + +/** + * Restricts each half to plain decimal, so `Number` cannot read a foreign cursor's `0x3` as + * three. + */ +const CURSOR_COMPONENT = /^\d+$/; + +export function formatAzureDevOpsDiffCursor(cursor: AzureDevOpsDiffCursor): string { + return `${cursor.iterationId}${CURSOR_SEPARATOR}${cursor.fileIndex}`; +} + +/** Null for anything this did not write, which starts the read from the top rather than failing. */ +export function parseAzureDevOpsDiffCursor( + raw: string | null | undefined, +): AzureDevOpsDiffCursor | null { + if (raw === null || raw === undefined) return null; + const [iteration, file, ...rest] = raw.split(CURSOR_SEPARATOR); + if (rest.length > 0) return null; + if (iteration === undefined || file === undefined) return null; + if (!CURSOR_COMPONENT.test(iteration) || !CURSOR_COMPONENT.test(file)) return null; + const iterationId = Number(iteration); + const fileIndex = Number(file); + if (!Number.isSafeInteger(iterationId) || iterationId <= 0) return null; + if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) return null; + return { iterationId, fileIndex }; +} + +/** The two texts of one changed file, empty on whichever side the change does not have. */ +export interface AzureDevOpsFileTexts { + readonly oldContents: string; + readonly newContents: string; + /** Azure's own flag for a file it hands back base64-encoded instead of as text. */ + readonly binary: boolean; +} + +export interface AzureDevOpsFilePatch { + readonly section: string; + /** The file changed but its hunks are not in the section, so the patch has a hole in it. */ + readonly truncated: boolean; + /** + * The diff was given up on partway, having spent the whole edit budget, so a caller reading a + * run of files stops here rather than paying that again for each one behind it. + */ + readonly abandoned: boolean; + /** + * Lines added or removed, which is the edit distance the diff had to search out, and what a + * slice's budget is spent in: a file of short lines is cheap on the wire and dear to diff. + */ + readonly edits: number; +} + +/** + * Beyond this a file is shown as changed without its hunks. Azure hands back whole files rather + * than a patch, so an oversize file is paid for twice over before anything can be diffed. + */ +const MAX_FILE_BYTES = 512 * 1024; + +/** Git's own default, and what the hunks from this repo's other hosts are already cut to. */ +const PATCH_CONTEXT_LINES = 3; + +/** + * How far apart one file's two sides may be before it is listed without its hunks. The line diff + * costs about the square of the edit distance, so an unbounded pair sharing almost nothing could + * hold the whole server. Bounded in edits rather than milliseconds so a change slices the same + * way on every machine. + */ +export const MAX_FILE_DIFF_EDITS = 2_000; + +/** + * A backstop for a machine slower than the edit ceiling was tuned for. The ceiling rather than + * this is what decides a patch's shape: a timeout that fires would slice the same change one way + * here and another on a busier host. + */ +const MAX_FILE_DIFF_MILLIS = 2_000; + +/** How much diff work one slice does before the rest is left for the next request. */ +export const MAX_DIFF_SLICE_EDITS = 6_000; + +/** How much patch one slice carries before the rest is left for the next request. */ +export const MAX_DIFF_SLICE_BYTES = 256 * 1024; + +/** + * How many files one slice carries however little each one weighs. A binary, oversize, or + * unreadable entry is just a header, so neither budget above stops a run of thousands of them. + */ +export const MAX_DIFF_SLICE_FILES = 300; + +/** Git's own note for a side whose last line has no newline after it. */ +const NO_NEWLINE_MARKER = "\\ No newline at end of file"; + +/** A text's lines, without the empty one that a trailing newline leaves behind a split. */ +function contentLines(contents: string): ReadonlyArray { + if (contents === "") return []; + const lines = contents.split("\n"); + if (lines.at(-1) === "") lines.pop(); + return lines; +} + +/** A NUL byte is git's own test for it, and it survives Azure's JSON envelope intact. */ +function isBinary(contents: string): boolean { + return contents.includes("\u0000"); +} + +/** + * What a file costs on the wire, which is its bytes rather than its code units: a ceiling counted + * in characters lets a file of three-byte glyphs through at three times the intended size. + */ +export const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8"); + +/** + * Git points an empty range at the line before it, which is line zero for a file that is wholly + * new or wholly gone, and writes a single line as its number alone. + */ +function hunkRange(start: number, lines: number): string { + if (lines === 0) return `${start - 1},0`; + return lines === 1 ? String(start) : `${start},${lines}`; +} + +/** + * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file + * mode, so the ordinary one stands in. Names are quoted the way git quotes them, prefix inside the + * quoting: a header reader stops a bare name at its first tab or newline, so a path holding either + * would be truncated and its viewed mark would land on the wrong file. + */ +function patchHeader(change: AzureDevOpsChangeEntry): string { + const oldSide = quoteGitPatchPath(`a/${change.oldPath}`); + const newSide = quoteGitPatchPath(`b/${change.path}`); + const lines = [`diff --git ${oldSide} ${newSide}`]; + if (change.changeKind === "new") lines.push("new file mode 100644"); + if (change.changeKind === "deleted") lines.push("deleted file mode 100644"); + if (change.changeKind === "rename-pure" || change.changeKind === "rename-changed") { + lines.push( + `rename from ${quoteGitPatchPath(change.oldPath)}`, + `rename to ${quoteGitPatchPath(change.path)}`, + ); + } + lines.push( + `--- ${change.changeKind === "new" ? "/dev/null" : oldSide}`, + `+++ ${change.changeKind === "deleted" ? "/dev/null" : newSide}`, + ); + return lines.join("\n"); +} + +/** + * A file written out as wholly replaced: every old line gone, every new line arrived, in one + * hunk, which needs no edit-distance search at all. + */ +function replacementSection(header: string, texts: AzureDevOpsFileTexts): string { + const oldLines = contentLines(texts.oldContents); + const newLines = contentLines(texts.newContents); + const noNewline = (contents: string, lines: ReadonlyArray) => + lines.length > 0 && !contents.endsWith("\n") ? [NO_NEWLINE_MARKER] : []; + return [ + header, + `@@ -${hunkRange(1, oldLines.length)} +${hunkRange(1, newLines.length)} @@`, + ...oldLines.map((line) => `-${line}`), + ...noNewline(texts.oldContents, oldLines), + ...newLines.map((line) => `+${line}`), + ...noNewline(texts.newContents, newLines), + "", + ].join("\n"); +} + +/** + * One file's section of a unified patch, built here because Azure has no route that returns one: + * its diff routes name the files that changed, and the contents are a separate read per side. + */ +export function azureDevOpsFilePatch(input: { + readonly change: AzureDevOpsChangeEntry; + readonly texts: AzureDevOpsFileTexts; +}): AzureDevOpsFilePatch { + const header = patchHeader(input.change); + const { oldContents, newContents } = input.texts; + + if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { + // Git's own wording for a file it will not spell out. + const oldSide = quoteGitPatchPath(`a/${input.change.oldPath}`); + const newSide = quoteGitPatchPath(`b/${input.change.path}`); + const binary = `Binary files ${oldSide} and ${newSide} differ`; + return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false, edits: 0 }; + } + if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) { + return { section: `${header}\n`, truncated: true, abandoned: false, edits: 0 }; + } + + // A creation or deletion has nothing on one side, so writing both sides in full is already the + // minimal patch and needs no diff search. + const created = oldContents === "" && newContents !== ""; + const deleted = newContents === "" && oldContents !== ""; + if (created || deleted) { + const contents = created ? newContents : oldContents; + const lines = contentLines(contents); + // A `+`/`-` marker on every line can put a side that just fits the size ceiling over it. + // Checked against bytes-plus-marker-count rather than building the section, since that upper + // bound is cheaper and this is exactly the file shape that would cost the most to build. + if (byteLength(contents) + lines.length > MAX_FILE_BYTES) { + return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; + } + const section = replacementSection(header, input.texts); + if (byteLength(section) > MAX_FILE_BYTES) { + return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; + } + return { section, truncated: false, abandoned: false, edits: lines.length }; + } + + const patch = structuredPatch( + `a/${input.change.oldPath}`, + `b/${input.change.path}`, + oldContents, + newContents, + undefined, + undefined, + { + context: PATCH_CONTEXT_LINES, + maxEditLength: MAX_FILE_DIFF_EDITS, + timeout: MAX_FILE_DIFF_MILLIS, + }, + ); + // Hitting the edit ceiling lists the file without hunks rather than falling back to a full + // replacement: the ceiling is a distance, not a proportion, so a long file can reach it having + // changed in one small corner, and both sides in full would bury that corner in a wall of text. + if (patch === undefined) { + return { + section: `${header}\n`, + truncated: true, + abandoned: true, + edits: MAX_FILE_DIFF_EDITS, + }; + } + + let edits = 0; + const hunks = patch.hunks.map((hunk) => { + for (const line of hunk.lines) { + if (line.startsWith("+") || line.startsWith("-")) edits += 1; + } + return [ + `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`, + ...hunk.lines, + ].join("\n"); + }); + // A pure rename has no hunks to give but is still listed, since dropping it would take the file + // out of the change altogether. + const section = hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`; + // The edit ceiling bounds edit distance, not the hunks' size: a handful of very long lines plus + // context can still exceed the size ceiling even well inside the edit ceiling. + if (byteLength(section) > MAX_FILE_BYTES) { + return { section: `${header}\n`, truncated: true, abandoned: false, edits }; + } + return { section, truncated: false, abandoned: false, edits }; +} + +/** A file listed without its hunks, for when the host would not hand one of its two sides over. */ +export function azureDevOpsUnreadableFilePatch( + change: AzureDevOpsChangeEntry, +): AzureDevOpsFilePatch { + return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false, edits: 0 }; +} diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e863b8751d9d14576e4a319db357b1516ab0f66 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts @@ -0,0 +1,652 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodeItemContentJson, + decodeIterationChangesJson, + decodeIterationsJson, + decodePullRequestJson, + decodePullRequestListJson, + decodeThreadsJson, + decodeViewerJson, +} from "./azureDevOpsPullRequestJson.ts"; + +const REST_URL = + "https://dev.azure.com/acme/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/pullRequests/42"; + +/** Shaped after Azure's `GitPullRequest`, trimmed to the fields that are read. */ +function pullRequest(overrides: Record = {}): Record { + return { + pullRequestId: 42, + title: "Add the change requests page", + description: "Ships the page.", + status: "active", + isDraft: false, + mergeStatus: "succeeded", + createdBy: { displayName: "Bilal Hassan", uniqueName: "bilal@acme.dev" }, + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: REST_URL, + repository: { name: "web", project: { name: "platform" } }, + ...overrides, + }; +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +const asJson = (value: unknown) => JSON.stringify(value); + +describe("decodePullRequestListJson", () => { + it("reads a pull request as a change request", () => { + const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); + + expect(batch.items).toHaveLength(1); + expect(batch.items[0]).toMatchObject({ + number: 42, + title: "Add the change requests page", + // The login is an email, because that is what `az account show` reports to compare with. + author: { login: "bilal@acme.dev", name: "Bilal Hassan" }, + // Azure prefixes its refs, which no other host does. + headBranch: "feat/page", + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + }); + }); + + it("assembles a browser url when Azure reports no web link", () => { + const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); + + expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); + }); + + it("prefers the web link Azure sends when asked for one", () => { + const batch = expectSuccess( + decodePullRequestListJson( + asJson([ + pullRequest({ + _links: { + web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, + }, + }), + ]), + ), + ); + + expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); + }); + + it.each([ + ["active", "open"], + ["completed", "merged"], + ["abandoned", "closed"], + ["something new", "open"], + ])("reads the %s status as %s", (status, expected) => { + const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ status })]))); + + expect(batch.items[0]?.state).toBe(expected); + }); + + it.each([ + ["succeeded", "mergeable"], + ["conflicts", "conflicting"], + ["rejectedByPolicy", "conflicting"], + ["queued", "unknown"], + ["notSet", "unknown"], + ])("reads the %s merge status as %s", (mergeStatus, expected) => { + const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ mergeStatus })]))); + + expect(batch.items[0]?.mergeability).toBe(expected); + }); + + it("stands the closing time in for a last-touched time Azure does not keep", () => { + const batch = expectSuccess( + decodePullRequestListJson( + asJson([pullRequest({ status: "completed", closedDate: "2026-07-05T00:00:00Z" })]), + ), + ); + + expect(batch.items[0]).toMatchObject({ + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-05T00:00:00Z", + }); + }); + + it("skips a malformed row but still counts it, so paging does not stop early", () => { + const batch = expectSuccess( + decodePullRequestListJson(asJson([{ pullRequestId: "nope" }, pullRequest()])), + ); + + expect(batch.items).toHaveLength(1); + expect(batch.rawCount).toBe(2); + expect(batch.rawIndexes).toEqual([1]); + }); +}); + +describe("decodePullRequestJson", () => { + it("reads reviewers as review requests", () => { + const detail = expectSuccess( + decodePullRequestJson( + asJson( + pullRequest({ + reviewers: [{ displayName: "Julius", uniqueName: "julius@acme.dev", vote: 10 }], + }), + ), + ), + ); + + expect(detail?.reviewRequestLogins).toEqual(["julius@acme.dev"]); + expect(detail?.reviewers).toEqual([ + { login: "julius@acme.dev", name: "Julius", avatarUrl: null }, + ]); + }); + + it("reads auto-complete from whoever armed it, and its absence as nobody", () => { + const armed = expectSuccess( + decodePullRequestJson( + asJson(pullRequest({ autoCompleteSetBy: { displayName: "Bilal Hassan" } })), + ), + ); + expect(armed?.autoMergeEnabled).toBe(true); + + // Azure leaves the field out entirely rather than sending it empty, so its absence is the + // whole of what it says about auto-complete being off. + expect(expectSuccess(decodePullRequestJson(asJson(pullRequest())))?.autoMergeEnabled).toBe( + false, + ); + }); + + it("keeps the strategy stored with auto-complete", () => { + const armed = expectSuccess( + decodePullRequestJson( + asJson( + pullRequest({ + autoCompleteSetBy: { displayName: "Bilal Hassan" }, + completionOptions: { mergeStrategy: "squash" }, + }), + ), + ), + ); + + expect(armed).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); + + const unspecified = expectSuccess( + decodePullRequestJson( + asJson( + pullRequest({ + autoCompleteSetBy: { displayName: "Bilal Hassan" }, + completionOptions: { squashMerge: false }, + }), + ), + ), + ); + expect(unspecified?.autoMergeEnabled).toBe(true); + expect(unspecified?.autoMergeMethod).toBeUndefined(); + }); + + it("works out where the repository lives from what Azure returned", () => { + const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest()))); + + expect(detail?.location).toEqual({ project: "platform", repository: "web" }); + }); + + it("reports no repository location when Azure said too little to name one", () => { + // A web link places the pull request, but with no repository named there is nothing to + // address the routes that read its files and its conversation. + const detail = expectSuccess( + decodePullRequestJson( + asJson( + pullRequest({ + url: null, + repository: null, + _links: { + web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, + }, + }), + ), + ), + ); + + expect(detail?.location).toBeNull(); + }); + + it("returns nothing when Azure gave no way to place the pull request at all", () => { + const detail = expectSuccess( + decodePullRequestJson(asJson(pullRequest({ url: null, repository: null }))), + ); + + expect(detail).toBeNull(); + }); +}); + +describe("decodeViewerJson", () => { + it("reads the signed-in account name", () => { + expect(expectSuccess(decodeViewerJson(asJson({ user: { name: "bilal@acme.dev" } })))).toBe( + "bilal@acme.dev", + ); + }); + + it("returns nothing when nobody is signed in", () => { + expect(expectSuccess(decodeViewerJson(asJson({ user: null })))).toBeNull(); + }); +}); + +describe("decodeThreadsJson", () => { + it("takes every real comment of every thread, oldest first", () => { + const comments = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 2, + comments: [ + { + id: 1, + content: "Second remark.", + author: { displayName: "Julius", uniqueName: "julius@acme.dev" }, + publishedDate: "2026-07-03T00:00:00Z", + }, + ], + }, + { + id: 1, + comments: [ + // Azure's own activity notes are events rather than remarks. + { id: 1, content: "Bilal voted", commentType: "system", publishedDate: "x" }, + { + id: 2, + content: "First remark.", + author: { displayName: "Bilal", uniqueName: "bilal@acme.dev" }, + publishedDate: "2026-07-02T00:00:00Z", + }, + ], + }, + ], + }), + ), + ); + + expect(comments.map((comment) => comment.body)).toEqual(["First remark.", "Second remark."]); + expect(comments[0]).toMatchObject({ + kind: "issue-comment", + author: { login: "bilal@acme.dev" }, + }); + }); + + it("reads a thread pinned to a file as a review comment", () => { + const comments = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 3, + threadContext: { filePath: "/src/app.ts" }, + comments: [{ id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }], + }, + ], + }), + ), + ); + + expect(comments[0]).toMatchObject({ kind: "review-comment", path: "/src/app.ts" }); + }); + + it("keeps the replies under a thread, which are as much of the conversation", () => { + const comments = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 4, + threadContext: { filePath: "/src/app.ts" }, + comments: [ + { id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }, + { id: 2, content: "Renamed.", publishedDate: "2026-07-02T01:00:00Z" }, + { id: 3, content: "Thanks.", publishedDate: "2026-07-02T02:00:00Z" }, + ], + }, + ], + }), + ), + ); + + expect(comments.map((comment) => comment.id)).toEqual(["4:1", "4:2", "4:3"]); + }); + + it("drops deleted threads and threads with nothing to show", () => { + const comments = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 1, + isDeleted: true, + comments: [{ id: 1, content: "gone", publishedDate: "2026-07-02T00:00:00Z" }], + }, + { id: 2, comments: [] }, + { + id: 3, + comments: [{ id: 1, content: " ", publishedDate: "2026-07-02T00:00:00Z" }], + }, + ], + }), + ), + ); + + expect(comments).toEqual([]); + }); +}); + +describe("decodeIterationsJson", () => { + const iteration = (id: number, head: string, base: string) => ({ + id, + sourceRefCommit: { commitId: head }, + commonRefCommit: { commitId: base }, + targetRefCommit: { commitId: base }, + }); + + it("reads every push in order, oldest first", () => { + const iterations = expectSuccess( + decodeIterationsJson( + asJson({ value: [iteration(2, "bbb", "base"), iteration(1, "aaa", "base")] }), + ), + ); + + expect(iterations.map((entry) => entry.id)).toEqual([1, 2]); + expect(iterations.at(-1)).toEqual({ id: 2, headCommit: "bbb", mergeBaseCommit: "base" }); + }); + + it("skips a push Azure could not place both ends of", () => { + // A patch is taken over a range, and an iteration missing either end names no range at all. + const iterations = expectSuccess( + decodeIterationsJson( + asJson({ + value: [{ id: 1, sourceRefCommit: { commitId: "aaa" } }, iteration(2, "bbb", "base")], + }), + ), + ); + + expect(iterations.map((entry) => entry.id)).toEqual([2]); + }); +}); + +describe("decodeIterationChangesJson", () => { + it("names each changed file without the slash Azure leads its paths with", () => { + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }, + { + changeType: "edit", + item: { path: "/README.md", objectId: "8f80", originalObjectId: "0ca4" }, + }, + { changeType: "delete", item: { path: "/OLD.md", originalObjectId: "1111" } }, + ], + }), + ), + ); + + expect(page.changes.map((change) => [change.path, change.changeKind])).toEqual([ + ["DEMO.md", "new"], + ["README.md", "change"], + ["OLD.md", "deleted"], + ]); + }); + + it("keeps a space at the end of a file's name, which belongs to the name", () => { + // Git will carry a name that ends in a space, and the patch and the viewed mark are both + // keyed by it. Tidying it here files the change under a name nothing else uses. + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { changeType: "edit", item: { path: "/docs/readme.md ", objectId: "ec00" } }, + { + changeType: "rename", + sourceServerItem: "/docs/old.md ", + item: { path: "/docs/moved.md", objectId: "aaaa", originalObjectId: "aaaa" }, + }, + ], + }), + ), + ); + + expect(page.changes.map((change) => [change.path, change.oldPath])).toEqual([ + ["docs/readme.md ", "docs/readme.md "], + ["docs/moved.md", "docs/old.md "], + ]); + }); + + it("reads a rename as one file that moved, and says whether it also changed", () => { + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { + changeType: "rename", + sourceServerItem: "/docs/old.md", + item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, + }, + { + changeType: "edit, rename", + sourceServerItem: "/src/old.ts", + item: { path: "/src/new.ts", objectId: "bbbb", originalObjectId: "cccc" }, + }, + ], + }), + ), + ); + + expect(page.changes).toEqual([ + { + path: "docs/new.md", + oldPath: "docs/old.md", + changeKind: "rename-pure", + objectId: "aaaa", + originalObjectId: "aaaa", + }, + { + path: "src/new.ts", + oldPath: "src/old.ts", + changeKind: "rename-changed", + objectId: "bbbb", + originalObjectId: "cccc", + }, + ]); + }); + + it("drops the folders Azure lists alongside the files that changed", () => { + // A review shows files, and a folder has no content on either side to show for one. + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { changeType: "add", item: { path: "/docs", isFolder: true, gitObjectType: "tree" } }, + { changeType: "add", item: { path: "/docs/page.md", objectId: "dddd" } }, + ], + }), + ), + ); + + expect(page.changes.map((change) => change.path)).toEqual(["docs/page.md"]); + }); + + it("carries where the next page of a long change starts", () => { + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], + nextSkip: 2000, + }), + ), + ); + + expect(page.nextSkip).toBe(2000); + }); + + it("reads the last page, which names no page after it, as the end of the change", () => { + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], + }), + ), + ); + + expect(page.nextSkip).toBeNull(); + }); + + it("reads where a rename came from out of either of the two places Azure names it", () => { + // The iteration-changes route answers with `originalPath`; the commit routes answer with + // `sourceServerItem`, and both are the same fact under two names. + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { + changeType: "rename", + originalPath: "/docs/old.md", + item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, + }, + ], + }), + ), + ); + + expect(page.changes.at(0)?.oldPath).toBe("docs/old.md"); + }); +}); + +describe("decodeItemContentJson", () => { + it("reads the file's text out of the envelope Azure wraps it in", () => { + expect( + expectSuccess(decodeItemContentJson(asJson({ path: "/a.md", content: "one\ntwo" }))), + ).toEqual({ contents: "one\ntwo", isBinary: false }); + }); + + it("reads an empty file as empty rather than as a failure to look", () => { + expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toEqual({ + contents: "", + isBinary: false, + }); + }); + + it("keeps Azure's own word that a file is binary", () => { + // Which it answers base64-encoded, so nothing in the text it sent would give it away. + expect( + expectSuccess( + decodeItemContentJson( + asJson({ path: "/logo.png", content: "b2xk", contentMetadata: { isBinary: true } }), + ), + ), + ).toEqual({ contents: "b2xk", isBinary: true }); + }); +}); + +/** + * Every other fixture in this file is written from Azure's published contract. These are the + * shapes a real organisation answered with, read back through `az devops invoke` rather than + * `az rest`: the extension hands over the route's own body with one key of its own added, and a + * live repository leaves out fields the contract documents. + */ +describe("what az devops invoke answers with", () => { + it("reads the envelope the extension adds its own continuation token to", () => { + // Set on every JSON body it returns, from a response header these routes do not send, so it + // arrives as null rather than not at all. Nothing reads it, and it must not fail the decode. + expect( + expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))), + ).toEqual([]); + + expect( + expectSuccess( + decodeIterationsJson( + asJson({ + count: 1, + continuation_token: null, + value: [ + { + id: 1, + sourceRefCommit: { commitId: "f4031105213c68f197cd46ea303bb5e72acc889a" }, + commonRefCommit: { commitId: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35" }, + }, + ], + }), + ), + ), + ).toEqual([ + { + id: 1, + headCommit: "f4031105213c68f197cd46ea303bb5e72acc889a", + mergeBaseCommit: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35", + }, + ]); + }); + + it("keeps the files of a change that names neither their object type nor their page after it", () => { + // A live iteration states `changeType`, `item.path` and `item.objectId` and nothing else: no + // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. Each of those absences + // is what the defaults in the decoder are for, and reading any of them as stated would drop + // every file of every Azure change request. + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + continuation_token: null, + changeEntries: [ + { changeType: "add", item: { path: "/DEMO.md", objectId: "EC005DB24" } }, + { changeType: "edit", item: { path: "/README.md", objectId: "8F8047A49" } }, + ], + }), + ), + ); + + expect(page.nextSkip).toBeNull(); + expect(page.changes).toEqual([ + { + path: "DEMO.md", + oldPath: "DEMO.md", + changeKind: "new", + objectId: "EC005DB24", + originalObjectId: null, + }, + { + path: "README.md", + oldPath: "README.md", + changeKind: "change", + objectId: "8F8047A49", + originalObjectId: null, + }, + ]); + }); + + it("reads a text file whose content type Azure calls a stream", () => { + // `contentMetadata` comes back for a markdown file with `application/octet-stream` on it and + // no `isBinary` at all, so the content type is not the field to ask, and its absence is the + // answer that the text is text. + expect( + expectSuccess( + decodeItemContentJson( + asJson({ + path: "/README.md", + objectId: "8f8047a49", + gitObjectType: "blob", + content: "# T3Demo\n", + contentMetadata: { + contentType: "application/octet-stream", + encoding: 65001, + extension: "md", + fileName: "README.md", + }, + continuation_token: null, + }), + ), + ), + ).toEqual({ contents: "# T3Demo\n", isBinary: false }); + }); +}); diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5e9694f1cd37cb25e438de8f3267f47ceda0e24 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts @@ -0,0 +1,574 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestComment, + PullRequestMergeMethod, + PullRequestMergeability, + PullRequestState, +} from "@t3tools/contracts"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +import { azureDevOpsPullRequestWebUrl } from "./azureDevOpsPullRequests.ts"; + +/** + * Azure's enums are decoded as plain strings and normalized here, in the same tolerant style as + * the other hosts: a new merge status must not fail a whole payload. Every field beyond the + * identity is optional, because `az repos pr` returns rather more or less of the REST object + * depending on the command. + */ +const RawIdentitySchema = Schema.Struct({ + displayName: Schema.optional(Schema.NullOr(Schema.String)), + /** An email or UPN, which is what `az account show` reports for the signed-in user. */ + uniqueName: Schema.optional(Schema.NullOr(Schema.String)), + imageUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawPullRequestSchema = Schema.Struct({ + pullRequestId: Schema.Int, + title: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** + * Who armed auto-complete, which is the only thing Azure says about it: the field carries an + * identity while the pull request is set to complete on its own, and Azure leaves it out + * entirely once nobody has. So its presence is the answer, and there is no third state. + */ + autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), + completionOptions: Schema.optional( + Schema.NullOr( + Schema.Struct({ + mergeStrategy: Schema.optional(Schema.NullOr(Schema.String)), + squashMerge: Schema.optional(Schema.NullOr(Schema.Boolean)), + }), + ), + ), + mergeStatus: Schema.optional(Schema.NullOr(Schema.String)), + createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), + reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))), + // Required, and required to be non-empty: the wire contract will not carry a change request + // without a branch or a created time, so a row missing one is skipped rather than breaking the + // response it travels in. + sourceRefName: TrimmedNonEmptyString, + targetRefName: TrimmedNonEmptyString, + creationDate: TrimmedNonEmptyString, + closedDate: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), + repository: Schema.optional( + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + webUrl: Schema.optional(Schema.NullOr(Schema.String)), + project: Schema.optional( + Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + }), + ), + ), + _links: Schema.optional( + Schema.NullOr( + Schema.Struct({ + web: Schema.optional( + Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), + ), + }), + ), + ), +}); + +/** A pull request thread, which is how Azure keeps its conversation. */ +const RawThreadSchema = Schema.Struct({ + id: Schema.Int, + isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), + threadContext: Schema.optional( + Schema.NullOr(Schema.Struct({ filePath: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + comments: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.Int)), + content: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawIdentitySchema)), + publishedDate: Schema.optional(Schema.NullOr(Schema.String)), + isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** `system` marks the notes Azure writes itself, which are events, not comments. */ + commentType: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + ), +}); + +const RawThreadPageSchema = Schema.Struct({ + value: Schema.Array(Schema.Unknown), +}); + +const RawViewerSchema = Schema.Struct({ + user: Schema.optional( + Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +/** + * Where a repository lives, in the terms Azure's REST routes address it by. They take the project + * and the repository as separate route parameters rather than as one path, so the pair travels + * together rather than as a URL that would have to be taken apart again to use. + */ +export interface AzureDevOpsRepositoryLocation { + readonly project: string; + readonly repository: string; +} + +export interface AzureDevOpsPullRequest { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + readonly createdAt: string; + /** + * Azure records no last-touched time on a pull request, so the closing time stands in where + * there is one and the creation time otherwise. The same fallback the rest of the app uses. + */ + readonly updatedAt: string; + readonly closedAt: string | null; + readonly body: string; + readonly reviewRequestLogins: ReadonlyArray; + readonly reviewers: ReadonlyArray; + /** Where this pull request lives, when Azure said enough to work it out. */ + readonly location: AzureDevOpsRepositoryLocation | null; + /** Whether Azure is set to complete this on its own once its policies pass. */ + readonly autoMergeEnabled: boolean; + /** The completion strategy Azure stored with auto-complete, where it reported one. */ + readonly autoMergeMethod?: PullRequestMergeMethod; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function normalizeRefName(refName: string): string { + return refName.trim().replace(/^refs\/heads\//, ""); +} + +/** A login has to compare against `az account show`, which reports an email. */ +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName); + return login === null + ? null + : { login, name: trimmed(raw?.displayName), avatarUrl: trimmed(raw?.imageUrl) }; +} + +function toState(raw: Schema.Schema.Type): PullRequestState { + switch (raw.status?.trim().toLowerCase()) { + case "completed": + return "merged"; + case "abandoned": + return "closed"; + default: + return "open"; + } +} + +function toMergeability(value: string | null | undefined): PullRequestMergeability { + switch (value?.trim().toLowerCase()) { + case "succeeded": + return "mergeable"; + case "conflicts": + case "failure": + case "rejectedbypolicy": + return "conflicting"; + default: + // `queued` and `notSet` mean Azure has not finished checking. + return "unknown"; + } +} + +/** + * Where a pull request's own repository sits. Taken from what Azure returned rather than from the + * local remote, whose shape differs between the modern, legacy and SSH forms. + */ +function toLocation( + raw: Schema.Schema.Type, +): AzureDevOpsRepositoryLocation | null { + const project = trimmed(raw.repository?.project?.name); + const repository = trimmed(raw.repository?.name); + if (project === null || repository === null) return null; + return { project, repository }; +} + +function toAutoMergeMethod( + raw: Schema.Schema.Type, +): PullRequestMergeMethod | undefined { + if (raw.autoCompleteSetBy == null) return undefined; + switch (raw.completionOptions?.mergeStrategy?.trim().toLowerCase()) { + case "squash": + return "squash"; + case "rebase": + case "rebasemerge": + return "rebase"; + case "nofastforward": + return "merge"; + default: + return raw.completionOptions?.squashMerge === true ? "squash" : undefined; + } +} + +/** + * Null when Azure said too little to place the pull request: a row with no browser url and no + * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract + * refuses to carry it either. + */ +function toPullRequest( + raw: Schema.Schema.Type, +): AzureDevOpsPullRequest | null { + const autoMergeMethod = toAutoMergeMethod(raw); + const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { + const actor = toActor(reviewer); + return actor === null ? [] : [actor]; + }); + const closedAt = trimmed(raw.closedDate); + const url = trimmed( + azureDevOpsPullRequestWebUrl({ + pullRequestId: raw.pullRequestId, + webLink: raw._links?.web?.href, + repositoryWebUrl: raw.repository?.webUrl, + restApiUrl: raw.url, + projectName: raw.repository?.project?.name, + repositoryName: raw.repository?.name, + }), + ); + const headBranch = trimmed(normalizeRefName(raw.sourceRefName)); + const baseBranch = trimmed(normalizeRefName(raw.targetRefName)); + if (url === null || headBranch === null || baseBranch === null) return null; + return { + number: raw.pullRequestId, + title: raw.title, + url, + author: toActor(raw.createdBy), + headBranch, + baseBranch, + state: toState(raw), + isDraft: raw.isDraft ?? false, + mergeability: toMergeability(raw.mergeStatus), + createdAt: raw.creationDate, + updatedAt: closedAt ?? raw.creationDate, + closedAt, + body: raw.description ?? "", + reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), + reviewers, + location: toLocation(raw), + autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, + ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); +const decodePullRequest = decodeJsonResult(RawPullRequestSchema); +const decodeThreadPage = decodeJsonResult(RawThreadPageSchema); +const decodeThreadEntry = Schema.decodeUnknownExit(RawThreadSchema); +const decodeViewer = decodeJsonResult(RawViewerSchema); + +type DecodeFailure = Cause.Cause; + +export interface AzureDevOpsPullRequestBatch { + readonly items: ReadonlyArray; + /** Zero-based positions of the decoded items in Azure's raw page. */ + readonly rawIndexes: ReadonlyArray; + /** Rows Azure returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +/** Malformed entries are skipped rather than failing the batch, as on the other hosts. */ +export function decodePullRequestListJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: AzureDevOpsPullRequest[] = []; + const rawIndexes: number[] = []; + for (const [rawIndex, entry] of decoded.success.entries()) { + const item = decodePullRequestEntry(entry); + if (Exit.isFailure(item)) continue; + const pullRequest = toPullRequest(item.value); + if (pullRequest !== null) { + items.push(pullRequest); + rawIndexes.push(rawIndex); + } + } + return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); +} + +/** Null carries "Azure answered, but with too little to use", which the caller reports. */ +export function decodePullRequestJson( + raw: string, +): Result.Result { + const decoded = decodePullRequest(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toPullRequest(decoded.success)) + : Result.fail(decoded.failure); +} + +/** `az account show --query user` reports the signed-in account, whose name is an email. */ +export function decodeViewerJson(raw: string): Result.Result { + const decoded = decodeViewer(raw); + return Result.isSuccess(decoded) + ? Result.succeed(trimmed(decoded.success.user?.name)) + : Result.fail(decoded.failure); +} + +/** + * Azure keeps its conversation as threads of comments, and every one of them is a remark + * somebody wrote: a reply under a thread is as much of the conversation as the line that opened + * it. A thread pinned to a file is a line-level review comment. + * + * Azure answers the whole thread collection in one response, with no cursor and no page to + * follow, so what this returns is everything the host has. + */ +export function decodeThreadsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeThreadPage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const comments: PullRequestComment[] = []; + for (const entry of decoded.success.value) { + const decodedThread = decodeThreadEntry(entry); + if (Exit.isFailure(decodedThread)) continue; + const thread = decodedThread.value; + if (thread.isDeleted === true) continue; + const path = trimmed(thread.threadContext?.filePath); + for (const comment of thread.comments ?? []) { + const publishedDate = trimmed(comment.publishedDate); + if ( + comment.isDeleted === true || + comment.commentType?.trim().toLowerCase() === "system" || + (comment.content ?? "").trim().length === 0 || + publishedDate === null + ) { + continue; + } + comments.push({ + id: `${thread.id}:${comment.id ?? 0}`, + kind: path === null ? "issue-comment" : "review-comment", + author: toActor(comment.author), + body: comment.content ?? "", + createdAt: publishedDate, + url: null, + path, + reviewState: null, + }); + } + } + return Result.succeed( + comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + ); +} + +/** + * One push's worth of a pull request. Azure records every push as an iteration and keys the whole + * review off them: the changed files, and the marks a reader leaves on those files, both hang + * from an iteration rather than from the pull request. + */ +const RawIterationSchema = Schema.Struct({ + id: Schema.Int, + sourceRefCommit: Schema.optional( + Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + commonRefCommit: Schema.optional( + Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) }); + +const RawChangeEntrySchema = Schema.Struct({ + changeType: Schema.optional(Schema.NullOr(Schema.String)), + sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)), + /** Where a renamed file came from. Azure states it here on an iteration's changes. */ + originalPath: Schema.optional(Schema.NullOr(Schema.String)), + item: Schema.optional( + Schema.NullOr( + Schema.Struct({ + path: Schema.optional(Schema.NullOr(Schema.String)), + objectId: Schema.optional(Schema.NullOr(Schema.String)), + originalObjectId: Schema.optional(Schema.NullOr(Schema.String)), + /** Azure marks a directory this way; a review has nothing to show for one. */ + isFolder: Schema.optional(Schema.NullOr(Schema.Boolean)), + gitObjectType: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +const RawChangePageSchema = Schema.Struct({ + changeEntries: Schema.Array(Schema.Unknown), + /** Where the page after this one starts. Azure leaves it out on the last page. */ + nextSkip: Schema.optional(Schema.NullOr(Schema.Number)), +}); + +const RawItemContentSchema = Schema.Struct({ + content: Schema.optional(Schema.NullOr(Schema.String)), + /** What Azure makes of the file it is handing over, which is where it says it is not text. */ + contentMetadata: Schema.optional( + Schema.NullOr(Schema.Struct({ isBinary: Schema.optional(Schema.NullOr(Schema.Boolean)) })), + ), +}); + +/** The head and the merge base of one iteration, which is the range its patch is taken over. */ +export interface AzureDevOpsIteration { + readonly id: number; + readonly headCommit: string; + readonly mergeBaseCommit: string; +} + +/** + * What one file did across an iteration. `oldPath` differs from `path` only for a rename, which + * Azure reports by naming the file's previous home rather than as a delete and an add. + */ +export interface AzureDevOpsChangeEntry { + readonly path: string; + readonly oldPath: string; + readonly changeKind: "new" | "deleted" | "change" | "rename-pure" | "rename-changed"; + readonly objectId: string | null; + readonly originalObjectId: string | null; +} + +/** + * One page of what an iteration changed, and where the next one starts. Azure pages this route + * rather than answering with the whole change, so a review large enough to be paged is followed + * to its end instead of being cut off at the first page's worth. + */ +export interface AzureDevOpsChangePage { + readonly changes: ReadonlyArray; + readonly nextSkip: number | null; +} + +/** One file's text at one commit, and whether Azure says the text is text at all. */ +export interface AzureDevOpsItemContent { + readonly contents: string; + readonly isBinary: boolean; +} + +const decodeIterationPage = decodeJsonResult(RawIterationPageSchema); +const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema); +const decodeChangePage = decodeJsonResult(RawChangePageSchema); +const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema); +const decodeItemContent = decodeJsonResult(RawItemContentSchema); + +/** + * Azure leads a path with a slash that every other host and every patch omits. Not trimmed like + * the rest of this payload, since a leading/trailing space is a legal part of a file's name and + * the patch and viewed mark are keyed by the untrimmed path. + */ +function toRepositoryPath(value: string | null | undefined): string | null { + if (value === undefined || value === null) return null; + const path = value.replace(/^\/+/, ""); + return path.length === 0 ? null : path; +} + +/** + * Azure names a change with one word or two, and a rename arrives either alone or alongside the + * edit that came with it. Anything it has added since reads as a plain change, which shows the + * file rather than dropping it from the review. + */ +function toChangeKind( + raw: string | null | undefined, + renamed: boolean, +): AzureDevOpsChangeEntry["changeKind"] { + const parts = new Set( + (raw ?? "") + .toLowerCase() + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0), + ); + if (parts.has("delete")) return "deleted"; + if (renamed || parts.has("rename")) return parts.has("edit") ? "rename-changed" : "rename-pure"; + if (parts.has("add")) return "new"; + return "change"; +} + +export function decodeIterationsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeIterationPage(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const iterations: AzureDevOpsIteration[] = []; + for (const entry of decoded.success.value) { + const decodedIteration = decodeIterationEntry(entry); + if (Exit.isFailure(decodedIteration)) continue; + const iteration = decodedIteration.value; + const headCommit = trimmed(iteration.sourceRefCommit?.commitId); + const mergeBaseCommit = trimmed(iteration.commonRefCommit?.commitId); + // An iteration Azure cannot place both ends of names no range, and a patch needs both. + if (headCommit === null || mergeBaseCommit === null) continue; + iterations.push({ id: iteration.id, headCommit, mergeBaseCommit }); + } + return Result.succeed(iterations.toSorted((left, right) => left.id - right.id)); +} + +export function decodeIterationChangesJson( + raw: string, +): Result.Result { + const decoded = decodeChangePage(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const changes: AzureDevOpsChangeEntry[] = []; + for (const entry of decoded.success.changeEntries) { + const decodedChange = decodeChangeEntry(entry); + if (Exit.isFailure(decodedChange)) continue; + const change = decodedChange.value; + const path = toRepositoryPath(change.item?.path); + if (path === null) continue; + // Azure lists the folders a change touched alongside the files themselves. A review shows + // files, and a folder has no content to show for either side of one. + if (change.item?.isFolder === true) continue; + if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue; + // Azure names where a renamed file came from in either of two places depending on the route + // and the version, so both are read and the current path stands in when neither is there. + const oldPath = + toRepositoryPath(change.sourceServerItem) ?? toRepositoryPath(change.originalPath) ?? path; + changes.push({ + path, + oldPath, + changeKind: toChangeKind(change.changeType, oldPath !== path), + objectId: trimmed(change.item?.objectId), + originalObjectId: trimmed(change.item?.originalObjectId), + }); + } + const nextSkip = decoded.success.nextSkip ?? null; + return Result.succeed({ + changes, + nextSkip: nextSkip !== null && Number.isSafeInteger(nextSkip) && nextSkip > 0 ? nextSkip : null, + }); +} + +/** + * Azure answers an absent file with an empty body rather than an error, which reads as empty. + * Whether the bytes are text is Azure's own call, not this decoder's to guess from content. + */ +export function decodeItemContentJson( + raw: string, +): Result.Result { + const decoded = decodeItemContent(raw); + return Result.isSuccess(decoded) + ? Result.succeed({ + contents: decoded.success.content ?? "", + isBinary: decoded.success.contentMetadata?.isBinary === true, + }) + : Result.fail(decoded.failure); +} diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts new file mode 100644 index 0000000000000000000000000000000000000000..6179a61aa65d093829577491d1216c3a3e514436 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts @@ -0,0 +1,222 @@ +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +export interface NormalizedAzureDevOpsPullRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt: Option.Option; +} + +const AzureDevOpsPullRequestSchema = Schema.Struct({ + pullRequestId: PositiveInt, + title: TrimmedNonEmptyString, + url: Schema.optional(Schema.String), + repository: Schema.optional( + Schema.Struct({ + name: Schema.optional(Schema.String), + webUrl: Schema.optional(Schema.String), + project: Schema.optional( + Schema.Struct({ + name: Schema.optional(Schema.String), + }), + ), + }), + ), + sourceRefName: TrimmedNonEmptyString, + targetRefName: TrimmedNonEmptyString, + status: Schema.String, + isDraft: Schema.optional(Schema.Boolean), + creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + _links: Schema.optional( + Schema.Struct({ + web: Schema.optional( + Schema.Struct({ + href: Schema.String, + }), + ), + }), + ), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeRefName(refName: string): string { + return refName.trim().replace(/^refs\/heads\//, ""); +} + +function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" | "merged" { + switch (status.trim().toLowerCase()) { + case "completed": + return "merged"; + case "abandoned": + return "closed"; + default: + return "open"; + } +} + +function encodeAzureDevOpsPathSegment(segment: string): string { + return encodeURIComponent(segment); +} + +/** + * The organization root a REST url belongs to, which is where a browser url has to be hung when + * Azure answered with neither a web link nor a repository url. Read from what Azure returned + * rather than from the local remote, whose shape varies. + */ +function azureDevOpsOrganizationBaseFromRestApiUrl( + value: string | null | undefined, +): string | null { + const rawUrl = trimOptionalString(value); + if (!rawUrl) { + return null; + } + + try { + const url = new URL(rawUrl); + const hostname = url.hostname.toLowerCase(); + const pathSegments = url.pathname.split("/").filter((segment) => segment.length > 0); + const isAzureRestUrl = pathSegments.some((segment) => segment.toLowerCase() === "_apis"); + if (!isAzureRestUrl) { + return null; + } + + if (hostname === "dev.azure.com") { + const organization = pathSegments[0]; + return organization ? `${url.origin}/${organization}` : null; + } + + if (hostname.endsWith(".visualstudio.com")) { + return url.origin; + } + + return null; + } catch { + return null; + } +} + +/** + * Where a pull request lives in a browser. Azure answers with a web link when asked for one and + * otherwise leaves it to be assembled, so all three routes are tried in the order they can be + * trusted. Takes plain fields so both the source control provider and the pull requests page + * can share it. + */ +export function azureDevOpsPullRequestWebUrl(input: { + readonly pullRequestId: number; + readonly webLink?: string | null | undefined; + readonly repositoryWebUrl?: string | null | undefined; + readonly restApiUrl?: string | null | undefined; + readonly projectName?: string | null | undefined; + readonly repositoryName?: string | null | undefined; +}): string { + const webLink = trimOptionalString(input.webLink); + if (webLink) { + return webLink; + } + + const repositoryWebUrl = trimOptionalString(input.repositoryWebUrl); + if (repositoryWebUrl) { + return `${repositoryWebUrl.replace(/\/+$/, "")}/pullrequest/${input.pullRequestId}`; + } + + const organizationBase = azureDevOpsOrganizationBaseFromRestApiUrl(input.restApiUrl); + const projectName = trimOptionalString(input.projectName); + const repositoryName = trimOptionalString(input.repositoryName); + if (organizationBase && projectName && repositoryName) { + const encodedProjectName = encodeAzureDevOpsPathSegment(projectName); + const encodedRepositoryName = encodeAzureDevOpsPathSegment(repositoryName); + return `${organizationBase}/${encodedProjectName}/_git/${encodedRepositoryName}/pullrequest/${input.pullRequestId}`; + } + + return trimOptionalString(input.restApiUrl) ?? ""; +} + +function normalizeAzureDevOpsPullRequestUrl( + raw: Schema.Schema.Type, +): string { + return azureDevOpsPullRequestWebUrl({ + pullRequestId: raw.pullRequestId, + webLink: raw._links?.web?.href, + repositoryWebUrl: raw.repository?.webUrl, + restApiUrl: raw.url, + projectName: raw.repository?.project?.name, + repositoryName: raw.repository?.name, + }); +} + +function normalizeAzureDevOpsPullRequestRecord( + raw: Schema.Schema.Type, +): NormalizedAzureDevOpsPullRequestRecord { + const state = normalizeAzureDevOpsPullRequestState(raw.status); + const terminalAt = Option.match(raw.closedDate ?? Option.none(), { + onNone: () => null, + onSome: DateTime.formatIso, + }); + return { + number: raw.pullRequestId, + title: raw.title, + url: normalizeAzureDevOpsPullRequestUrl(raw), + baseRefName: normalizeRefName(raw.targetRefName), + headRefName: normalizeRefName(raw.sourceRefName), + state, + ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: state === "closed" ? terminalAt : null, + mergedAt: state === "merged" ? terminalAt : null, + updatedAt: (raw.closedDate ?? Option.none()).pipe( + Option.orElse(() => raw.creationDate ?? Option.none()), + ), + }; +} + +const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema); +const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema); + +export function decodeAzureDevOpsPullRequestListJson( + raw: string, +): Result.Result< + ReadonlyArray, + Cause.Cause +> { + const result = decodeAzureDevOpsPullRequestList(raw); + if (Result.isSuccess(result)) { + const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = []; + for (const entry of result.success) { + const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry); + if (Exit.isFailure(decodedEntry)) { + continue; + } + pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value)); + } + return Result.succeed(pullRequests); + } + return Result.fail(result.failure); +} + +export function decodeAzureDevOpsPullRequestJson( + raw: string, +): Result.Result> { + const result = decodeAzureDevOpsPullRequest(raw); + if (Result.isSuccess(result)) { + return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success)); + } + return Result.fail(result.failure); +} diff --git a/packages/source-control-azure-devops/src/server/driver.ts b/packages/source-control-azure-devops/src/server/driver.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a0c42c9f3ca4060a52673aa5b2989d695971845 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/driver.ts @@ -0,0 +1,19 @@ +/** + * The Azure DevOps driver: repository operations, discovery, and pull requests through `az`. + * + * @module source-control-azure-devops/server/driver + */ +import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; + +import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; +import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; + +export const driver = defineSourceControlDriver({ + kind: "azure-devops", + make: Effect.all({ + sourceControl: AzureDevOpsSourceControlProvider.make, + discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery), + pullRequests: AzureDevOpsPullRequestProvider.make, + }), +}); diff --git a/packages/source-control-azure-devops/tsconfig.json b/packages/source-control-azure-devops/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..374bac55202ddcad695ede1c567ec219a61e9c48 --- /dev/null +++ b/packages/source-control-azure-devops/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e91a9bb0555fb177e7c964a5f19fa69b727ff131..54036c86d8626778e14492383cd9ff556d2e5a97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -648,15 +648,15 @@ importers: '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi + '@t3tools/source-control-azure-devops': + specifier: workspace:* + version: link:../../packages/source-control-azure-devops '@t3tools/source-control-core': specifier: workspace:* version: link:../../packages/source-control-core '@t3tools/source-control-forgejo': specifier: workspace:* version: link:../../packages/source-control-forgejo - diff: - specifier: 8.0.3 - version: 8.0.3 effect: specifier: 4.0.2 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) @@ -1516,6 +1516,40 @@ importers: specifier: 1.0.0 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) + packages/source-control-azure-devops: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + diff: + specifier: 8.0.3 + version: 8.0.3 + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@effect/platform-node': + specifier: 4.0.2 + 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) + '@effect/vitest': + specifier: 4.0.2 + 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) + '@t3tools/source-control-testing': + specifier: workspace:* + version: link:../source-control-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + 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) + packages/source-control-core: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 6482dd28f295f7e118204d512b527c8e53b86851..7f29acf844a0dedb36c054e9e6c94ff80c91b3f1 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -37,6 +37,7 @@ const workspaceFiles = [ "packages/provider-grok/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", + "packages/source-control-azure-devops/package.json", "packages/source-control-core/package.json", "packages/source-control-forgejo/package.json", "packages/source-control-testing/package.json",