test/openagents/issues_test.exs

58e6347eeb72 · 26 KB

defmodule OpenAgents.IssuesTest do
  use OpenAgents.DataCase

  alias OpenAgents.Issues
  alias OpenAgents.Issues.Comment
  alias OpenAgents.Issues.Issue
  alias OpenAgents.Issues.UnknownReference

  defmodule AnalyticsSink do
    def capture(event, distinct_id, properties) do
      send(Application.fetch_env!(:openagents, :issues_analytics_test_pid), {
        :captured,
        event,
        distinct_id,
        properties
      })
    end
  end

  setup do
    repository = repository_fixture()

    Enum.each(~w(alice bob carol), fn login ->
      user = repository_user_fixture(login)
      {:ok, _membership} = OpenAgents.Repositories.add_member(repository, user, "contributor")
    end)

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

  defp backdate!(%Issue{} = issue, seconds_ago) do
    at = DateTime.utc_now() |> DateTime.add(-seconds_ago, :second) |> DateTime.truncate(:second)

    {1, nil} =
      Repo.update_all(from(i in Issue, where: i.id == ^issue.id), set: [inserted_at: at])

    Issues.get_issue!(repository(), issue.id)
  end

  describe "triage analytics" do
    setup do
      original_token = Application.get_env(:openagents, :posthog_project_token)
      original_sink = Application.get_env(:openagents, :analytics_sink)
      original_pid = Application.get_env(:openagents, :issues_analytics_test_pid)

      Application.put_env(:openagents, :posthog_project_token, "phc_test")
      Application.put_env(:openagents, :analytics_sink, AnalyticsSink)
      Application.put_env(:openagents, :issues_analytics_test_pid, self())

      on_exit(fn ->
        restore_env(:posthog_project_token, original_token)
        restore_env(:analytics_sink, original_sink)
        restore_env(:issues_analytics_test_pid, original_pid)
      end)

      maintainer = repository_user_fixture("triage-maintainer")

      {:ok, _membership} =
        OpenAgents.Repositories.add_member(repository(), maintainer, "maintainer")

      %{maintainer: maintainer}
    end

    test "issue creation carries stable issue and label state", %{maintainer: maintainer} do
      assert {:ok, issue} =
               Issues.create_issue(repository(), %{title: "Measure triage"}, maintainer)

      assert_receive {:captured, "issue_created", _distinct_id, properties}
      assert properties["owner"] == repository().owner
      assert properties["repo"] == repository().name
      assert properties["issue_number"] == issue.number
      assert properties["issue_state"] == "open"
      assert properties["has_labels"] == false
    end

    test "issue updates identify real state transitions", %{maintainer: maintainer} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Close me"}, maintainer)
      assert_receive {:captured, "issue_created", _, _}

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

      assert_receive {:captured, "issue_updated", _distinct_id, properties}
      assert properties["issue_number"] == issue.number
      assert properties["previous_issue_state"] == "open"
      assert properties["issue_state"] == "closed"
      assert properties["issue_state_changed"] == true
      assert properties["has_labels"] == false
    end

    test "comments record whether the author is a maintainer", %{maintainer: maintainer} do
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Respond to me"}, maintainer)
      assert_receive {:captured, "issue_created", _, _}

      assert {:ok, _comment} =
               Issues.create_comment(issue, %{body: "Acknowledged"}, maintainer)

      assert_receive {:captured, "issue_commented", _distinct_id, properties}
      assert properties["owner"] == repository().owner
      assert properties["repo"] == repository().name
      assert properties["issue_number"] == issue.number
      assert properties["author_role"] == "maintainer"
      assert properties["is_maintainer"] == true
    end
  end

  describe "list_issues/1" do
    test "returns only open issues by default" do
      open = issue_fixture(title: "open one")
      closed = issue_fixture(title: "closed one")
      {:ok, closed} = Issues.update_issue(closed, %{"state" => "closed"})

      numbers = Issues.list_issues(repository()) |> Enum.map(& &1.number)

      assert open.number in numbers
      refute closed.number in numbers
    end

    test "filters by an explicit state" do
      _open = issue_fixture(title: "open one")
      closed = issue_fixture(title: "closed one")
      {:ok, closed} = Issues.update_issue(closed, %{"state" => "closed"})

      assert Issues.list_issues(repository(), state: "closed") |> Enum.map(& &1.number) == [
               closed.number
             ]
    end

    test "state: \"all\" skips the filter" do
      open = issue_fixture(title: "open one")
      closed = issue_fixture(title: "closed one")
      {:ok, _} = Issues.update_issue(closed, %{"state" => "closed"})

      numbers =
        Issues.list_issues(repository(), state: "all") |> Enum.map(& &1.number) |> Enum.sort()

      assert numbers == Enum.sort([open.number, closed.number])
    end

    test "returns an empty list when nothing matches" do
      assert Issues.list_issues(repository()) == []
      assert Issues.list_issues(repository(), state: "all") == []
    end

    test "orders newest first" do
      oldest = issue_fixture(title: "oldest") |> backdate!(300)
      middle = issue_fixture(title: "middle") |> backdate!(200)
      newest = issue_fixture(title: "newest") |> backdate!(100)

      assert Issues.list_issues(repository()) |> Enum.map(& &1.id) == [
               newest.id,
               middle.id,
               oldest.id
             ]
    end
  end

  describe "get_issue!/1 and get_issue_by_number!/1" do
    test "get_issue!/1 returns the issue with the given id" do
      issue = issue_fixture()
      assert Issues.get_issue!(repository(), issue.id) == issue
    end

    test "get_issue!/1 raises for an unknown id" do
      issue = issue_fixture()
      assert_raise Ecto.NoResultsError, fn -> Issues.get_issue!(repository(), issue.id + 1) end
    end

    test "get_issue_by_number!/1 returns the issue with the given number" do
      issue = issue_fixture()
      assert Issues.get_issue_by_number!(repository(), issue.number) == issue
    end

    test "get_issue_by_number!/1 raises for an unknown number" do
      issue = issue_fixture()

      assert_raise Ecto.NoResultsError, fn ->
        Issues.get_issue_by_number!(repository(), issue.number + 1)
      end
    end
  end

  describe "create_issue/1" do
    test "assigns numbers from one upwards" do
      assert {:ok, %Issue{number: 1}} = Issues.create_issue(repository(), %{title: "one"})
      assert {:ok, %Issue{number: 2}} = Issues.create_issue(repository(), %{title: "two"})
      assert {:ok, %Issue{number: 3}} = Issues.create_issue(repository(), %{title: "three"})
    end

    test "ignores a caller-supplied number" do
      assert {:ok, %Issue{number: 1}} =
               Issues.create_issue(repository(), %{title: "one", number: 99})
    end

    test "sets the documented defaults" do
      assert {:ok, %Issue{} = issue} = Issues.create_issue(repository(), %{title: "defaults"})

      assert issue.state == "open"
      assert issue.locked == false
      assert issue.comments == 0
      assert issue.labels == []
      assert issue.assignees == []
      assert is_nil(issue.milestone)
      assert is_nil(issue.closed_at)
    end

    test "accepts string keys" do
      assert {:ok, %Issue{} = issue} =
               Issues.create_issue(repository(), %{"title" => "strings", "body" => "hello"})

      assert issue.title == "strings"
      assert issue.body == "hello"
    end

    test "requires a title" do
      assert {:error, %Ecto.Changeset{} = changeset} =
               Issues.create_issue(repository(), %{body: "no title"})

      assert %{title: ["can't be blank"]} = errors_on(changeset)
    end

    test "called with no attrs at all it still refuses" do
      assert {:error, %Ecto.Changeset{} = changeset} = Issues.create_issue(repository(), %{})
      assert %{title: ["can't be blank"]} = errors_on(changeset)
    end

    test "expands known label names into label maps" do
      label = label_fixture(name: "bug", color: "d73a4a", description: "Something broken")

      assert {:ok, %Issue{} = issue} =
               Issues.create_issue(repository(), %{title: "labelled", labels: ["bug"]})

      assert issue.labels == [
               %{
                 "id" => label.id,
                 "name" => "bug",
                 "color" => "d73a4a",
                 "description" => "Something broken"
               }
             ]
    end

    # Not `Ecto.NoResultsError`: that is what resolving the repository raises,
    # and the API turns it into the `404` a repository the caller cannot see
    # answers with. A label the repository does not have is a rejected field,
    # and it says which field and which value.
    test "rejects a label outside the repository label set" do
      error =
        assert_raise UnknownReference, fn ->
          Issues.create_issue(repository(), %{title: "labelled", labels: ["nope"]})
        end

      assert error.field == :labels
      assert error.value == "nope"
      assert Exception.message(error) =~ "nope"
    end

    test "canonicalizes label maps from the repository row" do
      label = label_fixture(name: "bug", color: "abcdef")
      given = [%{"name" => "bug", "color" => "abcdef"}]

      assert {:ok, %Issue{} = issue} =
               Issues.create_issue(repository(), %{title: "labelled", labels: given})

      assert issue.labels == [
               %{
                 "id" => label.id,
                 "name" => "bug",
                 "color" => "abcdef",
                 "description" => label.description
               }
             ]
    end

    test "accepts an empty label list" do
      assert {:ok, %Issue{labels: []}} =
               Issues.create_issue(repository(), %{title: "none", labels: []})
    end

    test "expands assignee logins into assignee maps" do
      assert {:ok, %Issue{} = issue} =
               Issues.create_issue(repository(), %{title: "assigned", assignees: ["alice", "bob"]})

      assert issue.assignees == [%{"login" => "alice"}, %{"login" => "bob"}]
    end

    test "canonicalizes assignee maps from repository membership" do
      given = [%{"login" => "alice", "id" => 7}]

      assert {:ok, %Issue{} = issue} =
               Issues.create_issue(repository(), %{title: "assigned", assignees: given})

      assert issue.assignees == [%{"login" => "alice"}]
    end

    test "expands a milestone number into a milestone map" do
      milestone = milestone_fixture(title: "v1", state: "open", due_on: "2026-01-01")

      assert {:ok, %Issue{} = issue} =
               Issues.create_issue(repository(), %{title: "planned", milestone: milestone.number})

      assert issue.milestone == %{
               "number" => milestone.number,
               "title" => "v1",
               "state" => "open",
               "description" => milestone.description,
               "due_on" => "2026-01-01"
             }
    end

    test "raises for an unknown milestone number" do
      error =
        assert_raise UnknownReference, fn ->
          Issues.create_issue(repository(), %{title: "planned", milestone: 404})
        end

      assert error.field == :milestone
      assert error.value == 404
    end

    test "raises for a login that is not assignable in this repository" do
      error =
        assert_raise UnknownReference, fn ->
          Issues.create_issue(repository(), %{title: "assigned", assignees: ["nobody"]})
        end

      assert error.field == :assignees
      assert error.value == "nobody"
    end

    test "accepts an explicit nil milestone" do
      assert {:ok, %Issue{milestone: nil}} =
               Issues.create_issue(repository(), %{title: "unplanned", milestone: nil})
    end
  end

  describe "update_issue/2" do
    test "updates plain fields" do
      issue = issue_fixture()

      assert {:ok, %Issue{} = updated} =
               Issues.update_issue(issue, %{"title" => "new title", "body" => "new body"})

      assert updated.title == "new title"
      assert updated.body == "new body"
    end

    test "returns an error changeset for invalid data" do
      issue = issue_fixture()

      assert {:error, %Ecto.Changeset{}} = Issues.update_issue(issue, %{"title" => nil})
      assert issue == Issues.get_issue!(repository(), issue.id)
    end

    test "closing stamps closed_at and defaults the state reason" do
      issue = issue_fixture()

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

      assert closed.state == "closed"
      assert closed.state_reason == "completed"
      refute is_nil(closed.closed_at)
    end

    test "closing keeps an explicit state reason" do
      issue = issue_fixture()

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

      assert closed.state_reason == "not_planned"
    end

    test "closing an already-closed issue does not re-stamp closed_at" do
      issue = issue_fixture()
      {:ok, closed} = Issues.update_issue(issue, %{"state" => "closed"})

      assert {:ok, %Issue{} = again} =
               Issues.update_issue(closed, %{"state" => "closed", "title" => "still closed"})

      assert again.closed_at == closed.closed_at
      assert again.title == "still closed"
    end

    test "reopening clears closed_at and the state reason" do
      issue = issue_fixture()
      {:ok, closed} = Issues.update_issue(issue, %{"state" => "closed"})

      assert {:ok, %Issue{} = reopened} = Issues.update_issue(closed, %{"state" => "open"})

      assert reopened.state == "open"
      assert is_nil(reopened.closed_at)
      assert is_nil(reopened.state_reason)
    end

    test "closing works with atom keys too" do
      issue = issue_fixture()

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

      assert closed.state == "closed"
      assert closed.state_reason == "completed"
      refute is_nil(closed.closed_at)
    end

    test "closing with atom keys keeps a caller-supplied closed_at" do
      issue = issue_fixture()
      at = ~U[2026-01-01 00:00:00Z]

      assert {:ok, %Issue{} = closed} =
               Issues.update_issue(issue, %{state: "closed", closed_at: at})

      assert closed.closed_at == at
    end

    test "reopening works with atom keys too" do
      issue = issue_fixture()
      {:ok, closed} = Issues.update_issue(issue, %{state: "closed"})

      assert {:ok, %Issue{} = reopened} = Issues.update_issue(closed, %{state: "open"})

      assert is_nil(reopened.closed_at)
      assert is_nil(reopened.state_reason)
    end

    test "locking an issue is a plain field update" do
      issue = issue_fixture()

      assert {:ok, %Issue{} = locked} =
               Issues.update_issue(issue, %{"locked" => true, "locked_reason" => "spam"})

      assert locked.locked
      assert locked.locked_reason == "spam"
    end
  end

  describe "change_issue/2" do
    test "returns a changeset" do
      issue = issue_fixture()
      assert %Ecto.Changeset{} = Issues.change_issue(issue)
    end

    test "applies attrs and surfaces validation errors" do
      issue = issue_fixture()

      assert Issues.change_issue(issue, %{title: "Renamed"}).valid?

      changeset = Issues.change_issue(issue, %{title: nil})
      refute changeset.valid?
      assert %{title: ["can't be blank"]} = errors_on(changeset)
    end
  end

  describe "labels on an issue" do
    test "add_labels/2 appends known labels" do
      label_fixture(name: "bug", color: "d73a4a")
      issue = issue_fixture()

      assert {:ok, %Issue{} = updated} = Issues.add_labels(issue, ["bug"])
      assert Enum.map(updated.labels, & &1["name"]) == ["bug"]
      assert hd(updated.labels)["color"] == "d73a4a"
    end

    test "add_labels/2 does not duplicate an existing label" do
      label_fixture(name: "bug", color: "d73a4a")
      issue = issue_fixture()

      {:ok, issue} = Issues.add_labels(issue, ["bug"])
      assert {:ok, %Issue{} = updated} = Issues.add_labels(issue, ["bug"])

      assert Enum.map(updated.labels, & &1["name"]) == ["bug"]
    end

    test "add_labels/2 accepts several names at once" do
      label_fixture(name: "bug", color: "d73a4a")
      label_fixture(name: "docs", color: "0075ca")
      issue = issue_fixture()

      assert {:ok, %Issue{} = updated} = Issues.add_labels(issue, ["bug", "docs"])
      assert Enum.map(updated.labels, & &1["name"]) == ["bug", "docs"]
    end

    # GitHub creates a label on the fly when one is added that does not
    # exist, and so do we: the name is scoped to this repository either way.
    test "add_labels/2 creates an unknown label instead of raising" do
      issue = issue_fixture()

      assert {:ok, %Issue{labels: [label]}} = Issues.add_labels(issue, ["nope"])
      assert label["name"] == "nope"

      assert %{color: color} =
               OpenAgents.Labels.get_label_by_name!(
                 repository(),
                 "nope"
               )

      refute color in ["", nil]
    end

    test "add_labels/2 with an empty list is a no-op" do
      issue = issue_fixture()
      assert {:ok, %Issue{labels: []}} = Issues.add_labels(issue, [])
    end

    test "remove_label/2 drops the named label" do
      label_fixture(name: "bug", color: "d73a4a")
      label_fixture(name: "docs", color: "0075ca")
      issue = issue_fixture()
      {:ok, issue} = Issues.add_labels(issue, ["bug", "docs"])

      assert {:ok, %Issue{} = updated} = Issues.remove_label(issue, "bug")
      assert Enum.map(updated.labels, & &1["name"]) == ["docs"]
    end

    test "remove_label/2 decodes a percent-encoded name" do
      label_fixture(name: "help wanted", color: "008672")
      issue = issue_fixture()
      {:ok, issue} = Issues.add_labels(issue, ["help wanted"])

      assert {:ok, %Issue{labels: []}} = Issues.remove_label(issue, "help%20wanted")
    end

    test "remove_label/2 is a no-op for a label the issue does not carry" do
      label_fixture(name: "bug", color: "d73a4a")
      issue = issue_fixture()
      {:ok, issue} = Issues.add_labels(issue, ["bug"])

      assert {:ok, %Issue{} = updated} = Issues.remove_label(issue, "docs")
      assert Enum.map(updated.labels, & &1["name"]) == ["bug"]
    end

    test "remove_label/2 tolerates an issue with no labels" do
      issue = issue_fixture()
      assert {:ok, %Issue{labels: []}} = Issues.remove_label(issue, "bug")
    end
  end

  describe "assignees on an issue" do
    test "add_assignees/2 appends logins" do
      issue = issue_fixture()

      assert {:ok, %Issue{} = updated} = Issues.add_assignees(issue, ["alice", "bob"])
      assert updated.assignees == [%{"login" => "alice"}, %{"login" => "bob"}]
    end

    test "add_assignees/2 does not duplicate an existing login" do
      issue = issue_fixture()
      {:ok, issue} = Issues.add_assignees(issue, ["alice"])

      assert {:ok, %Issue{} = updated} = Issues.add_assignees(issue, ["alice", "bob"])
      assert updated.assignees == [%{"login" => "alice"}, %{"login" => "bob"}]
    end

    test "remove_assignees/2 drops the named logins" do
      issue = issue_fixture()
      {:ok, issue} = Issues.add_assignees(issue, ["alice", "bob", "carol"])

      assert {:ok, %Issue{} = updated} = Issues.remove_assignees(issue, ["alice", "carol"])
      assert updated.assignees == [%{"login" => "bob"}]
    end

    test "remove_assignees/2 ignores logins the issue does not carry" do
      issue = issue_fixture()
      {:ok, issue} = Issues.add_assignees(issue, ["alice"])

      assert {:ok, %Issue{} = updated} = Issues.remove_assignees(issue, ["bob"])
      assert updated.assignees == [%{"login" => "alice"}]
    end

    test "remove_assignees/2 tolerates an issue with no assignees" do
      issue = issue_fixture()
      assert {:ok, %Issue{assignees: []}} = Issues.remove_assignees(issue, ["alice"])
    end
  end

  describe "set_milestone/2" do
    test "attaches the milestone by number" do
      milestone = milestone_fixture(title: "v1", state: "open")
      issue = issue_fixture()

      assert {:ok, %Issue{} = updated} = Issues.set_milestone(issue, milestone.number)
      assert updated.milestone["number"] == milestone.number
      assert updated.milestone["title"] == "v1"
    end

    test "clears the milestone with nil" do
      milestone = milestone_fixture(title: "v1")
      issue = issue_fixture()
      {:ok, issue} = Issues.set_milestone(issue, milestone.number)

      assert {:ok, %Issue{milestone: nil}} = Issues.set_milestone(issue, nil)
    end

    test "raises for an unknown milestone number" do
      issue = issue_fixture()
      assert_raise Ecto.NoResultsError, fn -> Issues.set_milestone(issue, 404) end
    end
  end

  describe "comments" do
    test "create_comment/1 stores the comment and bumps the issue counter" do
      issue = issue_fixture()

      assert {:ok, %Comment{} = comment} =
               Issues.create_comment(%{body: "hello", issue_id: issue.id})

      assert comment.body == "hello"
      assert comment.issue_id == issue.id
      refute is_nil(comment.created_at)
      refute is_nil(comment.updated_at)

      assert Issues.get_issue!(repository(), issue.id).comments == 1
    end

    test "create_comment/1 keeps explicit timestamps" do
      issue = issue_fixture()
      at = ~U[2026-01-01 00:00:00Z]

      assert {:ok, %Comment{} = comment} =
               Issues.create_comment(%{
                 "body" => "hello",
                 "issue_id" => issue.id,
                 "created_at" => at,
                 "updated_at" => at
               })

      assert comment.created_at == at
      assert comment.updated_at == at
    end

    test "create_comment/1 stores the author payload" do
      issue = issue_fixture()

      assert {:ok, %Comment{} = comment} =
               Issues.create_comment(%{
                 body: "hello",
                 issue_id: issue.id,
                 user: %{"login" => "alice"}
               })

      assert comment.user == %{"login" => "alice"}
    end

    test "create_comment/1 rejects a blank body and leaves the counter alone" do
      issue = issue_fixture()

      assert {:error, %Ecto.Changeset{} = changeset} =
               Issues.create_comment(%{body: nil, issue_id: issue.id})

      assert %{body: ["can't be blank"]} = errors_on(changeset)
      assert Issues.get_issue!(repository(), issue.id).comments == 0
    end

    test "create_comment/1 requires an issue_id" do
      assert {:error, %Ecto.Changeset{} = changeset} = Issues.create_comment(%{body: "orphan"})
      assert %{issue_id: ["can't be blank"]} = errors_on(changeset)
    end

    test "create_comment/0 refuses an empty comment" do
      assert {:error, %Ecto.Changeset{} = changeset} = Issues.create_comment(%{})
      assert %{body: ["can't be blank"], issue_id: ["can't be blank"]} = errors_on(changeset)
    end

    test "get_comment!/1 returns the comment" do
      issue = issue_fixture()
      {:ok, comment} = Issues.create_comment(%{body: "hello", issue_id: issue.id})

      assert Issues.get_comment!(repository(), comment.id) == comment
    end

    test "get_comment!/1 raises for an unknown id" do
      issue = issue_fixture()
      {:ok, comment} = Issues.create_comment(%{body: "hello", issue_id: issue.id})

      assert_raise Ecto.NoResultsError, fn ->
        Issues.get_comment!(repository(), comment.id + 1)
      end
    end

    test "list_comments/1 is scoped to one issue and ordered by creation time" do
      issue = issue_fixture(title: "mine")
      other = issue_fixture(title: "theirs")

      {:ok, second} =
        Issues.create_comment(%{
          body: "second",
          issue_id: issue.id,
          created_at: ~U[2026-01-02 00:00:00Z],
          updated_at: ~U[2026-01-02 00:00:00Z]
        })

      {:ok, first} =
        Issues.create_comment(%{
          body: "first",
          issue_id: issue.id,
          created_at: ~U[2026-01-01 00:00:00Z],
          updated_at: ~U[2026-01-01 00:00:00Z]
        })

      {:ok, _elsewhere} = Issues.create_comment(%{body: "elsewhere", issue_id: other.id})

      assert Issues.list_comments(issue) |> Enum.map(& &1.id) == [first.id, second.id]
    end

    test "list_comments/1 returns an empty list for an issue with no comments" do
      issue = issue_fixture()
      assert Issues.list_comments(issue) == []
    end

    test "update_comment/2 edits the body and bumps updated_at" do
      issue = issue_fixture()

      {:ok, comment} =
        Issues.create_comment(%{
          body: "before",
          issue_id: issue.id,
          created_at: ~U[2026-01-01 00:00:00Z],
          updated_at: ~U[2026-01-01 00:00:00Z]
        })

      assert {:ok, %Comment{} = updated} = Issues.update_comment(comment, %{body: "after"})

      assert updated.body == "after"
      assert updated.created_at == comment.created_at
      assert DateTime.after?(updated.updated_at, comment.updated_at)
    end

    test "update_comment/2 rejects a blank body" do
      issue = issue_fixture()
      {:ok, comment} = Issues.create_comment(%{body: "before", issue_id: issue.id})

      assert {:error, %Ecto.Changeset{}} = Issues.update_comment(comment, %{body: nil})
      assert Issues.get_comment!(repository(), comment.id).body == "before"
    end

    test "delete_comment/1 removes it and decrements the issue counter" do
      issue = issue_fixture()
      {:ok, comment} = Issues.create_comment(%{body: "hello", issue_id: issue.id})
      assert Issues.get_issue!(repository(), issue.id).comments == 1

      assert {:ok, :ok} = Issues.delete_comment(comment)

      assert_raise Ecto.NoResultsError, fn -> Issues.get_comment!(repository(), comment.id) end
      assert Issues.get_issue!(repository(), issue.id).comments == 0
    end

    test "the counter tracks several comments" do
      issue = issue_fixture()
      {:ok, a} = Issues.create_comment(%{body: "a", issue_id: issue.id})
      {:ok, _b} = Issues.create_comment(%{body: "b", issue_id: issue.id})
      assert Issues.get_issue!(repository(), issue.id).comments == 2

      {:ok, :ok} = Issues.delete_comment(a)
      assert Issues.get_issue!(repository(), issue.id).comments == 1
    end
  end

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

  defp issue_fixture(attrs \\ %{}) do
    OpenAgents.IssuesFixtures.issue_fixture(repository(), attrs)
  end

  defp label_fixture(attrs) do
    OpenAgents.LabelsFixtures.label_fixture(repository(), attrs)
  end

  defp milestone_fixture(attrs) do
    OpenAgents.MilestonesFixtures.milestone_fixture(repository(), attrs)
  end

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