483 lines · 15.4 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import type { PullRequestReaction } from "@t3tools/contracts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; describe("gitHubViewerPermissions", () => {  it("offers everything to a viewer who can write to the repository", () => {    expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({      // Arming a merge for later is the merge, so it travels with it.      actions: [        "merge",        "enable-auto-merge",        "disable-auto-merge",        "ready",        "draft",        "close",        "reopen",      ],      comment: true,      resolve: true,      verdicts: ["comment", "approve", "request-changes"],      requestReviewers: true,    });  });   it("leaves a passer-by on a repository they can only read nothing but the review", () => {    // Every open-source pull request somebody else opened: GitHub says no to all five actions    // and to resolving, and yes to commenting and to every verdict.    expect(      gitHubViewerPermissions({ canWrite: false, canUpdate: false, didAuthor: false }),    ).toEqual({      actions: [],      comment: true,      resolve: false,      verdicts: ["comment", "approve", "request-changes"],      // Asking somebody else to review is the one thing read access never stretches to.      requestReviewers: false,    });  });   it("keeps an author's own pull request theirs to close, with read access and no more", () => {    expect(gitHubViewerPermissions({ canWrite: false, canUpdate: true, didAuthor: true })).toEqual({      // Merging is the one thing writing is needed for, now or later; the rest an author may do.      actions: ["ready", "draft", "close", "reopen"],      comment: true,      resolve: true,      // GitHub refuses an author's approval of their own change, so the page does not offer one.      verdicts: ["comment"],      requestReviewers: false,    });  });   it.effect("uses the small viewer-access read for core permissions", () =>    Effect.gen(function* () {      const provider = yield* make;      const detail = yield* provider.getChangeRequest({        cwd: "/w",        repository: "acme/web",        host: "github.com",        number: 7,      });       expect(detail.viewerPermissions).toEqual({        actions: ["ready", "draft", "close", "reopen"],        comment: true,        resolve: false,        verdicts: ["comment", "approve", "request-changes"],        requestReviewers: false,      });    }).pipe(      Effect.provide(        Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({          getPullRequestDetail: () =>            Effect.succeed({              authorId: null,              number: 7,              title: "Pull request 7",              url: "https://github.com/acme/web/pull/7",              author: null,              headRepositoryOwner: null,              headBranch: "feat/page",              baseBranch: "main",              state: "open",              isDraft: false,              mergeability: "mergeable",              reviewDecision: null,              additions: 1,              deletions: 1,              createdAt: "2026-07-01T00:00:00Z",              updatedAt: "2026-07-02T00:00:00Z",              reviewRequestLogins: [],              hasTeamReviewRequest: false,              checksState: null,              labels: [],              body: "",              changedFiles: 1,              mergedAt: null,              closedAt: null,              checks: [],              comments: [],              commits: [],            }),          getRepositoryAccess: () =>            Effect.succeed({              canWrite: false,              mergeCapabilities: { merge: true, squash: true, rebase: true },            }),          getViewerAccess: () =>            Effect.succeed({ canWrite: false, canUpdate: true, didAuthor: false }),        }),      ),    ),  );}); describe("getViewerPermissions", () => {  const openDetail = {    authorId: null,    number: 7,    title: "Pull request 7",    url: "https://github.com/acme/web/pull/7",    author: null,    headRepositoryOwner: "acme",    headBranch: "feat/page",    baseBranch: "main",    state: "open" as const,    isDraft: false,    mergeability: "mergeable" as const,    reviewDecision: null,    additions: 1,    deletions: 1,    createdAt: "2026-07-01T00:00:00Z",    updatedAt: "2026-07-02T00:00:00Z",    reviewRequestLogins: [],    hasTeamReviewRequest: false,    checksState: null,    labels: [],    body: "",    changedFiles: 1,    mergedAt: null,    closedAt: null,    checks: [],    comments: [],    commits: [],  };   const layerWithComparison = (    comparison: Effect.Effect<{      readonly behindBy: number | null;      readonly viewerCanUpdate: boolean;    }>,  ) =>    Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({      getPullRequestDetail: () => Effect.succeed(openDetail),      getPullRequestBaseComparison: () => comparison,      getViewerAccess: () => Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),    });   it.effect("offers update-branch when the comparison grants it", () =>    Effect.gen(function* () {      const provider = yield* make;      const permissions = yield* provider.getViewerPermissions({        cwd: "/w",        repository: "acme/web",        host: "github.com",        number: 7,      });       expect(permissions.actions).toContain("update-branch");      expect(permissions.updateMethods).toEqual(["merge", "rebase"]);    }).pipe(      Effect.provide(layerWithComparison(Effect.succeed({ behindBy: 3, viewerCanUpdate: true }))),    ),  );   it.effect("uses the GraphQL reserve for manual permission checks", () => {    let viewerAllowReserve: boolean | undefined;    let comparisonAllowReserve: boolean | undefined;    return Effect.gen(function* () {      const provider = yield* make;      yield* provider.getViewerPermissions({        cwd: "/w",        repository: "acme/web",        host: "github.com",        number: 7,      });       expect(viewerAllowReserve).toBe(true);      expect(comparisonAllowReserve).toBe(true);    }).pipe(      Effect.provide(        Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({          getPullRequestDetail: () => Effect.succeed(openDetail),          getPullRequestBaseComparison: (input) =>            Effect.sync(() => {              comparisonAllowReserve = input.allowReserve;              return { behindBy: 3, viewerCanUpdate: true };            }),          getViewerAccess: (input) =>            Effect.sync(() => {              viewerAllowReserve = input.allowReserve;              return { canWrite: true, canUpdate: true, didAuthor: false };            }),        }),      ),    );  });   it.effect("withholds update-branch when the comparison cannot be read", () =>    Effect.gen(function* () {      const provider = yield* make;      const permissions = yield* provider.getViewerPermissions({        cwd: "/w",        repository: "acme/web",        host: "github.com",        number: 7,      });       expect(permissions.actions).not.toContain("update-branch");      expect(permissions.updateMethods).toBeUndefined();      // The rest of the answer survives a comparison nobody could make.      expect(permissions.actions).toContain("merge");    }).pipe(      Effect.provide(        Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({          getPullRequestDetail: () => Effect.succeed(openDetail),          getPullRequestBaseComparison: () =>            Effect.fail(              new GitHubPullRequestCli.GitHubPullRequestReadError({                command: "gh",                cwd: "/w",                operation: "getPullRequestBaseComparison",                cause: new Error("unreadable"),              }),            ),          getViewerAccess: () =>            Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),        }),      ),    ),  );}); describe("getChangeRequest commits", () => {  const baseDetail = {    authorId: null,    number: 7,    title: "Pull request 7",    url: "https://github.com/acme/web/pull/7",    author: null,    headBranch: "feat/page",    baseBranch: "main",    state: "open" as const,    isDraft: false,    mergeability: "mergeable" as const,    additions: 1,    deletions: 1,    createdAt: "2026-07-01T00:00:00Z",    updatedAt: "2026-07-02T00:00:00Z",    reviewRequestLogins: [],    hasTeamReviewRequest: false,    checksState: null,    labels: [],    body: "",    changedFiles: 1,    mergedAt: null,    closedAt: null,    checks: [],    comments: [],  };   const baseThreadComments = {    comments: [],    dismissalsByReviewId: new Map<string, string>(),    reviewThreads: [],    commentCount: 0,    truncated: false,    reactions: [],    reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(),    reviewers: [],    avatarsByLogin: new Map<string, string>(),    commitStats: new Map<string, { readonly additions: number; readonly deletions: number }>(),    viewer: { canUpdate: true, didAuthor: false },  };   const layerWith = (commits: GitHubReviewThreadComments["commits"]) =>    Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({      getPullRequestActivity: () =>        Effect.succeed({          author: baseDetail.author,          comments: baseDetail.comments,          commits: [            {              oid: "view-oldest",              messageHeadline: "gh pr view's oldest commit",              committedDate: "2026-01-01T00:00:00Z",              authors: [],            },          ],        }),      listReviewThreadComments: () => Effect.succeed({ ...baseThreadComments, commits }),    });   it.effect("prefers the GraphQL commits, which are the newest, over the gh view list", () =>    Effect.gen(function* () {      const provider = yield* make;      const detail = yield* provider.getChangeRequestActivity({        cwd: "/w",        repository: "acme/web",        host: "github.com",        number: 7,      });       expect(detail.commits.map((commit) => commit.oid)).toEqual(["graphql-newest"]);    }).pipe(      Effect.provide(        layerWith([          {            oid: "graphql-newest",            messageHeadline: "the newest commit gh pr view drops",            committedDate: "2026-07-06T00:00:00Z",            authors: [],          },        ]),      ),    ),  );   it.effect("falls back to the gh view list when the GraphQL read has no commits", () =>    Effect.gen(function* () {      const provider = yield* make;      const detail = yield* provider.getChangeRequestActivity({        cwd: "/w",        repository: "acme/web",        host: "github.com",        number: 7,      });       expect(detail.commits.map((commit) => commit.oid)).toEqual(["view-oldest"]);    }).pipe(Effect.provide(layerWith([]))),  );}); describe("getChangeRequestActivity dismissed reviews", () => {  const dismissedReview = (body: string) => ({    id: "PRR_1",    kind: "review" as const,    author: null,    body,    createdAt: "2026-07-03T00:00:00Z",    url: null,    path: null,    reviewState: "DISMISSED",  });  const threadComments: GitHubReviewThreadComments = {    comments: [],    dismissalsByReviewId: new Map([["PRR_1", "Dismissing prior approval to re-evaluate 9b66581"]]),    reviewThreads: [],    commentCount: 0,    truncated: false,    reactions: [],    reactionsById: new Map(),    reviewers: [],    avatarsByLogin: new Map(),    commitStats: new Map(),    commits: [],    viewer: { canUpdate: true, didAuthor: false },  };  const layerFor = (body: string) =>    Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({      getPullRequestActivity: () =>        Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }),      listReviewThreadComments: () => Effect.succeed(threadComments),    });  const readActivity = Effect.gen(function* () {    const provider = yield* make;    return yield* provider.getChangeRequestActivity({      cwd: "/w",      repository: "acme/web",      host: "github.com",      number: 7,    });  });   it.effect("fills a marker-only dismissed review with the timeline's reason", () =>    // Macroscope's approvals carry only an HTML comment, which markdown renders as nothing —    // an empty-string check misses them and the card opens onto nothing.    readActivity.pipe(      Effect.map((activity) => {        expect(activity.comments[0]?.body).toBe("Dismissing prior approval to re-evaluate 9b66581");      }),      Effect.provide(layerFor("<!-- Macroscope (Approvability) review body marker -->")),    ),  );   it.effect("keeps the words of a dismissed review that has its own", () =>    readActivity.pipe(      Effect.map((activity) => {        expect(activity.comments[0]?.body).toBe("These findings still stand.");      }),      Effect.provide(layerFor("These findings still stand.")),    ),  );}); describe("editing", () => {  const rewrites: Array<unknown> = [];   it.effect("hands a rewrite to the CLI as the request named it", () =>    Effect.gen(function* () {      const provider = yield* make;       expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: true });      yield* provider.updateChangeRequest!({        cwd: "/w",        repository: "acme/web",        host: "github.com",        number: 7,        title: "A better title",      });      yield* provider.updateComment!({        cwd: "/w",        repository: "acme/web",        host: "github.com",        number: 7,        commentId: "IC_1",        kind: "review-comment",        body: "Reworded.",      });       expect(rewrites).toEqual([        {          cwd: "/w",          repository: "acme/web",          host: "github.com",          number: 7,          title: "A better title",        },        {          cwd: "/w",          repository: "acme/web",          host: "github.com",          number: 7,          commentId: "IC_1",          kind: "review-comment",          body: "Reworded.",        },      ]);    }).pipe(      Effect.provide(        Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({          updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)),          updateComment: (input) => Effect.sync(() => void rewrites.push(input)),        }),      ),    ),  );}); describe("loginAvatarUrl", () => {  it("serves a user's picture from the host they belong to", () => {    expect(loginAvatarUrl("octocat", "github.com")).toBe("https://github.com/octocat.png?size=80");    expect(loginAvatarUrl("octocat", "ghe.example.com")).toBe(      "https://ghe.example.com/octocat.png?size=80",    );  });   it("has nothing for an app, which names no page", () => {    // `dependabot[bot]` has a picture, but not at `/dependabot[bot].png` — a guess that 404s is    // worse than the initials it would replace.    expect(loginAvatarUrl("dependabot[bot]", "github.com")).toBeNull();  });   it("refuses anything that is not a login, rather than building a URL out of it", () => {    for (const login of ["../../etc", "a b", "-leading", "x".repeat(40), ""]) {      expect(loginAvatarUrl(login, "github.com")).toBeNull();    }  });});