Give projects a description and discussion notes

d3aa922fd9f8 · Devin AI · · parent 2479f73750ca

Give projects a description and discussion notes

A project now carries one Markdown `description` and a `project_notes` table holding two kinds of record: a discussion note, which its author writes and only its author edits or deletes, and an activity entry, which records a title, description, or state change in the same transaction as the change and never changes again. Notes are a separate paginated read rather than a timeline embedded in the project object, so a long-lived board accumulates decisions without bound.

`PATCH /repos/:owner/:repo/projectsV2/:project_number` accepts `title`, `description`, and `state`, and the notes routes list, create, edit, and delete records under the same repository authority. A note stores both its project and its repository, and reads carry both, so a note cannot cross a repository boundary. The board renders the description above the columns and the notes below them, subscribes to project PubSub, and rereads through the viewer's authorization boundary, so a remote CLI or API change appears without a reload.

Closes #60.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>
Closes
#60

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/github-api-issues-projects-assessment.md
  • modified lib/openagents/projects.ex
  • modified lib/openagents/projects/project.ex
  • added lib/openagents/projects/project_note.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/project_controller.ex
  • modified lib/openagents_web/controllers/project_json.ex
  • modified lib/openagents_web/live/project_show_live.ex
  • modified lib/openagents_web/router.ex
  • modified priv/docs/cli-api.md
  • modified priv/docs/projects.md
  • modified priv/docs/rest-api.md
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823043000_add_project_descriptions_and_notes.exs
  • added test/openagents/project_notes_test.exs
  • modified test/openagents_web/controllers/project_controller_test.exs
  • modified test/openagents_web/live/project_show_live_test.exs

Diff

17 files changed, +1568 -22

docs/github-api-issues-projects-assessment.md modified +50 -6

@@ -33,15 +33,50 @@ locks, dependencies, sub-issues, and suggestion APIs are not implemented.

33 33
| Method | Path |
34 34
| --- | --- |
35 35
| `GET, POST` | `/repos/{owner}/{repo}/projectsV2` |
36
| `GET` | `/repos/{owner}/{repo}/projectsV2/{project_number}` |
36
| `GET, PATCH` | `/repos/{owner}/{repo}/projectsV2/{project_number}` |
37
| `GET, POST` | `/repos/{owner}/{repo}/projectsV2/{project_number}/notes` |
38
| `PATCH, DELETE` | `/repos/{owner}/{repo}/projectsV2/{project_number}/notes/{note_id}` |
37 39
| `GET, POST` | `/repos/{owner}/{repo}/projectsV2/{project_number}/items` |
38 40
| `PATCH` | `/repos/{owner}/{repo}/projectsV2/{project_number}/items/{item_id}` |
39 41
| `GET, POST` | `/repos/{owner}/{repo}/projectsV2/{project_number}/fields` |
40 42
41 43
The project-creation endpoint is an OpenAgents extension because the comparable
42 44
GitHub Projects V2 creation workflow is not supplied by the assessed REST
43
surface. Project update/delete, item delete/read, field mutation, views,
44
ordering, draft items, and organization projects remain unimplemented.
45
surface. Project delete, item delete/read, field mutation, views, ordering,
46
draft items, and organization projects remain unimplemented.
47
48
### Differences from GitHub Projects V2
49
50
These are deliberate divergences, not gaps waiting on parity work:
51
52
- A project carries one Markdown `description`. GitHub Projects V2 splits
53
  project prose into a short description and a separate README, and exposes both
54
  only through GraphQL. One canonical field keeps the API, the CLI, and the board
55
  describing the same thing.
56
- Project notes are an OpenAgents surface with no GitHub REST equivalent. They
57
  hold project-wide context — operating assumptions, triage decisions,
58
  provider-order changes, paused lanes — that would otherwise be filed as an
59
  issue comment on whichever issue happened to be open at the time.
60
- Discussion notes and activity entries share one table and one paginated read,
61
  distinguished by `kind`. An activity entry is immutable and is written in the
62
  same transaction as the change it records, so the log cannot describe an update
63
  that did not commit. GitHub keeps its equivalent record in a separate event
64
  API.
65
- Edit and delete authority for a discussion note is its author, not repository
66
  write access. Repository membership stays the authority boundary for the
67
  project itself.
68
- Notes are never embedded in the project object. A long-lived board accumulates
69
  decisions without bound, so the timeline is a separate paginated read.
70
71
### Toward a Linear-compatible shape
72
73
The longer-term direction is a Linear-shaped tracker, as recorded in
74
`docs/2026-08-20-linear-design-github-shape.md`. Project descriptions and notes
75
move that way without breaking the GitHub-shaped subset: a description maps onto
76
a Linear project's summary, and notes map onto project updates, which Linear
77
treats as first-class project-level records rather than comments on an issue. The
78
names stay GitHub-shaped where a GitHub client reads them, and the semantics stay
79
compatible with where the tracker is going.
45 80
46 81
## CLI access
47 82

@@ -76,9 +111,16 @@ These are current measured behaviors:

76 111
  issue from another repository without weakening project write authority.
77 112
- Issue and milestone numbers are repository-local. Project numbers are also
78 113
  repository-local for the repository-shaped LiveView surface.
79
- Project list, show, item, update-item, and field actions resolve the repository
80
  from the route. Public repositories allow anonymous reads. Private reads and
81
  every write require membership in that repository.
114
- Project list, show, update, item, update-item, field, and note actions resolve
115
  the repository from the route. Public repositories allow anonymous reads.
116
  Private reads and every write require membership in that repository.
117
- A project update accepts only `title`, `description`, and `state`, and rejects
118
  a `state` other than `open` or `closed` with `422`. Repository and owner
119
  overrides in the request body are dropped.
120
- A project note stores both its project and its repository, and reads carry both
121
  in the query, so a note cannot be read or written across a repository
122
  boundary. Editing or deleting a note requires authorship: another member with
123
  write access receives `403`. An activity entry receives `403` for both.
82 124
- Item creation accepts either the legacy repository-local `issue_number` or
83 125
  an `issue` object with `owner`, `repo`, and `number`. Cross-repository adds
84 126
  require write access to the project repository and read access to the source

@@ -121,5 +163,7 @@ those authority boundaries.

121 163
- `test/openagents_web/controllers/issue_assignee_controller_test.exs`
122 164
- `test/openagents_web/controllers/milestone_controller_test.exs`
123 165
- `test/openagents_web/controllers/project_controller_test.exs`
166
- `test/openagents/project_notes_test.exs`
167
- `test/openagents_web/live/project_show_live_test.exs`
124 168
- `test/openagents_web/controllers/repository_isolation_controller_test.exs`
125 169
- `test/openagents/repositories_test.exs`
lib/openagents/projects.ex modified +220 -4

@@ -9,6 +9,7 @@ defmodule OpenAgents.Projects do

9 9
  alias OpenAgents.ProjectFields.ProjectField
10 10
  alias OpenAgents.ProjectItems.ProjectItem
11 11
  alias OpenAgents.Projects.Project
12
  alias OpenAgents.Projects.ProjectNote
12 13
  alias OpenAgents.Repo
13 14
  alias OpenAgents.Repositories
14 15
  alias OpenAgents.Repositories.Repository

@@ -170,7 +171,19 @@ defmodule OpenAgents.Projects do

170 171
  defp actor_distinct_id(nil), do: Analytics.system_distinct_id("api")
171 172
  defp actor_distinct_id(%User{} = actor), do: Analytics.distinct_id(actor)
172 173
173
  def update_project(%Project{} = project, attrs) do
174
  def update_project(%Project{} = project, attrs), do: update_project(project, attrs, nil)
175
176
  @doc """
177
  Updates `project` and records what changed in its activity log.
178
179
  Every accepted change to the title, description, or state appends one
180
  immutable `"activity"` note, so a board carries the decision record even
181
  when nobody wrote discussion around it. The note is written in the same
182
  transaction as the update: an activity entry for a change that did not
183
  commit would be a false record.
184
  """
185
  def update_project(%Project{} = project, attrs, actor)
186
      when is_nil(actor) or is_struct(actor, User) do
174 187
    attrs = attrs |> to_string_map() |> Map.drop(["repository_id", "owner_user_id"])
175 188
176 189
    attrs =

@@ -180,9 +193,24 @@ defmodule OpenAgents.Projects do

180 193
        attrs
181 194
      end
182 195
183
    project
184
    |> Project.changeset(attrs)
185
    |> Repo.update()
196
    changeset = Project.changeset(project, attrs)
197
198
    Repo.transaction(fn ->
199
      case Repo.update(changeset) do
200
        {:ok, updated} ->
201
          Enum.each(activity_bodies(changeset), fn body ->
202
            case insert_note(updated, %{"body" => body, "kind" => "activity"}, actor) do
203
              {:ok, _note} -> :ok
204
              {:error, note_changeset} -> Repo.rollback(note_changeset)
205
            end
206
          end)
207
208
          updated
209
210
        {:error, changeset} ->
211
          Repo.rollback(changeset)
212
      end
213
    end)
186 214
    |> case do
187 215
      {:ok, project} ->
188 216
        Repositories.broadcast_projects(project.repository_id)

@@ -193,6 +221,29 @@ defmodule OpenAgents.Projects do

193 221
    end
194 222
  end
195 223
224
  # One line per changed property, in a fixed order so a reader of the log sees
225
  # the same shape every time. Only these three are worth a record: the rest of
226
  # a project's columns are its identity, not its operating state.
227
  defp activity_bodies(changeset) do
228
    Enum.flat_map(
229
      [
230
        {:state, &"Changed the state to `#{&1}`."},
231
        {:title, &"Changed the title to #{inspect(&1)}."},
232
        {:description, &describe_description_change/1}
233
      ],
234
      fn {field, describe} ->
235
        case Ecto.Changeset.fetch_change(changeset, field) do
236
          {:ok, value} -> [describe.(value)]
237
          :error -> []
238
        end
239
      end
240
    )
241
  end
242
243
  defp describe_description_change(nil), do: "Removed the description."
244
  defp describe_description_change(""), do: "Removed the description."
245
  defp describe_description_change(_value), do: "Updated the description."
246
196 247
  def delete_project(%Project{} = project) do
197 248
    Repo.delete(project)
198 249
    |> case do

@@ -215,6 +266,171 @@ defmodule OpenAgents.Projects do

215 266
    Project.changeset(project, attrs)
216 267
  end
217 268
269
  @notes_per_page 20
270
271
  @doc "How many project notes one page carries."
272
  def notes_per_page, do: @notes_per_page
273
274
  @doc """
275
  One page of `project`'s notes, newest first, with the unpaginated total.
276
277
  A project object never embeds its timeline: a long-lived board accumulates
278
  decisions without bound, so the notes are a separate paginated read. Page 1
279
  is the most recent `notes_per_page/0` entries, which is what an operator
280
  opening a board wants first.
281
282
  Supported options: `:page` and `:kind`. `:kind` takes `"note"` for
283
  discussion, `"activity"` for the immutable change record, or `"all"`, the
284
  default.
285
286
  Authority is the project's repository, which the caller has already resolved
287
  through `OpenAgents.Repositories.get_visible_by_path!/3` or its writable
288
  counterpart. Notes carry no separate visibility of their own.
289
  """
290
  def list_project_notes_page(%Project{} = project, opts \\ []) when is_list(opts) do
291
    query = project_notes_query(project, opts)
292
    page = max(parse_page(opts[:page]), 1)
293
294
    notes =
295
      query
296
      |> order_by([note], desc: note.inserted_at, desc: note.id)
297
      |> limit(@notes_per_page)
298
      |> offset(^((page - 1) * @notes_per_page))
299
      |> Repo.all()
300
      |> Repo.preload(:author_user)
301
302
    {notes, Repo.aggregate(query, :count)}
303
  end
304
305
  @doc "How many notes `project` carries, with the same filters."
306
  def count_project_notes(%Project{} = project, opts \\ []) when is_list(opts),
307
    do: project |> project_notes_query(opts) |> Repo.aggregate(:count)
308
309
  @doc "One note of `project`, by id."
310
  def get_project_note!(%Project{id: project_id, repository_id: repository_id}, id) do
311
    ProjectNote
312
    |> Repo.get_by!(id: id, project_id: project_id, repository_id: repository_id)
313
    |> Repo.preload(:author_user)
314
  end
315
316
  @doc """
317
  Writes one discussion note on `project`, authored by `author`.
318
319
  The caller establishes write authority on the project's repository first.
320
  `kind` is not accepted from outside: activity entries are written only by the
321
  context that made the change they record.
322
  """
323
  def create_project_note(%Project{} = project, attrs, author \\ nil)
324
      when is_nil(author) or is_struct(author, User) do
325
    attrs =
326
      attrs
327
      |> to_string_map()
328
      |> Map.take(["body"])
329
      |> Map.put("kind", "note")
330
331
    case insert_note(project, attrs, author) do
332
      {:ok, note} ->
333
        Analytics.capture("project_note_created", actor_distinct_id(author), %{
334
          "project_number" => project.number
335
        })
336
337
        Repositories.broadcast_projects(project.repository_id)
338
339
        {:ok, Repo.preload(note, :author_user)}
340
341
      result ->
342
        result
343
    end
344
  end
345
346
  @doc """
347
  Edits the body of one discussion note.
348
349
  An activity entry is the record of a change that happened, so it is
350
  immutable: this returns `{:error, :immutable}` for one. Authority to call
351
  this is the note's author, which the caller checks with
352
  `authored_by?/2`.
353
  """
354
  def update_project_note(%ProjectNote{kind: "activity"}, _attrs), do: {:error, :immutable}
355
356
  def update_project_note(%ProjectNote{} = note, attrs) do
357
    attrs = attrs |> to_string_map() |> Map.take(["body"])
358
359
    note
360
    |> ProjectNote.changeset(attrs)
361
    |> Repo.update()
362
    |> case do
363
      {:ok, note} ->
364
        Repositories.broadcast_projects(note.repository_id)
365
        {:ok, Repo.preload(note, :author_user)}
366
367
      result ->
368
        result
369
    end
370
  end
371
372
  @doc "Deletes one discussion note. Activity entries never delete."
373
  def delete_project_note(%ProjectNote{kind: "activity"}), do: {:error, :immutable}
374
375
  def delete_project_note(%ProjectNote{} = note) do
376
    case Repo.delete(note) do
377
      {:ok, note} ->
378
        Repositories.broadcast_projects(note.repository_id)
379
        {:ok, note}
380
381
      result ->
382
        result
383
    end
384
  end
385
386
  @doc """
387
  Whether `user` wrote `note`.
388
389
  Edit and delete authority for a discussion note is its author, and nobody
390
  else: repository write access adds a note of your own rather than rewriting
391
  somebody else's words. A note written without an authenticated author, by an
392
  import or a token with no user behind it, has no author to match, so it is
393
  not editable through this predicate.
394
  """
395
  def authored_by?(%ProjectNote{author_user_id: nil}, _user), do: false
396
  def authored_by?(%ProjectNote{}, nil), do: false
397
398
  def authored_by?(%ProjectNote{author_user_id: author_user_id}, %User{id: user_id}),
399
    do: author_user_id == user_id
400
401
  @doc "A blank or seeded changeset for the note form."
402
  def change_project_note(%ProjectNote{} = note \\ %ProjectNote{}, attrs \\ %{}),
403
    do: ProjectNote.changeset(note, to_string_map(attrs))
404
405
  defp project_notes_query(%Project{id: project_id, repository_id: repository_id}, opts) do
406
    from(note in ProjectNote,
407
      where: note.project_id == ^project_id and note.repository_id == ^repository_id
408
    )
409
    |> maybe_filter_note_kind(Keyword.get(opts, :kind, "all"))
410
  end
411
412
  defp maybe_filter_note_kind(query, kind) when kind in ["note", "activity"],
413
    do: where(query, kind: ^kind)
414
415
  defp maybe_filter_note_kind(query, _all), do: query
416
417
  defp insert_note(%Project{} = project, attrs, author) do
418
    attrs
419
    |> Map.put("project_id", project.id)
420
    |> Map.put("repository_id", project.repository_id)
421
    |> put_note_author(author)
422
    |> then(&ProjectNote.changeset(%ProjectNote{}, &1))
423
    |> Repo.insert()
424
  end
425
426
  defp put_note_author(attrs, nil), do: attrs
427
428
  defp put_note_author(attrs, %User{} = author) do
429
    attrs
430
    |> Map.put("author_user_id", author.id)
431
    |> Map.put("author", %{"login" => author.github_login})
432
  end
433
218 434
  def list_project_items(%Project{id: project_id, repository_id: repository_id}) do
219 435
    project_items_query(project_id, repository_id)
220 436
    |> order_by(asc: :id)
lib/openagents/projects/project.ex modified +11 -1

@@ -7,6 +7,7 @@ defmodule OpenAgents.Projects.Project do

7 7
  schema "projects" do
8 8
    field :number, :integer
9 9
    field :title, :string
10
    field :description, :string
10 11
    field :owner, :string
11 12
    field :state, :string, default: "open"
12 13
    belongs_to :repository, Repository, type: :binary_id

@@ -18,8 +19,17 @@ defmodule OpenAgents.Projects.Project do

18 19
  @doc false
19 20
  def changeset(project, attrs) do
20 21
    project
21
    |> cast(attrs, [:number, :title, :owner, :state, :repository_id, :owner_user_id])
22
    |> cast(attrs, [
23
      :number,
24
      :title,
25
      :description,
26
      :owner,
27
      :state,
28
      :repository_id,
29
      :owner_user_id
30
    ])
22 31
    |> validate_required([:number, :title, :owner, :state, :repository_id])
32
    |> validate_length(:description, max: 20_000)
23 33
    |> unique_constraint([:repository_id, :number])
24 34
    |> foreign_key_constraint(:repository_id)
25 35
    |> foreign_key_constraint(:owner_user_id)
lib/openagents/projects/project_note.ex added +61

@@ -0,0 +1,61 @@

1
defmodule OpenAgents.Projects.ProjectNote do
2
  @moduledoc """
3
  One entry in a project's discussion and activity record.
4
5
  A note carries project-wide context: why the project exists, the operating
6
  assumptions in force, and the decisions that apply across several issues.
7
  Issue comments stay on issues.
8
9
  Two kinds share the table and the ordering:
10
11
    * `"note"` is discussion an operator writes. Its author can edit and delete
12
      it.
13
    * `"activity"` is the immutable record of a project change, written by the
14
      context that made the change. Nothing edits or deletes it.
15
16
  The `repository_id` repeats the owning project's repository so the row is
17
  filtered through the same authority boundary every other project surface
18
  reads through, and a database constraint keeps the pair in agreement.
19
  """
20
21
  use Ecto.Schema
22
  import Ecto.Changeset
23
24
  alias OpenAgents.Projects.Project
25
  alias OpenAgents.Repositories.Repository
26
27
  @kinds ["note", "activity"]
28
29
  @doc "The note kinds, discussion first."
30
  def kinds, do: @kinds
31
32
  schema "project_notes" do
33
    field :body, :string
34
    field :kind, :string, default: "note"
35
    field :author, :map
36
37
    belongs_to :project, Project
38
    belongs_to :repository, Repository, type: :binary_id
39
    belongs_to :author_user, OpenAgents.Accounts.User, type: :binary_id
40
41
    timestamps(type: :utc_datetime)
42
  end
43
44
  @doc false
45
  def changeset(note, attrs) do
46
    note
47
    |> cast(attrs, [:body, :kind, :author, :project_id, :repository_id, :author_user_id])
48
    |> update_change(:body, &trim/1)
49
    |> validate_required([:body, :kind, :project_id, :repository_id])
50
    |> validate_inclusion(:kind, @kinds)
51
    |> validate_length(:body, max: 20_000)
52
    |> foreign_key_constraint(:project_id)
53
    |> foreign_key_constraint(:repository_id)
54
    |> foreign_key_constraint(:author_user_id)
55
    |> foreign_key_constraint(:project_id, name: :project_notes_project_repository_fkey)
56
    |> check_constraint(:kind, name: :project_notes_kind_check)
57
  end
58
59
  defp trim(body) when is_binary(body), do: String.trim(body)
60
  defp trim(body), do: body
61
end
lib/openagents_web/api_route_authority.ex modified +7

@@ -68,6 +68,7 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

68 68
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number" => :optional_bearer,
69 69
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number/items" => :optional_bearer,
70 70
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number/fields" => :optional_bearer,
71
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number/notes" => :optional_bearer,
71 72
      # Scoped bearer pipelines require the route-specific token authority.
72 73
      "get /api/v3/chat/events" => :required_bearer,
73 74
      "post /api/v3/chat/turns" => :required_bearer,

@@ -108,6 +109,12 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

108 109
      "post /api/v3/repos/:owner/:repo/projectsV2" => :required_bearer,
109 110
      "post /api/v3/repos/:owner/:repo/projectsV2/:project_number/items" => :required_bearer,
110 111
      "post /api/v3/repos/:owner/:repo/projectsV2/:project_number/fields" => :required_bearer,
112
      "patch /api/v3/repos/:owner/:repo/projectsV2/:project_number" => :required_bearer,
113
      "post /api/v3/repos/:owner/:repo/projectsV2/:project_number/notes" => :required_bearer,
114
      "patch /api/v3/repos/:owner/:repo/projectsV2/:project_number/notes/:note_id" =>
115
        :required_bearer,
116
      "delete /api/v3/repos/:owner/:repo/projectsV2/:project_number/notes/:note_id" =>
117
        :required_bearer,
111 118
      "post /api/v3/user/repos" => :required_bearer,
112 119
      "post /api/v3/user/repos/imports" => :required_bearer,
113 120
      "put /api/v3/repos/:owner/:repo/issues/:issue_number" => :required_bearer,
lib/openagents_web/controllers/project_controller.ex modified +168

@@ -43,6 +43,164 @@ defmodule OpenAgentsWeb.ProjectController do

43 43
    Ecto.NoResultsError -> not_found(conn)
44 44
  end
45 45
46
  @doc """
47
  Updates the title, description, or state of one project.
48
49
  Authority is a writable membership in the repository the path names, the same
50
  boundary every other project write reads. `description` is Markdown, and
51
  `state` is `open` or `closed`.
52
  """
53
  def update(
54
        conn,
55
        %{
56
          "owner" => owner,
57
          "repo" => repo,
58
          "project_number" => project_number
59
        } = params
60
      ) do
61
    repository = writable_repository!(conn, owner, repo)
62
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
63
    attrs = Map.take(params, ["title", "description", "state"])
64
65
    cond do
66
      attrs == %{} ->
67
        unprocessable(conn, %{base: ["no updatable field was given"]})
68
69
      not valid_state?(attrs) ->
70
        unprocessable(conn, %{state: ["is invalid"]})
71
72
      true ->
73
        case Projects.update_project(project, attrs, conn.assigns.current_user) do
74
          {:ok, %Project{} = project} ->
75
            render(conn, :show, project: project)
76
77
          {:error, %Ecto.Changeset{} = changeset} ->
78
            conn
79
            |> put_status(:unprocessable_entity)
80
            |> render(:error, changeset: changeset)
81
        end
82
    end
83
  rescue
84
    Ecto.NoResultsError -> not_found(conn)
85
  end
86
87
  @doc """
88
  One page of a project's notes, newest first.
89
90
  Reads follow the repository's visibility, so a private project's notes stay
91
  invisible to a non-member: the request 404s at the repository before a note
92
  is read. Query parameters are `page` and `kind`, where `kind` is `note`,
93
  `activity`, or `all`.
94
  """
95
  def notes(
96
        conn,
97
        %{
98
          "owner" => owner,
99
          "repo" => repo,
100
          "project_number" => project_number
101
        } = params
102
      ) do
103
    repository = visible_repository!(conn, owner, repo)
104
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
105
    page = Projects.parse_page(params["page"])
106
107
    {notes, total_count} =
108
      Projects.list_project_notes_page(project, page: page, kind: params["kind"])
109
110
    render(conn, :notes, notes: notes, page: page, total_count: total_count)
111
  rescue
112
    Ecto.NoResultsError -> not_found(conn)
113
  end
114
115
  @doc "Writes one discussion note on a project."
116
  def create_note(
117
        conn,
118
        %{
119
          "owner" => owner,
120
          "repo" => repo,
121
          "project_number" => project_number
122
        } = params
123
      ) do
124
    repository = writable_repository!(conn, owner, repo)
125
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
126
127
    case Projects.create_project_note(project, params, conn.assigns.current_user) do
128
      {:ok, note} ->
129
        conn
130
        |> put_status(:created)
131
        |> render(:note, note: note)
132
133
      {:error, %Ecto.Changeset{} = changeset} ->
134
        conn
135
        |> put_status(:unprocessable_entity)
136
        |> render(:error, changeset: changeset)
137
    end
138
  rescue
139
    Ecto.NoResultsError -> not_found(conn)
140
  end
141
142
  @doc """
143
  Edits one discussion note.
144
145
  Authority is the note's author. Repository write access is necessary but not
146
  sufficient: another member adds a note of their own rather than rewriting
147
  this one, and an activity entry is never editable.
148
  """
149
  def update_note(
150
        conn,
151
        %{
152
          "owner" => owner,
153
          "repo" => repo,
154
          "project_number" => project_number,
155
          "note_id" => note_id
156
        } = params
157
      ) do
158
    with {:ok, note} <- authored_note(conn, owner, repo, project_number, note_id),
159
         {:ok, note} <- Projects.update_project_note(note, params) do
160
      render(conn, :note, note: note)
161
    else
162
      {:error, :forbidden} -> forbidden(conn)
163
      {:error, :immutable} -> forbidden(conn)
164
      {:error, %Ecto.Changeset{} = changeset} -> unprocessable_changeset(conn, changeset)
165
    end
166
  rescue
167
    Ecto.NoResultsError -> not_found(conn)
168
  end
169
170
  @doc "Deletes one discussion note. Authority is the note's author."
171
  def delete_note(conn, %{
172
        "owner" => owner,
173
        "repo" => repo,
174
        "project_number" => project_number,
175
        "note_id" => note_id
176
      }) do
177
    with {:ok, note} <- authored_note(conn, owner, repo, project_number, note_id),
178
         {:ok, _note} <- Projects.delete_project_note(note) do
179
      send_resp(conn, :no_content, "")
180
    else
181
      {:error, :forbidden} -> forbidden(conn)
182
      {:error, :immutable} -> forbidden(conn)
183
      {:error, %Ecto.Changeset{} = changeset} -> unprocessable_changeset(conn, changeset)
184
    end
185
  rescue
186
    Ecto.NoResultsError -> not_found(conn)
187
  end
188
189
  defp authored_note(conn, owner, repo, project_number, note_id) do
190
    repository = writable_repository!(conn, owner, repo)
191
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
192
    note = Projects.get_project_note!(project, parse_id!(note_id))
193
194
    if Projects.authored_by?(note, conn.assigns.current_user) do
195
      {:ok, note}
196
    else
197
      {:error, :forbidden}
198
    end
199
  end
200
201
  defp valid_state?(%{"state" => state}), do: state in ["open", "closed"]
202
  defp valid_state?(_attrs), do: true
203
46 204
  def items(conn, %{
47 205
        "owner" => owner,
48 206
        "repo" => repo,

@@ -223,6 +381,16 @@ defmodule OpenAgentsWeb.ProjectController do

223 381
    |> json(%{errors: errors})
224 382
  end
225 383
384
  defp unprocessable_changeset(conn, changeset) do
385
    conn
386
    |> put_status(:unprocessable_entity)
387
    |> render(:error, changeset: changeset)
388
  end
389
390
  defp forbidden(conn) do
391
    conn |> put_status(:forbidden) |> json(%{message: "Forbidden"})
392
  end
393
226 394
  defp not_found(conn) do
227 395
    conn |> put_status(:not_found) |> json(%{message: "Not Found"})
228 396
  end
lib/openagents_web/controllers/project_json.ex modified +31 -1

@@ -19,6 +19,19 @@ defmodule OpenAgentsWeb.ProjectJSON do

19 19
    %{fields: Enum.map(fields, &field_json/1)}
20 20
  end
21 21
22
  def render("notes.json", %{notes: notes, page: page, total_count: total_count}) do
23
    %{
24
      notes: Enum.map(notes, &note_json/1),
25
      page: page,
26
      per_page: OpenAgents.Projects.notes_per_page(),
27
      total_count: total_count
28
    }
29
  end
30
31
  def render("note.json", %{note: note}) do
32
    note_json(note)
33
  end
34
22 35
  def render("error.json", %{changeset: changeset}) do
23 36
    %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
24 37
  end

@@ -28,8 +41,25 @@ defmodule OpenAgentsWeb.ProjectJSON do

28 41
      id: project.id,
29 42
      number: project.number,
30 43
      title: project.title,
44
      # `description` is the canonical project-context field, and it is
45
      # Markdown. Nothing renders it server-side for the API; a client renders
46
      # it the same way it renders an issue body.
47
      description: project.description,
31 48
      owner: project.owner,
32
      state: project.state
49
      state: project.state,
50
      created_at: project.inserted_at,
51
      updated_at: project.updated_at
52
    }
53
  end
54
55
  defp note_json(note) do
56
    %{
57
      id: note.id,
58
      kind: note.kind,
59
      body: note.body,
60
      author: note.author,
61
      created_at: note.inserted_at,
62
      updated_at: note.updated_at
33 63
    }
34 64
  end
35 65
lib/openagents_web/live/project_show_live.ex modified +354 -7

@@ -1,21 +1,46 @@

1 1
defmodule OpenAgentsWeb.ProjectShowLive do
2 2
  @moduledoc """
3
  Renders a simple kanban board for a Project V2.
3
  One project: its description, its board, and its discussion.
4
5
  The page answers three questions in that order — why the project exists, what
6
  is on it, and what was decided about it — because a board alone carries no
7
  context and the context is what a reader arriving mid-effort is missing.
8
9
  Two structural decisions:
10
11
    * **Reading is the default state.** The description renders as prose and
12
      moves behind **Edit description** for members with write access, so the
13
      page describes the project rather than being a form that shows one.
14
15
    * **The discussion is paginated and separate.** A long-lived project
16
      accumulates decisions without bound, so the notes are read one page at a
17
      time through `OpenAgents.Projects.list_project_notes_page/2` rather than
18
      embedded in the project. Discussion notes and the immutable activity
19
      record interleave in one feed, because their order relative to each other
20
      is most of the answer.
21
22
  Authority is the project's repository. Anyone who can see the repository can
23
  read the board and its notes; writing anything needs write access, and
24
  deleting a note needs authorship.
4 25
  """
5 26
  use OpenAgentsWeb, :live_view
6 27
7 28
  alias OpenAgents.Issues
29
  alias OpenAgents.Markdown
8 30
  alias OpenAgents.Projects
9 31
  alias OpenAgents.ProjectItems.ProjectItem
10 32
  alias OpenAgents.Repositories
33
  alias OpenAgentsWeb.UI.Circle
11 34
12 35
  @statuses ["To Do", "In Progress", "Done"]
13 36
14 37
  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do
15
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
38
    user = socket.assigns.current_user
39
    repository = Repositories.get_visible_by_path!(owner, repo, user)
16 40
    project = Projects.get_project_by_number!(repository, String.to_integer(number))
17
    items = project_items(project, socket.assigns.current_user)
18
    issue_options = issue_options(repository)
41
    can_write = Repositories.writable?(repository, user)
42
43
    if connected?(socket), do: Repositories.subscribe_projects(repository.id)
19 44
20 45
    {:ok,
21 46
     socket

@@ -24,14 +49,20 @@ defmodule OpenAgentsWeb.ProjectShowLive do

24 49
     |> assign(:repo, repo)
25 50
     |> assign(:repository, repository)
26 51
     |> assign(:project, project)
27
     |> assign(:items, items)
28
     |> assign(:issue_options, issue_options)
52
     |> assign(:can_write, can_write)
53
     |> assign(:items, project_items(project, user))
54
     |> assign(:issue_options, issue_options(repository))
29 55
     # The board columns are read as `@statuses` inside ~H, where `@` means
30 56
     # `assigns.statuses`, not the module attribute. Without this assign every
31 57
     # render raised KeyError and the route was unreachable.
32 58
     |> assign(:statuses, @statuses)
33 59
     |> assign(:status_options, Enum.map(@statuses, &{&1, &1}))
34
     |> assign(:form, to_form(ProjectItem.changeset(%ProjectItem{}, %{}), as: "item"))}
60
     |> assign(:editing_description?, false)
61
     |> assign(:description_form, description_form(project))
62
     |> assign(:note_form, note_form())
63
     |> assign(:notes_page, 1)
64
     |> assign(:form, to_form(ProjectItem.changeset(%ProjectItem{}, %{}), as: "item"))
65
     |> load_notes(connected?(socket))}
35 66
  end
36 67
37 68
  def handle_event("add_item", %{"item" => item_params}, socket) do

@@ -56,6 +87,137 @@ defmodule OpenAgentsWeb.ProjectShowLive do

56 87
    end
57 88
  end
58 89
90
  def handle_event("edit_description", _params, socket) do
91
    {:noreply,
92
     socket
93
     |> assign(:editing_description?, socket.assigns.can_write)
94
     |> assign(:description_form, description_form(socket.assigns.project))}
95
  end
96
97
  def handle_event("cancel_description", _params, socket) do
98
    {:noreply, assign(socket, :editing_description?, false)}
99
  end
100
101
  def handle_event("save_description", %{"project" => params}, socket) do
102
    with true <- socket.assigns.can_write,
103
         {:ok, project} <-
104
           Projects.update_project(
105
             socket.assigns.project,
106
             Map.take(params, ["description"]),
107
             socket.assigns.current_user
108
           ) do
109
      {:noreply,
110
       socket
111
       |> assign(:project, project)
112
       |> assign(:editing_description?, false)
113
       |> assign(:description_form, description_form(project))
114
       |> reload_notes()
115
       |> put_flash(:info, "Description saved")}
116
    else
117
      false ->
118
        {:noreply, put_flash(socket, :error, "You cannot edit this project.")}
119
120
      {:error, changeset} ->
121
        {:noreply, assign(socket, :description_form, to_form(changeset, as: "project"))}
122
    end
123
  end
124
125
  def handle_event("add_note", %{"note" => params}, socket) do
126
    with true <- socket.assigns.can_write,
127
         {:ok, _note} <-
128
           Projects.create_project_note(
129
             socket.assigns.project,
130
             params,
131
             socket.assigns.current_user
132
           ) do
133
      {:noreply,
134
       socket
135
       |> assign(:note_form, note_form())
136
       |> assign(:notes_page, 1)
137
       |> reload_notes()}
138
    else
139
      false ->
140
        {:noreply, put_flash(socket, :error, "You cannot write notes on this project.")}
141
142
      {:error, changeset} ->
143
        {:noreply, assign(socket, :note_form, to_form(changeset, as: "note"))}
144
    end
145
  end
146
147
  def handle_event("delete_note", %{"id" => id}, socket) do
148
    note = Projects.get_project_note!(socket.assigns.project, id)
149
150
    if Projects.authored_by?(note, socket.assigns.current_user) do
151
      case Projects.delete_project_note(note) do
152
        {:ok, _note} -> {:noreply, reload_notes(socket)}
153
        {:error, _reason} -> {:noreply, put_flash(socket, :error, "That note cannot be deleted.")}
154
      end
155
    else
156
      {:noreply, put_flash(socket, :error, "Only the author can delete a note.")}
157
    end
158
  end
159
160
  def handle_event("show_notes_page", %{"page" => page}, socket) do
161
    {:noreply,
162
     socket
163
     |> assign(:notes_page, max(String.to_integer(page), 1))
164
     |> reload_notes()}
165
  end
166
167
  # A project changed somewhere else — the API, the CLI, or another board — so
168
  # the page rereads through this viewer's authorization boundary rather than
169
  # trusting the broadcast payload. The message carries a repository id and
170
  # nothing about who may see what.
171
  def handle_info({:projects_changed, repository_id}, socket) do
172
    if repository_id == socket.assigns.repository.id do
173
      user = socket.assigns.current_user
174
175
      try do
176
        Projects.get_project_by_number!(socket.assigns.repository, socket.assigns.project.number)
177
      rescue
178
        Ecto.NoResultsError -> nil
179
      end
180
      |> case do
181
        nil ->
182
          {:noreply, put_flash(socket, :error, "This project no longer exists.")}
183
184
        project ->
185
          {:noreply,
186
           socket
187
           |> assign(:project, project)
188
           |> assign(:items, project_items(project, user))
189
           |> reload_notes()}
190
      end
191
    else
192
      {:noreply, socket}
193
    end
194
  end
195
196
  defp load_notes(socket, false), do: assign(socket, :notes, :loading)
197
  defp load_notes(socket, true), do: reload_notes(socket)
198
199
  # The feed is a read against the database on a page that has already rendered
200
  # its board, so a failure here degrades the section instead of the page.
201
  defp reload_notes(socket) do
202
    page = socket.assigns.notes_page
203
204
    try do
205
      {notes, total_count} = Projects.list_project_notes_page(socket.assigns.project, page: page)
206
207
      socket
208
      |> assign(:notes, notes)
209
      |> assign(:notes_total_count, total_count)
210
      |> assign(:notes_pages, max(ceil(total_count / Projects.notes_per_page()), 1))
211
    rescue
212
      _error -> assign(socket, :notes, :error)
213
    end
214
  end
215
216
  defp description_form(project),
217
    do: to_form(Projects.change_project(project, %{}), as: "project")
218
219
  defp note_form, do: to_form(Projects.change_project_note(), as: "note")
220
59 221
  defp project_items(project, user) do
60 222
    Projects.list_visible_project_items(project, user)
61 223
    |> Enum.map(fn item ->

@@ -70,6 +232,15 @@ defmodule OpenAgentsWeb.ProjectShowLive do

70 232
    |> Enum.map(&{"##{&1.number} #{&1.title}", &1.number})
71 233
  end
72 234
235
  defp author(%{author: %{"login" => login}}) when is_binary(login), do: login
236
  defp author(_note), do: "unattributed"
237
238
  defp stamp(nil), do: nil
239
  defp stamp(at), do: Calendar.strftime(at, "%b %-d, %Y at %H:%M UTC")
240
241
  defp viewer(%{github_login: login}) when is_binary(login), do: login
242
  defp viewer(_user), do: nil
243
73 244
  def render(assigns) do
74 245
    ~H"""
75 246
    <Layouts.app

@@ -89,7 +260,62 @@ defmodule OpenAgentsWeb.ProjectShowLive do

89 260
        </.link>
90 261
      </div>
91 262
263
      <section
264
        id="project-description"
265
        class="card !mx-0 !mt-0 mb-6"
266
        aria-labelledby="project-description-heading"
267
      >
268
        <header class="flex items-center justify-between mb-2">
269
          <h2 id="project-description-heading" class="card-title !text-sm">Description</h2>
270
          <.button
271
            :if={@can_write and not @editing_description?}
272
            id="edit-description"
273
            type="button"
274
            variant={:ghost}
275
            size={:sm}
276
            phx-click="edit_description"
277
          >
278
            Edit description
279
          </.button>
280
        </header>
281
282
        <div :if={not @editing_description?}>
283
          <div :if={@project.description} class="timeline-comment__body !p-0">
284
            {Markdown.to_html(@project.description)}
285
          </div>
286
          <.empty
287
            :if={is_nil(@project.description)}
288
            id="project-description-empty"
289
            title="No description yet"
290
          >
291
            A description records why this project exists and how it operates. Markdown is
292
            supported.
293
          </.empty>
294
        </div>
295
296
        <.form
297
          :if={@editing_description?}
298
          for={@description_form}
299
          id="project-description-form"
300
          phx-submit="save_description"
301
        >
302
          <.input
303
            field={@description_form[:description]}
304
            type="textarea"
305
            label="Description"
306
            placeholder="Why this project exists, and how it operates."
307
          />
308
          <footer class="flex justify-end gap-2 mt-2">
309
            <.button type="button" variant={:ghost} size={:sm} phx-click="cancel_description">
310
              Cancel
311
            </.button>
312
            <.button type="submit" variant={:primary} size={:sm}>Save description</.button>
313
          </footer>
314
        </.form>
315
      </section>
316
92 317
      <.form
318
        :if={@can_write}
93 319
        for={@form}
94 320
        id="new-project-item-form"
95 321
        phx-submit="add_item"

@@ -154,6 +380,127 @@ defmodule OpenAgentsWeb.ProjectShowLive do

154 380
          </section>
155 381
        <% end %>
156 382
      </div>
383
384
      <div id="project-discussion" class="mt-8" aria-labelledby="project-discussion-heading">
385
        <h2 id="project-discussion-heading" class="text-lg font-semibold mb-2">
386
          Discussion and activity
387
        </h2>
388
389
        <p :if={@notes == :loading} id="project-notes-loading" class="text-sm text-muted-foreground">
390
          Loading discussion…
391
        </p>
392
393
        <.alert
394
          :if={@notes == :error}
395
          id="project-notes-error"
396
          variant={:warning}
397
          appearance={:notice}
398
        >
399
          The discussion could not be loaded. The board above is current; reload the page to try
400
          again.
401
        </.alert>
402
403
        <%= if is_list(@notes) do %>
404
          <.empty :if={@notes == []} id="project-notes-empty" title="No notes yet">
405
            Project notes carry decisions that apply across issues. Changes to the title,
406
            description, or state are recorded here automatically.
407
          </.empty>
408
409
          <Circle.timeline :if={@notes != []}>
410
            <%= for note <- @notes do %>
411
              <Circle.timeline_event
412
                :if={note.kind == "activity"}
413
                actor={author(note)}
414
                text={note.body}
415
                icon="history"
416
                tone={:neutral}
417
                at={stamp(note.inserted_at)}
418
              />
419
              <Circle.timeline_comment
420
                :if={note.kind == "note"}
421
                id={"project-note-#{note.id}"}
422
                author={author(note)}
423
                at={stamp(note.inserted_at)}
424
              >
425
                {Markdown.to_html(note.body)}
426
                <:actions>
427
                  <.button
428
                    :if={Projects.authored_by?(note, @current_user)}
429
                    type="button"
430
                    variant={:ghost}
431
                    size={:sm}
432
                    phx-click="delete_note"
433
                    phx-value-id={note.id}
434
                  >
435
                    Delete
436
                  </.button>
437
                </:actions>
438
              </Circle.timeline_comment>
439
            <% end %>
440
          </Circle.timeline>
441
442
          <nav
443
            :if={@notes_pages > 1}
444
            id="project-notes-pagination"
445
            class="flex items-center justify-between mt-3"
446
            aria-label="Discussion pages"
447
          >
448
            <.button
449
              type="button"
450
              variant={:ghost}
451
              size={:sm}
452
              disabled={@notes_page <= 1}
453
              phx-click="show_notes_page"
454
              phx-value-page={@notes_page - 1}
455
            >
456
              Newer
457
            </.button>
458
            <span class="text-xs text-muted-foreground">
459
              Page {@notes_page} of {@notes_pages}
460
            </span>
461
            <.button
462
              type="button"
463
              variant={:ghost}
464
              size={:sm}
465
              disabled={@notes_page >= @notes_pages}
466
              phx-click="show_notes_page"
467
              phx-value-page={@notes_page + 1}
468
            >
469
              Older
470
            </.button>
471
          </nav>
472
        <% end %>
473
474
        <.alert
475
          :if={not @can_write}
476
          id="project-notes-unauthorized"
477
          variant={:info}
478
          appearance={:notice}
479
        >
480
          Members with write access to {@owner}/{@repo} can add notes to this project.
481
        </.alert>
482
483
        <.form
484
          :if={@can_write}
485
          for={@note_form}
486
          id="project-note-form"
487
          phx-submit="add_note"
488
          class="mt-4"
489
        >
490
          <Circle.comment_composer id="project-note-composer" author={viewer(@current_user)}>
491
            <.input
492
              field={@note_form[:body]}
493
              type="textarea"
494
              label="Note"
495
              placeholder="A decision, an assumption, or context that applies across issues"
496
            />
497
            <:hint>Markdown is supported.</:hint>
498
            <:actions>
499
              <.button type="submit" variant={:primary} size={:sm}>Add note</.button>
500
            </:actions>
501
          </Circle.comment_composer>
502
        </.form>
503
      </div>
157 504
    </Layouts.app>
158 505
    """
159 506
  end
lib/openagents_web/router.ex modified +15

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

314 314
    get "/repos/:owner/:repo/projectsV2/:project_number", ProjectController, :show
315 315
    get "/repos/:owner/:repo/projectsV2/:project_number/items", ProjectController, :items
316 316
    get "/repos/:owner/:repo/projectsV2/:project_number/fields", ProjectController, :fields
317
    get "/repos/:owner/:repo/projectsV2/:project_number/notes", ProjectController, :notes
317 318
318 319
    # The forum reads. Posting and claiming live behind the write scope.
319 320
    get "/forum", ForumApiController, :boards

@@ -377,6 +378,20 @@ defmodule OpenAgentsWeb.Router do

377 378
    patch "/repos/:owner/:repo/projectsV2/:project_number/items/:item_id",
378 379
          ProjectController,
379 380
          :update_item
381
382
    patch "/repos/:owner/:repo/projectsV2/:project_number", ProjectController, :update
383
384
    post "/repos/:owner/:repo/projectsV2/:project_number/notes",
385
         ProjectController,
386
         :create_note
387
388
    patch "/repos/:owner/:repo/projectsV2/:project_number/notes/:note_id",
389
          ProjectController,
390
          :update_note
391
392
    delete "/repos/:owner/:repo/projectsV2/:project_number/notes/:note_id",
393
           ProjectController,
394
           :delete_note
380 395
  end
381 396
382 397
  # Enable LiveDashboard and Swoosh mailbox preview in development
priv/docs/cli-api.md modified +34

@@ -102,6 +102,14 @@ openagents api repos/OWNER/REPOSITORY/projectsV2

102 102
openagents api repos/OWNER/REPOSITORY/projectsV2/PROJECT_NUMBER
103 103
openagents api repos/OWNER/REPOSITORY/projectsV2/PROJECT_NUMBER/items
104 104
openagents api repos/OWNER/REPOSITORY/projectsV2/PROJECT_NUMBER/fields
105
openagents api repos/OWNER/REPOSITORY/projectsV2/PROJECT_NUMBER/notes
106
```
107
108
The notes response is paginated. Read a later page, or one kind of entry:
109
110
```sh
111
openagents api 'repos/OWNER/REPOSITORY/projectsV2/PROJECT_NUMBER/notes?page=2'
112
openagents api 'repos/OWNER/REPOSITORY/projectsV2/PROJECT_NUMBER/notes?kind=activity'
105 113
```
106 114
107 115
Create a project and add an issue. `issue_number` is the repository-local issue

@@ -124,6 +132,32 @@ printf '%s' '{"values":{"Status":"Done"}}' | \

124 132
  repos/OWNER/REPOSITORY/projectsV2/PROJECT_NUMBER/items/ITEM_ID
125 133
```
126 134
135
Update a project's title, description, or state. The description is Markdown,
136
and `state` is `open` or `closed`. Each accepted change appends one activity
137
entry to the project's notes:
138
139
```sh
140
printf '%s' '{"description":"## Why\n\nProvider order is under test."}' | \
141
  openagents api -X PATCH --input - \
142
  repos/OWNER/REPOSITORY/projectsV2/PROJECT_NUMBER
143
```
144
145
Write a discussion note. Its author is the account behind the token, and only
146
that author can edit or delete it:
147
148
```sh
149
printf '%s' '{"body":"Stress lane 3 is paused until the provider order lands."}' | \
150
  openagents api -X POST --input - \
151
  repos/OWNER/REPOSITORY/projectsV2/PROJECT_NUMBER/notes
152
153
printf '%s' '{"body":"Edited."}' | \
154
  openagents api -X PATCH --input - \
155
  repos/OWNER/REPOSITORY/projectsV2/PROJECT_NUMBER/notes/NOTE_ID
156
157
openagents api -X DELETE \
158
  repos/OWNER/REPOSITORY/projectsV2/PROJECT_NUMBER/notes/NOTE_ID
159
```
160
127 161
## Use output in scripts
128 162
129 163
Standard output contains only a successful response body. `--json` writes the
priv/docs/projects.md modified +35 -2

@@ -2,6 +2,27 @@

2 2
3 3
A project is a board of issues. Browse them at `/:owner/:repo/projects`.
4 4
5
## The description
6
7
A project carries one `description` field, which is Markdown. Use it for the
8
context that applies to the whole project: why the project exists, the
9
assumptions it operates under, and the decisions that outlive any single issue.
10
The board renders the description above the columns. Members with write access
11
to the repository edit it in place.
12
13
## Discussion and activity
14
15
A project also carries notes, which the board shows below the columns:
16
17
- A **discussion note** is Markdown prose you write. Its author can edit or
18
  delete it; nobody else can, even with write access to the repository.
19
- An **activity entry** records a change to the project's title, description, or
20
  state. Entries are written when the change commits, and they never change or
21
  delete.
22
23
Issue comments stay on the issue. Project notes carry what applies across
24
issues.
25
5 26
## The board
6 27
7 28
Items are grouped into columns by status. Adding an issue to a project creates

@@ -16,6 +37,18 @@ issue, so two boards can hold different views of the same work.

16 37
## Through the API
17 38
18 39
Projects are exposed under `/repos/:owner/:repo/projectsV2`. The repository in
19
the path controls visibility and write authority for every project, item, and
20
field operation. See [REST API](/docs/rest-api), or use
40
the path controls visibility and write authority for every project, item, field,
41
and note operation. See [REST API](/docs/rest-api), or use
21 42
[`openagents api`](/docs/cli-api) to work with projects from a terminal.
43
44
A project object carries `description`, `created_at`, and `updated_at` alongside
45
its `number`, `title`, `owner`, and `state`. `PATCH` on a project accepts
46
`title`, `description`, and `state`, where `state` is `open` or `closed`.
47
48
Notes are a separate paginated read at
49
`/repos/:owner/:repo/projectsV2/:project_number/notes`, so a long-lived board
50
does not carry an unbounded timeline inside the project object. The response
51
carries `notes`, `page`, `per_page`, and `total_count`. Each note carries a
52
stable `id`, its `kind` (`note` or `activity`), the Markdown `body`, the
53
`author`, `created_at`, and `updated_at`. Pass `kind=note` or `kind=activity` to
54
read one side of the feed.
priv/docs/rest-api.md modified +15

@@ -146,6 +146,11 @@ DELETE /api/v3/repos/:owner/:repo/milestones/:milestone_number

146 146
GET    /api/v3/repos/:owner/:repo/projectsV2
147 147
POST   /api/v3/repos/:owner/:repo/projectsV2
148 148
GET    /api/v3/repos/:owner/:repo/projectsV2/:project_number
149
PATCH  /api/v3/repos/:owner/:repo/projectsV2/:project_number
150
GET    /api/v3/repos/:owner/:repo/projectsV2/:project_number/notes
151
POST   /api/v3/repos/:owner/:repo/projectsV2/:project_number/notes
152
PATCH  /api/v3/repos/:owner/:repo/projectsV2/:project_number/notes/:note_id
153
DELETE /api/v3/repos/:owner/:repo/projectsV2/:project_number/notes/:note_id
149 154
GET    /api/v3/repos/:owner/:repo/projectsV2/:project_number/items
150 155
POST   /api/v3/repos/:owner/:repo/projectsV2/:project_number/items
151 156
PATCH  /api/v3/repos/:owner/:repo/projectsV2/:project_number/items/:item_id

@@ -158,6 +163,16 @@ numbers are repository-local. Project creation through this REST path is an

158 163
OpenAgents extension; GitHub Projects V2 creation is not part of the assessed
159 164
GitHub REST surface.
160 165
166
A project update accepts `title`, `description`, and `state`, where `description`
167
is Markdown and `state` is `open` or `closed`. Each accepted change appends one
168
immutable activity entry to the project's notes.
169
170
The notes read is paginated and takes `page` and `kind`, where `kind` is `note`,
171
`activity`, or `all`. The response carries `notes`, `page`, `per_page`, and
172
`total_count`. Editing or deleting a note requires authorship: another member
173
with write access receives `403 Forbidden`, and an activity entry is never
174
editable. See [Projects](/docs/projects).
175
161 176
## Know the compatibility limits
162 177
163 178
- List responses use named envelopes such as `issues`, `comments`, `labels`,
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -236,7 +236,8 @@

236 236
    20260823013135,
237 237
    20260823021021,
238 238
    20260823034851,
239
    20260823040635
239
    20260823040635,
240
    20260823043000
240 241
  ],
241 242
  "required_tables": [
242 243
    "users",
priv/repo/migrations/20260823043000_add_project_descriptions_and_notes.exs added +53

@@ -0,0 +1,53 @@

1
defmodule OpenAgents.Repo.Migrations.AddProjectDescriptionsAndNotes do
2
  use Ecto.Migration
3
4
  def up do
5
    alter table(:projects) do
6
      add :description, :text
7
    end
8
9
    create table(:project_notes) do
10
      add :project_id, references(:projects, on_delete: :delete_all), null: false
11
12
      add :repository_id,
13
          references(:repositories, type: :binary_id, on_delete: :delete_all),
14
          null: false
15
16
      add :author_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
17
      add :author, :map
18
      add :kind, :string, null: false, default: "note"
19
      add :body, :text, null: false
20
21
      timestamps(type: :utc_datetime)
22
    end
23
24
    create index(:project_notes, [:project_id, :id])
25
    create index(:project_notes, [:repository_id])
26
27
    create constraint(:project_notes, :project_notes_kind_check,
28
             check: "kind in ('note', 'activity')"
29
           )
30
31
    # A note belongs to the project and to the repository that owns the
32
    # project, and the pair has to agree: the repository is the authority
33
    # boundary every project surface reads through, so a note whose
34
    # repository_id drifted from its project's would be readable by the wrong
35
    # members.
36
    execute("""
37
    ALTER TABLE project_notes
38
      ADD CONSTRAINT project_notes_project_repository_fkey
39
      FOREIGN KEY (project_id, repository_id)
40
      REFERENCES projects (id, repository_id)
41
      ON DELETE CASCADE
42
    """)
43
  end
44
45
  def down do
46
    execute("ALTER TABLE project_notes DROP CONSTRAINT project_notes_project_repository_fkey")
47
    drop table(:project_notes)
48
49
    alter table(:projects) do
50
      remove :description
51
    end
52
  end
53
end
test/openagents/project_notes_test.exs added +154

@@ -0,0 +1,154 @@

1
defmodule OpenAgents.ProjectNotesTest do
2
  use OpenAgents.DataCase
3
4
  import OpenAgents.ProjectsFixtures
5
6
  alias OpenAgents.Projects
7
8
  setup do
9
    repository = repository_fixture()
10
    author = repository_user_fixture("note-author-#{System.unique_integer([:positive])}")
11
12
    {:ok, project} =
13
      Projects.create_project(repository, %{title: "Stress testing", owner: author.github_login})
14
15
    %{repository: repository, project: project, author: author}
16
  end
17
18
  describe "descriptions" do
19
    test "a project carries a Markdown description through create and update", %{
20
      repository: repository,
21
      author: author
22
    } do
23
      {:ok, project} =
24
        Projects.create_project(repository, %{
25
          title: "Ox alpha",
26
          owner: author.github_login,
27
          description: "## Why\n\nProvider order is the thing under test."
28
        })
29
30
      assert project.description =~ "Provider order"
31
32
      assert {:ok, updated} =
33
               Projects.update_project(project, %{"description" => "Rewritten."}, author)
34
35
      assert updated.description == "Rewritten."
36
    end
37
38
    test "an update records one immutable activity note per changed field", %{
39
      project: project,
40
      author: author
41
    } do
42
      assert {:ok, _updated} =
43
               Projects.update_project(
44
                 project,
45
                 %{"title" => "Stress testing Ox Alpha", "state" => "closed"},
46
                 author
47
               )
48
49
      {notes, total} = Projects.list_project_notes_page(project, kind: "activity")
50
51
      assert total == 2
52
      assert Enum.all?(notes, &(&1.kind == "activity"))
53
      assert Enum.any?(notes, &(&1.body =~ "state"))
54
      assert Enum.any?(notes, &(&1.body =~ "title"))
55
      assert Enum.all?(notes, &(&1.author == %{"login" => author.github_login}))
56
57
      assert [activity | _] = notes
58
      assert {:error, :immutable} = Projects.update_project_note(activity, %{"body" => "nope"})
59
      assert {:error, :immutable} = Projects.delete_project_note(activity)
60
    end
61
62
    test "a failed update writes no activity note", %{project: project, author: author} do
63
      assert {:error, %Ecto.Changeset{}} =
64
               Projects.update_project(project, %{"title" => nil}, author)
65
66
      assert Projects.count_project_notes(project) == 0
67
    end
68
  end
69
70
  describe "notes" do
71
    test "a note keeps its Markdown body, author, and timestamps", %{
72
      project: project,
73
      author: author
74
    } do
75
      assert {:ok, note} =
76
               Projects.create_project_note(project, %{"body" => "- paused lane 3"}, author)
77
78
      assert note.body == "- paused lane 3"
79
      assert note.kind == "note"
80
      assert note.author == %{"login" => author.github_login}
81
      assert note.author_user_id == author.id
82
      assert note.inserted_at
83
      assert note.updated_at
84
    end
85
86
    test "a note cannot be created as an activity entry", %{project: project, author: author} do
87
      assert {:ok, note} =
88
               Projects.create_project_note(
89
                 project,
90
                 %{"body" => "Not a record", "kind" => "activity"},
91
                 author
92
               )
93
94
      assert note.kind == "note"
95
    end
96
97
    test "a blank body is rejected", %{project: project, author: author} do
98
      assert {:error, changeset} =
99
               Projects.create_project_note(project, %{"body" => "   "}, author)
100
101
      assert %{body: ["can't be blank"]} = errors_on(changeset)
102
    end
103
104
    test "notes list newest first, one page at a time", %{project: project, author: author} do
105
      per_page = Projects.notes_per_page()
106
107
      for index <- 1..(per_page + 3) do
108
        {:ok, _note} = Projects.create_project_note(project, %{"body" => "note #{index}"}, author)
109
      end
110
111
      {first_page, total} = Projects.list_project_notes_page(project, page: 1)
112
      {second_page, ^total} = Projects.list_project_notes_page(project, page: 2)
113
114
      assert total == per_page + 3
115
      assert length(first_page) == per_page
116
      assert length(second_page) == 3
117
      assert hd(first_page).body == "note #{per_page + 3}"
118
      assert List.last(second_page).body == "note 1"
119
    end
120
121
    test "a note belongs to one project", %{project: project, author: author} do
122
      other = project_fixture(project.repository_id |> repository!(), %{title: "Other"})
123
      {:ok, _note} = Projects.create_project_note(project, %{"body" => "mine"}, author)
124
125
      assert {[], 0} = Projects.list_project_notes_page(other)
126
    end
127
128
    test "only the author may edit or delete a note", %{project: project, author: author} do
129
      other = repository_user_fixture("other-#{System.unique_integer([:positive])}")
130
      {:ok, note} = Projects.create_project_note(project, %{"body" => "mine"}, author)
131
132
      assert Projects.authored_by?(note, author)
133
      refute Projects.authored_by?(note, other)
134
      refute Projects.authored_by?(note, nil)
135
136
      assert {:ok, edited} = Projects.update_project_note(note, %{"body" => "mine, edited"})
137
      assert edited.body == "mine, edited"
138
      assert {:ok, _deleted} = Projects.delete_project_note(edited)
139
      assert Projects.count_project_notes(project) == 0
140
    end
141
142
    test "a note written without an author has no editor", %{project: project} do
143
      assert {:ok, note} = Projects.create_project_note(project, %{"body" => "by a token"})
144
      assert note.author == nil
145
146
      refute Projects.authored_by?(
147
               note,
148
               repository_user_fixture("nobody-#{System.unique_integer([:positive])}")
149
             )
150
    end
151
  end
152
153
  defp repository!(id), do: OpenAgents.Repo.get!(OpenAgents.Repositories.Repository, id)
154
end
test/openagents_web/controllers/project_controller_test.exs modified +234

@@ -69,6 +69,19 @@ defmodule OpenAgentsWeb.ProjectControllerTest do

69 69
      assert Projects.get_project_by_number!(repository(), number).title == "New board"
70 70
    end
71 71
72
    test "POST /api/v3/repos/:owner/:repo/projectsV2 accepts a Markdown description", %{
73
      conn: conn
74
    } do
75
      conn =
76
        post(conn, ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2", %{
77
          title: "Stress testing Ox Alpha",
78
          description: "## Why\n\nProvider order is under test."
79
        })
80
81
      assert %{"description" => "## Why\n\nProvider order is under test."} =
82
               json_response(conn, 201)
83
    end
84
72 85
    test "POST /api/v3/repos/:owner/:repo/projectsV2 ignores repository override params", %{
73 86
      conn: conn
74 87
    } do

@@ -595,6 +608,227 @@ defmodule OpenAgentsWeb.ProjectControllerTest do

595 608
    end
596 609
  end
597 610
611
  describe "update" do
612
    test "PATCH projectsV2/:number updates the title, description, and state", %{conn: conn} do
613
      project = project_fixture(%{title: "Roadmap", owner: "alice", state: "open"})
614
615
      conn =
616
        patch(conn, ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}", %{
617
          title: "Stress testing Ox Alpha",
618
          description: "## Why\n\nProvider order is under test.",
619
          state: "closed"
620
        })
621
622
      assert %{
623
               "title" => "Stress testing Ox Alpha",
624
               "description" => "## Why\n\nProvider order is under test.",
625
               "state" => "closed",
626
               "created_at" => _created,
627
               "updated_at" => _updated
628
             } = json_response(conn, 200)
629
    end
630
631
    test "PATCH projectsV2/:number ignores fields it does not own", %{conn: conn} do
632
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
633
634
      conn =
635
        patch(conn, ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}", %{
636
          title: "Renamed",
637
          number: 9999,
638
          owner: "mallory"
639
        })
640
641
      assert %{"title" => "Renamed", "number" => number, "owner" => "alice"} =
642
               json_response(conn, 200)
643
644
      assert number == project.number
645
    end
646
647
    test "PATCH projectsV2/:number rejects an unknown state", %{conn: conn} do
648
      project = project_fixture(%{title: "Roadmap", owner: "alice", state: "open"})
649
650
      conn =
651
        patch(conn, ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}", %{
652
          state: "sideways"
653
        })
654
655
      assert json_response(conn, 422) == %{"errors" => %{"state" => ["is invalid"]}}
656
      assert Projects.get_project_by_number!(repository(), project.number).state == "open"
657
    end
658
659
    test "PATCH projectsV2/:number hides a private repository from a non-member", %{conn: _conn} do
660
      project = project_fixture(%{title: "Alice only", owner: "alice"})
661
      mallory = put_forge_api_token(build_conn(), "project-mallory-update", "mallory")
662
663
      assert patch(
664
               mallory,
665
               ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}",
666
               %{title: "Mine now"}
667
             )
668
             |> json_response(404) == %{"message" => "Not Found"}
669
670
      assert Projects.get_project_by_number!(repository(), project.number).title == "Alice only"
671
    end
672
  end
673
674
  describe "notes" do
675
    test "GET and POST notes round-trip a Markdown note with its author", %{conn: conn} do
676
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
677
678
      created =
679
        post(
680
          conn,
681
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes",
682
          %{body: "- paused lane 3"}
683
        )
684
685
      assert %{
686
               "id" => id,
687
               "kind" => "note",
688
               "body" => "- paused lane 3",
689
               "author" => %{"login" => "alice"},
690
               "created_at" => _created_at,
691
               "updated_at" => _updated_at
692
             } = json_response(created, 201)
693
694
      listed =
695
        get(
696
          recycle(conn),
697
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes"
698
        )
699
700
      assert %{"notes" => [note], "page" => 1, "per_page" => per_page, "total_count" => 1} =
701
               json_response(listed, 200)
702
703
      assert note["id"] == id
704
      assert per_page == Projects.notes_per_page()
705
    end
706
707
    test "GET notes paginates and filters by kind", %{conn: conn} do
708
      project = project_fixture(%{title: "Roadmap", owner: "alice", state: "open"})
709
      user = github_user("api-token-projects", "alice")
710
711
      for index <- 1..(Projects.notes_per_page() + 1) do
712
        {:ok, _note} =
713
          Projects.create_project_note(project, %{"body" => "note #{index}"}, user)
714
      end
715
716
      {:ok, _updated} = Projects.update_project(project, %{"state" => "closed"}, user)
717
718
      page_two =
719
        get(
720
          conn,
721
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes?page=2"
722
        )
723
724
      assert %{"notes" => notes, "page" => 2} = json_response(page_two, 200)
725
      assert length(notes) == 2
726
727
      activity =
728
        get(
729
          recycle(conn),
730
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes?kind=activity"
731
        )
732
733
      assert %{"notes" => [%{"kind" => "activity", "body" => body}], "total_count" => 1} =
734
               json_response(activity, 200)
735
736
      assert body =~ "closed"
737
    end
738
739
    test "only the author may edit or delete a note", %{conn: conn} do
740
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
741
      author = github_user("api-token-projects", "alice")
742
      {:ok, note} = Projects.create_project_note(project, %{"body" => "mine"}, author)
743
744
      mallory_conn =
745
        put_forge_api_token(build_conn(), "project-mallory-notes", "mallory", repository())
746
747
      assert patch(
748
               mallory_conn,
749
               ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes/#{note.id}",
750
               %{body: "not mine"}
751
             )
752
             |> json_response(403) == %{"message" => "Forbidden"}
753
754
      assert delete(
755
               recycle(mallory_conn),
756
               ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes/#{note.id}"
757
             )
758
             |> json_response(403) == %{"message" => "Forbidden"}
759
760
      assert patch(
761
               conn,
762
               ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes/#{note.id}",
763
               %{body: "mine, edited"}
764
             )
765
             |> json_response(200)
766
             |> Map.fetch!("body") == "mine, edited"
767
768
      assert delete(
769
               recycle(conn),
770
               ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes/#{note.id}"
771
             )
772
             |> response(204)
773
774
      assert Projects.count_project_notes(project) == 0
775
    end
776
777
    test "an activity entry cannot be edited or deleted", %{conn: conn} do
778
      project = project_fixture(%{title: "Roadmap", owner: "alice", state: "open"})
779
      user = github_user("api-token-projects", "alice")
780
      {:ok, _updated} = Projects.update_project(project, %{"state" => "closed"}, user)
781
782
      {[activity], 1} = Projects.list_project_notes_page(project, kind: "activity")
783
784
      assert patch(
785
               conn,
786
               ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes/#{activity.id}",
787
               %{body: "rewritten"}
788
             )
789
             |> json_response(403) == %{"message" => "Forbidden"}
790
791
      assert delete(
792
               recycle(conn),
793
               ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes/#{activity.id}"
794
             )
795
             |> json_response(403) == %{"message" => "Forbidden"}
796
797
      assert Projects.count_project_notes(project, kind: "activity") == 1
798
    end
799
800
    test "notes on a private repository stay hidden from a non-member", %{conn: conn} do
801
      project = project_fixture(%{title: "Alice only", owner: "alice"})
802
803
      {:ok, _note} =
804
        Projects.create_project_note(
805
          project,
806
          %{"body" => "private context"},
807
          github_user("api-token-projects", "alice")
808
        )
809
810
      assert get(
811
               build_conn(),
812
               ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes"
813
             )
814
             |> json_response(404) == %{"message" => "Not Found"}
815
816
      assert post(
817
               put_forge_api_token(build_conn(), "project-outsider-notes", "outsider"),
818
               ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes",
819
               %{body: "mine now"}
820
             )
821
             |> json_response(404) == %{"message" => "Not Found"}
822
823
      assert %{"notes" => [_note]} =
824
               get(
825
                 conn,
826
                 ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/notes"
827
               )
828
               |> json_response(200)
829
    end
830
  end
831
598 832
  defp repository, do: Process.get({__MODULE__, :repository})
599 833
600 834
  defp project_fixture(attrs) do
test/openagents_web/live/project_show_live_test.exs modified +124

@@ -171,6 +171,130 @@ defmodule OpenAgentsWeb.ProjectShowLiveTest do

171 171
    end
172 172
  end
173 173
174
  describe "description" do
175
    test "renders the description as Markdown, with an empty state when there is none", %{
176
      conn: conn
177
    } do
178
      project = project!()
179
180
      {:ok, view, html} = live(conn, path(project))
181
      assert html =~ "No description yet"
182
      assert has_element?(view, "#project-description-empty")
183
184
      {:ok, _updated} =
185
        Projects.update_project(project, %{"description" => "## Why\n\nProvider order."})
186
187
      html = render(view)
188
      assert html =~ "<h2>Why</h2>"
189
      refute html =~ "No description yet"
190
    end
191
192
    test "a member edits the description and the change lands in the activity feed", %{
193
      conn: conn
194
    } do
195
      project = project!()
196
197
      {:ok, view, _html} = live(conn, path(project))
198
199
      view |> element("#edit-description") |> render_click()
200
201
      html =
202
        view
203
        |> form("#project-description-form", project: %{description: "Operating notes."})
204
        |> render_submit()
205
206
      assert html =~ "Operating notes."
207
      assert html =~ "Updated the description."
208
209
      assert Projects.get_project_by_number!(repository(), project.number).description ==
210
               "Operating notes."
211
    end
212
  end
213
214
  describe "discussion" do
215
    test "an empty project shows the notes empty state", %{conn: conn} do
216
      project = project!()
217
218
      {:ok, view, html} = live(conn, path(project))
219
220
      assert html =~ "Discussion and activity"
221
      assert has_element?(view, "#project-notes-empty")
222
    end
223
224
    test "a member adds a note and can delete their own", %{conn: conn} do
225
      project = project!()
226
227
      {:ok, view, _html} = live(conn, path(project))
228
229
      html =
230
        view
231
        |> form("#project-note-form", note: %{body: "Lane 3 is paused."})
232
        |> render_submit()
233
234
      assert html =~ "Lane 3 is paused."
235
      assert [note] = elem(Projects.list_project_notes_page(project), 0)
236
      assert has_element?(view, "#project-note-#{note.id}")
237
238
      html = view |> element("#project-note-#{note.id} button", "Delete") |> render_click()
239
240
      refute html =~ "Lane 3 is paused."
241
      assert Projects.count_project_notes(project) == 0
242
    end
243
244
    test "pagination appears once the notes outrun one page", %{conn: conn} do
245
      project = project!()
246
      per_page = Projects.notes_per_page()
247
248
      for index <- 1..(per_page + 1) do
249
        {:ok, _note} = Projects.create_project_note(project, %{"body" => "note #{index}"})
250
      end
251
252
      {:ok, view, _html} = live(conn, path(project))
253
254
      assert has_element?(view, "#project-notes-pagination")
255
      assert render(view) =~ "Page 1 of 2"
256
257
      html = view |> element("#project-notes-pagination button", "Older") |> render_click()
258
259
      assert html =~ "Page 2 of 2"
260
      assert html =~ "note 1"
261
    end
262
263
    test "a note written remotely reaches an open board without a reload", %{conn: conn} do
264
      project = project!()
265
266
      {:ok, view, _html} = live(conn, path(project))
267
268
      {:ok, _note} =
269
        Projects.create_project_note(project, %{"body" => "Decided by the CLI."})
270
271
      {:ok, _updated} = Projects.update_project(project, %{"title" => "Renamed remotely"})
272
273
      html = render(view)
274
      assert html =~ "Decided by the CLI."
275
      assert html =~ "Renamed remotely"
276
    end
277
  end
278
279
  describe "a reader without write access" do
280
    setup %{conn: conn} do
281
      {:ok, conn: log_in_github_user(conn, "project-show-reader")}
282
    end
283
284
    test "reads the board and the notes but writes nothing", %{conn: conn} do
285
      project = project!()
286
      {:ok, _note} = Projects.create_project_note(project, %{"body" => "Context for readers."})
287
288
      {:ok, view, html} = live(conn, path(project))
289
290
      assert html =~ "Context for readers."
291
      assert has_element?(view, "#project-notes-unauthorized")
292
      refute has_element?(view, "#project-note-form")
293
      refute has_element?(view, "#new-project-item-form")
294
      refute has_element?(view, "#edit-description")
295
    end
296
  end
297
174 298
  defp repository do
175 299
    OpenAgents.Repositories.get_by_path!("OpenAgentsInc", "openagents.com")
176 300
  end

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