613 lines · 18.8 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("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", "--squash", "false"] },    { 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("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({

Lines 1–500 of 613.