Add Phoenix-generated AGENTS.md with project-specific TDD workflow.

d89030049d36 · AtlantisPleb · · parent 6e46e74031bd

Add Phoenix-generated AGENTS.md with project-specific TDD workflow.

Copies the AGENTS.md generated by phx_new 1.8.9 as the starting
project guide, updates the DaisyUI guidance to match our stack, and
adds a test-driven-development section for the GitHub-compatible
Issues and Projects API.

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

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

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • added AGENTS.md

Diff

1 file changed, +495 -0

AGENTS.md added +495

@@ -0,0 +1,495 @@

1
This is a web application written using the Phoenix web framework.
2
3
## Project guidelines
4
5
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
6
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
7
8
### Phoenix v1.8 guidelines
9
10
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
11
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
12
- Anytime you run into errors with no `current_scope` assign:
13
  - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
14
  - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
15
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
16
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
17
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors
18
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
19
custom classes must fully style the input
20
21
### JS and CSS guidelines
22
23
- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces.
24
- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`:
25
26
      @import "tailwindcss" source(none);
27
      @source "../css";
28
      @source "../js";
29
      @source "../../lib/my_app_web";
30
31
- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new`
32
- **Never** use `@apply` when writing raw css
33
- **Use DaisyUI component classes and patterns** where they already cover the needed UI. Add custom Tailwind only when the design is unique to OpenAgents.
34
- Out of the box **only the app.js and app.css bundles are supported**
35
  - You cannot reference an external vendor'd script `src` or link `href` in the layouts
36
  - You must import the vendor deps into app.js and app.css to use them
37
  - **Never write inline <script>custom js</script> tags within templates**
38
39
### UI/UX & design guidelines
40
41
- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles
42
- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions)
43
- Ensure **clean typography, spacing, and layout balance** for a refined, premium look
44
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
45
46
47
<!-- usage-rules-start -->
48
49
<!-- phoenix:elixir-start -->
50
## Elixir guidelines
51
52
- Elixir lists **do not support index based access via the access syntax**
53
54
  **Never do this (invalid)**:
55
56
      i = 0
57
      mylist = ["blue", "green"]
58
      mylist[i]
59
60
  Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
61
62
      i = 0
63
      mylist = ["blue", "green"]
64
      Enum.at(mylist, i)
65
66
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
67
  you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
68
69
      # INVALID: we are rebinding inside the `if` and the result never gets assigned
70
      if connected?(socket) do
71
        socket = assign(socket, :val, val)
72
      end
73
74
      # VALID: we rebind the result of the `if` to a new variable
75
      socket =
76
        if connected?(socket) do
77
          assign(socket, :val, val)
78
        end
79
80
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
81
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
82
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
83
- Don't use `String.to_atom/1` on user input (memory leak risk)
84
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
85
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
86
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
87
88
## Mix guidelines
89
90
- Read the docs and options before using tasks (by using `mix help task_name`)
91
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
92
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
93
94
## Test guidelines
95
96
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
97
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
98
  - Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
99
100
      ref = Process.monitor(pid)
101
      assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
102
103
   - Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
104
<!-- phoenix:elixir-end -->
105
106
<!-- phoenix:phoenix-start -->
107
## Phoenix guidelines
108
109
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
110
111
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
112
113
      scope "/admin", AppWeb.Admin do
114
        pipe_through :browser
115
116
        live "/users", UserLive, :index
117
      end
118
119
  the UserLive route would point to the `AppWeb.Admin.UserLive` module
120
121
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
122
<!-- phoenix:phoenix-end -->
123
124
<!-- phoenix:ecto-start -->
125
## Ecto Guidelines
126
127
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
128
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
129
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
130
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
131
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
132
- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
133
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
134
<!-- phoenix:ecto-end -->
135
136
<!-- phoenix:html-start -->
137
## Phoenix HTML guidelines
138
139
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
140
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
141
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
142
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
143
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
144
145
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
146
147
  **Never do this (invalid)**:
148
149
      <%= if condition do %>
150
        ...
151
      <% else if other_condition %>
152
        ...
153
      <% end %>
154
155
  Instead **always** do this:
156
157
      <%= cond do %>
158
        <% condition -> %>
159
          ...
160
        <% condition2 -> %>
161
          ...
162
        <% true -> %>
163
          ...
164
      <% end %>
165
166
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
167
168
      <code phx-no-curly-interpolation>
169
        let obj = {key: "val"}
170
      </code>
171
172
  Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
173
174
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
175
176
      <a class={[
177
        "px-2 text-white",
178
        @some_flag && "py-5",
179
        if(@other_condition, do: "border-red-500", else: "border-blue-100"),
180
        ...
181
      ]}>Text</a>
182
183
  and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
184
185
  and **never** do this, since it's invalid (note the missing `[` and `]`):
186
187
      <a class={
188
        "px-2 text-white",
189
        @some_flag && "py-5"
190
      }> ...
191
      => Raises compile syntax error on invalid HEEx attr syntax
192
193
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
194
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
195
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
196
197
  **Always** do this:
198
199
      <div id={@id}>
200
        {@my_assign}
201
        <%= if @some_block_condition do %>
202
          {@another_assign}
203
        <% end %>
204
      </div>
205
206
  and **Never** do this – the program will terminate with a syntax error:
207
208
      <%!-- THIS IS INVALID NEVER EVER DO THIS --%>
209
      <div id="<%= @invalid_interpolation %>">
210
        {if @invalid_block_construct do}
211
        {end}
212
      </div>
213
<!-- phoenix:html-end -->
214
215
<!-- phoenix:liveview-start -->
216
## Phoenix LiveView guidelines
217
218
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and  `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
219
- **Avoid LiveComponent's** unless you have a strong, specific need for them
220
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
221
222
### LiveView streams
223
224
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
225
  - basic append of N items - `stream(socket, :messages, [new_msg])`
226
  - resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
227
  - prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
228
  - deleting items - `stream_delete(socket, :messages, msg)`
229
230
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
231
232
      <div id="messages" phx-update="stream">
233
        <div :for={{id, msg} <- @streams.messages} id={id}>
234
          {msg.text}
235
        </div>
236
      </div>
237
238
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
239
240
      def handle_event("filter", %{"filter" => filter}, socket) do
241
        # re-fetch the messages based on the filter
242
        messages = list_messages(filter)
243
244
        {:noreply,
245
         socket
246
         |> assign(:messages_empty?, messages == [])
247
         # reset the stream with the new messages
248
         |> stream(:messages, messages, reset: true)}
249
      end
250
251
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
252
253
      <div id="tasks" phx-update="stream">
254
        <div class="hidden only:block">No tasks yet</div>
255
        <div :for={{id, task} <- @streams.tasks} id={id}>
256
          {task.name}
257
        </div>
258
      </div>
259
260
  The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
261
262
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
263
  along with the updated assign:
264
265
      def handle_event("edit_message", %{"message_id" => message_id}, socket) do
266
        message = Chat.get_message!(message_id)
267
        edit_form = to_form(Chat.change_message(message, %{content: message.content}))
268
269
        # re-insert message so @editing_message_id toggle logic takes effect for that stream item
270
        {:noreply,
271
         socket
272
         |> stream_insert(:messages, message)
273
         |> assign(:editing_message_id, String.to_integer(message_id))
274
         |> assign(:edit_form, edit_form)}
275
      end
276
277
  And in the template:
278
279
      <div id="messages" phx-update="stream">
280
        <div :for={{id, message} <- @streams.messages} id={id} class="flex group">
281
          {message.username}
282
          <%= if @editing_message_id == message.id do %>
283
            <%!-- Edit mode --%>
284
            <.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
285
              ...
286
            </.form>
287
          <% end %>
288
        </div>
289
      </div>
290
291
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
292
293
### LiveView JavaScript interop
294
295
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
296
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
297
298
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
299
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
300
301
#### Inline colocated js hooks
302
303
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
304
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
305
when writing scripts inside the template**:
306
307
    <input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
308
    <script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
309
      export default {
310
        mounted() {
311
          this.el.addEventListener("input", e => {
312
            let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
313
            if(match) {
314
              this.el.value = `${match[1]}-${match[2]}-${match[3]}`
315
            }
316
          })
317
        }
318
      }
319
    </script>
320
321
- colocated hooks are automatically integrated into the app.js bundle
322
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
323
324
#### External phx-hook
325
326
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
327
LiveSocket constructor:
328
329
    const MyHook = {
330
      mounted() { ... }
331
    }
332
    let liveSocket = new LiveSocket("/live", Socket, {
333
      hooks: { MyHook }
334
    });
335
336
#### Pushing events between client and server
337
338
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
339
**Always** return or rebind the socket on `push_event/3` when pushing events:
340
341
    # re-bind socket so we maintain event state to be pushed
342
    socket = push_event(socket, "my_event", %{...})
343
344
    # or return the modified socket directly:
345
    def handle_event("some_event", _, socket) do
346
      {:noreply, push_event(socket, "my_event", %{...})}
347
    end
348
349
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
350
351
    mounted() {
352
      this.handleEvent("my_event", data => console.log("from server:", data));
353
    }
354
355
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
356
357
    mounted() {
358
      this.el.addEventListener("click", e => {
359
        this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
360
      })
361
    }
362
363
Where the server handled it via:
364
365
    def handle_event("my_event", %{"one" => 1}, socket) do
366
      {:reply, %{two: 2}, socket}
367
    end
368
369
### LiveView tests
370
371
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
372
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
373
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
374
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
375
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
376
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
377
- Focus on testing outcomes rather than implementation details
378
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
379
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
380
381
      html = render(view)
382
      document = LazyHTML.from_fragment(html)
383
      matches = LazyHTML.filter(document, "your-complex-selector")
384
      IO.inspect(matches, label: "Matches")
385
386
### Form handling
387
388
#### Creating a form from params
389
390
If you want to create a form based on `handle_event` params:
391
392
    def handle_event("submitted", params, socket) do
393
      {:noreply, assign(socket, form: to_form(params))}
394
    end
395
396
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
397
398
You can also specify a name to nest the params:
399
400
    def handle_event("submitted", %{"user" => user_params}, socket) do
401
      {:noreply, assign(socket, form: to_form(user_params, as: :user))}
402
    end
403
404
#### Creating a form from changesets
405
406
When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
407
408
    defmodule MyApp.Users.User do
409
      use Ecto.Schema
410
      ...
411
    end
412
413
And then you create a changeset that you pass to `to_form`:
414
415
    %MyApp.Users.User{}
416
    |> Ecto.Changeset.change()
417
    |> to_form()
418
419
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
420
421
In the template, the form form assign can be passed to the `<.form>` function component:
422
423
    <.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
424
      <.input field={@form[:field]} type="text" />
425
    </.form>
426
427
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
428
429
#### Avoiding form errors
430
431
**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
432
433
    <%!-- ALWAYS do this (valid) --%>
434
    <.form for={@form} id="my-form">
435
      <.input field={@form[:field]} type="text" />
436
    </.form>
437
438
And **never** do this:
439
440
    <%!-- NEVER do this (invalid) --%>
441
    <.form for={@changeset} id="my-form">
442
      <.input field={@changeset[:field]} type="text" />
443
    </.form>
444
445
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
446
- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
447
<!-- phoenix:liveview-end -->
448
449
## Test-driven development workflow
450
451
We are driving the GitHub-compatible Issues and Projects API through tests. This section applies while we build that surface.
452
453
### Principles
454
455
- One endpoint at a time. Each new endpoint starts with a failing test before the route, controller, context, or schema exists.
456
- Tests are the contract. The GitHub REST API description in `docs/github-api-issues-projects-assessment.md` is the source of truth for paths, status codes, and JSON shape.
457
- Use `OpenAgentsWeb.ConnCase` for controller and JSON API tests, and `OpenAgents.DataCase` for domain tests.
458
- Use `Req` for outbound fixtures we mirror. Prefer captured GitHub API responses stored in `test/fixtures/github/*.json`.
459
- Focus each test on one thing: status, a few key fields, and the link between the request and the database.
460
- Avoid `Process.sleep/1` in tests. Use monitors and `assert_receive` when waiting for process teardown.
461
462
### Order of work
463
464
Build the endpoints in this order:
465
466
1. `GET /api/v3/repos/{owner}/{repo}/issues` — list
467
2. `GET /api/v3/repos/{owner}/{repo}/issues/{issue_number}` — get
468
3. `POST /api/v3/repos/{owner}/{repo}/issues` — create
469
4. `PATCH /api/v3/repos/{owner}/{repo}/issues/{issue_number}` — update
470
5. `GET/POST /api/v3/repos/{owner}/{repo}/issues/{issue_number}/comments` — comments
471
6. `GET/POST/DELETE /api/v3/repos/{owner}/{repo}/issues/{issue_number}/assignees` — assignees
472
7. `GET/POST/DELETE /api/v3/repos/{owner}/{repo}/issues/{issue_number}/labels` — labels
473
8. `GET/POST/DELETE /api/v3/repos/{owner}/{repo}/milestones` — milestones
474
9. `/api/v3/users/{username}/projectsV2/*` — project read and write
475
476
### Test anatomy
477
478
Each endpoint test file follows this shape:
479
480
    defmodule OpenAgentsWeb.IssueControllerTest do
481
      use OpenAgentsWeb.ConnCase
482
483
      test "GET /api/v3/repos/:owner/:repo/issues lists open issues", %{conn: conn} do
484
        conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues")
485
        assert json_response(conn, 200)["issues"] != nil
486
      end
487
    end
488
489
### Running tests
490
491
- Run one file: `mix test test/openagents_web/controllers/issue_controller_test.exs`
492
- Run a failed set: `mix test --failed`
493
- Before a final commit: run `mix precommit` and fix all compile warnings, formatting, and test failures.
494
495
<!-- usage-rules-end -->
\ No newline at end of file

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