test/openagents_web/controllers/project_controller_test.exs

58e6347eeb72 · 68 KB

defmodule OpenAgentsWeb.ProjectControllerTest do
  use OpenAgentsWeb.ConnCase

  import OpenAgents.CompensationFixtures

  alias OpenAgents.Issues
  alias OpenAgents.Projects

  @owner "ProjectTestOrg"
  @repo "project-api"

  setup %{conn: conn} do
    user = github_user("api-token-projects", "alice")

    repository =
      repository_with_member_fixture(user, %{
        owner: @owner,
        name: @repo,
        visibility: "private"
      })

    Process.put({__MODULE__, :repository}, repository)
    on_exit(fn -> Process.delete({__MODULE__, :repository}) end)

    {:ok,
     conn: put_forge_api_token(conn, "projects", "alice"), repository: repository, user: user}
  end

  describe "index" do
    test "GET /api/v1/repos/:owner/:repo/projectsV2 lists the repository's projects", %{
      conn: conn
    } do
      project_fixture(%{title: "Roadmap", owner: "alice", state: "open"})

      conn = get(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2")

      assert %{"projects" => [project]} = json_response(conn, 200)
      assert project["title"] == "Roadmap"
      assert project["owner"] == "alice"
      assert project["state"] == "open"
    end

    test "GET /api/v1/repos/:owner/:repo/projectsV2 excludes another repository", %{conn: conn} do
      project_fixture(%{title: "Mine", owner: "alice"})
      other_repository = repository_fixture(%{owner: "Elsewhere", name: "other-projects"})

      {:ok, _project} =
        Projects.create_project(other_repository, %{title: "Theirs", owner: "bob"})

      conn = get(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2")

      assert %{"projects" => [%{"title" => "Mine"}]} = json_response(conn, 200)
    end

    test "GET /api/v1/repos/:owner/:repo/projectsV2 hides a private repository from a non-member" do
      conn = get(build_conn(), ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2")
      assert_api_error(conn, 404, "not_found")
    end
  end

  describe "create" do
    test "POST /api/v1/repos/:owner/:repo/projectsV2 creates a project", %{conn: conn} do
      conn =
        post(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2", %{
          title: "New board"
        })

      assert %{"title" => "New board", "owner" => "alice", "number" => number} =
               json_response(conn, 201)

      assert Projects.get_project_by_number!(repository(), number).title == "New board"
    end

    test "POST /api/v1/repos/:owner/:repo/projectsV2 accepts a Markdown description", %{
      conn: conn
    } do
      conn =
        post(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2", %{
          title: "Stress testing Ox Alpha",
          description: "## Why\n\nProvider order is under test."
        })

      assert %{"description" => "## Why\n\nProvider order is under test."} =
               json_response(conn, 201)
    end

    test "POST /api/v1/repos/:owner/:repo/projectsV2 ignores repository override params", %{
      conn: conn
    } do
      conn =
        post(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2", %{
          title: "Board",
          owner: "mallory",
          repo: "elsewhere"
        })

      assert json_response(conn, 201)["owner"] == "alice"
    end

    test "POST /api/v1/repos/:owner/:repo/projectsV2 returns 422 without a title", %{
      conn: conn
    } do
      conn = post(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2", %{})

      assert %{"errors" => %{"title" => _}} = json_response(conn, 422)
      assert Projects.list_projects(repository()) == []
    end
  end

  describe "show" do
    test "GET /api/v1/repos/:owner/:repo/projectsV2/:project_number returns the project", %{
      conn: conn
    } do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      conn = get(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}")

      assert %{"title" => "Roadmap", "id" => id} = json_response(conn, 200)
      assert id == project.id
    end

    test "GET /api/v1/repos/:owner/:repo/projectsV2/:project_number returns 404 when missing", %{
      conn: conn
    } do
      conn = get(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/999999")

      assert_api_error(conn, 404, "not_found")
    end
  end

  describe "items" do
    test "GET .../projectsV2/:project_number/items returns an empty list for a new project", %{
      conn: conn
    } do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      conn =
        get(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items")

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

    test "GET .../projectsV2/:project_number/items lists the project's items", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
      item = project_item_fixture(%{project_id: project.id, values: %{"Status" => "Todo"}})

      conn =
        get(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items")

      assert %{"items" => [rendered]} = json_response(conn, 200)
      assert rendered["id"] == item.id
      assert rendered["issue_id"] == item.issue_id
      assert rendered["values"] == %{"Status" => "Todo"}
    end

    test "GET .../projectsV2/:project_number/items omits an unreadable source issue", %{
      conn: conn
    } do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
      {:ok, local_issue} = create_issue(%{title: "Local work"})

      {:ok, _local_item} =
        Projects.create_project_item(%{"issue_number" => local_issue.number}, project)

      source =
        repository_fixture(%{owner: "HiddenOrg", name: "hidden-api", visibility: "private"})

      {:ok, hidden_issue} = Issues.create_issue(source, %{title: "Confidential work"})

      {:ok, _hidden_item} =
        Projects.create_project_item(
          %{"issue_number" => hidden_issue.number, "issue_repository_id" => source.id},
          project
        )

      conn =
        get(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items")

      body = response(conn, 200)
      refute body =~ "hidden-api"
      refute body =~ "HiddenOrg"

      assert %{"items" => [%{"issue" => %{"repo" => "project-api", "number" => number}}]} =
               Jason.decode!(body)

      assert number == local_issue.number
    end

    test "GET .../projectsV2/:project_number/items returns 404 for a missing project", %{
      conn: conn
    } do
      conn = get(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/999999/items")

      assert_api_error(conn, 404, "not_found")
    end
  end

  describe "promise registries" do
    test "LIVE projections require and expose an accepted outcome", %{conn: conn} do
      project = project_fixture(%{title: "Promises", owner: "alice"})

      {:ok, _field} =
        Projects.create_project_field(%{
          project_id: project.id,
          name: "Promise state",
          data_type: "promise_state",
          options: %{"values" => ["LIVE", "GATED", "WITHDRAWN"]}
        })

      {:ok, issue} = create_issue(%{title: "Accepted outcome promise"})
      decision = outcome_decision_fixture()

      values =
        promise_values("LIVE", "accepted")
        |> put_in(["promise", "evidence"], [
          %{
            "kind" => "accepted_outcome",
            "decision_receipt_ref" => decision.decision_receipt_ref
          }
        ])

      {:ok, _item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "values" => values},
          project
        )

      conn =
        get(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items?promise_state=LIVE"
        )

      assert %{
               "items" => [
                 %{
                   "openagents" => %{
                     "promise" => %{
                       "record" => %{"id" => "accepted"},
                       "state" => "LIVE",
                       "bounty_candidate" => false
                     }
                   }
                 }
               ]
             } = json_response(conn, 200)
    end

    test "projects expose promise projections and promise filters", %{conn: conn} do
      project = project_fixture(%{title: "Promises", owner: "alice"})

      {:ok, _field} =
        Projects.create_project_field(%{
          project_id: project.id,
          name: "Promise state",
          data_type: "promise_state",
          options: %{"values" => ["LIVE", "GATED", "WITHDRAWN"]}
        })

      {:ok, gated_issue} = create_issue(%{title: "Gated promise"})
      {:ok, withdrawn_issue} = create_issue(%{title: "Withdrawn promise"})

      {:ok, _gated_item} =
        Projects.create_project_item(
          %{"issue_number" => gated_issue.number, "values" => promise_values("GATED", "gated")},
          project
        )

      withdrawn_values =
        promise_values("WITHDRAWN", "withdrawn")
        |> put_in(["promise", "withdrawal"], %{
          "reason" => "Replaced",
          "replacement" => "A new promise",
          "date" => "2026-08-23"
        })

      {:ok, _withdrawn_item} =
        Projects.create_project_item(
          %{
            "issue_number" => withdrawn_issue.number,
            "values" => withdrawn_values
          },
          project
        )

      conn =
        get(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items?promise_state=GATED&bounty_candidate=true"
        )

      assert %{
               "items" => [
                 %{
                   "openagents" => %{
                     "promise" => %{
                       "record" => %{"id" => "gated"},
                       "state" => "GATED",
                       "bounty_candidate" => true
                     }
                   }
                 }
               ]
             } = json_response(conn, 200)
    end

    test "promise item events are paginated and actor-attributed", %{conn: conn, user: user} do
      project = project_fixture(%{title: "Promises", owner: "alice"})

      {:ok, _field} =
        Projects.create_project_field(%{
          project_id: project.id,
          name: "Promise state",
          data_type: "promise_state",
          options: %{"values" => ["LIVE", "GATED", "WITHDRAWN"]}
        })

      {:ok, issue} = create_issue(%{title: "Gated promise"})

      {:ok, item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "values" => promise_values("GATED", "events")},
          project,
          user
        )

      conn =
        get(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}/events"
        )

      assert %{
               "events" => [
                 %{"actor_login" => "alice", "kind" => "create", "to_state" => "GATED"}
               ],
               "pagination" => %{"page" => 1, "total" => 1}
             } = json_response(conn, 200)
    end

    test "anonymous readers can read a public registry" do
      project = project_fixture(%{title: "Public promises", owner: "alice"})

      {:ok, _field} =
        Projects.create_project_field(%{
          project_id: project.id,
          name: "Promise state",
          data_type: "promise_state",
          options: %{"values" => ["LIVE", "GATED", "WITHDRAWN"]}
        })

      {:ok, issue} = create_issue(%{title: "Public promise"})

      {:ok, _item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "values" => promise_values("GATED", "public")},
          project
        )

      OpenAgents.Repo.update_all(
        OpenAgents.Repositories.Repository,
        set: [visibility: "public"]
      )

      conn =
        build_conn()
        |> get(~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items")

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

    test "promise evidence from an unreadable repository is redacted", %{conn: conn} do
      project = project_fixture(%{title: "Promises", owner: "alice"})

      {:ok, _field} =
        Projects.create_project_field(%{
          project_id: project.id,
          name: "Promise state",
          data_type: "promise_state",
          options: %{"values" => ["LIVE", "GATED", "WITHDRAWN"]}
        })

      source =
        repository_fixture(%{owner: "SecretOrg", name: "secret-evidence", visibility: "private"})

      {:ok, secret_issue} = Issues.create_issue(source, %{title: "Secret evidence"})
      {:ok, visible_issue} = create_issue(%{title: "Visible promise"})

      values =
        promise_values("GATED", "redacted")
        |> put_in(["promise", "evidence"], [
          %{
            "kind" => "issue",
            "owner" => source.owner,
            "repo" => source.name,
            "number" => secret_issue.number
          }
        ])

      {:ok, _item} =
        Projects.create_project_item(
          %{"issue_number" => visible_issue.number, "values" => values},
          project
        )

      conn =
        get(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items")

      assert %{"items" => [%{"openagents" => %{"promise" => %{"record" => record}}}]} =
               json_response(conn, 200)

      assert record["evidence"] == []
    end
  end

  describe "create_item" do
    setup do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
      {:ok, issue} = create_issue(%{title: "Trackable issue"})
      %{project: project, issue: issue}
    end

    test "POST .../projectsV2/:project_number/items adds an issue to the board", %{
      conn: conn,
      project: project,
      issue: issue
    } do
      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{
            issue_number: issue.number
          }
        )

      assert %{"items" => [item]} = json_response(conn, 201)
      assert item["issue_id"] == issue.id

      assert [%{issue_id: issue_id}] = Projects.list_project_items(project)
      assert issue_id == issue.id
    end

    test "POST .../items adds a readable issue from another repository", %{
      conn: conn,
      project: project
    } do
      source =
        repository_fixture(%{owner: "SourceOrg", name: "source-api", visibility: "public"})

      {:ok, issue} = Issues.create_issue(source, %{title: "Cross-repository work"})

      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{issue: %{owner: "SourceOrg", repo: "source-api", number: issue.number}}
        )

      assert %{
               "items" => [
                 %{
                   "issue_id" => issue_id,
                   "issue" => %{
                     "owner" => "SourceOrg",
                     "repo" => "source-api",
                     "number" => number,
                     "html_url" => html_url
                   }
                 }
               ]
             } = json_response(conn, 201)

      assert issue_id == issue.id
      assert number == issue.number
      assert html_url =~ "/SourceOrg/source-api/issues/#{issue.number}"
      assert [%{issue_repository_id: source_id}] = Projects.list_project_items(project)
      assert source_id == source.id
    end

    test "POST .../items adds a private source issue the member can read", %{
      conn: conn,
      project: project,
      user: user
    } do
      source =
        repository_with_member_fixture(
          user,
          %{owner: "ReadableOrg", name: "readable-api", visibility: "private"},
          "viewer"
        )

      {:ok, issue} = Issues.create_issue(source, %{title: "Private but readable"})

      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{issue: %{owner: "ReadableOrg", repo: "readable-api", number: issue.number}}
        )

      assert %{"items" => [%{"issue_id" => issue_id}]} = json_response(conn, 201)
      assert issue_id == issue.id
    end

    test "POST .../items refuses a project reader who can write the source repository", %{
      project: project,
      repository: repository
    } do
      reader = github_user("api-token-project-reader", "carol")
      {:ok, _membership} = OpenAgents.Repositories.add_member(repository, reader, "viewer")

      source =
        repository_with_member_fixture(
          reader,
          %{owner: "WriterOrg", name: "writer-api", visibility: "private"},
          "owner"
        )

      {:ok, issue} = Issues.create_issue(source, %{title: "Their own work"})

      conn =
        post(
          put_forge_api_token(build_conn(), "project-reader", "carol"),
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{issue: %{owner: "WriterOrg", repo: "writer-api", number: issue.number}}
        )

      assert_api_error(conn, 404, "not_found")
      assert Projects.list_project_items(project) == []
    end

    test "POST .../items returns 404 for an unknown source repository", %{
      conn: conn,
      project: project
    } do
      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{issue: %{owner: "NoSuchOrg", repo: "no-such-api", number: 1}}
        )

      assert_api_error(conn, 404, "not_found")
      assert Projects.list_project_items(project) == []
    end

    test "POST .../items returns 404 for an unknown issue in a readable source repository", %{
      conn: conn,
      project: project
    } do
      repository_fixture(%{owner: "SourceOrg", name: "empty-api", visibility: "public"})

      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{issue: %{owner: "SourceOrg", repo: "empty-api", number: 999_999}}
        )

      assert_api_error(conn, 404, "not_found")
      assert Projects.list_project_items(project) == []
    end

    test "POST .../items reads the number in the named source repository", %{
      conn: conn,
      project: project,
      issue: issue
    } do
      source =
        repository_fixture(%{owner: "SourceOrg", name: "same-number-api", visibility: "public"})

      {:ok, source_issue} = Issues.create_issue(source, %{title: "Same number elsewhere"})
      assert source_issue.number == issue.number

      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{issue: %{owner: "SourceOrg", repo: "same-number-api", number: source_issue.number}}
        )

      assert %{"items" => [%{"issue_id" => issue_id}]} = json_response(conn, 201)
      assert issue_id == source_issue.id
      refute issue_id == issue.id
    end

    test "POST .../items answers a repeated add with the membership it already made", %{
      conn: conn,
      project: project
    } do
      source =
        repository_fixture(%{owner: "SourceOrg", name: "repeat-api", visibility: "public"})

      {:ok, issue} = Issues.create_issue(source, %{title: "Added once"})
      body = %{issue: %{owner: "SourceOrg", repo: "repeat-api", number: issue.number}}
      path = ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items"

      assert %{"items" => [%{"id" => id}]} = json_response(post(conn, path, body), 201)
      assert %{"items" => [%{"id" => ^id}]} = json_response(post(recycle(conn), path, body), 200)
      assert length(Projects.list_project_items(project)) == 1
    end

    test "POST .../items hides an unreadable source repository", %{
      conn: conn,
      project: project
    } do
      source =
        repository_fixture(%{owner: "SecretOrg", name: "secret-api", visibility: "private"})

      {:ok, issue} = Issues.create_issue(source, %{title: "Private work"})

      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{issue: %{owner: "SecretOrg", repo: "secret-api", number: issue.number}}
        )

      assert_api_error(conn, 404, "not_found")
      assert Projects.list_project_items(project) == []
    end

    test "POST .../projectsV2/:project_number/items stores field values", %{
      conn: conn,
      project: project,
      issue: issue
    } do
      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{
            issue_number: issue.number,
            values: %{"Status" => "In Progress"}
          }
        )

      assert %{"items" => [%{"values" => %{"Status" => "In Progress"}}]} =
               json_response(conn, 201)
    end

    test "POST .../projectsV2/:project_number/items returns 422 for non-map values", %{
      conn: conn,
      project: project,
      issue: issue
    } do
      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{
            issue_number: issue.number,
            values: "not-a-map"
          }
        )

      assert %{"errors" => %{"values" => _}} = json_response(conn, 422)
      assert Projects.list_project_items(project) == []
    end

    test "POST .../projectsV2/:project_number/items returns 422 without an issue_number", %{
      conn: conn,
      project: project
    } do
      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{}
        )

      assert %{"errors" => %{"issue_number" => _}} = json_response(conn, 422)
      assert Projects.list_project_items(project) == []
    end

    test "POST .../projectsV2/:project_number/items returns 422 for a non-numeric issue_number",
         %{conn: conn, project: project} do
      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{
            issue_number: "not-a-number"
          }
        )

      assert %{"errors" => %{"issue_number" => _}} = json_response(conn, 422)
      assert Projects.list_project_items(project) == []
    end

    test "POST .../projectsV2/:project_number/items returns 404 for an unknown issue", %{
      conn: conn,
      project: project
    } do
      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{
            issue_number: 999_999
          }
        )

      assert_api_error(conn, 404, "not_found")
    end

    test "POST .../projectsV2/:project_number/items returns 404 for a missing project", %{
      conn: conn,
      issue: issue
    } do
      conn =
        post(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/999999/items", %{
          issue_number: issue.number
        })

      assert_api_error(conn, 404, "not_found")
    end
  end

  describe "update_item" do
    setup do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
      item = project_item_fixture(%{project_id: project.id, values: %{"Status" => "Todo"}})
      %{project: project, item: item}
    end

    test "PATCH .../items/:item_id merges new field values", %{
      conn: conn,
      project: project,
      item: item
    } do
      conn =
        patch(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}",
          %{
            values: %{"Status" => "Done"}
          }
        )

      assert %{"items" => [%{"values" => %{"Status" => "Done"}}]} = json_response(conn, 200)
      assert Projects.get_project_item!(project, item.id).values == %{"Status" => "Done"}
    end

    test "PATCH .../items/:item_id returns 404 for an unreadable source issue", %{
      conn: conn,
      project: project
    } do
      source =
        repository_fixture(%{owner: "HiddenOrg", name: "hidden-patch-api", visibility: "private"})

      {:ok, issue} = Issues.create_issue(source, %{title: "Confidential work"})

      {:ok, item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "issue_repository_id" => source.id},
          project
        )

      conn =
        patch(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}",
          %{values: %{"Status" => "Done"}}
        )

      assert_api_error(conn, 404, "not_found")
      assert Projects.get_project_item!(project, item.id).values == %{}
    end

    test "PATCH .../items/:item_id keeps values it was not asked to change", %{
      conn: conn,
      project: project,
      item: item
    } do
      conn =
        patch(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}",
          %{
            values: %{"Priority" => "P1"}
          }
        )

      assert %{"items" => [%{"values" => values}]} = json_response(conn, 200)
      assert values == %{"Status" => "Todo", "Priority" => "P1"}
    end

    test "PATCH .../items/:item_id returns 422 for non-map values", %{
      conn: conn,
      project: project,
      item: item
    } do
      conn =
        patch(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}",
          %{
            values: "not-a-map"
          }
        )

      assert %{"errors" => %{"values" => _}} = json_response(conn, 422)
      assert Projects.get_project_item!(project, item.id).values == %{"Status" => "Todo"}
    end

    test "PATCH .../items/:item_id returns 404 for a missing item", %{
      conn: conn,
      project: project
    } do
      conn =
        patch(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/999999",
          %{
            values: %{"Status" => "Done"}
          }
        )

      assert_api_error(conn, 404, "not_found")
    end
  end

  describe "fields" do
    test "GET .../projectsV2/:project_number/fields returns an empty list for a new project", %{
      conn: conn
    } do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      conn =
        get(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields"
        )

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

    test "GET .../projectsV2/:project_number/fields lists the project's fields", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      field =
        project_field_fixture(%{
          project_id: project.id,
          name: "Status",
          data_type: "single_select",
          options: %{"values" => ["Todo", "Done"]}
        })

      conn =
        get(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields"
        )

      assert %{"fields" => [rendered]} = json_response(conn, 200)
      assert rendered["id"] == field.id
      assert rendered["name"] == "Status"
      assert rendered["data_type"] == "single_select"
      assert rendered["options"] == %{"values" => ["Todo", "Done"]}
    end

    test "GET .../projectsV2/:project_number/fields returns 404 for a missing project", %{
      conn: conn
    } do
      conn = get(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/999999/fields")

      assert_api_error(conn, 404, "not_found")
    end

    test "POST .../projectsV2/:project_number/fields creates a field", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields",
          %{
            name: "Status",
            data_type: "single_select",
            options: %{values: ["To Do", "In Progress", "Done"]}
          }
        )

      assert %{
               "fields" => [
                 %{
                   "name" => "Status",
                   "data_type" => "single_select",
                   "options" => %{"values" => ["To Do", "In Progress", "Done"]}
                 }
               ]
             } = json_response(conn, 201)

      assert [%{name: "Status", project_id: project_id}] = Projects.list_project_fields(project)
      assert project_id == project.id
    end

    test "POST .../projectsV2/:project_number/fields returns 422 without required values", %{
      conn: conn
    } do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields",
          %{}
        )

      assert %{"errors" => errors} = json_response(conn, 422)
      assert Map.has_key?(errors, "name")
      assert Map.has_key?(errors, "data_type")
      assert Projects.list_project_fields(project) == []
    end

    test "POST .../projectsV2/:project_number/fields takes the project from the path", %{
      conn: conn
    } do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
      other_project = project_fixture(%{title: "Other", owner: "alice"})

      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields",
          %{
            name: "Status",
            data_type: "text",
            project_id: other_project.id
          }
        )

      assert json_response(conn, 201)
      assert [%{project_id: project_id}] = Projects.list_project_fields(project)
      assert project_id == project.id
      assert Projects.list_project_fields(other_project) == []
    end
  end

  describe "repository authorization" do
    test "a non-member cannot read or mutate a private repository's project" do
      project = project_fixture(%{title: "Alice only", owner: "alice"})
      {:ok, issue} = create_issue(%{title: "Tracked"})
      mallory_conn = put_forge_api_token(build_conn(), "project-mallory", "mallory")

      {:ok, item} =
        Projects.create_project_item(%{"issue_number" => issue.number}, project)

      _field =
        project_field_fixture(%{
          project_id: project.id,
          name: "Status",
          data_type: "text"
        })

      assert get(
               mallory_conn,
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}"
             )
             |> api_error_code(404) == "not_found"

      assert get(
               recycle(mallory_conn),
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items"
             )
             |> api_error_code(404) == "not_found"

      assert get(
               recycle(mallory_conn),
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields"
             )
             |> api_error_code(404) == "not_found"

      assert post(
               recycle(mallory_conn),
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
               %{issue_number: issue.number}
             )
             |> api_error_code(404) == "not_found"

      assert patch(
               recycle(mallory_conn),
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}",
               %{values: %{"Status" => "Done"}}
             )
             |> api_error_code(404) == "not_found"

      assert post(
               recycle(mallory_conn),
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields",
               %{name: "Priority", data_type: "text"}
             )
             |> api_error_code(404) == "not_found"

      assert Projects.get_project_item!(project, item.id).values == %{}
    end

    test "a token owner cannot create a project in another repository", %{conn: conn} do
      other_repository =
        repository_fixture(%{owner: "OtherOrg", name: "other-private", visibility: "private"})

      assert post(conn, ~p"/api/v1/repos/OtherOrg/other-private/projectsV2", %{
               title: "Not Alice's"
             })
             |> api_error_code(404) == "not_found"

      assert Projects.list_projects(other_repository) == []
    end
  end

  describe "update" do
    test "PATCH projectsV2/:number updates the title, description, and state", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice", state: "open"})

      conn =
        patch(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}", %{
          title: "Stress testing Ox Alpha",
          description: "## Why\n\nProvider order is under test.",
          state: "closed"
        })

      assert %{
               "title" => "Stress testing Ox Alpha",
               "description" => "## Why\n\nProvider order is under test.",
               "state" => "closed",
               "created_at" => _created,
               "updated_at" => _updated
             } = json_response(conn, 200)
    end

    test "PATCH projectsV2/:number ignores fields it does not own", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      conn =
        patch(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}", %{
          title: "Renamed",
          number: 9999,
          owner: "mallory"
        })

      assert %{"title" => "Renamed", "number" => number, "owner" => "alice"} =
               json_response(conn, 200)

      assert number == project.number
    end

    test "PATCH projectsV2/:number rejects an unknown state", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice", state: "open"})

      conn =
        patch(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}", %{
          state: "sideways"
        })

      assert_api_error(conn, 422, "validation_failed", errors: %{"state" => ["is invalid"]})
      assert Projects.get_project_by_number!(repository(), project.number).state == "open"
    end

    test "PATCH projectsV2/:number hides a private repository from a non-member", %{conn: _conn} do
      project = project_fixture(%{title: "Alice only", owner: "alice"})
      mallory = put_forge_api_token(build_conn(), "project-mallory-update", "mallory")

      assert patch(
               mallory,
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}",
               %{title: "Mine now"}
             )
             |> api_error_code(404) == "not_found"

      assert Projects.get_project_by_number!(repository(), project.number).title == "Alice only"
    end
  end

  describe "notes" do
    test "GET and POST notes round-trip a Markdown note with its author", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      created =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes",
          %{body: "- paused lane 3"}
        )

      assert %{
               "id" => id,
               "kind" => "note",
               "body" => "- paused lane 3",
               "author" => %{"login" => "alice"},
               "created_at" => _created_at,
               "updated_at" => _updated_at
             } = json_response(created, 201)

      listed =
        get(
          recycle(conn),
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes"
        )

      assert %{"notes" => [note], "page" => 1, "per_page" => per_page, "total_count" => 1} =
               json_response(listed, 200)

      assert note["id"] == id
      assert per_page == Projects.notes_per_page()
    end

    test "GET notes paginates and filters by kind", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice", state: "open"})
      user = github_user("api-token-projects", "alice")

      for index <- 1..(Projects.notes_per_page() + 1) do
        {:ok, _note} =
          Projects.create_project_note(project, %{"body" => "note #{index}"}, user)
      end

      {:ok, _updated} = Projects.update_project(project, %{"state" => "closed"}, user)

      page_two =
        get(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes?page=2"
        )

      assert %{"notes" => notes, "page" => 2} = json_response(page_two, 200)
      assert length(notes) == 2

      activity =
        get(
          recycle(conn),
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes?kind=activity"
        )

      assert %{"notes" => [%{"kind" => "activity", "body" => body}], "total_count" => 1} =
               json_response(activity, 200)

      assert body =~ "closed"
    end

    test "only the author may edit or delete a note", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
      author = github_user("api-token-projects", "alice")
      {:ok, note} = Projects.create_project_note(project, %{"body" => "mine"}, author)

      mallory_conn =
        put_forge_api_token(build_conn(), "project-mallory-notes", "mallory", repository())

      assert patch(
               mallory_conn,
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes/#{note.id}",
               %{body: "not mine"}
             )
             |> api_error_code(403) == "forbidden"

      assert delete(
               recycle(mallory_conn),
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes/#{note.id}"
             )
             |> api_error_code(403) == "forbidden"

      assert patch(
               conn,
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes/#{note.id}",
               %{body: "mine, edited"}
             )
             |> json_response(200)
             |> Map.fetch!("body") == "mine, edited"

      assert delete(
               recycle(conn),
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes/#{note.id}"
             )
             |> response(204)

      assert Projects.count_project_notes(project) == 0
    end

    test "an activity entry cannot be edited or deleted", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice", state: "open"})
      user = github_user("api-token-projects", "alice")
      {:ok, _updated} = Projects.update_project(project, %{"state" => "closed"}, user)

      {[activity], 1} = Projects.list_project_notes_page(project, kind: "activity")

      assert patch(
               conn,
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes/#{activity.id}",
               %{body: "rewritten"}
             )
             |> api_error_code(403) == "forbidden"

      assert delete(
               recycle(conn),
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes/#{activity.id}"
             )
             |> api_error_code(403) == "forbidden"

      assert Projects.count_project_notes(project, kind: "activity") == 1
    end

    test "notes on a private repository stay hidden from a non-member", %{conn: conn} do
      project = project_fixture(%{title: "Alice only", owner: "alice"})

      {:ok, _note} =
        Projects.create_project_note(
          project,
          %{"body" => "private context"},
          github_user("api-token-projects", "alice")
        )

      assert get(
               build_conn(),
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes"
             )
             |> api_error_code(404) == "not_found"

      assert post(
               put_forge_api_token(build_conn(), "project-outsider-notes", "outsider"),
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes",
               %{body: "mine now"}
             )
             |> api_error_code(404) == "not_found"

      assert %{"notes" => [_note]} =
               get(
                 conn,
                 ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes"
               )
               |> json_response(200)
    end
  end

  describe "lifecycle" do
    test "PATCH projectsV2/:number closes, reopens, and archives a project", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice", state: "open"})
      path = ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}"

      assert %{"state" => "closed", "archived" => false} =
               conn |> patch(path, %{state: "closed"}) |> json_response(200)

      assert %{"state" => "open", "archived" => false} =
               conn |> patch(path, %{state: "open"}) |> json_response(200)

      assert %{"state" => "open", "archived" => true, "archived_at" => archived_at} =
               conn |> patch(path, %{archived: true}) |> json_response(200)

      assert is_binary(archived_at)
      assert Projects.get_project_by_number!(repository(), project.number).archived_at

      assert %{"archived" => false, "archived_at" => nil} =
               conn |> patch(path, %{archived: false}) |> json_response(200)

      refute Projects.get_project_by_number!(repository(), project.number).archived_at
    end

    test "PATCH projectsV2/:number rejects a non-boolean archived flag", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      conn =
        patch(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}", %{
          archived: "sideways"
        })

      assert_api_error(conn, 422, "validation_failed", errors: %{"archived" => ["is invalid"]})
      refute Projects.get_project_by_number!(repository(), project.number).archived_at
    end

    test "lifecycle transitions appear in the project's activity, attributed to the actor", %{
      conn: conn
    } do
      project = project_fixture(%{title: "Roadmap", owner: "alice", state: "open"})
      path = ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}"

      assert json_response(patch(conn, path, %{state: "closed"}), 200)
      assert json_response(patch(conn, path, %{archived: true}), 200)

      assert %{"notes" => notes} =
               conn
               |> get(
                 ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes?kind=activity"
               )
               |> json_response(200)

      bodies = Enum.map(notes, & &1["body"])
      assert Enum.any?(bodies, &(&1 =~ "Archived the project."))
      assert Enum.any?(bodies, &(&1 =~ "Changed the state to `closed`."))
      assert Enum.all?(notes, &(get_in(&1, ["author", "login"]) == "alice"))
    end

    test "DELETE projectsV2/:number refuses a project that is not archived", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      conn =
        delete(conn, ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}")

      assert %{"errors" => %{"archived" => [message]}} = json_response(conn, 422)
      assert message =~ "archived"
      assert Projects.get_project_by_number!(repository(), project.number)
    end

    test "DELETE projectsV2/:number removes an archived project, its fields, and its items", %{
      conn: conn
    } do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
      {:ok, issue} = create_issue(%{title: "Tracked"})
      item = project_item_fixture(%{project_id: project.id, issue_id: issue.id})
      field = project_field_fixture(%{project_id: project.id, name: "Status", data_type: "text"})

      path = ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}"
      assert json_response(patch(conn, path, %{archived: true}), 200)
      assert response(delete(conn, path), 204) == ""

      assert_raise Ecto.NoResultsError, fn ->
        Projects.get_project_by_number!(repository(), project.number)
      end

      refute OpenAgents.Repo.get(OpenAgents.ProjectItems.ProjectItem, item.id)
      refute OpenAgents.Repo.get(OpenAgents.ProjectFields.ProjectField, field.id)
      # A project item is a reference to a canonical issue. Deleting the board
      # must never delete the work it pointed at.
      assert OpenAgents.Repo.get(OpenAgents.Issues.Issue, issue.id)
    end

    test "DELETE projectsV2/:number hides a private repository from a non-member", %{conn: conn} do
      project = project_fixture(%{title: "Alice only", owner: "alice"})
      path = ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}"
      assert json_response(patch(conn, path, %{archived: true}), 200)

      mallory = put_forge_api_token(build_conn(), "project-mallory-delete", "mallory")

      assert delete(mallory, path) |> api_error_code(404) == "not_found"
      assert Projects.get_project_by_number!(repository(), project.number)
    end

    test "a public repository's board refuses a non-member's PATCH and DELETE" do
      # A public repository is the case the private-repository tests cannot
      # reach: the outsider can see the board, so a 404 here has to come from
      # write authority rather than from visibility. Without that separation, a
      # delete route with no authorization check would still pass the private
      # tests.
      owner = github_user("project-public-owner", "publius")

      repository =
        repository_with_member_fixture(owner, %{
          owner: "PublicProjectOrg",
          name: "public-projects",
          visibility: "public"
        })

      {:ok, project} =
        Projects.create_project(repository, %{"title" => "Public roadmap"}, owner)

      {:ok, project} = Projects.update_project(project, %{"archived" => true}, owner)

      path = ~p"/api/v1/repos/PublicProjectOrg/public-projects/projectsV2/#{project.number}"
      list_path = ~p"/api/v1/repos/PublicProjectOrg/public-projects/projectsV2?archived=true"
      mallory = put_forge_api_token(build_conn(), "project-public-outsider", "mallory")

      # The outsider really can read the board. The refusal below is therefore
      # about writing, not about seeing.
      assert %{"projects" => before_write} =
               mallory |> get(list_path) |> json_response(200)

      assert Enum.map(before_write, & &1["number"]) == [project.number]
      assert json_response(get(mallory, path), 200)["title"] == "Public roadmap"

      assert mallory |> patch(path, %{title: "Mine now"}) |> api_error_code(404) == "not_found"
      assert mallory |> delete(path) |> api_error_code(404) == "not_found"

      assert %{"projects" => after_write} =
               mallory |> get(list_path) |> json_response(200)

      assert Enum.map(after_write, & &1["number"]) == [project.number]
      assert Projects.get_project_by_number!(repository, project.number).title == "Public roadmap"
    end

    test "an archived project stays out of the default list and returns on request", %{
      conn: conn
    } do
      kept = project_fixture(%{title: "Kept", owner: "alice"})
      retired = project_fixture(%{title: "Retired", owner: "alice"})

      assert json_response(
               patch(
                 conn,
                 ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{retired.number}",
                 %{archived: true}
               ),
               200
             )

      assert %{"projects" => listed} =
               conn
               |> get(~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2")
               |> json_response(200)

      assert Enum.map(listed, & &1["number"]) == [kept.number]

      assert %{"projects" => all} =
               conn
               |> get(~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2?archived=true")
               |> json_response(200)

      assert Enum.sort(Enum.map(all, & &1["number"])) == Enum.sort([kept.number, retired.number])
    end
  end

  describe "field validation" do
    test "POST fields rejects an unsupported data type", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields",
          %{name: "Status", data_type: "rocket"}
        )

      assert %{"errors" => %{"data_type" => [_ | _]}} = json_response(conn, 422)
      assert Projects.list_project_fields(project) == []
    end

    test "POST fields rejects a duplicate name, ignoring case", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
      path = ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields"

      assert json_response(post(conn, path, %{name: "Status", data_type: "text"}), 201)

      conn = post(conn, path, %{name: "status", data_type: "text"})

      assert %{"errors" => %{"name" => [_ | _]}} = json_response(conn, 422)
      assert length(Projects.list_project_fields(project)) == 1
    end

    test "POST fields allows the same name on a different project", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
      other = project_fixture(%{title: "Other", owner: "alice"})

      for board <- [project, other] do
        assert json_response(
                 post(
                   conn,
                   ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{board.number}/fields",
                   %{name: "Status", data_type: "text"}
                 ),
                 201
               )
      end
    end

    test "POST fields requires options for a single select", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields",
          %{name: "Status", data_type: "single_select"}
        )

      assert %{"errors" => %{"options" => [_ | _]}} = json_response(conn, 422)
      assert Projects.list_project_fields(project) == []
    end

    test "POST fields rejects duplicate option identifiers", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields",
          %{name: "Status", data_type: "single_select", options: %{values: ["Todo", "Todo"]}}
        )

      assert %{"errors" => %{"options" => [_ | _]}} = json_response(conn, 422)
      assert Projects.list_project_fields(project) == []
    end

    test "POST fields keeps explicit option identifiers alongside their names", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields",
          %{
            name: "Status",
            data_type: "single_select",
            options: %{
              values: [%{id: "todo", name: "To do"}, %{id: "done", name: "Done"}]
            }
          }
        )

      assert %{"fields" => [%{"options" => options}]} = json_response(conn, 201)

      assert options == %{
               "values" => [
                 %{"id" => "todo", "name" => "To do"},
                 %{"id" => "done", "name" => "Done"}
               ]
             }
    end

    test "POST fields rejects options on a data type that carries none", %{conn: conn} do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields",
          %{name: "Points", data_type: "number", options: %{values: ["1", "2"]}}
        )

      assert %{"errors" => %{"options" => [_ | _]}} = json_response(conn, 422)
      assert Projects.list_project_fields(project) == []
    end
  end

  describe "update_field" do
    setup do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      field =
        project_field_fixture(%{
          project_id: project.id,
          name: "Status",
          data_type: "single_select",
          options: %{"values" => ["Todo", "Done"]}
        })

      %{project: project, field: field}
    end

    test "PATCH fields/:field_id renames a field and rewrites stored item values", %{
      conn: conn,
      project: project,
      field: field
    } do
      item = project_item_fixture(%{project_id: project.id, values: %{"Status" => "Todo"}})

      conn =
        patch(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields/#{field.id}",
          %{name: "Stage"}
        )

      assert %{"fields" => [%{"name" => "Stage", "id" => id}]} = json_response(conn, 200)
      assert id == field.id
      assert Projects.get_project_item!(project, item.id).values == %{"Stage" => "Todo"}
    end

    test "PATCH fields/:field_id refuses to change the data type", %{
      conn: conn,
      project: project,
      field: field
    } do
      conn =
        patch(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields/#{field.id}",
          %{data_type: "text"}
        )

      assert %{"errors" => %{"data_type" => [_ | _]}} = json_response(conn, 422)
      assert Projects.get_project_field!(project, field.id).data_type == "single_select"
    end

    test "PATCH fields/:field_id adds an option", %{
      conn: conn,
      project: project,
      field: field
    } do
      conn =
        patch(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields/#{field.id}",
          %{options: %{values: ["Todo", "In progress", "Done"]}}
        )

      assert %{"fields" => [%{"options" => %{"values" => values}}]} = json_response(conn, 200)
      assert values == ["Todo", "In progress", "Done"]
    end

    test "PATCH fields/:field_id refuses to remove an option items still carry", %{
      conn: conn,
      project: project,
      field: field
    } do
      item = project_item_fixture(%{project_id: project.id, values: %{"Status" => "Todo"}})

      conn =
        patch(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields/#{field.id}",
          %{options: %{values: ["Done"]}}
        )

      assert %{"errors" => %{"options" => [message]}} = json_response(conn, 422)
      assert message =~ "Todo"

      assert Projects.get_project_field!(project, field.id).options == %{
               "values" => ["Todo", "Done"]
             }

      assert Projects.get_project_item!(project, item.id).values == %{"Status" => "Todo"}
    end

    test "PATCH fields/:field_id refuses a name another field already carries", %{
      conn: conn,
      project: project,
      field: field
    } do
      project_field_fixture(%{project_id: project.id, name: "Priority", data_type: "text"})

      conn =
        patch(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields/#{field.id}",
          %{name: "Priority"}
        )

      assert %{"errors" => %{"name" => [_ | _]}} = json_response(conn, 422)
      assert Projects.get_project_field!(project, field.id).name == "Status"
    end

    test "PATCH fields/:field_id returns 404 for a field on another project", %{
      conn: conn,
      field: field
    } do
      other = project_fixture(%{title: "Other", owner: "alice"})

      conn =
        patch(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{other.number}/fields/#{field.id}",
          %{name: "Stage"}
        )

      assert_api_error(conn, 404, "not_found")
    end

    test "PATCH fields/:field_id hides a private repository from a non-member", %{
      project: project,
      field: field
    } do
      mallory = put_forge_api_token(build_conn(), "project-mallory-field", "mallory")

      assert patch(
               mallory,
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields/#{field.id}",
               %{name: "Mine now"}
             )
             |> api_error_code(404) == "not_found"

      assert Projects.get_project_field!(project, field.id).name == "Status"
    end
  end

  describe "delete_field" do
    setup do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      field =
        project_field_fixture(%{
          project_id: project.id,
          name: "Status",
          data_type: "single_select",
          options: %{"values" => ["Todo", "Done"]}
        })

      %{project: project, field: field}
    end

    test "DELETE fields/:field_id removes a field no item carries", %{
      conn: conn,
      project: project,
      field: field
    } do
      conn =
        delete(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields/#{field.id}"
        )

      assert response(conn, 204) == ""
      assert Projects.list_project_fields(project) == []
    end

    test "DELETE fields/:field_id refuses a field items still carry", %{
      conn: conn,
      project: project,
      field: field
    } do
      item = project_item_fixture(%{project_id: project.id, values: %{"Status" => "Todo"}})

      conn =
        delete(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields/#{field.id}"
        )

      assert %{"errors" => %{"name" => [message]}} = json_response(conn, 422)
      assert message =~ "1"
      assert Projects.get_project_field!(project, field.id)
      assert Projects.get_project_item!(project, item.id).values == %{"Status" => "Todo"}
    end

    test "DELETE fields/:field_id hides a private repository from a non-member", %{
      project: project,
      field: field
    } do
      mallory = put_forge_api_token(build_conn(), "project-mallory-field-delete", "mallory")

      assert delete(
               mallory,
               ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/fields/#{field.id}"
             )
             |> api_error_code(404) == "not_found"

      assert Projects.get_project_field!(project, field.id)
    end
  end

  describe "item values against stored fields" do
    setup do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
      {:ok, issue} = create_issue(%{title: "Tracked"})

      project_field_fixture(%{
        project_id: project.id,
        name: "Status",
        data_type: "single_select",
        options: %{"values" => ["Todo", "Done"]}
      })

      project_field_fixture(%{project_id: project.id, name: "Points", data_type: "number"})
      project_field_fixture(%{project_id: project.id, name: "Due", data_type: "date"})

      %{project: project, issue: issue}
    end

    test "POST items accepts a value a field declares", %{
      conn: conn,
      project: project,
      issue: issue
    } do
      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{
            issue_number: issue.number,
            values: %{"Status" => "Done", "Points" => 3, "Due" => "2026-09-01"}
          }
        )

      assert %{"items" => [%{"values" => values}]} = json_response(conn, 201)
      assert values == %{"Status" => "Done", "Points" => 3, "Due" => "2026-09-01"}
    end

    test "POST items rejects a value outside a single select's options", %{
      conn: conn,
      project: project,
      issue: issue
    } do
      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{issue_number: issue.number, values: %{"Status" => "Sideways"}}
        )

      assert %{"errors" => %{"values" => [message]}} = json_response(conn, 422)
      assert message =~ "Status"
      assert Projects.list_project_items(project) == []
    end

    test "POST items rejects a non-numeric number and a non-ISO date", %{
      conn: conn,
      project: project,
      issue: issue
    } do
      path = ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items"

      assert %{"errors" => %{"values" => [_ | _]}} =
               conn
               |> post(path, %{issue_number: issue.number, values: %{"Points" => "many"}})
               |> json_response(422)

      assert %{"errors" => %{"values" => [_ | _]}} =
               conn
               |> post(path, %{issue_number: issue.number, values: %{"Due" => "next Tuesday"}})
               |> json_response(422)

      assert Projects.list_project_items(project) == []
    end

    test "POST items keeps a value for a field the project has not declared", %{
      conn: conn,
      project: project,
      issue: issue
    } do
      conn =
        post(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
          %{issue_number: issue.number, values: %{"Squad" => "Platform"}}
        )

      assert %{"items" => [%{"values" => %{"Squad" => "Platform"}}]} = json_response(conn, 201)
    end

    test "PATCH items/:item_id rejects a value outside a single select's options", %{
      conn: conn,
      project: project,
      issue: issue
    } do
      item =
        project_item_fixture(%{
          project_id: project.id,
          issue_id: issue.id,
          values: %{"Status" => "Todo"}
        })

      conn =
        patch(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}",
          %{values: %{"Status" => "Sideways"}}
        )

      assert %{"errors" => %{"values" => [_ | _]}} = json_response(conn, 422)
      assert Projects.get_project_item!(project, item.id).values == %{"Status" => "Todo"}
    end
  end

  describe "delete_item" do
    setup do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
      item = project_item_fixture(%{project_id: project.id, values: %{"Status" => "Todo"}})
      %{project: project, item: item}
    end

    test "DELETE .../items/:item_id removes the item and keeps the issue", %{
      conn: conn,
      project: project,
      item: item
    } do
      conn =
        delete(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}"
        )

      assert response(conn, 204) == ""
      assert Projects.list_project_items(project) == []
      assert Issues.get_issue!(repository(), item.issue_id)
    end

    test "DELETE .../items/:item_id returns 404 for an item already removed", %{
      conn: conn,
      project: project,
      item: item
    } do
      path =
        ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}"

      assert response(delete(conn, path), 204) == ""
      assert_api_error(delete(recycle(conn), path), 404, "not_found")
    end

    test "DELETE .../items/:item_id is refused for a non-member", %{
      project: project,
      item: item
    } do
      mallory_conn = put_forge_api_token(build_conn(), "project-delete-mallory", "mallory")

      conn =
        delete(
          mallory_conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}"
        )

      assert_api_error(conn, 404, "not_found")
      assert Projects.get_project_item!(project, item.id)
    end

    test "DELETE .../items/:item_id returns 404 for an unreadable source issue", %{
      conn: conn,
      project: project
    } do
      source =
        repository_fixture(%{
          owner: "HiddenOrg",
          name: "hidden-delete-api",
          visibility: "private"
        })

      {:ok, issue} = Issues.create_issue(source, %{title: "Confidential work"})

      {:ok, item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "issue_repository_id" => source.id},
          project
        )

      conn =
        delete(
          conn,
          ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}"
        )

      assert_api_error(conn, 404, "not_found")
      assert Projects.get_project_item!(project, item.id)
    end
  end

  describe "move_item" do
    setup do
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

      {:ok, field} =
        Projects.create_project_field(%{
          "project_id" => project.id,
          "name" => "Status",
          "data_type" => "single_select",
          "options" => %{
            "values" => [
              %{"id" => "todo", "name" => "To Do"},
              %{"id" => "done", "name" => "Done"}
            ]
          }
        })

      %{project: project, field: field}
    end

    defp move_path(project, item),
      do:
        ~p"/api/v1/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}/move"

    defp add_item(project, title, values) do
      {:ok, issue} = Issues.create_issue(repository(), %{title: title})

      {:ok, item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "values" => values},
          project
        )

      item
    end

    test "POST .../items/:item_id/move changes the column", %{conn: conn, project: project} do
      item = add_item(project, "Movable", %{"Status" => "todo"})

      conn = post(conn, move_path(project, item), %{values: %{"Status" => "done"}})

      assert %{"items" => [%{"values" => %{"Status" => "done"}}]} = json_response(conn, 200)
      assert Projects.get_project_item!(project, item.id).values == %{"Status" => "done"}
    end

    test "POST .../items/:item_id/move reorders within one column", %{
      conn: conn,
      project: project
    } do
      _first = add_item(project, "First", %{"Status" => "todo"})
      _second = add_item(project, "Second", %{"Status" => "todo"})
      third = add_item(project, "Third", %{"Status" => "todo"})

      assert %{"items" => [_moved]} =
               json_response(post(conn, move_path(project, third), %{position: 1}), 200)

      assert Enum.map(Projects.list_project_items(project), & &1.issue.title) == [
               "Third",
               "First",
               "Second"
             ]
    end

    test "POST .../items/:item_id/move refuses a stale option identifier", %{
      conn: conn,
      project: project
    } do
      item = add_item(project, "Movable", %{"Status" => "todo"})

      conn = post(conn, move_path(project, item), %{values: %{"Status" => "retired"}})

      assert %{"errors" => %{"values" => [_ | _]}} = json_response(conn, 422)
      assert Projects.get_project_item!(project, item.id).values == %{"Status" => "todo"}
    end

    test "POST .../items/:item_id/move refuses a position that is not positive", %{
      conn: conn,
      project: project
    } do
      item = add_item(project, "Movable", %{"Status" => "todo"})

      conn = post(conn, move_path(project, item), %{position: -1})

      assert %{"errors" => %{"position" => [_ | _]}} = json_response(conn, 422)
    end

    test "POST .../items/:item_id/move repeated settles to the same board", %{
      conn: conn,
      project: project
    } do
      _first = add_item(project, "First", %{"Status" => "todo"})
      second = add_item(project, "Second", %{"Status" => "todo"})

      assert %{"items" => [_]} =
               json_response(post(conn, move_path(project, second), %{position: 1}), 200)

      assert %{"items" => [_]} =
               json_response(
                 post(recycle(conn), move_path(project, second), %{position: 1}),
                 200
               )

      assert Enum.map(Projects.list_project_items(project), & &1.issue.title) == [
               "Second",
               "First"
             ]
    end

    test "POST .../items/:item_id/move is refused for a non-member", %{project: project} do
      item = add_item(project, "Movable", %{"Status" => "todo"})
      mallory_conn = put_forge_api_token(build_conn(), "project-move-mallory", "mallory")

      conn = post(mallory_conn, move_path(project, item), %{values: %{"Status" => "done"}})

      assert_api_error(conn, 404, "not_found")
      assert Projects.get_project_item!(project, item.id).values == %{"Status" => "todo"}
    end

    test "POST .../items/:item_id/move returns 404 for an unreadable source issue", %{
      conn: conn,
      project: project
    } do
      source =
        repository_fixture(%{owner: "HiddenOrg", name: "hidden-move-api", visibility: "private"})

      {:ok, issue} = Issues.create_issue(source, %{title: "Confidential work"})

      {:ok, item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "issue_repository_id" => source.id},
          project
        )

      conn = post(conn, move_path(project, item), %{values: %{"Status" => "done"}})

      assert_api_error(conn, 404, "not_found")
    end
  end

  defp repository, do: Process.get({__MODULE__, :repository})

  defp project_fixture(attrs) do
    OpenAgents.ProjectsFixtures.project_fixture(repository(), attrs)
  end

  defp project_item_fixture(attrs) do
    OpenAgents.ProjectItemsFixtures.project_item_fixture(repository(), attrs)
  end

  defp project_field_fixture(attrs) do
    OpenAgents.ProjectFieldsFixtures.project_field_fixture(repository(), attrs)
  end

  defp create_issue(attrs), do: Issues.create_issue(repository(), attrs)

  defp promise_values(state, id) do
    record = %{
      "id" => id,
      "problem" => "A problem",
      "claim" => "A claim",
      "scope" => "A scope",
      "acceptance_criteria" => ["A criterion"],
      "success_metrics" => ["A metric"],
      "owner" => "OpenAgents",
      "target" => "2026-12-31",
      "evidence" => [],
      "gate" => %{
        "missing" => "A receipt",
        "owner" => "OpenAgents",
        "next_review" => "2026-09-01"
      }
    }

    %{"Promise state" => state, "promise" => record}
  end
end