Add issue CRUD endpoints and TDD tests.

4cf8bb7fea87 · AtlantisPleb · · parent e91c1e285574

Add issue CRUD endpoints and TDD tests.

Sets the Postgres socket to /tmp and the current user, creates the
issues schema, migration, context, and JSON controller, and adds the
first issue CRUD tests. All issue controller 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 config/dev.exs
  • modified config/test.exs
  • added lib/openagents/issues.ex
  • added lib/openagents/issues/issue.ex
  • added lib/openagents_web/controllers/issue_controller.ex
  • added lib/openagents_web/controllers/issue_json.ex
  • modified lib/openagents_web/router.ex
  • added priv/repo/migrations/20260819202148_create_issues.exs
  • added test/openagents_web/controllers/issue_controller_test.exs

Diff

9 files changed, +375 -10

config/dev.exs modified +3 -3

@@ -2,9 +2,9 @@ import Config

2 2
3 3
# Configure your database
4 4
config :openagents, OpenAgents.Repo,
5
  username: "postgres",
6
  password: "postgres",
7
  hostname: "localhost",
5
  username: System.get_env("USER") || "christopherdavid",
6
  password: "",
7
  socket_dir: "/tmp",
8 8
  database: "openagents_dev",
9 9
  stacktrace: true,
10 10
  show_sensitive_data_on_connection_error: true,
config/test.exs modified +3 -3

@@ -6,9 +6,9 @@ import Config

6 6
# to provide built-in test partitioning in CI environment.
7 7
# Run `mix help test` for more information.
8 8
config :openagents, OpenAgents.Repo,
9
  username: "postgres",
10
  password: "postgres",
11
  hostname: "localhost",
9
  username: System.get_env("USER") || "christopherdavid",
10
  password: "",
11
  socket_dir: "/tmp",
12 12
  database: "openagents_test#{System.get_env("MIX_TEST_PARTITION")}",
13 13
  pool: Ecto.Adapters.SQL.Sandbox,
14 14
  pool_size: System.schedulers_online() * 2
lib/openagents/issues.ex added +87

@@ -0,0 +1,87 @@

1
defmodule OpenAgents.Issues do
2
  @moduledoc """
3
  The Issues context.
4
  """
5
6
  import Ecto.Query, warn: false
7
  alias OpenAgents.Repo
8
  alias OpenAgents.Issues.Issue
9
10
  def list_issues(opts \\ []) do
11
    state = Keyword.get(opts, :state, "open")
12
13
    Issue
14
    |> maybe_filter_state(state)
15
    |> order_by(desc: :inserted_at)
16
    |> Repo.all()
17
  end
18
19
  def get_issue!(id), do: Repo.get!(Issue, id)
20
21
  def get_issue_by_number!(number) when is_integer(number),
22
    do: Repo.get_by!(Issue, number: number)
23
24
  def create_issue(attrs \\ %{}) do
25
    number = next_issue_number()
26
    normalized = for {k, v} <- attrs, into: %{}, do: {to_string(k), v}
27
28
    %Issue{}
29
    |> Issue.changeset(Map.put(normalized, "number", number))
30
    |> Repo.insert()
31
  end
32
33
  def update_issue(%Issue{} = issue, attrs) do
34
    attrs = maybe_closed_attrs(issue, attrs)
35
36
    issue
37
    |> Issue.changeset(attrs)
38
    |> Repo.update()
39
  end
40
41
  def change_issue(%Issue{} = issue, attrs \\ %{}) do
42
    Issue.changeset(issue, attrs)
43
  end
44
45
  defp maybe_closed_attrs(issue, %{"state" => "closed"} = attrs) do
46
    if issue.state == "open" do
47
      attrs
48
      |> Map.put("closed_at", DateTime.utc_now() |> DateTime.truncate(:second))
49
      |> Map.put_new("state_reason", "completed")
50
    else
51
      attrs
52
    end
53
  end
54
55
  defp maybe_closed_attrs(_issue, %{state: "closed"} = attrs) do
56
    if is_nil(attrs[:closed_at]) do
57
      Map.put(attrs, :closed_at, DateTime.utc_now() |> DateTime.truncate(:second))
58
    else
59
      attrs
60
    end
61
    |> Map.put_new(:state_reason, "completed")
62
  end
63
64
  defp maybe_closed_attrs(_issue, %{"state" => "open"} = attrs) do
65
    attrs
66
    |> Map.put("closed_at", nil)
67
    |> Map.put("state_reason", nil)
68
  end
69
70
  defp maybe_closed_attrs(_issue, %{state: "open"} = attrs) do
71
    attrs
72
    |> Map.put(:closed_at, nil)
73
    |> Map.put(:state_reason, nil)
74
  end
75
76
  defp maybe_closed_attrs(_issue, attrs), do: attrs
77
78
  defp maybe_filter_state(query, "all"), do: query
79
  defp maybe_filter_state(query, state), do: where(query, state: ^state)
80
81
  defp next_issue_number do
82
    case Repo.aggregate(Issue, :max, :number) do
83
      nil -> 1
84
      n -> n + 1
85
    end
86
  end
87
end
lib/openagents/issues/issue.ex added +42

@@ -0,0 +1,42 @@

1
defmodule OpenAgents.Issues.Issue do
2
  use Ecto.Schema
3
  import Ecto.Changeset
4
5
  schema "issues" do
6
    field :number, :integer
7
    field :title, :string
8
    field :body, :string
9
    field :state, :string, default: "open"
10
    field :state_reason, :string
11
    field :locked, :boolean, default: false
12
    field :locked_reason, :string
13
    field :closed_at, :utc_datetime
14
    field :comments, :integer, default: 0
15
    field :labels, {:array, :map}, default: []
16
    field :assignees, {:array, :map}, default: []
17
    field :milestone, :map
18
    field :user, :map
19
    timestamps(type: :utc_datetime)
20
  end
21
22
  @doc false
23
  def changeset(issue, attrs) do
24
    issue
25
    |> cast(attrs, [
26
      :number,
27
      :title,
28
      :body,
29
      :state,
30
      :state_reason,
31
      :locked,
32
      :locked_reason,
33
      :closed_at,
34
      :comments,
35
      :labels,
36
      :assignees,
37
      :milestone,
38
      :user
39
    ])
40
    |> validate_required([:title, :number])
41
  end
42
end
lib/openagents_web/controllers/issue_controller.ex added +63

@@ -0,0 +1,63 @@

1
defmodule OpenAgentsWeb.IssueController do
2
  use OpenAgentsWeb, :controller
3
4
  alias OpenAgents.Issues
5
  alias OpenAgents.Issues.Issue
6
7
  def index(conn, %{"owner" => owner, "repo" => repo} = params) do
8
    state = Map.get(params, "state", "open")
9
    issues = Issues.list_issues(state: state)
10
    render(conn, :index, issues: issues, owner: owner, repo: repo)
11
  end
12
13
  def create(conn, %{"owner" => owner, "repo" => repo} = params) do
14
    case Issues.create_issue(params) do
15
      {:ok, %Issue{} = issue} ->
16
        conn
17
        |> put_status(:created)
18
        |> render(:show, issue: issue, owner: owner, repo: repo)
19
20
      {:error, %Ecto.Changeset{} = changeset} ->
21
        conn
22
        |> put_status(:unprocessable_entity)
23
        |> render(:error, changeset: changeset)
24
    end
25
  end
26
27
  def show(conn, %{
28
        "owner" => owner,
29
        "repo" => repo,
30
        "issue_number" => issue_number
31
      }) do
32
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
33
    render(conn, :show, issue: issue, owner: owner, repo: repo)
34
  rescue
35
    Ecto.NoResultsError ->
36
      conn
37
      |> put_status(:not_found)
38
      |> json(%{message: "Not Found"})
39
  end
40
41
  def update(conn, %{
42
        "owner" => owner,
43
        "repo" => repo,
44
        "issue_number" => issue_number
45
      } = params) do
46
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
47
48
    case Issues.update_issue(issue, params) do
49
      {:ok, %Issue{} = issue} ->
50
        render(conn, :show, issue: issue, owner: owner, repo: repo)
51
52
      {:error, %Ecto.Changeset{} = changeset} ->
53
        conn
54
        |> put_status(:unprocessable_entity)
55
        |> render(:error, changeset: changeset)
56
    end
57
  rescue
58
    Ecto.NoResultsError ->
59
      conn
60
      |> put_status(:not_found)
61
      |> json(%{message: "Not Found"})
62
  end
63
end
lib/openagents_web/controllers/issue_json.ex added +49

@@ -0,0 +1,49 @@

1
defmodule OpenAgentsWeb.IssueJSON do
2
  @moduledoc """
3
  Renders GitHub-compatible issue JSON.
4
  """
5
6
  def render("index.json", %{issues: issues} = assigns) do
7
    %{issues: Enum.map(issues, &issue_json(&1, assigns))}
8
  end
9
10
  def render("show.json", %{issue: issue} = assigns) do
11
    issue_json(issue, assigns)
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 issue_json(issue, assigns) do
19
    owner = Map.get(assigns, :owner, "OpenAgents")
20
    repo = Map.get(assigns, :repo, "openagents")
21
22
    %{
23
      id: issue.id,
24
      node_id: "I_#{issue.id}",
25
      number: issue.number,
26
      title: issue.title,
27
      body: issue.body,
28
      state: issue.state,
29
      state_reason: issue.state_reason,
30
      locked: issue.locked,
31
      comments: issue.comments,
32
      labels: issue.labels || [],
33
      assignees: issue.assignees || [],
34
      milestone: issue.milestone,
35
      user: issue.user,
36
      created_at: issue.inserted_at,
37
      updated_at: issue.updated_at,
38
      closed_at: issue.closed_at,
39
      html_url: "https://openagents.com/#{owner}/#{repo}/issues/#{issue.number}",
40
      url: "https://openagents.com/api/v3/repos/#{owner}/#{repo}/issues/#{issue.number}"
41
    }
42
  end
43
44
  defp translate_error({msg, opts}) do
45
    Regex.replace(~r/%{(\w+)}/, msg, fn _, key ->
46
      to_string(Keyword.get(opts, String.to_existing_atom(key), key))
47
    end)
48
  end
49
end
lib/openagents_web/router.ex modified +7 -4

@@ -20,10 +20,13 @@ defmodule OpenAgentsWeb.Router do

20 20
    get "/", PageController, :home
21 21
  end
22 22
23
  # Other scopes may use custom stacks.
24
  # scope "/api", OpenAgentsWeb do
25
  #   pipe_through :api
26
  # end
23
  scope "/api/v3", OpenAgentsWeb do
24
    pipe_through :api
25
26
    resources "/repos/:owner/:repo/issues", IssueController,
27
      only: [:index, :create, :show, :update],
28
      param: "issue_number"
29
  end
27 30
28 31
  # Enable LiveDashboard and Swoosh mailbox preview in development
29 32
  if Application.compile_env(:openagents, :dev_routes) do
priv/repo/migrations/20260819202148_create_issues.exs added +24

@@ -0,0 +1,24 @@

1
defmodule OpenAgents.Repo.Migrations.CreateIssues do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:issues) do
6
      add :number, :integer, null: false
7
      add :title, :string, null: false
8
      add :body, :text
9
      add :state, :string, default: "open"
10
      add :state_reason, :string
11
      add :locked, :boolean, default: false
12
      add :locked_reason, :string
13
      add :closed_at, :utc_datetime
14
      add :comments, :integer, default: 0
15
      add :labels, {:array, :map}, default: []
16
      add :assignees, {:array, :map}, default: []
17
      add :milestone, :map
18
      add :user, :map
19
      timestamps(type: :utc_datetime)
20
    end
21
22
    create unique_index(:issues, [:number])
23
  end
24
end
test/openagents_web/controllers/issue_controller_test.exs added +97

@@ -0,0 +1,97 @@

1
defmodule OpenAgentsWeb.IssueControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  alias OpenAgents.Issues
5
6
  describe "index" do
7
    test "GET /api/v3/repos/:owner/:repo/issues lists open issues by default", %{
8
      conn: conn
9
    } do
10
      {:ok, _issue} = Issues.create_issue(%{title: "First issue"})
11
12
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues")
13
14
      assert %{"issues" => [issue | _]} = json_response(conn, 200)
15
      assert issue["title"] == "First issue"
16
      assert issue["state"] == "open"
17
    end
18
19
    test "GET /api/v3/repos/:owner/:repo/issues filters by state", %{conn: conn} do
20
      {:ok, _open_issue} = Issues.create_issue(%{title: "Open issue"})
21
      {:ok, _closed_issue} = Issues.create_issue(%{title: "Closed issue", state: "closed"})
22
23
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues?state=closed")
24
25
      assert %{"issues" => [issue]} = json_response(conn, 200)
26
      assert issue["title"] == "Closed issue"
27
      assert issue["state"] == "closed"
28
    end
29
  end
30
31
  describe "create" do
32
    test "POST /api/v3/repos/:owner/:repo/issues creates an issue", %{conn: conn} do
33
      conn =
34
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues", %{
35
          title: "New issue",
36
          body: "A description"
37
        })
38
39
      assert %{
40
               "number" => _,
41
               "title" => "New issue",
42
               "body" => "A description",
43
               "state" => "open"
44
             } = json_response(conn, 201)
45
    end
46
47
    test "POST /api/v3/repos/:owner/:repo/issues returns 422 for missing title", %{
48
      conn: conn
49
    } do
50
      conn =
51
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues", %{body: "No title"})
52
53
      assert json_response(conn, 422)["errors"] != %{}
54
    end
55
  end
56
57
  describe "show" do
58
    test "GET /api/v3/repos/:owner/:repo/issues/:issue_number returns the issue", %{
59
      conn: conn
60
    } do
61
      {:ok, issue} = Issues.create_issue(%{title: "Show me"})
62
63
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}")
64
65
      assert %{
66
               "title" => "Show me",
67
               "number" => n
68
             } = json_response(conn, 200)
69
70
      assert n == issue.number
71
    end
72
73
    test "GET /api/v3/repos/:owner/:repo/issues/:issue_number returns 404 when missing", %{
74
      conn: conn
75
    } do
76
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999")
77
78
      assert json_response(conn, 404)
79
    end
80
  end
81
82
  describe "update" do
83
    test "PATCH /api/v3/repos/:owner/:repo/issues/:issue_number closes an issue", %{
84
      conn: conn
85
    } do
86
      {:ok, issue} = Issues.create_issue(%{title: "Close me"})
87
88
      conn =
89
        patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}", %{
90
          state: "closed"
91
        })
92
93
      assert %{"state" => "closed", "number" => n} = json_response(conn, 200)
94
      assert n == issue.number
95
    end
96
  end
97
end

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