Fix milestone counts and project field setup

50b882b8d909 · AtlantisPleb · · parent 62f07cb5469f

Fix milestone counts and project field setup

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 docs/2026-08-21-issue-project-triage-runbook.md
  • modified lib/openagents/milestones.ex
  • modified lib/openagents/milestones/milestone.ex
  • modified lib/openagents_web/controllers/milestone_json.ex
  • modified lib/openagents_web/controllers/project_controller.ex
  • modified lib/openagents_web/router.ex
  • modified test/openagents_web/controllers/milestone_controller_test.exs
  • modified test/openagents_web/controllers/project_controller_test.exs

Diff

8 files changed, +173 -24

docs/2026-08-21-issue-project-triage-runbook.md modified +19 -6

@@ -212,17 +212,30 @@ Finally, create a starting milestone and project so triaged work has a

212 212
destination:
213 213
214 214
```sh
215
curl -s -X POST "$BASE/../milestones" -H "Authorization: Bearer $TOKEN" \
215
REPOSITORY_API=https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.com
216
USER_LOGIN=$(curl -fsS "https://openagents.com/api/v3/user" \
217
  -H "Authorization: Bearer $TOKEN" | jq -r '.login')
218
219
curl -fsS -X POST "$REPOSITORY_API/milestones" \
220
  -H "Authorization: Bearer $TOKEN" \
216 221
  -H "Content-Type: application/json" \
217 222
  -d '{"title": "Backlog hygiene", "description": "First triage sweep"}'
218 223
219
curl -s -X POST "https://openagents.com/api/v3/OpenAgentsInc/projectsV2" \
220
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
221
  -d '{"title": "openagents.com roadmap"}'
224
PROJECT_NUMBER=$(curl -fsS -X POST \
225
  "https://openagents.com/api/v3/$USER_LOGIN/projectsV2" \
226
  -H "Authorization: Bearer $TOKEN" \
227
  -H "Content-Type: application/json" \
228
  -d '{"title": "openagents.com roadmap"}' | jq -r '.number')
229
230
curl -fsS -X POST \
231
  "https://openagents.com/api/v3/users/$USER_LOGIN/projectsV2/$PROJECT_NUMBER/fields" \
232
  -H "Authorization: Bearer $TOKEN" \
233
  -H "Content-Type: application/json" \
234
  -d '{"name": "Status", "data_type": "single_select", "options": {"values": ["To Do", "In Progress", "Done"]}}'
222 235
```
223 236
224
Add the Status field values ("To Do", "In Progress", "Done") through the
225
project fields API, then pin high-signal issues to the board.
237
The project belongs to the GitHub login tied to your token. Add high-signal
238
issues to the board and set their `Status` value through the project items API.
226 239
227 240
### Label vocabulary
228 241
lib/openagents/milestones.ex modified +47 -14

@@ -6,6 +6,7 @@ defmodule OpenAgents.Milestones do

6 6
  import Ecto.Query, warn: false
7 7
  alias OpenAgents.Accounts.User
8 8
  alias OpenAgents.Analytics
9
  alias OpenAgents.Issues.Issue
9 10
  alias OpenAgents.Repo
10 11
  alias OpenAgents.Repositories
11 12
  alias OpenAgents.Repositories.Repository

@@ -27,6 +28,7 @@ defmodule OpenAgents.Milestones do

27 28
    Milestone
28 29
    |> where(repository_id: ^repository_id)
29 30
    |> order_by(asc: :number)
31
    |> with_issue_counts()
30 32
    |> Repo.all()
31 33
  end
32 34

@@ -47,25 +49,35 @@ defmodule OpenAgents.Milestones do

47 49
  def get_milestone!(id), do: get_milestone!(Repositories.initial_repository!(), id)
48 50
49 51
  def get_milestone!(%Repository{id: repository_id}, id) do
50
    Repo.get_by!(Milestone, id: id, repository_id: repository_id)
52
    Milestone
53
    |> where(id: ^id, repository_id: ^repository_id)
54
    |> with_issue_counts()
55
    |> Repo.one!()
51 56
  end
52 57
53 58
  def get_milestone_by_number!(number) when is_integer(number),
54 59
    do: get_milestone_by_number!(Repositories.initial_repository!(), number)
55 60
56
  def get_milestone_by_number!(%Repository{id: repository_id}, number) when is_integer(number),
57
    do: Repo.get_by!(Milestone, repository_id: repository_id, number: number)
61
  def get_milestone_by_number!(%Repository{id: repository_id}, number) when is_integer(number) do
62
    Milestone
63
    |> where(repository_id: ^repository_id, number: ^number)
64
    |> with_issue_counts()
65
    |> Repo.one!()
66
  end
58 67
59 68
  def get_milestone_by_path!(owner, repository_name, number) when is_integer(number) do
60
    Repo.one!(
61
      from milestone in Milestone,
62
        join: repository in Repository,
63
        on: repository.id == milestone.repository_id,
64
        where:
65
          repository.owner_key == ^String.downcase(owner) and
66
            repository.name_key == ^String.downcase(repository_name) and
67
            repository.visibility == "public" and milestone.number == ^number
69
    Milestone
70
    |> join(:inner, [milestone], repository in Repository,
71
      on: repository.id == milestone.repository_id
72
    )
73
    |> where(
74
      [milestone, repository],
75
      repository.owner_key == ^String.downcase(owner) and
76
        repository.name_key == ^String.downcase(repository_name) and
77
        repository.visibility == "public" and milestone.number == ^number
68 78
    )
79
    |> with_issue_counts()
80
    |> Repo.one!()
69 81
  end
70 82
71 83
  @doc """

@@ -169,9 +181,17 @@ defmodule OpenAgents.Milestones do

169 181
  def update_milestone(%Milestone{} = milestone, attrs) do
170 182
    attrs = Map.drop(attrs, [:repository_id, "repository_id", :number, "number"])
171 183
172
    milestone
173
    |> Milestone.changeset(attrs)
174
    |> Repo.update()
184
    case milestone |> Milestone.changeset(attrs) |> Repo.update() do
185
      {:ok, updated} ->
186
        {:ok,
187
         get_milestone!(
188
           %Repository{id: updated.repository_id},
189
           updated.id
190
         )}
191
192
      result ->
193
        result
194
    end
175 195
  end
176 196
177 197
  @doc """

@@ -202,4 +222,17 @@ defmodule OpenAgents.Milestones do

202 222
  def change_milestone(%Milestone{} = milestone, attrs \\ %{}) do
203 223
    Milestone.changeset(milestone, attrs)
204 224
  end
225
226
  defp with_issue_counts(query) do
227
    from milestone in query,
228
      left_join: issue in Issue,
229
      on:
230
        issue.milestone_id == milestone.id and
231
          issue.repository_id == milestone.repository_id,
232
      group_by: milestone.id,
233
      select_merge: %{
234
        open_issues: filter(count(issue.id), issue.state == "open"),
235
        closed_issues: filter(count(issue.id), issue.state == "closed")
236
      }
237
  end
205 238
end
lib/openagents/milestones/milestone.ex modified +2

@@ -10,6 +10,8 @@ defmodule OpenAgents.Milestones.Milestone do

10 10
    field :description, :string
11 11
    field :due_on, :string
12 12
    field :number, :integer
13
    field :open_issues, :integer, virtual: true, default: 0
14
    field :closed_issues, :integer, virtual: true, default: 0
13 15
    belongs_to :repository, Repository, type: :binary_id
14 16
15 17
    timestamps(type: :utc_datetime)
lib/openagents_web/controllers/milestone_json.ex modified +2 -2

@@ -26,8 +26,8 @@ defmodule OpenAgentsWeb.MilestoneJSON do

26 26
      state: milestone.state,
27 27
      description: milestone.description,
28 28
      due_on: milestone.due_on,
29
      open_issues: 0,
30
      closed_issues: 0,
29
      open_issues: milestone.open_issues,
30
      closed_issues: milestone.closed_issues,
31 31
      url: "https://openagents.com/api/v3/repos/#{owner}/#{repo}/milestones/#{milestone.number}"
32 32
    }
33 33
  end
lib/openagents_web/controllers/project_controller.ex modified +32

@@ -157,6 +157,38 @@ defmodule OpenAgentsWeb.ProjectController do

157 157
      |> json(%{message: "Not Found"})
158 158
  end
159 159
160
  def create_field(
161
        conn,
162
        %{
163
          "username" => username,
164
          "project_number" => project_number
165
        } = params
166
      ) do
167
    authorize_owner!(conn.assigns.current_user, username)
168
169
    project =
170
      Projects.get_project_by_owner_and_number!(username, String.to_integer(project_number))
171
172
    attrs =
173
      params
174
      |> Map.take(["name", "data_type", "options"])
175
      |> Map.put("project_id", project.id)
176
177
    case Projects.create_project_field(attrs) do
178
      {:ok, field} ->
179
        conn
180
        |> put_status(:created)
181
        |> render(:fields, fields: [field])
182
183
      {:error, %Ecto.Changeset{} = changeset} ->
184
        conn
185
        |> put_status(:unprocessable_entity)
186
        |> render(:error, changeset: changeset)
187
    end
188
  rescue
189
    Ecto.NoResultsError -> not_found(conn)
190
  end
191
160 192
  defp cast_issue_number(number) when is_integer(number), do: {:ok, number}
161 193
162 194
  defp cast_issue_number(number) when is_binary(number) do
lib/openagents_web/router.ex modified +1

@@ -280,6 +280,7 @@ defmodule OpenAgentsWeb.Router do

280 280
    delete "/repos/:owner/:repo/milestones/:milestone_number", MilestoneController, :delete
281 281
    post "/:owner/projectsV2", ProjectController, :create
282 282
    post "/users/:username/projectsV2/:project_number/items", ProjectController, :create_item
283
    post "/users/:username/projectsV2/:project_number/fields", ProjectController, :create_field
283 284
284 285
    patch "/users/:username/projectsV2/:project_number/items/:item_id",
285 286
          ProjectController,
test/openagents_web/controllers/milestone_controller_test.exs modified +7 -2

@@ -5,6 +5,7 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

5 5
6 6
  import OpenAgents.MilestonesFixtures
7 7
8
  alias OpenAgents.Issues
8 9
  alias OpenAgents.Milestones
9 10
10 11
  describe "index" do

@@ -20,12 +21,16 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

20 21
21 22
    test "GET /api/v3/repos/:owner/:repo/milestones renders issue counts and url", %{conn: conn} do
22 23
      milestone = milestone_fixture(%{title: "v1.0"})
24
      {:ok, _open_issue} = Issues.create_issue(%{title: "Open", milestone: milestone.number})
25
26
      {:ok, _closed_issue} =
27
        Issues.create_issue(%{title: "Closed", milestone: milestone.number, state: "closed"})
23 28
24 29
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones")
25 30
26 31
      assert %{"milestones" => [rendered]} = json_response(conn, 200)
27
      assert rendered["open_issues"] == 0
28
      assert rendered["closed_issues"] == 0
32
      assert rendered["open_issues"] == 1
33
      assert rendered["closed_issues"] == 1
29 34
30 35
      assert rendered["url"] ==
31 36
               "https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.com/milestones/#{milestone.number}"
test/openagents_web/controllers/project_controller_test.exs modified +63

@@ -315,6 +315,62 @@ defmodule OpenAgentsWeb.ProjectControllerTest do

315 315
316 316
      assert json_response(conn, 404) == %{"message" => "Not Found"}
317 317
    end
318
319
    test "POST .../projectsV2/:project_number/fields creates a field", %{conn: conn} do
320
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
321
322
      conn =
323
        post(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/fields", %{
324
          name: "Status",
325
          data_type: "single_select",
326
          options: %{values: ["To Do", "In Progress", "Done"]}
327
        })
328
329
      assert %{
330
               "fields" => [
331
                 %{
332
                   "name" => "Status",
333
                   "data_type" => "single_select",
334
                   "options" => %{"values" => ["To Do", "In Progress", "Done"]}
335
                 }
336
               ]
337
             } = json_response(conn, 201)
338
339
      assert [%{name: "Status", project_id: project_id}] = Projects.list_project_fields(project)
340
      assert project_id == project.id
341
    end
342
343
    test "POST .../projectsV2/:project_number/fields returns 422 without required values", %{
344
      conn: conn
345
    } do
346
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
347
348
      conn = post(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/fields", %{})
349
350
      assert %{"errors" => errors} = json_response(conn, 422)
351
      assert Map.has_key?(errors, "name")
352
      assert Map.has_key?(errors, "data_type")
353
      assert Projects.list_project_fields(project) == []
354
    end
355
356
    test "POST .../projectsV2/:project_number/fields takes the project from the path", %{
357
      conn: conn
358
    } do
359
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
360
      other_project = project_fixture(%{title: "Other", owner: "alice"})
361
362
      conn =
363
        post(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/fields", %{
364
          name: "Status",
365
          data_type: "single_select",
366
          project_id: other_project.id
367
        })
368
369
      assert json_response(conn, 201)
370
      assert [%{project_id: project_id}] = Projects.list_project_fields(project)
371
      assert project_id == project.id
372
      assert Projects.list_project_fields(other_project) == []
373
    end
318 374
  end
319 375
320 376
  describe "owner-path authorization" do

@@ -357,6 +413,13 @@ defmodule OpenAgentsWeb.ProjectControllerTest do

357 413
             )
358 414
             |> json_response(404) == %{"message" => "Not Found"}
359 415
416
      assert post(
417
               recycle(conn),
418
               ~p"/api/v3/users/bob/projectsV2/#{project.number}/fields",
419
               %{name: "Priority", data_type: "single_select"}
420
             )
421
             |> json_response(404) == %{"message" => "Not Found"}
422
360 423
      assert Projects.get_project_item!(item.id).values == %{}
361 424
    end
362 425

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