test/openagents/stacks_test.exs

main at 58e6347eeb72 · 12 KB

defmodule OpenAgents.StacksTest do
  use OpenAgents.DataCase

  alias OpenAgents.PullRequests
  alias OpenAgents.PullRequests.PullRequest
  alias OpenAgents.Repo
  alias OpenAgents.Stacks
  alias OpenAgents.Stacks.Stack
  alias OpenAgents.Stacks.StackEntry

  import OpenAgents.AccountsFixtures
  import OpenAgents.IssuesFixtures

  defp sha(character), do: String.duplicate(character, 40)

  defp pull_request(repository, head_ref, base_ref, attrs \\ %{}) do
    issue = issue_fixture(repository, %{title: "PR #{head_ref}"})

    defaults = %{
      repository_id: repository.id,
      issue_id: issue.id,
      head_repository_id: repository.id,
      head_ref: head_ref,
      head_sha: sha_for(head_ref),
      base_ref: base_ref,
      base_sha: sha_for(base_ref),
      state: "open"
    }

    {:ok, pull_request} =
      %PullRequest{}
      |> PullRequest.changeset(Map.merge(defaults, attrs))
      |> Repo.insert()

    pull_request
  end

  defp sha_for(ref) do
    :sha
    |> :crypto.hash(ref)
    |> Base.encode16(case: :lower)
  end

  defp chain(repository, refs) do
    refs
    |> Enum.zip(["main" | refs])
    |> Enum.map(fn {head, base} -> pull_request(repository, head, base) end)
  end

  describe "create/3" do
    test "creates a stack with contiguous entries and stored boundaries" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      [bottom, middle, top] = chain(repository, ["layer-1", "layer-2", "layer-3"])

      assert {:ok, %Stack{} = stack} = Stacks.create(repository, [bottom, middle, top], user)
      assert stack.number == 1
      assert stack.trunk_ref == "main"
      assert stack.state == "open"
      assert stack.health == "healthy"
      assert stack.version == 1

      [entry_1, entry_2, entry_3] = stack.entries
      assert Enum.map(stack.entries, & &1.position) == [1, 2, 3]
      assert entry_1.boundary_oid == bottom.base_sha
      assert entry_2.boundary_oid == bottom.head_sha
      assert entry_3.boundary_oid == middle.head_sha
      assert entry_1.observed_head_oid == bottom.head_sha
      assert entry_2.observed_head_oid == middle.head_sha
      assert entry_3.observed_head_oid == top.head_sha
    end

    test "numbers are repository-local" do
      user = repository_user_fixture("stack-author")
      first_repository = repository_fixture()
      second_repository = repository_fixture()

      [first_bottom] = chain(first_repository, ["layer-1"])
      [second_bottom] = chain(second_repository, ["layer-1"])

      assert {:ok, %Stack{number: 1}} = Stacks.create(first_repository, [first_bottom], user)
      assert {:ok, %Stack{number: 1}} = Stacks.create(second_repository, [second_bottom], user)

      [next] = chain(first_repository, ["layer-2b"])
      next = %{next | base_ref: "main", base_sha: sha_for("main")}
      assert {:ok, %Stack{number: 2}} = Stacks.create(first_repository, [next], user)
    end

    test "rejects a stack above the maximum size" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()

      refs = Enum.map(1..(Stacks.max_entries() + 1), &"layer-#{&1}")
      pull_requests = chain(repository, refs)

      assert {:error, :stack_too_large} = Stacks.create(repository, pull_requests, user)
    end

    test "rejects an empty stack" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()

      assert {:error, :empty_stack} = Stacks.create(repository, [], user)
    end

    test "rejects pull requests from another repository" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      other_repository = repository_fixture()
      [foreign] = chain(other_repository, ["layer-1"])

      assert {:error, :repository_mismatch} = Stacks.create(repository, [foreign], user)
    end

    test "rejects cross-repository heads" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      fork = repository_fixture()

      forked =
        pull_request(repository, "layer-1", "main", %{head_repository_id: fork.id})

      assert {:error, :cross_repository_head} = Stacks.create(repository, [forked], user)
    end

    test "rejects closed pull requests" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      closed = pull_request(repository, "layer-1", "main", %{state: "closed"})

      assert {:error, :pull_request_not_open} = Stacks.create(repository, [closed], user)
    end

    test "rejects duplicate pull requests" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      [bottom] = chain(repository, ["layer-1"])

      assert {:error, :duplicate_pull_request} =
               Stacks.create(repository, [bottom, bottom], user)
    end

    test "rejects duplicate branches" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      bottom = pull_request(repository, "layer-1", "main")
      loop = pull_request(repository, "main", "layer-1")

      assert {:error, :duplicate_branch} = Stacks.create(repository, [bottom, loop], user)
    end

    test "rejects a broken direct-base chain" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      bottom = pull_request(repository, "layer-1", "main")
      detached = pull_request(repository, "layer-2", "main")

      assert {:error, :broken_base_chain} = Stacks.create(repository, [bottom, detached], user)
    end

    test "rejects a pull request that is already in an active stack" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      [bottom] = chain(repository, ["layer-1"])

      assert {:ok, _stack} = Stacks.create(repository, [bottom], user)
      assert {:error, :already_stacked} = Stacks.create(repository, [bottom], user)
    end
  end

  describe "entry constraints" do
    test "one pull request belongs to at most one active stack" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      [bottom] = chain(repository, ["layer-1"])
      other = pull_request(repository, "layer-x", "main")

      assert {:ok, stack} = Stacks.create(repository, [bottom], user)
      assert {:ok, other_stack} = Stacks.create(repository, [other], user)

      assert {:error, changeset} =
               %StackEntry{}
               |> StackEntry.changeset(%{
                 stack_id: other_stack.id,
                 pull_request_id: bottom.id,
                 position: 2,
                 boundary_oid: sha("a"),
                 observed_head_oid: sha("b")
               })
               |> Repo.insert()

      assert "has already been taken" in errors_on(changeset).pull_request_id

      [entry] = stack.entries
      removed_at = DateTime.utc_now()
      {:ok, _removed} = entry |> StackEntry.changeset(%{removed_at: removed_at}) |> Repo.update()

      assert {:ok, _entry} =
               %StackEntry{}
               |> StackEntry.changeset(%{
                 stack_id: other_stack.id,
                 pull_request_id: bottom.id,
                 position: 2,
                 boundary_oid: sha("a"),
                 observed_head_oid: sha("b")
               })
               |> Repo.insert()
    end

    test "active positions are unique per stack" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      [bottom] = chain(repository, ["layer-1"])
      other = pull_request(repository, "layer-x", "main")

      assert {:ok, stack} = Stacks.create(repository, [bottom], user)

      assert {:error, changeset} =
               %StackEntry{}
               |> StackEntry.changeset(%{
                 stack_id: stack.id,
                 pull_request_id: other.id,
                 position: 1,
                 boundary_oid: sha("a"),
                 observed_head_oid: sha("b")
               })
               |> Repo.insert()

      assert "has already been taken" in errors_on(changeset).stack_id
    end

    test "positions start at one" do
      changeset =
        StackEntry.changeset(%StackEntry{}, %{
          stack_id: Ecto.UUID.generate(),
          pull_request_id: Ecto.UUID.generate(),
          position: 0,
          boundary_oid: sha("a"),
          observed_head_oid: sha("b")
        })

      assert "must be greater than or equal to 1" in errors_on(changeset).position
    end

    test "OIDs accept SHA-1 and SHA-256 object names and reject other values" do
      base = %{
        stack_id: Ecto.UUID.generate(),
        pull_request_id: Ecto.UUID.generate(),
        position: 1,
        observed_head_oid: sha("b")
      }

      sha256 = String.duplicate("ab", 32)

      changeset = StackEntry.changeset(%StackEntry{}, Map.put(base, :boundary_oid, sha256))
      assert Ecto.Changeset.get_field(changeset, :boundary_oid) == sha256

      for invalid <- [String.duplicate("a", 39), String.duplicate("z", 40), 42] do
        changeset = StackEntry.changeset(%StackEntry{}, Map.put(base, :boundary_oid, invalid))
        assert "is invalid" in errors_on(changeset).boundary_oid
      end
    end

    test "OIDs round-trip through the database" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      [bottom] = chain(repository, ["layer-1"])

      assert {:ok, stack} = Stacks.create(repository, [bottom], user)
      [entry] = stack.entries
      reloaded = Repo.get!(StackEntry, entry.id)

      assert reloaded.boundary_oid == bottom.base_sha
      assert reloaded.observed_head_oid == bottom.head_sha
    end
  end

  describe "base edits" do
    test "generic base edits fail while a pull request is stacked" do
      user = repository_user_fixture("stack-author")
      repository = repository_with_member_fixture(user)
      [bottom] = chain(repository, ["layer-1"])

      assert :ok = Stacks.ensure_base_editable(bottom)
      assert {:ok, _stack} = Stacks.create(repository, [bottom], user)
      assert {:error, :stack_managed_base} = Stacks.ensure_base_editable(bottom)

      assert {:error, :stack_managed_base} =
               PullRequests.update(bottom, %{"base" => "other-branch"}, user)
    end
  end

  describe "health and state" do
    test "a stale graph never dissolves a stack" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      [bottom] = chain(repository, ["layer-1"])

      assert {:ok, stack} = Stacks.create(repository, [bottom], user)
      assert {:ok, updated} = Stacks.set_health(stack, "needs_rebase")
      assert updated.health == "needs_rebase"
      assert updated.state == "open"
      assert updated.version == stack.version
    end

    test "rejects unknown health states" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      [bottom] = chain(repository, ["layer-1"])

      assert {:ok, stack} = Stacks.create(repository, [bottom], user)
      assert {:error, changeset} = Stacks.set_health(stack, "broken")
      assert "is invalid" in errors_on(changeset).health
    end

    test "completing and dissolving bump the version and require the caller's version" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      [bottom] = chain(repository, ["layer-1"])
      other = pull_request(repository, "layer-x", "main")

      assert {:ok, stack} = Stacks.create(repository, [bottom], user)
      assert {:ok, completed} = Stacks.complete(stack)
      assert completed.state == "completed"
      assert completed.version == stack.version + 1

      assert {:error, :stale_stack_version} = Stacks.complete(stack)
      assert {:error, :stack_not_open} = Stacks.dissolve(completed)

      assert {:ok, second} = Stacks.create(repository, [other], user)
      assert {:ok, dissolved} = Stacks.dissolve(second)
      assert dissolved.state == "dissolved"
      assert dissolved.version == second.version + 1
    end

    test "get_by_number!/2 returns ordered active entries" do
      user = repository_user_fixture("stack-author")
      repository = repository_fixture()
      pull_requests = chain(repository, ["layer-1", "layer-2"])

      assert {:ok, stack} = Stacks.create(repository, pull_requests, user)
      loaded = Stacks.get_by_number!(repository, stack.number)

      assert Enum.map(loaded.entries, & &1.position) == [1, 2]
      assert Enum.map(loaded.entries, & &1.pull_request.id) == Enum.map(pull_requests, & &1.id)
    end
  end
end