Add issue comment endpoints and tests.

c103ce44975b · AtlantisPleb · · parent 4cf8bb7fea87

Add issue comment endpoints and tests.

Creates the comments table and schema, adds comment management to the
Issues context, and wires the GitHub-compatible comment REST routes under
/api/v3/repos/:owner/:repo/issues. All tests pass.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents/issues.ex
  • added lib/openagents/issues/comment.ex
  • added lib/openagents_web/controllers/comment_controller.ex
  • added lib/openagents_web/controllers/comment_json.ex
  • modified lib/openagents_web/router.ex
  • added priv/repo/migrations/20260819202444_create_comments.exs
  • added test/openagents_web/controllers/comment_controller_test.exs

Diff

7 files changed, +303 -0

lib/openagents/issues.ex modified +61

@@ -5,6 +5,7 @@ defmodule OpenAgents.Issues do

5 5
6 6
  import Ecto.Query, warn: false
7 7
  alias OpenAgents.Repo
8
  alias OpenAgents.Issues.Comment
8 9
  alias OpenAgents.Issues.Issue
9 10
10 11
  def list_issues(opts \\ []) do

@@ -78,6 +79,66 @@ defmodule OpenAgents.Issues do

78 79
  defp maybe_filter_state(query, "all"), do: query
79 80
  defp maybe_filter_state(query, state), do: where(query, state: ^state)
80 81
82
  def list_comments(issue_id) do
83
    Comment
84
    |> where(issue_id: ^issue_id)
85
    |> order_by(:created_at)
86
    |> Repo.all()
87
  end
88
89
  def get_comment!(id), do: Repo.get!(Comment, id)
90
91
  def create_comment(attrs \\ %{}) do
92
    normalized = for {k, v} <- attrs, into: %{}, do: {to_string(k), v}
93
    issue_id = Map.get(normalized, "issue_id")
94
    now = DateTime.utc_now() |> DateTime.truncate(:second)
95
    normalized =
96
      normalized
97
      |> Map.put_new("created_at", now)
98
      |> Map.put_new("updated_at", now)
99
100
    Repo.transaction(fn ->
101
      with {:ok, %Comment{} = comment} <-
102
             %Comment{}
103
             |> Comment.changeset(normalized)
104
             |> Repo.insert(),
105
           {1, nil} <-
106
             from(i in Issue, where: i.id == ^issue_id, update: [inc: [comments: 1]])
107
             |> Repo.update_all([]) do
108
        comment
109
      else
110
        {:error, changeset} -> Repo.rollback(changeset)
111
        {_, _} -> Repo.rollback(%Comment{})
112
      end
113
    end)
114
  end
115
116
  def update_comment(%Comment{} = comment, attrs) do
117
    normalized = for {k, v} <- attrs, into: %{}, do: {to_string(k), v}
118
    now = DateTime.utc_now() |> DateTime.truncate(:second)
119
    normalized = Map.put(normalized, "updated_at", now)
120
121
    comment
122
    |> Comment.changeset(normalized)
123
    |> Repo.update()
124
  end
125
126
  def delete_comment(%Comment{} = comment) do
127
    Repo.transaction(fn ->
128
      issue_id = comment.issue_id
129
130
      with {:ok, %Comment{}} <- Repo.delete(comment),
131
           {1, nil} <-
132
             from(i in Issue, where: i.id == ^issue_id, update: [inc: [comments: -1]])
133
             |> Repo.update_all([]) do
134
        :ok
135
      else
136
        {:error, changeset} -> Repo.rollback(changeset)
137
        {_, _} -> Repo.rollback(:ok)
138
      end
139
    end)
140
  end
141
81 142
  defp next_issue_number do
82 143
    case Repo.aggregate(Issue, :max, :number) do
83 144
      nil -> 1
lib/openagents/issues/comment.ex added +22

@@ -0,0 +1,22 @@

1
defmodule OpenAgents.Issues.Comment do
2
  use Ecto.Schema
3
  import Ecto.Changeset
4
5
  alias OpenAgents.Issues.Issue
6
7
  schema "comments" do
8
    field :body, :string
9
    field :user, :map
10
    field :created_at, :utc_datetime
11
    field :updated_at, :utc_datetime
12
13
    belongs_to :issue, Issue
14
  end
15
16
  @doc false
17
  def changeset(comment, attrs) do
18
    comment
19
    |> cast(attrs, [:body, :user, :issue_id, :created_at, :updated_at])
20
    |> validate_required([:body, :issue_id, :created_at, :updated_at])
21
  end
22
end
lib/openagents_web/controllers/comment_controller.ex added +94

@@ -0,0 +1,94 @@

1
defmodule OpenAgentsWeb.CommentController do
2
  use OpenAgentsWeb, :controller
3
4
  alias OpenAgents.Issues
5
  alias OpenAgents.Issues.Comment
6
7
  def index(conn, %{
8
        "owner" => _owner,
9
        "repo" => _repo,
10
        "issue_number" => issue_number
11
      }) do
12
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
13
    comments = Issues.list_comments(issue.id)
14
    render(conn, :index, comments: comments)
15
  rescue
16
    Ecto.NoResultsError ->
17
      conn
18
      |> put_status(:not_found)
19
      |> json(%{message: "Not Found"})
20
  end
21
22
  def create(conn, %{
23
        "owner" => _owner,
24
        "repo" => _repo,
25
        "issue_number" => issue_number
26
      } = params) do
27
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
28
29
    case Issues.create_comment(Map.put(params, :issue_id, issue.id)) do
30
      {:ok, %Comment{} = comment} ->
31
        conn
32
        |> put_status(:created)
33
        |> render(:show, comment: comment)
34
35
      {:error, %Ecto.Changeset{} = changeset} ->
36
        conn
37
        |> put_status(:unprocessable_entity)
38
        |> render(:error, changeset: changeset)
39
    end
40
  rescue
41
    Ecto.NoResultsError ->
42
      conn
43
      |> put_status(:not_found)
44
      |> json(%{message: "Not Found"})
45
  end
46
47
  def show(conn, %{"owner" => _owner, "repo" => _repo, "id" => id}) do
48
    comment = Issues.get_comment!(String.to_integer(id))
49
    render(conn, :show, comment: comment)
50
  rescue
51
    Ecto.NoResultsError ->
52
      conn
53
      |> put_status(:not_found)
54
      |> json(%{message: "Not Found"})
55
  end
56
57
  def update(conn, %{"owner" => _owner, "repo" => _repo, "id" => id} = params) do
58
    comment = Issues.get_comment!(String.to_integer(id))
59
60
    case Issues.update_comment(comment, params) do
61
      {:ok, %Comment{} = comment} ->
62
        render(conn, :show, comment: comment)
63
64
      {:error, %Ecto.Changeset{} = changeset} ->
65
        conn
66
        |> put_status(:unprocessable_entity)
67
        |> render(:error, changeset: changeset)
68
    end
69
  rescue
70
    Ecto.NoResultsError ->
71
      conn
72
      |> put_status(:not_found)
73
      |> json(%{message: "Not Found"})
74
  end
75
76
  def delete(conn, %{"owner" => _owner, "repo" => _repo, "id" => id}) do
77
    comment = Issues.get_comment!(String.to_integer(id))
78
79
    case Issues.delete_comment(comment) do
80
      {:ok, :ok} ->
81
        send_resp(conn, :no_content, "")
82
83
      {:error, _} ->
84
        conn
85
        |> put_status(:unprocessable_entity)
86
        |> json(%{message: "Could not delete comment"})
87
    end
88
  rescue
89
    Ecto.NoResultsError ->
90
      conn
91
      |> put_status(:not_found)
92
      |> json(%{message: "Not Found"})
93
  end
94
end
lib/openagents_web/controllers/comment_json.ex added +34

@@ -0,0 +1,34 @@

1
defmodule OpenAgentsWeb.CommentJSON do
2
  @moduledoc """
3
  Renders GitHub-compatible issue comment JSON.
4
  """
5
6
  def render("index.json", %{comments: comments}) do
7
    %{comments: Enum.map(comments, &comment_json/1)}
8
  end
9
10
  def render("show.json", %{comment: comment}) do
11
    comment_json(comment)
12
  end
13
14
  def render("error.json", %{changeset: changeset}) do
15
    %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
16
  end
17
18
  defp comment_json(comment) do
19
    %{
20
      id: comment.id,
21
      node_id: "IC_#{comment.id}",
22
      body: comment.body,
23
      user: comment.user,
24
      created_at: comment.created_at,
25
      updated_at: comment.updated_at
26
    }
27
  end
28
29
  defp translate_error({msg, opts}) do
30
    Regex.replace(~r/%{(\w+)}/, msg, fn _, key ->
31
      to_string(Keyword.get(opts, String.to_existing_atom(key), key))
32
    end)
33
  end
34
end
lib/openagents_web/router.ex modified +6

@@ -26,6 +26,12 @@ defmodule OpenAgentsWeb.Router do

26 26
    resources "/repos/:owner/:repo/issues", IssueController,
27 27
      only: [:index, :create, :show, :update],
28 28
      param: "issue_number"
29
30
    resources "/repos/:owner/:repo/issues/:issue_number/comments", CommentController,
31
      only: [:index, :create]
32
33
    resources "/repos/:owner/:repo/issues/comments", CommentController,
34
      only: [:show, :update, :delete]
29 35
  end
30 36
31 37
  # Enable LiveDashboard and Swoosh mailbox preview in development
priv/repo/migrations/20260819202444_create_comments.exs added +13

@@ -0,0 +1,13 @@

1
defmodule OpenAgents.Repo.Migrations.CreateComments do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:comments) do
6
      add :body, :text
7
      add :issue_id, references(:issues, on_delete: :delete_all), null: false
8
      add :user, :map
9
      add :created_at, :utc_datetime
10
      add :updated_at, :utc_datetime
11
    end
12
  end
13
end
test/openagents_web/controllers/comment_controller_test.exs added +73

@@ -0,0 +1,73 @@

1
defmodule OpenAgentsWeb.CommentControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  alias OpenAgents.Issues
5
6
  setup do
7
    {:ok, issue} = Issues.create_issue(%{title: "Comment target"})
8
    %{issue: issue}
9
  end
10
11
  test "GET /api/v3/repos/:owner/:repo/issues/:issue_number/comments lists comments", %{
12
    conn: conn,
13
    issue: issue
14
  } do
15
    Issues.create_comment(%{issue_id: issue.id, body: "First comment"})
16
17
    conn =
18
      get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/comments")
19
20
    assert %{"comments" => [comment]} = json_response(conn, 200)
21
    assert comment["body"] == "First comment"
22
  end
23
24
  test "POST /api/v3/repos/:owner/:repo/issues/:issue_number/comments creates a comment", %{
25
    conn: conn,
26
    issue: issue
27
  } do
28
    conn =
29
      post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/comments", %{
30
        body: "New comment"
31
      })
32
33
    assert %{"body" => "New comment"} = json_response(conn, 201)
34
  end
35
36
  test "GET /api/v3/repos/:owner/:repo/issues/comments/:id returns a comment", %{
37
    conn: conn,
38
    issue: issue
39
  } do
40
    {:ok, comment} = Issues.create_comment(%{issue_id: issue.id, body: "Show me"})
41
42
    conn =
43
      get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/comments/#{comment.id}")
44
45
    assert %{"body" => "Show me"} = json_response(conn, 200)
46
  end
47
48
  test "PATCH /api/v3/repos/:owner/:repo/issues/comments/:id updates a comment", %{
49
    conn: conn,
50
    issue: issue
51
  } do
52
    {:ok, comment} = Issues.create_comment(%{issue_id: issue.id, body: "Before"})
53
54
    conn =
55
      patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/comments/#{comment.id}", %{
56
        body: "After"
57
      })
58
59
    assert %{"body" => "After"} = json_response(conn, 200)
60
  end
61
62
  test "DELETE /api/v3/repos/:owner/:repo/issues/comments/:id removes a comment", %{
63
    conn: conn,
64
    issue: issue
65
  } do
66
    {:ok, comment} = Issues.create_comment(%{issue_id: issue.id, body: "Delete me"})
67
68
    conn =
69
      delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/comments/#{comment.id}")
70
71
    assert response(conn, 204)
72
  end
73
end

This page updates live while a promote is in flight · changelog