Open public forge surfaces to signed-out visitors

52fbab615a74 · Devin AI · · parent a4408218d293

Open public forge surfaces to signed-out visitors

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

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified lib/openagents/issues.ex
  • modified lib/openagents/projects.ex
  • modified lib/openagents_web/live/issue_index_live.ex
  • modified lib/openagents_web/live/issue_workspace_live.ex
  • modified lib/openagents_web/live/label_index_live.ex
  • modified lib/openagents_web/live/milestone_index_live.ex
  • modified lib/openagents_web/live/project_index_live.ex
  • modified lib/openagents_web/live/project_show_live.ex
  • modified lib/openagents_web/live/project_workspace_live.ex
  • modified lib/openagents_web/router.ex
  • modified test/openagents_web/live/issue_workspace_live_test.exs
  • modified test/openagents_web/live/label_index_live_test.exs
  • modified test/openagents_web/live/milestone_index_live_test.exs
  • modified test/openagents_web/live/project_index_live_test.exs
  • modified test/openagents_web/live/project_show_live_test.exs
  • modified test/openagents_web/live/project_workspace_live_test.exs

Diff

16 files changed, +472 -154

lib/openagents/issues.ex modified +5 -3

@@ -80,7 +80,8 @@ defmodule OpenAgents.Issues do

80 80
  Rows come back with their repository preloaded, because a cross-repository
81 81
  list has to say which repository each row belongs to.
82 82
  """
83
  def list_visible_issues_page(%User{} = user, opts \\ []) when is_list(opts) do
83
  def list_visible_issues_page(user, opts \\ [])
84
      when (is_nil(user) or is_struct(user, User)) and is_list(opts) do
84 85
    page = max(parse_page(opts[:page]), 1)
85 86
    query = visible_issue_query(user, opts)
86 87

@@ -98,8 +99,9 @@ defmodule OpenAgents.Issues do

98 99
  end
99 100
100 101
  @doc "How many issues `user` can read across every repository, filtered."
101
  def count_visible_issues(%User{} = user, opts \\ []) when is_list(opts),
102
    do: user |> visible_issue_query(opts) |> Repo.aggregate(:count)
102
  def count_visible_issues(user, opts \\ [])
103
      when (is_nil(user) or is_struct(user, User)) and is_list(opts),
104
      do: user |> visible_issue_query(opts) |> Repo.aggregate(:count)
103 105
104 106
  # The readable-repository set arrives as a subquery rather than as extra
105 107
  # joins on this query, so the membership left join keeps its own bindings and
lib/openagents/projects.ex modified +5 -3

@@ -38,7 +38,8 @@ defmodule OpenAgents.Projects do

38 38
  Supported options: `:state`, `:owner`, and `:page`. Rows come back with their
39 39
  repository preloaded, because a project's board lives at a repository path.
40 40
  """
41
  def list_visible_projects_page(%User{} = user, opts \\ []) when is_list(opts) do
41
  def list_visible_projects_page(user, opts \\ [])
42
      when (is_nil(user) or is_struct(user, User)) and is_list(opts) do
42 43
    page = max(parse_page(opts[:page]), 1)
43 44
    query = visible_project_query(user, opts)
44 45

@@ -56,8 +57,9 @@ defmodule OpenAgents.Projects do

56 57
  end
57 58
58 59
  @doc "How many projects `user` can read across every repository, filtered."
59
  def count_visible_projects(%User{} = user, opts \\ []) when is_list(opts),
60
    do: user |> visible_project_query(opts) |> Repo.aggregate(:count)
60
  def count_visible_projects(user, opts \\ [])
61
      when (is_nil(user) or is_struct(user, User)) and is_list(opts),
62
      do: user |> visible_project_query(opts) |> Repo.aggregate(:count)
61 63
62 64
  @doc "Clamps a reader-supplied page number into the bounded range."
63 65
  def parse_page(page), do: OpenAgents.Issues.parse_page(page)
lib/openagents_web/live/issue_index_live.ex modified -2

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

222 222
223 223
        <:actions>
224 224
          <.link
225
            :if={@can_write}
226 225
            navigate={~p"/#{@owner}/#{@repo}/labels"}
227 226
            class="btn"
228 227
            data-variant="ghost"

@@ -231,7 +230,6 @@ defmodule OpenAgentsWeb.IssueIndexLive do

231 230
            <.icon name="tag" /> Labels
232 231
          </.link>
233 232
          <.link
234
            :if={@can_write}
235 233
            navigate={~p"/#{@owner}/#{@repo}/milestones"}
236 234
            class="btn"
237 235
            data-variant="ghost"
lib/openagents_web/live/issue_workspace_live.ex modified +28 -16

@@ -59,11 +59,12 @@ defmodule OpenAgentsWeb.IssueWorkspaceLive do

59 59
60 60
  def mount(_params, _session, socket) do
61 61
    if connected?(socket), do: Repositories.subscribe_all_issues()
62
    user = socket.assigns.current_user
62 63
63 64
    {:ok,
64 65
     socket
65 66
     |> assign(:current_scope, socket.assigns[:current_scope])
66
     |> assign(:involvements, @involvements)
67
     |> assign(:involvements, involvement_options(user))
67 68
     |> assign(:refresh_timer_ref, nil)
68 69
     |> assign(
69 70
       :any_repository?,

@@ -120,7 +121,7 @@ defmodule OpenAgentsWeb.IssueWorkspaceLive do

120 121
  end
121 122
122 123
  def handle_params(params, _url, socket) do
123
    filters = read_filters(params)
124
    filters = read_filters(params, socket.assigns.current_user)
124 125
125 126
    {:noreply,
126 127
     socket

@@ -135,7 +136,7 @@ defmodule OpenAgentsWeb.IssueWorkspaceLive do

135 136
  # desired set and patching replaces it wholesale.
136 137
  def handle_event("filter", params, socket) do
137 138
    filters = %{
138
      "involvement" => normalize_involvement(params["involvement"]),
139
      "involvement" => normalize_involvement(params["involvement"], socket.assigns.current_user),
139 140
      "q" => blank_to_nil(params["q"])
140 141
    }
141 142

@@ -146,19 +147,21 @@ defmodule OpenAgentsWeb.IssueWorkspaceLive do

146 147
    {:noreply, put_flash(socket, :error, "That action is not available here.")}
147 148
  end
148 149
149
  defp read_filters(params) do
150
  defp read_filters(params, user) do
150 151
    %{
151
      "involvement" => normalize_involvement(params["involvement"]),
152
      "involvement" => normalize_involvement(params["involvement"], user),
152 153
      "q" => blank_to_nil(params["q"])
153 154
    }
154 155
  end
155 156
156 157
  # A hand-edited query string cannot smuggle an option into the context call:
157 158
  # anything unrecognized becomes the default.
158
  defp normalize_involvement(involvement) when involvement in ~w(assigned created),
159
  defp normalize_involvement(_involvement, nil), do: "all"
160
161
  defp normalize_involvement(involvement, _user) when involvement in ~w(assigned created),
159 162
    do: involvement
160 163
161
  defp normalize_involvement(_involvement), do: "all"
164
  defp normalize_involvement(_involvement, _user), do: "all"
162 165
163 166
  defp normalize_state("closed"), do: "closed"
164 167
  defp normalize_state("all"), do: "all"

@@ -198,10 +201,14 @@ defmodule OpenAgentsWeb.IssueWorkspaceLive do

198 201
  # The view's involvement words become the context's own options. "Assigned"
199 202
  # reads the assignee snapshot by login; "opened by" matches either the
200 203
  # durable author link or an imported login.
204
  defp involvement_opts(nil, _involvement), do: []
201 205
  defp involvement_opts(user, "assigned"), do: [assignee: user.github_login]
202 206
  defp involvement_opts(user, "created"), do: [author: user]
203 207
  defp involvement_opts(_user, _all), do: []
204 208
209
  defp involvement_options(nil), do: [{"Everyone", "all"}]
210
  defp involvement_options(_user), do: @involvements
211
205 212
  def render(assigns) do
206 213
    ~H"""
207 214
    <Layouts.app

@@ -262,15 +269,20 @@ defmodule OpenAgentsWeb.IssueWorkspaceLive do

262 269
        id="workspace-issues-no-repositories"
263 270
        title="No repositories yet"
264 271
      >
265
        Issues appear here once you can read a repository.
266
        <.link navigate={~p"/repositories/new"} data-variant="link" class="btn px-0">
267
          Create one
268
        </.link>
269
        or
270
        <.link navigate={~p"/repositories/import/github"} data-variant="link" class="btn px-0">
271
          import one from GitHub
272
        </.link>
273
        , and its issues arrive with it.
272
        <%= if @current_user do %>
273
          Issues appear here once you can read a repository.
274
          <.link navigate={~p"/repositories/new"} data-variant="link" class="btn px-0">
275
            Create one
276
          </.link>
277
          or
278
          <.link navigate={~p"/repositories/import/github"} data-variant="link" class="btn px-0">
279
            import one from GitHub
280
          </.link>
281
          , and its issues arrive with it.
282
        <% else %>
283
          Sign in with GitHub to see issues from repositories you can read.
284
          <.github_login id="workspace-issues-signin" size={:sm} />
285
        <% end %>
274 286
      </.empty>
275 287
276 288
      <.empty
lib/openagents_web/live/label_index_live.ex modified +53 -18

@@ -9,7 +9,8 @@ defmodule OpenAgentsWeb.LabelIndexLive do

9 9
  alias OpenAgents.Repositories
10 10
11 11
  def mount(%{"owner" => owner, "repo" => repo}, _session, socket) do
12
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
12
    repository = visible_repository!(owner, repo, socket.assigns.current_user)
13
    can_write = Repositories.writable?(repository, socket.assigns.current_user)
13 14
14 15
    {:ok,
15 16
     socket

@@ -17,32 +18,64 @@ defmodule OpenAgentsWeb.LabelIndexLive do

17 18
     |> assign(:owner, owner)
18 19
     |> assign(:repo, repo)
19 20
     |> assign(:repository, repository)
21
     |> assign(:can_write, can_write)
20 22
     |> assign(:labels, Labels.list_labels(repository))
21 23
     |> assign(:form, to_form(Labels.change_label(%Label{})))}
22 24
  end
23 25
24 26
  def handle_event("save", %{"label" => label_params}, socket) do
25
    case Labels.create_label(socket.assigns.repository, label_params, socket.assigns.current_user) do
26
      {:ok, _label} ->
27
        {:noreply,
28
         socket
29
         |> assign(:labels, Labels.list_labels(socket.assigns.repository))
30
         |> assign(:form, to_form(Labels.change_label(%Label{})))
31
         |> put_flash(:info, "Label created")}
27
    socket = refresh_authority(socket)
32 28
33
      {:error, changeset} ->
34
        {:noreply, assign(socket, :form, to_form(changeset))}
29
    if socket.assigns.can_write do
30
      case Labels.create_label(
31
             socket.assigns.repository,
32
             label_params,
33
             socket.assigns.current_user
34
           ) do
35
        {:ok, _label} ->
36
          {:noreply,
37
           socket
38
           |> assign(:labels, Labels.list_labels(socket.assigns.repository))
39
           |> assign(:form, to_form(Labels.change_label(%Label{})))
40
           |> put_flash(:info, "Label created")}
41
42
        {:error, changeset} ->
43
          {:noreply, assign(socket, :form, to_form(changeset))}
44
      end
45
    else
46
      {:noreply, put_flash(socket, :error, "Only repository members can create labels.")}
35 47
    end
36 48
  end
37 49
38 50
  def handle_event("delete", %{"id" => id}, socket) do
39
    label = Labels.get_label!(socket.assigns.repository, String.to_integer(id))
40
    {:ok, _} = Labels.delete_label(label)
51
    socket = refresh_authority(socket)
41 52
42
    {:noreply,
43
     socket
44
     |> assign(:labels, Labels.list_labels(socket.assigns.repository))
45
     |> put_flash(:info, "Label deleted")}
53
    if socket.assigns.can_write do
54
      label = Labels.get_label!(socket.assigns.repository, String.to_integer(id))
55
      {:ok, _} = Labels.delete_label(label)
56
57
      {:noreply,
58
       socket
59
       |> assign(:labels, Labels.list_labels(socket.assigns.repository))
60
       |> put_flash(:info, "Label deleted")}
61
    else
62
      {:noreply, put_flash(socket, :error, "Only repository members can delete labels.")}
63
    end
64
  end
65
66
  defp refresh_authority(socket) do
67
    assign(
68
      socket,
69
      :can_write,
70
      Repositories.writable?(socket.assigns.repository, socket.assigns.current_user)
71
    )
72
  end
73
74
  defp visible_repository!(owner, repo, user) do
75
    Repositories.get_visible_by_path!(owner, repo, user)
76
  rescue
77
    Ecto.NoResultsError ->
78
      raise OpenAgentsWeb.PublicNotFoundError, message: "repository not found"
46 79
  end
47 80
48 81
  def render(assigns) do

@@ -53,10 +86,11 @@ defmodule OpenAgentsWeb.LabelIndexLive do

53 86
      current_scope={@current_scope}
54 87
    >
55 88
      <div class="flex items-center justify-between mb-4">
56
        <h1 class="text-2xl font-bold">Labels</h1>
89
        <h1 id="labels-title" class="text-2xl font-bold">Labels</h1>
57 90
      </div>
58 91
59 92
      <.form
93
        :if={@can_write}
60 94
        for={@form}
61 95
        id="new-label-form"
62 96
        phx-submit="save"

@@ -73,7 +107,7 @@ defmodule OpenAgentsWeb.LabelIndexLive do

73 107
      </.form>
74 108
75 109
      <%= if @labels == [] do %>
76
        <div class="alert" data-variant="info" role="status">
110
        <div id="labels-empty" class="alert" data-variant="info" role="status">
77 111
          <.icon name="info-circle" class="size-5" />
78 112
          <section>No labels yet.</section>
79 113
        </div>

@@ -90,6 +124,7 @@ defmodule OpenAgentsWeb.LabelIndexLive do

90 124
          <:col :let={label} label="Description">{label.description || "—"}</:col>
91 125
          <:col :let={label} label="">
92 126
            <button
127
              :if={@can_write}
93 128
              class="btn"
94 129
              data-variant="ghost"
95 130
              data-size="sm"
lib/openagents_web/live/milestone_index_live.ex modified +68 -31

@@ -10,7 +10,8 @@ defmodule OpenAgentsWeb.MilestoneIndexLive do

10 10
  alias OpenAgents.Repositories
11 11
12 12
  def mount(%{"owner" => owner, "repo" => repo}, _session, socket) do
13
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
13
    repository = visible_repository!(owner, repo, socket.assigns.current_user)
14
    can_write = Repositories.writable?(repository, socket.assigns.current_user)
14 15
15 16
    {:ok,
16 17
     socket

@@ -18,46 +19,80 @@ defmodule OpenAgentsWeb.MilestoneIndexLive do

18 19
     |> assign(:owner, owner)
19 20
     |> assign(:repo, repo)
20 21
     |> assign(:repository, repository)
22
     |> assign(:can_write, can_write)
21 23
     |> assign(:milestones, milestones_with_stats(repository))
22 24
     |> assign(:form, to_form(Milestones.change_milestone(%Milestone{})))}
23 25
  end
24 26
25 27
  def handle_event("save", %{"milestone" => milestone_params}, socket) do
26
    case Milestones.create_milestone(
27
           socket.assigns.repository,
28
           milestone_params,
29
           socket.assigns.current_user
30
         ) do
31
      {:ok, _milestone} ->
32
        {:noreply,
33
         socket
34
         |> assign(:milestones, milestones_with_stats(socket.assigns.repository))
35
         |> assign(:form, to_form(Milestones.change_milestone(%Milestone{})))
36
         |> put_flash(:info, "Milestone created")}
37
38
      {:error, changeset} ->
39
        {:noreply, assign(socket, :form, to_form(changeset))}
28
    socket = refresh_authority(socket)
29
30
    if socket.assigns.can_write do
31
      case Milestones.create_milestone(
32
             socket.assigns.repository,
33
             milestone_params,
34
             socket.assigns.current_user
35
           ) do
36
        {:ok, _milestone} ->
37
          {:noreply,
38
           socket
39
           |> assign(:milestones, milestones_with_stats(socket.assigns.repository))
40
           |> assign(:form, to_form(Milestones.change_milestone(%Milestone{})))
41
           |> put_flash(:info, "Milestone created")}
42
43
        {:error, changeset} ->
44
          {:noreply, assign(socket, :form, to_form(changeset))}
45
      end
46
    else
47
      {:noreply, put_flash(socket, :error, "Only repository members can create milestones.")}
40 48
    end
41 49
  end
42 50
43 51
  def handle_event("close", %{"id" => id}, socket) do
44
    milestone = Milestones.get_milestone!(socket.assigns.repository, String.to_integer(id))
45
    {:ok, _} = Milestones.update_milestone(milestone, %{"state" => "closed"})
46
47
    {:noreply,
48
     socket
49
     |> assign(:milestones, milestones_with_stats(socket.assigns.repository))
50
     |> put_flash(:info, "Milestone closed")}
52
    socket = refresh_authority(socket)
53
54
    if socket.assigns.can_write do
55
      milestone = Milestones.get_milestone!(socket.assigns.repository, String.to_integer(id))
56
      {:ok, _} = Milestones.update_milestone(milestone, %{"state" => "closed"})
57
58
      {:noreply,
59
       socket
60
       |> assign(:milestones, milestones_with_stats(socket.assigns.repository))
61
       |> put_flash(:info, "Milestone closed")}
62
    else
63
      {:noreply, put_flash(socket, :error, "Only repository members can close milestones.")}
64
    end
51 65
  end
52 66
53 67
  def handle_event("delete", %{"id" => id}, socket) do
54
    milestone = Milestones.get_milestone!(socket.assigns.repository, String.to_integer(id))
55
    {:ok, _} = Milestones.delete_milestone(milestone)
68
    socket = refresh_authority(socket)
69
70
    if socket.assigns.can_write do
71
      milestone = Milestones.get_milestone!(socket.assigns.repository, String.to_integer(id))
72
      {:ok, _} = Milestones.delete_milestone(milestone)
73
74
      {:noreply,
75
       socket
76
       |> assign(:milestones, milestones_with_stats(socket.assigns.repository))
77
       |> put_flash(:info, "Milestone deleted")}
78
    else
79
      {:noreply, put_flash(socket, :error, "Only repository members can delete milestones.")}
80
    end
81
  end
56 82
57
    {:noreply,
58
     socket
59
     |> assign(:milestones, milestones_with_stats(socket.assigns.repository))
60
     |> put_flash(:info, "Milestone deleted")}
83
  defp refresh_authority(socket) do
84
    assign(
85
      socket,
86
      :can_write,
87
      Repositories.writable?(socket.assigns.repository, socket.assigns.current_user)
88
    )
89
  end
90
91
  defp visible_repository!(owner, repo, user) do
92
    Repositories.get_visible_by_path!(owner, repo, user)
93
  rescue
94
    Ecto.NoResultsError ->
95
      raise OpenAgentsWeb.PublicNotFoundError, message: "repository not found"
61 96
  end
62 97
63 98
  defp milestones_with_stats(repository) do

@@ -95,10 +130,11 @@ defmodule OpenAgentsWeb.MilestoneIndexLive do

95 130
      current_scope={@current_scope}
96 131
    >
97 132
      <div class="flex items-center justify-between mb-4">
98
        <h1 class="text-2xl font-bold">Milestones</h1>
133
        <h1 id="milestones-title" class="text-2xl font-bold">Milestones</h1>
99 134
      </div>
100 135
101 136
      <.form
137
        :if={@can_write}
102 138
        for={@form}
103 139
        id="new-milestone-form"
104 140
        phx-submit="save"

@@ -115,7 +151,7 @@ defmodule OpenAgentsWeb.MilestoneIndexLive do

115 151
      </.form>
116 152
117 153
      <%= if @milestones == [] do %>
118
        <div class="alert" data-variant="info" role="status">
154
        <div id="milestones-empty" class="alert" data-variant="info" role="status">
119 155
          <.icon name="info-circle" class="size-5" />
120 156
          <section>No milestones yet.</section>
121 157
        </div>

@@ -171,7 +207,7 @@ defmodule OpenAgentsWeb.MilestoneIndexLive do

171 207
              </div>
172 208
173 209
              <footer class="flex justify-end gap-2">
174
                <%= if milestone.state == "open" do %>
210
                <%= if @can_write and milestone.state == "open" do %>
175 211
                  <button
176 212
                    class="btn"
177 213
                    data-variant="ghost"

@@ -183,6 +219,7 @@ defmodule OpenAgentsWeb.MilestoneIndexLive do

183 219
                  </button>
184 220
                <% end %>
185 221
                <button
222
                  :if={@can_write}
186 223
                  class="btn"
187 224
                  data-variant="ghost"
188 225
                  data-size="sm"
lib/openagents_web/live/project_index_live.ex modified +67 -30

@@ -10,7 +10,8 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

10 10
  alias OpenAgents.Repositories
11 11
12 12
  def mount(%{"owner" => owner, "repo" => repo}, _session, socket) do
13
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
13
    repository = visible_repository!(owner, repo, socket.assigns.current_user)
14
    can_write = Repositories.writable?(repository, socket.assigns.current_user)
14 15
15 16
    {:ok,
16 17
     socket

@@ -18,28 +19,35 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

18 19
     |> assign(:owner, owner)
19 20
     |> assign(:repo, repo)
20 21
     |> assign(:repository, repository)
22
     |> assign(:can_write, can_write)
21 23
     |> assign(:projects, Projects.list_projects(repository))
22 24
     |> assign(:form, to_form(Projects.change_project(repository, %Project{}, %{})))}
23 25
  end
24 26
25 27
  def handle_event("save", %{"project" => project_params}, socket) do
26
    case Projects.create_project(
27
           socket.assigns.repository,
28
           project_params,
29
           socket.assigns.current_user
30
         ) do
31
      {:ok, _project} ->
32
        {:noreply,
33
         socket
34
         |> assign(:projects, Projects.list_projects(socket.assigns.repository))
35
         |> assign(
36
           :form,
37
           to_form(Projects.change_project(socket.assigns.repository, %Project{}, %{}))
38
         )
39
         |> put_flash(:info, "Project created")}
40
41
      {:error, changeset} ->
42
        {:noreply, assign(socket, :form, to_form(changeset))}
28
    socket = refresh_authority(socket)
29
30
    if socket.assigns.can_write do
31
      case Projects.create_project(
32
             socket.assigns.repository,
33
             project_params,
34
             socket.assigns.current_user
35
           ) do
36
        {:ok, _project} ->
37
          {:noreply,
38
           socket
39
           |> assign(:projects, Projects.list_projects(socket.assigns.repository))
40
           |> assign(
41
             :form,
42
             to_form(Projects.change_project(socket.assigns.repository, %Project{}, %{}))
43
           )
44
           |> put_flash(:info, "Project created")}
45
46
        {:error, changeset} ->
47
          {:noreply, assign(socket, :form, to_form(changeset))}
48
      end
49
    else
50
      {:noreply, put_flash(socket, :error, "Only repository members can create projects.")}
43 51
    end
44 52
  end
45 53

@@ -47,20 +55,47 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

47 55
  # invented concept -- and it is the only property of a project this schema
48 56
  # carries that is worth changing without opening the board.
49 57
  def handle_event("set_state", %{"id" => id, "state" => state}, socket) do
50
    project = Projects.get_project!(socket.assigns.repository, id)
51
    {:ok, _updated} = Projects.update_project(project, %{"state" => state})
58
    socket = refresh_authority(socket)
59
60
    if socket.assigns.can_write do
61
      project = Projects.get_project!(socket.assigns.repository, id)
62
      {:ok, _updated} = Projects.update_project(project, %{"state" => state})
52 63
53
    {:noreply, assign(socket, :projects, Projects.list_projects(socket.assigns.repository))}
64
      {:noreply, assign(socket, :projects, Projects.list_projects(socket.assigns.repository))}
65
    else
66
      {:noreply, put_flash(socket, :error, "Only repository members can change project state.")}
67
    end
54 68
  end
55 69
56 70
  def handle_event("delete", %{"id" => id}, socket) do
57
    project = Projects.get_project!(socket.assigns.repository, String.to_integer(id))
58
    {:ok, _} = Projects.delete_project(project)
71
    socket = refresh_authority(socket)
59 72
60
    {:noreply,
61
     socket
62
     |> assign(:projects, Projects.list_projects(socket.assigns.repository))
63
     |> put_flash(:info, "Project deleted")}
73
    if socket.assigns.can_write do
74
      project = Projects.get_project!(socket.assigns.repository, String.to_integer(id))
75
      {:ok, _} = Projects.delete_project(project)
76
77
      {:noreply,
78
       socket
79
       |> assign(:projects, Projects.list_projects(socket.assigns.repository))
80
       |> put_flash(:info, "Project deleted")}
81
    else
82
      {:noreply, put_flash(socket, :error, "Only repository members can delete projects.")}
83
    end
84
  end
85
86
  defp refresh_authority(socket) do
87
    assign(
88
      socket,
89
      :can_write,
90
      Repositories.writable?(socket.assigns.repository, socket.assigns.current_user)
91
    )
92
  end
93
94
  defp visible_repository!(owner, repo, user) do
95
    Repositories.get_visible_by_path!(owner, repo, user)
96
  rescue
97
    Ecto.NoResultsError ->
98
      raise OpenAgentsWeb.PublicNotFoundError, message: "repository not found"
64 99
  end
65 100
66 101
  def render(assigns) do

@@ -73,6 +108,7 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

73 108
      wide
74 109
    >
75 110
      <.form
111
        :if={@can_write}
76 112
        for={@form}
77 113
        id="new-project-form"
78 114
        phx-submit="save"

@@ -84,12 +120,12 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

84 120
        </footer>
85 121
      </.form>
86 122
87
      <div :if={@projects == []} class="alert" data-variant="info" role="status">
123
      <div :if={@projects == []} id="projects-empty" class="alert" data-variant="info" role="status">
88 124
        <.icon name="info-circle" class="size-5" />
89 125
        <section>No projects yet.</section>
90 126
      </div>
91 127
92
      <div :if={@projects != []} class="project-index">
128
      <div :if={@projects != []} id="projects" class="project-index">
93 129
        <div :for={project <- @projects} class="project-index__row">
94 130
          <Circle.project_row
95 131
            name={project.title}

@@ -97,7 +133,7 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

97 133
            status_category={if project.state == "closed", do: :completed, else: :unstarted}
98 134
            status_label={String.capitalize(project.state)}
99 135
          >
100
            <:state>
136
            <:state :if={@can_write}>
101 137
              <Circle.field_menu
102 138
                id={"project-state-#{project.id}"}
103 139
                label={"Change the state of #{project.title}"}

@@ -121,6 +157,7 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

121 157
          row grew: the row is a link to a board, and a destructive action
122 158
          inside a link target is how people delete things by accident. --%>
123 159
          <button
160
            :if={@can_write}
124 161
            class="btn project-index__delete"
125 162
            data-variant="ghost"
126 163
            data-size="sm"
lib/openagents_web/live/project_show_live.ex modified +40 -19

@@ -36,7 +36,7 @@ defmodule OpenAgentsWeb.ProjectShowLive do

36 36
37 37
  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do
38 38
    user = socket.assigns.current_user
39
    repository = Repositories.get_visible_by_path!(owner, repo, user)
39
    repository = visible_repository!(owner, repo, user)
40 40
    project = Projects.get_project_by_number!(repository, String.to_integer(number))
41 41
    can_write = Repositories.writable?(repository, user)
42 42

@@ -66,24 +66,30 @@ defmodule OpenAgentsWeb.ProjectShowLive do

66 66
  end
67 67
68 68
  def handle_event("add_item", %{"item" => item_params}, socket) do
69
    project = socket.assigns.project
70
    number = String.to_integer(item_params["issue_number"])
71
    status = item_params["status"] || "To Do"
72
73
    case Projects.create_project_item(
74
           %{"issue_number" => number, "values" => %{"Status" => status}},
75
           project,
76
           socket.assigns.current_user
77
         ) do
78
      {:ok, _item} ->
79
        {:noreply,
80
         socket
81
         |> assign(:items, project_items(project, socket.assigns.current_user))
82
         |> assign(:form, to_form(ProjectItem.changeset(%ProjectItem{}, %{}), as: "item"))
83
         |> put_flash(:info, "Issue added to project")}
69
    socket = refresh_authority(socket)
84 70
85
      {:error, changeset} ->
86
        {:noreply, assign(socket, :form, to_form(changeset))}
71
    if socket.assigns.can_write do
72
      project = socket.assigns.project
73
      number = String.to_integer(item_params["issue_number"])
74
      status = item_params["status"] || "To Do"
75
76
      case Projects.create_project_item(
77
             %{"issue_number" => number, "values" => %{"Status" => status}},
78
             project,
79
             socket.assigns.current_user
80
           ) do
81
        {:ok, _item} ->
82
          {:noreply,
83
           socket
84
           |> assign(:items, project_items(project, socket.assigns.current_user))
85
           |> assign(:form, to_form(ProjectItem.changeset(%ProjectItem{}, %{}), as: "item"))
86
           |> put_flash(:info, "Issue added to project")}
87
88
        {:error, changeset} ->
89
          {:noreply, assign(socket, :form, to_form(changeset))}
90
      end
91
    else
92
      {:noreply, put_flash(socket, :error, "Only repository members can add project items.")}
87 93
    end
88 94
  end
89 95

@@ -218,6 +224,21 @@ defmodule OpenAgentsWeb.ProjectShowLive do

218 224
219 225
  defp note_form, do: to_form(Projects.change_project_note(), as: "note")
220 226
227
  defp refresh_authority(socket) do
228
    assign(
229
      socket,
230
      :can_write,
231
      Repositories.writable?(socket.assigns.repository, socket.assigns.current_user)
232
    )
233
  end
234
235
  defp visible_repository!(owner, repo, user) do
236
    Repositories.get_visible_by_path!(owner, repo, user)
237
  rescue
238
    Ecto.NoResultsError ->
239
      raise OpenAgentsWeb.PublicNotFoundError, message: "repository not found"
240
  end
241
221 242
  defp project_items(project, user) do
222 243
    Projects.list_visible_project_items(project, user)
223 244
    |> Enum.map(fn item ->

@@ -249,7 +270,7 @@ defmodule OpenAgentsWeb.ProjectShowLive do

249 270
      current_scope={@current_scope}
250 271
    >
251 272
      <div class="flex items-center justify-between mb-4">
252
        <h1 class="text-2xl font-bold">{@project.title}</h1>
273
        <h1 id="project-board-title" class="text-2xl font-bold">{@project.title}</h1>
253 274
        <.link
254 275
          navigate={~p"/#{@owner}/#{@repo}/projects"}
255 276
          class="btn"
lib/openagents_web/live/project_workspace_live.ex modified +29 -14

@@ -37,11 +37,12 @@ defmodule OpenAgentsWeb.ProjectWorkspaceLive do

37 37
38 38
  def mount(_params, _session, socket) do
39 39
    if connected?(socket), do: Repositories.subscribe_all_projects()
40
    user = socket.assigns.current_user
40 41
41 42
    {:ok,
42 43
     socket
43 44
     |> assign(:current_scope, socket.assigns[:current_scope])
44
     |> assign(:involvements, @involvements)
45
     |> assign(:involvements, involvement_options(user))
45 46
     |> assign(:refresh_timer_ref, nil)
46 47
     |> assign(
47 48
       :any_repository?,

@@ -97,7 +98,9 @@ defmodule OpenAgentsWeb.ProjectWorkspaceLive do

97 98
  end
98 99
99 100
  def handle_params(params, _url, socket) do
100
    filters = %{"involvement" => normalize_involvement(params["involvement"])}
101
    filters = %{
102
      "involvement" => normalize_involvement(params["involvement"], socket.assigns.current_user)
103
    }
101 104
102 105
    {:noreply,
103 106
     socket

@@ -109,7 +112,9 @@ defmodule OpenAgentsWeb.ProjectWorkspaceLive do

109 112
  end
110 113
111 114
  def handle_event("filter", params, socket) do
112
    filters = %{"involvement" => normalize_involvement(params["involvement"])}
115
    filters = %{
116
      "involvement" => normalize_involvement(params["involvement"], socket.assigns.current_user)
117
    }
113 118
114 119
    {:noreply, push_patch(socket, to: projects_path(filters, %{"state" => socket.assigns.state}))}
115 120
  end

@@ -118,8 +123,9 @@ defmodule OpenAgentsWeb.ProjectWorkspaceLive do

118 123
    {:noreply, put_flash(socket, :error, "That action is not available here.")}
119 124
  end
120 125
121
  defp normalize_involvement("created"), do: "created"
122
  defp normalize_involvement(_involvement), do: "all"
126
  defp normalize_involvement(_involvement, nil), do: "all"
127
  defp normalize_involvement("created", _user), do: "created"
128
  defp normalize_involvement(_involvement, _user), do: "all"
123 129
124 130
  defp normalize_state("closed"), do: "closed"
125 131
  defp normalize_state("all"), do: "all"

@@ -152,9 +158,13 @@ defmodule OpenAgentsWeb.ProjectWorkspaceLive do

152 158
    |> assign(:projects, projects)
153 159
  end
154 160
161
  defp involvement_opts(nil, _involvement), do: []
155 162
  defp involvement_opts(user, "created"), do: [owner: user]
156 163
  defp involvement_opts(_user, _all), do: []
157 164
165
  defp involvement_options(nil), do: [{"Everyone", "all"}]
166
  defp involvement_options(_user), do: @involvements
167
158 168
  def render(assigns) do
159 169
    ~H"""
160 170
    <Layouts.app

@@ -205,15 +215,20 @@ defmodule OpenAgentsWeb.ProjectWorkspaceLive do

205 215
        id="workspace-projects-no-repositories"
206 216
        title="No repositories yet"
207 217
      >
208
        Projects belong to a repository.
209
        <.link navigate={~p"/repositories/new"} data-variant="link" class="btn px-0">
210
          Create one
211
        </.link>
212
        or
213
        <.link navigate={~p"/repositories/import/github"} data-variant="link" class="btn px-0">
214
          import one from GitHub
215
        </.link>
216
        to start a board.
218
        <%= if @current_user do %>
219
          Projects belong to a repository.
220
          <.link navigate={~p"/repositories/new"} data-variant="link" class="btn px-0">
221
            Create one
222
          </.link>
223
          or
224
          <.link navigate={~p"/repositories/import/github"} data-variant="link" class="btn px-0">
225
            import one from GitHub
226
          </.link>
227
          to start a board.
228
        <% else %>
229
          Sign in with GitHub to see projects from repositories you can read.
230
          <.github_login id="workspace-projects-signin" size={:sm} />
231
        <% end %>
217 232
      </.empty>
218 233
219 234
      <.empty
lib/openagents_web/router.ex modified +9 -14

@@ -118,12 +118,6 @@ defmodule OpenAgentsWeb.Router do

118 118
      live "/repositories/new", RepositoryNewLive, :new
119 119
      live "/repositories/import/github", RepositoryImportLive, :new
120 120
121
      # The sidebar's global surfaces. They are workspace-wide by construction:
122
      # each reads through the repository visibility predicate rather than
123
      # through a repository the URL names, so what they show never depends on
124
      # which page you arrived from.
125
      live "/issues", IssueWorkspaceLive, :index
126
      live "/projects", ProjectWorkspaceLive, :index
127 121
      live "/forum", ForumHomeLive, :index
128 122
      live "/forum/f/:slug", ForumBoardLive, :show
129 123
      live "/forum/t/:id", ForumTopicLive, :show

@@ -132,12 +126,7 @@ defmodule OpenAgentsWeb.Router do

132 126
      live "/:owner/:repo/issues/new", IssueNewLive, :new
133 127
134 128
      live "/:owner/:repo/members", MemberIndexLive, :index
135
      live "/:owner/:repo/labels", LabelIndexLive, :index
136
      live "/:owner/:repo/milestones", MilestoneIndexLive, :index
137 129
      live "/:owner/:repo/assignees", AssigneeIndexLive, :index
138
139
      live "/:owner/:repo/projects/:number", ProjectShowLive, :show
140
      live "/:owner/:repo/projects", ProjectIndexLive, :index
141 130
    end
142 131
143 132
    post "/voice/calls", VoiceCallController, :create

@@ -178,17 +167,23 @@ defmodule OpenAgentsWeb.Router do

178 167
    end
179 168
  end
180 169
181
  # Reading issues is a public activity on a public repository, the way code
170
  # These GitHub-style reads are public on a public repository, the way code
182 171
  # browsing already is, so this session runs behind plain :browser and mounts
183 172
  # whoever is signed in. Each view decides what an anonymous visitor may do,
184 173
  # and every write re-checks authority at the server. The scope comes after
185
  # the authenticated one so the literal `new` segment keeps winning over
186
  # `:number`.
174
  # the authenticated one so literal segments such as `new` keep winning over
175
  # repository-shaped routes.
187 176
  scope "/", OpenAgentsWeb do
188 177
    pipe_through :browser
189 178
190 179
    live_session :forge_issues,
191 180
      on_mount: [{OpenAgentsWeb.UserAuth, :mount_current_user}] do
181
      live "/issues", IssueWorkspaceLive, :index
182
      live "/projects", ProjectWorkspaceLive, :index
183
      live "/:owner/:repo/labels", LabelIndexLive, :index
184
      live "/:owner/:repo/milestones", MilestoneIndexLive, :index
185
      live "/:owner/:repo/projects/:number", ProjectShowLive, :show
186
      live "/:owner/:repo/projects", ProjectIndexLive, :index
192 187
      live "/:owner/:repo/pulls/:number", PullRequestShowLive, :show
193 188
      live "/:owner/:repo/pulls", PullRequestIndexLive, :index
194 189
      live "/:owner/:repo/issues/:number", IssueShowLive, :show
test/openagents_web/live/issue_workspace_live_test.exs modified +27 -2

@@ -182,8 +182,33 @@ defmodule OpenAgentsWeb.IssueWorkspaceLiveTest do

182 182
    refute second =~ newest
183 183
  end
184 184
185
  test "a signed-out visitor is sent home rather than shown the list", %{} do
186
    assert {:error, {:redirect, %{to: "/"}}} = live(build_conn(), ~p"/issues")
185
  test "a signed-out visitor sees the public workspace and only the everyone filter" do
186
    Repo.update_all(Repository, set: [visibility: "private"])
187
188
    {:ok, view, _html} = live(build_conn(), ~p"/issues")
189
190
    assert has_element?(view, "#workspace-issues-no-repositories")
191
    assert has_element?(view, "#workspace-issue-filter-form option[value=\"all\"]")
192
    refute has_element?(view, "#workspace-issue-filter-form option[value=\"assigned\"]")
193
    refute has_element?(view, "#workspace-issue-filter-form option[value=\"created\"]")
194
    assert has_element?(view, "#workspace-issues-signin")
195
  end
196
197
  test "a signed-out visitor cannot see a private issue through any filter", context do
198
    for query <- [
199
          "",
200
          "q=Rotate+the+signing+key",
201
          "involvement=assigned",
202
          "involvement=created",
203
          "state=all",
204
          "state=closed",
205
          "state=closed&involvement=assigned",
206
          "state=closed&involvement=created",
207
          "page=2"
208
        ] do
209
      {:ok, view, _html} = live(build_conn(), ~p"/issues?#{query}")
210
      refute issue_linked?(view, context.private, context.secret)
211
    end
187 212
  end
188 213
189 214
  defp bulk_title(index), do: "Bulk #{String.pad_leading(to_string(index), 3, "0")}"
test/openagents_web/live/label_index_live_test.exs modified +28

@@ -19,6 +19,34 @@ defmodule OpenAgentsWeb.LabelIndexLiveTest do

19 19
    refute has_element?(view, "#labels")
20 20
  end
21 21
22
  test "a signed-out visitor can read labels without write affordances" do
23
    {:ok, view, _html} = live(build_conn(), ~p"/OpenAgentsInc/openagents.com/labels")
24
25
    assert has_element?(view, "#labels-title")
26
    refute has_element?(view, "#new-label-form")
27
    refute has_element?(view, "#labels button[phx-click=\"delete\"]")
28
  end
29
30
  test "a signed-out visitor cannot submit a label write event" do
31
    {:ok, view, _html} = live(build_conn(), ~p"/OpenAgentsInc/openagents.com/labels")
32
33
    html =
34
      render_submit(view, "save", %{
35
        "label" => %{"name" => "forged", "color" => "ffffff", "description" => ""}
36
      })
37
38
    assert html =~ "Only repository members can create labels."
39
    refute has_element?(view, "#labels td", "forged")
40
  end
41
42
  test "a private repository is not visible to a signed-out visitor" do
43
    private = repository_fixture(%{owner: "HiddenLabels", name: "labels", visibility: "private"})
44
45
    assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
46
      live(build_conn(), ~p"/#{private.owner}/#{private.name}/labels")
47
    end
48
  end
49
22 50
  test "lists seeded labels with their descriptions", %{conn: conn} do
23 51
    label_fixture(repository(), %{
24 52
      name: "bug",
test/openagents_web/live/milestone_index_live_test.exs modified +30

@@ -19,6 +19,36 @@ defmodule OpenAgentsWeb.MilestoneIndexLiveTest do

19 19
    assert has_element?(view, ~s{[role="status"]}, "No milestones yet")
20 20
  end
21 21
22
  test "a signed-out visitor can read milestones without write affordances" do
23
    {:ok, view, _html} = live(build_conn(), ~p"/OpenAgentsInc/openagents.com/milestones")
24
25
    assert has_element?(view, "#milestones-title")
26
    refute has_element?(view, "#new-milestone-form")
27
    refute has_element?(view, "button[phx-click=\"close\"]")
28
    refute has_element?(view, "button[phx-click=\"delete\"]")
29
  end
30
31
  test "a signed-out visitor cannot submit a milestone write event" do
32
    {:ok, view, _html} = live(build_conn(), ~p"/OpenAgentsInc/openagents.com/milestones")
33
34
    html =
35
      render_submit(view, "save", %{
36
        "milestone" => %{"title" => "Forged", "due_on" => "", "description" => ""}
37
      })
38
39
    assert html =~ "Only repository members can create milestones."
40
    refute has_element?(view, "#milestones td", "Forged")
41
  end
42
43
  test "a private repository is not visible to a signed-out visitor" do
44
    private =
45
      repository_fixture(%{owner: "HiddenMilestones", name: "milestones", visibility: "private"})
46
47
    assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
48
      live(build_conn(), ~p"/#{private.owner}/#{private.name}/milestones")
49
    end
50
  end
51
22 52
  test "lists seeded milestones with their state and due date", %{conn: conn} do
23 53
    milestone_fixture(repository(), %{
24 54
      title: "v1.0",
test/openagents_web/live/project_index_live_test.exs modified +27

@@ -21,6 +21,33 @@ defmodule OpenAgentsWeb.ProjectIndexLiveTest do

21 21
    assert has_element?(view, ~s{[role="status"]}, "No projects yet")
22 22
  end
23 23
24
  test "a signed-out visitor can read projects without write affordances" do
25
    {:ok, view, _html} = live(build_conn(), ~p"/OpenAgentsInc/openagents.com/projects")
26
27
    assert has_element?(view, "#projects-empty")
28
    refute has_element?(view, "#new-project-form")
29
    refute has_element?(view, "button[phx-click=\"delete\"]")
30
    refute has_element?(view, "button[phx-click=\"set_state\"]")
31
  end
32
33
  test "a signed-out visitor cannot submit a project write event" do
34
    {:ok, view, _html} = live(build_conn(), ~p"/OpenAgentsInc/openagents.com/projects")
35
36
    html = render_submit(view, "save", %{"project" => %{"title" => "Forged"}})
37
38
    assert html =~ "Only repository members can create projects."
39
    refute has_element?(view, "#projects", "Forged")
40
  end
41
42
  test "a private repository is not visible to a signed-out visitor" do
43
    private =
44
      repository_fixture(%{owner: "HiddenProjects", name: "projects", visibility: "private"})
45
46
    assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
47
      live(build_conn(), ~p"/#{private.owner}/#{private.name}/projects")
48
    end
49
  end
50
24 51
  test "lists projects owned by the URL owner and links to each board", %{conn: conn} do
25 52
    project =
26 53
      project_fixture(repository(), %{title: "Roadmap", owner: "OpenAgentsInc", state: "open"})
test/openagents_web/live/project_show_live_test.exs modified +33

@@ -35,6 +35,39 @@ defmodule OpenAgentsWeb.ProjectShowLiveTest do

35 35
           )
36 36
  end
37 37
38
  test "a signed-out visitor can read a project board without the add form" do
39
    project = project!()
40
41
    {:ok, view, _html} = live(build_conn(), path(project))
42
43
    assert has_element?(view, "#project-board-title")
44
    refute has_element?(view, "#new-project-item-form")
45
  end
46
47
  test "a signed-out visitor cannot submit an add-item event" do
48
    project = project!()
49
    {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Hidden forged item"})
50
51
    {:ok, view, _html} = live(build_conn(), path(project))
52
53
    html =
54
      render_submit(view, "add_item", %{
55
        "item" => %{"issue_number" => to_string(issue.number), "status" => "Done"}
56
      })
57
58
    assert html =~ "Only repository members can add project items."
59
    refute has_element?(view, ~s{a[href="/OpenAgentsInc/openagents.com/issues/#{issue.number}"]})
60
  end
61
62
  test "a private repository is not visible to a signed-out visitor" do
63
    private =
64
      repository_fixture(%{owner: "HiddenBoard", name: "projects", visibility: "private"})
65
66
    assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
67
      live(build_conn(), ~p"/#{private.owner}/#{private.name}/projects/1")
68
    end
69
  end
70
38 71
  test "an empty board renders the columns with no cards", %{conn: conn} do
39 72
    project = project!()
40 73
test/openagents_web/live/project_workspace_live_test.exs modified +23 -2

@@ -137,8 +137,29 @@ defmodule OpenAgentsWeb.ProjectWorkspaceLiveTest do

137 137
    refute second =~ newest
138 138
  end
139 139
140
  test "a signed-out visitor is sent home rather than shown the list", %{} do
141
    assert {:error, {:redirect, %{to: "/"}}} = live(build_conn(), ~p"/projects")
140
  test "a signed-out visitor sees the public workspace and only the everyone filter" do
141
    Repo.update_all(Repository, set: [visibility: "private"])
142
143
    {:ok, view, _html} = live(build_conn(), ~p"/projects")
144
145
    assert has_element?(view, "#workspace-projects-no-repositories")
146
    assert has_element?(view, "#workspace-project-filter-form option[value=\"all\"]")
147
    refute has_element?(view, "#workspace-project-filter-form option[value=\"created\"]")
148
    assert has_element?(view, "#workspace-projects-signin")
149
  end
150
151
  test "a signed-out visitor cannot see a private project through any filter", context do
152
    for query <- [
153
          "",
154
          "involvement=created",
155
          "state=all",
156
          "state=closed",
157
          "state=closed&involvement=created",
158
          "page=2"
159
        ] do
160
      {:ok, view, _html} = live(build_conn(), ~p"/projects?#{query}")
161
      refute project_linked?(view, context.private, context.secret)
162
    end
142 163
  end
143 164
144 165
  defp bulk_title(index), do: "Board #{String.pad_leading(to_string(index), 3, "0")}"

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