Open issue reading to everyone, filing to sign-ins, and members to owners

f7f831c676da · AtlantisPleb · · parent 5a1dfc5b9590

Open issue reading to everyone, filing to sign-ins, and members to owners

The participation model splits reading from authority the way GitHub
does: anyone reads issues on a public repository, any signed-in person
files and comments, an author edits and closes their own report, and
triage stays behind writable membership. Every event handler re-checks
authority at the server, and the route ledger classifies the issue
pages as public read.

With that boundary in place the tracker can actually be operated:

- A members page gives owners listing, add-by-login, role changes, and
  removal, with last-owner protection and audit records on every move.
- The issue index gains label/assignee/milestone filters, literal text
  search, pagination, filter-aware tab counts, and PubSub live updates.
- Default GitHub labels seed onto every created or imported repository.
- Four pinned API deviations close: new_name renames labels,
  create-on-add for missing labels, 404 for removing an unattached
  label, and path-correct label URL encoding.

The JSON API's member-gated writes are unchanged; the participation
model is a browser-session contract. The runbook records the
pull-request product stance -- general capability, disable-able per
repository, default on, off for openagents.com -- while push remains
membership-gated so external contribution stays issue-shaped until the
switch ships.

Changelog: Anyone can read issues on public repositories; sign in once to file or comment, no invitation needed.
Changelog: Repository owners now manage members at /:owner/:repo/members.
Changelog: Issue lists gained filters, search, pagination, and live updates.

Changelog-Category: feature

Changelog-Visibility: public
Changelog
Anyone can read issues on public repositories; sign in once to file or comment, no invitation needed.
Changelog
Repository owners now manage members at /:owner/:repo/members.
Changelog
Issue lists gained filters, search, pagination, and live updates.
Changelog-Category
feature
Changelog-Visibility
public

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified docs/2026-08-21-issue-project-triage-runbook.md
  • modified docs/github-api-issues-projects-assessment.md
  • modified docs/issues-projects-ui-roadmap.md
  • modified lib/openagents/issues.ex
  • modified lib/openagents/labels.ex
  • modified lib/openagents/repositories.ex
  • modified lib/openagents_web/controllers/issue_label_controller.ex
  • modified lib/openagents_web/controllers/label_controller.ex
  • modified lib/openagents_web/controllers/label_json.ex
  • modified lib/openagents_web/live/issue_index_live.ex
  • modified lib/openagents_web/live/issue_new_live.ex
  • modified lib/openagents_web/live/issue_show_live.ex
  • added lib/openagents_web/live/member_index_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • added test/openagents/issues_query_test.exs
  • modified test/openagents/issues_test.exs
  • added test/openagents/repositories_membership_test.exs
  • modified test/openagents/repositories_test.exs
  • modified test/openagents_web/controllers/issue_label_controller_test.exs
  • modified test/openagents_web/controllers/label_controller_test.exs
  • added test/openagents_web/live/issue_access_live_test.exs
  • modified test/openagents_web/live/issue_index_live_test.exs
  • modified test/openagents_web/live/issue_show_live_test.exs
  • added test/openagents_web/live/member_index_live_test.exs

Diff

25 files changed, +2114 -428

docs/2026-08-21-issue-project-triage-runbook.md modified +207 -256

@@ -2,22 +2,23 @@

2 2
3 3
Date: 2026-08-21
4 4
5
Status: Assessed against production at commit `a40a799`. The runbook describes
6
the interim process you can run today; the gap list describes what must change
7
before community issue submission works without manual steps.
5
Status: Current. The gap list from the morning assessment is implemented and
6
covered by tests; this document records what ships, the pull-request product
7
stance, and how to operate triage day to day.
8 8
9 9
## Purpose
10 10
11
OpenAgents just cut over to production, the first repository
12
(`OpenAgentsInc/openagents.com`) is being imported, and the goal is to start
13
building a public backlog: people read issues and submit them, maintainers
14
triage them, pull requests stay closed to the public for now.
11
OpenAgents cut over to production, imported the first repository
12
(`OpenAgentsInc/openagents.com`), and opened the tracker for a public backlog:
13
anyone can read issues and, once signed in, file them. Maintainers triage.
14
Pull requests stay closed on this repository while the backlog forms, and the
15
product direction for them is written down below.
15 16
16 17
This document has three parts:
17 18
18
1. What the issues-and-projects system implements today.
19
2. The gaps that stand between that implementation and public triage, ranked.
20
3. A triage process and runbook you can operate with what ships now.
19
1. What the issues-and-projects system implements now.
20
2. The pull-request product stance and what deliberately remains unbuilt.
21
3. A triage process and runbook you can operate today.
21 22
22 23
## Part 1: What is implemented
23 24

@@ -26,177 +27,138 @@ This document has three parts:

26 27
- Sign-in is GitHub OAuth. Namespaces are keyed to immutable GitHub numeric
27 28
  IDs; logins are mutable projections.
28 29
- Browser mutations use the signed session. API writes use a personal access
29
  token (`oa_pat_…`) with the exact `forge:write` scope, created and revoked at
30
  `/settings/api-tokens`; plaintext is shown once.
30
  token (`oa_pat_…`) with the exact `forge:write` scope, created and revoked
31
  at `/settings/api-tokens`; plaintext is shown once.
31 32
- Repository roles are `owner`, `maintainer`, `contributor` (writable), and
32
  `viewer` (read-only). Every GitHub sign-in used to grant `contributor` on the
33
  initial repository; commit `348dcc8` removed that auto-grant, so membership
34
  is now explicit.
35
36
### Repositories and Git transport
37
38
- Create, one-time GitHub import, list, and view work through both the browser
39
  (`/repositories`, `/repositories/new`, `/repositories/import/github`) and
40
  the API (`POST /api/v3/user/repos`, `POST /api/v3/user/repos/imports`, and
41
  organization equivalents). Provisioning runs through a durable outbox.
42
- Git smart HTTP serves `https://openagents.com/{owner}/{repo}.git` plus a
43
  legacy `/git` compatibility route. Public repositories allow anonymous clone
44
  and fetch. Push requires repository membership with a writable role,
45
  authenticated by PAT over HTTP Basic or the CLI credential helper. Machine
46
  (`smct_…`) and operator lanes are separate.
47
- The CLI (`@openagentsinc/cli`, published on npm) handles auth login,
48
  `repo create|import|list|view|clone`, and Git credential setup.
49
- Public code browsing (repository home, commit, blob) reads from the WAL and
50
  requires no sign-in.
51
52
### Issues
53
54
The GitHub-shaped subset is implemented end to end:
55
56
- List, get, create, update, close, reopen with `state_reason`
57
  (`completed`, `not_planned`, `duplicate`, `reopened`).
58
- Comments (list, create, edit, delete).
59
- Issue numbers are repository-local; composite foreign keys make cross-
60
  repository references impossible at the database level.
61
- JSON API: anonymous public reads on `/api/v3/repos/{owner}/{repo}/issues`;
62
  token-authenticated writes.
63
- Web UI (`/:owner/:repo/issues`) has open/closed tabs, inline state changes
64
  with close reasons, inline assignee toggles, label and milestone pickers in
65
  the issue rail, and a comment timeline.
66
67
### Labels, milestones, and assignees
68
69
- Full CRUD through UI and API for labels and milestones.
70
- Assignees resolve to active repository members with writable roles;
71
  assignment of arbitrary logins is rejected.
72
73
### Projects V2
74
75
- Bounded subset: list, get, create projects; add items; update item field
76
  values; list fields.
77
- The board (`/:owner/:repo/projects/:number`) renders columns from the values
78
  of a "Status" project field ("To Do", "In Progress", "Done") and links items
79
  to issues.
33
  `viewer` (read-only). Membership is explicit and managed through product
34
  surfaces, not consoles.
35
36
### The participation model
37
38
Reading and writing split cleanly, GitHub-style:
39
40
| Who | Read issues | File issues | Comment | Triage (label, assign, milestone, close others') |
41
| --- | --- | --- | --- | --- |
42
| Anonymous visitor | Public repositories | No | No | No |
43
| Signed in, not a member | Public repositories | Yes | Yes | No |
44
| An issue's author | Their issue | Already filed | Yes | Edit title/body, close, reopen their own |
45
| Repository member with write role | All visible repositories | Yes | Yes | Yes |
46
| `viewer` member | Including private repositories | On public only | On public only | No |
47
48
Rules the server enforces, regardless of what any client renders:
49
50
- Issue reading runs behind plain `:browser`; the route ledger classifies it
51
  `public_read` with scope `forge:issues:web`. A hand-crafted event from a
52
  viewer without authority is refused with a flash message, never applied.
53
- Filing requires an identity: `/issues/new` stays behind sign-in, because an
54
  issue without an author cannot be triaged honestly. The author is recorded
55
  on the issue.
56
- Every mutation re-checks authority in the view's event handlers. The
57
  templates hide controls for viewers who lack them, but hiding is courtesy,
58
  not security.
59
60
### Membership management
61
62
`/:owner/:repo/members`, owner-only, lists members with roles and supports:
63
64
- Adding a member by GitHub login (the person must have signed in once).
65
- Changing roles across the four levels.
66
- Removing members.
67
- Last-owner protection: the final `owner` cannot be demoted or removed.
68
69
Every change flows through `OpenAgents.Repositories` functions that write
70
audit records naming the actor, subject, action, and role. Non-owners are
71
redirected; non-members get the same quiet bounce as a nonexistent
72
repository, so the page never confirms who has access to what.
73
74
### Repositories, Git transport, and code browsing
75
76
- Create, one-time GitHub import, list, and view work through the browser,
77
  the API, and the published CLI (`@openagentsinc/cli` on npm). Provisioning
78
  runs through a durable outbox.
79
- Git smart HTTP serves `https://openagents.com/{owner}/{repo}.git`. Public
80
  repositories allow anonymous clone and fetch. Push requires repository
81
  membership with a write role, authenticated by PAT over HTTP Basic or the
82
  CLI credential helper.
83
- Default labels seed automatically on every repository creation and import:
84
  `bug`, `documentation`, `duplicate`, `enhancement`, `good first issue`,
85
  `help wanted`, `invalid`, `question`, `wontfix`.
86
87
### Issue list ergonomics
88
89
The issue index has grown past open/closed tabs:
90
91
- Filters by label, assignee, and milestone, plus text search across titles
92
  and bodies (wildcards escaped; what you type is matched literally).
93
- Pagination at 25 issues per page with Previous and Next, and a row count.
94
- The Open and Closed tab counts respect the active filters, so the counts
95
  and the list always agree — they read the same query.
96
97
### Live updates
98
99
Every committed issue write broadcasts on a per-repository topic. The index
100
and detail views subscribe and re-read through the viewer's own authorization,
101
so two people triaging together converge instead of drifting. The message
102
carries the repository id and nothing else; subscribers re-read rather than
103
trust payloads.
104
105
### JSON API compatibility
106
107
Four pinned deviations from GitHub behavior were closed:
108
109
- Label URLs percent-encode as paths (`good%20first%20issue`), and the
110
  advertised URL resolves through the show endpoint.
111
- `PATCH .../labels/:name` renames through `new_name`.
112
- Adding a nonexistent label to an issue creates it with a generated color,
113
  instead of returning 404.
114
- Removing a label an issue does not wear returns 404, instead of silently
115
  succeeding.
116
117
Issue creation with unknown labels remains strict (422); the add-to-issue
118
endpoint creates on the fly, matching GitHub. Cross-repository isolation is
119
unchanged: composite foreign keys still make cross-repo references
120
impossible, and a name collision between repositories produces two
121
independent labels rather than a link.
80 122
81 123
### Analytics
82 124
83 125
PostHog captures `issue_created`, `issue_updated`, `issue_commented`,
84 126
`label_created`, `milestone_created`, `project_created`, and
85
`project_item_added` at the domain-context choke points, so triage activity is
86
measurable from day one.
127
`project_item_added` at the domain-context choke points.
87 128
88 129
### Tests
89 130
90
The issues-and-projects layer went from 33% to 98% line coverage in the
91
August coverage push; 23 previously untested modules including all eight
92
LiveViews are covered. Two real defects were found and fixed this way
93
(including a `ProjectShowLive` that had never rendered).
131
The suite covers all of the above: the participation matrix for anonymous
132
visitors, non-member filers, authors, and members; the members page including
133
last-owner protection; filter, search, and pagination semantics; broadcast
134
delivery; and each compatibility fix against the endpoint contract it
135
restores.
94 136
95
## Part 2: Gaps between here and public triage
137
## Part 2: The pull-request product stance
96 138
97
Ranked by how much they block the stated goal. Items 1–3 block it outright.
139
Recorded direction, stated plainly so nobody has to rediscover it:
98 140
99
### 1. No membership management surface — the blocker
141
- Pull requests will exist as a general repository capability.
142
- They will be disable-able per repository.
143
- New repositories default to **enabled**.
144
- `OpenAgentsInc/openagents.com` defaults to **disabled**: contribution to
145
  this repository starts with issues, not patches.
146
- Priority is explicitly lower than smooth issue interaction, which is why it
147
  is not part of this pass.
100 148
101
`OpenAgents.Repositories.add_member/3` exists, writes audit records, and has
102
no caller anywhere in product code. OAuth no longer auto-grants membership.
103
Consequence: nobody who signs up from now on can see or touch issues, and
104
there is no UI, API, or admin page that fixes that. Today the only way to add
105
a maintainer or reporter is a production console command (see the runbook).
149
Nothing about that stance needs guarding in the meantime, because the Git
150
plane already behaves as if pull requests were switched off everywhere: push
151
requires a writable repository membership, so external participation is
152
issue-shaped by construction. When the feature lands, the per-repository
153
switch turns the existing membership gate into a policy knob instead of a
154
hard rule, and this repository's switch starts at `off`.
106 155
107
**Recommendation:** ship the smallest possible surface first — an owner-only
108
"Members" section on the repository settings page that lists members and adds
109
one by GitHub login with a role. It needs one context function that already
110
exists, audit records that already exist, and one LiveView.
156
Deliberately not built yet, in priority order:
111 157
112
### 2. Issue pages require writable membership
113
114
Every issue LiveView resolves the repository with
115
`Repositories.get_writable_by_path!/3`, which raises when the viewer lacks a
116
writable-role membership. A signed-in non-member gets an exception page instead
117
of a 404 or a read-only view, and an anonymous visitor cannot view issues at
118
all even on a public repository — while anonymous code browsing works.
119
120
The read path already exists: `get_visible_by_path!/3` returns public
121
repositories to anyone and private ones to any member, including `viewer`.
122
123
**Recommendation:** split reads from writes in the issue LiveViews. Read with
124
`get_visible_by_path!` (plus an anonymous path for public repositories), gate
125
every mutation behind writability, and render a clean "sign in to interact"
126
state otherwise. This is also what makes the public backlog readable, which is
127
the point of having one.
128
129
### 3. Submission policy is undecided
130
131
Today, opening an issue requires a writable membership, because `contributor`
132
was the only door in. That conflates two different grants: "can report bugs"
133
and "can push code". GitHub's model separates them: anyone signed in can open
134
an issue on a public repository; only collaborators can label, assign,
135
close, or edit.
136
137
**Recommendation:** adopt the GitHub model. Allow any active signed-in user to
138
create issues and comments on public repositories, recorded with their author
139
attribution, and keep every other mutation behind membership. This matches the
140
existing data model (issues already carry an author), keeps the API contract
141
unchanged, and turns "submit an issue" into a link instead of a manual grant.
142
143
Until that lands, the interim policy in Part 3 uses manual grants.
144
145
### 4. New repositories have no default labels
146
147
A freshly created repository has an empty label set, so the first triage pass
148
has nothing to attach. **Recommendation:** seed GitHub's default set on
149
repository creation (`bug`, `documentation`, `duplicate`, `enhancement`,
150
`good first issue`, `help wanted`, `invalid`, `question`, `wontfix`). Until
151
then, seed once per repository through the API (scripted in the runbook).
152
153
### 5. List ergonomics stop at open/closed tabs
154
155
The issue list has state tabs only: no filter by label, assignee, or milestone;
156
no search; no sorting; no pagination (the list loads every row). The design
157
ruling in `2026-08-20-linear-design-github-shape.md` notes grouping, sorting,
158
and filtering are free — arithmetic over existing responses — so these are low
159
risk. **Recommendation:** filter-by-label and filter-by-assignee first, then
160
pagination, then text search.
161
162
### 6. Concurrent triage has no live updates
163
164
Issue pages load once; there is no PubSub subscription, so two maintainers
165
triaging simultaneously see stale rows until reload. Repository provisioning
166
already demonstrates the broadcast pattern. **Recommendation:** subscribe the
167
issue index and show views to a per-repository topic and re-stream on change.
168
169
### 7. Known compatibility sharp edges
170
171
Pinned by tests, still present at `a40a799`. None block triage, but each will
172
bite a scripted client eventually:
173
174
- `LabelJSON` renders label URLs with `URI.encode_www_form/1`, so a label
175
  named `good first issue` advertises a URL its own endpoint cannot resolve.
176
- `PATCH .../labels/:name` ignores `new_name`; renaming is impossible through
177
  the API.
178
- Adding a nonexistent label to an issue returns 404 where GitHub creates the
179
  label.
180
- Removing a label an issue does not have is a silent no-op where GitHub
181
  returns 404.
182
183
**Recommendation:** fix the URL encoding first (it is a correctness bug in
184
advertised data), batch the rest into one compatibility pass.
185
186
### 8. Notifications do not exist yet
187
188
There is no email and no mention machinery; the mailer is development-only.
189
A backlog stays healthy when reporters hear back. Acceptable to defer while
190
the community is small and responses happen in chat, but it should not be
191
deferred past the first hundred external issues.
192
193
### 9. Pull requests stay out — deliberately
194
195
Pull requests, reviews, and fork workflows are unimplemented, and per the
196
current direction they should stay that way while the backlog forms. The Git
197
plane already enforces the useful half of this: only members with writable
198
roles can push, so external contribution is limited to issues until you decide
199
otherwise.
158
1. Pull requests with a per-repository enable/disable switch.
159
2. Notifications (email or mentions). Acceptable while the community is small
160
   and responses happen in chat; revisit before the first hundred external
161
   issues.
200 162
201 163
## Part 3: Triage runbook
202 164

@@ -204,76 +166,56 @@ otherwise.

204 166
205 167
| Role | Can do |
206 168
| --- | --- |
207
| `owner` | Everything, plus member management (console until gap 1 closes) |
208
| `maintainer` | Triage: label, assign, milestone, close, edit any issue |
209
| `contributor` | Open and comment on issues; push if granted Git access |
210
| `viewer` | Read private-repository content; no writes |
211
212
While gap 2 stands, all of these roles also gate *viewing* the web UI, which
213
is why setup begins with grants.
214
215
### One-time production setup
216
217
Run these once, after the import finishes and the repository reports `ready`.
218
219
1. Verify the repository exists and is public:
220
221
   ```sh
222
   curl -s https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.com | grep visibility
223
   ```
224
225
2. Seed the default labels. Run once per missing label:
169
| `owner` | Everything, including managing members |
170
| `maintainer` | Triage: label, assign, set milestones, close, edit any issue |
171
| `contributor` | File and comment on issues; push if granted Git access |
172
| `viewer` | Read private-repository content; join public conversations |
226 173
227
   ```sh
228
   TOKEN=oa_pat_your_token   # created at /settings/api-tokens, forge:write scope
229
   BASE=https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.com/labels
174
### One-time setup for this repository
230 175
231
   for spec in bug:d73a4a documentation:0075ca duplicate:cfd3d7 \
232
     enhancement:a2eeef "good first issue:7057ff" help wanted:008672 \
233
     invalid:e4e669 question:d876e3 wontfix:ffffff; do
234
     name=${spec%%:*}; color=${spec#*:}
235
     curl -s -X POST "$BASE" -H "Authorization: Bearer $TOKEN" \
236
       -H "Content-Type: application/json" \
237
       -d "{\"name\": \"$name\", \"color\": \"$color\"}" >/dev/null
238
   done
239
   ```
176
The initial repository was imported before automatic label seeding shipped,
177
so give it the default vocabulary once. Either use the Labels page
178
(`/:owner/:repo/labels`) or run the equivalent calls:
240 179
241
3. Grant memberships to the initial maintainers. Until gap 1 closes this needs
242
   a console session on a production node:
243
244
   ```elixir
245
   alias OpenAgents.{Accounts, Repo, Repositories}
246
247
   repo = Repositories.get_by_path!("OpenAgentsInc", "openagents.com")
248
   user = Repo.get_by!(Accounts.User, github_login: "their-github-login")
249
   Repositories.add_member(repo, user, "maintainer")
250
   ```
180
```sh
181
TOKEN=oa_pat_your_token   # created at /settings/api-tokens, forge:write scope
182
BASE=https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.com/labels
183
184
for spec in bug:d73a4a documentation:0075ca duplicate:cfd3d7 \
185
  enhancement:a2eeef "good first issue:7057ff" help wanted:008672 \
186
  invalid:e4e669 question:d876e3 wontfix:ffffff; do
187
  name=${spec%%:*}; color=${spec#*:}
188
  curl -s -X POST "$BASE" -H "Authorization: Bearer $TOKEN" \
189
    -H "Content-Type: application/json" \
190
    -d "{\"name\": \"$name\", \"color\": \"$color\"}" >/dev/null
191
done
192
```
251 193
252
   Grant `maintainer` to everyone who will run triage; keep `owner` to two or
253
   three people. Each call writes an `Audit` record.
194
Repositories created from now on get these labels automatically.
254 195
255
4. Create the first milestone so work has a destination:
196
Then grant maintainer roles to everyone who will run triage:
256 197
257
   ```sh
258
   curl -s -X POST "$BASE/../milestones" -H "Authorization: Bearer $TOKEN" \
259
     -H "Content-Type: application/json" \
260
     -d '{"title": "Backlog hygiene", "description": "First triage sweep", "state": "open"}'
261
   ```
198
1. Each person signs in at `openagents.com` once with GitHub.
199
2. An owner opens `/:owner/:repo/members`, adds their login with the
200
   `maintainer` role, and saves.
201
3. Keep `owner` to two or three people; the last owner is protected, but a
202
   single-owner repository still has a bus factor of one.
262 203
263
5. Create a project for the public board:
204
Finally, create a starting milestone and project so triaged work has a
205
destination:
264 206
265
   ```sh
266
   curl -s -X POST "https://openagents.com/api/v3/OpenAgentsInc/projectsV2" \
267
     -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
268
     -d '{"title": "openagents.com roadmap"}'
269
   ```
207
```sh
208
curl -s -X POST "$BASE/../milestones" -H "Authorization: Bearer $TOKEN" \
209
  -H "Content-Type: application/json" \
210
  -d '{"title": "Backlog hygiene", "description": "First triage sweep"}'
270 211
271
   Then add its Status field values ("To Do", "In Progress", "Done") through
272
   the project fields API, and pin high-signal issues to the board with
273
   `project_item_added`.
212
curl -s -X POST "https://openagents.com/api/v3/OpenAgentsInc/projectsV2" \
213
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
214
  -d '{"title": "openagents.com roadmap"}'
215
```
274 216
275
6. Record who holds which role in your own notes; there is no UI to look it up
276
   until gap 1 closes.
217
Add the Status field values ("To Do", "In Progress", "Done") through the
218
project fields API, then pin high-signal issues to the board.
277 219
278 220
### Label vocabulary
279 221

@@ -311,37 +253,38 @@ Ten minutes, ideally same time each day:

311 253
   - Answered questions: close with reason `completed`.
312 254
5. Move anything scheduled onto the project board and into a milestone. The
313 255
   board is the promise; the milestone is the deadline.
314
6. Sweep the closed tab briefly: a wrong close is cheaper to catch the same
256
6. Sweep the Closed tab briefly: a wrong close is cheaper to catch the same
315 257
   day than next week.
316 258
317
Everything above mutates through either the issue rail in the UI or the
318
equivalent PATCH calls below.
259
Use the filters to work in slices — `label:bug` for the bug sweep,
260
`assignee:you` for your queue, the search box to check whether a new report
261
duplicates an old one before you label it. The page updates live while other
262
maintainers triage alongside you.
319 263
320 264
### The weekly review
321 265
322 266
1. Walk the project board column by column. "In Progress" older than a week
323 267
   gets an owner, a milestone, or moves back to "To Do".
324 268
2. Check the milestone: scope fixed, due date honest, nothing stuck.
325
3. Skim PostHog for `issue_created` volume versus `issue_commented` — a rising
326
   creation rate with flat response rate means triage is falling behind.
327
4. Confirm every new triage participant has a membership row (console check
328
   until the members UI exists).
269
3. Skim PostHog for `issue_created` volume versus `issue_commented` — a
270
   rising creation rate with flat response rate means triage is falling
271
   behind.
272
4. Review the members list on the members page: departed maintainers lose
273
   their role the week they leave, not someday.
329 274
5. File the meta-issue: anything about the triage process itself that hurt
330 275
   this week becomes an issue labeled `enhancement` on this same tracker.
331 276
332
### Interim community submission policy
277
### Community submissions
333 278
334
Until gaps 1–3 close, the honest flow for an outside reporter is:
279
The standing policy, publishable anywhere the tracker is announced:
335 280
336
1. They sign in with GitHub at `openagents.com`.
337
2. You grant their account `contributor` from the console (step 3 of setup).
338
3. They open issues and comment; they cannot push unless you separately intend
339
   them to, since Git push checks the same membership table but is a distinct
340
   decision you control by role.
281
1. Sign in with GitHub at `openagents.com`.
282
2. Open issues on any public repository — no invitation, no membership.
283
3. Expect triage: a maintainer labels and responds. Comments stay open, so
284
   the conversation continues on your issue.
341 285
342
Publish this expectation wherever you announce the tracker, or hold public
343
submissions until the GitHub-model change lands — a crash page for eager
344
first-time reporters is worse than "issue tracker opens next week".
286
Reporters who later become contributors get roles through the members page;
287
that is a deliberate human decision, not an automatic upgrade.
345 288
346 289
### API recipes
347 290

@@ -359,6 +302,10 @@ curl -s -X POST "https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.co

359 302
  -d '{"title": "Search returns duplicates", "body": "Steps to reproduce...", "labels": ["bug"]}'
360 303
```
361 304
305
Note the difference in kind: the JSON API's write path stays
306
membership-gated. The participation model is a browser-session contract; PAT
307
holders remain collaborators.
308
362 309
Close as not planned:
363 310
364 311
```sh

@@ -367,30 +314,34 @@ curl -s -X PATCH "https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.c

367 314
  -d '{"state": "closed", "state_reason": "not_planned"}'
368 315
```
369 316
370
Label and assign in one update: `labels` takes names, `assignees` takes
371
logins, and both replace the full set, so send the complete desired lists.
317
Label and assign in one update: `labels` takes names (creating missing ones),
318
`assignees` takes logins, and both replace the full set, so send the complete
319
desired lists.
372 320
373 321
### Measuring triage health
374 322
375 323
PostHog events land at the domain boundary, so the funnel is queryable:
376 324
377
- Median time from `issue_created` to first `issue_commented` by a maintainer:
378
  your response-time promise.
325
- Median time from `issue_created` to first `issue_commented` by a
326
  maintainer: your response-time promise.
379 327
- `issue_created` count per week versus issues closed per week: backlog
380 328
  direction.
381
- Share of issues carrying zero labels after 24 hours: triage loop discipline.
329
- Share of issues carrying zero labels after 24 hours: triage loop
330
  discipline.
382 331
383
Track them as three insights on one dashboard before the first announcement;
332
Track them as three insights on one dashboard before the next announcement;
384 333
retrofitting measurement onto a neglected backlog is miserable.
385 334
386 335
## Guardrails
387 336
388
- Do not widen Git push beyond repository members; that is the mechanism that
389
  keeps public participation issue-shaped while pull requests remain closed.
337
- Do not widen Git push beyond repository members; that is the mechanism
338
  that keeps public participation issue-shaped while pull requests remain a
339
  planned feature.
390 340
- Do not grant `maintainer` casually: triage roles can close and rewrite
391 341
  anyone's issues.
392
- Keep every membership change flowing through `add_member/3` so audit
393
  records accumulate; when the members UI lands it inherits the trail.
394
- When you fix gaps 1–3, update this document the same day; a runbook that
395
  says "use the console" one commit longer than necessary is how stale
396
  instructions become incident reports.
342
- Keep every membership change flowing through the members page so audit
343
  records accumulate; direct console edits skip the trail.
344
- When the pull-request switch ships, update this document the day it does:
345
  record where the setting lives, which repositories default which way, and
346
  flip `OpenAgentsInc/openagents.com` to `off` explicitly rather than relying
347
  on the default.
docs/github-api-issues-projects-assessment.md modified +7 -3

@@ -70,12 +70,16 @@ These are current measured behaviors:

70 70
71 71
These are compatibility limits, not authorization fallbacks:
72 72
73
- Label rename does not implement GitHub's `new_name` behavior.
74
- Adding a nonexistent label, removing an absent label, and some label URL
75
  encoding cases differ from GitHub behavior.
73
- Issue creation with a nonexistent label returns 422; only the
74
  add-labels-to-issue endpoint creates labels on the fly, matching GitHub.
76 75
- Error envelopes and pagination/link headers are a bounded local contract,
77 76
  not complete Octokit or `gh` parity.
78 77
78
Closed on 2026-08-21, each pinned by tests: label rename through `new_name`,
79
create-on-add for missing labels at the issue-labels endpoint, 404 for
80
removing a label an issue does not wear, and path-correct percent-encoding of
81
label URLs so an advertised label URL resolves through the show endpoint.
82
79 83
Gate 6 supplied the explicit API principal and mutation policy. Gate 7 supplied
80 84
repository entities, foreign keys, scoped uniqueness, ownership checks, and
81 85
cross-repository isolation tests. Further compatibility work must preserve
docs/issues-projects-ui-roadmap.md modified +35 -22

@@ -2,31 +2,38 @@

2 2
3 3
Date: 2026-08-20
4 4
5
Status: Core surfaces and repository boundary implemented; staging UX validation pending
5
Updated: 2026-08-21
6
7
Status: Core surfaces, repository boundary, public issue reading, and triage
8
ergonomics implemented; staging UX validation pending
6 9
7 10
## Current surface
8 11
9
The Phoenix LiveView application currently ships authenticated pages for:
12
The Phoenix LiveView application currently ships these pages:
10 13
11
| Surface | Route | LiveView |
14
| Surface | Route | Access |
12 15
| --- | --- | --- |
13
| Issue list | `/:owner/:repo/issues` | `OpenAgentsWeb.IssueIndexLive` |
14
| New issue | `/:owner/:repo/issues/new` | `OpenAgentsWeb.IssueNewLive` |
15
| Issue detail and comments | `/:owner/:repo/issues/:number` | `OpenAgentsWeb.IssueShowLive` |
16
| Labels | `/:owner/:repo/labels` | `OpenAgentsWeb.LabelIndexLive` |
17
| Milestones | `/:owner/:repo/milestones` | `OpenAgentsWeb.MilestoneIndexLive` |
18
| Assignees | `/:owner/:repo/assignees` | `OpenAgentsWeb.AssigneeIndexLive` |
19
| Project list | `/:owner/:repo/projects` | `OpenAgentsWeb.ProjectIndexLive` |
20
| Project board | `/:owner/:repo/projects/:number` | `OpenAgentsWeb.ProjectShowLive` |
16
| Issue list | `/:owner/:repo/issues` | Public on public repositories; filters, search, pagination, live updates |
17
| New issue | `/:owner/:repo/issues/new` | Any signed-in person on a public repository |
18
| Issue detail and comments | `/:owner/:repo/issues/:number` | Public read; participation per the model below |
19
| Members | `/:owner/:repo/members` | Repository owners only |
20
| Labels | `/:owner/:repo/labels` | Writable members |
21
| Milestones | `/:owner/:repo/milestones` | Writable members |
22
| Assignees | `/:owner/:repo/assignees` | Writable members |
23
| Project list | `/:owner/:repo/projects` | Writable members |
24
| Project board | `/:owner/:repo/projects/:number` | Writable members |
21 25
22 26
The matching `/api/v3` issue, comment, label, assignee, milestone, and Projects
23 27
V2 subset is implemented and covered. The dated
24 28
[coverage audit](2026-08-20-test-coverage-audit.md) records the original gaps
25
and the coverage added to close them.
29
and the coverage added to close them. The
30
[triage runbook](2026-08-21-issue-project-triage-runbook.md) records the
31
participation model and its authority rules.
26 32
27 33
## Interface rules
28 34
29
- Every page uses `Layouts.app` and the authenticated LiveView session.
35
- Every page uses `Layouts.app`. The issue list and detail pages mount in a
36
  public-read session; everything else requires an authenticated session.
30 37
- New reusable primitives come from `OpenAgentsWeb.UI`; domain compositions can
31 38
  live in a focused issue, project, or forge component module.
32 39
- Basecoat supplies pinned structural CSS and `assets/css/openagents.css`

@@ -38,6 +45,8 @@ and the coverage added to close them.

38 45
  sanitized Markdown path selected by Gate 4.
39 46
- Icons come from the vendored set through `OpenAgentsWeb.UI.icon/1`; icon-only
40 47
  controls have accessible names.
48
- Hiding a control for an unauthorized viewer is courtesy, not security:
49
  every event handler re-checks authority at the server.
41 50
42 51
The current governed component inventory is documented in
43 52
[docs/component-library.md](component-library.md).

@@ -52,9 +61,12 @@ Gate 7 completed the durable repository boundary:

52 61
- Issues, labels, milestones, comments, projects, project items, issue-label
53 62
  links, and issue-assignee links carry repository ownership. Issue,
54 63
  milestone, and project numbers are unique within a repository.
55
- Public API reads resolve only public repositories. Authenticated LiveViews
56
  and PAT writes resolve a writable repository membership before loading or
57
  changing a resource.
64
- Public API reads resolve only public repositories. PAT writes resolve a
65
  writable repository membership before changing a resource. Browser issue
66
  pages follow the participation model recorded in the
67
  [triage runbook](2026-08-21-issue-project-triage-runbook.md): public read on
68
  public repositories, filing and commenting for any signed-in person, and
69
  triage writes for writable members only.
58 70
- Assignees are active repository members with a writable role. Arbitrary
59 71
  login snapshots are no longer accepted.
60 72
- Projects V2 compatibility paths enforce the requested username in show,

@@ -79,13 +91,14 @@ With the domain boundary established:

79 91
1. Extract repeated issue rows, comment threads, label selectors, milestone
80 92
   progress, and project columns only where doing so improves behavior and test
81 93
   ownership.
82
2. Add bounded search, filtering, pagination, and useful empty/loading/error
83
   states.
84
3. Add PubSub invalidation and database rereads where concurrent users need
85
   live updates.
86
4. Run accessibility, keyboard, responsive, compiled-CSS, and browser staging
94
2. Run accessibility, keyboard, responsive, compiled-CSS, and browser staging
87 95
   checks against the same candidate SHA.
88 96
89
Drag-and-drop boards, advanced project views, pull requests, review workflows,
97
Done on 2026-08-21: bounded search, filtering (label, assignee, milestone),
98
and pagination on the issue index; PubSub invalidation with database rereads
99
for issue surfaces; the members management page.
100
101
Drag-and-drop boards, advanced project views, notifications, pull requests
102
with a per-repository enable/disable switch, review workflows,
90 103
and pixel-level compatibility with another forge remain planned rather than
91 104
current promises.
lib/openagents/issues.ex modified +170 -23

@@ -14,19 +14,70 @@ defmodule OpenAgents.Issues do

14 14
  alias OpenAgents.Repositories
15 15
  alias OpenAgents.Repositories.Repository
16 16
17
  @issues_per_page 25
18
19
  @doc "How many issues one index page shows."
20
  def per_page, do: @issues_per_page
21
17 22
  def list_issues(opts \\ []) when is_list(opts),
18 23
    do: list_issues(Repositories.initial_repository!(), opts)
19 24
20 25
  def list_issues(%Repository{id: repository_id}, opts) when is_list(opts) do
21
    state = Keyword.get(opts, :state, "open")
22
23
    Issue
24
    |> where(repository_id: ^repository_id)
25
    |> maybe_filter_state(state)
26
    repository_id
27
    |> issue_query(opts)
26 28
    |> order_by(desc: :inserted_at)
27 29
    |> Repo.all()
28 30
  end
29 31
32
  @doc """
33
  One page of the filtered issue list, with the unpaginated total.
34
35
  Supported options: `:state`, `:label`, `:assignee`, `:milestone`, `:q`,
36
  and `:page`. Filters compose; counts and pages always agree because they
37
  read the same query.
38
  """
39
  def list_issues_page(%Repository{} = repository, opts) when is_list(opts) do
40
    page = max(parse_page(opts[:page]), 1)
41
    query = issue_query(repository.id, opts)
42
43
    total = Repo.aggregate(query, :count)
44
45
    issues =
46
      query
47
      |> order_by(desc: :inserted_at)
48
      |> limit(@issues_per_page)
49
      |> offset(^((page - 1) * @issues_per_page))
50
      |> Repo.all()
51
52
    {issues, total}
53
  end
54
55
  def count_issues(%Repository{} = repository, opts) when is_list(opts),
56
    do: repository.id |> issue_query(opts) |> Repo.aggregate(:count)
57
58
  def parse_page(page) when is_integer(page), do: page
59
60
  def parse_page(page) when is_binary(page) do
61
    case Integer.parse(page) do
62
      {number, _rest} -> number
63
      :error -> 1
64
    end
65
  end
66
67
  def parse_page(_page), do: 1
68
69
  # Every list surface shares one filter chain so a page, a count, and an
70
  # unpaginated read can never disagree about what matches.
71
  defp issue_query(repository_id, opts) do
72
    from(issue in Issue, as: :issue)
73
    |> where([issue], issue.repository_id == ^repository_id)
74
    |> maybe_filter_state(Keyword.get(opts, :state, "open"))
75
    |> maybe_filter_label(Keyword.get(opts, :label))
76
    |> maybe_filter_assignee(Keyword.get(opts, :assignee))
77
    |> maybe_filter_milestone(Keyword.get(opts, :milestone))
78
    |> maybe_filter_search(Keyword.get(opts, :q))
79
  end
80
30 81
  def get_issue!(id), do: get_issue!(Repositories.initial_repository!(), id)
31 82
32 83
  def get_issue!(%Repository{id: repository_id}, id) do

@@ -98,6 +149,8 @@ defmodule OpenAgents.Issues do

98 149
          "has_assignees" => has_assignees?(normalized)
99 150
        })
100 151
152
        Repositories.broadcast_issues(repository.id)
153
101 154
        {:ok, issue}
102 155
103 156
      result ->

@@ -137,6 +190,8 @@ defmodule OpenAgents.Issues do

137 190
          "state" => updated.state
138 191
        })
139 192
193
        Repositories.broadcast_issues(issue.repository_id)
194
140 195
        {:ok, updated}
141 196
142 197
      result ->

@@ -158,12 +213,12 @@ defmodule OpenAgents.Issues do

158 213
  end
159 214
160 215
  def add_labels(%Issue{} = issue, names) when is_list(names) do
216
    repository = repository_stub(issue.repository_id)
217
161 218
    new_labels =
162 219
      Enum.map(names, fn name ->
163
        issue.repository_id
164
        |> repository_stub()
165
        |> Labels.get_label_by_name!(name)
166
        |> label_json()
220
        {:ok, label} = Labels.get_or_create_label_by_name(repository, name)
221
        label_json(label)
167 222
      end)
168 223
169 224
    labels = ((issue.labels || []) ++ new_labels) |> Enum.uniq_by(& &1["name"])

@@ -294,6 +349,8 @@ defmodule OpenAgents.Issues do

294 349
          "issue_number" => issue.number
295 350
        })
296 351
352
        Repositories.broadcast_issues(issue.repository_id)
353
297 354
        {:ok, comment}
298 355
299 356
      result ->

@@ -314,20 +371,28 @@ defmodule OpenAgents.Issues do

314 371
  end
315 372
316 373
  def delete_comment(%Comment{} = comment) do
317
    Repo.transaction(fn ->
318
      with {:ok, %Comment{}} <- Repo.delete(comment),
319
           {1, nil} <-
320
             from(i in Issue,
321
               where: i.id == ^comment.issue_id and i.repository_id == ^comment.repository_id,
322
               update: [inc: [comments: -1]]
323
             )
324
             |> Repo.update_all([]) do
325
        :ok
326
      else
327
        {:error, changeset} -> Repo.rollback(changeset)
328
        {_, _} -> Repo.rollback(:ok)
329
      end
330
    end)
374
    result =
375
      Repo.transaction(fn ->
376
        with {:ok, %Comment{}} <- Repo.delete(comment),
377
             {1, nil} <-
378
               from(i in Issue,
379
                 where: i.id == ^comment.issue_id and i.repository_id == ^comment.repository_id,
380
                 update: [inc: [comments: -1]]
381
               )
382
               |> Repo.update_all([]) do
383
          :ok
384
        else
385
          {:error, changeset} -> Repo.rollback(changeset)
386
          {_, _} -> Repo.rollback(:ok)
387
        end
388
      end)
389
390
    case result do
391
      {:ok, :ok} -> Repositories.broadcast_issues(comment.repository_id)
392
      _other -> :ok
393
    end
394
395
    result
331 396
  end
332 397
333 398
  defp prepare_collections(attrs, repository) do

@@ -510,6 +575,88 @@ defmodule OpenAgents.Issues do

510 575
  defp maybe_filter_state(query, "all"), do: query
511 576
  defp maybe_filter_state(query, state), do: where(query, state: ^state)
512 577
578
  # The label, assignee, and milestone filters read the JSONB snapshots that
579
  # already live on the issue row rather than joining the link tables. The
580
  # snapshots are maintained in the same transaction as the links, so a filter
581
  # over them cannot drift from what the API returns.
582
  defp maybe_filter_label(query, nil), do: query
583
  defp maybe_filter_label(query, ""), do: query
584
585
  defp maybe_filter_label(query, name) when is_binary(name) do
586
    decoded = URI.decode(name)
587
588
    where(
589
      query,
590
      [issue],
591
      fragment(
592
        "EXISTS (SELECT 1 FROM unnest(?) AS l WHERE l ->> 'name' = ?)",
593
        issue.labels,
594
        ^decoded
595
      )
596
    )
597
  end
598
599
  defp maybe_filter_assignee(query, nil), do: query
600
  defp maybe_filter_assignee(query, ""), do: query
601
602
  defp maybe_filter_assignee(query, login) when is_binary(login) do
603
    login_key = String.downcase(URI.decode(login))
604
605
    where(
606
      query,
607
      [issue],
608
      fragment(
609
        "EXISTS (SELECT 1 FROM unnest(?) AS a WHERE lower(a ->> 'login') = ?)",
610
        issue.assignees,
611
        ^login_key
612
      )
613
    )
614
  end
615
616
  defp maybe_filter_milestone(query, nil), do: query
617
  defp maybe_filter_milestone(query, ""), do: query
618
619
  # The milestone snapshot on the row carries its own number, so the filter
620
  # reads the JSON instead of resolving a milestone row first. A number that
621
  # matches no milestone then simply matches no issues.
622
  defp maybe_filter_milestone(query, number) when is_integer(number),
623
    do: filter_milestone_number(query, number)
624
625
  defp maybe_filter_milestone(query, number) when is_binary(number) do
626
    case Integer.parse(number) do
627
      {parsed, _rest} -> filter_milestone_number(query, parsed)
628
      :error -> query
629
    end
630
  end
631
632
  defp filter_milestone_number(query, number) do
633
    where(
634
      query,
635
      [issue],
636
      fragment("coalesce((? ->> 'number')::int, -1)", issue.milestone) == ^number
637
    )
638
  end
639
640
  defp maybe_filter_search(query, nil), do: query
641
  defp maybe_filter_search(query, ""), do: query
642
643
  defp maybe_filter_search(query, q) when is_binary(q) do
644
    # A percent sign typed into the box is a literal character, so the LIKE
645
    # wildcards are escaped before the needle is wrapped.
646
    escaped =
647
      q
648
      |> String.trim()
649
      |> String.replace("\\", "\\\\")
650
      |> String.replace("%", "\\%")
651
      |> String.replace("_", "\\_")
652
653
    where(
654
      query,
655
      [issue],
656
      ilike(issue.title, ^"%#{escaped}%") or ilike(issue.body, ^"%#{escaped}%")
657
    )
658
  end
659
513 660
  defp to_string_map(attrs) do
514 661
    for {key, value} <- attrs, into: %{}, do: {to_string(key), value}
515 662
  end
lib/openagents/labels.ex modified +20

@@ -58,6 +58,26 @@ defmodule OpenAgents.Labels do

58 58
    Repo.get_by!(Label, repository_id: repository_id, name: URI.decode(name))
59 59
  end
60 60
61
  @doc """
62
  Returns the named label, creating it with a generated colour when it is
63
  missing, the way GitHub does when a script adds a label an issue has never
64
  worn.
65
  """
66
  def get_or_create_label_by_name(%Repository{} = repository, name, actor \\ nil)
67
      when is_binary(name) do
68
    decoded = URI.decode(name)
69
70
    case Repo.get_by(Label, repository_id: repository.id, name: decoded) do
71
      %Label{} = label ->
72
        {:ok, label}
73
74
      nil ->
75
        create_label(repository, %{"name" => decoded, "color" => generated_color()}, actor)
76
    end
77
  end
78
79
  defp generated_color, do: Base.encode16(:crypto.strong_rand_bytes(3), case: :lower)
80
61 81
  def get_label_by_path!(owner, repository_name, name) do
62 82
    Repo.one!(
63 83
      from label in Label,
lib/openagents/repositories.ex modified +245

@@ -22,8 +22,24 @@ defmodule OpenAgents.Repositories do

22 22
  @initial_owner "OpenAgentsInc"
23 23
  @initial_name "openagents.com"
24 24
  @writable_roles ~w(owner maintainer contributor)
25
  @all_roles ~w(owner maintainer contributor viewer)
25 26
  @repository_namespace_limit 100
26 27
28
  # GitHub's default label set. Every created or imported repository starts
29
  # with this vocabulary so triage has something to attach on day one; a
30
  # repository that does not want a name can delete it.
31
  @default_labels [
32
    {"bug", "d73a4a", "Something isn't working"},
33
    {"documentation", "0075ca", "Improvements or additions to docs"},
34
    {"duplicate", "cfd3d7", "This issue or pull request already exists"},
35
    {"enhancement", "a2eeef", "New feature or request"},
36
    {"good first issue", "7057ff", "Good for newcomers"},
37
    {"help wanted", "008672", "Extra attention is needed"},
38
    {"invalid", "e4e669", "This doesn't seem right"},
39
    {"question", "d876e3", "Further information is requested"},
40
    {"wontfix", "ffffff", "This will not be worked on"}
41
  ]
42
27 43
  # The two durable receipts that say where a repository is in provisioning:
28 44
  # the outbox row is the work, the import row is the GitHub snapshot. Both are
29 45
  # `has_one`, so they are preloaded together wherever a surface renders

@@ -553,6 +569,148 @@ defmodule OpenAgents.Repositories do

553 569
    end)
554 570
  end
555 571
572
  @doc """
573
  Lists one repository's members with their users, owners first.
574
575
  The order is role rank and then login, so the page reads the same way every
576
  time it renders.
577
  """
578
  def list_members(%Repository{id: repository_id}) do
579
    from(membership in Membership,
580
      join: user in assoc(membership, :user),
581
      where: membership.repository_id == ^repository_id,
582
      order_by: [
583
        asc:
584
          fragment(
585
            "array_position(array['owner','maintainer','contributor','viewer'], ?)",
586
            membership.role
587
          ),
588
        asc: fragment("lower(?)", user.github_login)
589
      ],
590
      preload: [user: user]
591
    )
592
    |> Repo.all()
593
  end
594
595
  @doc "The acting owner's view of one member row: add by GitHub login."
596
  def add_member_by_login(%Repository{} = repository, %User{} = actor, login, role)
597
      when is_binary(login) and role in @all_roles do
598
    case active_user_by_login(login) do
599
      %User{} = user ->
600
        case Repo.transaction(fn ->
601
               membership = upsert_membership!(repository, user, role)
602
               audit_membership(repository, actor, user, "added", role)
603
               membership
604
             end) do
605
          {:ok, membership} -> {:ok, membership}
606
          {:error, reason} -> {:error, reason}
607
        end
608
609
      nil ->
610
        {:error, :unknown_user}
611
    end
612
  end
613
614
  def change_member_role(%Repository{} = repository, %User{} = actor, user_id, role)
615
      when role in @all_roles do
616
    with %User{} = user <- Repo.get(User, user_id) || {:error, :unknown_member},
617
         %Membership{} <-
618
           Repo.get_by(Membership, repository_id: repository.id, user_id: user.id) ||
619
             {:error, :unknown_member},
620
         :ok <- guard_last_owner(repository, user, role) do
621
      Repo.transaction(fn ->
622
        updated = upsert_membership!(repository, user, role)
623
        audit_membership(repository, actor, user, "role changed", role)
624
        updated
625
      end)
626
      |> case do
627
        {:ok, membership} -> {:ok, membership}
628
        {:error, reason} -> {:error, reason}
629
      end
630
    else
631
      {:error, reason} -> {:error, reason}
632
    end
633
  end
634
635
  def remove_member(%Repository{} = repository, %User{} = actor, user_id) do
636
    with %User{} = user <- Repo.get(User, user_id) || {:error, :unknown_member},
637
         %Membership{} = membership <-
638
           Repo.get_by(Membership, repository_id: repository.id, user_id: user.id) ||
639
             {:error, :unknown_member},
640
         :ok <- guard_last_owner(repository, user, nil) do
641
      Repo.transaction(fn ->
642
        Repo.delete!(membership)
643
644
        Audit.record!(
645
          "repository.membership.removed",
646
          {:user, actor.id},
647
          "membership",
648
          membership_subject_id(membership),
649
          repository_id: repository.id,
650
          metadata: %{"login" => user.github_login}
651
        )
652
      end)
653
654
      :ok
655
    else
656
      {:error, reason} -> {:error, reason}
657
    end
658
  end
659
660
  # A repository with no owner cannot be administered any more, so the last
661
  # owner cannot be demoted or removed, including by themselves.
662
  defp guard_last_owner(%Repository{id: repository_id}, %User{id: user_id}, new_role) do
663
    owner_count =
664
      Repo.one!(
665
        from membership in Membership,
666
          where: membership.repository_id == ^repository_id and membership.role == "owner",
667
          select: count()
668
      )
669
670
    leaving_owner? =
671
      membership_role(%Repository{id: repository_id}, %User{id: user_id}) == "owner"
672
673
    still_owner? = new_role == "owner"
674
675
    if leaving_owner? and not still_owner? and owner_count <= 1,
676
      do: {:error, :last_owner},
677
      else: :ok
678
  end
679
680
  defp upsert_membership!(repository, user, role) do
681
    %Membership{}
682
    |> Membership.changeset(%{
683
      repository_id: repository.id,
684
      user_id: user.id,
685
      role: role
686
    })
687
    |> Repo.insert!(
688
      on_conflict: {:replace, [:role, :updated_at]},
689
      conflict_target: [:repository_id, :user_id],
690
      returning: true
691
    )
692
  end
693
694
  defp audit_membership(repository, actor, subject, action, role) do
695
    Audit.record!(
696
      "repository.membership.updated",
697
      {:user, actor.id},
698
      "membership",
699
      "#{repository.id}:#{subject.id}",
700
      repository_id: repository.id,
701
      metadata: %{"action" => action, "role" => role, "login" => subject.github_login}
702
    )
703
  end
704
705
  defp active_user_by_login(login) do
706
    Repo.one(
707
      from user in User,
708
        where:
709
          user.status == "active" and
710
            fragment("lower(?)", user.github_login) == ^String.downcase(String.trim(login))
711
    )
712
  end
713
556 714
  def grant_machine(%Repository{} = repository, %User{} = actor, %Machine{} = machine, operations)
557 715
      when is_list(operations) do
558 716
    with true <- machine.user_id == actor.id or {:error, :machine_not_owned},

@@ -626,6 +784,8 @@ defmodule OpenAgents.Repositories do

626 784
    )
627 785
  end
628 786
787
  def writable?(%Repository{}, nil), do: false
788
629 789
  def membership_role(%Repository{id: repository_id}, %User{id: user_id}) do
630 790
    Repo.one(
631 791
      from membership in Membership,

@@ -636,6 +796,89 @@ defmodule OpenAgents.Repositories do

636 796
637 797
  def membership_role(%Repository{}, nil), do: nil
638 798
799
  @doc "Whether the repository is publicly readable."
800
  def public?(%Repository{visibility: "public"}), do: true
801
  def public?(%Repository{}), do: false
802
803
  @doc """
804
  Whether the user holds any membership role, including read-only `viewer`.
805
  """
806
  def member?(%Repository{id: repository_id}, %User{id: user_id}) do
807
    Repo.exists?(
808
      from membership in Membership,
809
        join: user in User,
810
        on: user.id == membership.user_id and user.status == "active",
811
        where: membership.repository_id == ^repository_id and membership.user_id == ^user_id
812
    )
813
  end
814
815
  def member?(%Repository{}, nil), do: false
816
817
  @doc """
818
  Whether the user may take part in issue conversations: open issues and
819
  comment.
820
821
  GitHub's model, which is ours: an active signed-in person can join the
822
  conversation on any public repository without membership; a private
823
  repository admits its own members. Triage writes (label, assign, close,
824
  edit) stay behind writability and are not governed by this predicate.
825
  """
826
  def issue_participant?(%Repository{}, nil), do: false
827
  def issue_participant?(%Repository{visibility: "public"}, %User{}), do: true
828
829
  def issue_participant?(%Repository{} = repository, %User{} = user),
830
    do: member?(repository, user)
831
832
  @doc "Whether the user holds the repository's `owner` role."
833
  def owner?(%Repository{} = repository, %User{} = user) do
834
    membership_role(repository, user) == "owner"
835
  end
836
837
  def owner?(%Repository{}, nil), do: false
838
839
  @doc "Subscribes the caller to one repository's issue activity."
840
  def subscribe_issues(repository_id),
841
    do: Phoenix.PubSub.subscribe(OpenAgents.PubSub, issues_topic(repository_id))
842
843
  def unsubscribe_issues(repository_id),
844
    do: Phoenix.PubSub.unsubscribe(OpenAgents.PubSub, issues_topic(repository_id))
845
846
  @doc """
847
  Announces that one repository's issues moved.
848
849
  Called after the owning transaction commits. The message carries the
850
  repository id and nothing else, so every subscriber re-reads through its own
851
  visibility and authorization predicates.
852
  """
853
  def broadcast_issues(repository_id) do
854
    Phoenix.PubSub.broadcast(
855
      OpenAgents.PubSub,
856
      issues_topic(repository_id),
857
      {:issues_changed, repository_id}
858
    )
859
  end
860
861
  defp issues_topic(repository_id), do: "issues:" <> repository_id
862
863
  @doc "Seeds GitHub's default label vocabulary onto a new or imported repository."
864
  def seed_default_labels!(%Repository{} = repository) do
865
    Enum.each(@default_labels, fn {name, color, description} ->
866
      Repo.insert!(
867
        %OpenAgents.Labels.Label{}
868
        |> OpenAgents.Labels.Label.changeset(%{
869
          name: name,
870
          color: color,
871
          description: description,
872
          repository_id: repository.id
873
        }),
874
        on_conflict: :nothing,
875
        conflict_target: [:repository_id, :name]
876
      )
877
    end)
878
879
    :ok
880
  end
881
639 882
  def list_assignable_users(%Repository{id: repository_id}) do
640 883
    Repo.all(
641 884
      from user in User,

@@ -760,6 +1003,8 @@ defmodule OpenAgents.Repositories do

760 1003
      metadata: %{"role" => "owner"}
761 1004
    )
762 1005
1006
    seed_default_labels!(repository)
1007
763 1008
    repository_import =
764 1009
      if source do
765 1010
        created_import =
lib/openagents_web/controllers/issue_label_controller.ex modified +16 -7

@@ -50,15 +50,24 @@ defmodule OpenAgentsWeb.IssueLabelController do

50 50
      }) do
51 51
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
52 52
    issue = Issues.get_issue_by_number!(repository, String.to_integer(issue_number))
53
    decoded = URI.decode(name)
53 54
54
    case Issues.remove_label(issue, name) do
55
      {:ok, %Issues.Issue{} = issue} ->
56
        json(conn, %{labels: issue.labels})
55
    if Enum.any?(issue.labels || [], &(&1["name"] == decoded)) do
56
      case Issues.remove_label(issue, name) do
57
        {:ok, %Issues.Issue{} = issue} ->
58
          json(conn, %{labels: issue.labels})
57 59
58
      {:error, _} ->
59
        conn
60
        |> put_status(:unprocessable_entity)
61
        |> json(%{message: "Could not remove label"})
60
        {:error, _} ->
61
          conn
62
          |> put_status(:unprocessable_entity)
63
          |> json(%{message: "Could not remove label"})
64
      end
65
    else
66
      # GitHub refuses to remove a label the issue does not wear; a silent
67
      # no-op hides the mismatch from the script that sent it.
68
      conn
69
      |> put_status(:not_found)
70
      |> json(%{message: "Label does not exist on this issue"})
62 71
    end
63 72
  rescue
64 73
    Ecto.NoResultsError ->
lib/openagents_web/controllers/label_controller.ex modified +9 -1

@@ -45,7 +45,15 @@ defmodule OpenAgentsWeb.LabelController do

45 45
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
46 46
    label = Labels.get_label_by_name!(repository, name)
47 47
48
    case Labels.update_label(label, params) do
48
    # GitHub renames through `new_name`; without this the path's name always
49
    # won and the rename was silently dropped.
50
    attrs =
51
      case params["new_name"] do
52
        nil -> params
53
        new_name -> Map.put(params, "name", new_name)
54
      end
55
56
    case Labels.update_label(label, attrs) do
49 57
      {:ok, %Label{} = label} ->
50 58
        render(conn, :show, label: label, owner: owner, repo: repo)
51 59
lib/openagents_web/controllers/label_json.ex modified +1 -1

@@ -26,7 +26,7 @@ defmodule OpenAgentsWeb.LabelJSON do

26 26
      description: label.description,
27 27
      default: false,
28 28
      url:
29
        "https://openagents.com/api/v3/repos/#{owner}/#{repo}/labels/#{URI.encode_www_form(label.name)}"
29
        "https://openagents.com/api/v3/repos/#{owner}/#{repo}/labels/#{URI.encode(label.name, &URI.char_unreserved?/1)}"
30 30
    }
31 31
  end
32 32
lib/openagents_web/live/issue_index_live.ex modified +218 -45

@@ -1,83 +1,183 @@

1 1
defmodule OpenAgentsWeb.IssueIndexLive do
2 2
  @moduledoc """
3 3
  Lists issues for a repository.
4
5
  Reading is public on a public repository, the way code browsing already is.
6
  Interacting — opening an issue, commenting on the detail page — needs a
7
  signed-in person, and triage (state, assignees) needs a writable membership,
8
  so every control that writes checks its authority at the server and not only
9
  in what the template renders.
4 10
  """
5 11
  use OpenAgentsWeb, :live_view
6 12
7 13
  alias OpenAgents.Issues
8
  alias OpenAgentsWeb.UI.Circle
14
  alias OpenAgents.Labels
15
  alias OpenAgents.Milestones
9 16
  alias OpenAgents.Repositories
17
  alias OpenAgentsWeb.UI.Circle
18
19
  @filter_keys ~w(label assignee milestone q)
10 20
11 21
  def mount(_params, _session, socket) do
12 22
    {:ok, assign(socket, :current_scope, socket.assigns[:current_scope])}
13 23
  end
14 24
15 25
  def handle_params(%{"owner" => owner, "repo" => repo} = params, _url, socket) do
16
    state = params["state"] || "open"
17
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
18
19
    socket =
20
      socket
21
      |> assign(:owner, owner)
22
      |> assign(:repo, repo)
23
      |> assign(:repository, repository)
24
      |> assign(:state, state)
25
      |> assign(:assignable, Repositories.list_assignable_users(repository))
26
      |> load()
27
28
    {:noreply, socket}
26
    with {:ok, repository} <- visible_repository(owner, repo, socket.assigns.current_user) do
27
      if connected?(socket), do: Repositories.subscribe_issues(repository.id)
28
29
      user = socket.assigns.current_user
30
      can_write = Repositories.writable?(repository, user)
31
32
      socket =
33
        socket
34
        |> assign(:owner, owner)
35
        |> assign(:repo, repo)
36
        |> assign(:repository, repository)
37
        |> assign(:current_user, user)
38
        |> assign(:can_write, can_write)
39
        |> assign(:can_participate, Repositories.issue_participant?(repository, user))
40
        |> assign(:state, normalize_state(params["state"]))
41
        |> assign(:page, Issues.parse_page(params["page"]))
42
        |> assign(:filters, read_filters(params))
43
        |> assign(:label_options, if(can_write, do: Labels.list_labels(repository), else: []))
44
        |> assign(
45
          :assignable,
46
          if(can_write, do: Repositories.list_assignable_users(repository), else: [])
47
        )
48
        |> assign(
49
          :milestone_options,
50
          if(can_write, do: Milestones.list_milestones(repository), else: [])
51
        )
52
        |> load()
53
54
      {:noreply, socket}
55
    else
56
      :error -> raise OpenAgentsWeb.PublicNotFoundError, message: "repository not found"
57
    end
29 58
  end
30 59
31
  # The row's own controls, which is what Circle has that a static list does
32
  # not. Only state and assignee: they are the two facts worth changing without
33
  # opening the issue, and labels and milestone need option lists longer than a
34
  # row has room to explain. Both live in the rail on the issue page instead.
35
  def handle_event("set_state", %{"id" => id, "state" => "open"}, socket),
36
    do: write(socket, id, %{"state" => "open", "state_reason" => nil})
60
  # Filters arrive as query params; anything unrecognized is dropped so a
61
  # hand-edited URL cannot smuggle an option into the context call.
62
  defp read_filters(params) do
63
    Map.new(@filter_keys, fn key -> {key, blank_to_nil(params[key])} end)
64
  end
37 65
38
  def handle_event("set_state", %{"id" => id, "state" => "closed"} = params, socket),
39
    do: write(socket, id, %{"state" => "closed", "state_reason" => params["reason"]})
66
  # One form drives every filter, so one change event carries the complete
67
  # desired set and patching replaces it wholesale.
68
  def handle_event("filter", params, socket) do
69
    filters = Map.new(@filter_keys, fn key -> {key, blank_to_nil(params[key])} end)
70
    apply_filters(socket, filters)
71
  end
72
73
  def handle_event("set_state", %{"id" => id, "state" => state} = params, socket) do
74
    if socket.assigns.can_write do
75
      attrs =
76
        case state do
77
          "open" -> %{"state" => "open", "state_reason" => nil}
78
          "closed" -> %{"state" => "closed", "state_reason" => params["reason"]}
79
        end
80
81
      Issues.update_issue(issue!(socket, id), attrs, socket.assigns.current_user)
82
      {:noreply, load(socket)}
83
    else
84
      {:noreply, put_flash(socket, :error, "Only repository members can change issue state.")}
85
    end
86
  end
40 87
41 88
  def handle_event("toggle_assignee", %{"id" => id, "login" => login}, socket) do
42
    issue = issue!(socket, id)
89
    if socket.assigns.can_write do
90
      issue = issue!(socket, id)
43 91
44
    {:ok, _updated} =
45 92
      if Enum.any?(issue.assignees || [], &(&1["login"] == login)) do
46 93
        Issues.remove_assignees(issue, [login])
47 94
      else
48 95
        Issues.add_assignees(issue, [login])
49 96
      end
50 97
51
    {:noreply, load(socket)}
98
      {:noreply, load(socket)}
99
    else
100
      {:noreply, put_flash(socket, :error, "Only repository members can change assignees.")}
101
    end
52 102
  end
53 103
54
  defp write(socket, id, attrs) do
55
    {:ok, _updated} = Issues.update_issue(issue!(socket, id), attrs, socket.assigns.current_user)
56
    {:noreply, load(socket)}
104
  # Live updates: any committed issue write in this repository re-reads the
105
  # current page through this viewer's own authorization, so two people
106
  # triaging together converge instead of drifting.
107
  def handle_info({:issues_changed, repository_id}, socket) do
108
    if repository_id == socket.assigns.repository.id,
109
      do: {:noreply, load(socket)},
110
      else: {:noreply, socket}
57 111
  end
58 112
59
  # `JS.push` sends the id as a number; a `phx-value-` attribute would send a
60
  # string. The handler takes whichever arrives.
61
  defp issue!(socket, id) when is_integer(id),
62
    do: Issues.get_issue!(socket.assigns.repository, id)
113
  defp apply_filters(socket, filters) do
114
    {:noreply,
115
     push_patch(socket, to: issues_path(socket.assigns.owner, socket.assigns.repo, filters))}
116
  end
63 117
64
  defp issue!(socket, id) when is_binary(id),
65
    do: Issues.get_issue!(socket.assigns.repository, String.to_integer(id))
118
  defp issues_path(owner, repo, filters, extra \\ %{}) do
119
    query =
120
      filters
121
      |> Map.merge(extra)
122
      |> Enum.reject(fn {_key, value} -> value in [nil, ""] end)
123
      |> Map.new()
124
125
    ~p"/#{owner}/#{repo}/issues?#{query}"
126
  end
127
128
  defp blank_to_nil(""), do: nil
129
  defp blank_to_nil(value), do: value
130
131
  defp normalize_state("closed"), do: "closed"
132
  defp normalize_state(_state), do: "open"
133
134
  defp visible_repository(owner, repo, user) do
135
    try do
136
      {:ok, Repositories.get_visible_by_path!(owner, repo, user)}
137
    rescue
138
      Ecto.NoResultsError -> :error
139
    end
140
  end
66 141
67 142
  # Reloading rather than patching one row: closing an issue while the Open tab
68 143
  # is showing has to remove it from the list and change both tab counts, and a
69 144
  # row that stays visible after being closed is worse than a reload.
70 145
  defp load(socket) do
71 146
    repository = socket.assigns.repository
72
    issues = Issues.list_issues(repository, state: socket.assigns.state)
147
    %{filters: filters, state: state, page: page} = socket.assigns
148
149
    opts =
150
      filters
151
      |> Keyword.new(fn {key, value} -> {String.to_existing_atom(key), value} end)
152
      |> Keyword.put(:state, state)
153
      |> Keyword.put(:page, page)
154
155
    count_opts = Keyword.drop(opts, [:page])
156
157
    {issues, total} = Issues.list_issues_page(repository, opts)
73 158
74 159
    socket
75
    |> assign(:open_count, length(Issues.list_issues(repository, state: "open")))
76
    |> assign(:closed_count, length(Issues.list_issues(repository, state: "closed")))
160
    |> assign(
161
      :open_count,
162
      Issues.count_issues(repository, Keyword.put(count_opts, :state, "open"))
163
    )
164
    |> assign(
165
      :closed_count,
166
      Issues.count_issues(repository, Keyword.put(count_opts, :state, "closed"))
167
    )
168
    |> assign(:total_count, total)
77 169
    |> assign(:issues_count, length(issues))
78 170
    |> stream(:issues, issues, reset: true)
79 171
  end
80 172
173
  # `JS.push` sends the id as a number; a `phx-value-` attribute would send a
174
  # string. The handler takes whichever arrives.
175
  defp issue!(socket, id) when is_integer(id),
176
    do: Issues.get_issue!(socket.assigns.repository, id)
177
178
  defp issue!(socket, id) when is_binary(id),
179
    do: Issues.get_issue!(socket.assigns.repository, String.to_integer(id))
180
81 181
  def render(assigns) do
82 182
    ~H"""
83 183
    <Layouts.app

@@ -92,12 +192,12 @@ defmodule OpenAgentsWeb.IssueIndexLive do

92 192
          <Circle.view_tabs>
93 193
            <:tab
94 194
              label={"#{@open_count} Open"}
95
              patch={~p"/#{@owner}/#{@repo}/issues?state=open"}
195
              patch={issues_path(@owner, @repo, @filters, %{"state" => "open"})}
96 196
              selected={@state == "open"}
97 197
            />
98 198
            <:tab
99 199
              label={"#{@closed_count} Closed"}
100
              patch={~p"/#{@owner}/#{@repo}/issues?state=closed"}
200
              patch={issues_path(@owner, @repo, @filters, %{"state" => "closed"})}
101 201
              selected={@state == "closed"}
102 202
            />
103 203
          </Circle.view_tabs>

@@ -105,6 +205,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

105 205
106 206
        <:actions>
107 207
          <.link
208
            :if={@can_write}
108 209
            navigate={~p"/#{@owner}/#{@repo}/labels"}
109 210
            class="btn"
110 211
            data-variant="ghost"

@@ -113,6 +214,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

113 214
            <.icon name="tag" /> Labels
114 215
          </.link>
115 216
          <.link
217
            :if={@can_write}
116 218
            navigate={~p"/#{@owner}/#{@repo}/milestones"}
117 219
            class="btn"
118 220
            data-variant="ghost"

@@ -121,6 +223,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

121 223
            <.icon name="flag" /> Milestones
122 224
          </.link>
123 225
          <.link
226
            :if={@can_participate}
124 227
            navigate={~p"/#{@owner}/#{@repo}/issues/new"}
125 228
            class="btn"
126 229
            data-variant="primary"

@@ -131,12 +234,56 @@ defmodule OpenAgentsWeb.IssueIndexLive do

131 234
        </:actions>
132 235
      </Circle.issue_toolbar>
133 236
237
      <div class="issue-filters">
238
        <.form for={%{}} as={:filter} phx-change="filter" id="issue-filter-form">
239
          <.input
240
            type="search"
241
            name="q"
242
            value={@filters["q"]}
243
            placeholder="Search issues"
244
            aria-label="Search issues"
245
            class="!w-56"
246
          />
247
          <.input
248
            :if={@milestone_options != []}
249
            type="select"
250
            name="milestone"
251
            value={@filters["milestone"]}
252
            options={Enum.map(@milestone_options, &{&1.title, Integer.to_string(&1.number)})}
253
            prompt="All milestones"
254
            aria-label="Filter by milestone"
255
          />
256
          <.input
257
            :if={@label_options != []}
258
            type="select"
259
            name="label"
260
            value={@filters["label"]}
261
            options={Enum.map(@label_options, &{&1.name, &1.name})}
262
            prompt="All labels"
263
            aria-label="Filter by label"
264
          />
265
          <.input
266
            :if={@assignable != []}
267
            type="select"
268
            name="assignee"
269
            value={@filters["assignee"]}
270
            options={Enum.map(@assignable, &{&1.github_login, &1.github_login})}
271
            prompt="Everyone"
272
            aria-label="Filter by assignee"
273
          />
274
        </.form>
275
      </div>
276
134 277
      <.empty
135 278
        :if={@issues_count == 0}
136 279
        id="issues-empty"
137 280
        title={"No #{@state} issues"}
138 281
      >
139
        Issues will show up here once they are created.
282
        <%= if @can_participate do %>
283
          Issues will show up here once they are created.
284
        <% else %>
285
          Nothing matches here yet.
286
        <% end %>
140 287
      </.empty>
141 288
142 289
      <div :if={@issues_count > 0} id="issues" phx-update="stream" class="issue-list">

@@ -154,7 +301,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

154 301
          author={author(issue)}
155 302
          comments={issue.comments}
156 303
        >
157
          <:state>
304
          <:state :if={@can_write}>
158 305
            <Circle.field_menu
159 306
              id={"row-state-#{issue.id}"}
160 307
              label={"Change the state of issue ##{issue.number}"}

@@ -174,7 +321,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

174 321
              </Circle.field_menu_item>
175 322
            </Circle.field_menu>
176 323
          </:state>
177
          <:people>
324
          <:people :if={@can_write}>
178 325
            <Circle.field_menu
179 326
              id={"row-assignee-#{issue.id}"}
180 327
              label={"Assign issue ##{issue.number}"}

@@ -200,13 +347,39 @@ defmodule OpenAgentsWeb.IssueIndexLive do

200 347
          </:people>
201 348
        </Circle.issue_row>
202 349
      </div>
350
351
      <nav :if={@total_count > Issues.per_page()} class="issue-pagination" aria-label="Pages">
352
        <span class="issue-pagination__status">
353
          Showing {@issues_count} of {@total_count}
354
        </span>
355
        <span class="issue-pagination__controls">
356
          <.link
357
            :if={@page > 1}
358
            patch={issues_path(@owner, @repo, @filters, %{"state" => @state, "page" => @page - 1})}
359
            class="btn"
360
            data-variant="ghost"
361
            data-size="sm"
362
          >
363
            Previous
364
          </.link>
365
          <.link
366
            :if={@page * Issues.per_page() < @total_count}
367
            patch={issues_path(@owner, @repo, @filters, %{"state" => @state, "page" => @page + 1})}
368
            class="btn"
369
            data-variant="ghost"
370
            data-size="sm"
371
          >
372
            Next
373
          </.link>
374
        </span>
375
      </nav>
203 376
    </Layouts.app>
204 377
    """
205 378
  end
206 379
207
  # The row's own state cell is a control now, so the static category and label
208
  # it would otherwise draw come from `Circle.issue_state/1` instead. They stay
209
  # as the row's defaults for a caller with nowhere to send a change.
380
  # GitHub's two states, and nothing invented on top of them. `not_planned` is
381
  # the one close reason with a distinct reading, so it takes the cancelled
382
  # glyph; every other close is a completion.
210 383
  defp category(%{state: "closed", state_reason: "not_planned"}), do: :canceled
211 384
  defp category(%{state: "closed"}), do: :completed
212 385
  defp category(_issue), do: :unstarted
lib/openagents_web/live/issue_new_live.ex modified +67 -28

@@ -1,6 +1,11 @@

1 1
defmodule OpenAgentsWeb.IssueNewLive do
2 2
  @moduledoc """
3 3
  Renders a form to create a new issue.
4
5
  Filing an issue needs an identity, not a membership: any signed-in person
6
  can open one on a public repository. Labels and milestones are triage
7
  decisions, so their pickers appear only for repository members with write
8
  access.
4 9
  """
5 10
  use OpenAgentsWeb, :live_view
6 11

@@ -11,33 +16,55 @@ defmodule OpenAgentsWeb.IssueNewLive do

11 16
  alias OpenAgents.Repositories
12 17
13 18
  def mount(%{"owner" => owner, "repo" => repo}, _session, socket) do
14
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
15
    changeset = Issues.change_issue(%Issue{}, %{"title" => "", "body" => ""})
16
17
    socket =
18
      socket
19
      |> assign(:current_scope, socket.assigns[:current_scope])
20
      |> assign(:owner, owner)
21
      |> assign(:repo, repo)
22
      |> assign(:repository, repository)
23
      |> assign(:form, to_form(changeset))
24
      |> assign(
25
        :milestone_options,
26
        Enum.map(Milestones.list_milestones(repository), &{&1.title, &1.number})
27
      )
28
      |> assign(
29
        :label_options,
30
        Enum.map(Labels.list_labels(repository), &{&1.name, &1.name})
31
      )
32
33
    {:ok, socket}
19
    user = socket.assigns.current_user
20
21
    repository =
22
      try do
23
        Repositories.get_visible_by_path!(owner, repo, user)
24
      rescue
25
        Ecto.NoResultsError -> nil
26
      end
27
28
    if is_nil(repository) or not Repositories.issue_participant?(repository, user) do
29
      {:ok,
30
       socket
31
       |> assign(:current_scope, socket.assigns[:current_scope])
32
       |> put_flash(:error, "You cannot open an issue on that repository.")
33
       |> redirect(to: ~p"/")}
34
    else
35
      changeset = Issues.change_issue(%Issue{}, %{"title" => "", "body" => ""})
36
      can_write = Repositories.writable?(repository, user)
37
38
      {:ok,
39
       socket
40
       |> assign(:current_scope, socket.assigns[:current_scope])
41
       |> assign(:owner, owner)
42
       |> assign(:repo, repo)
43
       |> assign(:repository, repository)
44
       |> assign(:can_write, can_write)
45
       |> assign(:form, to_form(changeset))
46
       |> assign(
47
         :milestone_options,
48
         if(can_write,
49
           do: Enum.map(Milestones.list_milestones(repository), &{&1.title, &1.number}),
50
           else: []
51
         )
52
       )
53
       |> assign(
54
         :label_options,
55
         if(can_write,
56
           do: Enum.map(Labels.list_labels(repository), &{&1.name, &1.name}),
57
           else: []
58
         )
59
       )}
60
    end
34 61
  end
35 62
36 63
  def handle_event("save", %{"issue" => issue_params}, socket) do
37 64
    title = issue_params["title"]
38 65
    body = issue_params["body"]
39
    milestone = issue_params["milestone"] || ""
40
    labels = issue_params["labels"] || []
66
    milestone = if(socket.assigns.can_write, do: issue_params["milestone"] || "", else: "")
67
    labels = if(socket.assigns.can_write, do: issue_params["labels"] || [], else: [])
41 68
42 69
    case Issues.create_issue(
43 70
           socket.assigns.repository,

@@ -62,16 +89,26 @@ defmodule OpenAgentsWeb.IssueNewLive do

62 89
  defp apply_metadata(issue, labels, milestone) do
63 90
    labels = List.wrap(labels) |> Enum.reject(&(&1 == ""))
64 91
65
    if labels != [] do
66
      Issues.add_labels(issue, labels)
67
    end
92
    issue =
93
      if labels != [] do
94
        case Issues.add_labels(issue, labels) do
95
          {:ok, updated} -> updated
96
          _error -> issue
97
        end
98
      else
99
        issue
100
      end
68 101
69 102
    if milestone != "" do
70 103
      milestone_number = String.to_integer(milestone)
71
      Issues.set_milestone(issue, milestone_number)
72
    end
73 104
74
    issue
105
      case Issues.set_milestone(issue, milestone_number) do
106
        {:ok, updated} -> updated
107
        _error -> issue
108
      end
109
    else
110
      issue
111
    end
75 112
  end
76 113
77 114
  def render(assigns) do

@@ -92,6 +129,7 @@ defmodule OpenAgentsWeb.IssueNewLive do

92 129
        <.input field={@form[:title]} label="Title" required />
93 130
        <.input field={@form[:body]} type="textarea" label="Body" />
94 131
        <.input
132
          :if={@can_write}
95 133
          field={@form[:milestone]}
96 134
          type="select"
97 135
          label="Milestone"

@@ -99,6 +137,7 @@ defmodule OpenAgentsWeb.IssueNewLive do

99 137
          prompt="Select a milestone"
100 138
        />
101 139
        <.input
140
          :if={@can_write}
102 141
          field={@form[:labels]}
103 142
          type="select"
104 143
          label="Labels"
lib/openagents_web/live/issue_show_live.ex modified +99 -20

@@ -28,6 +28,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

28 28
29 29
  alias OpenAgents.Issues
30 30
  alias OpenAgents.Issues.Comment
31
  alias OpenAgents.Issues.Issue
31 32
  alias OpenAgents.Labels
32 33
  alias OpenAgents.Markdown
33 34
  alias OpenAgents.Milestones

@@ -35,24 +36,49 @@ defmodule OpenAgentsWeb.IssueShowLive do

35 36
  alias OpenAgentsWeb.UI.Circle
36 37
37 38
  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do
38
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
39
    repository =
40
      try do
41
        Repositories.get_visible_by_path!(owner, repo, socket.assigns.current_user)
42
      rescue
43
        Ecto.NoResultsError ->
44
          raise OpenAgentsWeb.PublicNotFoundError, message: "repository not found"
45
      end
46
39 47
    issue = Issues.get_issue_by_number!(repository, String.to_integer(number))
40 48
49
    user = socket.assigns.current_user
50
    can_write = Repositories.writable?(repository, user)
51
52
    if connected?(socket), do: Repositories.subscribe_issues(repository.id)
53
41 54
    {:ok,
42 55
     socket
43 56
     |> assign(:current_scope, socket.assigns[:current_scope])
44 57
     |> assign(:owner, owner)
45 58
     |> assign(:repo, repo)
46 59
     |> assign(:repository, repository)
60
     |> assign(:current_user, user)
61
     |> assign(:can_write, can_write)
62
     |> assign(
63
       :can_participate,
64
       Repositories.issue_participant?(repository, user)
65
     )
66
     |> assign(:can_edit, can_write || author?(issue, user))
47 67
     |> assign(:editing, false)
48 68
     |> assign(:comment_form, to_form(Comment.changeset(%Comment{}, %{})))
49
     |> assign(:repo_labels, Labels.list_labels(repository))
50
     |> assign(:repo_milestones, Milestones.list_milestones(repository))
51
     |> assign(:assignable, Repositories.list_assignable_users(repository))
69
     |> assign(:repo_labels, if(can_write, do: Labels.list_labels(repository), else: []))
70
     |> assign(
71
       :repo_milestones,
72
       if(can_write, do: Milestones.list_milestones(repository), else: [])
73
     )
74
     |> assign(
75
       :assignable,
76
       if(can_write, do: Repositories.list_assignable_users(repository), else: [])
77
     )
52 78
     |> load(issue)}
53 79
  end
54 80
55
  def handle_event("toggle_edit", _params, socket) do
81
  def handle_event("toggle_edit", _params, socket) when socket.assigns.can_edit do
56 82
    issue = socket.assigns.issue
57 83
58 84
    {:noreply,

@@ -61,7 +87,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

61 87
     |> assign(:form, to_form(Issues.change_issue(issue)))}
62 88
  end
63 89
64
  def handle_event("save", %{"issue" => issue_params}, socket) do
90
  def handle_event("save", %{"issue" => issue_params}, socket) when socket.assigns.can_edit do
65 91
    issue = socket.assigns.issue
66 92
    attrs = %{"title" => issue_params["title"], "body" => issue_params["body"]}
67 93

@@ -78,18 +104,25 @@ defmodule OpenAgentsWeb.IssueShowLive do

78 104
    end
79 105
  end
80 106
81
  def handle_event("close", _params, socket), do: set_state(socket, "closed", "completed")
82
  def handle_event("reopen", _params, socket), do: set_state(socket, "open", nil)
107
  # A viewer without authority who hand-crafts an event gets a refusal rather
108
  # than a silent success; the UI never shows them the control in the first
109
  # place.
110
  def handle_event("close", _params, socket) when socket.assigns.can_edit,
111
    do: set_state(socket, "closed", "completed")
112
113
  def handle_event("reopen", _params, socket) when socket.assigns.can_edit,
114
    do: set_state(socket, "open", nil)
83 115
84 116
  # The rail's state menu picks a close reason as well as a state, which the
85 117
  # header's two buttons cannot. Both end in the same write.
86
  def handle_event("set_state", %{"state" => "open"}, socket),
118
  def handle_event("set_state", %{"state" => "open"}, socket) when socket.assigns.can_edit,
87 119
    do: set_state(socket, "open", nil)
88 120
89
  def handle_event("set_state", %{"state" => "closed"} = params, socket),
90
    do: set_state(socket, "closed", params["reason"] || "completed")
121
  def handle_event("set_state", %{"state" => "closed"} = params, socket)
122
      when socket.assigns.can_edit,
123
      do: set_state(socket, "closed", params["reason"] || "completed")
91 124
92
  def handle_event("toggle_label", %{"name" => name}, socket) do
125
  def handle_event("toggle_label", %{"name" => name}, socket) when socket.assigns.can_write do
93 126
    issue = socket.assigns.issue
94 127
95 128
    {:ok, updated} =

@@ -102,7 +135,8 @@ defmodule OpenAgentsWeb.IssueShowLive do

102 135
    {:noreply, load(socket, updated)}
103 136
  end
104 137
105
  def handle_event("toggle_assignee", %{"login" => login}, socket) do
138
  def handle_event("toggle_assignee", %{"login" => login}, socket)
139
      when socket.assigns.can_write do
106 140
    issue = socket.assigns.issue
107 141
108 142
    {:ok, updated} =

@@ -115,12 +149,14 @@ defmodule OpenAgentsWeb.IssueShowLive do

115 149
    {:noreply, load(socket, updated)}
116 150
  end
117 151
118
  def handle_event("set_milestone", %{"number" => number}, socket) do
152
  def handle_event("set_milestone", %{"number" => number}, socket)
153
      when socket.assigns.can_write do
119 154
    {:ok, updated} = Issues.set_milestone(socket.assigns.issue, number_or_nil(number))
120 155
    {:noreply, load(socket, updated)}
121 156
  end
122 157
123
  def handle_event("add_comment", %{"comment" => %{"body" => body}}, socket) do
158
  def handle_event("add_comment", %{"comment" => %{"body" => body}}, socket)
159
      when socket.assigns.can_participate do
124 160
    issue = socket.assigns.issue
125 161
126 162
    case Issues.create_comment(issue, %{body: body}, socket.assigns.current_user) do

@@ -136,6 +172,26 @@ defmodule OpenAgentsWeb.IssueShowLive do

136 172
    end
137 173
  end
138 174
175
  # Live updates: someone else's write re-reads this issue through the same
176
  # visibility check the mount used.
177
  def handle_info({:issues_changed, repository_id}, socket)
178
      when repository_id == socket.assigns.repository.id do
179
    issue = Issues.get_issue_by_number!(socket.assigns.repository, socket.assigns.issue.number)
180
181
    {:noreply,
182
     socket
183
     |> assign(:can_edit, socket.assigns.can_write || author?(issue, socket.assigns.current_user))
184
     |> load(issue)}
185
  end
186
187
  def handle_info({:issues_changed, _other_repository}, socket), do: {:noreply, socket}
188
189
  defp author?(%Issue{author_user_id: author_id}, %OpenAgents.Accounts.User{id: user_id})
190
       when is_binary(author_id),
191
       do: author_id == user_id
192
193
  defp author?(_issue, _user), do: false
194
139 195
  defp set_state(socket, state, reason) do
140 196
    attrs = %{"state" => state, "state_reason" => reason}
141 197

@@ -207,7 +263,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

207 263
            </p>
208 264
            <div class="issue-heading__actions">
209 265
              <button
210
                :if={@issue.state == "open"}
266
                :if={@can_edit and @issue.state == "open"}
211 267
                class="btn"
212 268
                data-variant="primary"
213 269
                data-size="sm"

@@ -216,7 +272,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

216 272
                Close issue
217 273
              </button>
218 274
              <button
219
                :if={@issue.state == "closed"}
275
                :if={@can_edit and @issue.state == "closed"}
220 276
                class="btn"
221 277
                data-variant="ghost"
222 278
                data-size="sm"

@@ -224,7 +280,13 @@ defmodule OpenAgentsWeb.IssueShowLive do

224 280
              >
225 281
                Reopen issue
226 282
              </button>
227
              <button class="btn" data-variant="ghost" data-size="sm" phx-click="toggle_edit">
283
              <button
284
                :if={@can_edit}
285
                class="btn"
286
                data-variant="ghost"
287
                data-size="sm"
288
                phx-click="toggle_edit"
289
              >
228 290
                <.icon name="edit" /> Edit
229 291
              </button>
230 292
            </div>

@@ -232,7 +294,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

232 294
        </:heading>
233 295
234 296
        <:rail>
235
          <Circle.properties_panel>
297
          <Circle.properties_panel :if={@can_write}>
236 298
            <:group heading="State">
237 299
              <Circle.field_menu id="issue-state-menu" label="Change the state of this issue">
238 300
                <:trigger>

@@ -373,7 +435,24 @@ defmodule OpenAgentsWeb.IssueShowLive do

373 435
          This conversation is locked{if @issue.locked_reason, do: " as #{@issue.locked_reason}"}.
374 436
        </.alert>
375 437
376
        <.form :if={!@issue.locked} for={@comment_form} id="comment-form" phx-submit="add_comment">
438
        <.alert
439
          :if={!@can_participate and !@issue.locked}
440
          variant={:info}
441
          appearance={:notice}
442
          id="sign-in-to-comment"
443
        >
444
          <.link navigate={~p"/"} class="font-medium underline">
445
            Sign in with GitHub
446
          </.link>
447
          to comment on this issue.
448
        </.alert>
449
450
        <.form
451
          :if={@can_participate and !@issue.locked}
452
          for={@comment_form}
453
          id="comment-form"
454
          phx-submit="add_comment"
455
        >
377 456
          <Circle.comment_composer id="issue-composer" author={viewer(@current_user)}>
378 457
            <.input
379 458
              field={@comment_form[:body]}
lib/openagents_web/live/member_index_live.ex added +213

@@ -0,0 +1,213 @@

1
defmodule OpenAgentsWeb.MemberIndexLive do
2
  @moduledoc """
3
  Repository members, managed by owners.
4
5
  Membership is what admits a person to private repositories, issue triage,
6
  and Git push, so the surface stays owner-only and every change flows through
7
  `OpenAgents.Repositories.add_member_by_login/4` and its siblings, which
8
  write audit records. The last owner cannot be demoted or removed; that rule
9
  lives in the context and is enforced again here for a readable error.
10
  """
11
  use OpenAgentsWeb, :live_view
12
13
  alias OpenAgents.Repositories
14
15
  @roles ~w(owner maintainer contributor viewer)
16
17
  def mount(%{"owner" => owner, "repo" => repo}, _session, socket) do
18
    user = socket.assigns.current_user
19
20
    # A non-member gets the same quiet bounce as a nonexistent repository:
21
    # member administration never confirms who has access to what.
22
    repository =
23
      try do
24
        Repositories.get_writable_by_path!(owner, repo, user)
25
      rescue
26
        Ecto.NoResultsError -> nil
27
      end
28
29
    cond do
30
      is_nil(repository) ->
31
        {:ok,
32
         socket
33
         |> put_flash(:error, "You do not have access to that repository.")
34
         |> redirect(to: ~p"/")}
35
36
      not Repositories.owner?(repository, user) ->
37
        {:ok,
38
         socket
39
         |> assign(:current_scope, socket.assigns[:current_scope])
40
         |> put_flash(:error, "Only repository owners can manage members.")
41
         |> redirect(to: ~p"/#{owner}/#{repo}/issues")}
42
43
      true ->
44
        {:ok,
45
         socket
46
         |> assign(:current_scope, socket.assigns[:current_scope])
47
         |> assign(:owner, owner)
48
         |> assign(:repo, repo)
49
         |> assign(:repository, repository)
50
         |> assign(:roles, @roles)
51
         |> stream(:members, Repositories.list_members(repository),
52
           dom_id: &"member-#{&1.user_id}",
53
           reset: true
54
         )}
55
    end
56
  end
57
58
  def handle_event("add_member", %{"member" => params}, socket) do
59
    login = params["login"] || ""
60
    role = params["role"] || "contributor"
61
62
    case Repositories.add_member_by_login(
63
           socket.assigns.repository,
64
           socket.assigns.current_user,
65
           login,
66
           role
67
         ) do
68
      {:ok, _membership} ->
69
        {:noreply,
70
         socket
71
         |> reload()
72
         |> put_flash(:info, "@#{login} added as #{role}")}
73
74
      {:error, :unknown_user} ->
75
        {:noreply,
76
         put_flash(
77
           socket,
78
           :error,
79
           "No OpenAgents account found for @#{login}. They need to sign in once first."
80
         )}
81
    end
82
  end
83
84
  def handle_event("change_role", %{"user_id" => user_id, "role" => role}, socket)
85
      when role in @roles do
86
    case Repositories.change_member_role(
87
           socket.assigns.repository,
88
           socket.assigns.current_user,
89
           user_id,
90
           role
91
         ) do
92
      {:ok, _membership} ->
93
        {:noreply,
94
         socket
95
         |> reload()
96
         |> put_flash(:info, "Role updated to #{role}")}
97
98
      {:error, :last_owner} ->
99
        {:noreply, put_flash(socket, :error, "A repository must keep at least one owner.")}
100
    end
101
  end
102
103
  def handle_event("remove_member", %{"user-id" => user_id}, socket) do
104
    case Repositories.remove_member(
105
           socket.assigns.repository,
106
           socket.assigns.current_user,
107
           user_id
108
         ) do
109
      :ok ->
110
        {:noreply,
111
         socket
112
         |> reload()
113
         |> put_flash(:info, "Member removed")}
114
115
      {:error, :last_owner} ->
116
        {:noreply, put_flash(socket, :error, "A repository must keep at least one owner.")}
117
    end
118
  end
119
120
  defp reload(socket) do
121
    socket
122
    |> stream(
123
      :members,
124
      Repositories.list_members(socket.assigns.repository),
125
      dom_id: &"member-#{&1.user_id}",
126
      reset: true
127
    )
128
  end
129
130
  def render(assigns) do
131
    ~H"""
132
    <Layouts.app
133
      flash={@flash}
134
      sidebar_sections={assigns[:sidebar_sections]}
135
      current_scope={@current_scope}
136
      title="Members"
137
    >
138
      <div class="flex items-center justify-between mb-4">
139
        <h1 class="text-2xl font-bold">Members</h1>
140
      </div>
141
142
      <.form for={%{}} as={:member} phx-submit="add_member" id="add-member-form">
143
        <div class="grid grid-cols-1 md:grid-cols-[2fr_1fr_auto] gap-4 items-end card !m-0 mb-6 p-4">
144
          <.input
145
            name="member[login]"
146
            label="GitHub login"
147
            placeholder="their-github-login"
148
            required
149
          />
150
          <.input
151
            name="member[role]"
152
            type="select"
153
            label="Role"
154
            options={Enum.map(@roles, &{String.capitalize(&1), &1})}
155
            value="contributor"
156
          />
157
          <.button type="submit" variant={:primary}>Add member</.button>
158
        </div>
159
      </.form>
160
161
      <div id="members" phx-update="stream">
162
        <div
163
          :for={{id, membership} <- @streams.members}
164
          id={id}
165
          class="flex items-center justify-between border-b border-border py-3"
166
        >
167
          <div class="flex items-center gap-3 min-w-0">
168
            <img
169
              src={membership.user.github_avatar_url}
170
              alt=""
171
              class="size-8 rounded-full"
172
              loading="lazy"
173
            />
174
            <span class="font-medium truncate">{membership.user.github_login}</span>
175
          </div>
176
177
          <div class="flex items-center gap-2">
178
            <form
179
              id={"member-role-form-#{id}"}
180
              phx-change="change_role"
181
              class="flex items-center gap-2"
182
            >
183
              <input type="hidden" name="user_id" value={membership.user_id} />
184
              <select
185
                name="role"
186
                aria-label={"Role for #{membership.user.github_login}"}
187
                class="input !py-1.5"
188
                data-member-role={membership.role}
189
              >
190
                {Phoenix.HTML.Form.options_for_select(
191
                  Enum.map(@roles, &{String.capitalize(&1), &1}),
192
                  membership.role
193
                )}
194
              </select>
195
            </form>
196
            <button
197
              phx-click="remove_member"
198
              phx-value-user-id={membership.user_id}
199
              class="btn"
200
              data-variant="ghost"
201
              data-size="sm"
202
              data-tone="danger"
203
              aria-label={"Remove #{membership.user.github_login}"}
204
            >
205
              Remove
206
            </button>
207
          </div>
208
        </div>
209
      </div>
210
    </Layouts.app>
211
    """
212
  end
213
end
lib/openagents_web/route_authority.ex modified +22 -1

@@ -210,6 +210,17 @@ defmodule OpenAgentsWeb.RouteAuthority do

210 210
      tracker_browser_path?(path) ->
211 211
        declaration(:authenticated_browser, "active encrypted browser session", "forge:web", true)
212 212
213
      issue_browser_path?(path) and verb in [:get, :head] ->
214
        # Reading issues is public on a public repository. The GET mutates
215
        # nothing: every write rides the LiveView channel and is re-checked
216
        # against writability in the view's event handlers.
217
        declaration(
218
          :public_read,
219
          "anonymous visitor or signed-in person",
220
          "forge:issues:web",
221
          false
222
        )
223
213 224
      repository_browser_path?(path) and verb in [:get, :head] ->
214 225
        declaration(
215 226
          :public_read,

@@ -243,7 +254,17 @@ defmodule OpenAgentsWeb.RouteAuthority do

243 254
  defp browser_mutation?(_path, _verb), do: true
244 255
245 256
  defp tracker_browser_path?(path) do
246
    String.match?(path, ~r{\A/:owner/:repo/(issues|labels|milestones|assignees|projects)})
257
    String.match?(
258
      path,
259
      ~r{\A/:owner/:repo/(issues/new|labels|milestones|assignees|projects|members)}
260
    )
261
  end
262
263
  # The issue index and detail pages live in their own public-read session;
264
  # `issues/new` above stays behind sign-in because filing needs an author.
265
  defp issue_browser_path?(path) do
266
    path == "/:owner/:repo/issues" or
267
      String.match?(path, ~r{\A/:owner/:repo/issues/:number\z})
247 268
  end
248 269
249 270
  defp repository_browser_path?(path) do
lib/openagents_web/router.ex modified +17 -2

@@ -109,9 +109,8 @@ defmodule OpenAgentsWeb.Router do

109 109
      live "/repositories/new", RepositoryNewLive, :new
110 110
      live "/repositories/import/github", RepositoryImportLive, :new
111 111
      live "/:owner/:repo/issues/new", IssueNewLive, :new
112
      live "/:owner/:repo/issues/:number", IssueShowLive, :show
113
      live "/:owner/:repo/issues", IssueIndexLive, :index
114 112
113
      live "/:owner/:repo/members", MemberIndexLive, :index
115 114
      live "/:owner/:repo/labels", LabelIndexLive, :index
116 115
      live "/:owner/:repo/milestones", MilestoneIndexLive, :index
117 116
      live "/:owner/:repo/assignees", AssigneeIndexLive, :index

@@ -141,6 +140,22 @@ defmodule OpenAgentsWeb.Router do

141 140
    delete "/github/connection", AuthController, :disconnect
142 141
  end
143 142
143
  # Reading issues is a public activity on a public repository, the way code
144
  # browsing already is, so this session runs behind plain :browser and mounts
145
  # whoever is signed in. Each view decides what an anonymous visitor may do,
146
  # and every write re-checks authority at the server. The scope comes after
147
  # the authenticated one so the literal `new` segment keeps winning over
148
  # `:number`.
149
  scope "/", OpenAgentsWeb do
150
    pipe_through :browser
151
152
    live_session :forge_issues,
153
      on_mount: [{OpenAgentsWeb.UserAuth, :mount_current_user}] do
154
      live "/:owner/:repo/issues/:number", IssueShowLive, :show
155
      live "/:owner/:repo/issues", IssueIndexLive, :index
156
    end
157
  end
158
144 159
  scope "/api", OpenAgentsWeb do
145 160
    pipe_through :authenticated_api
146 161
test/openagents/issues_query_test.exs added +164

@@ -0,0 +1,164 @@

1
defmodule OpenAgents.IssuesQueryTest do
2
  use OpenAgents.DataCase
3
4
  import OpenAgents.AccountsFixtures
5
6
  alias OpenAgents.Issues
7
  alias OpenAgents.Labels
8
  alias OpenAgents.Milestones
9
  alias OpenAgents.Repositories
10
11
  setup do
12
    repository = Repositories.initial_repository!()
13
14
    {:ok, open} =
15
      Issues.create_issue(repository, %{"title" => "First open", "body" => "alpha body"})
16
17
    {:ok, closed} =
18
      Issues.create_issue(repository, %{"title" => "A closed one", "body" => "beta body"})
19
20
    {:ok, _} = Issues.update_issue(closed, %{"state" => "closed", "state_reason" => "completed"})
21
22
    %{repository: repository, open: open, closed: closed}
23
  end
24
25
  test "list_issues_page returns one page plus the unpaginated total", %{
26
    repository: repository,
27
    open: open,
28
    closed: closed
29
  } do
30
    {issues, total} = Issues.list_issues_page(repository, state: "all", page: 1)
31
32
    assert total == 2
33
    assert length(issues) == 2
34
    # Newest first.
35
    assert hd(issues).id == closed.id or hd(issues).id == open.id
36
  end
37
38
  test "the state filter matches the tab it drives", %{repository: repository} do
39
    {open_issues, open_total} = Issues.list_issues_page(repository, state: "open")
40
    {closed_issues, closed_total} = Issues.list_issues_page(repository, state: "closed")
41
42
    assert open_total == 1
43
    assert closed_total == 1
44
    assert Enum.map(open_issues, & &1.state) == ["open"]
45
    assert Enum.map(closed_issues, & &1.state) == ["closed"]
46
  end
47
48
  test "the label filter reads the row snapshot", %{repository: repository, open: open} do
49
    Labels.create_label(repository, %{"name" => "triaged", "color" => "0e8a16"})
50
    {:ok, _} = Issues.add_labels(open, ["triaged"])
51
52
    {_matching, total} = Issues.list_issues_page(repository, state: "all", label: "triaged")
53
    assert total == 1
54
55
    {_matching, total} = Issues.list_issues_page(repository, state: "all", label: "bug")
56
    assert total == 0
57
  end
58
59
  test "the assignee filter is case-insensitive on login", %{
60
    repository: repository,
61
    open: open
62
  } do
63
    user = repository_user_fixture("filter-assignee")
64
    {:ok, _} = Repositories.add_member(repository, user, "maintainer")
65
66
    {:ok, _} = Issues.add_assignees(open, ["Filter-Assignee"])
67
68
    {_matching, total} =
69
      Issues.list_issues_page(repository, state: "all", assignee: "filter-assignee")
70
71
    assert total == 1
72
  end
73
74
  test "the milestone filter reads the snapshot number", %{
75
    repository: repository,
76
    open: open
77
  } do
78
    {:ok, milestone} =
79
      Milestones.create_milestone(repository, %{"title" => "Sweep one"}, nil)
80
81
    {:ok, _} = Issues.set_milestone(open, milestone.number)
82
83
    {_matching, total} =
84
      Issues.list_issues_page(repository,
85
        state: "all",
86
        milestone: Integer.to_string(milestone.number)
87
      )
88
89
    assert total == 1
90
91
    {_matching, total} =
92
      Issues.list_issues_page(repository, state: "all", milestone: "9999")
93
94
    assert total == 0
95
  end
96
97
  test "search matches title or body and escapes wildcards", %{
98
    repository: repository,
99
    open: open,
100
    closed: closed
101
  } do
102
    {_matching, total} = Issues.list_issues_page(repository, state: "all", q: "closed one")
103
    assert total == 1
104
105
    {_matching, total} = Issues.list_issues_page(repository, state: "all", q: "ALPHA")
106
    assert total == 1
107
108
    assert hd(elem(Issues.list_issues_page(repository, state: "all", q: "ALPHA"), 0)).id ==
109
             open.id
110
111
    # A percent sign is a literal, not a wildcard.
112
    {:ok, _literal} = Issues.create_issue(repository, %{"title" => "100% repro"})
113
    {_matching, literal_total} = Issues.list_issues_page(repository, state: "all", q: "100%")
114
    assert literal_total == 1
115
116
    assert closed.title =~ "closed"
117
  end
118
119
  test "counts respect every filter except state so both tabs agree", %{
120
    repository: repository,
121
    open: open
122
  } do
123
    Labels.create_label(repository, %{"name" => "counted", "color" => "1d76db"})
124
    {:ok, _} = Issues.add_labels(open, ["counted"])
125
126
    assert Issues.count_issues(repository, label: "counted", state: "open") == 1
127
    assert Issues.count_issues(repository, label: "counted", state: "closed") == 0
128
    assert Issues.count_issues(repository, state: "all") == 2
129
  end
130
131
  test "pagination walks without repeating rows", %{repository: repository} do
132
    for index <- 1..29 do
133
      {:ok, _} = Issues.create_issue(repository, %{"title" => "Bulk #{index}"})
134
    end
135
136
    {page_one, total} = Issues.list_issues_page(repository, state: "all", page: 1)
137
    {page_two, ^total} = Issues.list_issues_page(repository, state: "all", page: 2)
138
139
    assert total == 31
140
    assert length(page_one) == Issues.per_page()
141
    assert length(page_two) == 31 - Issues.per_page()
142
143
    page_one_ids = MapSet.new(page_one, & &1.id)
144
    assert Enum.all?(page_two, &(&1.id not in page_one_ids))
145
  end
146
147
  test "committed writes announce themselves on the repository topic", %{
148
    repository: repository
149
  } do
150
    :ok = Repositories.subscribe_issues(repository.id)
151
152
    {:ok, issue} = Issues.create_issue(repository, %{"title" => "Announced"})
153
    assert_receive {:issues_changed, repository_id}, 500
154
    assert repository_id == repository.id
155
156
    {:ok, _} = Issues.update_issue(issue, %{"state" => "closed"}, nil)
157
    assert_receive {:issues_changed, repository_id}, 500
158
    assert repository_id == repository.id
159
160
    {:ok, _} = Issues.create_comment(issue, %{body: "hello"}, nil)
161
    assert_receive {:issues_changed, repository_id}, 500
162
    assert repository_id == repository.id
163
  end
164
end
test/openagents/issues_test.exs modified +14 -2

@@ -362,9 +362,21 @@ defmodule OpenAgents.IssuesTest do

362 362
      assert Enum.map(updated.labels, & &1["name"]) == ["bug", "docs"]
363 363
    end
364 364
365
    test "add_labels/2 raises for an unknown label" do
365
    # GitHub creates a label on the fly when one is added that does not
366
    # exist, and so do we: the name is scoped to this repository either way.
367
    test "add_labels/2 creates an unknown label instead of raising" do
366 368
      issue = issue_fixture()
367
      assert_raise Ecto.NoResultsError, fn -> Issues.add_labels(issue, ["nope"]) end
369
370
      assert {:ok, %Issue{labels: [label]}} = Issues.add_labels(issue, ["nope"])
371
      assert label["name"] == "nope"
372
373
      assert %{color: color} =
374
               OpenAgents.Labels.get_label_by_name!(
375
                 OpenAgents.Repositories.initial_repository!(),
376
                 "nope"
377
               )
378
379
      refute color in ["", nil]
368 380
    end
369 381
370 382
    test "add_labels/2 with an empty list is a no-op" do
test/openagents/repositories_membership_test.exs added +192

@@ -0,0 +1,192 @@

1
defmodule OpenAgents.RepositoriesMembershipTest do
2
  use OpenAgents.DataCase
3
4
  alias OpenAgents.Accounts
5
  alias OpenAgents.Labels
6
  alias OpenAgents.Repositories
7
  alias OpenAgents.Repositories.Membership
8
9
  # A user with no automatic membership anywhere. `repository_user_fixture/1`
10
  # grants the initial repository, so tests that need an outsider build their
11
  # account here instead.
12
  defp plain_user(login) do
13
    github_id = System.unique_integer([:positive, :monotonic])
14
15
    {:ok, user} =
16
      Accounts.upsert_github_user(%{
17
        github_id: github_id,
18
        github_login: login,
19
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
20
      })
21
22
    user
23
  end
24
25
  setup do
26
    repository = Repositories.initial_repository!()
27
    owner = plain_user("members-owner")
28
    {:ok, _membership} = Repositories.add_member(repository, owner, "owner")
29
30
    %{repository: repository, owner: owner}
31
  end
32
33
  test "list_members orders by role rank and then login", %{
34
    repository: repository,
35
    owner: _owner
36
  } do
37
    maintainer = plain_user("zebra-maintainer")
38
    viewer = plain_user("alpha-viewer")
39
40
    {:ok, _} = Repositories.add_member(repository, maintainer, "maintainer")
41
    {:ok, _} = Repositories.add_member(repository, viewer, "viewer")
42
43
    members = Repositories.list_members(repository)
44
45
    assert Enum.map(members, &{&1.user.github_login, &1.role}) == [
46
             {"members-owner", "owner"},
47
             {"zebra-maintainer", "maintainer"},
48
             {"alpha-viewer", "viewer"}
49
           ]
50
  end
51
52
  test "add_member_by_login grants a role to an existing active user", %{
53
    repository: repository,
54
    owner: owner
55
  } do
56
    newcomer = plain_user("newcomer")
57
58
    assert {:ok, %Membership{} = membership} =
59
             Repositories.add_member_by_login(
60
               repository,
61
               owner,
62
               String.upcase(newcomer.github_login),
63
               "contributor"
64
             )
65
66
    assert membership.role == "contributor"
67
    assert membership.user_id == newcomer.id
68
  end
69
70
  test "add_member_by_login rejects unknown users and invalid roles", %{
71
    repository: repository,
72
    owner: owner
73
  } do
74
    assert {:error, :unknown_user} =
75
             Repositories.add_member_by_login(repository, owner, "nobody-here", "contributor")
76
77
    assert_raise FunctionClauseError, fn ->
78
      Repositories.add_member_by_login(repository, owner, "anyone", "emperor")
79
    end
80
  end
81
82
  test "change_member_role updates an existing membership", %{
83
    repository: repository,
84
    owner: owner
85
  } do
86
    member = plain_user("promoted")
87
    {:ok, _} = Repositories.add_member(repository, member, "contributor")
88
89
    assert {:ok, %Membership{role: "maintainer"}} =
90
             Repositories.change_member_role(repository, owner, member.id, "maintainer")
91
  end
92
93
  test "change_member_role refuses users who hold no membership", %{
94
    repository: repository,
95
    owner: owner
96
  } do
97
    stranger = plain_user("stranger-not-member")
98
99
    assert {:error, :unknown_member} =
100
             Repositories.change_member_role(repository, owner, stranger.id, "viewer")
101
  end
102
103
  test "the last owner cannot be demoted or removed", %{
104
    repository: repository,
105
    owner: owner
106
  } do
107
    assert {:error, :last_owner} =
108
             Repositories.change_member_role(repository, owner, owner.id, "maintainer")
109
110
    assert {:error, :last_owner} = Repositories.remove_member(repository, owner, owner.id)
111
  end
112
113
  test "an owner can be demoted while another owner remains", %{
114
    repository: repository,
115
    owner: owner
116
  } do
117
    second_owner = plain_user("second-owner")
118
    {:ok, _} = Repositories.add_member(repository, second_owner, "owner")
119
120
    assert {:ok, %Membership{role: "maintainer"}} =
121
             Repositories.change_member_role(repository, second_owner, owner.id, "maintainer")
122
  end
123
124
  test "remove_member deletes the membership row", %{repository: repository, owner: owner} do
125
    member = plain_user("departing")
126
    {:ok, _} = Repositories.add_member(repository, member, "contributor")
127
128
    assert :ok = Repositories.remove_member(repository, owner, member.id)
129
    refute Repositories.member?(repository, member)
130
  end
131
132
  test "member?, public?, and issue_participant? follow the participation model", %{
133
    repository: repository
134
  } do
135
    viewer_only = plain_user("viewer-only")
136
    {:ok, _} = Repositories.add_member(repository, viewer_only, "viewer")
137
    outsider = plain_user("outsider")
138
139
    assert Repositories.member?(repository, viewer_only)
140
    assert Repositories.issue_participant?(repository, viewer_only)
141
142
    # A public repository admits any signed-in person to the conversation.
143
    assert Repositories.public?(repository)
144
    assert Repositories.issue_participant?(repository, outsider)
145
    refute Repositories.issue_participant?(repository, nil)
146
147
    {:ok, private} =
148
      Repositories.create_repository(%{
149
        owner: "SecondOrg",
150
        name: "private-participation",
151
        visibility: "private"
152
      })
153
154
    refute Repositories.public?(private)
155
    refute Repositories.issue_participant?(private, outsider)
156
157
    {:ok, _} = Repositories.add_member(private, viewer_only, "viewer")
158
    assert Repositories.issue_participant?(private, viewer_only)
159
    refute Repositories.member?(private, outsider)
160
  end
161
162
  test "seed_default_labels! is idempotent", %{repository: repository} do
163
    assert :ok = Repositories.seed_default_labels!(repository)
164
    before = Labels.list_labels(repository)
165
    assert :ok = Repositories.seed_default_labels!(repository)
166
167
    names = Enum.map(before, & &1.name)
168
    assert "bug" in names
169
    assert "good first issue" in names
170
    assert length(before) == length(Labels.list_labels(repository))
171
  end
172
173
  test "creating a repository through the product path seeds default labels" do
174
    user = plain_user("seeder")
175
176
    {:ok, repository, _receipt} =
177
      Repositories.create_user_repository(
178
        user,
179
        %{
180
          name: "seeded-labels-repo",
181
          visibility: "public",
182
          default_branch: "main"
183
        },
184
        "seed-labels-key-#{System.unique_integer()}"
185
      )
186
187
    names = repository |> Labels.list_labels() |> Enum.map(& &1.name)
188
    assert "bug" in names
189
    assert "enhancement" in names
190
    assert "wontfix" in names
191
  end
192
end
test/openagents/repositories_test.exs modified +16 -4

@@ -75,12 +75,24 @@ defmodule OpenAgents.RepositoriesTest do

75 75
    second: second
76 76
  } do
77 77
    assert {:ok, issue} = Issues.create_issue(initial, %{title: "Scoped"})
78
    assert {:ok, _label} = Labels.create_label(second, %{name: "private", color: "ffffff"})
78
    assert {:ok, second_label} = Labels.create_label(second, %{name: "private", color: "ffffff"})
79 79
    assert {:ok, milestone} = Milestones.create_milestone(second, %{title: "Second"})
80 80
81
    assert_raise Ecto.NoResultsError, fn -> Issues.add_labels(issue, ["private"]) end
82
    assert_raise Ecto.NoResultsError, fn -> Issues.set_milestone(issue, milestone.number) end
83
    assert Labels.list_labels(initial) == []
81
    # Adding a name that exists only in the other repository creates a fresh,
82
    # locally-scoped label; it never links across the repository boundary.
83
    assert {:ok, labelled} = Issues.add_labels(issue, ["private"])
84
    assert [%{"name" => "private"}] = labelled.labels
85
86
    local_label = Labels.get_label_by_name!(initial, "private")
87
    refute local_label.id == second_label.id
88
89
    # The milestone lookup stays strict: a number from another repository
90
    # cannot be attached at all.
91
    assert_raise Ecto.NoResultsError, fn ->
92
      Issues.set_milestone(issue, milestone.number)
93
    end
94
95
    assert Enum.map(Labels.list_labels(second), & &1.name) == ["private"]
84 96
  end
85 97
86 98
  test "only active repository members are assignable", %{initial: initial, second: second} do
test/openagents_web/controllers/issue_label_controller_test.exs modified +18 -5

@@ -6,6 +6,7 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

6 6
  import OpenAgents.LabelsFixtures
7 7
8 8
  alias OpenAgents.Issues
9
  alias OpenAgents.Labels
9 10
10 11
  setup do
11 12
    {:ok, issue} = Issues.create_issue(%{title: "Labelled issue"})

@@ -119,7 +120,10 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

119 120
      assert json_response(conn, 200) == %{"labels" => []}
120 121
    end
121 122
122
    test "POST .../issues/:issue_number/labels returns 404 for a label that does not exist", %{
123
    # GitHub creates a missing label on the fly when one is added to an
124
    # issue, so a script that references `perf` before anyone defined it
125
    # still works and the label outlives the call.
126
    test "POST .../issues/:issue_number/labels creates a label that does not exist yet", %{
123 127
      conn: conn,
124 128
      issue: issue
125 129
    } do

@@ -132,8 +136,13 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

132 136
          }
133 137
        )
134 138
135
      assert json_response(conn, 404) == %{"message" => "Not Found"}
136
      assert Issues.get_issue_by_number!(issue.number).labels == []
139
      assert %{"labels" => [%{"name" => "never-created"}]} = json_response(conn, 200)
140
      assert [%{"name" => "never-created"}] = Issues.get_issue_by_number!(issue.number).labels
141
142
      refute Labels.get_label_by_name!(
143
               OpenAgents.Repositories.initial_repository!(),
144
               "never-created"
145
             ).color == ""
137 146
    end
138 147
139 148
    test "POST .../issues/:issue_number/labels returns 404 for a missing issue", %{conn: conn} do

@@ -183,7 +192,10 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

183 192
      assert json_response(conn, 200) == %{"labels" => []}
184 193
    end
185 194
186
    test "DELETE .../issues/:issue_number/labels/:name is a no-op for an unattached label", %{
195
    # GitHub refuses the removal rather than pretending it happened, so a
196
    # script whose bookkeeping drifted learns about it instead of silently
197
    # succeeding.
198
    test "DELETE .../issues/:issue_number/labels/:name returns 404 for an unattached label", %{
187 199
      conn: conn,
188 200
      issue: issue
189 201
    } do

@@ -196,7 +208,8 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

196 208
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/labels/docs"
197 209
        )
198 210
199
      assert %{"labels" => [%{"name" => "bug"}]} = json_response(conn, 200)
211
      assert json_response(conn, 404)
212
      assert [%{"name" => "bug"}] = Issues.get_issue_by_number!(issue.number).labels
200 213
    end
201 214
202 215
    test "DELETE .../issues/:issue_number/labels/:name returns 404 for a missing issue", %{
test/openagents_web/controllers/label_controller_test.exs modified +30

@@ -103,6 +103,20 @@ defmodule OpenAgentsWeb.LabelControllerTest do

103 103
104 104
      assert json_response(conn, 404) == %{"message" => "Not Found"}
105 105
    end
106
107
    # The advertised URL is percent-encoded the way a path is, so a label
108
    # with a space in its name advertises a link that resolves.
109
    test "the rendered url round-trips through the show endpoint", %{conn: conn} do
110
      label_fixture(%{name: "good first issue", color: "7057ff"})
111
112
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels")
113
114
      assert %{"labels" => [label]} = json_response(conn, 200)
115
      assert label["url"] =~ "good%20first%20issue"
116
117
      %URI{path: path} = URI.parse(label["url"])
118
      assert get(conn, path).resp_body =~ "good first issue"
119
    end
106 120
  end
107 121
108 122
  describe "update" do

@@ -127,6 +141,22 @@ defmodule OpenAgentsWeb.LabelControllerTest do

127 141
      assert json_response(conn, 200)["description"] == "new"
128 142
    end
129 143
144
    # GitHub renames through `new_name`; the name in the path identifies the
145
    # label and `new_name` is what it becomes.
146
    test "PATCH /api/v3/repos/:owner/:repo/labels/:name renames with new_name", %{conn: conn} do
147
      label_fixture(%{name: "bug", color: "d73a4a"})
148
149
      conn =
150
        patch(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels/bug", %{
151
          new_name: "defect"
152
        })
153
154
      assert json_response(conn, 200)["name"] == "defect"
155
      assert Labels.get_label_by_name!("defect").color == "d73a4a"
156
157
      assert_raise Ecto.NoResultsError, fn -> Labels.get_label_by_name!("bug") end
158
    end
159
130 160
    test "PATCH /api/v3/repos/:owner/:repo/labels/:name returns 422 for a blank color", %{
131 161
      conn: conn
132 162
    } do
test/openagents_web/live/issue_access_live_test.exs added +182

@@ -0,0 +1,182 @@

1
defmodule OpenAgentsWeb.IssueAccessLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  import Phoenix.LiveViewTest
5
6
  alias OpenAgents.Accounts
7
  alias OpenAgents.Issues
8
  alias OpenAgents.Repositories
9
10
  # A signed-in account that holds no membership anywhere. The shared fixture
11
  # grants the initial repository, which would defeat every authorization
12
  # boundary these tests exist to exercise.
13
  defp sign_in_non_member(conn, key) do
14
    github_id = System.unique_integer([:positive, :monotonic])
15
16
    {:ok, user} =
17
      Accounts.upsert_github_user(%{
18
        github_id: github_id,
19
        github_login: key,
20
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
21
      })
22
23
    Plug.Test.init_test_session(conn, %{"user_id" => user.id})
24
  end
25
26
  defp file_issue(conn, title) do
27
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues/new")
28
29
    view
30
    |> form("#new-issue-form", issue: %{title: title, body: ""})
31
    |> render_submit()
32
33
    {path, _flash} = assert_redirect(view)
34
    number = path |> String.split("/") |> List.last() |> String.to_integer()
35
36
    Issues.get_issue_by_number!(Repositories.initial_repository!(), number)
37
  end
38
39
  describe "anonymous visitors" do
40
    test "read a public issue and its timeline without any write controls" do
41
      {:ok, issue} = Issues.create_issue(%{"title" => "Readable by anyone"})
42
      {:ok, _} = Issues.create_comment(issue, %{body: "First comment"}, nil)
43
44
      {:ok, view, html} =
45
        live(build_conn(), ~p"/OpenAgentsInc/openagents.com/issues/#{issue.number}")
46
47
      assert html =~ "Readable by anyone"
48
      assert html =~ "First comment"
49
      refute has_element?(view, "#comment-form")
50
      assert has_element?(view, "#sign-in-to-comment")
51
      refute has_element?(view, "#issue-edit-form")
52
      refute has_element?(view, ~s{button[phx-click="close"]})
53
      refute has_element?(view, ~s{button[phx-click="toggle_edit"]})
54
    end
55
56
    test "are refused on a private repository" do
57
      {:ok, _private} =
58
        Repositories.create_repository(%{
59
          owner: "SecondOrg",
60
          name: "secret",
61
          visibility: "private"
62
        })
63
64
      assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
65
        live(build_conn(), ~p"/SecondOrg/secret/issues")
66
      end
67
    end
68
  end
69
70
  describe "signed-in people who are not members" do
71
    setup %{conn: conn} do
72
      {:ok, conn: sign_in_non_member(conn, "passerby")}
73
    end
74
75
    test "can file an issue on a public repository and are recorded as its author", %{
76
      conn: conn
77
    } do
78
      issue = file_issue(conn, "Reported from outside")
79
80
      assert issue.title == "Reported from outside"
81
      assert issue.user["login"] == "passerby"
82
      assert issue.author_user_id != nil
83
    end
84
85
    test "cannot see triage pickers when filing", %{conn: conn} do
86
      {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues/new")
87
88
      refute has_element?(view, ~s{select[name="issue[labels]"]})
89
      refute has_element?(view, ~s{select[name="issue[milestone]"]})
90
    end
91
92
    test "can comment on an open conversation", %{conn: conn} do
93
      {:ok, issue} = Issues.create_issue(%{"title" => "Needs a reply"})
94
      {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues/#{issue.number}")
95
96
      view
97
      |> form("#comment-form", comment: %{body: "I can reproduce this."})
98
      |> render_submit()
99
100
      [comment] = Issues.list_comments(issue)
101
      assert has_element?(view, "#comment-#{comment.id}")
102
      assert comment.body == "I can reproduce this."
103
      assert comment.author_user_id != nil
104
    end
105
106
    test "get no triage controls anywhere on the issue page", %{conn: conn} do
107
      {:ok, issue} = Issues.create_issue(%{"title" => "Not yours to close"})
108
      {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues/#{issue.number}")
109
110
      refute has_element?(view, ~s{button[phx-click="close"]})
111
      refute has_element?(view, ~s{button[phx-click="toggle_edit"]})
112
      refute has_element?(view, ~s{[id^="row-state-"]})
113
114
      assert Issues.get_issue!(issue.id).state == "open"
115
    end
116
117
    test "see the issue list with a filing link but no row menus", %{conn: conn} do
118
      {:ok, _issue} = Issues.create_issue(%{"title" => "Visible to all"})
119
120
      {:ok, view, html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues")
121
122
      assert html =~ "Visible to all"
123
      # They can file, because participating is not triaging.
124
      assert has_element?(view, ~s{a[href="/OpenAgentsInc/openagents.com/issues/new"]})
125
      refute has_element?(view, ~s{[id^="row-state-"]})
126
      refute has_element?(view, ~s{[id^="row-assignee-"]})
127
    end
128
129
    test "are refused on private repositories", %{conn: conn} do
130
      {:ok, _private} =
131
        Repositories.create_repository(%{
132
          owner: "SecondOrg",
133
          name: "inner",
134
          visibility: "private"
135
        })
136
137
      assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
138
        live(conn, ~p"/SecondOrg/inner/issues")
139
      end
140
    end
141
  end
142
143
  describe "the issue's own author" do
144
    setup %{conn: conn} do
145
      {:ok, conn: sign_in_non_member(conn, "reporter")}
146
    end
147
148
    test "can edit the title and body of their own issue afterwards", %{conn: conn} do
149
      issue = file_issue(conn, "Typo in report")
150
151
      {:ok, show_view, _html} =
152
        live(conn, ~p"/OpenAgentsInc/openagents.com/issues/#{issue.number}")
153
154
      show_view
155
      |> element(~s{button[phx-click="toggle_edit"]})
156
      |> render_click()
157
158
      show_view
159
      |> form("#issue-edit-form", issue: %{title: "Fixed title", body: "Now with steps"})
160
      |> render_submit()
161
162
      updated = Issues.get_issue!(issue.id)
163
      assert updated.title == "Fixed title"
164
      assert updated.body == "Now with steps"
165
    end
166
167
    test "can close their own issue", %{conn: conn} do
168
      issue = file_issue(conn, "Solved it myself")
169
170
      {:ok, show_view, _html} =
171
        live(conn, ~p"/OpenAgentsInc/openagents.com/issues/#{issue.number}")
172
173
      show_view
174
      |> element(~s{button[phx-click="close"]})
175
      |> render_click()
176
177
      closed = Issues.get_issue!(issue.id)
178
      assert closed.state == "closed"
179
      assert closed.state_reason == "completed"
180
    end
181
  end
182
end
test/openagents_web/live/issue_index_live_test.exs modified +19 -4

@@ -207,10 +207,25 @@ defmodule OpenAgentsWeb.IssueIndexLiveTest do

207 207
    refute inspect(title_link) =~ "row-assignee-#{issue.id}"
208 208
  end
209 209
210
  test "an anonymous visitor is redirected away from the issue list" do
211
    assert {:error, {:redirect, %{to: to}}} =
212
             live(build_conn(), ~p"/OpenAgentsInc/openagents.com/issues")
210
  # Reading a public repository's issues is a public activity now, the same
211
  # way reading its code is: no redirect, no controls, and an invitation to
212
  # sign in rather than a wall.
213
  test "an anonymous visitor reads a public repository's issue list" do
214
    {:ok, _issue} = Issues.create_issue(%{"title" => "Public spectable"})
215
216
    {:ok, view, html} = live(build_conn(), ~p"/OpenAgentsInc/openagents.com/issues")
217
218
    assert html =~ "Public spectable"
219
    refute has_element?(view, "#issues-empty")
220
    # No triage controls, no filing link: the toolbar actions need authority.
221
    refute has_element?(view, ~s{a[href="/OpenAgentsInc/openagents.com/issues/new"]})
222
    refute has_element?(view, ~s{[id^="row-state-"]})
223
    refute has_element?(view, ~s{[id^="row-assignee-"]})
224
  end
213 225
214
    refute to == "/OpenAgentsInc/openagents.com/issues"
226
  test "an anonymous visitor cannot open a private repository's issues" do
227
    assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
228
      live(build_conn(), ~p"/SecondOrg/hidden-repo/issues")
229
    end
215 230
  end
216 231
end
test/openagents_web/live/issue_show_live_test.exs modified +11 -4

@@ -313,10 +313,17 @@ defmodule OpenAgentsWeb.IssueShowLiveTest do

313 313
    assert html =~ "ada"
314 314
  end
315 315
316
  test "an anonymous visitor is redirected away from an issue page" do
317
    issue = issue!(%{"title" => "Private"})
316
  # Reading is public on a public repository; the page shows the conversation
317
  # and an invitation to sign in, and no control that writes.
318
  test "an anonymous visitor reads an issue page without write controls" do
319
    issue = issue!(%{"title" => "Public reading"})
318 320
319
    assert {:error, {:redirect, %{to: to}}} = live(build_conn(), path(issue))
320
    refute to == "/OpenAgentsInc/openagents.com/issues/#{issue.number}"
321
    {:ok, view, html} = live(build_conn(), path(issue))
322
323
    assert html =~ "Public reading"
324
    assert has_element?(view, "#sign-in-to-comment")
325
    refute has_element?(view, "#comment-form")
326
    refute has_element?(view, ~s{button[phx-click="close"]})
327
    refute has_element?(view, ~s{button[phx-click="toggle_edit"]})
321 328
  end
322 329
end
test/openagents_web/live/member_index_live_test.exs added +122

@@ -0,0 +1,122 @@

1
defmodule OpenAgentsWeb.MemberIndexLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  import Phoenix.LiveViewTest
5
  import OpenAgents.AccountsFixtures
6
7
  alias OpenAgents.Repositories
8
9
  defp plain_user(login) do
10
    github_id = System.unique_integer([:positive, :monotonic])
11
12
    {:ok, user} =
13
      OpenAgents.Accounts.upsert_github_user(%{
14
        github_id: github_id,
15
        github_login: login,
16
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
17
      })
18
19
    user
20
  end
21
22
  defp sign_in_owner(conn, key) do
23
    user = repository_user_fixture(key)
24
    {:ok, _} = Repositories.add_member(Repositories.initial_repository!(), user, "owner")
25
    {Plug.Test.init_test_session(conn, %{"user_id" => user.id}), user}
26
  end
27
28
  test "an owner sees the members and their roles", %{conn: conn} do
29
    {conn, _owner} = sign_in_owner(conn, "members-view-owner")
30
    repository_user_fixture("members-view-member")
31
32
    {:ok, _view, html} = live(conn, ~p"/OpenAgentsInc/openagents.com/members")
33
34
    assert html =~ "members-view-owner"
35
    assert html =~ "members-view-member"
36
    assert html =~ String.capitalize("contributor")
37
  end
38
39
  test "an owner adds a member by GitHub login", %{conn: conn} do
40
    {conn, _owner} = sign_in_owner(conn, "members-add-owner")
41
    recruit = plain_user("recruit")
42
43
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/members")
44
45
    view
46
    |> form("#add-member-form", member: %{login: "recruit", role: "maintainer"})
47
    |> render_submit()
48
49
    html = render(view)
50
    assert has_element?(view, "#members")
51
    assert html =~ "recruit"
52
53
    assert Repositories.membership_role(Repositories.initial_repository!(), recruit) ==
54
             "maintainer"
55
  end
56
57
  test "adding an unknown login explains what to do", %{conn: conn} do
58
    {conn, _owner} = sign_in_owner(conn, "members-unknown-owner")
59
60
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/members")
61
62
    html =
63
      view
64
      |> form("#add-member-form", member: %{login: "never-signed-in", role: "viewer"})
65
      |> render_submit()
66
67
    assert html =~ "need to sign in once"
68
  end
69
70
  test "an owner changes a member's role", %{conn: conn} do
71
    {conn, _owner} = sign_in_owner(conn, "members-role-owner")
72
    member = repository_user_fixture("role-target")
73
74
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/members")
75
76
    view
77
    |> form("#member-role-form-member-#{member.id}", %{
78
      "user_id" => member.id,
79
      "role" => "maintainer"
80
    })
81
    |> render_change()
82
83
    assert Repositories.membership_role(Repositories.initial_repository!(), member) ==
84
             "maintainer"
85
  end
86
87
  test "an owner removes a member", %{conn: conn} do
88
    {conn, _owner} = sign_in_owner(conn, "members-remove-owner")
89
    member = repository_user_fixture("remove-target")
90
    repository = Repositories.initial_repository!()
91
92
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/members")
93
94
    view
95
    |> element(~s{button[phx-click="remove_member"][phx-value-user-id="#{member.id}"]})
96
    |> render_click()
97
98
    refute Repositories.member?(repository, member)
99
  end
100
101
  test "the last owner cannot be removed through the page", %{conn: conn} do
102
    {conn, owner} = sign_in_owner(conn, "members-last-owner")
103
104
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/members")
105
106
    html =
107
      view
108
      |> element(~s{button[phx-click="remove_member"][phx-value-user-id="#{owner.id}"]})
109
      |> render_click()
110
111
    assert html =~ "at least one owner"
112
    assert Repositories.member?(Repositories.initial_repository!(), owner)
113
  end
114
115
  test "a non-owner is sent away", %{conn: conn} do
116
    conn = log_in_github_user(conn, "plain-contributor")
117
118
    {:error, {:redirect, %{to: to}}} = live(conn, ~p"/OpenAgentsInc/openagents.com/members")
119
120
    assert to == "/OpenAgentsInc/openagents.com/issues"
121
  end
122
end

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