Show the merged state and keep the stack map on merged pull requests

3095255b8d3f · Devin AI · · parent 7146389f12ff

Show the merged state and keep the stack map on merged pull requests

A merged pull request rendered only its closed state, and merging
removed its stack entry so the stack map vanished from the page.
The pull request page and list now derive the state from merged_at,
draft, and state together, and a new Stacks.merged_context/1 resolves
the historical stack placement — matching only entries whose removal
coincided with the merge — so merged stack members keep a read-only
stack map, and their Open Graph cards keep the stack layer chip.

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 assets/css/openagents.css
  • modified lib/openagents/stacks.ex
  • modified lib/openagents_web/components/ui.ex
  • modified lib/openagents_web/controllers/og_image_controller.ex
  • modified lib/openagents_web/live/pull_request_index_live.ex
  • modified lib/openagents_web/live/pull_request_show_live.ex
  • modified test/openagents/stacks/merge_test.exs
  • modified test/openagents_web/live/pull_request_live_test.exs

Diff

8 files changed, +261 -7

assets/css/openagents.css modified +5

@@ -1064,6 +1064,11 @@

1064 1064
    color: var(--danger);
1065 1065
  }
1066 1066
1067
  /* Merged pull requests and issues closed as completed. */
1068
  .badge[data-variant="done"] {
1069
    color: var(--done);
1070
  }
1071
1067 1072
  /* Technical metadata keeps the monospace accent. */
1068 1073
  .badge[data-variant="dim"] {
1069 1074
    color: var(--text-dim);
lib/openagents/stacks.ex modified +57

@@ -185,6 +185,63 @@ defmodule OpenAgents.Stacks do

185 185
    end
186 186
  end
187 187
188
  @doc """
189
  The historical stack placement for a pull request that merged as part
190
  of a stack.
191
192
  Merging removes the entry from the active stack, so `review_context/2`
193
  stops covering the pull request the moment it lands. This returns the
194
  stack with every layer it held — removed entries included — so the page
195
  keeps showing where the pull request sat and where each sibling went. It
196
  matches only entries whose removal coincided with the merge, so a pull
197
  request that left a stack and merged on its own stays `:not_stacked`.
198
  """
199
  def merged_context(%PullRequest{} = pull_request) do
200
    with %DateTime{} = merged_at <- pull_request.merged_at,
201
         %StackEntry{} = entry <- merged_entry_for_pull_request(pull_request, merged_at) do
202
      stack =
203
        Stack
204
        |> Repo.get!(entry.stack_id)
205
        |> Repo.preload(entries: all_entries_query())
206
207
      entries = latest_entry_per_pull_request(stack.entries)
208
      stack = %{stack | entries: entries}
209
      entry = Enum.find(entries, &(&1.id == entry.id))
210
211
      {:ok,
212
       %{
213
         stack: stack,
214
         entry: entry,
215
         position: entry.position,
216
         size: length(entries)
217
       }}
218
    else
219
      _other -> {:error, :not_stacked}
220
    end
221
  end
222
223
  defp merged_entry_for_pull_request(%PullRequest{id: pull_request_id}, merged_at) do
224
    Repo.one(
225
      from entry in StackEntry,
226
        where: entry.pull_request_id == ^pull_request_id and entry.removed_at == ^merged_at
227
    )
228
  end
229
230
  defp latest_entry_per_pull_request(entries) do
231
    entries
232
    |> Enum.group_by(& &1.pull_request_id)
233
    |> Enum.map(fn {_pull_request_id, group} ->
234
      Enum.max_by(group, & &1.inserted_at, DateTime)
235
    end)
236
    |> Enum.sort_by(& &1.position)
237
  end
238
239
  defp all_entries_query do
240
    from entry in StackEntry,
241
      order_by: [asc: entry.position],
242
      preload: [pull_request: :issue]
243
  end
244
188 245
  defp parent_ref(stack, %StackEntry{position: 1}), do: stack.trunk_ref
189 246
190 247
  defp parent_ref(stack, entry) do
lib/openagents_web/components/ui.ex modified +1 -1

@@ -459,7 +459,7 @@ defmodule OpenAgentsWeb.UI do

459 459
  and never stands in for the words.
460 460
  """
461 461
  attr :variant, :atom,
462
    values: [:default, :info, :success, :warning, :danger, :dim],
462
    values: [:default, :info, :success, :warning, :danger, :done, :dim],
463 463
    default: :default
464 464
465 465
  attr :class, :any, default: nil
lib/openagents_web/controllers/og_image_controller.ex modified +7

@@ -204,6 +204,13 @@ defmodule OpenAgentsWeb.OgImageController do

204 204
205 205
  defp stack_placement(repository, pull_request) do
206 206
    case safe(fn -> Stacks.review_context(repository, pull_request) end) do
207
      {:ok, context} -> {context.position, context.size}
208
      _other -> merged_stack_placement(pull_request)
209
    end
210
  end
211
212
  defp merged_stack_placement(pull_request) do
213
    case safe(fn -> Stacks.merged_context(pull_request) end) do
207 214
      {:ok, context} -> {context.position, context.size}
208 215
      _other -> {nil, nil}
209 216
    end
lib/openagents_web/live/pull_request_index_live.ex modified +16 -2

@@ -67,8 +67,8 @@ defmodule OpenAgentsWeb.PullRequestIndexLive do

67 67
                      #{pull_request.issue.number} from {pull_request.head_repository.owner}/{pull_request.head_repository.name}:{pull_request.head_ref} into {pull_request.base_ref}
68 68
                    </p>
69 69
                  </div>
70
                  <.badge variant={if(pull_request.state == "open", do: :success, else: :secondary)}>
71
                    {pull_request.state}
70
                  <.badge variant={state_variant(state_label(pull_request))}>
71
                    {state_label(pull_request)}
72 72
                  </.badge>
73 73
                </div>
74 74
              </.link>

@@ -80,6 +80,20 @@ defmodule OpenAgentsWeb.PullRequestIndexLive do

80 80
    """
81 81
  end
82 82
83
  defp state_label(pull_request) do
84
    cond do
85
      pull_request.merged_at -> "merged"
86
      pull_request.state == "closed" -> "closed"
87
      pull_request.draft -> "draft"
88
      true -> "open"
89
    end
90
  end
91
92
  defp state_variant("merged"), do: :done
93
  defp state_variant("open"), do: :success
94
  defp state_variant("draft"), do: :dim
95
  defp state_variant(_closed), do: :danger
96
83 97
  defp visible_repository!(owner, repo, user) do
84 98
    Repositories.get_visible_by_path!(owner, repo, user)
85 99
  rescue
lib/openagents_web/live/pull_request_show_live.ex modified +43 -4

@@ -30,6 +30,9 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

30 30
        {:error, :not_stacked} -> nil
31 31
      end
32 32
33
    stack_history = if stack_context, do: nil, else: merged_history(pull_request)
34
    stack_placement = stack_context || stack_history
35
33 36
    {:ok,
34 37
     socket
35 38
     |> assign(:current_scope, socket.assigns[:current_scope])

@@ -38,13 +41,14 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

38 41
     |> assign(:repository, repository)
39 42
     |> assign(:pull_request, pull_request)
40 43
     |> assign(:stack_context, stack_context)
44
     |> assign(:stack_history, stack_history)
41 45
     |> assign(:stack_operation, nil)
42 46
     |> assign(
43 47
       :og,
44 48
       OG.meta(
45 49
         OG.pull_request(repository.namespace.slug, repository.name, pull_request,
46
           stack_position: stack_context && stack_context.position,
47
           stack_size: stack_context && stack_context.size
50
           stack_position: stack_placement && stack_placement.position,
51
           stack_size: stack_placement && stack_placement.size
48 52
         )
49 53
       )
50 54
     )

@@ -213,8 +217,8 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

213 217
          <article class="mx-auto w-full max-w-5xl px-4 py-8">
214 218
            <div class="flex flex-wrap items-center gap-3">
215 219
              <h1 class="text-2xl font-semibold text-foreground">{@pull_request.issue.title}</h1>
216
              <.badge variant={if(@pull_request.state == "open", do: :success, else: :secondary)}>
217
                {@pull_request.state}
220
              <.badge id="pull-request-state" variant={state_variant(layer_state(@pull_request))}>
221
                {layer_state(@pull_request)}
218 222
              </.badge>
219 223
            </div>
220 224
            <p class="mt-3 text-sm text-muted-foreground">

@@ -356,6 +360,29 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

356 360
                The diff is truncated.
357 361
              </p>
358 362
            </section>
363
364
            <section :if={@stack_history} id="stack-history" class="mt-8">
365
              <div class="flex flex-wrap items-center gap-3">
366
                <h2 class="text-lg font-semibold text-foreground">
367
                  Stack #{@stack_history.stack.number} · layer {@stack_history.position} of {@stack_history.size}
368
                </h2>
369
                <.badge variant={:done}>merged</.badge>
370
              </div>
371
372
              <.stack_map
373
                id="stack-history-map"
374
                number={@stack_history.stack.number}
375
                trunk={@stack_history.stack.trunk_ref}
376
                trunk_navigate={~p"/#{@owner}/#{@repo}/tree/#{@stack_history.stack.trunk_ref}"}
377
                layers={stack_map_layers(@stack_history, @owner, @repo)}
378
                class="mt-4 max-w-md"
379
              />
380
381
              <p id="stack-history-note" class="mt-3 text-sm text-muted-foreground">
382
                This pull request merged into {@stack_history.stack.trunk_ref} as part of
383
                the stack.
384
              </p>
385
            </section>
359 386
          </article>
360 387
        </.repo_view>
361 388
      </main>

@@ -363,6 +390,18 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

363 390
    """
364 391
  end
365 392
393
  defp merged_history(pull_request) do
394
    case Stacks.merged_context(pull_request) do
395
      {:ok, context} -> context
396
      {:error, :not_stacked} -> nil
397
    end
398
  end
399
400
  defp state_variant("merged"), do: :done
401
  defp state_variant("open"), do: :success
402
  defp state_variant("draft"), do: :dim
403
  defp state_variant(_closed), do: :danger
404
366 405
  defp short(sha), do: String.slice(sha, 0, 12)
367 406
368 407
  defp stack_map_layers(context, owner, repo) do
test/openagents/stacks/merge_test.exs modified +40

@@ -596,6 +596,46 @@ defmodule OpenAgents.Stacks.MergeTest do

596 596
    end
597 597
  end
598 598
599
  describe "the merged review context" do
600
    test "a merged pull request resolves its historical stack placement", context do
601
      %{repository: repository, actor: actor} = context
602
603
      %{stack: stack, pull_requests: [pr_1, pr_2]} = seed_stack(repository, actor)
604
605
      {:ok, {_operation, :created}} =
606
        Merge.request_from_api(
607
          repository,
608
          stack.number,
609
          %{"pull_request_number" => pr_2.issue.number, "merge_method" => "merge"},
610
          actor,
611
          "merged-context-1"
612
        )
613
614
      assert :processed = OperationWorker.run_once()
615
616
      for pr <- [pr_1, pr_2] do
617
        merged = Repo.one!(from p in PullRequest, where: p.id == ^pr.id)
618
        assert {:ok, merged_context} = Stacks.merged_context(merged)
619
        assert merged_context.stack.id == stack.id
620
        assert merged_context.size == 2
621
        assert merged_context.entry.pull_request_id == pr.id
622
623
        assert Enum.map(merged_context.stack.entries, & &1.position) == [1, 2]
624
625
        assert Enum.map(merged_context.stack.entries, & &1.pull_request.issue.number) ==
626
                 [pr_1.issue.number, pr_2.issue.number]
627
      end
628
    end
629
630
    test "an unmerged stacked pull request has no merged context", context do
631
      %{repository: repository, actor: actor} = context
632
633
      %{pull_requests: [pr_1, _pr_2]} = seed_stack(repository, actor)
634
635
      assert {:error, :not_stacked} = Stacks.merged_context(pr_1)
636
    end
637
  end
638
599 639
  ## Fixtures
600 640
601 641
  # main ── layer-1 ── layer-2 ── …, each layer adding one file.
test/openagents_web/live/pull_request_live_test.exs modified +92

@@ -9,6 +9,8 @@ defmodule OpenAgentsWeb.PullRequestLiveTest do

9 9
  alias OpenAgents.PullRequests.PullRequest
10 10
  alias OpenAgents.Repo
11 11
  alias OpenAgents.Stacks
12
  alias OpenAgents.Stacks.Stack
13
  alias OpenAgents.Stacks.StackEntry
12 14
13 15
  test "the pull request list links to a valid browser detail page", %{conn: conn} do
14 16
    target = repository_fixture()

@@ -195,6 +197,96 @@ defmodule OpenAgentsWeb.PullRequestLiveTest do

195 197
      assert render(show) =~ "The pull request left the stack."
196 198
      refute has_element?(show, "#stack-review")
197 199
    end
200
201
    test "a merged stack member keeps its stack map and shows the merged state", %{
202
      conn: conn,
203
      repository: repository,
204
      pull_requests: pull_requests
205
    } do
206
      merge_stack!(pull_requests)
207
      [bottom, top] = pull_requests
208
209
      {:ok, show, _html} = live(conn, pull_path(repository, bottom))
210
211
      assert element(show, "#pull-request-state") |> render() =~ "merged"
212
      refute has_element?(show, "#stack-review")
213
      assert element(show, "#stack-history") |> render() =~ "layer 1 of 2"
214
      assert element(show, "#stack-history-note") |> render() =~ "merged into main"
215
216
      layers = element(show, "#stack-history-map") |> render()
217
      assert layers =~ ~s(data-state="merged")
218
      refute layers =~ ~s(data-state="open")
219
220
      assert has_element?(
221
               show,
222
               "#stack-history-map a[href='#{pull_path(repository, top)}']"
223
             )
224
225
      refute has_element?(show, "#stack-rebase")
226
      refute has_element?(show, "#stack-unstack")
227
    end
228
229
    test "a pull request closed without merging shows closed and no stack history", %{
230
      conn: conn,
231
      repository: repository,
232
      pull_requests: pull_requests
233
    } do
234
      top = Enum.at(pull_requests, 1)
235
236
      {1, _rows} =
237
        Repo.update_all(
238
          from(entry in StackEntry, where: entry.pull_request_id == ^top.id),
239
          set: [removed_at: DateTime.utc_now()]
240
        )
241
242
      {1, _rows} =
243
        Repo.update_all(
244
          from(pr in PullRequest, where: pr.id == ^top.id),
245
          set: [state: "closed"]
246
        )
247
248
      {:ok, show, _html} = live(conn, pull_path(repository, top))
249
250
      assert element(show, "#pull-request-state") |> render() =~ "closed"
251
      refute has_element?(show, "#stack-review")
252
      refute has_element?(show, "#stack-history")
253
    end
254
255
    test "the pull request list shows the merged state", %{
256
      conn: conn,
257
      repository: repository,
258
      pull_requests: pull_requests
259
    } do
260
      merge_stack!(pull_requests)
261
262
      {:ok, index, _html} = live(conn, "/#{repository.owner}/#{repository.name}/pulls")
263
264
      assert has_element?(index, "span[data-variant='done']", "merged")
265
    end
266
  end
267
268
  # Marks every layer merged the way `Merge.execute/2` records it: the entry
269
  # removal and the pull request merge share one timestamp, and the stack
270
  # completes.
271
  defp merge_stack!(pull_requests) do
272
    now = DateTime.utc_now()
273
    ids = Enum.map(pull_requests, & &1.id)
274
275
    {_count, _rows} =
276
      Repo.update_all(
277
        from(entry in StackEntry, where: entry.pull_request_id in ^ids),
278
        set: [removed_at: now]
279
      )
280
281
    {_count, _rows} =
282
      Repo.update_all(
283
        from(pr in PullRequest, where: pr.id in ^ids),
284
        set: [state: "closed", merged_at: now]
285
      )
286
287
    {_count, _rows} = Repo.update_all(Stack, set: [state: "completed"])
288
289
    :ok
198 290
  end
199 291
200 292
  defp pull_path(repository, pull_request) do

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