Live-update the global issue list on out-of-band writes

f05131a67dc1 · AtlantisPleb · · parent 8514ad950d67

Live-update the global issue list on out-of-band writes

The repository-scoped issue list re-reads whenever its repository's issues
move; the global /issues workspace rendered one mount-time snapshot, so an
issue filed through the API appeared only after a manual reload. A global
issues topic now carries every repository's change announcement, the
workspace subscribes to it in mount, and each announcement re-reads the
current page through this viewer's own authorization — the same readable_by
predicate as the initial load — so a private issue can never surface for a
viewer who cannot read it.

Bursts coalesce: each change re-arms a single 250 ms timer and only the last
fires a re-read. Tests run with zero debounce and refresh synchronously on
the change message.

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/repositories.ex
  • modified lib/openagents_web/live/issue_workspace_live.ex
  • modified test/openagents_web/live/issue_workspace_live_test.exs

Diff

3 files changed, +111 -0

lib/openagents/repositories.ex modified +12

@@ -842,6 +842,8 @@ defmodule OpenAgents.Repositories do

842 842
  end
843 843
844 844
  @doc "Subscribes the caller to one repository's issue activity."
845
  @all_issues_topic "issues:all"
846
845 847
  def subscribe_issues(repository_id),
846 848
    do: Phoenix.PubSub.subscribe(OpenAgents.PubSub, issues_topic(repository_id))
847 849

@@ -861,10 +863,20 @@ defmodule OpenAgents.Repositories do

861 863
      issues_topic(repository_id),
862 864
      {:issues_changed, repository_id}
863 865
    )
866
867
    Phoenix.PubSub.broadcast(
868
      OpenAgents.PubSub,
869
      @all_issues_topic,
870
      {:issues_changed, repository_id}
871
    )
864 872
  end
865 873
866 874
  defp issues_topic(repository_id), do: "issues:" <> repository_id
867 875
876
  @doc "Receives `{:issues_changed, repository_id}` for every repository at once."
877
  def subscribe_all_issues,
878
    do: Phoenix.PubSub.subscribe(OpenAgents.PubSub, @all_issues_topic)
879
868 880
  @doc "Seeds GitHub's default label vocabulary onto a new or imported repository."
869 881
  def seed_default_labels!(%Repository{} = repository) do
870 882
    Enum.each(@default_labels, fn {name, color, description} ->
lib/openagents_web/live/issue_workspace_live.ex modified +48

@@ -58,16 +58,64 @@ defmodule OpenAgentsWeb.IssueWorkspaceLive do

58 58
  ]
59 59
60 60
  def mount(_params, _session, socket) do
61
    if connected?(socket), do: Repositories.subscribe_all_issues()
62
61 63
    {:ok,
62 64
     socket
63 65
     |> assign(:current_scope, socket.assigns[:current_scope])
64 66
     |> assign(:involvements, @involvements)
67
     |> assign(:refresh_timer_ref, nil)
65 68
     |> assign(
66 69
       :any_repository?,
67 70
       Repositories.any_visible_repository?(socket.assigns.current_user)
68 71
     )}
69 72
  end
70 73
74
  # Live updates across every repository. Any committed issue write anywhere
75
  # re-reads the current page through this viewer's own authorization — the
76
  # same `readable_by` predicate the initial load used — so a viewer who keeps
77
  # the page open converges instead of drifting. Bursts coalesce: each change
78
  # (re)arms one timer and only the last fires a re-read.
79
  def handle_info({:issues_changed, _repository_id}, socket) do
80
    {:noreply, schedule_refresh(socket)}
81
  end
82
83
  def handle_info(:refresh_issues_now, socket) do
84
    {:noreply, socket |> assign(:refresh_timer_ref, nil) |> load()}
85
  end
86
87
  def handle_info(_other, socket), do: {:noreply, socket}
88
89
  @refresh_debounce_ms if Application.compile_env(:openagents, :runtime_environment) == :test,
90
                         do: 0,
91
                         else: 250
92
93
  # Zero debounce means tests: refresh synchronously on the change message so
94
  # assertions need no waiting.
95
  defp schedule_refresh(socket) when @refresh_debounce_ms == 0 do
96
    load(socket)
97
  end
98
99
  defp schedule_refresh(socket) do
100
    case socket.assigns.refresh_timer_ref do
101
      nil ->
102
        assign(
103
          socket,
104
          :refresh_timer_ref,
105
          Process.send_after(self(), :refresh_issues_now, @refresh_debounce_ms)
106
        )
107
108
      ref when is_reference(ref) ->
109
        Process.cancel_timer(ref)
110
111
        assign(
112
          socket,
113
          :refresh_timer_ref,
114
          Process.send_after(self(), :refresh_issues_now, @refresh_debounce_ms)
115
        )
116
    end
117
  end
118
71 119
  def handle_params(params, _url, socket) do
72 120
    filters = read_filters(params)
73 121
test/openagents_web/live/issue_workspace_live_test.exs modified +51

@@ -207,4 +207,55 @@ defmodule OpenAgentsWeb.IssueWorkspaceLiveTest do

207 207
    |> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
208 208
    |> Repo.update!()
209 209
  end
210
211
  describe "live updates" do
212
    test "an out-of-band write re-renders the stream without a reload", context do
213
      {:ok, view, _html} = live(context.conn, ~p"/issues")
214
215
      {:ok, fresh} = Issues.create_issue(context.private, %{"title" => "Filed from the API"})
216
217
      send(view.pid, {:issues_changed, context.private.id})
218
      _ = :sys.get_state(view.pid)
219
220
      assert has_element?(
221
               view,
222
               ~s{a[href="/#{context.private.owner}/#{context.private.name}/issues/#{fresh.number}"]},
223
               "Filed from the API"
224
             )
225
    end
226
227
    test "a burst of writes coalesces into one refresh", context do
228
      {:ok, view, _html} = live(context.conn, ~p"/issues")
229
230
      Enum.each(1..5, fn n ->
231
        {:ok, _} = Issues.create_issue(context.private, %{"title" => "Burst #{n}"})
232
        send(view.pid, {:issues_changed, context.private.id})
233
      end)
234
235
      # With the test debounce at zero every armed timer fires immediately; what
236
      # matters here is that the page converges to all five rows in one piece.
237
      _ = :sys.get_state(view.pid)
238
239
      html = render(view)
240
241
      for n <- 1..5 do
242
        assert html =~ "Burst #{n}"
243
      end
244
    end
245
246
    test "a private issue never reaches a viewer who cannot read it", context do
247
      outsider_conn =
248
        Plug.Test.init_test_session(build_conn(), %{"user_id" => github_user("outsider").id})
249
250
      {:ok, view, html} = live(outsider_conn, ~p"/issues")
251
252
      refute html =~ "Rotate the signing key"
253
254
      send(view.pid, {:issues_changed, context.private.id})
255
      _ = :sys.get_state(view.pid)
256
      _ = render(view)
257
258
      refute render(view) =~ "Rotate the signing key"
259
    end
260
  end
210 261
end

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