5.4 KiB · text History b868907
defmodule GitGud.BranchProtections.StatusChecksTest do
  @moduledoc """
  Required status checks: a PR merges into a protected branch only when
  every required pattern matches a status on its head, and every status
  a pattern matches is `success` or `warning`.
  """
  use GitGud.DataCase, async: false

  import GitGud.AccountsFixtures
  import GitGud.ForgeFixtures

  alias GitGud.BranchProtections
  alias GitGud.PullRequests
  alias GitGud.Repositories
  alias GitGud.Workflows.CommitStatus

  @moduletag :git_required

  setup do
    {owner, repo} = repository_fixture()
    pr = pr_with_branches(repo, owner)
    {:ok, main_sha} = Repositories.resolve(repo, "main")
    {:ok, owner: owner, repo: repo, pr: pr, main_sha: main_sha}
  end

  defp require_checks!(repo, checks, extra \\ %{}) do
    {:ok, rule} =
      BranchProtections.create_rule(
        repo,
        Map.merge(
          %{"pattern" => "main", "require_status_checks" => true, "required_checks" => checks},
          extra
        )
      )

    rule
  end

  defp status!(repo, sha, context, state) do
    Repo.insert!(%CommitStatus{repository_id: repo.id, sha: sha, context: context, state: state})
  end

  defp merge_check(repo, pr, main_sha, opts \\ []) do
    BranchProtections.check_ref_update(
      repo,
      "main",
      main_sha,
      pr.head_sha,
      [pull_request_id: pr.id, head_sha: pr.head_sha] ++ opts
    )
  end

  test "a passing matching status lets the merge through", ctx do
    require_checks!(ctx.repo, ["ci / test *"])
    status!(ctx.repo, ctx.pr.head_sha, "ci / test (push)", "success")

    assert :ok = merge_check(ctx.repo, ctx.pr, ctx.main_sha)
  end

  test "a warning passes, as success does", ctx do
    require_checks!(ctx.repo, ["ci / *"])
    status!(ctx.repo, ctx.pr.head_sha, "ci / test (push)", "success")
    status!(ctx.repo, ctx.pr.head_sha, "ci / audit (push)", "warning")

    assert :ok = merge_check(ctx.repo, ctx.pr, ctx.main_sha)
  end

  test "a failing matching status blocks, by context", ctx do
    require_checks!(ctx.repo, ["ci / test *"])
    status!(ctx.repo, ctx.pr.head_sha, "ci / test (push)", "failure")
    status!(ctx.repo, ctx.pr.head_sha, "ci / lint (push)", "failure")

    assert {:error, {:status_checks, [], ["ci / test (push)"]}} =
             merge_check(ctx.repo, ctx.pr, ctx.main_sha)
  end

  test "every matching status must pass, pending included", ctx do
    require_checks!(ctx.repo, ["ci / test *"])
    status!(ctx.repo, ctx.pr.head_sha, "ci / test (push)", "success")
    status!(ctx.repo, ctx.pr.head_sha, "ci / test (pull_request)", "pending")

    assert {:error, {:status_checks, [], ["ci / test (pull_request)"]}} =
             merge_check(ctx.repo, ctx.pr, ctx.main_sha)
  end

  test "a pattern with no status is missing", ctx do
    require_checks!(ctx.repo, ["ci / test *"])
    status!(ctx.repo, ctx.pr.head_sha, "ci / lint (push)", "success")

    assert {:error, {:status_checks, ["ci / test *"], []}} =
             merge_check(ctx.repo, ctx.pr, ctx.main_sha)
  end

  test "`*` crosses slashes and spaces, the rest is literal", ctx do
    require_checks!(ctx.repo, ["*/ build (arm64, 28) (push)"])
    status!(ctx.repo, ctx.pr.head_sha, "release / ci / build (arm64, 28) (push)", "success")

    assert :ok = merge_check(ctx.repo, ctx.pr, ctx.main_sha)
  end

  test "an empty list requires every status, and at least one", ctx do
    require_checks!(ctx.repo, [])

    assert {:error, {:status_checks, ["*"], []}} = merge_check(ctx.repo, ctx.pr, ctx.main_sha)

    status!(ctx.repo, ctx.pr.head_sha, "ci / test (push)", "success")
    assert :ok = merge_check(ctx.repo, ctx.pr, ctx.main_sha)

    status!(ctx.repo, ctx.pr.head_sha, "deploy / deploy (push)", "cancelled")

    assert {:error, {:status_checks, [], ["deploy / deploy (push)"]}} =
             merge_check(ctx.repo, ctx.pr, ctx.main_sha)
  end

  test "a rule that doesn't require checks ignores them", ctx do
    {:ok, _} = BranchProtections.create_rule(ctx.repo, %{"pattern" => "main"})
    status!(ctx.repo, ctx.pr.head_sha, "ci / test (push)", "failure")

    assert :ok = merge_check(ctx.repo, ctx.pr, ctx.main_sha)
  end

  test "approvals and checks are both enforced", ctx do
    require_checks!(ctx.repo, ["ci / test *"], %{"required_approvals" => 1})
    status!(ctx.repo, ctx.pr.head_sha, "ci / test (push)", "failure")

    assert {:error, {:approvals_required, 1, 0}} = merge_check(ctx.repo, ctx.pr, ctx.main_sha)

    {:ok, _} =
      PullRequests.add_review(ctx.pr, grant_repo!(ctx.repo, user_fixture()), %{
        "state" => "approved"
      })

    assert {:error, {:status_checks, [], ["ci / test (push)"]}} =
             merge_check(ctx.repo, ctx.pr, ctx.main_sha)
  end

  test "a direct push isn't blocked by checks", ctx do
    require_checks!(ctx.repo, ["ci / test *"])

    assert :ok =
             BranchProtections.check_ref_update(ctx.repo, "main", ctx.main_sha, ctx.pr.head_sha)
  end

  test "merge! refuses on a failing check, and an override merges anyway", ctx do
    require_checks!(ctx.repo, ["ci / test *"])
    status!(ctx.repo, ctx.pr.head_sha, "ci / test (push)", "failure")

    assert PullRequests.status_check_blockers(ctx.repo, ctx.pr) == [
             {:failing_check, "ci / test (push)"}
           ]

    assert {:error, {:status_checks, [], ["ci / test (push)"]}} =
             PullRequests.merge!(ctx.pr, ctx.owner)

    assert {:ok, %{state: "merged"}} = PullRequests.merge!(ctx.pr, ctx.owner, override: true)
  end
end