Model stacks with durable entries and structural invariants

00b31e35b6cc · Devin AI · · parent b0ed63583300

Model stacks with durable entries and structural invariants

Add pull_request_stacks and pull_request_stack_entries tables so a
stack is a first-class server-side object rather than inferred branch
topology. Stack rows carry repository scope, a repository-local
number, trunk ref, lifecycle state (open, completed, dissolved), an
independent health state, and an optimistic version. Entry rows carry
one-based position, boundary OID, observed head OID, and soft
removal; partial unique indexes keep active positions unique and one
pull request in at most one active stack.

OIDs store as raw bytes through OpenAgents.Stacks.OID, which accepts
SHA-1 and SHA-256 object names.

OpenAgents.Stacks.create/3 validates structural invariants before
insert: nonempty membership, same-repository pull requests and heads,
open pull requests only, no duplicate pull requests or branches, an
intact direct-base chain, and no pull request already in an active
stack. Generic base edits on an actively stacked pull request fail
with stack_managed_base and route through the stack service instead.

Health updates never change lifecycle state, so a stale graph never
dissolves a stack. Completing or dissolving a stack requires the
current version and increments it; stale writes fail with
stale_stack_version.

Record the contract as STACK-001 in INVARIANTS.md with an executable
proof at ops/ci/stack-contracts.sh, and register the migration in the
prior-2026-08-19 lineage map.

Closes #47

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

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 INVARIANTS.md
  • modified lib/openagents/pull_requests.ex
  • added lib/openagents/stacks.ex
  • added lib/openagents/stacks/oid.ex
  • added lib/openagents/stacks/stack.ex
  • added lib/openagents/stacks/stack_entry.ex
  • added ops/ci/stack-contracts.sh
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823051500_create_pull_request_stacks.exs
  • added test/openagents/stacks_test.exs

Diff

10 files changed, +885 -3

INVARIANTS.md modified +35

@@ -1948,6 +1948,40 @@ Evidence: `ops/ci/push-remote-check.sh`, `ops/dev/install-push-guard.sh`,

1948 1948
`OpenAgents.Forge.Pushes`, `OpenAgents.Forge.MirrorWatch`, and
1949 1949
`test/openagents/push_remote_contract_test.exs`.
1950 1950
1951
### STACK-001 — A pull request stack is a durable object, not inferred topology
1952
1953
Status: Current
1954
1955
A stack of pull requests exists as a row, not as a reading of branch bases.
1956
Branch topology alone is ambiguous: a branch can be based on another without
1957
intending a stack, a retarget can be accidental, and closed pull requests
1958
blur any inferred chain. `pull_request_stacks` carries the identity — a
1959
repository-local number, the trunk ref, an `open`/`completed`/`dissolved`
1960
state, and an optimistic `version` — and `pull_request_stack_entries` carries
1961
the order: contiguous positions from 1, the boundary object ID that marks
1962
where each layer's unique commits begin, and the observed head. Object IDs
1963
store as raw bytes so SHA-1 and SHA-256 repositories both fit; nothing
1964
assumes a 40-character column.
1965
1966
Structure is validated, not trusted. Creation requires same-repository
1967
membership, same-repository heads, open pull requests, unique entries, unique
1968
branches, and an unbroken direct-base chain from the trunk upward. A partial
1969
unique index keeps a pull request in at most one active stack, and a second
1970
partial index keeps active positions unique per stack. While a pull request
1971
is stacked, a generic base edit fails with `stack_managed_base`; the base
1972
belongs to the stack service.
1973
1974
Health is an observation and state is a lifecycle, and the two never merge.
1975
A stack whose graph has gone stale reads `needs_rebase`, `conflicted`,
1976
`missing_ref`, `head_changed`, `policy_blocked`, or `operation_in_progress`
1977
while its state stays `open`. A stale graph never dissolves a stack; only an
1978
explicit transition does, and that transition bumps the version so concurrent
1979
operations see the change.
1980
1981
Evidence: `OpenAgents.Stacks`, `OpenAgents.Stacks.Stack`,
1982
`OpenAgents.Stacks.StackEntry`, `OpenAgents.Stacks.OID`,
1983
`ops/ci/stack-contracts.sh`, and `test/openagents/stacks_test.exs`.
1984
1951 1985
## Executable proof index
1952 1986
1953 1987
This index is part of the ledger. Every `Current` invariant has at least one

@@ -2033,3 +2067,4 @@ contract; the invariant prose above defines the assertion, not the filename.

2033 2067
| TRANSPARENCY-001 | `test/openagents/forge/visibility_test.exs`, `test/openagents/forge/browse_test.exs`, `test/openagents_web/live/code_live_test.exs` |
2034 2068
| REPOSITORY-001 | `test/openagents/repository_lifecycle_test.exs`, `test/openagents/repositories/provisioner_test.exs`, `test/openagents_web/controllers/repository_controller_test.exs`, `test/openagents/issues_workspace_test.exs`, `test/openagents_web/live/issue_workspace_live_test.exs`, `test/openagents_web/live/project_workspace_live_test.exs`, `test/openagents/forge/git_http_test.exs` |
2035 2069
| REPOSITORY-002 | `ops/ci/push-remote-check.sh`, `ops/dev/install-push-guard.sh`, `test/openagents/push_remote_contract_test.exs` |
2070
| STACK-001 | `ops/ci/stack-contracts.sh`, `test/openagents/stacks_test.exs` |
lib/openagents/pull_requests.ex modified +24 -2

@@ -11,6 +11,7 @@ defmodule OpenAgents.PullRequests do

11 11
  alias OpenAgents.Repositories
12 12
  alias OpenAgents.Repositories.Repository
13 13
  alias OpenAgents.Repositories.RepositoryPublication
14
  alias OpenAgents.Stacks
14 15
  alias OpenAgents.Tools.Redaction
15 16
16 17
  def list(%Repository{id: id}) do

@@ -59,7 +60,8 @@ defmodule OpenAgents.PullRequests do

59 60
60 61
    if pr.issue.author_user_id == actor.id or role in ~w(owner maintainer) do
61 62
      Repo.transaction(fn ->
62
        with {:ok, issue} <-
63
        with {:ok, base_attrs} <- base_update_attrs(pr, attrs),
64
             {:ok, issue} <-
63 65
               Issues.update_issue(
64 66
                 pr.issue,
65 67
                 Map.take(attrs, ["title", "body", "state"]),

@@ -67,7 +69,9 @@ defmodule OpenAgents.PullRequests do

67 69
               ),
68 70
             {:ok, updated} <-
69 71
               pr
70
               |> PullRequest.changeset(pull_request_update_attrs(attrs, issue.state))
72
               |> PullRequest.changeset(
73
                 Map.merge(pull_request_update_attrs(attrs, issue.state), base_attrs)
74
               )
71 75
               |> Repo.update() do
72 76
          %{updated | issue: issue}
73 77
        else

@@ -79,6 +83,24 @@ defmodule OpenAgents.PullRequests do

79 83
    end
80 84
  end
81 85
86
  # While a pull request is an active stack member its base belongs to the
87
  # stack service, so a generic base edit fails with an explanation.
88
  defp base_update_attrs(pr, attrs) do
89
    case Map.get(attrs, "base") do
90
      nil ->
91
        {:ok, %{}}
92
93
      base_ref when is_binary(base_ref) and base_ref != "" ->
94
        with :ok <- Stacks.ensure_base_editable(pr),
95
             {:ok, base_sha} <- resolve(pr.repository, base_ref) do
96
          {:ok, %{base_ref: base_ref, base_sha: base_sha}}
97
        end
98
99
      _other ->
100
        {:error, :invalid_ref}
101
    end
102
  end
103
82 104
  @doc "Opens or refreshes the draft pull request for an accepted Forge publication."
83 105
  def open_from_publication(%RepositoryPublication{} = publication, attrs, %User{} = actor) do
84 106
    publication = Repo.preload(publication, :repository)
lib/openagents/stacks.ex added +247

@@ -0,0 +1,247 @@

1
defmodule OpenAgents.Stacks do
2
  @moduledoc """
3
  Durable pull request stacks.
4
5
  A stack is a first-class server-side object: ordered pull request entries
6
  with stored commit boundaries, structural validation, and health states.
7
  Branch topology alone stays ambiguous; the stack row is the identity.
8
  """
9
  import Ecto.Query, warn: false
10
11
  alias OpenAgents.Accounts.User
12
  alias OpenAgents.PullRequests.PullRequest
13
  alias OpenAgents.Repo
14
  alias OpenAgents.Repositories.Repository
15
  alias OpenAgents.Stacks.Stack
16
  alias OpenAgents.Stacks.StackEntry
17
18
  @doc """
19
  Creates a stack from pull requests ordered bottom to top.
20
21
  The bottom pull request's base branch becomes the stack trunk. Every later
22
  pull request must target the preceding pull request's head branch. All pull
23
  requests must be open, belong to `repository`, carry same-repository heads,
24
  and not already belong to an active stack.
25
  """
26
  def create(%Repository{} = repository, pull_requests, %User{} = actor)
27
      when is_list(pull_requests) do
28
    with :ok <- validate_structure(repository, pull_requests) do
29
      insert_with_number(repository, pull_requests, actor, 20)
30
    end
31
  end
32
33
  def get_by_number!(%Repository{id: repository_id}, number) when is_integer(number) do
34
    Stack
35
    |> Repo.get_by!(repository_id: repository_id, number: number)
36
    |> Repo.preload(entries: active_entries_query())
37
  end
38
39
  @doc "Returns the active entries of a stack ordered by position."
40
  def active_entries(%Stack{id: stack_id}) do
41
    Repo.all(from entry in active_entries_query(), where: entry.stack_id == ^stack_id)
42
  end
43
44
  @doc "Returns the active stack entry for a pull request, or `nil`."
45
  def active_entry_for_pull_request(%PullRequest{id: pull_request_id}) do
46
    Repo.one(
47
      from entry in StackEntry,
48
        where: entry.pull_request_id == ^pull_request_id and is_nil(entry.removed_at)
49
    )
50
  end
51
52
  @doc """
53
  Confirms a generic base edit may touch a pull request.
54
55
  While a pull request is an active stack member, its base branch belongs to
56
  the stack service; a generic edit fails with `:stack_managed_base`.
57
  """
58
  def ensure_base_editable(%PullRequest{} = pull_request) do
59
    case active_entry_for_pull_request(pull_request) do
60
      nil -> :ok
61
      %StackEntry{} -> {:error, :stack_managed_base}
62
    end
63
  end
64
65
  @doc """
66
  Records an observed health state without changing the stack state.
67
68
  Health describes the current Git graph; state describes the stack's
69
  lifecycle. A stale graph never dissolves a stack.
70
  """
71
  def set_health(%Stack{} = stack, health) do
72
    stack
73
    |> Stack.changeset(%{health: health})
74
    |> Repo.update()
75
  end
76
77
  @doc "Marks an open stack completed, expecting the caller's version."
78
  def complete(%Stack{} = stack), do: transition(stack, "completed")
79
80
  @doc "Marks an open stack dissolved, expecting the caller's version."
81
  def dissolve(%Stack{} = stack), do: transition(stack, "dissolved")
82
83
  defp transition(%Stack{id: id, version: version, state: "open"}, state) do
84
    now = DateTime.utc_now()
85
86
    {count, updated} =
87
      Repo.update_all(
88
        from(stack in Stack,
89
          where: stack.id == ^id and stack.version == ^version and stack.state == "open",
90
          select: stack
91
        ),
92
        set: [state: state, version: version + 1, updated_at: now]
93
      )
94
95
    case {count, updated} do
96
      {1, [stack]} -> {:ok, stack}
97
      {0, _} -> {:error, :stale_stack_version}
98
    end
99
  end
100
101
  defp transition(%Stack{}, _state), do: {:error, :stack_not_open}
102
103
  defp active_entries_query do
104
    from entry in StackEntry,
105
      where: is_nil(entry.removed_at),
106
      order_by: [asc: entry.position],
107
      preload: [pull_request: :issue]
108
  end
109
110
  defp validate_structure(_repository, []), do: {:error, :empty_stack}
111
112
  defp validate_structure(%Repository{id: repository_id}, pull_requests) do
113
    cond do
114
      Enum.any?(pull_requests, &(&1.repository_id != repository_id)) ->
115
        {:error, :repository_mismatch}
116
117
      Enum.any?(pull_requests, &(&1.head_repository_id != repository_id)) ->
118
        {:error, :cross_repository_head}
119
120
      Enum.any?(pull_requests, &(&1.state != "open")) ->
121
        {:error, :pull_request_not_open}
122
123
      duplicate?(Enum.map(pull_requests, & &1.id)) ->
124
        {:error, :duplicate_pull_request}
125
126
      duplicate?(branches(pull_requests)) ->
127
        {:error, :duplicate_branch}
128
129
      not chained?(pull_requests) ->
130
        {:error, :broken_base_chain}
131
132
      stacked_already?(pull_requests) ->
133
        {:error, :already_stacked}
134
135
      true ->
136
        :ok
137
    end
138
  end
139
140
  defp branches([bottom | _rest] = pull_requests) do
141
    [bottom.base_ref | Enum.map(pull_requests, & &1.head_ref)]
142
  end
143
144
  defp duplicate?(values), do: Enum.uniq(values) != values
145
146
  defp chained?(pull_requests) do
147
    pull_requests
148
    |> Enum.chunk_every(2, 1, :discard)
149
    |> Enum.all?(fn [lower, upper] -> upper.base_ref == lower.head_ref end)
150
  end
151
152
  defp stacked_already?(pull_requests) do
153
    ids = Enum.map(pull_requests, & &1.id)
154
155
    Repo.exists?(
156
      from entry in StackEntry,
157
        where: entry.pull_request_id in ^ids and is_nil(entry.removed_at)
158
    )
159
  end
160
161
  defp insert_with_number(_repository, _pull_requests, _actor, 0), do: {:error, :number_conflict}
162
163
  defp insert_with_number(repository, pull_requests, actor, attempts_remaining) do
164
    [bottom | _rest] = pull_requests
165
166
    result =
167
      Repo.transaction(fn ->
168
        number = next_number(repository.id)
169
170
        with {:ok, stack} <-
171
               %Stack{}
172
               |> Stack.changeset(%{
173
                 repository_id: repository.id,
174
                 created_by_user_id: actor.id,
175
                 number: number,
176
                 trunk_ref: bottom.base_ref
177
               })
178
               |> Repo.insert(),
179
             {:ok, entries} <- insert_entries(stack, pull_requests) do
180
          %{stack | entries: entries}
181
        else
182
          {:error, changeset} -> Repo.rollback(changeset)
183
        end
184
      end)
185
186
    case result do
187
      {:error, changeset} when attempts_remaining > 1 ->
188
        if number_conflict?(changeset) do
189
          insert_with_number(repository, pull_requests, actor, attempts_remaining - 1)
190
        else
191
          {:error, changeset}
192
        end
193
194
      other ->
195
        other
196
    end
197
  end
198
199
  defp insert_entries(stack, pull_requests) do
200
    pull_requests
201
    |> Enum.with_index(1)
202
    |> Enum.reduce_while({:ok, []}, fn {pull_request, position}, {:ok, entries} ->
203
      boundary =
204
        case entries do
205
          [] -> pull_request.base_sha
206
          [previous_entry | _] -> previous_entry.observed_head_oid
207
        end
208
209
      case %StackEntry{}
210
           |> StackEntry.changeset(%{
211
             stack_id: stack.id,
212
             pull_request_id: pull_request.id,
213
             position: position,
214
             boundary_oid: boundary,
215
             observed_head_oid: pull_request.head_sha
216
           })
217
           |> Repo.insert() do
218
        {:ok, entry} -> {:cont, {:ok, [entry | entries]}}
219
        {:error, changeset} -> {:halt, {:error, changeset}}
220
      end
221
    end)
222
    |> case do
223
      {:ok, entries} -> {:ok, Enum.reverse(entries)}
224
      {:error, changeset} -> {:error, changeset}
225
    end
226
  end
227
228
  defp next_number(repository_id) do
229
    max =
230
      Repo.one(
231
        from stack in Stack,
232
          where: stack.repository_id == ^repository_id,
233
          select: max(stack.number)
234
      )
235
236
    (max || 0) + 1
237
  end
238
239
  defp number_conflict?(%Ecto.Changeset{errors: errors}) do
240
    Enum.any?(errors, fn {_field, {_message, meta}} ->
241
      meta[:constraint] == :unique and
242
        meta[:constraint_name] == "pull_request_stacks_repository_id_number_index"
243
    end)
244
  end
245
246
  defp number_conflict?(_other), do: false
247
end
lib/openagents/stacks/oid.ex added +39

@@ -0,0 +1,39 @@

1
defmodule OpenAgents.Stacks.OID do
2
  @moduledoc """
3
  Git object ID stored as raw bytes.
4
5
  Casts lowercase hex strings for SHA-1 (40 characters) and SHA-256
6
  (64 characters) object names, stores the decoded 20-byte or 32-byte
7
  binary, and loads back to the hex string.
8
  """
9
  use Ecto.Type
10
11
  @impl true
12
  def type, do: :binary
13
14
  @impl true
15
  def cast(hex) when is_binary(hex) and byte_size(hex) in [40, 64] do
16
    case Base.decode16(hex, case: :lower) do
17
      {:ok, raw} -> {:ok, hex_from_raw(raw)}
18
      :error -> :error
19
    end
20
  end
21
22
  def cast(_other), do: :error
23
24
  @impl true
25
  def dump(hex) when is_binary(hex) and byte_size(hex) in [40, 64] do
26
    Base.decode16(hex, case: :lower)
27
  end
28
29
  def dump(_other), do: :error
30
31
  @impl true
32
  def load(raw) when is_binary(raw) and byte_size(raw) in [20, 32] do
33
    {:ok, hex_from_raw(raw)}
34
  end
35
36
  def load(_other), do: :error
37
38
  defp hex_from_raw(raw), do: Base.encode16(raw, case: :lower)
39
end
lib/openagents/stacks/stack.ex added +56

@@ -0,0 +1,56 @@

1
defmodule OpenAgents.Stacks.Stack do
2
  @moduledoc false
3
  use Ecto.Schema
4
  import Ecto.Changeset
5
6
  @primary_key {:id, :binary_id, autogenerate: true}
7
  @foreign_key_type :binary_id
8
9
  @states ~w(open completed dissolved)
10
  @healths ~w(healthy needs_rebase conflicted missing_ref head_changed policy_blocked operation_in_progress)
11
12
  schema "pull_request_stacks" do
13
    belongs_to :repository, OpenAgents.Repositories.Repository
14
    field :number, :integer
15
    field :trunk_ref, :string
16
    field :state, :string, default: "open"
17
    field :health, :string, default: "healthy"
18
    field :version, :integer, default: 1
19
    belongs_to :created_by_user, OpenAgents.Accounts.User
20
21
    has_many :entries, OpenAgents.Stacks.StackEntry,
22
      foreign_key: :stack_id,
23
      preload_order: [asc: :position]
24
25
    timestamps(type: :utc_datetime_usec)
26
  end
27
28
  def states, do: @states
29
  def healths, do: @healths
30
31
  def changeset(stack, attrs) do
32
    stack
33
    |> cast(attrs, [:number, :trunk_ref, :state, :health, :version])
34
    |> put_programmatic_change(attrs, :repository_id)
35
    |> put_programmatic_change(attrs, :created_by_user_id)
36
    |> validate_required([:repository_id, :number, :trunk_ref, :state, :health, :version])
37
    |> validate_length(:trunk_ref, min: 1, max: 255)
38
    |> validate_number(:number, greater_than_or_equal_to: 1)
39
    |> validate_number(:version, greater_than_or_equal_to: 1)
40
    |> validate_inclusion(:state, @states)
41
    |> validate_inclusion(:health, @healths)
42
    |> unique_constraint([:repository_id, :number])
43
    |> check_constraint(:state, name: :pull_request_stacks_state_check)
44
    |> check_constraint(:health, name: :pull_request_stacks_health_check)
45
    |> check_constraint(:version, name: :pull_request_stacks_version_check)
46
    |> check_constraint(:number, name: :pull_request_stacks_number_check)
47
    |> foreign_key_constraint(:repository_id)
48
  end
49
50
  defp put_programmatic_change(changeset, attrs, field) do
51
    case Map.fetch(attrs, field) do
52
      {:ok, value} -> put_change(changeset, field, value)
53
      :error -> changeset
54
    end
55
  end
56
end
lib/openagents/stacks/stack_entry.ex added +49

@@ -0,0 +1,49 @@

1
defmodule OpenAgents.Stacks.StackEntry do
2
  @moduledoc false
3
  use Ecto.Schema
4
  import Ecto.Changeset
5
6
  @primary_key {:id, :binary_id, autogenerate: true}
7
  @foreign_key_type :binary_id
8
9
  schema "pull_request_stack_entries" do
10
    belongs_to :stack, OpenAgents.Stacks.Stack
11
    belongs_to :pull_request, OpenAgents.PullRequests.PullRequest
12
    field :position, :integer
13
    field :boundary_oid, OpenAgents.Stacks.OID
14
    field :observed_head_oid, OpenAgents.Stacks.OID
15
    field :removed_at, :utc_datetime_usec
16
    timestamps(type: :utc_datetime_usec)
17
  end
18
19
  def changeset(entry, attrs) do
20
    entry
21
    |> cast(attrs, [:position, :boundary_oid, :observed_head_oid, :removed_at])
22
    |> put_programmatic_change(attrs, :stack_id)
23
    |> put_programmatic_change(attrs, :pull_request_id)
24
    |> validate_required([
25
      :stack_id,
26
      :pull_request_id,
27
      :position,
28
      :boundary_oid,
29
      :observed_head_oid
30
    ])
31
    |> validate_number(:position, greater_than_or_equal_to: 1)
32
    |> unique_constraint([:stack_id, :position],
33
      name: :pull_request_stack_entries_active_position_index
34
    )
35
    |> unique_constraint(:pull_request_id,
36
      name: :pull_request_stack_entries_active_pull_request_index
37
    )
38
    |> check_constraint(:position, name: :pull_request_stack_entries_position_check)
39
    |> foreign_key_constraint(:stack_id)
40
    |> foreign_key_constraint(:pull_request_id)
41
  end
42
43
  defp put_programmatic_change(changeset, attrs, field) do
44
    case Map.fetch(attrs, field) do
45
      {:ok, value} -> put_change(changeset, field, value)
46
      :error -> changeset
47
    end
48
  end
49
end
ops/ci/stack-contracts.sh added +10

@@ -0,0 +1,10 @@

1
#!/bin/sh
2
set -eu
3
4
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd)
6
7
cd "$repo_root"
8
9
MIX_ENV=test mix test --warnings-as-errors \
10
  test/openagents/stacks_test.exs
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -239,7 +239,8 @@

239 239
    20260823040635,
240 240
    20260823042207,
241 241
    20260823043000,
242
    20260823050000
242
    20260823050000,
243
    20260823051500
243 244
  ],
244 245
  "required_tables": [
245 246
    "users",
priv/repo/migrations/20260823051500_create_pull_request_stacks.exs added +85

@@ -0,0 +1,85 @@

1
defmodule OpenAgents.Repo.Migrations.CreatePullRequestStacks do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:pull_request_stacks, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
8
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
9
        null: false
10
11
      add :number, :bigint, null: false
12
      add :trunk_ref, :string, null: false
13
      add :state, :string, null: false, default: "open"
14
      add :health, :string, null: false, default: "healthy"
15
      add :version, :bigint, null: false, default: 1
16
      add :created_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
17
      timestamps(type: :utc_datetime_usec)
18
    end
19
20
    create unique_index(:pull_request_stacks, [:repository_id, :number])
21
22
    create constraint(:pull_request_stacks, :pull_request_stacks_state_check,
23
             check: "state IN ('open', 'completed', 'dissolved')"
24
           )
25
26
    create constraint(:pull_request_stacks, :pull_request_stacks_health_check,
27
             check:
28
               "health IN ('healthy', 'needs_rebase', 'conflicted', 'missing_ref', 'head_changed', 'policy_blocked', 'operation_in_progress')"
29
           )
30
31
    create constraint(:pull_request_stacks, :pull_request_stacks_version_check,
32
             check: "version >= 1"
33
           )
34
35
    create constraint(:pull_request_stacks, :pull_request_stacks_number_check,
36
             check: "number >= 1"
37
           )
38
39
    create table(:pull_request_stack_entries, primary_key: false) do
40
      add :id, :binary_id, primary_key: true
41
42
      add :stack_id,
43
          references(:pull_request_stacks, type: :binary_id, on_delete: :delete_all),
44
          null: false
45
46
      add :pull_request_id, references(:pull_requests, type: :binary_id, on_delete: :restrict),
47
        null: false
48
49
      add :position, :integer, null: false
50
      add :boundary_oid, :bytea, null: false
51
      add :observed_head_oid, :bytea, null: false
52
      add :removed_at, :utc_datetime_usec
53
      timestamps(type: :utc_datetime_usec)
54
    end
55
56
    create index(:pull_request_stack_entries, [:stack_id])
57
    create index(:pull_request_stack_entries, [:pull_request_id])
58
59
    create unique_index(:pull_request_stack_entries, [:stack_id, :position],
60
             where: "removed_at IS NULL",
61
             name: :pull_request_stack_entries_active_position_index
62
           )
63
64
    create unique_index(:pull_request_stack_entries, [:pull_request_id],
65
             where: "removed_at IS NULL",
66
             name: :pull_request_stack_entries_active_pull_request_index
67
           )
68
69
    create constraint(:pull_request_stack_entries, :pull_request_stack_entries_position_check,
70
             check: "position >= 1"
71
           )
72
73
    create constraint(
74
             :pull_request_stack_entries,
75
             :pull_request_stack_entries_boundary_oid_check,
76
             check: "octet_length(boundary_oid) IN (20, 32)"
77
           )
78
79
    create constraint(
80
             :pull_request_stack_entries,
81
             :pull_request_stack_entries_observed_head_oid_check,
82
             check: "octet_length(observed_head_oid) IN (20, 32)"
83
           )
84
  end
85
end
test/openagents/stacks_test.exs added +338

@@ -0,0 +1,338 @@

1
defmodule OpenAgents.StacksTest do
2
  use OpenAgents.DataCase
3
4
  alias OpenAgents.PullRequests
5
  alias OpenAgents.PullRequests.PullRequest
6
  alias OpenAgents.Repo
7
  alias OpenAgents.Stacks
8
  alias OpenAgents.Stacks.Stack
9
  alias OpenAgents.Stacks.StackEntry
10
11
  import OpenAgents.AccountsFixtures
12
  import OpenAgents.IssuesFixtures
13
14
  defp sha(character), do: String.duplicate(character, 40)
15
16
  defp pull_request(repository, head_ref, base_ref, attrs \\ %{}) do
17
    issue = issue_fixture(repository, %{title: "PR #{head_ref}"})
18
19
    defaults = %{
20
      repository_id: repository.id,
21
      issue_id: issue.id,
22
      head_repository_id: repository.id,
23
      head_ref: head_ref,
24
      head_sha: sha_for(head_ref),
25
      base_ref: base_ref,
26
      base_sha: sha_for(base_ref),
27
      state: "open"
28
    }
29
30
    {:ok, pull_request} =
31
      %PullRequest{}
32
      |> PullRequest.changeset(Map.merge(defaults, attrs))
33
      |> Repo.insert()
34
35
    pull_request
36
  end
37
38
  defp sha_for(ref) do
39
    :sha
40
    |> :crypto.hash(ref)
41
    |> Base.encode16(case: :lower)
42
  end
43
44
  defp chain(repository, refs) do
45
    refs
46
    |> Enum.zip(["main" | refs])
47
    |> Enum.map(fn {head, base} -> pull_request(repository, head, base) end)
48
  end
49
50
  describe "create/3" do
51
    test "creates a stack with contiguous entries and stored boundaries" do
52
      user = repository_user_fixture("stack-author")
53
      repository = repository_fixture()
54
      [bottom, middle, top] = chain(repository, ["layer-1", "layer-2", "layer-3"])
55
56
      assert {:ok, %Stack{} = stack} = Stacks.create(repository, [bottom, middle, top], user)
57
      assert stack.number == 1
58
      assert stack.trunk_ref == "main"
59
      assert stack.state == "open"
60
      assert stack.health == "healthy"
61
      assert stack.version == 1
62
63
      [entry_1, entry_2, entry_3] = stack.entries
64
      assert Enum.map(stack.entries, & &1.position) == [1, 2, 3]
65
      assert entry_1.boundary_oid == bottom.base_sha
66
      assert entry_2.boundary_oid == bottom.head_sha
67
      assert entry_3.boundary_oid == middle.head_sha
68
      assert entry_1.observed_head_oid == bottom.head_sha
69
      assert entry_2.observed_head_oid == middle.head_sha
70
      assert entry_3.observed_head_oid == top.head_sha
71
    end
72
73
    test "numbers are repository-local" do
74
      user = repository_user_fixture("stack-author")
75
      first_repository = repository_fixture()
76
      second_repository = repository_fixture()
77
78
      [first_bottom] = chain(first_repository, ["layer-1"])
79
      [second_bottom] = chain(second_repository, ["layer-1"])
80
81
      assert {:ok, %Stack{number: 1}} = Stacks.create(first_repository, [first_bottom], user)
82
      assert {:ok, %Stack{number: 1}} = Stacks.create(second_repository, [second_bottom], user)
83
84
      [next] = chain(first_repository, ["layer-2b"])
85
      next = %{next | base_ref: "main", base_sha: sha_for("main")}
86
      assert {:ok, %Stack{number: 2}} = Stacks.create(first_repository, [next], user)
87
    end
88
89
    test "rejects an empty stack" do
90
      user = repository_user_fixture("stack-author")
91
      repository = repository_fixture()
92
93
      assert {:error, :empty_stack} = Stacks.create(repository, [], user)
94
    end
95
96
    test "rejects pull requests from another repository" do
97
      user = repository_user_fixture("stack-author")
98
      repository = repository_fixture()
99
      other_repository = repository_fixture()
100
      [foreign] = chain(other_repository, ["layer-1"])
101
102
      assert {:error, :repository_mismatch} = Stacks.create(repository, [foreign], user)
103
    end
104
105
    test "rejects cross-repository heads" do
106
      user = repository_user_fixture("stack-author")
107
      repository = repository_fixture()
108
      fork = repository_fixture()
109
110
      forked =
111
        pull_request(repository, "layer-1", "main", %{head_repository_id: fork.id})
112
113
      assert {:error, :cross_repository_head} = Stacks.create(repository, [forked], user)
114
    end
115
116
    test "rejects closed pull requests" do
117
      user = repository_user_fixture("stack-author")
118
      repository = repository_fixture()
119
      closed = pull_request(repository, "layer-1", "main", %{state: "closed"})
120
121
      assert {:error, :pull_request_not_open} = Stacks.create(repository, [closed], user)
122
    end
123
124
    test "rejects duplicate pull requests" do
125
      user = repository_user_fixture("stack-author")
126
      repository = repository_fixture()
127
      [bottom] = chain(repository, ["layer-1"])
128
129
      assert {:error, :duplicate_pull_request} =
130
               Stacks.create(repository, [bottom, bottom], user)
131
    end
132
133
    test "rejects duplicate branches" do
134
      user = repository_user_fixture("stack-author")
135
      repository = repository_fixture()
136
      bottom = pull_request(repository, "layer-1", "main")
137
      loop = pull_request(repository, "main", "layer-1")
138
139
      assert {:error, :duplicate_branch} = Stacks.create(repository, [bottom, loop], user)
140
    end
141
142
    test "rejects a broken direct-base chain" do
143
      user = repository_user_fixture("stack-author")
144
      repository = repository_fixture()
145
      bottom = pull_request(repository, "layer-1", "main")
146
      detached = pull_request(repository, "layer-2", "main")
147
148
      assert {:error, :broken_base_chain} = Stacks.create(repository, [bottom, detached], user)
149
    end
150
151
    test "rejects a pull request that is already in an active stack" do
152
      user = repository_user_fixture("stack-author")
153
      repository = repository_fixture()
154
      [bottom] = chain(repository, ["layer-1"])
155
156
      assert {:ok, _stack} = Stacks.create(repository, [bottom], user)
157
      assert {:error, :already_stacked} = Stacks.create(repository, [bottom], user)
158
    end
159
  end
160
161
  describe "entry constraints" do
162
    test "one pull request belongs to at most one active stack" do
163
      user = repository_user_fixture("stack-author")
164
      repository = repository_fixture()
165
      [bottom] = chain(repository, ["layer-1"])
166
      other = pull_request(repository, "layer-x", "main")
167
168
      assert {:ok, stack} = Stacks.create(repository, [bottom], user)
169
      assert {:ok, other_stack} = Stacks.create(repository, [other], user)
170
171
      assert {:error, changeset} =
172
               %StackEntry{}
173
               |> StackEntry.changeset(%{
174
                 stack_id: other_stack.id,
175
                 pull_request_id: bottom.id,
176
                 position: 2,
177
                 boundary_oid: sha("a"),
178
                 observed_head_oid: sha("b")
179
               })
180
               |> Repo.insert()
181
182
      assert "has already been taken" in errors_on(changeset).pull_request_id
183
184
      [entry] = stack.entries
185
      removed_at = DateTime.utc_now()
186
      {:ok, _removed} = entry |> StackEntry.changeset(%{removed_at: removed_at}) |> Repo.update()
187
188
      assert {:ok, _entry} =
189
               %StackEntry{}
190
               |> StackEntry.changeset(%{
191
                 stack_id: other_stack.id,
192
                 pull_request_id: bottom.id,
193
                 position: 2,
194
                 boundary_oid: sha("a"),
195
                 observed_head_oid: sha("b")
196
               })
197
               |> Repo.insert()
198
    end
199
200
    test "active positions are unique per stack" do
201
      user = repository_user_fixture("stack-author")
202
      repository = repository_fixture()
203
      [bottom] = chain(repository, ["layer-1"])
204
      other = pull_request(repository, "layer-x", "main")
205
206
      assert {:ok, stack} = Stacks.create(repository, [bottom], user)
207
208
      assert {:error, changeset} =
209
               %StackEntry{}
210
               |> StackEntry.changeset(%{
211
                 stack_id: stack.id,
212
                 pull_request_id: other.id,
213
                 position: 1,
214
                 boundary_oid: sha("a"),
215
                 observed_head_oid: sha("b")
216
               })
217
               |> Repo.insert()
218
219
      assert "has already been taken" in errors_on(changeset).stack_id
220
    end
221
222
    test "positions start at one" do
223
      changeset =
224
        StackEntry.changeset(%StackEntry{}, %{
225
          stack_id: Ecto.UUID.generate(),
226
          pull_request_id: Ecto.UUID.generate(),
227
          position: 0,
228
          boundary_oid: sha("a"),
229
          observed_head_oid: sha("b")
230
        })
231
232
      assert "must be greater than or equal to 1" in errors_on(changeset).position
233
    end
234
235
    test "OIDs accept SHA-1 and SHA-256 object names and reject other values" do
236
      base = %{
237
        stack_id: Ecto.UUID.generate(),
238
        pull_request_id: Ecto.UUID.generate(),
239
        position: 1,
240
        observed_head_oid: sha("b")
241
      }
242
243
      sha256 = String.duplicate("ab", 32)
244
245
      changeset = StackEntry.changeset(%StackEntry{}, Map.put(base, :boundary_oid, sha256))
246
      assert Ecto.Changeset.get_field(changeset, :boundary_oid) == sha256
247
248
      for invalid <- [String.duplicate("a", 39), String.duplicate("z", 40), 42] do
249
        changeset = StackEntry.changeset(%StackEntry{}, Map.put(base, :boundary_oid, invalid))
250
        assert "is invalid" in errors_on(changeset).boundary_oid
251
      end
252
    end
253
254
    test "OIDs round-trip through the database" do
255
      user = repository_user_fixture("stack-author")
256
      repository = repository_fixture()
257
      [bottom] = chain(repository, ["layer-1"])
258
259
      assert {:ok, stack} = Stacks.create(repository, [bottom], user)
260
      [entry] = stack.entries
261
      reloaded = Repo.get!(StackEntry, entry.id)
262
263
      assert reloaded.boundary_oid == bottom.base_sha
264
      assert reloaded.observed_head_oid == bottom.head_sha
265
    end
266
  end
267
268
  describe "base edits" do
269
    test "generic base edits fail while a pull request is stacked" do
270
      user = repository_user_fixture("stack-author")
271
      repository = repository_with_member_fixture(user)
272
      [bottom] = chain(repository, ["layer-1"])
273
274
      assert :ok = Stacks.ensure_base_editable(bottom)
275
      assert {:ok, _stack} = Stacks.create(repository, [bottom], user)
276
      assert {:error, :stack_managed_base} = Stacks.ensure_base_editable(bottom)
277
278
      assert {:error, :stack_managed_base} =
279
               PullRequests.update(bottom, %{"base" => "other-branch"}, user)
280
    end
281
  end
282
283
  describe "health and state" do
284
    test "a stale graph never dissolves a stack" do
285
      user = repository_user_fixture("stack-author")
286
      repository = repository_fixture()
287
      [bottom] = chain(repository, ["layer-1"])
288
289
      assert {:ok, stack} = Stacks.create(repository, [bottom], user)
290
      assert {:ok, updated} = Stacks.set_health(stack, "needs_rebase")
291
      assert updated.health == "needs_rebase"
292
      assert updated.state == "open"
293
      assert updated.version == stack.version
294
    end
295
296
    test "rejects unknown health states" do
297
      user = repository_user_fixture("stack-author")
298
      repository = repository_fixture()
299
      [bottom] = chain(repository, ["layer-1"])
300
301
      assert {:ok, stack} = Stacks.create(repository, [bottom], user)
302
      assert {:error, changeset} = Stacks.set_health(stack, "broken")
303
      assert "is invalid" in errors_on(changeset).health
304
    end
305
306
    test "completing and dissolving bump the version and require the caller's version" do
307
      user = repository_user_fixture("stack-author")
308
      repository = repository_fixture()
309
      [bottom] = chain(repository, ["layer-1"])
310
      other = pull_request(repository, "layer-x", "main")
311
312
      assert {:ok, stack} = Stacks.create(repository, [bottom], user)
313
      assert {:ok, completed} = Stacks.complete(stack)
314
      assert completed.state == "completed"
315
      assert completed.version == stack.version + 1
316
317
      assert {:error, :stale_stack_version} = Stacks.complete(stack)
318
      assert {:error, :stack_not_open} = Stacks.dissolve(completed)
319
320
      assert {:ok, second} = Stacks.create(repository, [other], user)
321
      assert {:ok, dissolved} = Stacks.dissolve(second)
322
      assert dissolved.state == "dissolved"
323
      assert dissolved.version == second.version + 1
324
    end
325
326
    test "get_by_number!/2 returns ordered active entries" do
327
      user = repository_user_fixture("stack-author")
328
      repository = repository_fixture()
329
      pull_requests = chain(repository, ["layer-1", "layer-2"])
330
331
      assert {:ok, stack} = Stacks.create(repository, pull_requests, user)
332
      loaded = Stacks.get_by_number!(repository, stack.number)
333
334
      assert Enum.map(loaded.entries, & &1.position) == [1, 2]
335
      assert Enum.map(loaded.entries, & &1.pull_request.id) == Enum.map(pull_requests, & &1.id)
336
    end
337
  end
338
end

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