Wire stack actions into the pull request page

65bb3edbbe49 · Devin AI · · parent 47de26d4f45e

Wire stack actions into the pull request page

The pull request page now starts the durable stack rebase from the
stack map, removes the top layer from the stack, links the trunk row to
the repository tree, and states stack-wide readiness. The operation
notice reports queued, running, paused, finished, failed, and cancelled
states with a progress check. Actions render only for writers.

Closes #114

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

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_web/live/pull_request_show_live.ex
  • modified test/openagents_web/live/pull_request_live_test.exs

Diff

2 files changed, +250 -4

lib/openagents_web/live/pull_request_show_live.ex modified +183 -3

@@ -16,6 +16,7 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

16 16
  alias OpenAgents.PullRequests
17 17
  alias OpenAgents.Repositories
18 18
  alias OpenAgents.Stacks
19
  alias OpenAgents.Stacks.Restack
19 20
  alias OpenAgentsWeb.RepositoryAccess
20 21
21 22
  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do

@@ -36,6 +37,11 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

36 37
     |> assign(:repository, repository)
37 38
     |> assign(:pull_request, pull_request)
38 39
     |> assign(:stack_context, stack_context)
40
     |> assign(:stack_operation, nil)
41
     |> assign(
42
       :can_write,
43
       Repositories.writable?(repository, socket.assigns.current_user)
44
     )
39 45
     |> assign(
40 46
       :diff_readable,
41 47
       RepositoryAccess.full_source?(repository, socket.assigns.current_user)

@@ -75,6 +81,99 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

75 81
  defp layer_range_if_intact(%{boundary_state: :stale}), do: nil
76 82
  defp layer_range_if_intact(context), do: context.layer_range
77 83
84
  def handle_event("restack", _params, socket) do
85
    %{assigns: assigns} = socket
86
87
    with true <- assigns.can_write and assigns.stack_context != nil,
88
         {:ok, {operation, _replay_state}} <-
89
           Restack.request_from_api(
90
             assigns.repository,
91
             assigns.stack_context.stack.number,
92
             %{},
93
             assigns.current_user,
94
             Ecto.UUID.generate()
95
           ) do
96
      {:noreply,
97
       socket
98
       |> assign(:stack_operation, operation)
99
       |> put_flash(:info, "Stack rebase started.")}
100
    else
101
      {:error, {:operation_in_progress, operation_id}} ->
102
        {:noreply, socket |> load_operation(operation_id) |> refresh_stack()}
103
104
      {:error, _reason} ->
105
        {:noreply, put_flash(socket, :error, "The stack rebase could not start.")}
106
107
      false ->
108
        {:noreply, socket}
109
    end
110
  end
111
112
  def handle_event("refresh-operation", _params, socket) do
113
    case socket.assigns.stack_operation do
114
      nil -> {:noreply, socket}
115
      operation -> {:noreply, socket |> load_operation(operation.id) |> refresh_stack()}
116
    end
117
  end
118
119
  def handle_event("unstack", _params, socket) do
120
    %{assigns: assigns} = socket
121
122
    with true <- assigns.can_write and assigns.stack_context != nil,
123
         {:ok, _result} <-
124
           Stacks.unstack_from_api(
125
             assigns.repository,
126
             assigns.stack_context.stack.number,
127
             %{"pull_request" => assigns.pull_request.issue.number},
128
             assigns.current_user,
129
             Ecto.UUID.generate()
130
           ) do
131
      {:noreply,
132
       socket
133
       |> put_flash(:info, "The pull request left the stack.")
134
       |> refresh_stack()}
135
    else
136
      {:error, {:operation_in_progress, operation_id}} ->
137
        {:noreply, load_operation(socket, operation_id)}
138
139
      {:error, :not_stack_top} ->
140
        {:noreply, put_flash(socket, :error, "Only the top layer can leave the stack.")}
141
142
      {:error, _reason} ->
143
        {:noreply, put_flash(socket, :error, "The pull request could not leave the stack.")}
144
145
      false ->
146
        {:noreply, socket}
147
    end
148
  end
149
150
  defp load_operation(socket, operation_id) do
151
    %{assigns: assigns} = socket
152
153
    case Restack.get_operation(
154
           assigns.repository,
155
           assigns.stack_context.stack.number,
156
           operation_id
157
         ) do
158
      {:ok, operation} -> assign(socket, :stack_operation, operation)
159
      {:error, _reason} -> socket
160
    end
161
  end
162
163
  defp refresh_stack(socket) do
164
    %{assigns: assigns} = socket
165
166
    stack_context =
167
      case Stacks.review_context(assigns.repository, assigns.pull_request) do
168
        {:ok, context} -> context
169
        {:error, :not_stacked} -> nil
170
      end
171
172
    socket
173
    |> assign(:stack_context, stack_context)
174
    |> assign_stack_diff()
175
  end
176
78 177
  def render(assigns) do
79 178
    ~H"""
80 179
    <Layouts.app

@@ -131,9 +230,51 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

131 230
                id="stack-map"
132 231
                number={@stack_context.stack.number}
133 232
                trunk={@stack_context.stack.trunk_ref}
233
                trunk_navigate={~p"/#{@owner}/#{@repo}/tree/#{@stack_context.stack.trunk_ref}"}
134 234
                layers={stack_map_layers(@stack_context, @owner, @repo)}
135 235
                class="mt-4 max-w-md"
136
              />
236
              >
237
                <:action :if={@can_write}>
238
                  <.button id="stack-rebase" variant={:outline} size={:sm} phx-click="restack">
239
                    Rebase the stack
240
                  </.button>
241
                  <.button
242
                    :if={@stack_context.position == @stack_context.size}
243
                    id="stack-unstack"
244
                    variant={:outline}
245
                    size={:sm}
246
                    phx-click="unstack"
247
                  >
248
                    Remove from stack
249
                  </.button>
250
                </:action>
251
              </.stack_map>
252
253
              <p id="stack-readiness" class="mt-3 text-sm text-muted-foreground">
254
                {readiness(@stack_context)}
255
              </p>
256
257
              <.alert
258
                :if={@stack_operation}
259
                id="stack-operation-status"
260
                variant={operation_variant(@stack_operation.state)}
261
                appearance={:notice}
262
                label={operation_label(@stack_operation.state)}
263
                class="mt-4"
264
              >
265
                The rebase runs on the server and moves every branch at once when
266
                it finishes.
267
                <:action>
268
                  <.button
269
                    id="stack-operation-refresh"
270
                    variant={:outline}
271
                    size={:sm}
272
                    phx-click="refresh-operation"
273
                  >
274
                    Check progress
275
                  </.button>
276
                </:action>
277
              </.alert>
137 278
138 279
              <nav class="mt-4 flex gap-2" aria-label="Stack diff views">
139 280
                <.button

@@ -185,8 +326,13 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

185 326
                A lower branch was rewritten, so the stored review boundary no longer
186 327
                matches the parent branch. Rebase the stack to restore the intended
187 328
                review boundary.
188
                <:action>
189
                  <.button id="stack-restack-action" variant={:outline} size={:sm} disabled>
329
                <:action :if={@can_write}>
330
                  <.button
331
                    id="stack-restack-action"
332
                    variant={:outline}
333
                    size={:sm}
334
                    phx-click="restack"
335
                  >
190 336
                    Rebase the stack
191 337
                  </.button>
192 338
                </:action>

@@ -226,6 +372,40 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

226 372
    end)
227 373
  end
228 374
375
  defp readiness(context) do
376
    states = Enum.map(context.stack.entries, &layer_state(&1.pull_request))
377
    total = length(states)
378
    draft = Enum.count(states, &(&1 == "draft"))
379
380
    cond do
381
      context.stack.health != "healthy" ->
382
        "#{layer_count(total)} · the stack needs a rebase before it can merge"
383
384
      draft > 0 ->
385
        "#{total - draft} of #{layer_count(total)} ready · #{draft} still in draft"
386
387
      true ->
388
        "#{layer_count(total)} ready to merge bottom-first"
389
    end
390
  end
391
392
  defp layer_count(1), do: "1 layer"
393
  defp layer_count(count), do: "#{count} layers"
394
395
  defp operation_variant(state) when state in ["succeeded"], do: :success
396
  defp operation_variant(state) when state in ["failed", "cancelled"], do: :danger
397
  defp operation_variant("waiting_for_conflict_resolution"), do: :warning
398
  defp operation_variant(_state), do: :info
399
400
  defp operation_label("pending"), do: "Stack rebase queued"
401
  defp operation_label("running"), do: "Stack rebase running"
402
  defp operation_label("waiting_for_conflict_resolution"), do: "Stack rebase paused on a conflict"
403
  defp operation_label("waiting_for_checks"), do: "Stack rebase waiting for checks"
404
  defp operation_label("succeeded"), do: "Stack rebase finished"
405
  defp operation_label("failed"), do: "Stack rebase failed"
406
  defp operation_label("cancelled"), do: "Stack rebase cancelled"
407
  defp operation_label(other), do: other
408
229 409
  defp layer_state(pull_request) do
230 410
    cond do
231 411
      pull_request.merged_at -> "merged"
test/openagents_web/live/pull_request_live_test.exs modified +67 -1

@@ -1,5 +1,6 @@

1 1
defmodule OpenAgentsWeb.PullRequestLiveTest do
2 2
  use OpenAgentsWeb.ConnCase
3
  import Ecto.Query
3 4
  import Phoenix.LiveViewTest
4 5
  import OpenAgents.AccountsFixtures
5 6
  import OpenAgents.IssuesFixtures

@@ -124,11 +125,76 @@ defmodule OpenAgentsWeb.PullRequestLiveTest do

124 125
      {:ok, show, html} = live(conn, pull_path(repository, top))
125 126
126 127
      assert has_element?(show, "#stack-stale-boundary")
127
      assert has_element?(show, "#stack-restack-action")
128 128
      assert render(show) =~ "based on an outdated parent commit"
129 129
      refute html =~ "layer-1.md"
130 130
      refute html =~ "layer-2.md"
131 131
    end
132
133
    test "a reader sees the readiness summary and trunk link but no actions", %{
134
      conn: conn,
135
      repository: repository,
136
      pull_requests: pull_requests
137
    } do
138
      top = Enum.at(pull_requests, 1)
139
      {:ok, show, _html} = live(conn, pull_path(repository, top))
140
141
      assert element(show, "#stack-readiness") |> render() =~
142
               "0 of 2 layers ready · 2 still in draft"
143
144
      assert has_element?(
145
               show,
146
               "#stack-map a[href='/#{repository.owner}/#{repository.name}/tree/main']"
147
             )
148
149
      refute has_element?(show, "#stack-rebase")
150
      refute has_element?(show, "#stack-unstack")
151
    end
152
153
    test "a writer starts a stack rebase from the page", %{
154
      conn: conn,
155
      repository: repository,
156
      pull_requests: pull_requests
157
    } do
158
      conn = log_in_repository_user(conn, "stack-writer", repository)
159
      top = Enum.at(pull_requests, 1)
160
      {:ok, show, _html} = live(conn, pull_path(repository, top))
161
162
      show |> element("#stack-rebase") |> render_click()
163
164
      assert element(show, "#stack-operation-status") |> render() =~ "Stack rebase queued"
165
166
      assert Repo.exists?(
167
               from operation in OpenAgents.Stacks.Operation,
168
                 where: operation.kind == "rebase" and operation.state == "pending"
169
             )
170
171
      show |> element("#stack-rebase") |> render_click()
172
      assert element(show, "#stack-operation-status") |> render() =~ "Stack rebase queued"
173
174
      show |> element("#stack-operation-refresh") |> render_click()
175
      assert has_element?(show, "#stack-operation-status")
176
    end
177
178
    test "a writer removes the top layer from the stack", %{
179
      conn: conn,
180
      repository: repository,
181
      pull_requests: pull_requests
182
    } do
183
      conn = log_in_repository_user(conn, "stack-writer", repository)
184
      [bottom, top] = pull_requests
185
186
      {:ok, bottom_show, _html} = live(conn, pull_path(repository, bottom))
187
      refute has_element?(bottom_show, "#stack-unstack")
188
      assert has_element?(bottom_show, "#stack-rebase")
189
190
      {:ok, show, _html} = live(conn, pull_path(repository, top))
191
      assert has_element?(show, "#stack-unstack")
192
193
      show |> element("#stack-unstack") |> render_click()
194
195
      assert render(show) =~ "The pull request left the stack."
196
      refute has_element?(show, "#stack-review")
197
    end
132 198
  end
133 199
134 200
  defp pull_path(repository, pull_request) do

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