418 lines · 13.3 KB · TypeScript
RawDownload
import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as PlatformError from "effect/PlatformError";import { ChildProcessSpawner } from "effect/unstable/process";import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts";import * as GitHubCli from "./GitHubCli.ts"; const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({  exitCode: ChildProcessSpawner.ExitCode(0),  stdout,  stderr: "",  stdoutTruncated: false,  stderrTruncated: false,}); const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>(); const layer = GitHubCli.layer.pipe(  Layer.provide(    Layer.mock(VcsProcess.VcsProcess)({      run: mockRun,    }),  ),); afterEach(() => {  mockRun.mockReset();}); describe("GitHubCli.layer", () => {  it("does not classify a missing cwd as an unavailable gh executable", () => {    const context = { command: "gh", cwd: "/repo" } as const;    const missingCwd = new VcsProcessSpawnError({      operation: "GitHubCli.execute",      command: "gh",      cwd: context.cwd,      cause: PlatformError.systemError({        _tag: "NotFound",        module: "FileSystem",        method: "access",        pathOrDescriptor: context.cwd,      }),    });     const commandFailure = GitHubCli.fromVcsError(context, missingCwd);     assert.equal(commandFailure._tag, "GitHubCliCommandError");    assert.strictEqual(commandFailure.cause, missingCwd);    assert.notProperty(commandFailure, "operation");  });   it.effect("parses pull request view output", () =>    Effect.gen(function* () {      mockRun.mockReturnValueOnce(        Effect.succeed(          processOutput(            // @effect-diagnostics-next-line preferSchemaOverJson:off            JSON.stringify({              number: 42,              title: "Add PR thread creation",              url: "https://github.com/pingdotgg/codething-mvp/pull/42",              baseRefName: "main",              headRefName: "feature/pr-threads",              state: "OPEN",              isDraft: true,              mergedAt: null,              updatedAt: "2026-08-24T12:34:56Z",              isCrossRepository: true,              headRepository: {                nameWithOwner: "octocat/codething-mvp",              },              headRepositoryOwner: {                login: "octocat",              },            }),          ),        ),      );       const gh = yield* GitHubCli.GitHubCli;      const result = yield* gh.getPullRequest({        cwd: "/repo",        reference: "#42",      });       assert.deepStrictEqual(result, {        number: 42,        title: "Add PR thread creation",        url: "https://github.com/pingdotgg/codething-mvp/pull/42",        baseRefName: "main",        headRefName: "feature/pr-threads",        state: "open",        closedAt: null,        mergedAt: null,        isDraft: true,        updatedAt: "2026-08-24T12:34:56.000Z",        isCrossRepository: true,        headRepositoryNameWithOwner: "octocat/codething-mvp",        headRepositoryOwnerLogin: "octocat",      });      expect(mockRun).toHaveBeenCalledWith({        operation: "GitHubCli.execute",        command: "gh",        args: [          "pr",          "view",          "#42",          "--json",          "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",        ],        cwd: "/repo",        timeoutMs: 30_000,      });    }).pipe(Effect.provide(layer)),  );   it.effect("trims pull request fields decoded from gh json", () =>    Effect.gen(function* () {      mockRun.mockReturnValueOnce(        Effect.succeed(          processOutput(            // @effect-diagnostics-next-line preferSchemaOverJson:off            JSON.stringify({              number: 42,              title: "  Add PR thread creation  \n",              url: " https://github.com/pingdotgg/codething-mvp/pull/42 ",              baseRefName: " main ",              headRefName: "\tfeature/pr-threads\t",              state: "OPEN",              mergedAt: null,              isCrossRepository: true,              headRepository: {                nameWithOwner: " octocat/codething-mvp ",              },              headRepositoryOwner: {                login: " octocat ",              },            }),          ),        ),      );       const gh = yield* GitHubCli.GitHubCli;      const result = yield* gh.getPullRequest({        cwd: "/repo",        reference: "#42",      });       assert.deepStrictEqual(result, {        number: 42,        title: "Add PR thread creation",        url: "https://github.com/pingdotgg/codething-mvp/pull/42",        baseRefName: "main",        headRefName: "feature/pr-threads",        state: "open",        closedAt: null,        mergedAt: null,        isCrossRepository: true,        headRepositoryNameWithOwner: "octocat/codething-mvp",        headRepositoryOwnerLogin: "octocat",      });    }).pipe(Effect.provide(layer)),  );   it.effect("skips invalid entries when parsing pr lists", () =>    Effect.gen(function* () {      mockRun.mockReturnValueOnce(        Effect.succeed(          processOutput(            // @effect-diagnostics-next-line preferSchemaOverJson:off            JSON.stringify([              {                number: 0,                title: "invalid",                url: "https://github.com/pingdotgg/codething-mvp/pull/0",                baseRefName: "main",                headRefName: "feature/invalid",              },              {                number: 43,                title: "  Valid PR  ",                url: " https://github.com/pingdotgg/codething-mvp/pull/43 ",                baseRefName: " main ",                headRefName: " feature/pr-list ",                headRepository: {                  nameWithOwner: "   ",                },                headRepositoryOwner: {                  login: "   ",                },              },            ]),          ),        ),      );       const gh = yield* GitHubCli.GitHubCli;      const result = yield* gh.listOpenPullRequests({        cwd: "/repo",        headSelector: "feature/pr-list",      });       assert.deepStrictEqual(result, [        {          number: 43,          title: "Valid PR",          url: "https://github.com/pingdotgg/codething-mvp/pull/43",          baseRefName: "main",          headRefName: "feature/pr-list",          state: "open",          closedAt: null,          mergedAt: null,        },      ]);    }).pipe(Effect.provide(layer)),  );   it.effect("keeps pull requests from gh versions without headRepository.nameWithOwner", () =>    // gh < 2.47 (e.g. Ubuntu-packaged 2.46) exports headRepository as    // {id, name} only. These entries must decode instead of being dropped,    // with nameWithOwner rebuilt from the owner login.    Effect.gen(function* () {      mockRun.mockReturnValueOnce(        Effect.succeed(          processOutput(            // @effect-diagnostics-next-line preferSchemaOverJson:off            JSON.stringify([              {                number: 2829,                title: "Codex turn mapping",                url: "https://github.com/pingdotgg/codething-mvp/pull/2829",                baseRefName: "main",                headRefName: "t3code/codex-turn-mapping",                state: "OPEN",                mergedAt: null,                isCrossRepository: false,                headRepository: {                  id: "R_kgDORLtfbQ",                  name: "codething-mvp",                },                headRepositoryOwner: {                  id: "MDEyOk9yZ2FuaXphdGlvbjg5MTkxNzI3",                  login: "pingdotgg",                },              },            ]),          ),        ),      );       const gh = yield* GitHubCli.GitHubCli;      const result = yield* gh.listOpenPullRequests({        cwd: "/repo",        headSelector: "t3code/codex-turn-mapping",      });       assert.deepStrictEqual(result, [        {          number: 2829,          title: "Codex turn mapping",          url: "https://github.com/pingdotgg/codething-mvp/pull/2829",          baseRefName: "main",          headRefName: "t3code/codex-turn-mapping",          state: "open",          closedAt: null,          mergedAt: null,          isCrossRepository: false,          headRepositoryNameWithOwner: "pingdotgg/codething-mvp",          headRepositoryOwnerLogin: "pingdotgg",        },      ]);    }).pipe(Effect.provide(layer)),  );   it.effect("reads repository clone URLs", () =>    Effect.gen(function* () {      mockRun.mockReturnValueOnce(        Effect.succeed(          processOutput(            // @effect-diagnostics-next-line preferSchemaOverJson:off            JSON.stringify({              nameWithOwner: "octocat/codething-mvp",              url: "https://github.com/octocat/codething-mvp",              sshUrl: "git@github.com:octocat/codething-mvp.git",            }),          ),        ),      );       const gh = yield* GitHubCli.GitHubCli;      const result = yield* gh.getRepositoryCloneUrls({        cwd: "/repo",        repository: "octocat/codething-mvp",      });       assert.deepStrictEqual(result, {        nameWithOwner: "octocat/codething-mvp",        url: "https://github.com/octocat/codething-mvp",        sshUrl: "git@github.com:octocat/codething-mvp.git",      });    }).pipe(Effect.provide(layer)),  );   it.effect("creates repositories and parses clone URLs from create output", () =>    Effect.gen(function* () {      mockRun.mockReturnValueOnce(        Effect.succeed(          processOutput(            "✓ Created repository octocat/codething-mvp on github.com\nhttps://github.com/octocat/codething-mvp\n",          ),        ),      );       const gh = yield* GitHubCli.GitHubCli;      const result = yield* gh.createRepository({        cwd: "/repo",        repository: "octocat/codething-mvp",        visibility: "private",      });       assert.deepStrictEqual(result, {        nameWithOwner: "octocat/codething-mvp",        url: "https://github.com/octocat/codething-mvp",        sshUrl: "git@github.com:octocat/codething-mvp.git",      });      expect(mockRun).toHaveBeenCalledTimes(1);      expect(mockRun).toHaveBeenNthCalledWith(1, {        operation: "GitHubCli.execute",        command: "gh",        args: ["repo", "create", "octocat/codething-mvp", "--private"],        cwd: "/repo",        timeoutMs: 30_000,      });    }).pipe(Effect.provide(layer)),  );   it.effect("falls back to constructed URLs when create output omits a URL", () =>    Effect.gen(function* () {      mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));       const gh = yield* GitHubCli.GitHubCli;      const result = yield* gh.createRepository({        cwd: "/repo",        repository: "octocat/codething-mvp",        visibility: "private",      });       assert.deepStrictEqual(result, {        nameWithOwner: "octocat/codething-mvp",        url: "https://github.com/octocat/codething-mvp",        sshUrl: "git@github.com:octocat/codething-mvp.git",      });    }).pipe(Effect.provide(layer)),  );   it.effect("surfaces a friendly error when the pull request is not found", () =>    Effect.gen(function* () {      const cause = new VcsProcessExitError({        operation: "GitHubCli.execute",        command: "gh pr view",        cwd: "/repo",        exitCode: 1,        failureKind: "not-found",        detail:          "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)",      });      mockRun.mockReturnValueOnce(Effect.fail(cause));       const gh = yield* GitHubCli.GitHubCli;      const error = yield* gh        .getPullRequest({          cwd: "/repo",          reference: "4888",        })        .pipe(Effect.flip);       assert.equal(error.message.includes("Pull request not found"), true);      assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError");      assert.strictEqual(error.command, "gh");      assert.strictEqual(error.cwd, "/repo");      assert.strictEqual(error.cause, cause);      assert.equal(error.message.includes(cause.detail), false);    }).pipe(Effect.provide(layer)),  );   it.effect("surfaces an actionable rate-limit error without exposing provider stderr", () =>    Effect.gen(function* () {      const cause = new VcsProcessExitError({        operation: "GitHubCli.execute",        command: "gh",        cwd: "/repo",        exitCode: 1,        failureKind: "rate-limited",        detail: "API rate limit exceeded.",        stderrLength: 82,        stderrTruncated: false,      });      mockRun.mockReturnValueOnce(Effect.fail(cause));       const gh = yield* GitHubCli.GitHubCli;      const error = yield* gh        .listOpenPullRequests({          cwd: "/repo",          headSelector: "feature/rate-limited",        })        .pipe(Effect.flip);       assert.strictEqual(error._tag, "GitHubCliRateLimitError");      assert.include(error.detail, "GitHub API rate limit exceeded");      assert.include(error.detail, "gh api rate_limit");      assert.strictEqual(error.cause, cause);      assert.notInclude(error.message, "user ID");    }).pipe(Effect.provide(layer)),  );});