test/openagents_web/controllers/stack_controller_test.exs

main at 58e6347eeb72 · 36 KB

defmodule OpenAgentsWeb.StackControllerTest do
  use OpenAgentsWeb.ConnCase

  alias OpenAgents.Forge.Repos
  alias OpenAgents.PullRequests.PullRequest
  alias OpenAgents.Repo
  alias OpenAgents.Stacks.IdempotencyRequest
  alias OpenAgents.Stacks.Stack
  alias OpenAgents.Stacks.StackEvent

  import Ecto.Query
  import OpenAgents.IssuesFixtures

  setup do
    base =
      Path.join(
        System.tmp_dir!(),
        "stack-controller-#{System.unique_integer([:positive])}"
      )

    previous_data = Application.get_env(:openagents, :forge_data_dir)
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))

    on_exit(fn ->
      restore_env(:forge_data_dir, previous_data)
      restore_env(:forge_wal_dir, previous_wal)
      File.rm_rf(base)
    end)

    :ok
  end

  describe "POST /api/v1/repos/:owner/:repo/stacks" do
    test "creates a stack, reads it back, and replays the idempotency key", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1", "layer-2", "layer-3"])
      [pr_1, pr_2, pr_3] = pull_request_chain(repository, oids, ["layer-1", "layer-2", "layer-3"])
      conn = put_forge_api_token(conn, "stack-create", repository)

      body = %{
        trunk_ref: "main",
        pull_requests: [pr_1, pr_2, pr_3],
        expected_heads: %{
          "#{pr_1}" => oids["layer-1"],
          "#{pr_2}" => oids["layer-2"],
          "#{pr_3}" => oids["layer-3"]
        }
      }

      create_conn =
        conn
        |> put_req_header("idempotency-key", "stack-create-1")
        |> post(path(repository), body)

      assert %{
               "number" => 1,
               "trunk_ref" => "main",
               "state" => "open",
               "health" => "healthy",
               "version" => 1,
               "size" => 3,
               "replayed" => false,
               "entries" => [entry_1, entry_2, entry_3]
             } = json_response(create_conn, 201)

      assert %{"position" => 1, "boundary_oid" => boundary_1} = entry_1
      assert boundary_1 == oids["main"]
      assert %{"position" => 2, "boundary_oid" => boundary_2} = entry_2
      assert boundary_2 == oids["layer-1"]
      assert %{"position" => 3, "observed_head_oid" => head_3} = entry_3
      assert head_3 == oids["layer-3"]

      events = Repo.all(from event in StackEvent, order_by: [asc: event.inserted_at])

      assert [%StackEvent{event_type: "pull_request_stack.created", stack_version: 1} | stacked] =
               events

      assert Enum.map(stacked, & &1.event_type) == List.duplicate("pull_request.stacked", 3)

      replay_conn =
        conn
        |> put_req_header("idempotency-key", "stack-create-1")
        |> post(path(repository), body)

      assert %{"number" => 1, "replayed" => true} = json_response(replay_conn, 201)
      assert Repo.aggregate(Stack, :count) == 1
      assert Repo.aggregate(IdempotencyRequest, :count) == 1

      show_conn = get(conn, "#{path(repository)}/1")
      assert %{"number" => 1, "size" => 3} = json_response(show_conn, 200)

      index_conn = get(conn, path(repository))
      assert [%{"number" => 1}] = json_response(index_conn, 200)
    end

    test "rejects a reused idempotency key with a different request", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      conn = put_forge_api_token(conn, "stack-idem-conflict", repository)

      first =
        conn
        |> put_req_header("idempotency-key", "stack-idem-1")
        |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})

      assert %{"number" => 1} = json_response(first, 201)

      second =
        conn
        |> put_req_header("idempotency-key", "stack-idem-1")
        |> post(path(repository), %{trunk_ref: "other", pull_requests: [pr_1]})

      assert %{"code" => "idempotency_conflict"} = json_response(second, 409)
    end

    test "requires one Idempotency-Key header", %{conn: conn} do
      repository = repository_fixture()
      conn = put_forge_api_token(conn, "stack-no-key", repository)

      conn = post(conn, path(repository), %{trunk_ref: "main", pull_requests: [1]})
      assert %{"message" => "Provide one Idempotency-Key header"} = json_response(conn, 400)
    end

    test "refuses a caller without write access", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      conn = put_forge_api_token(conn, "stack-nonmember")

      conn =
        conn
        |> put_req_header("idempotency-key", "stack-forbidden-1")
        |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})

      assert json_response(conn, 403)
    end

    test "rejects each structural violation with an explanation", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1", "layer-2", "layer-3"])
      [pr_1, pr_2, pr_3] = pull_request_chain(repository, oids, ["layer-1", "layer-2", "layer-3"])
      conn = put_forge_api_token(conn, "stack-rejections", repository)

      post_stack = fn body, key ->
        conn
        |> put_req_header("idempotency-key", key)
        |> post(path(repository), body)
      end

      response = post_stack.(%{trunk_ref: "main"}, "reject-1")
      assert %{"code" => "invalid_request"} = json_response(response, 422)

      response = post_stack.(%{trunk_ref: "main", pull_requests: [999]}, "reject-2")
      assert json_response(response, 404)

      response =
        post_stack.(%{trunk_ref: "wrong-trunk", pull_requests: [pr_1, pr_2]}, "reject-3")

      assert %{"code" => "trunk_mismatch"} = json_response(response, 422)

      response = post_stack.(%{trunk_ref: "main", pull_requests: [pr_1, pr_3]}, "reject-4")
      assert %{"code" => "broken_base_chain"} = json_response(response, 422)

      response = post_stack.(%{trunk_ref: "main", pull_requests: [pr_1, pr_1]}, "reject-5")
      assert %{"code" => "duplicate_pull_request"} = json_response(response, 422)

      response =
        post_stack.(
          %{
            trunk_ref: "main",
            pull_requests: [pr_1],
            expected_heads: %{"#{pr_1}" => String.duplicate("a", 40)}
          },
          "reject-6"
        )

      assert %{"code" => "expected_head_mismatch"} = json_response(response, 409)

      closed = pull_request(repository, "orphan", "main", oids["main"], oids["main"], "closed")

      response = post_stack.(%{trunk_ref: "main", pull_requests: [closed]}, "reject-7")
      assert %{"code" => "pull_request_not_open"} = json_response(response, 422)

      assert %{"number" => 1} =
               post_stack.(%{trunk_ref: "main", pull_requests: [pr_1, pr_2]}, "accept-1")
               |> json_response(201)

      response = post_stack.(%{trunk_ref: "main", pull_requests: [pr_1]}, "reject-8")
      assert %{"code" => "already_stacked"} = json_response(response, 422)
    end

    test "records the fork point as the boundary when the trunk moved past the branch", %{
      conn: conn
    } do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      conn = put_forge_api_token(conn, "stack-fork-point", repository)

      # The trunk advances after the branch forked, so the boundary must be
      # the fork point — recording the moved tip would make a later merge
      # land a tree that reverts the advance commit.
      git_dir = Repos.ensure_repo!(repository.storage_key, repository.default_branch)
      {trunk_tip, _tree} = commit(git_dir, oids["main"], "Trunk advance", "trunk.md")
      {_, 0} = Repos.git(git_dir, ["update-ref", "refs/heads/main", trunk_tip])

      create_conn =
        conn
        |> put_req_header("idempotency-key", "stack-fork-point-1")
        |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})

      assert %{"entries" => [entry]} = json_response(create_conn, 201)
      assert entry["boundary_oid"] == oids["main"]
    end

    test "rejects a head ref the git service cannot resolve", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, [])
      missing = pull_request(repository, "ghost", "main", oids["main"], oids["main"], "open")
      conn = put_forge_api_token(conn, "stack-missing-ref", repository)

      conn =
        conn
        |> put_req_header("idempotency-key", "stack-missing-1")
        |> post(path(repository), %{trunk_ref: "main", pull_requests: [missing]})

      assert %{"code" => "invalid_ref"} = json_response(conn, 422)
    end
  end

  describe "POST /api/v1/repos/:owner/:repo/stacks/:stack_number/append" do
    test "appends to the top, bumps the version, and replays retries", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1", "layer-2"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      pr_2 = pull_request(repository, "layer-2", "layer-1", oids["layer-1"], oids["layer-2"])
      conn = put_forge_api_token(conn, "stack-append", repository)

      assert %{"number" => 1, "version" => 1} =
               conn
               |> put_req_header("idempotency-key", "append-create-1")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
               |> json_response(201)

      append_conn =
        conn
        |> put_req_header("idempotency-key", "append-1")
        |> post("#{path(repository)}/1/append", %{
          pull_request: pr_2,
          expected_stack_version: 1,
          expected_head: oids["layer-2"]
        })

      assert %{
               "version" => 2,
               "size" => 2,
               "replayed" => false,
               "entries" => [_bottom, %{"position" => 2, "boundary_oid" => boundary}]
             } = json_response(append_conn, 200)

      assert boundary == oids["layer-1"]

      assert Repo.exists?(
               from event in StackEvent,
                 where: event.event_type == "pull_request_stack.appended"
             )

      replay_conn =
        conn
        |> put_req_header("idempotency-key", "append-1")
        |> post("#{path(repository)}/1/append", %{
          pull_request: pr_2,
          expected_stack_version: 1,
          expected_head: oids["layer-2"]
        })

      assert %{"version" => 2, "replayed" => true} = json_response(replay_conn, 200)
      assert Repo.aggregate(Stack, :count) == 1
    end

    test "rejects a stale expected version, a non-top target, and a missing stack", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1", "layer-2"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      pr_2 = pull_request(repository, "layer-2", "layer-1", oids["layer-1"], oids["layer-2"])
      orphan = pull_request(repository, "orphan", "main", oids["main"], oids["main"], "open")
      conn = put_forge_api_token(conn, "stack-append-reject", repository)

      assert %{"number" => 1} =
               conn
               |> put_req_header("idempotency-key", "append-reject-create")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
               |> json_response(201)

      stale =
        conn
        |> put_req_header("idempotency-key", "append-reject-1")
        |> post("#{path(repository)}/1/append", %{pull_request: pr_2, expected_stack_version: 9})

      assert %{"code" => "stale_stack_version"} = json_response(stale, 409)

      not_top =
        conn
        |> put_req_header("idempotency-key", "append-reject-2")
        |> post("#{path(repository)}/1/append", %{pull_request: orphan})

      assert %{"code" => "not_stack_top"} = json_response(not_top, 422)

      missing =
        conn
        |> put_req_header("idempotency-key", "append-reject-3")
        |> post("#{path(repository)}/9/append", %{pull_request: pr_2})

      assert json_response(missing, 404)
    end

    test "refuses to extend a stack that is no longer open", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1", "layer-2"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      pr_2 = pull_request(repository, "layer-2", "layer-1", oids["layer-1"], oids["layer-2"])
      conn = put_forge_api_token(conn, "stack-append-closed", repository)

      assert %{"number" => 1} =
               conn
               |> put_req_header("idempotency-key", "append-closed-create")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
               |> json_response(201)

      {:ok, _stack} =
        OpenAgents.Stacks.dissolve(Repo.get_by!(Stack, repository_id: repository.id))

      conn =
        conn
        |> put_req_header("idempotency-key", "append-closed-1")
        |> post("#{path(repository)}/1/append", %{pull_request: pr_2})

      assert %{"code" => "stack_not_open"} = json_response(conn, 409)
    end
  end

  describe "POST /api/v1/repos/:owner/:repo/stacks/:stack_number/unstack" do
    test "removes the top layer, records the event, and replays retries", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1", "layer-2"])
      [pr_1, pr_2] = pull_request_chain(repository, oids, ["layer-1", "layer-2"])
      conn = put_forge_api_token(conn, "stack-unstack", repository)

      assert %{"number" => 1, "version" => 1} =
               conn
               |> put_req_header("idempotency-key", "unstack-create-1")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1, pr_2]})
               |> json_response(201)

      unstack_conn =
        conn
        |> put_req_header("idempotency-key", "unstack-1")
        |> post("#{path(repository)}/1/unstack", %{
          pull_request: pr_2,
          expected_stack_version: 1
        })

      assert %{
               "state" => "open",
               "version" => 2,
               "size" => 1,
               "replayed" => false,
               "entries" => [%{"position" => 1}]
             } = json_response(unstack_conn, 200)

      assert Repo.exists?(
               from event in StackEvent,
                 where:
                   event.event_type == "pull_request.unstacked" and
                     fragment("?->>'reason' = 'unstacked'", event.payload)
             )

      replay_conn =
        conn
        |> put_req_header("idempotency-key", "unstack-1")
        |> post("#{path(repository)}/1/unstack", %{
          pull_request: pr_2,
          expected_stack_version: 1
        })

      assert %{"size" => 1, "replayed" => true} = json_response(replay_conn, 200)
    end

    test "dissolves the stack when the last layer leaves", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      conn = put_forge_api_token(conn, "stack-unstack-last", repository)

      assert %{"number" => 1} =
               conn
               |> put_req_header("idempotency-key", "unstack-last-create")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
               |> json_response(201)

      unstack_conn =
        conn
        |> put_req_header("idempotency-key", "unstack-last-1")
        |> post("#{path(repository)}/1/unstack", %{pull_request: pr_1})

      assert %{"state" => "dissolved", "size" => 0, "entries" => []} =
               json_response(unstack_conn, 200)

      assert Repo.exists?(
               from event in StackEvent,
                 where: event.event_type == "pull_request_stack.dissolved"
             )
    end

    test "rejects a non-top layer and an unknown pull request", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1", "layer-2"])
      [pr_1, _pr_2] = pull_request_chain(repository, oids, ["layer-1", "layer-2"])
      conn = put_forge_api_token(conn, "stack-unstack-reject", repository)

      assert %{"number" => 1} =
               conn
               |> put_req_header("idempotency-key", "unstack-reject-create")
               |> post(path(repository), %{
                 trunk_ref: "main",
                 pull_requests: [pr_1, pr_1 + 1]
               })
               |> json_response(201)

      not_top =
        conn
        |> put_req_header("idempotency-key", "unstack-reject-1")
        |> post("#{path(repository)}/1/unstack", %{pull_request: pr_1})

      assert %{"code" => "not_stack_top"} = json_response(not_top, 422)

      unknown =
        conn
        |> put_req_header("idempotency-key", "unstack-reject-2")
        |> post("#{path(repository)}/1/unstack", %{pull_request: 999})

      assert %{"code" => "pull_request_not_in_stack"} = json_response(unknown, 422)
    end
  end

  describe "POST /api/v1/repos/:owner/:repo/stacks/:stack_number/dissolve" do
    test "releases every layer, dissolves the stack, and replays retries", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1", "layer-2"])
      [pr_1, pr_2] = pull_request_chain(repository, oids, ["layer-1", "layer-2"])
      conn = put_forge_api_token(conn, "stack-dissolve", repository)

      assert %{"number" => 1} =
               conn
               |> put_req_header("idempotency-key", "dissolve-create-1")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1, pr_2]})
               |> json_response(201)

      dissolve_conn =
        conn
        |> put_req_header("idempotency-key", "dissolve-1")
        |> post("#{path(repository)}/1/dissolve", %{expected_stack_version: 1})

      assert %{"state" => "dissolved", "size" => 0, "entries" => [], "replayed" => false} =
               json_response(dissolve_conn, 200)

      unstacked =
        Repo.all(
          from event in StackEvent,
            where:
              event.event_type == "pull_request.unstacked" and
                fragment("?->>'reason' = 'dissolved'", event.payload)
        )

      assert length(unstacked) == 2

      assert Repo.exists?(
               from event in StackEvent,
                 where: event.event_type == "pull_request_stack.dissolved"
             )

      replay_conn =
        conn
        |> put_req_header("idempotency-key", "dissolve-1")
        |> post("#{path(repository)}/1/dissolve", %{expected_stack_version: 1})

      assert %{"state" => "dissolved", "replayed" => true} = json_response(replay_conn, 200)

      again =
        conn
        |> put_req_header("idempotency-key", "dissolve-2")
        |> post("#{path(repository)}/1/dissolve", %{})

      assert %{"code" => "stack_not_open"} = json_response(again, 409)
    end

    test "refuses while an operation is active", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      conn = put_forge_api_token(conn, "stack-dissolve-active", repository)

      assert %{"number" => 1} =
               conn
               |> put_req_header("idempotency-key", "dissolve-active-create")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
               |> json_response(201)

      assert %{"id" => operation_id} =
               conn
               |> put_req_header("idempotency-key", "dissolve-active-rebase")
               |> post("#{path(repository)}/1/rebase", %{})
               |> json_response(202)

      blocked =
        conn
        |> put_req_header("idempotency-key", "dissolve-active-1")
        |> post("#{path(repository)}/1/dissolve", %{})

      assert %{"code" => "operation_in_progress", "operation_id" => ^operation_id} =
               json_response(blocked, 409)
    end
  end

  describe "POST /api/v1/repos/:owner/:repo/stacks/:stack_number/rebase" do
    test "accepts a rebase, exposes the operation, and replays retries", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      conn = put_forge_api_token(conn, "stack-rebase", repository)

      assert %{"number" => 1} =
               conn
               |> put_req_header("idempotency-key", "rebase-create-1")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
               |> json_response(201)

      rebase_conn =
        conn
        |> put_req_header("idempotency-key", "rebase-1")
        |> post("#{path(repository)}/1/rebase", %{})

      assert %{
               "id" => operation_id,
               "kind" => "rebase",
               "state" => "pending",
               "replayed" => false
             } = json_response(rebase_conn, 202)

      assert %{"health" => "operation_in_progress"} =
               json_response(get(conn, "#{path(repository)}/1"), 200)

      replay_conn =
        conn
        |> put_req_header("idempotency-key", "rebase-1")
        |> post("#{path(repository)}/1/rebase", %{})

      assert %{"id" => ^operation_id, "replayed" => true} = json_response(replay_conn, 202)

      second_conn =
        conn
        |> put_req_header("idempotency-key", "rebase-2")
        |> post("#{path(repository)}/1/rebase", %{})

      assert %{"code" => "operation_in_progress"} = json_response(second_conn, 409)

      show_conn = get(conn, "#{path(repository)}/1/operations/#{operation_id}")
      assert %{"id" => ^operation_id, "state" => "pending"} = json_response(show_conn, 200)

      missing_conn =
        get(conn, "#{path(repository)}/1/operations/00000000-0000-0000-0000-000000000000")

      assert json_response(missing_conn, 404)
    end

    test "continue requires a paused operation, and abort cancels", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      conn = put_forge_api_token(conn, "stack-rebase-ops", repository)

      assert %{"number" => 1} =
               conn
               |> put_req_header("idempotency-key", "rebase-ops-create")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
               |> json_response(201)

      assert %{"id" => operation_id} =
               conn
               |> put_req_header("idempotency-key", "rebase-ops-1")
               |> post("#{path(repository)}/1/rebase", %{})
               |> json_response(202)

      continue_conn =
        post(conn, "#{path(repository)}/1/operations/#{operation_id}/continue", %{
          resolution_oid: oids["layer-1"]
        })

      assert %{"code" => "operation_not_waiting"} = json_response(continue_conn, 409)

      abort_conn = post(conn, "#{path(repository)}/1/operations/#{operation_id}/abort", %{})
      assert %{"id" => ^operation_id, "state" => "cancelled"} = json_response(abort_conn, 200)

      assert %{"health" => "healthy"} = json_response(get(conn, "#{path(repository)}/1"), 200)

      again_conn = post(conn, "#{path(repository)}/1/operations/#{operation_id}/abort", %{})
      assert %{"code" => "operation_not_abortable"} = json_response(again_conn, 409)
    end

    test "refuses a caller without write access", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1"])
      [_pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      conn = put_forge_api_token(conn, "stack-rebase-outsider")

      conn =
        conn
        |> put_req_header("idempotency-key", "rebase-forbidden-1")
        |> post("#{path(repository)}/1/rebase", %{})

      assert json_response(conn, 403)
    end
  end

  describe "POST /api/v1/repos/:owner/:repo/stacks/:stack_number/merge" do
    test "accepts a merge, exposes the operation, and replays retries", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      conn = put_forge_api_token(conn, "stack-merge", repository)

      assert %{"number" => 1} =
               conn
               |> put_req_header("idempotency-key", "merge-create-1")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
               |> json_response(201)

      body = %{pull_request_number: pr_1, merge_method: "merge"}

      merge_conn =
        conn
        |> put_req_header("idempotency-key", "merge-1")
        |> post("#{path(repository)}/1/merge", body)

      assert %{
               "id" => operation_id,
               "kind" => "merge",
               "state" => "pending",
               "replayed" => false
             } = json_response(merge_conn, 202)

      assert %{"health" => "operation_in_progress"} =
               json_response(get(conn, "#{path(repository)}/1"), 200)

      replay_conn =
        conn
        |> put_req_header("idempotency-key", "merge-1")
        |> post("#{path(repository)}/1/merge", body)

      assert %{"id" => ^operation_id, "replayed" => true} = json_response(replay_conn, 202)

      show_conn = get(conn, "#{path(repository)}/1/operations/#{operation_id}")
      assert %{"id" => ^operation_id, "state" => "pending"} = json_response(show_conn, 200)
    end

    test "rejects a queue action, a stranger PR, and a bad method", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      conn = put_forge_api_token(conn, "stack-merge-invalid", repository)

      assert %{"number" => 1} =
               conn
               |> put_req_header("idempotency-key", "merge-invalid-create")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
               |> json_response(201)

      queue_conn =
        conn
        |> put_req_header("idempotency-key", "merge-queue-1")
        |> post("#{path(repository)}/1/merge", %{
          pull_request_number: pr_1,
          merge_method: "merge",
          merge_action: "queue"
        })

      assert %{"code" => "merge_queue_unavailable"} = json_response(queue_conn, 409)

      stranger_conn =
        conn
        |> put_req_header("idempotency-key", "merge-stranger-1")
        |> post("#{path(repository)}/1/merge", %{
          pull_request_number: 999_999,
          merge_method: "merge"
        })

      assert %{"code" => "pull_request_not_in_stack"} = json_response(stranger_conn, 422)

      method_conn =
        conn
        |> put_req_header("idempotency-key", "merge-method-1")
        |> post("#{path(repository)}/1/merge", %{
          pull_request_number: pr_1,
          merge_method: "octopus"
        })

      assert %{"code" => "invalid_request"} = json_response(method_conn, 422)
    end

    test "refuses a caller without write access", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      conn = put_forge_api_token(conn, "stack-merge-outsider")

      conn =
        conn
        |> put_req_header("idempotency-key", "merge-forbidden-1")
        |> post("#{path(repository)}/1/merge", %{
          pull_request_number: pr_1,
          merge_method: "merge"
        })

      assert json_response(conn, 403)
    end
  end

  describe "PUT /api/v1/repos/:owner/:repo/pulls/:pull_number/merge-async" do
    test "accepts a submission, polls it, replays retries, and reports the active conflict",
         %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1", "layer-2"])
      [pr_1, pr_2] = pull_request_chain(repository, oids, ["layer-1", "layer-2"])
      conn = put_forge_api_token(conn, "merge-async", repository)

      assert %{"number" => 1} =
               conn
               |> put_req_header("idempotency-key", "merge-async-create")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1, pr_2]})
               |> json_response(201)

      submit_conn =
        conn
        |> put_req_header("idempotency-key", "merge-async-1")
        |> put("#{pulls_path(repository)}/#{pr_1}/merge-async", %{})

      assert %{
               "operation_id" => operation_id,
               "merge_status" => "pending",
               "state" => "pending",
               "merge_method" => "merge",
               "pull_request" => ^pr_1,
               "replayed" => false,
               "url" => poll_url
             } = json_response(submit_conn, 202)

      assert String.ends_with?(
               poll_url,
               "#{pulls_path(repository)}/#{pr_1}/merge-async/#{operation_id}"
             )

      poll_conn = get(conn, "#{pulls_path(repository)}/#{pr_1}/merge-async/#{operation_id}")

      assert %{"operation_id" => ^operation_id, "merge_status" => "pending"} =
               json_response(poll_conn, 200)

      replay_conn =
        conn
        |> put_req_header("idempotency-key", "merge-async-1")
        |> put("#{pulls_path(repository)}/#{pr_1}/merge-async", %{})

      assert %{"operation_id" => ^operation_id, "replayed" => true} =
               json_response(replay_conn, 202)

      second_conn =
        conn
        |> put_req_header("idempotency-key", "merge-async-2")
        |> put("#{pulls_path(repository)}/#{pr_2}/merge-async", %{})

      assert %{"code" => "operation_in_progress", "operation_id" => ^operation_id} =
               json_response(second_conn, 409)
    end

    test "scopes the poll to the submitting pull request", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1", "layer-2"])
      [pr_1, pr_2] = pull_request_chain(repository, oids, ["layer-1", "layer-2"])
      conn = put_forge_api_token(conn, "merge-async-scope", repository)

      assert %{"number" => 1} =
               conn
               |> put_req_header("idempotency-key", "merge-async-scope-create")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1, pr_2]})
               |> json_response(201)

      assert %{"operation_id" => operation_id} =
               conn
               |> put_req_header("idempotency-key", "merge-async-scope-1")
               |> put("#{pulls_path(repository)}/#{pr_1}/merge-async", %{})
               |> json_response(202)

      other_conn = get(conn, "#{pulls_path(repository)}/#{pr_2}/merge-async/#{operation_id}")
      assert json_response(other_conn, 404)

      bogus_conn = get(conn, "#{pulls_path(repository)}/#{pr_1}/merge-async/not-a-uuid")
      assert json_response(bogus_conn, 404)
    end

    test "rejects an unstacked pull request and an unknown pull request", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1"])
      solo = pull_request(repository, "layer-1", "main", oids["main"], oids["layer-1"])
      conn = put_forge_api_token(conn, "merge-async-unstacked", repository)

      solo_conn =
        conn
        |> put_req_header("idempotency-key", "merge-async-solo-1")
        |> put("#{pulls_path(repository)}/#{solo}/merge-async", %{})

      assert %{"code" => "not_stacked"} = json_response(solo_conn, 422)

      missing_conn =
        conn
        |> put_req_header("idempotency-key", "merge-async-missing-1")
        |> put("#{pulls_path(repository)}/999999/merge-async", %{})

      assert json_response(missing_conn, 404)
    end

    test "refuses a caller without write access", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1"])
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
      conn = put_forge_api_token(conn, "merge-async-outsider")

      writer_conn =
        Phoenix.ConnTest.build_conn()
        |> put_forge_api_token("merge-async-owner", repository)

      assert %{"number" => 1} =
               writer_conn
               |> put_req_header("idempotency-key", "merge-async-outsider-create")
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
               |> json_response(201)

      conn =
        conn
        |> put_req_header("idempotency-key", "merge-async-outsider-1")
        |> put("#{pulls_path(repository)}/#{pr_1}/merge-async", %{})

      assert json_response(conn, 403)
    end
  end

  describe "GET /api/v1/repos/:owner/:repo/stacks" do
    test "reads are public for a public repository", %{conn: conn} do
      repository = repository_fixture()

      assert [] == json_response(get(conn, path(repository)), 200)
      assert json_response(get(conn, "#{path(repository)}/1"), 404)
    end
  end

  describe "stack membership in pull request payloads" do
    test "stacked pull requests embed the stack context; unstacked carry nil", %{conn: conn} do
      repository = repository_fixture()
      oids = seed_chain(repository, ["layer-1", "layer-2"])
      [pr_1, pr_2] = pull_request_chain(repository, oids, ["layer-1", "layer-2"])
      solo = pull_request(repository, "layer-2", "main", oids["main"], oids["layer-2"])
      conn = put_forge_api_token(conn, "stack-payload", repository)

      create_conn =
        conn
        |> put_req_header("idempotency-key", "stack-payload-1")
        |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1, pr_2]})

      assert %{"number" => stack_number} = json_response(create_conn, 201)

      pulls = "/api/v1/repos/#{repository.owner}/#{repository.name}/pulls"

      show_conn = get(conn, "#{pulls}/#{pr_2}")
      main_oid = oids["main"]

      assert %{
               "stack" => %{
                 "number" => ^stack_number,
                 "position" => 2,
                 "size" => 2,
                 "health" => "healthy",
                 "base" => %{"ref" => "main", "sha" => ^main_oid}
               }
             } = json_response(show_conn, 200)

      solo_conn = get(conn, "#{pulls}/#{solo}")
      assert %{"stack" => nil} = json_response(solo_conn, 200)

      index_conn = get(conn, pulls)
      by_number = Map.new(json_response(index_conn, 200), &{&1["number"], &1})
      assert %{"stack" => %{"position" => 1, "size" => 2}} = Map.fetch!(by_number, pr_1)
      assert %{"stack" => nil} = Map.fetch!(by_number, solo)
    end
  end

  defp path(repository), do: "/api/v1/repos/#{repository.owner}/#{repository.name}/stacks"

  defp pulls_path(repository),
    do: "/api/v1/repos/#{repository.owner}/#{repository.name}/pulls"

  defp seed_chain(repository, branches) do
    path = Repos.ensure_repo!(repository.storage_key, repository.default_branch)

    {main, _tree} = commit(path, nil, "Seed repository", "README.md")
    {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", main])

    branches
    |> Enum.with_index(1)
    |> Enum.reduce(%{"main" => main}, fn {branch, index}, oids ->
      parent = Map.fetch!(oids, Enum.at(["main" | branches], index - 1))
      {oid, _tree} = commit(path, parent, "Layer #{branch}", "#{branch}.md")
      {_, 0} = Repos.git(path, ["update-ref", "refs/heads/#{branch}", oid])
      Map.put(oids, branch, oid)
    end)
  end

  defp commit(path, parent, message, file) do
    blob = git!(path, ["hash-object", "-w", "--stdin"], "#{file}\n")
    tree = git!(path, ["mktree"], "100644 blob #{blob}\t#{file}\n")
    parent_args = if parent, do: ["-p", parent], else: []

    oid =
      git!(path, ["commit-tree", tree] ++ parent_args ++ ["-m", message], "",
        env: [
          {"GIT_AUTHOR_NAME", "Test Author"},
          {"GIT_AUTHOR_EMAIL", "author@example.test"},
          {"GIT_COMMITTER_NAME", "Test Author"},
          {"GIT_COMMITTER_EMAIL", "author@example.test"}
        ]
      )

    {oid, tree}
  end

  defp pull_request_chain(repository, oids, branches) do
    branches
    |> Enum.with_index()
    |> Enum.map(fn {branch, index} ->
      base = Enum.at(["main" | branches], index)
      pull_request(repository, branch, base, oids[base], oids[branch])
    end)
  end

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

    {:ok, _pull_request} =
      %PullRequest{}
      |> PullRequest.changeset(%{
        repository_id: repository.id,
        issue_id: issue.id,
        head_repository_id: repository.id,
        head_ref: head_ref,
        head_sha: head_sha,
        base_ref: base_ref,
        base_sha: base_sha,
        state: state
      })
      |> Repo.insert()

    issue.number
  end

  defp git!(git_dir, args, input, options \\ []) do
    input_path =
      Path.join(
        System.tmp_dir!(),
        "stack-controller-input-#{System.unique_integer([:positive])}"
      )

    File.write!(input_path, input)

    try do
      {output, 0} =
        System.cmd(
          "sh",
          ["-c", ~s(exec git --git-dir "$GIT_DIR" "$@" < "$INPUT"), "sh"] ++ args,
          env: [{"GIT_DIR", git_dir}, {"INPUT", input_path}] ++ Keyword.get(options, :env, [])
        )

      String.trim(output)
    after
      File.rm(input_path)
    end
  end

  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
end