Harden issue participation and membership authorization

503ebf2425fe · AtlantisPleb · · parent 41a86558dc9c

Harden issue participation and membership authorization

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified docs/2026-08-21-issue-project-triage-runbook.md
  • modified lib/openagents/issues.ex
  • modified lib/openagents/labels.ex
  • modified lib/openagents/repositories.ex
  • modified lib/openagents_web/controllers/issue_assignee_controller.ex
  • modified lib/openagents_web/controllers/issue_label_controller.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
  • modified lib/openagents_web/live/member_index_live.ex
  • modified test/openagents/issues_query_test.exs
  • modified test/openagents/repositories_membership_test.exs
  • modified test/openagents_web/controllers/issue_label_controller_test.exs
  • modified test/openagents_web/live/issue_access_live_test.exs
  • modified test/openagents_web/live/issue_show_live_test.exs
  • modified test/openagents_web/live/member_index_live_test.exs

Diff

16 files changed, +479 -215

docs/2026-08-21-issue-project-triage-runbook.md modified +18 -10

@@ -43,7 +43,7 @@ Reading and writing split cleanly, GitHub-style:

43 43
| Signed in, not a member | Public repositories | Yes | Yes | No |
44 44
| An issue's author | Their issue | Already filed | Yes | Edit title/body, close, reopen their own |
45 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 |
46
| `viewer` member | Including private repositories | Yes | Yes | No |
47 47
48 48
Rules the server enforces, regardless of what any client renders:
49 49

@@ -168,8 +168,8 @@ Deliberately not built yet, in priority order:

168 168
| --- | --- |
169 169
| `owner` | Everything, including managing members |
170 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 |
171
| `contributor` | Triage, file and comment on issues, and push Git changes |
172
| `viewer` | Read visible repositories and join their issue conversations |
173 173
174 174
### One-time setup for this repository
175 175

@@ -181,14 +181,21 @@ so give it the default vocabulary once. Either use the Labels page

181 181
TOKEN=oa_pat_your_token   # created at /settings/api-tokens, forge:write scope
182 182
BASE=https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.com/labels
183 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#*:}
184
while IFS='|' read -r name color; do
188 185
  curl -s -X POST "$BASE" -H "Authorization: Bearer $TOKEN" \
189 186
    -H "Content-Type: application/json" \
190 187
    -d "{\"name\": \"$name\", \"color\": \"$color\"}" >/dev/null
191
done
188
done <<'LABELS'
189
bug|d73a4a
190
documentation|0075ca
191
duplicate|cfd3d7
192
enhancement|a2eeef
193
good first issue|7057ff
194
help wanted|008672
195
invalid|e4e669
196
question|d876e3
197
wontfix|ffffff
198
LABELS
192 199
```
193 200
194 201
Repositories created from now on get these labels automatically.

@@ -314,9 +321,10 @@ curl -s -X PATCH "https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.c

314 321
  -d '{"state": "closed", "state_reason": "not_planned"}'
315 322
```
316 323
317
Label and assign in one update: `labels` takes names (creating missing ones),
324
Label and assign in one issue update: `labels` takes existing names,
318 325
`assignees` takes logins, and both replace the full set, so send the complete
319
desired lists.
326
desired lists. Use `POST .../issues/{issue_number}/labels` when you want a
327
missing label to be created as it is added.
320 328
321 329
### Measuring triage health
322 330
lib/openagents/issues.ex modified +22 -11

@@ -15,6 +15,7 @@ defmodule OpenAgents.Issues do

15 15
  alias OpenAgents.Repositories.Repository
16 16
17 17
  @issues_per_page 25
18
  @maximum_page 10_000
18 19
19 20
  @doc "How many issues one index page shows."
20 21
  def per_page, do: @issues_per_page

@@ -25,7 +26,7 @@ defmodule OpenAgents.Issues do

25 26
  def list_issues(%Repository{id: repository_id}, opts) when is_list(opts) do
26 27
    repository_id
27 28
    |> issue_query(opts)
28
    |> order_by(desc: :inserted_at)
29
    |> order_by([issue], desc: issue.inserted_at, desc: issue.id)
29 30
    |> Repo.all()
30 31
  end
31 32

@@ -44,7 +45,7 @@ defmodule OpenAgents.Issues do

44 45
45 46
    issues =
46 47
      query
47
      |> order_by(desc: :inserted_at)
48
      |> order_by([issue], desc: issue.inserted_at, desc: issue.id)
48 49
      |> limit(@issues_per_page)
49 50
      |> offset(^((page - 1) * @issues_per_page))
50 51
      |> Repo.all()

@@ -55,12 +56,13 @@ defmodule OpenAgents.Issues do

55 56
  def count_issues(%Repository{} = repository, opts) when is_list(opts),
56 57
    do: repository.id |> issue_query(opts) |> Repo.aggregate(:count)
57 58
58
  def parse_page(page) when is_integer(page), do: page
59
  def parse_page(page) when is_integer(page), do: page |> max(1) |> min(@maximum_page)
59 60
60 61
  def parse_page(page) when is_binary(page) do
61 62
    case Integer.parse(page) do
62
      {number, _rest} -> number
63
      {number, ""} -> parse_page(number)
63 64
      :error -> 1
65
      {_number, _trailing} -> 1
64 66
    end
65 67
  end
66 68

@@ -215,14 +217,23 @@ defmodule OpenAgents.Issues do

215 217
  def add_labels(%Issue{} = issue, names) when is_list(names) do
216 218
    repository = repository_stub(issue.repository_id)
217 219
218
    new_labels =
219
      Enum.map(names, fn name ->
220
        {:ok, label} = Labels.get_or_create_label_by_name(repository, name)
221
        label_json(label)
222
      end)
220
    with {:ok, new_labels} <- resolve_or_create_labels(repository, names) do
221
      labels = ((issue.labels || []) ++ new_labels) |> Enum.uniq_by(& &1["name"])
222
      update_issue(issue, %{"labels" => labels})
223
    end
224
  end
223 225
224
    labels = ((issue.labels || []) ++ new_labels) |> Enum.uniq_by(& &1["name"])
225
    update_issue(issue, %{"labels" => labels})
226
  defp resolve_or_create_labels(repository, names) do
227
    Enum.reduce_while(names, {:ok, []}, fn name, {:ok, labels} ->
228
      case Labels.get_or_create_label_by_name(repository, name) do
229
        {:ok, label} -> {:cont, {:ok, [label_json(label) | labels]}}
230
        {:error, _reason} = error -> {:halt, error}
231
      end
232
    end)
233
    |> case do
234
      {:ok, labels} -> {:ok, Enum.reverse(labels)}
235
      error -> error
236
    end
226 237
  end
227 238
228 239
  def remove_label(%Issue{} = issue, name) when is_binary(name) do
lib/openagents/labels.ex modified +13 -1

@@ -72,7 +72,19 @@ defmodule OpenAgents.Labels do

72 72
        {:ok, label}
73 73
74 74
      nil ->
75
        create_label(repository, %{"name" => decoded, "color" => generated_color()}, actor)
75
        case create_label(repository, %{"name" => decoded, "color" => generated_color()}, actor) do
76
          {:error, %Ecto.Changeset{}} = error ->
77
            # Another request can create the same label after the read above.
78
            # Return that committed row instead of turning a harmless race
79
            # into a 500 response.
80
            case Repo.get_by(Label, repository_id: repository.id, name: decoded) do
81
              %Label{} = label -> {:ok, label}
82
              nil -> error
83
            end
84
85
          result ->
86
            result
87
        end
76 88
    end
77 89
  end
78 90
lib/openagents/repositories.ex modified +83 -68

@@ -595,86 +595,97 @@ defmodule OpenAgents.Repositories do

595 595
  @doc "The acting owner's view of one member row: add by GitHub login."
596 596
  def add_member_by_login(%Repository{} = repository, %User{} = actor, login, role)
597 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
598
    with_owner_memberships(repository, actor, fn _memberships ->
599
      case active_user_by_login(login) do
600
        %User{} = user ->
601
          membership = upsert_membership!(repository, user, role)
602
          audit_membership(repository, actor, user, "added", role)
603
          membership
608 604
609
      nil ->
610
        {:error, :unknown_user}
611
    end
605
        nil ->
606
          Repo.rollback(:unknown_user)
607
      end
608
    end)
612 609
  end
613 610
614 611
  def change_member_role(%Repository{} = repository, %User{} = actor, user_id, role)
615 612
      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
613
    with_owner_memberships(repository, actor, fn memberships ->
614
      user = Repo.get(User, user_id)
615
      membership = Enum.find(memberships, &(&1.user_id == user_id))
616
617
      if is_nil(user) or is_nil(membership), do: Repo.rollback(:unknown_member)
618
      guard_last_owner!(memberships, membership, role)
619
620
      updated = upsert_membership!(repository, user, role)
621
      audit_membership(repository, actor, user, "role changed", role)
622
      updated
623
    end)
624
  end
625
626
  def remove_member(%Repository{} = repository, %User{} = actor, user_id) do
627
    case with_owner_memberships(repository, actor, fn memberships ->
628
           user = Repo.get(User, user_id)
629
           membership = Enum.find(memberships, &(&1.user_id == user_id))
630
631
           if is_nil(user) or is_nil(membership), do: Repo.rollback(:unknown_member)
632
           guard_last_owner!(memberships, membership, nil)
633
           Repo.delete!(membership)
634
635
           Audit.record!(
636
             "repository.membership.removed",
637
             {:user, actor.id},
638
             "membership",
639
             membership_subject_id(membership),
640
             repository_id: repository.id,
641
             metadata: %{"login" => user.github_login}
642
           )
643
644
           :ok
645
         end) do
646
      {:ok, :ok} -> :ok
631 647
      {:error, reason} -> {:error, reason}
632 648
    end
633 649
  end
634 650
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)
651
  # Serialize membership administration per repository. This makes both the
652
  # owner check and the last-owner rule true at the instant of the write, even
653
  # when an owner keeps an old LiveView open or two owners act concurrently.
654
  defp with_owner_memberships(%Repository{id: repository_id}, %User{id: actor_id}, operation) do
655
    Repo.transaction(fn ->
656
      memberships =
657
        Repo.all(
658
          from membership in Membership,
659
            where: membership.repository_id == ^repository_id,
660
            order_by: [asc: membership.user_id],
661
            lock: "FOR UPDATE"
662
        )
643 663
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}
664
      active_actor? =
665
        Repo.exists?(
666
          from user in User,
667
            where: user.id == ^actor_id and user.status == "active"
651 668
        )
652
      end)
653 669
654
      :ok
655
    else
656
      {:error, reason} -> {:error, reason}
657
    end
670
      actor_owns? =
671
        Enum.any?(memberships, &(&1.user_id == actor_id and &1.role == "owner"))
672
673
      if active_actor? and actor_owns? do
674
        operation.(memberships)
675
      else
676
        Repo.rollback(:forbidden)
677
      end
678
    end)
658 679
  end
659 680
660 681
  # 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
      )
682
  # owner cannot be demoted or removed, including by themselves. The caller
683
  # holds row locks over every membership in this repository.
684
  defp guard_last_owner!(memberships, %Membership{} = membership, new_role) do
685
    leaving_owner? = membership.role == "owner" and new_role != "owner"
686
    owner_count = Enum.count(memberships, &(&1.role == "owner"))
669 687
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
688
    if leaving_owner? and owner_count <= 1, do: Repo.rollback(:last_owner)
678 689
  end
679 690
680 691
  defp upsert_membership!(repository, user, role) do

@@ -824,18 +835,22 @@ defmodule OpenAgents.Repositories do

824 835
  edit) stay behind writability and are not governed by this predicate.
825 836
  """
826 837
  def issue_participant?(%Repository{}, nil), do: false
827
  def issue_participant?(%Repository{visibility: "public"}, %User{}), do: true
828 838
829
  def issue_participant?(%Repository{} = repository, %User{} = user),
830
    do: member?(repository, user)
839
  def issue_participant?(%Repository{} = repository, %User{} = user) do
840
    active_user?(user) and (public?(repository) or member?(repository, user))
841
  end
831 842
832 843
  @doc "Whether the user holds the repository's `owner` role."
833 844
  def owner?(%Repository{} = repository, %User{} = user) do
834
    membership_role(repository, user) == "owner"
845
    active_user?(user) and membership_role(repository, user) == "owner"
835 846
  end
836 847
837 848
  def owner?(%Repository{}, nil), do: false
838 849
850
  defp active_user?(%User{id: user_id}) do
851
    Repo.exists?(from user in User, where: user.id == ^user_id and user.status == "active")
852
  end
853
839 854
  @doc "Subscribes the caller to one repository's issue activity."
840 855
  def subscribe_issues(repository_id),
841 856
    do: Phoenix.PubSub.subscribe(OpenAgents.PubSub, issues_topic(repository_id))
lib/openagents_web/controllers/issue_assignee_controller.ex modified +7 -1

@@ -33,7 +33,7 @@ defmodule OpenAgentsWeb.IssueAssigneeController do

33 33
      {:error, %Ecto.Changeset{} = changeset} ->
34 34
        conn
35 35
        |> put_status(:unprocessable_entity)
36
        |> json(%{errors: Ecto.Changeset.traverse_errors(changeset, & &1)})
36
        |> json(%{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)})
37 37
    end
38 38
  rescue
39 39
    Ecto.NoResultsError ->

@@ -69,4 +69,10 @@ defmodule OpenAgentsWeb.IssueAssigneeController do

69 69
      |> put_status(:not_found)
70 70
      |> json(%{message: "Not Found"})
71 71
  end
72
73
  defp translate_error({message, options}) do
74
    Regex.replace(~r/%{(\w+)}/, message, fn _, key ->
75
      options |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
76
    end)
77
  end
72 78
end
lib/openagents_web/controllers/issue_label_controller.ex modified +7 -1

@@ -33,7 +33,7 @@ defmodule OpenAgentsWeb.IssueLabelController do

33 33
      {:error, %Ecto.Changeset{} = changeset} ->
34 34
        conn
35 35
        |> put_status(:unprocessable_entity)
36
        |> json(%{errors: Ecto.Changeset.traverse_errors(changeset, & &1)})
36
        |> json(%{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)})
37 37
    end
38 38
  rescue
39 39
    Ecto.NoResultsError ->

@@ -75,4 +75,10 @@ defmodule OpenAgentsWeb.IssueLabelController do

75 75
      |> put_status(:not_found)
76 76
      |> json(%{message: "Not Found"})
77 77
  end
78
79
  defp translate_error({message, options}) do
80
    Regex.replace(~r/%{(\w+)}/, message, fn _, key ->
81
      options |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
82
    end)
83
  end
78 84
end
lib/openagents_web/live/issue_index_live.ex modified +27 -13

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

28 28
29 29
      user = socket.assigns.current_user
30 30
      can_write = Repositories.writable?(repository, user)
31
      filters = read_filters(params)
31 32
32 33
      socket =
33 34
        socket

@@ -39,16 +40,11 @@ defmodule OpenAgentsWeb.IssueIndexLive do

39 40
        |> assign(:can_participate, Repositories.issue_participant?(repository, user))
40 41
        |> assign(:state, normalize_state(params["state"]))
41 42
        |> 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
        )
43
        |> assign(:filters, filters)
44
        |> assign(:filter_form, to_form(filters, as: :filter))
45
        |> assign(:label_options, Labels.list_labels(repository))
46
        |> assign(:assignable, Repositories.list_assignable_users(repository))
47
        |> assign(:milestone_options, Milestones.list_milestones(repository))
52 48
        |> load()
53 49
54 50
      {:noreply, socket}

@@ -70,7 +66,10 @@ defmodule OpenAgentsWeb.IssueIndexLive do

70 66
    apply_filters(socket, filters)
71 67
  end
72 68
73
  def handle_event("set_state", %{"id" => id, "state" => state} = params, socket) do
69
  def handle_event("set_state", %{"id" => id, "state" => state} = params, socket)
70
      when state in ~w(open closed) do
71
    socket = refresh_authority(socket)
72
74 73
    if socket.assigns.can_write do
75 74
      attrs =
76 75
        case state do

@@ -86,6 +85,8 @@ defmodule OpenAgentsWeb.IssueIndexLive do

86 85
  end
87 86
88 87
  def handle_event("toggle_assignee", %{"id" => id, "login" => login}, socket) do
88
    socket = refresh_authority(socket)
89
89 90
    if socket.assigns.can_write do
90 91
      issue = issue!(socket, id)
91 92

@@ -101,15 +102,28 @@ defmodule OpenAgentsWeb.IssueIndexLive do

101 102
    end
102 103
  end
103 104
105
  def handle_event(_unsupported_event, _params, socket) do
106
    {:noreply, put_flash(socket, :error, "That issue action is not available.")}
107
  end
108
104 109
  # Live updates: any committed issue write in this repository re-reads the
105 110
  # current page through this viewer's own authorization, so two people
106 111
  # triaging together converge instead of drifting.
107 112
  def handle_info({:issues_changed, repository_id}, socket) do
108 113
    if repository_id == socket.assigns.repository.id,
109
      do: {:noreply, load(socket)},
114
      do: {:noreply, socket |> refresh_authority() |> load()},
110 115
      else: {:noreply, socket}
111 116
  end
112 117
118
  defp refresh_authority(socket) do
119
    repository = socket.assigns.repository
120
    user = socket.assigns.current_user
121
122
    socket
123
    |> assign(:can_write, Repositories.writable?(repository, user))
124
    |> assign(:can_participate, Repositories.issue_participant?(repository, user))
125
  end
126
113 127
  defp apply_filters(socket, filters) do
114 128
    {:noreply,
115 129
     push_patch(socket, to: issues_path(socket.assigns.owner, socket.assigns.repo, filters))}

@@ -235,7 +249,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

235 249
      </Circle.issue_toolbar>
236 250
237 251
      <div class="issue-filters">
238
        <.form for={%{}} as={:filter} phx-change="filter" id="issue-filter-form">
252
        <.form for={@filter_form} phx-change="filter" id="issue-filter-form">
239 253
          <.input
240 254
            type="search"
241 255
            name="q"
lib/openagents_web/live/issue_new_live.ex modified +32 -22

@@ -61,31 +61,41 @@ defmodule OpenAgentsWeb.IssueNewLive do

61 61
  end
62 62
63 63
  def handle_event("save", %{"issue" => issue_params}, socket) do
64
    title = issue_params["title"]
65
    body = issue_params["body"]
66
    milestone = if(socket.assigns.can_write, do: issue_params["milestone"] || "", else: "")
67
    labels = if(socket.assigns.can_write, do: issue_params["labels"] || [], else: [])
68
69
    case Issues.create_issue(
70
           socket.assigns.repository,
71
           %{"title" => title, "body" => body},
72
           socket.assigns.current_user
73
         ) do
74
      {:ok, issue} ->
75
        issue = apply_metadata(issue, labels, milestone)
76
77
        {:noreply,
78
         socket
79
         |> put_flash(:info, "Issue created")
80
         |> push_navigate(
81
           to: ~p"/#{socket.assigns.owner}/#{socket.assigns.repo}/issues/#{issue.number}"
82
         )}
83
84
      {:error, %Ecto.Changeset{} = changeset} ->
85
        {:noreply, assign(socket, :form, to_form(changeset))}
64
    repository = socket.assigns.repository
65
    user = socket.assigns.current_user
66
    can_participate = Repositories.issue_participant?(repository, user)
67
    can_write = Repositories.writable?(repository, user)
68
    socket = assign(socket, :can_write, can_write)
69
70
    if can_participate do
71
      title = issue_params["title"]
72
      body = issue_params["body"]
73
      milestone = if(can_write, do: issue_params["milestone"] || "", else: "")
74
      labels = if(can_write, do: issue_params["labels"] || [], else: [])
75
76
      case Issues.create_issue(repository, %{"title" => title, "body" => body}, user) do
77
        {:ok, issue} ->
78
          issue = apply_metadata(issue, labels, milestone)
79
80
          {:noreply,
81
           socket
82
           |> put_flash(:info, "Issue created")
83
           |> push_navigate(
84
             to: ~p"/#{socket.assigns.owner}/#{socket.assigns.repo}/issues/#{issue.number}"
85
           )}
86
87
        {:error, %Ecto.Changeset{} = changeset} ->
88
          {:noreply, assign(socket, :form, to_form(changeset))}
89
      end
90
    else
91
      {:noreply, put_flash(socket, :error, "You can no longer open an issue here.")}
86 92
    end
87 93
  end
88 94
95
  def handle_event(_unsupported_event, _params, socket) do
96
    {:noreply, put_flash(socket, :error, "That issue action is not available.")}
97
  end
98
89 99
  defp apply_metadata(issue, labels, milestone) do
90 100
    labels = List.wrap(labels) |> Enum.reject(&(&1 == ""))
91 101
lib/openagents_web/live/issue_show_live.ex modified +138 -77

@@ -78,110 +78,137 @@ defmodule OpenAgentsWeb.IssueShowLive do

78 78
     |> load(issue)}
79 79
  end
80 80
81
  def handle_event("toggle_edit", _params, socket) when socket.assigns.can_edit do
82
    issue = socket.assigns.issue
83
84
    {:noreply,
85
     socket
86
     |> assign(:editing, !socket.assigns.editing)
87
     |> assign(:form, to_form(Issues.change_issue(issue)))}
81
  def handle_event("toggle_edit", _params, socket) do
82
    with_authority(socket, :can_edit, "You can no longer edit this issue.", fn socket ->
83
      issue = socket.assigns.issue
84
85
      {:noreply,
86
       socket
87
       |> assign(:editing, !socket.assigns.editing)
88
       |> assign(:form, to_form(Issues.change_issue(issue)))}
89
    end)
88 90
  end
89 91
90
  def handle_event("save", %{"issue" => issue_params}, socket) when socket.assigns.can_edit do
91
    issue = socket.assigns.issue
92
    attrs = %{"title" => issue_params["title"], "body" => issue_params["body"]}
93
94
    case Issues.update_issue(issue, attrs, socket.assigns.current_user) do
95
      {:ok, updated} ->
96
        {:noreply,
97
         socket
98
         |> assign(:editing, false)
99
         |> put_flash(:info, "Issue updated")
100
         |> load(updated)}
101
102
      {:error, changeset} ->
103
        {:noreply, assign(socket, :form, to_form(changeset))}
104
    end
92
  def handle_event("save", %{"issue" => issue_params}, socket) do
93
    with_authority(socket, :can_edit, "You can no longer edit this issue.", fn socket ->
94
      issue = socket.assigns.issue
95
      attrs = %{"title" => issue_params["title"], "body" => issue_params["body"]}
96
97
      case Issues.update_issue(issue, attrs, socket.assigns.current_user) do
98
        {:ok, updated} ->
99
          {:noreply,
100
           socket
101
           |> assign(:editing, false)
102
           |> put_flash(:info, "Issue updated")
103
           |> load(updated)}
104
105
        {:error, changeset} ->
106
          {:noreply, assign(socket, :form, to_form(changeset))}
107
      end
108
    end)
105 109
  end
106 110
107 111
  # A viewer without authority who hand-crafts an event gets a refusal rather
108 112
  # than a silent success; the UI never shows them the control in the first
109 113
  # place.
110
  def handle_event("close", _params, socket) when socket.assigns.can_edit,
111
    do: set_state(socket, "closed", "completed")
114
  def handle_event("close", _params, socket),
115
    do: with_edit_authority(socket, &set_state(&1, "closed", "completed"))
112 116
113
  def handle_event("reopen", _params, socket) when socket.assigns.can_edit,
114
    do: set_state(socket, "open", nil)
117
  def handle_event("reopen", _params, socket),
118
    do: with_edit_authority(socket, &set_state(&1, "open", nil))
115 119
116 120
  # The rail's state menu picks a close reason as well as a state, which the
117 121
  # header's two buttons cannot. Both end in the same write.
118
  def handle_event("set_state", %{"state" => "open"}, socket) when socket.assigns.can_edit,
119
    do: set_state(socket, "open", nil)
120
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")
124
125
  def handle_event("toggle_label", %{"name" => name}, socket) when socket.assigns.can_write do
126
    issue = socket.assigns.issue
127
128
    {:ok, updated} =
129
      if Enum.any?(issue.labels || [], &(&1["name"] == name)) do
130
        Issues.remove_label(issue, name)
131
      else
132
        Issues.add_labels(issue, [name])
122
  def handle_event("set_state", %{"state" => "open"}, socket),
123
    do: with_edit_authority(socket, &set_state(&1, "open", nil))
124
125
  def handle_event("set_state", %{"state" => "closed"} = params, socket),
126
    do: with_edit_authority(socket, &set_state(&1, "closed", params["reason"] || "completed"))
127
128
  def handle_event("toggle_label", %{"name" => name}, socket) do
129
    with_authority(
130
      socket,
131
      :can_write,
132
      "Only repository members can change issue labels.",
133
      fn socket ->
134
        issue = socket.assigns.issue
135
136
        {:ok, updated} =
137
          if Enum.any?(issue.labels || [], &(&1["name"] == name)) do
138
            Issues.remove_label(issue, name)
139
          else
140
            Issues.add_labels(issue, [name])
141
          end
142
143
        {:noreply, load(socket, updated)}
133 144
      end
134
135
    {:noreply, load(socket, updated)}
145
    )
136 146
  end
137 147
138
  def handle_event("toggle_assignee", %{"login" => login}, socket)
139
      when socket.assigns.can_write do
140
    issue = socket.assigns.issue
141
142
    {:ok, updated} =
143
      if Enum.any?(issue.assignees || [], &(&1["login"] == login)) do
144
        Issues.remove_assignees(issue, [login])
145
      else
146
        Issues.add_assignees(issue, [login])
148
  def handle_event("toggle_assignee", %{"login" => login}, socket) do
149
    with_authority(
150
      socket,
151
      :can_write,
152
      "Only repository members can change issue assignees.",
153
      fn socket ->
154
        issue = socket.assigns.issue
155
156
        {:ok, updated} =
157
          if Enum.any?(issue.assignees || [], &(&1["login"] == login)) do
158
            Issues.remove_assignees(issue, [login])
159
          else
160
            Issues.add_assignees(issue, [login])
161
          end
162
163
        {:noreply, load(socket, updated)}
147 164
      end
148
149
    {:noreply, load(socket, updated)}
165
    )
150 166
  end
151 167
152
  def handle_event("set_milestone", %{"number" => number}, socket)
153
      when socket.assigns.can_write do
154
    {:ok, updated} = Issues.set_milestone(socket.assigns.issue, number_or_nil(number))
155
    {:noreply, load(socket, updated)}
168
  def handle_event("set_milestone", %{"number" => number}, socket) do
169
    with_authority(
170
      socket,
171
      :can_write,
172
      "Only repository members can change issue milestones.",
173
      fn socket ->
174
        {:ok, updated} = Issues.set_milestone(socket.assigns.issue, number_or_nil(number))
175
        {:noreply, load(socket, updated)}
176
      end
177
    )
156 178
  end
157 179
158
  def handle_event("add_comment", %{"comment" => %{"body" => body}}, socket)
159
      when socket.assigns.can_participate do
160
    issue = socket.assigns.issue
180
  def handle_event("add_comment", %{"comment" => %{"body" => body}}, socket) do
181
    with_authority(socket, :can_participate, "Sign in to comment on this issue.", fn socket ->
182
      issue = socket.assigns.issue
161 183
162
    case Issues.create_comment(issue, %{body: body}, socket.assigns.current_user) do
163
      {:ok, _comment} ->
164
        {:noreply,
165
         socket
166
         |> assign(:comment_form, to_form(Comment.changeset(%Comment{}, %{})))
167
         |> put_flash(:info, "Comment added")
168
         |> load(%{issue | comments: issue.comments + 1})}
184
      if issue.locked do
185
        {:noreply, put_flash(socket, :error, "This conversation is locked.")}
186
      else
187
        case Issues.create_comment(issue, %{body: body}, socket.assigns.current_user) do
188
          {:ok, _comment} ->
189
            {:noreply,
190
             socket
191
             |> assign(:comment_form, to_form(Comment.changeset(%Comment{}, %{})))
192
             |> put_flash(:info, "Comment added")
193
             |> load(%{issue | comments: issue.comments + 1})}
194
195
          {:error, changeset} ->
196
            {:noreply, assign(socket, :comment_form, to_form(changeset))}
197
        end
198
      end
199
    end)
200
  end
169 201
170
      {:error, changeset} ->
171
        {:noreply, assign(socket, :comment_form, to_form(changeset))}
172
    end
202
  def handle_event(_unsupported_event, _params, socket) do
203
    {:noreply, put_flash(socket, :error, "That issue action is not available.")}
173 204
  end
174 205
175 206
  # Live updates: someone else's write re-reads this issue through the same
176 207
  # visibility check the mount used.
177 208
  def handle_info({:issues_changed, repository_id}, socket)
178 209
      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)}
210
    socket = refresh_authority(socket)
211
    {:noreply, load(socket, socket.assigns.issue)}
185 212
  end
186 213
187 214
  def handle_info({:issues_changed, _other_repository}, socket), do: {:noreply, socket}

@@ -206,6 +233,40 @@ defmodule OpenAgentsWeb.IssueShowLive do

206 233
     |> load(updated)}
207 234
  end
208 235
236
  defp with_edit_authority(socket, operation) do
237
    with_authority(socket, :can_edit, "You can no longer change this issue's state.", operation)
238
  end
239
240
  defp with_authority(socket, permission, message, operation) do
241
    socket = refresh_authority(socket)
242
243
    if socket.assigns[permission] do
244
      operation.(socket)
245
    else
246
      {:noreply, put_flash(socket, :error, message)}
247
    end
248
  end
249
250
  defp refresh_authority(socket) do
251
    user = socket.assigns.current_user
252
253
    visible_repository = Repositories.get_visible_repository(socket.assigns.repository.id, user)
254
    repository = visible_repository || socket.assigns.repository
255
    visible? = not is_nil(visible_repository)
256
    can_write = visible? and Repositories.writable?(repository, user)
257
    can_participate = visible? and Repositories.issue_participant?(repository, user)
258
    issue = Issues.get_issue!(socket.assigns.issue.id)
259
    can_edit = can_write || (can_participate and author?(issue, user))
260
261
    socket
262
    |> assign(:repository, repository)
263
    |> assign(:issue, issue)
264
    |> assign(:can_write, can_write)
265
    |> assign(:can_participate, can_participate)
266
    |> assign(:can_edit, can_edit)
267
    |> assign(:editing, socket.assigns.editing and can_edit)
268
  end
269
209 270
  # One place rebuilds everything derived from the issue, so a write cannot
210 271
  # leave the timeline describing the previous version of the page.
211 272
  defp load(socket, issue) do
lib/openagents_web/live/member_index_live.ex modified +39 -11

@@ -48,6 +48,10 @@ defmodule OpenAgentsWeb.MemberIndexLive do

48 48
         |> assign(:repo, repo)
49 49
         |> assign(:repository, repository)
50 50
         |> assign(:roles, @roles)
51
         |> assign(
52
           :member_form,
53
           to_form(%{"login" => "", "role" => "contributor"}, as: :member)
54
         )
51 55
         |> stream(:members, Repositories.list_members(repository),
52 56
           dom_id: &"member-#{&1.user_id}",
53 57
           reset: true

@@ -68,6 +72,10 @@ defmodule OpenAgentsWeb.MemberIndexLive do

68 72
      {:ok, _membership} ->
69 73
        {:noreply,
70 74
         socket
75
         |> assign(
76
           :member_form,
77
           to_form(%{"login" => "", "role" => "contributor"}, as: :member)
78
         )
71 79
         |> reload()
72 80
         |> put_flash(:info, "@#{login} added as #{role}")}
73 81

@@ -78,6 +86,9 @@ defmodule OpenAgentsWeb.MemberIndexLive do

78 86
           :error,
79 87
           "No OpenAgents account found for @#{login}. They need to sign in once first."
80 88
         )}
89
90
      {:error, :forbidden} ->
91
        {:noreply, owner_required(socket)}
81 92
    end
82 93
  end
83 94

@@ -97,6 +108,12 @@ defmodule OpenAgentsWeb.MemberIndexLive do

97 108
98 109
      {:error, :last_owner} ->
99 110
        {:noreply, put_flash(socket, :error, "A repository must keep at least one owner.")}
111
112
      {:error, :forbidden} ->
113
        {:noreply, owner_required(socket)}
114
115
      {:error, :unknown_member} ->
116
        {:noreply, put_flash(socket, :error, "That repository member no longer exists.")}
100 117
    end
101 118
  end
102 119

@@ -114,9 +131,23 @@ defmodule OpenAgentsWeb.MemberIndexLive do

114 131
115 132
      {:error, :last_owner} ->
116 133
        {:noreply, put_flash(socket, :error, "A repository must keep at least one owner.")}
134
135
      {:error, :forbidden} ->
136
        {:noreply, owner_required(socket)}
137
138
      {:error, :unknown_member} ->
139
        {:noreply, put_flash(socket, :error, "That repository member no longer exists.")}
117 140
    end
118 141
  end
119 142
143
  def handle_event(_unsupported_event, _params, socket) do
144
    {:noreply, put_flash(socket, :error, "That membership action is not available.")}
145
  end
146
147
  defp owner_required(socket) do
148
    put_flash(socket, :error, "Only repository owners can manage members.")
149
  end
150
120 151
  defp reload(socket) do
121 152
    socket
122 153
    |> stream(

@@ -139,20 +170,19 @@ defmodule OpenAgentsWeb.MemberIndexLive do

139 170
        <h1 class="text-2xl font-bold">Members</h1>
140 171
      </div>
141 172
142
      <.form for={%{}} as={:member} phx-submit="add_member" id="add-member-form">
173
      <.form for={@member_form} phx-submit="add_member" id="add-member-form">
143 174
        <div class="grid grid-cols-1 md:grid-cols-[2fr_1fr_auto] gap-4 items-end card !m-0 mb-6 p-4">
144 175
          <.input
145
            name="member[login]"
176
            field={@member_form[:login]}
146 177
            label="GitHub login"
147 178
            placeholder="their-github-login"
148 179
            required
149 180
          />
150 181
          <.input
151
            name="member[role]"
182
            field={@member_form[:role]}
152 183
            type="select"
153 184
            label="Role"
154 185
            options={Enum.map(@roles, &{String.capitalize(&1), &1})}
155
            value="contributor"
156 186
          />
157 187
          <.button type="submit" variant={:primary}>Add member</.button>
158 188
        </div>

@@ -181,17 +211,15 @@ defmodule OpenAgentsWeb.MemberIndexLive do

181 211
              class="flex items-center gap-2"
182 212
            >
183 213
              <input type="hidden" name="user_id" value={membership.user_id} />
184
              <select
214
              <.input
185 215
                name="role"
216
                type="select"
186 217
                aria-label={"Role for #{membership.user.github_login}"}
187 218
                class="input !py-1.5"
188 219
                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>
220
                options={Enum.map(@roles, &{String.capitalize(&1), &1})}
221
                value={membership.role}
222
              />
195 223
            </form>
196 224
            <button
197 225
              phx-click="remove_member"
test/openagents/issues_query_test.exs modified +7

@@ -35,6 +35,13 @@ defmodule OpenAgents.IssuesQueryTest do

35 35
    assert hd(issues).id == closed.id or hd(issues).id == open.id
36 36
  end
37 37
38
  test "page parsing rejects trailing text and bounds public offsets" do
39
    assert Issues.parse_page("2") == 2
40
    assert Issues.parse_page("2junk") == 1
41
    assert Issues.parse_page("-1") == 1
42
    assert Issues.parse_page(String.duplicate("9", 100)) == 10_000
43
  end
44
38 45
  test "the state filter matches the tab it drives", %{repository: repository} do
39 46
    {open_issues, open_total} = Issues.list_issues_page(repository, state: "open")
40 47
    {closed_issues, closed_total} = Issues.list_issues_page(repository, state: "closed")
test/openagents/repositories_membership_test.exs modified +27

@@ -79,6 +79,33 @@ defmodule OpenAgents.RepositoriesMembershipTest do

79 79
    end
80 80
  end
81 81
82
  test "membership administration rejects a non-owner actor", %{
83
    repository: repository,
84
    owner: owner
85
  } do
86
    contributor = plain_user("membership-non-owner")
87
    recruit = plain_user("membership-recruit")
88
    {:ok, _} = Repositories.add_member(repository, contributor, "contributor")
89
    {:ok, _} = Repositories.add_member(repository, recruit, "viewer")
90
91
    assert {:error, :forbidden} =
92
             Repositories.add_member_by_login(
93
               repository,
94
               contributor,
95
               "membership-recruit",
96
               "maintainer"
97
             )
98
99
    assert {:error, :forbidden} =
100
             Repositories.change_member_role(repository, contributor, recruit.id, "maintainer")
101
102
    assert {:error, :forbidden} =
103
             Repositories.remove_member(repository, contributor, recruit.id)
104
105
    assert Repositories.membership_role(repository, recruit) == "viewer"
106
    assert Repositories.membership_role(repository, owner) == "owner"
107
  end
108
82 109
  test "change_member_role updates an existing membership", %{
83 110
    repository: repository,
84 111
    owner: owner
test/openagents_web/controllers/issue_label_controller_test.exs modified +15

@@ -145,6 +145,21 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

145 145
             ).color == ""
146 146
    end
147 147
148
    test "POST .../issues/:issue_number/labels rejects an empty label without crashing", %{
149
      conn: conn,
150
      issue: issue
151
    } do
152
      conn =
153
        post(
154
          conn,
155
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/labels",
156
          %{labels: [""]}
157
        )
158
159
      assert %{"errors" => %{"name" => [_message]}} = json_response(conn, 422)
160
      assert Issues.get_issue_by_number!(issue.number).labels == []
161
    end
162
148 163
    test "POST .../issues/:issue_number/labels returns 404 for a missing issue", %{conn: conn} do
149 164
      conn =
150 165
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/999999/labels", %{
test/openagents_web/live/issue_access_live_test.exs modified +11

@@ -103,6 +103,17 @@ defmodule OpenAgentsWeb.IssueAccessLiveTest do

103 103
      assert comment.author_user_id != nil
104 104
    end
105 105
106
    test "cannot post to a conversation locked after the page opened", %{conn: conn} do
107
      {:ok, issue} = Issues.create_issue(%{"title" => "Lock this thread"})
108
      {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues/#{issue.number}")
109
      {:ok, _locked} = Issues.update_issue(issue, %{"locked" => true})
110
111
      html = render_submit(view, "add_comment", %{"comment" => %{"body" => "Too late"}})
112
113
      assert html =~ "This conversation is locked"
114
      assert Issues.list_comments(issue) == []
115
    end
116
106 117
    test "get no triage controls anywhere on the issue page", %{conn: conn} do
107 118
      {:ok, issue} = Issues.create_issue(%{"title" => "Not yours to close"})
108 119
      {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues/#{issue.number}")
test/openagents_web/live/issue_show_live_test.exs modified +16

@@ -6,7 +6,9 @@ defmodule OpenAgentsWeb.IssueShowLiveTest do

6 6
  import OpenAgents.LabelsFixtures
7 7
  import OpenAgents.MilestonesFixtures
8 8
9
  alias OpenAgents.Accounts
9 10
  alias OpenAgents.Issues
11
  alias OpenAgents.Repositories
10 12
11 13
  setup %{conn: conn} do
12 14
    {:ok, conn: log_in_github_user(conn, "issue-show")}

@@ -126,6 +128,20 @@ defmodule OpenAgentsWeb.IssueShowLiveTest do

126 128
    assert Issues.get_issue!(issue.id).labels == []
127 129
  end
128 130
131
  test "a member who loses write access cannot triage through an open page", %{conn: conn} do
132
    user = Accounts.get_user(Plug.Conn.get_session(conn, "user_id"))
133
    issue = issue!(%{"title" => "Authority changed"})
134
    repository = Repositories.initial_repository!()
135
136
    {:ok, view, _html} = live(conn, path(issue))
137
    {:ok, _} = Repositories.add_member(repository, user, "viewer")
138
139
    html = render_click(view, "toggle_label", %{"name" => "bug"})
140
141
    assert html =~ "Only repository members can change issue labels"
142
    assert Issues.get_issue!(issue.id).labels == []
143
  end
144
129 145
  test "the history is one feed of comments and state changes", %{conn: conn} do
130 146
    issue = issue!(%{"title" => "Threaded"})
131 147
    {:ok, view, html} = live(conn, path(issue))
test/openagents_web/live/member_index_live_test.exs modified +17

@@ -98,6 +98,23 @@ defmodule OpenAgentsWeb.MemberIndexLiveTest do

98 98
    refute Repositories.member?(repository, member)
99 99
  end
100 100
101
  test "a demoted owner cannot keep administering through an open page", %{conn: conn} do
102
    {conn, owner} = sign_in_owner(conn, "members-stale-owner")
103
    recruit = plain_user("members-stale-recruit")
104
    repository = Repositories.initial_repository!()
105
106
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/members")
107
    {:ok, _} = Repositories.add_member(repository, owner, "contributor")
108
109
    html =
110
      view
111
      |> form("#add-member-form", member: %{login: recruit.github_login, role: "viewer"})
112
      |> render_submit()
113
114
    assert html =~ "Only repository owners can manage members"
115
    refute Repositories.member?(repository, recruit)
116
  end
117
101 118
  test "the last owner cannot be removed through the page", %{conn: conn} do
102 119
    {conn, owner} = sign_in_owner(conn, "members-last-owner")
103 120

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