test/openagents_web/controllers/issue_controller_test.exs

main at 58e6347eeb72 · 42 KB

defmodule OpenAgentsWeb.IssueControllerTest do
  use OpenAgentsWeb.ConnCase

  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "issues", repository())}

  alias OpenAgents.Issues
  alias OpenAgents.Agents
  alias OpenAgents.Accounts
  alias OpenAgents.Repo
  alias OpenAgents.Repositories

  import OpenAgents.MilestonesFixtures
  import OpenAgents.LabelsFixtures

  describe "index" do
    test "GET /api/v1/repos/:owner/:repo/issues lists open issues by default", %{
      conn: conn
    } do
      {:ok, _issue} = Issues.create_issue(repository(), %{title: "First issue"})

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues")

      assert %{"issues" => [issue | _]} = json_response(conn, 200)
      assert issue["title"] == "First issue"
      assert issue["state"] == "open"
    end

    test "GET /api/v1/repos/:owner/:repo/issues filters by state", %{conn: conn} do
      {:ok, _open_issue} = Issues.create_issue(repository(), %{title: "Open issue"})

      {:ok, _closed_issue} =
        Issues.create_issue(repository(), %{title: "Closed issue", state: "closed"})

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?state=closed")

      assert %{"issues" => [issue]} = json_response(conn, 200)
      assert issue["title"] == "Closed issue"
      assert issue["state"] == "closed"
    end
  end

  describe "create" do
    test "POST /api/v1/repos/:owner/:repo/issues creates an issue", %{conn: conn} do
      conn =
        post(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues", %{
          title: "New issue",
          body: "A description"
        })

      assert %{
               "number" => _,
               "title" => "New issue",
               "body" => "A description",
               "state" => "open"
             } = json_response(conn, 201)
    end

    test "POST /api/v1/repos/:owner/:repo/issues returns 422 for missing title", %{
      conn: conn
    } do
      conn =
        post(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues", %{body: "No title"})

      assert json_response(conn, 422)["errors"] != %{}
    end

    test "an unlinked agent can create an issue with an agent author", %{conn: conn} do
      {:ok, agent, credential} =
        Agents.register(%{
          handle: "issue-agent",
          display_name: "Issue agent",
          registration_ip: "192.0.2.50"
        })

      conn =
        conn
        |> put_req_header("authorization", "Bearer #{credential}")
        |> post(~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues", %{
          title: "Agent issue",
          body: "Filed without a human link"
        })

      assert %{"user" => %{"agent" => true, "handle" => "issue-agent"}} =
               json_response(conn, 201)

      assert Repo.get_by(OpenAgents.Issues.Issue, title: "Agent issue").author_agent_id ==
               agent.id
    end

    test "a suspended agent is refused on issue creation", %{conn: conn} do
      {:ok, agent, credential} =
        Agents.register(%{
          handle: "suspended-issue-bot",
          display_name: "Suspended issue bot",
          registration_ip: "192.0.2.52"
        })

      assert {:ok, _suspended} = Agents.suspend(agent, "test")

      conn =
        conn
        |> put_req_header("authorization", "Bearer #{credential}")
        |> post(~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues", %{
          title: "Should fail",
          body: "Suspended"
        })

      assert conn.status == 401
    end

    test "an agent credential is refused on private repository issue creation", %{conn: conn} do
      private =
        repository_fixture(%{
          owner: "PrivateOwner",
          name: "private-repository",
          visibility: "private"
        })

      {:ok, _agent, credential} =
        Agents.register(%{
          handle: "private-issue-bot",
          display_name: "Private issue bot",
          registration_ip: "192.0.2.53"
        })

      conn =
        conn
        |> put_req_header("authorization", "Bearer #{credential}")
        |> post("/api/v1/repos/#{private.owner}/#{private.name}/issues", %{
          title: "Should fail",
          body: "Private"
        })

      assert conn.status == 404
    end

    test "linking and unlinking do not rewrite issue authorship", %{conn: conn} do
      {:ok, agent, credential} =
        Agents.register(%{
          handle: "stable-issue-bot",
          display_name: "Stable issue bot",
          registration_ip: "192.0.2.54"
        })

      {:ok, user} =
        Accounts.upsert_github_user(%{
          github_id: 992_054,
          github_login: "stable-issue-reviewer",
          github_avatar_url: "https://avatars.githubusercontent.com/u/992054?v=4"
        })

      created =
        conn
        |> put_req_header("authorization", "Bearer #{credential}")
        |> post(~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues", %{
          title: "Stable issue",
          body: "Authorship must remain stable"
        })

      assert %{"number" => number, "user" => before_author} = json_response(created, 201)
      assert {:ok, pending} = Agents.request_link(agent, user)
      assert {:ok, _linked} = Agents.accept_link(user, pending.id)
      assert {:ok, _unlinked} = Agents.unlink(agent, user)

      after_link =
        get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{number}")
        |> json_response(200)

      assert after_link["user"] == before_author
      assert before_author["agent"] == true
      refute Map.has_key?(before_author, "owner")
    end

    test "a linked agent cannot close an issue through the human-only update route", %{conn: conn} do
      owner = repository_user_fixture("issue-close-agent-owner")
      repository = repository_with_member_fixture(owner)
      {:ok, issue} = Issues.create_issue(repository, %{title: "Must remain open"})

      {:ok, agent, credential} =
        Agents.register(%{
          handle: "issue-close-agent",
          display_name: "Issue close agent",
          registration_ip: "192.0.2.54"
        })

      {:ok, link} = Agents.request_link(agent, owner)
      {:ok, _linked} = Agents.accept_link(owner, link.id)
      assert {:ok, _grant} = Agents.grant_box_control(owner, agent)

      response =
        conn
        |> put_req_header("authorization", "Bearer #{credential}")
        |> patch(
          "/api/v1/repos/#{repository.owner}/#{repository.name}/issues/#{issue.number}",
          %{state: "closed"}
        )

      body = assert_api_error(response, 401, "unauthenticated")
      assert body["error"] == "invalid_api_token"
      assert Repo.get!(OpenAgents.Issues.Issue, issue.id).state == "open"
    end
  end

  describe "show" do
    test "GET /api/v1/repos/:owner/:repo/issues/:issue_number returns the issue", %{
      conn: conn
    } do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Show me"})

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")

      assert %{
               "title" => "Show me",
               "number" => n
             } = json_response(conn, 200)

      assert n == issue.number
    end

    test "GET /api/v1/repos/:owner/:repo/issues/:issue_number returns 404 when missing", %{
      conn: conn
    } do
      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/999999")

      assert json_response(conn, 404)
    end
  end

  describe "private repository reads" do
    test "a repository member can list and read issues, but an anonymous visitor cannot" do
      private_repository = repository_fixture(%{visibility: "private"})
      {:ok, issue} = Issues.create_issue(private_repository, %{title: "Private issue"})

      path =
        "/api/v1/repos/#{private_repository.owner}/#{private_repository.name}/issues"

      assert get(build_conn(), path) |> json_response(404)

      member_conn = put_forge_api_token(build_conn(), "private-issue-reader", private_repository)

      assert %{"issues" => [%{"title" => "Private issue"}]} =
               get(member_conn, path) |> json_response(200)

      issue_path = path <> "/#{issue.number}"

      member_conn = put_forge_api_token(build_conn(), "private-issue-show", private_repository)
      assert %{"title" => "Private issue"} = get(member_conn, issue_path) |> json_response(200)
      assert get(build_conn(), issue_path) |> json_response(404)
    end

    test "a bearer without repository membership cannot read private issues" do
      private_repository = repository_fixture(%{visibility: "private"})
      {:ok, _issue} = Issues.create_issue(private_repository, %{title: "Private issue"})

      conn = put_forge_api_token(build_conn(), "private-issue-nonmember")

      conn =
        get(
          conn,
          "/api/v1/repos/#{private_repository.owner}/#{private_repository.name}/issues"
        )

      assert json_response(conn, 404)
    end
  end

  # A `404` here means one thing on purpose: this caller does not get to know
  # whether the repository exists. Anything else that answered `404` borrowed
  # that ambiguity and became unreadable — a caller could not tell a privacy
  # decision from a typo, so the honest error was unreachable. These tests hold
  # the two apart in both directions.
  describe "a name the repository does not have" do
    test "POST with an unknown label answers 422 and names the label", %{conn: conn} do
      conn =
        post(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues", %{
          title: "Labelled",
          labels: ["area:data-rights"]
        })

      body = json_response(conn, 422)

      assert body["code"] == "validation_failed"
      assert [message] = body["errors"]["labels"]
      assert message =~ "area:data-rights"
      refute Map.has_key?(body["errors"], "repository")
    end

    test "POST with an unknown assignee answers 422 and names the login", %{conn: conn} do
      conn =
        post(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues", %{
          title: "Assigned",
          assignees: ["not-a-member"]
        })

      body = json_response(conn, 422)

      assert body["code"] == "validation_failed"
      assert [message] = body["errors"]["assignees"]
      assert message =~ "not-a-member"
    end

    test "POST with an unknown milestone answers 422 and names the number", %{conn: conn} do
      conn =
        post(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues", %{
          title: "Planned",
          milestone: 4004
        })

      body = json_response(conn, 422)

      assert body["code"] == "validation_failed"
      assert [message] = body["errors"]["milestone"]
      assert message =~ "4004"
    end

    test "PATCH with an unknown label answers 422 too", %{conn: conn} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Relabel me"})

      conn =
        patch(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}", %{
          labels: ["area:data-rights"]
        })

      body = json_response(conn, 422)

      assert body["code"] == "validation_failed"
      assert [message] = body["errors"]["labels"]
      assert message =~ "area:data-rights"
    end

    test "a repository the caller cannot see still answers 404 and discloses nothing" do
      private_repository = repository_fixture(%{visibility: "private"})
      conn = put_forge_api_token(build_conn(), "issue-create-nonmember")

      conn =
        post(
          conn,
          "/api/v1/repos/#{private_repository.owner}/#{private_repository.name}/issues",
          %{title: "Filed blind", labels: ["area:data-rights"]}
        )

      body = json_response(conn, 404)

      assert body["code"] == "not_found"
      assert body["message"] == "Not Found"
      assert body["errors"] == %{}

      # The label in the body is real to this request and absent from the
      # repository, and the answer says neither. Nothing here separates a
      # private repository from one that was never created.
      encoded = Jason.encode!(body)
      refute encoded =~ private_repository.name
      refute encoded =~ private_repository.owner
      refute encoded =~ "area:data-rights"
      refute encoded =~ "label"
    end

    # The width of a rescue is the thing to hold, not just today's outcome. The
    # rescue lives in `OpenAgentsWeb.ControllerHelpers.lookup/1` and wraps one
    # lookup; writing `rescue Ecto.NoResultsError` back into an action would
    # cover the write again, and the next bang lookup added inside it would
    # rejoin the repository's `404` without anyone deciding to. Neither of the
    # two controllers behind the `:agent_participation_api` issue and comment
    # writes names the exception at all.
    test "neither issue nor comment writes rescue Ecto.NoResultsError across an action" do
      for file <- [
            "lib/openagents_web/controllers/issue_controller.ex",
            "lib/openagents_web/controllers/comment_controller.ex"
          ] do
        refute File.read!(file) =~ "Ecto.NoResultsError",
               "#{file} rescues Ecto.NoResultsError itself. Wrap the one lookup in " <>
                 "OpenAgentsWeb.ControllerHelpers.lookup/1 instead, so a lookup added " <>
                 "later cannot leave by the repository's 404."
      end
    end

    test "the two answers are different responses, not one" do
      private_repository = repository_fixture(%{visibility: "private"})

      unknown_label =
        post(
          put_forge_api_token(build_conn(), "issue-create-typo", repository()),
          ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues",
          %{title: "Typo", labels: ["area:data-rights"]}
        )

      unreadable_repository =
        post(
          put_forge_api_token(build_conn(), "issue-create-blind"),
          "/api/v1/repos/#{private_repository.owner}/#{private_repository.name}/issues",
          %{title: "Blind", labels: ["area:data-rights"]}
        )

      assert unknown_label.status == 422
      assert unreadable_repository.status == 404

      assert json_response(unknown_label, 422)["code"] !=
               json_response(unreadable_repository, 404)["code"]
    end
  end

  describe "update" do
    test "PATCH /api/v1/repos/:owner/:repo/issues/:issue_number closes an issue", %{
      conn: conn
    } do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Close me"})

      conn =
        patch(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}", %{
          state: "closed"
        })

      assert %{"state" => "closed", "number" => n} = json_response(conn, 200)
      assert n == issue.number
    end
  end

  describe "request origin URLs" do
    test "issue URLs reflect the request host", %{conn: conn} do
      {:ok, _issue} = Issues.create_issue(repository(), %{title: "Origin issue"})

      conn =
        conn
        |> Map.replace!(:host, "staging.example.com")
        |> get(~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues")

      assert %{"issues" => [issue]} = json_response(conn, 200)

      assert issue["html_url"] =~
               "http://staging.example.com/OpenAgentsInc/openagents.com/issues/"
    end

    test "a forwarded host header does not replace the request origin", %{conn: conn} do
      {:ok, _issue} = Issues.create_issue(repository(), %{title: "Forwarded issue"})

      conn =
        conn
        |> put_req_header("x-forwarded-host", "evil.example.net")
        |> get(~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues")

      assert %{"issues" => [issue]} = json_response(conn, 200)
      refute issue["html_url"] =~ "evil.example.net"
    end
  end

  # The attempt record is `forge_assignments`. Writing one directly keeps this
  # test about the projection rather than about the admission path that
  # creates it, which `OpenAgents.Forge.AssignmentTest` already covers.
  # The evidence chain, built through the same two directions production uses:
  # #130's closing reference claims the commit, and the receipts bind to it.
  defp record_evidence(issue, sha) do
    repository = repository()

    user =
      Accounts.upsert_github_user(%{
        github_id: System.unique_integer([:positive]),
        github_login: "evidence-#{System.unique_integer([:positive])}",
        github_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4"
      })
      |> elem(1)

    %OpenAgents.Issues.ClosingReference{}
    |> OpenAgents.Issues.ClosingReference.changeset(%{
      repository_id: repository.id,
      issue_id: issue.id,
      commit_sha: sha,
      principal: "user:#{user.id}",
      verb: "closes",
      closed: true
    })
    |> Repo.insert!()

    build =
      %OpenAgents.Forge.BuildReceipt{}
      |> OpenAgents.Forge.BuildReceipt.start_changeset(%{
        repo: repository.storage_key,
        sha: sha,
        target_id: Ecto.UUID.generate()
      })
      |> Ecto.Changeset.put_change(:status, "complete")
      |> Repo.insert!()

    deploy =
      %OpenAgents.Forge.DeployReceipt{}
      |> OpenAgents.Forge.DeployReceipt.changeset(%{
        repo: repository.storage_key,
        sha: sha,
        target_id: Ecto.UUID.generate(),
        result: "live",
        deployment_type: "direct_load"
      })
      |> Repo.insert!()

    OpenAgents.Issues.Evidence.record_build(build)
    OpenAgents.Issues.Evidence.record_deploy(deploy)
  end

  defp record_attempt(issue, branch, offset_seconds, overrides) do
    user =
      Accounts.upsert_github_user(%{
        github_id: System.unique_integer([:positive]),
        github_login: "attempt-#{System.unique_integer([:positive])}",
        github_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4"
      })
      |> elem(1)

    {:ok, %{code: code}} =
      OpenAgents.Machines.start_pairing(%{
        "name" => branch,
        "tier" => "curated",
        "platform" => "linux-x64",
        "agent_version" => "0.1.0",
        "roots" => []
      })

    {:ok, machine} = OpenAgents.Machines.approve_pairing(user, code)

    now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
    admitted_at = DateTime.add(now, offset_seconds, :second)
    state = Map.get(overrides, :state, "running")

    %OpenAgents.Forge.Assignment{}
    |> OpenAgents.Forge.Assignment.changeset(
      Map.merge(
        %{
          target_kind: "computer",
          machine_id: machine.id,
          repository_id: repository().id,
          issue_id: issue.id,
          requesting_principal: %{"type" => "user", "id" => user.id},
          branch: branch,
          state: state,
          admitted_at: admitted_at,
          started_at: admitted_at,
          finished_at:
            if(state in OpenAgents.Forge.Assignment.terminal_states(), do: admitted_at),
          deadline_at: DateTime.add(admitted_at, 3600, :second)
        },
        overrides
      )
    )
    |> Repo.insert!()
  end

  defp repository do
    Repositories.get_by_path!("OpenAgentsInc", "openagents.com")
  end

  describe "pagination and filters" do
    test "index returns bounded pagination metadata", %{conn: conn} do
      {:ok, _issue} = Issues.create_issue(repository(), %{title: "Counted issue"})

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues")

      assert %{"issues" => issues, "pagination" => pagination} = json_response(conn, 200)
      assert length(issues) <= Issues.per_page()
      assert pagination["page"] == 1
      assert pagination["per_page"] == Issues.per_page()
      assert pagination["total"] == 1
      assert pagination["total_pages"] == 1
    end

    test "index filters by label, assignee, milestone, and search", %{conn: conn} do
      milestone_fixture(repository(), %{number: 3, title: "Sprint 3"})
      label_fixture(repository(), %{name: "bug", color: "d73a4a"})

      octavia = github_user("assignee-filter", "octavia")
      {:ok, _membership} = Repositories.add_member(repository(), octavia, "owner")

      {:ok, _matched} =
        Issues.create_issue(repository(), %{
          title: "Wombat routing",
          labels: [%{"name" => "bug"}],
          assignees: [%{"login" => "octavia"}],
          milestone: %{"number" => 3}
        })

      {:ok, _other} = Issues.create_issue(repository(), %{title: "Unrelated"})

      for params <- [
            %{"labels" => "bug"},
            %{"assignee" => "octavia"},
            %{"milestone" => "3"},
            %{"q" => "wombat"}
          ] do
        conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?#{params}")
        assert %{"issues" => [issue], "pagination" => %{"total" => 1}} = json_response(conn, 200)
        assert issue["title"] == "Wombat routing"
      end
    end

    test "index pages through results in a stable order", %{conn: conn} do
      Enum.each(1..30, fn n ->
        {:ok, _} = Issues.create_issue(repository(), %{title: "Paged #{n}"})
      end)

      first = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues")

      assert %{"issues" => page_one, "pagination" => %{"total_pages" => 2}} =
               json_response(first, 200)

      assert length(page_one) == Issues.per_page()

      second =
        get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?page=2")

      assert %{"issues" => page_two} = json_response(second, 200)
      assert length(page_two) == 30 - Issues.per_page()
      page_one_titles = MapSet.new(page_one, & &1["title"])
      refute Enum.any?(page_two, &MapSet.member?(page_one_titles, &1["title"]))
    end

    test "index rejects an unknown state with a stable error", %{conn: conn} do
      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?state=bogus")

      assert %{"errors" => %{"state" => [message]}} = json_response(conn, 422)
      assert message =~ "open"
    end

    test "index rejects a non-integer page with a stable error", %{conn: conn} do
      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?page=zero")

      assert %{"errors" => %{"page" => [message]}} = json_response(conn, 422)
      assert message =~ "positive integer"
    end
  end

  describe "the openagents issue extension" do
    test "show carries the dependency graph and names the extension", %{conn: conn} do
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Prerequisite"})
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{blocked.number}")

      assert %{
               "openagents" => %{
                 "blocked" => true,
                 "blocked_by" => [%{"number" => number, "state" => "open"}],
                 "blocks" => []
               }
             } = json_response(conn, 200)

      assert number == blocker.number
      assert get_resp_header(conn, "x-openagents-extensions") == ["issue.openagents"]
    end

    test "index carries the graph for every issue on the page", %{conn: conn} do
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Prerequisite"})
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues")

      assert %{"issues" => issues} = json_response(conn, 200)
      by_number = Map.new(issues, &{&1["number"], &1["openagents"]})

      assert %{"blocked" => true, "blocked_by" => [_one]} = by_number[blocked.number]
      assert %{"blocked" => false, "blocks" => [_one]} = by_number[blocker.number]
    end

    test "an issue without prerequisites reports an empty graph", %{conn: conn} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Ready"})

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")

      assert %{"openagents" => %{"blocked" => false, "blocked_by" => [], "blocks" => []}} =
               json_response(conn, 200)
    end

    test "an issue nobody has worked reports no attempts, not a missing field", %{conn: conn} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Unworked"})

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")

      assert %{"openagents" => %{"work" => []}} = json_response(conn, 200)
    end

    test "show carries every recorded execution attempt, oldest first", %{conn: conn} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Worked twice"})
      sha = String.duplicate("cd", 20)

      record_attempt(issue, "agent/first", -600, %{state: "failed", failure_reason: "timeout"})
      record_attempt(issue, "agent/second", -60, %{state: "completed", terminal_commit: sha})

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")

      assert %{"openagents" => %{"work" => [first, second]}} = json_response(conn, 200)

      assert first["branch"] == "agent/first"
      assert first["state"] == "failed"
      assert first["failure_reason"] == "timeout"
      assert is_nil(first["commit"])

      assert second["branch"] == "agent/second"
      assert second["state"] == "completed"
      assert second["commit"] == sha
      assert second["target"] == "computer"
    end

    test "an attempt never carries the prompt, conversation, or credential", %{conn: conn} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Bounded"})
      record_attempt(issue, "agent/bounded", -30, %{})

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")

      assert %{"openagents" => %{"work" => [attempt]}} = json_response(conn, 200)

      # The key set comes from the disclosure schedule, renamed the two ways
      # the API renames, so a column added to `forge_assignments` and put on a
      # rung shows up here without an edit and a column put nowhere fails
      # `OpenAgents.Transparency.WorkDisclosureTest` first.
      renames = %{target_kind: :target, terminal_commit: :commit}

      expected =
        :attempt
        |> OpenAgents.Transparency.WorkDisclosure.fields_at(:ledger)
        |> Enum.map(&to_string(Map.get(renames, &1, &1)))
        |> Enum.sort()

      assert Enum.sort(Map.keys(attempt)) == expected

      refute Map.has_key?(attempt, "prompt")
      refute Map.has_key?(attempt, "conversation_id")
      refute Map.has_key?(attempt, "requesting_principal")
      refute Map.has_key?(attempt, "credential_delivery_status")
      refute Map.has_key?(attempt, "machine_id")
    end

    test "an anonymous reader is told an attempt ran and not what it ran on", %{conn: conn} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Public work"})
      record_attempt(issue, "agent/secret-rename", -30, %{state: "completed"})

      # The same request, twice, differing only in who makes it. The member's
      # token reaches `ledger`; anonymous traffic on the same public repository
      # reaches `pulse`, and the tier is the only thing between them —
      # `Repositories.readable_by/2` admits both.
      member =
        conn
        |> get(~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")
        |> json_response(200)

      anonymous =
        Phoenix.ConnTest.build_conn()
        |> get(~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")
        |> json_response(200)

      assert [%{"branch" => "agent/secret-rename"}] = member["openagents"]["work"]

      assert [attempt] = anonymous["openagents"]["work"]
      assert attempt["state"] == "completed"
      assert attempt["requester_kind"] == "user"
      refute Map.has_key?(attempt, "branch")
      refute Map.has_key?(attempt, "commit")
      refute Map.has_key?(attempt, "terminal_branch")
      refute Map.has_key?(attempt, "failure_reason")
    end

    test "index carries the attempts for every issue on the page", %{conn: conn} do
      {:ok, worked} = Issues.create_issue(repository(), %{title: "Worked"})
      {:ok, _idle} = Issues.create_issue(repository(), %{title: "Idle"})
      record_attempt(worked, "agent/page", -45, %{})

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues")

      assert %{"issues" => issues} = json_response(conn, 200)
      by_title = Map.new(issues, &{&1["title"], &1["openagents"]["work"]})

      assert [%{"branch" => "agent/page"}] = by_title["Worked"]
      assert by_title["Idle"] == []
    end
  end

  describe "the issue evidence chain" do
    test "an issue nothing has evaluated reports no evidence, not a missing field", %{conn: conn} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Unevaluated"})

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")

      assert %{"openagents" => %{"evidence" => []}} = json_response(conn, 200)
    end

    test "show carries the receipts bound to the commit the issue claims", %{conn: conn} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Shipped"})
      sha = String.duplicate("9a", 20)
      record_evidence(issue, sha)

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")

      assert %{"openagents" => %{"evidence" => [build, deploy]}} = json_response(conn, 200)

      assert build["family"] == "build"
      assert build["commit"] == sha
      assert build["plane"] == "forge"
      assert build["result"] == "complete"
      assert build["source"] == "closing_reference"

      assert deploy["family"] == "deployment"
      assert deploy["environment"] == "fleet"
      assert deploy["result"] == "live"
    end

    test "an evidence edge never carries the actor or the attempt", %{conn: conn} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Bounded evidence"})
      record_evidence(issue, String.duplicate("7b", 20))

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")

      assert %{"openagents" => %{"evidence" => [entry | _]}} = json_response(conn, 200)

      assert Enum.sort(Map.keys(entry)) == [
               "commit",
               "environment",
               "family",
               "id",
               "plane",
               "receipt_id",
               "recorded_at",
               "result",
               "source"
             ]
    end

    test "index carries the evidence for every issue on the page", %{conn: conn} do
      {:ok, shipped} = Issues.create_issue(repository(), %{title: "Evidenced"})
      {:ok, _idle} = Issues.create_issue(repository(), %{title: "Unevidenced"})
      record_evidence(shipped, String.duplicate("4c", 20))

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues")

      assert %{"issues" => issues} = json_response(conn, 200)
      by_title = Map.new(issues, &{&1["title"], &1["openagents"]["evidence"]})

      assert [%{"family" => "build"}, %{"family" => "deployment"}] = by_title["Evidenced"]
      assert by_title["Unevidenced"] == []
    end
  end

  describe "the blocked filter" do
    test "index lists only the issues waiting on an open prerequisite", %{conn: conn} do
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Prerequisite"})
      {:ok, _ready} = Issues.create_issue(repository(), %{title: "Ready"})
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?blocked=true")

      assert %{"issues" => [issue], "pagination" => %{"total" => 1}} = json_response(conn, 200)
      assert issue["title"] == "Waiting"
    end

    test "index lists the issues an agent can start now", %{conn: conn} do
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Prerequisite"})
      {:ok, _ready} = Issues.create_issue(repository(), %{title: "Ready"})
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?blocked=false")

      assert %{"issues" => issues, "pagination" => %{"total" => 2}} = json_response(conn, 200)
      assert Enum.map(issues, & &1["title"]) |> Enum.sort() == ["Prerequisite", "Ready"]
    end

    test "closing the prerequisite moves the issue to the unblocked list", %{conn: conn} do
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Prerequisite"})
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])

      patch(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{blocker.number}", %{
        state: "closed"
      })

      blocked_conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?blocked=true")
      assert %{"issues" => [], "pagination" => %{"total" => 0}} = json_response(blocked_conn, 200)

      ready_conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?blocked=false")

      assert %{"issues" => [issue], "pagination" => %{"total" => 1}} =
               json_response(ready_conn, 200)

      assert issue["title"] == "Waiting"
    end

    test "index rejects a blocked value that is not a boolean", %{conn: conn} do
      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?blocked=maybe")

      assert %{"errors" => %{"blocked" => [message]}} = json_response(conn, 422)
      assert message =~ "true or false"
    end
  end

  describe "the progress extension field" do
    test "show reports what a board says about the issue", %{conn: conn} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Underway"})
      place(repository(), issue, "In Progress")

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")

      assert %{"openagents" => %{"progress" => "in_progress"}} = json_response(conn, 200)
    end

    test "an issue on no board has not started", %{conn: conn} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Queued"})

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")

      assert %{"openagents" => %{"progress" => "to_do"}} = json_response(conn, 200)
    end

    test "closing the issue finishes it", %{conn: conn} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Underway"})
      place(repository(), issue, "In Progress")

      conn =
        patch(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}", %{
          state: "closed"
        })

      assert %{"openagents" => %{"progress" => "done"}} = json_response(conn, 200)
    end

    test "index carries progress for every issue on the page", %{conn: conn} do
      {:ok, started} = Issues.create_issue(repository(), %{title: "Underway"})
      {:ok, queued} = Issues.create_issue(repository(), %{title: "Queued"})
      place(repository(), started, "In Progress")

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues")

      assert %{"issues" => issues} = json_response(conn, 200)
      by_number = Map.new(issues, &{&1["number"], &1["openagents"]["progress"]})

      assert by_number[started.number] == "in_progress"
      assert by_number[queued.number] == "to_do"
    end

    test "a GitHub-shaped client sees its own keys unchanged", %{conn: conn} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Underway"})
      place(repository(), issue, "In Progress")

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")
      body = json_response(conn, 200)

      assert body["state"] == "open"
      assert body["title"] == "Underway"

      # Every extension lives inside `openagents`; nothing leaks into the
      # GitHub-shaped key set, which is what a GitHub client reads.
      assert Enum.sort(Map.keys(body) -- ["openagents"]) ==
               Enum.sort(~w(
                 assignees body closed_at comments created_at html_url id labels locked
                 milestone node_id number state state_reason title updated_at url user
               ))
    end
  end

  describe "the progress filter" do
    test "index lists the issues a board has started", %{conn: conn} do
      {:ok, started} = Issues.create_issue(repository(), %{title: "Underway"})
      {:ok, _queued} = Issues.create_issue(repository(), %{title: "Queued"})
      place(repository(), started, "In Progress")

      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?progress=in_progress")

      assert %{"issues" => [issue], "pagination" => %{"total" => 1}} = json_response(conn, 200)
      assert issue["title"] == "Underway"
      assert issue["openagents"]["progress"] == "in_progress"
    end

    test "the filter and the field agree for every issue on the page", %{conn: conn} do
      {:ok, started} = Issues.create_issue(repository(), %{title: "Underway"})
      {:ok, _queued} = Issues.create_issue(repository(), %{title: "Queued"})
      {:ok, _closed} = Issues.create_issue(repository(), %{title: "Finished", state: "closed"})
      place(repository(), started, "In Progress")

      for value <- ["to_do", "in_progress", "done"] do
        conn =
          get(
            conn,
            ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?state=all&progress=#{value}"
          )

        for issue <- json_response(conn, 200)["issues"] do
          assert issue["openagents"]["progress"] == value
        end
      end
    end

    test "a private board never moves an issue into an outsider's started list" do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Tracked privately"})
      private = repository_fixture(%{visibility: "private"})
      place(private, issue, "In Progress")

      anonymous =
        get(
          build_conn(),
          ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}"
        )

      assert %{"openagents" => %{"progress" => "to_do"}} = json_response(anonymous, 200)

      listed =
        get(
          build_conn(),
          ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?progress=in_progress"
        )

      assert %{"issues" => [], "pagination" => %{"total" => 0}} = json_response(listed, 200)
    end

    test "index rejects a progress value outside the published enum", %{conn: conn} do
      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?progress=doing")

      assert %{"errors" => %{"progress" => [message]}} = json_response(conn, 422)
      assert message =~ "to_do"
    end
  end

  describe "pull requests in the issue list" do
    setup do
      source = OpenAgents.AccountsFixtures.repository_fixture()
      {:ok, proposal} = Issues.create_issue(repository(), %{title: "Proposes a change"})

      pull_request =
        %OpenAgents.PullRequests.PullRequest{}
        |> OpenAgents.PullRequests.PullRequest.changeset(%{
          repository_id: repository().id,
          issue_id: proposal.id,
          head_repository_id: source.id,
          head_ref: "feature",
          head_sha: String.duplicate("a", 40),
          base_ref: "main",
          base_sha: String.duplicate("b", 40),
          draft: false
        })
        |> Repo.insert!()

      {:ok, plain} = Issues.create_issue(repository(), %{title: "Plain issue"})

      %{plain: plain, proposal: proposal, pull_request: pull_request}
    end

    test "index returns both kinds by default, the way GitHub's does", %{
      conn: conn,
      plain: plain,
      proposal: proposal
    } do
      %{"issues" => issues} =
        conn
        |> get(~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues")
        |> json_response(200)

      numbers = Enum.map(issues, & &1["number"])
      assert plain.number in numbers
      assert proposal.number in numbers
    end

    test "a pull-request-backed entry carries GitHub's pull_request marker", %{
      conn: conn,
      proposal: proposal
    } do
      %{"issues" => issues} =
        conn
        |> get(~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues")
        |> json_response(200)

      entry = Enum.find(issues, &(&1["number"] == proposal.number))

      assert entry["draft"] == false
      assert entry["pull_request"]["html_url"] =~ "/pulls/#{proposal.number}"
      assert entry["pull_request"]["url"] =~ "/api/v1/repos/OpenAgentsInc/openagents.com/pulls/"
      assert entry["pull_request"]["merged_at"] == nil
    end

    test "a plain issue carries neither key, so presence is the fact", %{
      conn: conn,
      plain: plain
    } do
      entry =
        conn
        |> get(~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{plain.number}")
        |> json_response(200)

      refute Map.has_key?(entry, "pull_request")
      refute Map.has_key?(entry, "draft")
    end

    test "the show endpoint marks a pull request too", %{conn: conn, proposal: proposal} do
      entry =
        conn
        |> get(~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues/#{proposal.number}")
        |> json_response(200)

      assert entry["pull_request"]["html_url"] =~ "/pulls/#{proposal.number}"
    end

    test "type=issue lists issues without the pull requests", %{
      conn: conn,
      plain: plain,
      proposal: proposal
    } do
      %{"issues" => issues} =
        conn
        |> get(~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?type=issue")
        |> json_response(200)

      numbers = Enum.map(issues, & &1["number"])
      assert plain.number in numbers
      refute proposal.number in numbers
    end

    test "type=pull_request lists only the pull requests", %{
      conn: conn,
      plain: plain,
      proposal: proposal
    } do
      %{"issues" => issues} =
        conn
        |> get(~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?type=pull_request")
        |> json_response(200)

      numbers = Enum.map(issues, & &1["number"])
      assert proposal.number in numbers
      refute plain.number in numbers
    end

    test "index rejects a type outside the published enum", %{conn: conn} do
      conn = get(conn, ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues?type=proposal")

      assert %{"errors" => %{"type" => [message]}} = json_response(conn, 422)
      assert message =~ "pull_request"
    end
  end

  defp place(board_repository, issue, column) do
    {:ok, project} =
      OpenAgents.Projects.create_project(board_repository, %{
        title: "Board",
        owner: "OpenAgents"
      })

    {:ok, item} =
      OpenAgents.ProjectItems.create_project_item(board_repository, %{
        project_id: project.id,
        issue_id: issue.id,
        issue_repository_id: issue.repository_id,
        values: %{"Status" => column}
      })

    item
  end
end