branch: main
issues-prs.spec.mjs
9756 bytesRaw
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import {
  addRemote,
  appendLineAndCommit,
  cleanupTempDirs,
  cloneFixture,
  git,
  pushAsOwner,
} from "./helpers/git.mjs";
import { actorHeaders, createTestServer, uniqueId } from "./helpers/mf.mjs";

let server;
const tempDirs = [];

function uniqueToken(prefix) {
  return `${prefix}${uniqueId("token")}`.replace(/[^a-zA-Z0-9]/g, "");
}

function formHeaders(actorName) {
  return actorHeaders(actorName, {
    "Content-Type": "application/x-www-form-urlencoded",
  });
}

async function postForm(path, actorName, form = {}) {
  return server.dispatch(path, {
    method: "POST",
    redirect: "manual",
    headers: formHeaders(actorName),
    body: new URLSearchParams(form).toString(),
  });
}

function expectRedirectTo(response, suffix) {
  expect(response.status).toBe(302);
  expect(response.headers.get("location")).toMatch(new RegExp(`${suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`));
}

beforeAll(async () => {
  server = await createTestServer();
});

afterAll(async () => {
  await cleanupTempDirs(tempDirs);
  await server.mf.dispose();
});

describe("issues and pull requests", () => {
  test("creates, comments on, closes, and reopens an issue", async () => {
    const owner = uniqueId("owner");
    const repo = uniqueId("repo");
    const reporter = uniqueId("reporter");
    const commenter = uniqueId("commenter");
    const issueTitle = `Issue ${uniqueId("title")}`;
    const issueBody = `Issue body ${uniqueId("body")}`;
    const commentBody = `Comment ${uniqueId("comment")}`;

    let response = await postForm(`/${owner}/${repo}/issues`, reporter, {
      title: issueTitle,
      body: issueBody,
    });
    expectRedirectTo(response, `/${owner}/${repo}/issues/1`);

    response = await server.dispatch(`/${owner}/${repo}/issues/1?format=md`);
    expect(response.status).toBe(200);
    let markdown = await response.text();
    expect(markdown).toContain(`# Issue #1: ${issueTitle}`);
    expect(markdown).toContain("- State: `Open`");
    expect(markdown).toContain(issueBody);
    expect(markdown).toContain("No comments yet.");

    response = await postForm(`/${owner}/${repo}/issues/1/comment`, commenter, {
      body: commentBody,
    });
    expectRedirectTo(response, `/${owner}/${repo}/issues/1`);

    response = await postForm(`/${owner}/${repo}/issues/1/close`, reporter);
    expectRedirectTo(response, `/${owner}/${repo}/issues/1`);

    response = await server.dispatch(`/${owner}/${repo}/issues?state=closed&format=md`);
    expect(response.status).toBe(200);
    markdown = await response.text();
    expect(markdown).toContain(issueTitle);

    response = await postForm(`/${owner}/${repo}/issues/1/reopen`, owner);
    expectRedirectTo(response, `/${owner}/${repo}/issues/1`);

    response = await server.dispatch(`/${owner}/${repo}/issues/1?format=md`);
    expect(response.status).toBe(200);
    markdown = await response.text();
    expect(markdown).toContain("- State: `Open`");
    expect(markdown).toContain(commentBody);
    expect(markdown).toContain(commenter);
  });

  test("creates and merges a pull request from a fixture-backed branch", async () => {
    const owner = uniqueId("owner");
    const repo = uniqueId("repo");
    const contributor = uniqueId("contributor");
    const featureBranch = uniqueId("feature");
    const prTitle = `PR ${uniqueId("title")}`;
    const prBody = `PR body ${uniqueId("body")}`;
    const featureToken = `featuretoken${uniqueId("token")}`.replace(/[^a-zA-Z0-9]/g, "");
    const remoteUrl = new URL(`/${owner}/${repo}`, server.url).toString();

    const source = await cloneFixture();
    tempDirs.push(source.workDir);

    await addRemote(source.repoDir, "ripgit", remoteUrl);
    await pushAsOwner(source.repoDir, owner, "push", "ripgit", "HEAD:refs/heads/main");

    await git(source.repoDir, ["checkout", "-b", featureBranch]);
    await appendLineAndCommit(source.repoDir, "README.md", featureToken, "add feature branch change");
    await pushAsOwner(
      source.repoDir,
      owner,
      "push",
      "ripgit",
      `HEAD:refs/heads/${featureBranch}`,
    );

    let response = await postForm(`/${owner}/${repo}/pulls`, contributor, {
      title: prTitle,
      body: prBody,
      source: featureBranch,
      target: "main",
    });
    expectRedirectTo(response, `/${owner}/${repo}/pulls/1`);

    response = await server.dispatch(`/${owner}/${repo}/pulls/1?format=md`);
    expect(response.status).toBe(200);
    let markdown = await response.text();
    expect(markdown).toContain(`# Pull Request #1: ${prTitle}`);
    expect(markdown).toContain(`- Branches: \`${featureBranch}\` -> \`main\``);
    expect(markdown).toContain(prBody);
    expect(markdown).toContain("README.md");

    response = await server.dispatch(`/${owner}/${repo}/pulls?format=md`);
    expect(response.status).toBe(200);
    markdown = await response.text();
    expect(markdown).toContain(prTitle);

    response = await server.dispatch(`/${owner}/${repo}/search?q=${featureToken}&scope=code`);
    expect(response.status).toBe(200);
    let payload = await response.json();
    expect(payload.total_matches).toBe(0);

    response = await server.dispatch(`/${owner}/${repo}/pulls/1/merge`, {
      method: "POST",
      redirect: "manual",
      headers: actorHeaders(contributor),
    });
    expect(response.status).toBe(403);
    expect(await response.text()).toContain("only the repo owner can merge");

    response = await server.dispatch(`/${owner}/${repo}/pulls/1/merge`, {
      method: "POST",
      redirect: "manual",
      headers: actorHeaders(owner),
    });
    expectRedirectTo(response, `/${owner}/${repo}/pulls/1`);

    response = await server.dispatch(`/${owner}/${repo}/pulls/1?format=md`);
    expect(response.status).toBe(200);
    markdown = await response.text();
    expect(markdown).toContain("- State: `Merged`");
    expect(markdown).toContain("- Merge status: merged in `/");

    response = await server.dispatch(`/${owner}/${repo}/search?q=${featureToken}&scope=code`);
    expect(response.status).toBe(200);
    payload = await response.json();
    expect(payload.total_matches).toBeGreaterThan(0);

    response = await server.dispatch(`/${owner}/${repo}/file?ref=main&path=README.md`);
    expect(response.status).toBe(200);
    expect(await response.text()).toContain(featureToken);
  });

  test("returns 409 for a pull request merge conflict and leaves main unchanged", async () => {
    const owner = uniqueId("owner");
    const repo = uniqueId("repo");
    const contributor = uniqueId("contributor");
    const featureBranch = uniqueId("feature");
    const prTitle = `Conflict PR ${uniqueId("title")}`;
    const featureToken = uniqueToken("featureconflict");
    const mainToken = uniqueToken("mainconflict");
    const remoteUrl = new URL(`/${owner}/${repo}`, server.url).toString();

    const source = await cloneFixture();
    tempDirs.push(source.workDir);
    await addRemote(source.repoDir, "ripgit", remoteUrl);
    await pushAsOwner(source.repoDir, owner, "push", "ripgit", "HEAD:refs/heads/main");

    await git(source.repoDir, ["checkout", "-b", featureBranch]);
    await appendLineAndCommit(
      source.repoDir,
      "README.md",
      `feature conflict token ${featureToken}`,
      "feature-side conflict change",
    );
    await pushAsOwner(
      source.repoDir,
      owner,
      "push",
      "ripgit",
      `HEAD:refs/heads/${featureBranch}`,
    );

    const target = await cloneFixture();
    tempDirs.push(target.workDir);
    await addRemote(target.repoDir, "ripgit", remoteUrl);
    await appendLineAndCommit(
      target.repoDir,
      "README.md",
      `main conflict token ${mainToken}`,
      "main-side conflict change",
    );
    await pushAsOwner(target.repoDir, owner, "push", "ripgit", "HEAD:refs/heads/main");

    let response = await postForm(`/${owner}/${repo}/pulls`, contributor, {
      title: prTitle,
      body: "conflict body",
      source: featureBranch,
      target: "main",
    });
    expectRedirectTo(response, `/${owner}/${repo}/pulls/1`);

    response = await server.dispatch(`/${owner}/${repo}/pulls/1?format=md`, {
      headers: actorHeaders(owner),
    });
    expect(response.status).toBe(200);
    let markdown = await response.text();
    expect(markdown).toContain("conflicts return `409`");
    expect(markdown).toContain("README.md");

    response = await server.dispatch(`/${owner}/${repo}/pulls/1/merge`, {
      method: "POST",
      redirect: "manual",
      headers: actorHeaders(owner),
    });
    expect(response.status).toBe(409);
    expect(await response.text()).toContain("merge conflict in: README.md");

    response = await server.dispatch(`/${owner}/${repo}/pulls/1?format=md`, {
      headers: actorHeaders(owner),
    });
    expect(response.status).toBe(200);
    markdown = await response.text();
    expect(markdown).toContain("- State: `Open`");
    expect(markdown).toContain("conflicts return `409`");

    response = await server.dispatch(`/${owner}/${repo}/file?ref=main&path=README.md`);
    expect(response.status).toBe(200);
    const mainReadme = await response.text();
    expect(mainReadme).toContain(mainToken);
    expect(mainReadme).not.toContain(featureToken);

    response = await server.dispatch(`/${owner}/${repo}/search?q=${featureToken}&scope=code`);
    expect(response.status).toBe(200);
    let payload = await response.json();
    expect(payload.total_matches).toBe(0);

    response = await server.dispatch(`/${owner}/${repo}/search?q=${mainToken}&scope=code`);
    expect(response.status).toBe(200);
    payload = await response.json();
    expect(payload.total_matches).toBeGreaterThan(0);
  });
});