507 lines · 15.4 KB · TypeScript
RawDownload
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/unstable/process"; import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; const mockedExecute = vi.fn<AzureDevOpsCli.AzureDevOpsCli["Service"]["execute"]>(); 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,  };} function pullRequestRows(  count: number,  firstNumber: number,): ReadonlyArray<Record<string, unknown>> {  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<string> {  const call = mockedExecute.mock.calls[index];  assert.isDefined(call);  return call[0].args;} 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 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(              // @effect-diagnostics-next-line preferSchemaOverJson:off              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-diagnostics-next-line preferSchemaOverJson:off        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.each([    { 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("reads the conversation through the REST API, pinned to a version", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(        Effect.succeed(          output(            // @effect-diagnostics-next-line preferSchemaOverJson:off            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",        threadsUrl: "https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads",      });       assert.strictEqual(comments.length, 1);      expect(argsOfCall(0)).toContain("rest");      expect(argsOfCall(0)).toContain(        "https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads?api-version=7.1",      );    }),  );   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.            // @effect-diagnostics-next-line preferSchemaOverJson:off            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,        }),

Lines 1–500 of 507.