Show layer diffs and cumulative previews for stacked pull requests

0470bf0dd035 · Devin AI · · parent 013e54dc8b0e

Show layer diffs and cumulative previews for stacked pull requests

A stacked pull request now presents its own layer as the primary review
diff — the stored boundary OID to the observed head OID — and offers an
explicit cumulative preview from the current trunk tip labeled as
everything through this position. When a parent rewrite makes the stored
boundary unreachable from the parent's current tip, the page explains
the stale boundary and offers a restack action instead of silently
rendering lower layers inside the layer diff.

- Browse.diff_range/3 computes a bounded two-commit unified diff
- Stacks.review_context/2 returns the layer range, cumulative range,
  and boundary state for a stacked pull request
- PullRequestShowLive renders both views behind repository read
  authorization with LiveView tests for each commit range and the
  stale-boundary state

Closes #49

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

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/forge/browse.ex
  • modified lib/openagents/stacks.ex
  • modified lib/openagents_web/live/pull_request_show_live.ex
  • modified test/openagents_web/live/pull_request_live_test.exs

Diff

4 files changed, +412 -2

lib/openagents/forge/browse.ex modified +12

@@ -212,6 +212,18 @@ defmodule OpenAgents.Forge.Browse do

212 212
    end
213 213
  end
214 214
215
  @doc "The unified diff between two commits, byte-capped with a truncation flag."
216
  def diff_range(repo, base, head) do
217
    with :ok <- check(repo, head),
218
         {:ok, base_full} <- resolve_commit(repo, base),
219
         {:ok, head_full} <- resolve_commit_from_cache(repo, head) do
220
      case git(repo, ["diff", "-M", "--no-color", "--end-of-options", base_full, head_full]) do
221
        {output, 0} -> {:ok, truncate(output, @diff_cap), byte_size(output) > @diff_cap}
222
        _ -> {:error, :not_found}
223
      end
224
    end
225
  end
226
215 227
  @doc "Directory listing at `ref`/`path` as `[%{name, kind, size}]`, bounded."
216 228
  def tree(repo, ref, path \\ "") do
217 229
    with :ok <- check(repo, ref),
lib/openagents/stacks.ex modified +63

@@ -10,6 +10,7 @@ defmodule OpenAgents.Stacks do

10 10
11 11
  alias OpenAgents.Accounts.User
12 12
  alias OpenAgents.Forge.Browse
13
  alias OpenAgents.Forge.GitPlane
13 14
  alias OpenAgents.PullRequests.PullRequest
14 15
  alias OpenAgents.Repo
15 16
  alias OpenAgents.Repositories

@@ -89,6 +90,68 @@ defmodule OpenAgents.Stacks do

89 90
    end
90 91
  end
91 92
93
  @doc """
94
  The review ranges for a stacked pull request.
95
96
  The layer diff runs from the entry's stored boundary OID to its observed
97
  head OID, so it holds only this layer's commits. The cumulative preview
98
  runs from the current trunk tip to the head and answers what the
99
  repository looks like once everything through this position lands. The
100
  boundary is `:stale` when the parent branch was rewritten so the stored
101
  boundary is no longer reachable from the parent's current tip; a stale
102
  layer diff would silently pull lower layers into view.
103
  """
104
  def review_context(%Repository{} = repository, %PullRequest{} = pull_request) do
105
    case active_entry_for_pull_request(pull_request) do
106
      nil ->
107
        {:error, :not_stacked}
108
109
      %StackEntry{} = entry ->
110
        stack =
111
          Stack
112
          |> Repo.get!(entry.stack_id)
113
          |> Repo.preload(entries: active_entries_query())
114
115
        entry = Enum.find(stack.entries, &(&1.id == entry.id))
116
        parent_ref = parent_ref(stack, entry)
117
118
        {:ok,
119
         %{
120
           stack: stack,
121
           entry: entry,
122
           position: entry.position,
123
           size: length(stack.entries),
124
           layer_range: {entry.boundary_oid, entry.observed_head_oid},
125
           cumulative_range: cumulative_range(repository, stack, entry),
126
           boundary_state: boundary_state(repository, entry, parent_ref)
127
         }}
128
    end
129
  end
130
131
  defp parent_ref(stack, %StackEntry{position: 1}), do: stack.trunk_ref
132
133
  defp parent_ref(stack, entry) do
134
    parent = Enum.find(stack.entries, &(&1.position == entry.position - 1))
135
    parent.pull_request.head_ref
136
  end
137
138
  defp cumulative_range(repository, stack, entry) do
139
    case Browse.resolve_commit(repository, stack.trunk_ref) do
140
      {:ok, trunk_tip} -> {trunk_tip, entry.observed_head_oid}
141
      _other -> nil
142
    end
143
  end
144
145
  defp boundary_state(repository, entry, parent_ref) do
146
    with {:ok, parent_tip} <- GitPlane.resolve_commit(repository.storage_key, parent_ref),
147
         {:ok, reachable} <-
148
           GitPlane.ancestor?(repository.storage_key, entry.boundary_oid, parent_tip) do
149
      if reachable, do: :intact, else: :stale
150
    else
151
      _other -> :unknown
152
    end
153
  end
154
92 155
  def get_by_number!(%Repository{id: repository_id}, number) when is_integer(number) do
93 156
    Stack
94 157
    |> Repo.get_by!(repository_id: repository_id, number: number)
lib/openagents_web/live/pull_request_show_live.ex modified +139 -2

@@ -1,23 +1,80 @@

1 1
defmodule OpenAgentsWeb.PullRequestShowLive do
2
  @moduledoc "Shows one repository pull request."
2
  @moduledoc """
3
  Shows one repository pull request.
4
5
  A stacked pull request presents its own layer as the primary review diff
6
  — the stored boundary OID to the observed head OID — and offers an
7
  explicit cumulative preview from the current trunk tip. When a parent
8
  rewrite makes the stored boundary unreachable, the page explains the
9
  stale boundary and offers a restack action instead of silently rendering
10
  lower layers inside the layer diff.
11
  """
3 12
  use OpenAgentsWeb, :live_view
4 13
14
  alias OpenAgents.Diff
15
  alias OpenAgents.Forge.Browse
5 16
  alias OpenAgents.PullRequests
6 17
  alias OpenAgents.Repositories
18
  alias OpenAgents.Stacks
19
  alias OpenAgentsWeb.RepositoryAccess
7 20
8 21
  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do
9 22
    repository = visible_repository!(owner, repo, socket.assigns.current_user)
10 23
    pull_request = PullRequests.get_by_number!(repository, String.to_integer(number))
11 24
25
    stack_context =
26
      case Stacks.review_context(repository, pull_request) do
27
        {:ok, context} -> context
28
        {:error, :not_stacked} -> nil
29
      end
30
12 31
    {:ok,
13 32
     socket
14 33
     |> assign(:current_scope, socket.assigns[:current_scope])
15 34
     |> assign(:owner, owner)
16 35
     |> assign(:repo, repo)
17 36
     |> assign(:repository, repository)
18
     |> assign(:pull_request, pull_request)}
37
     |> assign(:pull_request, pull_request)
38
     |> assign(:stack_context, stack_context)
39
     |> assign(
40
       :diff_readable,
41
       RepositoryAccess.full_source?(repository, socket.assigns.current_user)
42
     )}
43
  end
44
45
  def handle_params(params, _uri, socket) do
46
    view = if params["view"] == "cumulative", do: :cumulative, else: :layer
47
    {:noreply, socket |> assign(:stack_view, view) |> assign_stack_diff()}
48
  end
49
50
  defp assign_stack_diff(%{assigns: %{stack_context: nil}} = socket) do
51
    socket |> assign(:diff_files, []) |> assign(:diff_truncated, false)
52
  end
53
54
  defp assign_stack_diff(%{assigns: assigns} = socket) do
55
    range =
56
      case assigns.stack_view do
57
        :cumulative -> assigns.stack_context.cumulative_range
58
        :layer -> layer_range_if_intact(assigns.stack_context)
59
      end
60
61
    {diff, truncated} =
62
      with true <- assigns.diff_readable,
63
           {base, head} <- range,
64
           {:ok, diff, truncated} <- Browse.diff_range(assigns.repository, base, head) do
65
        {diff, truncated}
66
      else
67
        _unavailable -> {nil, false}
68
      end
69
70
    socket
71
    |> assign(:diff_files, Diff.parse(diff))
72
    |> assign(:diff_truncated, truncated)
19 73
  end
20 74
75
  defp layer_range_if_intact(%{boundary_state: :stale}), do: nil
76
  defp layer_range_if_intact(context), do: context.layer_range
77
21 78
  def render(assigns) do
22 79
    ~H"""
23 80
    <Layouts.app

@@ -57,6 +114,84 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

57 114
            <div class="mt-8 rounded-xl border border-border bg-card p-6 whitespace-pre-wrap text-foreground">
58 115
              {@pull_request.issue.body || "No description provided."}
59 116
            </div>
117
118
            <section :if={@stack_context} id="stack-review" class="mt-8">
119
              <div class="flex flex-wrap items-center gap-3">
120
                <h2 class="text-lg font-semibold text-foreground">
121
                  Stack #{@stack_context.stack.number} · layer {@stack_context.position} of {@stack_context.size}
122
                </h2>
123
                <.badge variant={
124
                  if(@stack_context.stack.health == "healthy", do: :success, else: :warning)
125
                }>
126
                  {@stack_context.stack.health}
127
                </.badge>
128
              </div>
129
130
              <nav class="mt-4 flex gap-2" aria-label="Stack diff views">
131
                <.button
132
                  id="stack-view-layer"
133
                  patch={~p"/#{@owner}/#{@repo}/pulls/#{@pull_request.issue.number}?view=layer"}
134
                  variant={if @stack_view == :layer, do: :primary, else: :outline}
135
                  size={:sm}
136
                >
137
                  Layer diff
138
                </.button>
139
                <.button
140
                  id="stack-view-cumulative"
141
                  patch={~p"/#{@owner}/#{@repo}/pulls/#{@pull_request.issue.number}?view=cumulative"}
142
                  variant={if @stack_view == :cumulative, do: :primary, else: :outline}
143
                  size={:sm}
144
                >
145
                  Cumulative preview
146
                </.button>
147
              </nav>
148
149
              <p
150
                :if={@stack_view == :layer}
151
                id="stack-layer-range"
152
                class="mt-3 text-sm text-muted-foreground"
153
              >
154
                This layer only: {short(elem(@stack_context.layer_range, 0))} → {short(
155
                  elem(@stack_context.layer_range, 1)
156
                )}.
157
              </p>
158
159
              <p
160
                :if={@stack_view == :cumulative and @stack_context.cumulative_range}
161
                id="stack-cumulative-range"
162
                class="mt-3 text-sm text-muted-foreground"
163
              >
164
                Everything through position {@stack_context.position}: {short(
165
                  elem(@stack_context.cumulative_range, 0)
166
                )} → {short(elem(@stack_context.cumulative_range, 1))}.
167
              </p>
168
169
              <.alert
170
                :if={@stack_view == :layer and @stack_context.boundary_state == :stale}
171
                id="stack-stale-boundary"
172
                variant={:warning}
173
                appearance={:notice}
174
                label="This layer is based on an outdated parent commit"
175
                class="mt-4"
176
              >
177
                A lower branch was rewritten, so the stored review boundary no longer
178
                matches the parent branch. Rebase the stack to restore the intended
179
                review boundary.
180
                <:action>
181
                  <.button id="stack-restack-action" variant={:outline} size={:sm} disabled>
182
                    Rebase the stack
183
                  </.button>
184
                </:action>
185
              </.alert>
186
187
              <div :if={@diff_files != []} class="mt-6 space-y-4">
188
                <.diff_file :for={file <- @diff_files} file={file} />
189
              </div>
190
191
              <p :if={@diff_truncated} class="mt-3 text-sm text-muted-foreground">
192
                The diff is truncated.
193
              </p>
194
            </section>
60 195
          </article>
61 196
        </.repo_view>
62 197
      </main>

@@ -64,6 +199,8 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

64 199
    """
65 200
  end
66 201
202
  defp short(sha), do: String.slice(sha, 0, 12)
203
67 204
  defp visible_repository!(owner, repo, user) do
68 205
    Repositories.get_visible_by_path!(owner, repo, user)
69 206
  rescue
test/openagents_web/live/pull_request_live_test.exs modified +198

@@ -1,10 +1,13 @@

1 1
defmodule OpenAgentsWeb.PullRequestLiveTest do
2 2
  use OpenAgentsWeb.ConnCase
3 3
  import Phoenix.LiveViewTest
4
  import OpenAgents.AccountsFixtures
4 5
  import OpenAgents.IssuesFixtures
5 6
7
  alias OpenAgents.Forge.Repos
6 8
  alias OpenAgents.PullRequests.PullRequest
7 9
  alias OpenAgents.Repo
10
  alias OpenAgents.Stacks
8 11
9 12
  test "the pull request list links to a valid browser detail page", %{conn: conn} do
10 13
    target = repository_fixture()

@@ -36,4 +39,199 @@ defmodule OpenAgentsWeb.PullRequestLiveTest do

36 39
37 40
    assert has_element?(show, "#pull-request-show")
38 41
  end
42
43
  describe "stacked pull request review" do
44
    setup do
45
      base =
46
        Path.join(
47
          System.tmp_dir!(),
48
          "pull-request-show-#{System.unique_integer([:positive])}"
49
        )
50
51
      previous_data = Application.get_env(:openagents, :forge_data_dir)
52
      previous_wal = Application.get_env(:openagents, :forge_wal_dir)
53
      Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
54
      Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
55
56
      on_exit(fn ->
57
        restore_env(:forge_data_dir, previous_data)
58
        restore_env(:forge_wal_dir, previous_wal)
59
        File.rm_rf(base)
60
      end)
61
62
      repository = repository_fixture()
63
      oids = seed_chain(repository, ["layer-1", "layer-2"])
64
65
      pull_requests = pull_request_chain(repository, oids, ["layer-1", "layer-2"])
66
      actor = repository_user_fixture("stack-reviewer")
67
      {:ok, _stack} = Stacks.create(repository, pull_requests, actor)
68
69
      %{repository: repository, oids: oids, pull_requests: pull_requests}
70
    end
71
72
    test "the layer diff runs from the boundary OID to the observed head OID", %{
73
      conn: conn,
74
      repository: repository,
75
      oids: oids,
76
      pull_requests: pull_requests
77
    } do
78
      top = Enum.at(pull_requests, 1)
79
      {:ok, show, html} = live(conn, pull_path(repository, top))
80
81
      assert has_element?(show, "#stack-review")
82
83
      assert element(show, "#stack-layer-range") |> render() =~
84
               "#{short(oids["layer-1"])} → #{short(oids["layer-2"])}"
85
86
      assert html =~ "layer-2.md"
87
      refute html =~ "layer-1.md"
88
      refute has_element?(show, "#stack-stale-boundary")
89
    end
90
91
    test "the cumulative preview runs from the trunk tip to the observed head OID", %{
92
      conn: conn,
93
      repository: repository,
94
      oids: oids,
95
      pull_requests: pull_requests
96
    } do
97
      top = Enum.at(pull_requests, 1)
98
      {:ok, show, html} = live(conn, pull_path(repository, top) <> "?view=cumulative")
99
100
      assert element(show, "#stack-cumulative-range") |> render() =~
101
               "Everything through position 2"
102
103
      assert element(show, "#stack-cumulative-range") |> render() =~
104
               "#{short(oids["main"])} → #{short(oids["layer-2"])}"
105
106
      assert html =~ "layer-1.md"
107
      assert html =~ "layer-2.md"
108
    end
109
110
    test "a parent rewrite shows the stale boundary state instead of lower layers", %{
111
      conn: conn,
112
      repository: repository,
113
      oids: oids,
114
      pull_requests: pull_requests
115
    } do
116
      path = Repos.bare_path(repository.storage_key)
117
118
      rewritten =
119
        commit(path, oids["main"], "Rewritten layer", ["README.md", "rewritten.md"])
120
121
      {_, 0} = Repos.git(path, ["update-ref", "refs/heads/layer-1", rewritten])
122
123
      top = Enum.at(pull_requests, 1)
124
      {:ok, show, html} = live(conn, pull_path(repository, top))
125
126
      assert has_element?(show, "#stack-stale-boundary")
127
      assert has_element?(show, "#stack-restack-action")
128
      assert render(show) =~ "based on an outdated parent commit"
129
      refute html =~ "layer-1.md"
130
      refute html =~ "layer-2.md"
131
    end
132
  end
133
134
  defp pull_path(repository, pull_request) do
135
    "/#{repository.owner}/#{repository.name}/pulls/#{pull_request.issue.number}"
136
  end
137
138
  defp short(oid), do: String.slice(oid, 0, 12)
139
140
  defp seed_chain(repository, branches) do
141
    path = Repos.ensure_repo!(repository.storage_key, repository.default_branch)
142
143
    main = commit(path, nil, "Seed repository", ["README.md"])
144
    {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", main])
145
146
    {oids, _files} =
147
      Enum.reduce(branches, {%{"main" => main}, ["README.md"]}, fn branch, {oids, files} ->
148
        parent = Map.fetch!(oids, previous_branch(branches, branch))
149
        files = files ++ ["#{branch}.md"]
150
        oid = commit(path, parent, "Layer #{branch}", files)
151
        {_, 0} = Repos.git(path, ["update-ref", "refs/heads/#{branch}", oid])
152
        {Map.put(oids, branch, oid), files}
153
      end)
154
155
    oids
156
  end
157
158
  defp previous_branch(branches, branch) do
159
    index = Enum.find_index(branches, &(&1 == branch))
160
    Enum.at(["main" | branches], index)
161
  end
162
163
  defp commit(path, parent, message, files) do
164
    tree_input =
165
      Enum.map_join(files, fn file ->
166
        blob = git!(path, ["hash-object", "-w", "--stdin"], "#{file}\n")
167
        "100644 blob #{blob}\t#{file}\n"
168
      end)
169
170
    tree = git!(path, ["mktree"], tree_input)
171
    parent_args = if parent, do: ["-p", parent], else: []
172
173
    git!(path, ["commit-tree", tree] ++ parent_args ++ ["-m", message], "",
174
      env: [
175
        {"GIT_AUTHOR_NAME", "Test Author"},
176
        {"GIT_AUTHOR_EMAIL", "author@example.test"},
177
        {"GIT_COMMITTER_NAME", "Test Author"},
178
        {"GIT_COMMITTER_EMAIL", "author@example.test"}
179
      ]
180
    )
181
  end
182
183
  defp pull_request_chain(repository, oids, branches) do
184
    branches
185
    |> Enum.with_index()
186
    |> Enum.map(fn {branch, index} ->
187
      base = Enum.at(["main" | branches], index)
188
      pull_request(repository, branch, base, oids[base], oids[branch])
189
    end)
190
  end
191
192
  defp pull_request(repository, head_ref, base_ref, base_sha, head_sha) do
193
    issue = issue_fixture(repository, %{title: "PR #{head_ref}"})
194
195
    {:ok, pull_request} =
196
      %PullRequest{}
197
      |> PullRequest.changeset(%{
198
        repository_id: repository.id,
199
        issue_id: issue.id,
200
        head_repository_id: repository.id,
201
        head_ref: head_ref,
202
        head_sha: head_sha,
203
        base_ref: base_ref,
204
        base_sha: base_sha,
205
        state: "open"
206
      })
207
      |> Repo.insert()
208
209
    Repo.preload(pull_request, :issue)
210
  end
211
212
  defp git!(git_dir, args, input, options \\ []) do
213
    input_path =
214
      Path.join(
215
        System.tmp_dir!(),
216
        "pull-request-show-input-#{System.unique_integer([:positive])}"
217
      )
218
219
    File.write!(input_path, input)
220
221
    try do
222
      {output, 0} =
223
        System.cmd(
224
          "sh",
225
          ["-c", ~s(exec git --git-dir "$GIT_DIR" "$@" < "$INPUT"), "sh"] ++ args,
226
          env: [{"GIT_DIR", git_dir}, {"INPUT", input_path}] ++ Keyword.get(options, :env, [])
227
        )
228
229
      String.trim(output)
230
    after
231
      File.rm(input_path)
232
    end
233
  end
234
235
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
236
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
39 237
end

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