1144 lines · 38.0 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 GitLabCli from "../sourceControl/GitLabCli.ts";import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; const mockedExecute = vi.fn<GitLabCli.GitLabCli["Service"]["execute"]>(); const layer = it.layer(  GitLabPullRequestCli.layer.pipe(    Layer.provide(      Layer.mock(GitLabCli.GitLabCli)({        execute: mockedExecute,      }),    ),  ),); function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) {  return {    exitCode: ChildProcessSpawner.ExitCode(0),    stdout,    stderr: "",    stdoutTruncated,    stderrTruncated: false,    stdoutInvalidUtf8,  };} function mergeRequests(count: number, firstNumber: number): string {  return JSON.stringify(    Array.from({ length: count }, (_, index) => ({      iid: firstNumber + index,      title: `Merge request ${firstNumber + index}`,      web_url: `https://gitlab.com/acme/web/-/merge_requests/${firstNumber + index}`,      source_branch: "feat/page",      target_branch: "main",      created_at: "2026-07-01T00:00:00Z",      updated_at: "2026-07-02T00:00:00Z",    })),  );} /** A page of `/diffs` as GitLab serves it, a full one unless the count says otherwise. */function diffPage(firstIndex: number, count = 100): string {  return JSON.stringify(    Array.from({ length: count }, (_, index) => ({      old_path: `src/${firstIndex + index}.ts`,      new_path: `src/${firstIndex + index}.ts`,      diff: "@@ -1 +1 @@\n-a\n+b\n",    })),  );} /** A page of merge request notes, which is what the flat conversation is read from. */function notes(count: number, firstId: number): string {  return JSON.stringify(    Array.from({ length: count }, (_, index) => ({      id: firstId + index,      body: `note ${firstId + index}`,      author: { username: "bilal" },      created_at: "2026-07-01T00:00:00Z",    })),  );} /** Who opened the merge request, and somebody already reviewing it. */const author = { id: 1, username: "bilal" };const reviewer = { id: 5, username: "octocat" }; /** One merge request as `/merge_requests/:iid` answers with it. */function mergeRequestJson(overrides: Record<string, unknown>): string {  return JSON.stringify({    iid: 7,    title: "Merge request 7",    web_url: "https://gitlab.com/acme/web/-/merge_requests/7",    source_branch: "feat/page",    target_branch: "main",    created_at: "2026-07-01T00:00:00Z",    updated_at: "2026-07-02T00:00:00Z",    author,    ...overrides,  });} /** The endpoint or subcommand of the nth glab invocation. */function argsOfCall(index: number): ReadonlyArray<string> {  return callAt(index).args;} /** The whole nth invocation, so a request body can be asserted alongside its path. */function callAt(index: number) {  const call = mockedExecute.mock.calls[index];  assert.isDefined(call);  return call[0];} afterEach(() => {  mockedExecute.mockReset();}); layer("GitLabPullRequestCli.layer", (it) => {  it.effect("asks GitLab for one row more than the page, to probe for a next page", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1))));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       const batch = yield* cli.listMergeRequests({        cwd: "/w",        repository: "acme/web",        state: "open",        involvement: "all",        viewer: "bilal",        limit: 10,      });       assert.strictEqual(batch.items.length, 3);      assert.isFalse(batch.truncated);      assert.strictEqual(batch.cursorAdvance, 3);      const path = argsOfCall(0)[1] ?? "";      expect(path).toContain("projects/acme%2Fweb/merge_requests");      expect(path).toContain("per_page=11");      expect(path).toContain("state=opened");    }),  );   it.effect("walks pages at a fixed size, because GitLab pages by offset", () =>    Effect.gen(function* () {      mockedExecute        .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 1))))        .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 101))));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       const batch = yield* cli.listMergeRequests({        cwd: "/w",        repository: "acme/web",        state: "open",        involvement: "all",        viewer: "bilal",        limit: 150,      });       assert.strictEqual(batch.items.length, 150);      assert.isTrue(batch.truncated);      for (const index of [0, 1]) {        expect(argsOfCall(index)[1]).toContain("per_page=100");      }      expect(argsOfCall(0)[1]).toContain("page=1");      expect(argsOfCall(1)[1]).toContain("page=2");    }),  );   it.effect("hands a search to GitLab's own search parameter", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       yield* cli.listMergeRequests({        cwd: "/w",        repository: "acme/web",        state: "open",        involvement: "all",        viewer: "bilal",        limit: 10,        query: "page",      });       // GitLab matches `search` against title and description, which is more than the row shows.      expect(argsOfCall(0)[1]).toContain("search=page");    }),  );   it.effect("carries on from the number of rows already delivered", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1))));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       yield* cli.listMergeRequests({        cwd: "/w",        repository: "acme/web",        state: "open",        involvement: "all",        viewer: "bilal",        limit: 10,        cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 },      });       // GitLab's timestamp filter has no tie-breaker, so an offset is what advances through a      // boundary shared by more rows than one page can hold.      const path = argsOfCall(0)[1] ?? "";      expect(path).not.toContain("updated_before=");      expect(path).toContain("order_by=updated_at");      expect(path).toContain("per_page=11");      expect(path).toContain("page=1");    }),  );   it.effect("advances beyond several pages sharing the cursor timestamp", () =>    Effect.gen(function* () {      mockedExecute        .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 144))))        .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 155))));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       const batch = yield* cli.listMergeRequests({        cwd: "/w",        repository: "acme/web",        state: "open",        involvement: "all",        viewer: "bilal",        limit: 10,        cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 150 },      });       expect(argsOfCall(0)[1]).toContain("per_page=11");      expect(argsOfCall(0)[1]).toContain("page=14");      expect(argsOfCall(1)[1]).toContain("page=15");      expect(batch.items.map((item) => item.number)).toEqual([        151, 152, 153, 154, 155, 156, 157, 158, 159, 160,      ]);      assert.isTrue(batch.truncated);    }),  );   it.effect("advances the cursor through malformed raw rows", () =>    Effect.gen(function* () {      // @effect-diagnostics-next-line preferSchemaOverJson:off      const rows = JSON.parse(mergeRequests(2, 1)) as ReadonlyArray<unknown>;      mockedExecute.mockReturnValueOnce(        // @effect-diagnostics-next-line preferSchemaOverJson:off        Effect.succeed(output(JSON.stringify([{ iid: "malformed" }, ...rows]))),      );      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       const batch = yield* cli.listMergeRequests({        cwd: "/w",        repository: "acme/web",        state: "open",        involvement: "all",        viewer: "bilal",        limit: 2,      });       expect(batch.items.map((item) => item.number)).toEqual([1, 2]);      assert.strictEqual(batch.cursorAdvance, 3);      assert.isTrue(batch.truncated);    }),  );   it.effect("URL-encodes a search, so it cannot add a parameter of its own", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       yield* cli.listMergeRequests({        cwd: "/w",        repository: "acme/web",        state: "open",        involvement: "all",        viewer: "bilal",        limit: 10,        query: '-a&per_page=1 "b"',      });       const path = argsOfCall(0)[1] ?? "";      expect(path).toContain("search=-a%26per_page%3D1%20%22b%22");      // The page size the walk fixed is still the only one in the query.      assert.strictEqual(path.match(/per_page=/g)?.length, 1);    }),  );   it.effect("asks for no search at all when the reader typed only spaces", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       yield* cli.listMergeRequests({        cwd: "/w",        repository: "acme/web",        state: "open",        involvement: "all",        viewer: "bilal",        limit: 10,        query: "   ",      });       expect(argsOfCall(0)[1]).not.toContain("search=");    }),  );   it.effect("stops walking on a short page", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(40, 1))));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       const batch = yield* cli.listMergeRequests({        cwd: "/w",        repository: "acme/web",        state: "open",        involvement: "all",        viewer: "bilal",        limit: 150,      });       assert.strictEqual(batch.items.length, 40);      assert.isFalse(batch.truncated);      assert.strictEqual(mockedExecute.mock.calls.length, 1);    }),  );   it.effect("stops walking when every row on a page fails to decode", () =>    Effect.gen(function* () {      // Full pages of unusable rows: nothing is collected, so the collected-count bound never      // trips and only the page bound can end the walk.      // @effect-diagnostics-next-line preferSchemaOverJson:off      const unusable = JSON.stringify(Array.from({ length: 100 }, () => ({ iid: "nope" })));      mockedExecute.mockReturnValue(Effect.succeed(output(unusable)));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       const batch = yield* cli.listMergeRequests({        cwd: "/w",        repository: "acme/web",        state: "open",        involvement: "all",        viewer: "bilal",        limit: 150,      });       assert.strictEqual(batch.items.length, 0);      // ceil((150 + 1) / 100) pages, not one request per page forever.      assert.strictEqual(mockedExecute.mock.calls.length, 2);    }),  );   it.effect("asks GitLab for every state on the All tab", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       yield* cli.listMergeRequests({        cwd: "/w",        repository: "acme/web",        state: "all",        involvement: "all",        viewer: "bilal",        limit: 10,      });       expect(argsOfCall(0)[1]).toContain("state=all");    }),  );   it.effect("filters by the reviewer when the viewer is reviewing", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       yield* cli.listMergeRequests({        cwd: "/w",        repository: "acme/web",        state: "open",        involvement: "reviewing",        viewer: "bilal",        limit: 10,      });       expect(argsOfCall(0)[1]).toContain("reviewer_username=bilal");    }),  );   it.effect("addresses a nested group project by its encoded full path", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       yield* cli.listMergeRequests({        cwd: "/w",        repository: "acme/platform/web",        state: "open",        involvement: "all",        viewer: "bilal",        limit: 10,      });       expect(argsOfCall(0)[1]).toContain("projects/acme%2Fplatform%2Fweb/merge_requests");    }),  );   it.effect("merges immediately rather than leaving auto-merge armed", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       yield* cli.runMergeRequestAction({        cwd: "/w",        repository: "acme/web",        number: 7,        action: "merge",        mergeMethod: "squash",      });       expect(argsOfCall(0)).toEqual([        "mr",        "merge",        "7",        "--repo",        "acme/web",        "--auto-merge=false",        "--yes",        "--squash",      ]);    }),  );   it.effect("moves a merge request back to draft through glab", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       yield* cli.runMergeRequestAction({        cwd: "/w",        repository: "acme/web",        number: 7,        action: "draft",      });       expect(argsOfCall(0)).toEqual(["mr", "update", "7", "--repo", "acme/web", "--draft"]);    }),  );   it.effect("sends a comment body over stdin, never in argv", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       yield* cli.commentOnMergeRequest({        cwd: "/w",        repository: "acme/web",        number: 7,        body: "true",      });       const call = mockedExecute.mock.calls[0];      assert.isDefined(call);      expect(call[0].args).toEqual([        "api",        "projects/acme%2Fweb/merge_requests/7/notes",        "--method",        "POST",        "--input",        "-",        "--header",        "Content-Type: application/json",      ]);      // A JSON body, so a comment reading as a literal `true` stays text.      expect(call[0].stdin).toBe('{"body":"true"}');    }),  );   it.effect("reads one diff page and hands back the cursor for the next", () =>    Effect.gen(function* () {      mockedExecute.mockReturnValueOnce(Effect.succeed(output(diffPage(0))));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;       const diff = yield* cli.getMergeRequestDiff({        cwd: "/w",        repository: "acme/web",        number: 7,      });       // One page per call: the reader asks for the rest, the walk does not run on by itself.      assert.strictEqual(mockedExecute.mock.calls.length, 1);      assert.isNotNull(diff.nextCursor);      // A full page means more files, not a slice with something missing from it.      assert.isFalse(diff.truncated);      expect(argsOfCall(0)[1]).toContain("merge_requests/7/diffs?per_page=100&page=1");    }),  );   it.effect("carries on from a cursor at the page it names", () =>    Effect.gen(function* () {      mockedExecute        .mockReturnValueOnce(Effect.succeed(output(diffPage(0))))        .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3))));      const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;      const target = { cwd: "/w", repository: "acme/web", number: 7 };       const first = yield* cli.getMergeRequestDiff(target);      assert.isNotNull(first.nextCursor);      const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor });       expect(argsOfCall(1)[1]).toContain("page=2");      // A short page is the end of the change set, so there is nothing to carry on from.      assert.isNull(second.nextCursor);      expect(second.patch).toContain("diff --git a/src/100.ts b/src/100.ts");    }),  ); 

Lines 1–500 of 1144.