test/openagents/issue_progress_test.exs

58e6347eeb72 · 15 KB

defmodule OpenAgents.IssueProgressTest do
  use OpenAgents.DataCase

  import Ecto.Query
  import OpenAgentsWeb.ConnCase, only: [github_user: 1]

  alias OpenAgents.Forge.Assignment
  alias OpenAgents.Issues
  alias OpenAgents.ProjectItems
  alias OpenAgents.Projects
  alias OpenAgents.Threads
  alias OpenAgents.Threads.Thread
  alias OpenAgents.Transparency.ArtifactLink

  setup do
    {:ok, repository: repository_fixture()}
  end

  describe "progress_map/2" do
    test "an issue on no board has not started", %{repository: repository} do
      {:ok, issue} = Issues.create_issue(repository, %{title: "Untouched"})

      assert %{issue.id => "to_do"} == Issues.progress_map([issue])
    end

    test "a started column on a readable board reports in_progress", %{repository: repository} do
      {:ok, issue} = Issues.create_issue(repository, %{title: "Underway"})
      place(repository, issue, "In Progress")

      assert Issues.progress(issue) == "in_progress"
    end

    test "column matching ignores case and separators", %{repository: repository} do
      for column <- ["in progress", "IN_PROGRESS", " In-Progress ", "In Review", "Started"] do
        {:ok, issue} = Issues.create_issue(repository, %{title: "Underway in #{column}"})
        place(repository, issue, column)

        assert Issues.progress(issue) == "in_progress", "expected #{inspect(column)} to start"
      end
    end

    test "a column no board column names leaves the issue to_do", %{repository: repository} do
      {:ok, issue} = Issues.create_issue(repository, %{title: "Queued"})
      place(repository, issue, "To Do")

      assert Issues.progress(issue) == "to_do"
    end

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

      {:ok, closed} = Issues.update_issue(issue, %{"state" => "closed"})

      assert Issues.progress(closed) == "done"
    end

    test "a Done column on an open issue does not finish it", %{repository: repository} do
      {:ok, issue} = Issues.create_issue(repository, %{title: "Says done, still open"})
      place(repository, issue, "Done")

      assert Issues.progress(issue) == "to_do"
    end

    test "a private board's column never reaches a reader without membership" do
      public = repository_fixture(%{visibility: "public"})
      private = repository_fixture(%{visibility: "private"})
      member = repository_user_fixture("board-member")
      {:ok, _membership} = OpenAgents.Repositories.add_member(private, member, "owner")

      {:ok, issue} = Issues.create_issue(public, %{title: "Tracked privately"})
      place(private, issue, "In Progress")

      assert Issues.progress(issue, nil) == "to_do"
      assert Issues.progress(issue, repository_user_fixture("outsider")) == "to_do"
      assert Issues.progress(issue, member) == "in_progress"
    end

    test "one query serves a whole page", %{repository: repository} do
      {:ok, started} = Issues.create_issue(repository, %{title: "Started"})
      {:ok, queued} = Issues.create_issue(repository, %{title: "Queued"})
      {:ok, finished} = Issues.create_issue(repository, %{title: "Finished", state: "closed"})
      place(repository, started, "In Progress")

      progress = Issues.progress_map([started, queued, finished])

      assert progress[started.id] == "in_progress"
      assert progress[queued.id] == "to_do"
      assert progress[finished.id] == "done"
    end
  end

  describe "the progress filter" do
    test "lists exactly the issues the derived value calls started", %{repository: repository} do
      {:ok, started} = Issues.create_issue(repository, %{title: "Started"})
      {:ok, _queued} = Issues.create_issue(repository, %{title: "Queued"})
      place(repository, started, "In Progress")

      {issues, total} =
        Issues.list_issues_page(repository, state: "all", progress: "in_progress")

      assert total == 1
      assert Enum.map(issues, & &1.title) == ["Started"]
    end

    test "to_do is the complement within the open issues", %{repository: repository} do
      {:ok, started} = Issues.create_issue(repository, %{title: "Started"})
      {:ok, _queued} = Issues.create_issue(repository, %{title: "Queued"})
      place(repository, started, "In Progress")

      {issues, _total} = Issues.list_issues_page(repository, state: "open", progress: "to_do")

      assert Enum.map(issues, & &1.title) == ["Queued"]
    end

    test "done lists the closed issues", %{repository: repository} do
      {:ok, _open} = Issues.create_issue(repository, %{title: "Open"})
      {:ok, _closed} = Issues.create_issue(repository, %{title: "Closed", state: "closed"})

      {issues, _total} = Issues.list_issues_page(repository, state: "all", progress: "done")

      assert Enum.map(issues, & &1.title) == ["Closed"]
    end

    test "a private board cannot move an issue into another reader's list" do
      public = repository_fixture(%{visibility: "public"})
      private = repository_fixture(%{visibility: "private"})
      member = repository_user_fixture("filter-member")
      {:ok, _membership} = OpenAgents.Repositories.add_member(private, member, "owner")

      {:ok, issue} = Issues.create_issue(public, %{title: "Tracked privately"})
      place(private, issue, "In Progress")

      assert {[], 0} = Issues.list_issues_page(public, progress: "in_progress", reader: nil)

      assert {[found], 1} =
               Issues.list_issues_page(public, progress: "in_progress", reader: member)

      assert found.id == issue.id
    end
  end

  describe "an attempt holding the issue" do
    test "starts the issue with no board anywhere", %{repository: repository} do
      {:ok, issue} = Issues.create_issue(repository, %{title: "Picked up"})
      {:ok, untouched} = Issues.create_issue(repository, %{title: "Nobody has touched this"})

      attempt(repository, issue, state: "running")

      assert Issues.progress(issue) == "in_progress"
      assert Issues.progress(untouched) == "to_do"
    end

    test "an admitted attempt has started it too", %{repository: repository} do
      {:ok, issue} = Issues.create_issue(repository, %{title: "Admitted"})
      attempt(repository, issue, state: "admitted")

      assert Issues.progress(issue) == "in_progress"
    end

    test "a terminal attempt has released the claim", %{repository: repository} do
      for state <- ~w(completed failed cancelled) do
        {:ok, issue} = Issues.create_issue(repository, %{title: "Attempt #{state}"})
        attempt(repository, issue, state: state)

        assert Issues.progress(issue) == "to_do", "expected #{state} to stop claiming"
      end
    end

    test "an attempt past its deadline stops claiming work", %{repository: repository} do
      {:ok, issue} = Issues.create_issue(repository, %{title: "Ran over"})

      attempt(repository, issue,
        state: "running",
        deadline_at: DateTime.add(DateTime.utc_now(), -60, :second)
      )

      assert Issues.progress(issue) == "to_do"
    end

    test "a dark attempt is withheld rather than published as progress", %{
      repository: repository
    } do
      {:ok, issue} = Issues.create_issue(repository, %{title: "Withheld"})
      attempt(repository, issue, state: "running", transparency_tier: "dark")

      assert Issues.progress(issue) == "to_do"
    end

    test "a revoked artifact link takes the attempt out of the projection", %{
      repository: repository
    } do
      user = github_user("issue-progress-revoked")
      {:ok, issue} = Issues.create_issue(repository, %{title: "Revoked"})

      link =
        Repo.insert!(%ArtifactLink{
          account_id: user.id,
          artifact_type: "changelog",
          artifact_ref: "sha",
          repository_id: repository.id,
          tier: "ledger",
          revoked_at: DateTime.utc_now()
        })

      attempt(repository, issue, state: "running", artifact_link_id: link.id)

      assert Issues.progress(issue) == "to_do"
    end

    test "the filter lists exactly what the derived value calls started", %{
      repository: repository
    } do
      {:ok, started} = Issues.create_issue(repository, %{title: "Started"})
      {:ok, _queued} = Issues.create_issue(repository, %{title: "Queued"})
      attempt(repository, started, state: "running")

      assert {[found], 1} = Issues.list_issues_page(repository, progress: "in_progress")
      assert found.id == started.id

      {queued, _total} = Issues.list_issues_page(repository, progress: "to_do")
      assert Enum.map(queued, & &1.title) == ["Queued"]
    end
  end

  describe "a session bound to the issue" do
    test "an open thread starts the issue for the account running it", %{
      repository: repository
    } do
      user = github_user("issue-progress-owner")
      {:ok, issue} = Issues.create_issue(repository, %{title: "Being worked"})
      {:ok, untouched} = Issues.create_issue(repository, %{title: "Untouched"})

      {:ok, _thread} = Threads.open(user, "Implement it", issue_id: issue.id)

      assert Issues.progress(issue, user) == "in_progress"
      assert Issues.progress(untouched, user) == "to_do"
    end

    test "a thread that has gone quiet stops claiming the issue", %{repository: repository} do
      user = github_user("issue-progress-quiet")
      {:ok, issue} = Issues.create_issue(repository, %{title: "Walked away from"})

      {:ok, thread} = Threads.open(user, "Implement it", issue_id: issue.id)
      assert Issues.progress(issue, user) == "in_progress"

      go_quiet(thread)

      assert Issues.progress(issue, user) == "to_do"
      assert Repo.get!(Thread, thread.id).status == "open"
    end

    test "a finished thread stops claiming the issue", %{repository: repository} do
      user = github_user("issue-progress-finished")
      {:ok, issue} = Issues.create_issue(repository, %{title: "Session over"})

      {:ok, thread} = Threads.open(user, "Implement it", issue_id: issue.id)
      {:ok, _cancelled} = Threads.cancel(thread, "Done with it.")

      assert Issues.progress(issue, user) == "to_do"
    end

    test "an owner-only thread never becomes another reader's fact", %{repository: repository} do
      user = github_user("issue-progress-private")
      stranger = github_user("issue-progress-stranger")
      {:ok, issue} = Issues.create_issue(repository, %{title: "Privately worked"})

      {:ok, _dark} = Threads.open(user, "Implement it", issue_id: issue.id)

      assert Issues.progress(issue, user) == "in_progress"
      assert Issues.progress(issue, stranger) == "to_do"
      assert Issues.progress(issue, nil) == "to_do"
    end

    test "a thread opened at a wider tier reaches every reader", %{repository: repository} do
      user = github_user("issue-progress-wide")
      stranger = github_user("issue-progress-wide-stranger")
      {:ok, issue} = Issues.create_issue(repository, %{title: "Openly worked"})

      {:ok, _ledger} =
        Threads.open(user, "Implement it", issue_id: issue.id, visibility: "ledger")

      assert Issues.progress(issue, user) == "in_progress"
      assert Issues.progress(issue, stranger) == "in_progress"
      assert Issues.progress(issue, nil) == "in_progress"
    end

    test "the filter reads the same threads the derived value does", %{repository: repository} do
      user = github_user("issue-progress-filter")
      stranger = github_user("issue-progress-filter-stranger")
      {:ok, started} = Issues.create_issue(repository, %{title: "Started"})
      {:ok, _queued} = Issues.create_issue(repository, %{title: "Queued"})

      {:ok, _thread} = Threads.open(user, "Implement it", issue_id: started.id)

      assert {[found], 1} =
               Issues.list_issues_page(repository, progress: "in_progress", reader: user)

      assert found.id == started.id

      assert {[], 0} =
               Issues.list_issues_page(repository, progress: "in_progress", reader: stranger)

      {queued, _total} =
        Issues.list_issues_page(repository, progress: "to_do", reader: stranger)

      assert Enum.map(queued, & &1.title) |> Enum.sort() == ["Queued", "Started"]
    end
  end

  describe "binding a session to the issue its objective names" do
    test "an objective naming an issue in the thread's repository binds it", %{
      repository: repository
    } do
      user = github_user("issue-bind-owner")
      {:ok, issue} = Issues.create_issue(repository, %{title: "Named by the prompt"})
      path = "#{repository.owner}/#{repository.name}"

      {:ok, thread} =
        Threads.open(user, "Implement issue ##{issue.number} and close it", repository: path)

      assert thread.issue_id == issue.id
      assert Issues.progress(issue, user) == "in_progress"
    end

    test "the qualified form binds when it names the same repository", %{
      repository: repository
    } do
      user = github_user("issue-bind-qualified")
      {:ok, issue} = Issues.create_issue(repository, %{title: "Named in full"})
      path = "#{repository.owner}/#{repository.name}"

      {:ok, thread} = Threads.open(user, "Work #{path}##{issue.number}", repository: path)

      assert thread.issue_id == issue.id
    end

    test "a reference to another repository binds nothing", %{repository: repository} do
      user = github_user("issue-bind-cross")
      other = repository_fixture()
      {:ok, elsewhere} = Issues.create_issue(other, %{title: "Somewhere else"})

      {:ok, thread} =
        Threads.open(
          user,
          "Work #{other.owner}/#{other.name}##{elsewhere.number}",
          repository: "#{repository.owner}/#{repository.name}"
        )

      assert is_nil(thread.issue_id)
    end

    test "a thread naming no repository binds nothing", %{repository: repository} do
      user = github_user("issue-bind-unscoped")
      {:ok, issue} = Issues.create_issue(repository, %{title: "Unreachable"})

      {:ok, thread} = Threads.open(user, "Implement issue ##{issue.number}")

      assert is_nil(thread.issue_id)
    end

    test "an issue the opener cannot read binds nothing" do
      private = repository_fixture(%{visibility: "private"})
      outsider = github_user("issue-bind-outsider")
      {:ok, issue} = Issues.create_issue(private, %{title: "Private work"})

      {:ok, thread} =
        Threads.open(outsider, "Implement issue ##{issue.number}",
          repository: "#{private.owner}/#{private.name}"
        )

      assert is_nil(thread.issue_id)
    end

    test "a number naming no issue binds nothing", %{repository: repository} do
      user = github_user("issue-bind-missing")

      {:ok, thread} =
        Threads.open(user, "Implement issue #999999",
          repository: "#{repository.owner}/#{repository.name}"
        )

      assert is_nil(thread.issue_id)
    end
  end

  defp attempt(repository, issue, overrides) do
    now = DateTime.utc_now()

    defaults = [
      repository_id: repository.id,
      issue_id: issue.id,
      target_kind: "computer",
      requesting_principal: %{"kind" => "test"},
      branch: "work/#{issue.number}-#{System.unique_integer([:positive])}",
      state: "running",
      transparency_tier: "ledger",
      deadline_at: DateTime.add(now, 3600, :second),
      admitted_at: now,
      started_at: now
    ]

    Repo.insert!(struct!(Assignment, Keyword.merge(defaults, overrides)))
  end

  # Silence, not a status change: the thread stays open and simply records
  # nothing for longer than the quiet window.
  defp go_quiet(%Thread{id: id}) do
    quiet = DateTime.add(DateTime.utc_now(), -3 * 60 * 60, :second)

    {1, _} =
      Repo.update_all(from(thread in Thread, where: thread.id == ^id),
        set: [updated_at: quiet]
      )
  end

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

    {:ok, item} =
      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