|
1
|
+ |
defmodule OpenAgents.Stacks.Merge do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
Contiguous-prefix stack merge (`docs/stacked-prs.md` sections 13 and
|
|
4
|
+ |
5.9-5.13).
|
|
5
|
+ |
|
|
6
|
+ |
Selecting a pull request merges it and every open layer below it in one
|
|
7
|
+ |
durable operation. Preflight validates everything — the contiguous
|
|
8
|
+ |
prefix, the ancestry chain, live and expected heads, the stack version —
|
|
9
|
+ |
before any object builds. The merge result and every upper-layer restack
|
|
10
|
+ |
then build as unreachable candidate commits, persist under hidden
|
|
11
|
+ |
retention refs, and land through one batch compare-and-swap that moves
|
|
12
|
+ |
the trunk and every restacked branch together. Merged lower branches
|
|
13
|
+ |
stay untouched, so their history survives until callers delete them.
|
|
14
|
+ |
|
|
15
|
+ |
Git refs and SQL metadata are two durable stores, so the operation
|
|
16
|
+ |
records its planned result before the refs move and marks the plan
|
|
17
|
+ |
applied immediately after. A worker that reclaims a crashed operation
|
|
18
|
+ |
reconciles instead of re-merging: when the live refs already equal the
|
|
19
|
+ |
plan it only finalizes the metadata, when nothing moved it re-plans, and
|
|
20
|
+ |
when the refs diverged it marks the operation `partially_succeeded` —
|
|
21
|
+ |
pull requests whose refs landed stay merged even when a later step
|
|
22
|
+ |
fails.
|
|
23
|
+ |
|
|
24
|
+ |
An upper-layer restack conflict fails the operation during planning,
|
|
25
|
+ |
before any ref moves, with the conflict persisted on the operation;
|
|
26
|
+ |
resolve it through a stack rebase and retry the merge.
|
|
27
|
+ |
"""
|
|
28
|
+ |
import Ecto.Query, warn: false
|
|
29
|
+ |
|
|
30
|
+ |
alias OpenAgents.Accounts.User
|
|
31
|
+ |
alias OpenAgents.Forge.GitPlane
|
|
32
|
+ |
alias OpenAgents.Issues.Issue
|
|
33
|
+ |
alias OpenAgents.PullRequests.PullRequest
|
|
34
|
+ |
alias OpenAgents.Repo
|
|
35
|
+ |
alias OpenAgents.Repositories
|
|
36
|
+ |
alias OpenAgents.Repositories.Repository
|
|
37
|
+ |
alias OpenAgents.Stacks.Operation
|
|
38
|
+ |
alias OpenAgents.Stacks.Stack
|
|
39
|
+ |
alias OpenAgents.Stacks.StackEntry
|
|
40
|
+ |
alias OpenAgents.Stacks.StackEvent
|
|
41
|
+ |
|
|
42
|
+ |
@merge_methods ~w(merge squash rebase)
|
|
43
|
+ |
|
|
44
|
+ |
@doc """
|
|
45
|
+ |
Requests a merge of the contiguous lowest prefix ending at one pull
|
|
46
|
+ |
request.
|
|
47
|
+ |
|
|
48
|
+ |
The request names the selected pull request, the merge method, optional
|
|
49
|
+ |
expected heads per layer, and an optional expected stack version. It
|
|
50
|
+ |
inserts one durable `Operation` row in state `pending`; a worker claims
|
|
51
|
+ |
and executes it. A retried idempotency key replays the original
|
|
52
|
+ |
operation.
|
|
53
|
+ |
"""
|
|
54
|
+ |
def request_from_api(%Repository{} = repository, number, params, %User{} = actor, key)
|
|
55
|
+ |
when is_integer(number) and is_binary(key) do
|
|
56
|
+ |
with :ok <- authorize(repository, actor),
|
|
57
|
+ |
{:ok, request} <- parse_merge_request(params) do
|
|
58
|
+ |
Repo.transaction(fn ->
|
|
59
|
+ |
lock_repository_stacks(repository.id)
|
|
60
|
+ |
|
|
61
|
+ |
with {:ok, stack} <- get_stack_for_update(repository, number),
|
|
62
|
+ |
:ok <- validate_open(stack),
|
|
63
|
+ |
{:ok, replay} <- check_idempotency(stack, key, request),
|
|
64
|
+ |
:ok <- ensure_no_active_operation(stack, replay),
|
|
65
|
+ |
:ok <- validate_expected_version(request["expected_stack_version"], stack),
|
|
66
|
+ |
{:ok, position} <- selected_position(stack, request, replay) do
|
|
67
|
+ |
case replay do
|
|
68
|
+ |
%Operation{} = operation ->
|
|
69
|
+ |
{operation, :replayed}
|
|
70
|
+ |
|
|
71
|
+ |
nil ->
|
|
72
|
+ |
operation = insert_operation!(stack, actor, key, request, position)
|
|
73
|
+ |
set_health!(stack, "operation_in_progress")
|
|
74
|
+ |
{operation, :created}
|
|
75
|
+ |
end
|
|
76
|
+ |
else
|
|
77
|
+ |
{:error, reason} -> Repo.rollback(reason)
|
|
78
|
+ |
end
|
|
79
|
+ |
end)
|
|
80
|
+ |
end
|
|
81
|
+ |
end
|
|
82
|
+ |
|
|
83
|
+ |
@doc """
|
|
84
|
+ |
Executes one claimed merge operation to a terminal state.
|
|
85
|
+ |
|
|
86
|
+ |
The caller (the operation worker) has already marked the row `running`.
|
|
87
|
+ |
A re-execution after a crash reconciles from the persisted plan instead
|
|
88
|
+ |
of repeating work.
|
|
89
|
+ |
"""
|
|
90
|
+ |
def execute(%Operation{kind: "merge"} = operation, opts \\ []) do
|
|
91
|
+ |
stack = load_stack(operation.stack_id)
|
|
92
|
+ |
repository = Repo.one!(from r in Repository, where: r.id == ^stack.repository_id)
|
|
93
|
+ |
|
|
94
|
+ |
case reconcile_state(repository, operation) do
|
|
95
|
+ |
:fresh ->
|
|
96
|
+ |
run(operation, repository, stack, opts)
|
|
97
|
+ |
|
|
98
|
+ |
{:applied, plan} ->
|
|
99
|
+ |
finalize(operation, stack, plan)
|
|
100
|
+ |
|
|
101
|
+ |
{:diverged, plan} ->
|
|
102
|
+ |
partially_succeed(operation, plan, :refs_diverged)
|
|
103
|
+ |
end
|
|
104
|
+ |
end
|
|
105
|
+ |
|
|
106
|
+ |
defp run(operation, repository, stack, opts) do
|
|
107
|
+ |
with :ok <- validate_executable(operation, stack),
|
|
108
|
+ |
{:ok, trunk_tip} <- resolve_trunk(repository, stack),
|
|
109
|
+ |
{:ok, selected, upper} <- split_entries(stack, operation.target_position),
|
|
110
|
+ |
:ok <- preflight(repository, trunk_tip, selected, upper, operation.request),
|
|
111
|
+ |
operation = record_snapshot!(operation, stack, trunk_tip, selected),
|
|
112
|
+ |
{:ok, plan} <- build_plan(operation, repository, stack, trunk_tip, selected, upper),
|
|
113
|
+ |
operation = record_plan!(operation, plan),
|
|
114
|
+ |
:ok <- apply_refs(operation, repository, plan),
|
|
115
|
+ |
operation = mark_refs_applied!(operation),
|
|
116
|
+ |
:ok <- crash_seam(opts) do
|
|
117
|
+ |
finalize(operation, stack, plan)
|
|
118
|
+ |
else
|
|
119
|
+ |
{:error, reason} -> fail(operation, stack, reason)
|
|
120
|
+ |
end
|
|
121
|
+ |
end
|
|
122
|
+ |
|
|
123
|
+ |
# A test-only fault-injection seam: production callers never pass it, so
|
|
124
|
+ |
# the merge proceeds straight to finalization.
|
|
125
|
+ |
defp crash_seam(opts) do
|
|
126
|
+ |
case Keyword.get(opts, :after_refs) do
|
|
127
|
+ |
nil -> :ok
|
|
128
|
+ |
fun when is_function(fun, 0) -> fun.()
|
|
129
|
+ |
end
|
|
130
|
+ |
end
|
|
131
|
+ |
|
|
132
|
+ |
## Reconciliation
|
|
133
|
+ |
|
|
134
|
+ |
# The planned result is the recovery record: `refs_applied` flips true
|
|
135
|
+ |
# right after the batch-CAS lands, so a reclaimed operation knows whether
|
|
136
|
+ |
# the git side already moved.
|
|
137
|
+ |
defp reconcile_state(_repository, %Operation{planned_result: nil}), do: :fresh
|
|
138
|
+ |
|
|
139
|
+ |
defp reconcile_state(repository, %Operation{planned_result: plan}) do
|
|
140
|
+ |
refs = planned_refs(plan)
|
|
141
|
+ |
|
|
142
|
+ |
cond do
|
|
143
|
+ |
Enum.all?(refs, fn ref -> live_oid(repository, ref["ref"]) == ref["new"] end) ->
|
|
144
|
+ |
{:applied, plan}
|
|
145
|
+ |
|
|
146
|
+ |
not plan_marked_applied?(plan) and
|
|
147
|
+ |
Enum.all?(refs, fn ref -> live_oid(repository, ref["ref"]) == ref["old"] end) ->
|
|
148
|
+ |
:fresh
|
|
149
|
+ |
|
|
150
|
+ |
true ->
|
|
151
|
+ |
{:diverged, plan}
|
|
152
|
+ |
end
|
|
153
|
+ |
end
|
|
154
|
+ |
|
|
155
|
+ |
defp plan_marked_applied?(plan), do: Map.get(plan, "refs_applied") == true
|
|
156
|
+ |
|
|
157
|
+ |
defp planned_refs(plan) do
|
|
158
|
+ |
trunk = %{
|
|
159
|
+ |
"ref" => Map.fetch!(plan, "trunk_ref"),
|
|
160
|
+ |
"old" => Map.fetch!(plan, "trunk_old"),
|
|
161
|
+ |
"new" => Map.fetch!(plan, "trunk_new")
|
|
162
|
+ |
}
|
|
163
|
+ |
|
|
164
|
+ |
restacked =
|
|
165
|
+ |
plan
|
|
166
|
+ |
|> Map.fetch!("restacked")
|
|
167
|
+ |
|> Enum.map(fn step ->
|
|
168
|
+ |
%{
|
|
169
|
+ |
"ref" => Map.fetch!(step, "ref"),
|
|
170
|
+ |
"old" => Map.fetch!(step, "old_head"),
|
|
171
|
+ |
"new" => Map.fetch!(step, "new_head")
|
|
172
|
+ |
}
|
|
173
|
+ |
end)
|
|
174
|
+ |
|
|
175
|
+ |
[trunk | restacked]
|
|
176
|
+ |
end
|
|
177
|
+ |
|
|
178
|
+ |
defp live_oid(repository, ref) do
|
|
179
|
+ |
case GitPlane.resolve_commit(repository.storage_key, ref) do
|
|
180
|
+ |
{:ok, oid} -> oid
|
|
181
|
+ |
{:error, _reason} -> :absent
|
|
182
|
+ |
end
|
|
183
|
+ |
end
|
|
184
|
+ |
|
|
185
|
+ |
## Preflight
|
|
186
|
+ |
|
|
187
|
+ |
defp validate_executable(operation, stack) do
|
|
188
|
+ |
cond do
|
|
189
|
+ |
stack.state != "open" -> {:error, :stack_not_open}
|
|
190
|
+ |
stack.version != operation.expected_stack_version -> {:error, :stale_stack_version}
|
|
191
|
+ |
stack.entries == [] -> {:error, :empty_stack}
|
|
192
|
+ |
true -> :ok
|
|
193
|
+ |
end
|
|
194
|
+ |
end
|
|
195
|
+ |
|
|
196
|
+ |
defp resolve_trunk(repository, stack) do
|
|
197
|
+ |
case GitPlane.resolve_commit(repository.storage_key, "refs/heads/" <> stack.trunk_ref) do
|
|
198
|
+ |
{:ok, oid} -> {:ok, oid}
|
|
199
|
+ |
{:error, _reason} -> {:error, {:missing_ref, "refs/heads/" <> stack.trunk_ref}}
|
|
200
|
+ |
end
|
|
201
|
+ |
end
|
|
202
|
+ |
|
|
203
|
+ |
defp split_entries(stack, target_position) do
|
|
204
|
+ |
{selected, upper} = Enum.split_with(stack.entries, &(&1.position <= target_position))
|
|
205
|
+ |
|
|
206
|
+ |
if selected != [] and Enum.any?(selected, &(&1.position == target_position)) do
|
|
207
|
+ |
{:ok, selected, upper}
|
|
208
|
+ |
else
|
|
209
|
+ |
{:error, :pull_request_not_in_stack}
|
|
210
|
+ |
end
|
|
211
|
+ |
end
|
|
212
|
+ |
|
|
213
|
+ |
# Everything validates before any object builds: the selected prefix must
|
|
214
|
+ |
# be open, current on the trunk, an unbroken parent chain, and live on
|
|
215
|
+ |
# the branches the stack recorded — and any caller-supplied expected
|
|
216
|
+ |
# heads must match.
|
|
217
|
+ |
defp preflight(repository, trunk_tip, selected, upper, request) do
|
|
218
|
+ |
entries = selected ++ upper
|
|
219
|
+ |
|
|
220
|
+ |
with :ok <- validate_selected_open(selected),
|
|
221
|
+ |
:ok <- validate_chain(trunk_tip, entries),
|
|
222
|
+ |
:ok <- verify_live_heads(repository, entries) do
|
|
223
|
+ |
validate_expected_heads(request["expected_heads"], entries)
|
|
224
|
+ |
end
|
|
225
|
+ |
end
|
|
226
|
+ |
|
|
227
|
+ |
defp validate_selected_open(selected) do
|
|
228
|
+ |
Enum.find_value(selected, :ok, fn entry ->
|
|
229
|
+ |
if entry.pull_request.state == "open" do
|
|
230
|
+ |
nil
|
|
231
|
+ |
else
|
|
232
|
+ |
{:error, :pull_request_not_open}
|
|
233
|
+ |
end
|
|
234
|
+ |
end)
|
|
235
|
+ |
end
|
|
236
|
+ |
|
|
237
|
+ |
defp validate_chain(trunk_tip, entries) do
|
|
238
|
+ |
entries
|
|
239
|
+ |
|> Enum.reduce_while(trunk_tip, fn entry, expected_boundary ->
|
|
240
|
+ |
if entry.boundary_oid == expected_boundary do
|
|
241
|
+ |
{:cont, entry.observed_head_oid}
|
|
242
|
+ |
else
|
|
243
|
+ |
{:halt, {:error, :needs_rebase}}
|
|
244
|
+ |
end
|
|
245
|
+ |
end)
|
|
246
|
+ |
|> case do
|
|
247
|
+ |
{:error, reason} -> {:error, reason}
|
|
248
|
+ |
_head -> :ok
|
|
249
|
+ |
end
|
|
250
|
+ |
end
|
|
251
|
+ |
|
|
252
|
+ |
defp verify_live_heads(repository, entries) do
|
|
253
|
+ |
Enum.find_value(entries, :ok, fn entry ->
|
|
254
|
+ |
ref = "refs/heads/" <> entry.pull_request.head_ref
|
|
255
|
+ |
|
|
256
|
+ |
case GitPlane.resolve_commit(repository.storage_key, ref) do
|
|
257
|
+ |
{:ok, oid} when oid == entry.observed_head_oid -> nil
|
|
258
|
+ |
{:ok, actual} -> {:error, {:head_changed, ref, actual}}
|
|
259
|
+ |
{:error, _reason} -> {:error, {:missing_ref, ref}}
|
|
260
|
+ |
end
|
|
261
|
+ |
end)
|
|
262
|
+ |
end
|
|
263
|
+ |
|
|
264
|
+ |
defp validate_expected_heads(nil, _entries), do: :ok
|
|
265
|
+ |
|
|
266
|
+ |
defp validate_expected_heads(expected, entries) when is_map(expected) do
|
|
267
|
+ |
by_number = Map.new(entries, &{&1.pull_request.issue.number, &1})
|
|
268
|
+ |
|
|
269
|
+ |
Enum.find_value(expected, :ok, fn {number_key, oid} ->
|
|
270
|
+ |
with {:ok, number} <- parse_number_key(number_key),
|
|
271
|
+ |
%StackEntry{} = entry <- Map.get(by_number, number, :missing) do
|
|
272
|
+ |
if entry.observed_head_oid == oid, do: nil, else: {:error, :expected_head_mismatch}
|
|
273
|
+ |
else
|
|
274
|
+ |
_invalid -> {:error, :expected_head_mismatch}
|
|
275
|
+ |
end
|
|
276
|
+ |
end)
|
|
277
|
+ |
end
|
|
278
|
+ |
|
|
279
|
+ |
defp parse_number_key(key) when is_integer(key), do: {:ok, key}
|
|
280
|
+ |
|
|
281
|
+ |
defp parse_number_key(key) when is_binary(key) do
|
|
282
|
+ |
case Integer.parse(key) do
|
|
283
|
+ |
{number, ""} -> {:ok, number}
|
|
284
|
+ |
_other -> :error
|
|
285
|
+ |
end
|
|
286
|
+ |
end
|
|
287
|
+ |
|
|
288
|
+ |
## Snapshot and plan
|
|
289
|
+ |
|
|
290
|
+ |
defp record_snapshot!(%Operation{snapshot: nil} = operation, stack, trunk_tip, selected) do
|
|
291
|
+ |
selected_positions = Enum.map(selected, & &1.position)
|
|
292
|
+ |
|
|
293
|
+ |
snapshot = %{
|
|
294
|
+ |
"trunk_oid" => trunk_tip,
|
|
295
|
+ |
"stack_version" => stack.version,
|
|
296
|
+ |
"selected_positions" => selected_positions,
|
|
297
|
+ |
"entries" =>
|
|
298
|
+ |
Enum.map(stack.entries, fn entry ->
|
|
299
|
+ |
%{
|
|
300
|
+ |
"position" => entry.position,
|
|
301
|
+ |
"ref" => "refs/heads/" <> entry.pull_request.head_ref,
|
|
302
|
+ |
"boundary_oid" => entry.boundary_oid,
|
|
303
|
+ |
"observed_head_oid" => entry.observed_head_oid
|
|
304
|
+ |
}
|
|
305
|
+ |
end)
|
|
306
|
+ |
}
|
|
307
|
+ |
|
|
308
|
+ |
operation
|
|
309
|
+ |
|> Operation.transition_changeset(%{state: operation.state, snapshot: snapshot})
|
|
310
|
+ |
|> Repo.update!()
|
|
311
|
+ |
end
|
|
312
|
+ |
|
|
313
|
+ |
defp record_snapshot!(%Operation{} = operation, _stack, _trunk_tip, _selected), do: operation
|
|
314
|
+ |
|
|
315
|
+ |
defp build_plan(operation, repository, stack, trunk_tip, selected, upper) do
|
|
316
|
+ |
method = Map.fetch!(operation.request, "merge_method")
|
|
317
|
+ |
|
|
318
|
+ |
with {:ok, trunk_new, merged} <-
|
|
319
|
+ |
build_merge_result(repository, stack, trunk_tip, selected, method),
|
|
320
|
+ |
{:ok, restacked} <- plan_upper_restacks(repository, upper, trunk_new) do
|
|
321
|
+ |
{:ok,
|
|
322
|
+ |
%{
|
|
323
|
+ |
"merge_method" => method,
|
|
324
|
+ |
"trunk_ref" => "refs/heads/" <> stack.trunk_ref,
|
|
325
|
+ |
"trunk_old" => trunk_tip,
|
|
326
|
+ |
"trunk_new" => trunk_new,
|
|
327
|
+ |
"merged" => merged,
|
|
328
|
+ |
"restacked" => restacked,
|
|
329
|
+ |
"refs_applied" => false
|
|
330
|
+ |
}}
|
|
331
|
+ |
end
|
|
332
|
+ |
end
|
|
333
|
+ |
|
|
334
|
+ |
# One group merge commit: both selected history and the current trunk are
|
|
335
|
+ |
# parents, and the tree is exactly the selected top head's tree.
|
|
336
|
+ |
defp build_merge_result(repository, stack, trunk_tip, selected, "merge") do
|
|
337
|
+ |
top = List.last(selected)
|
|
338
|
+ |
numbers = Enum.map(selected, & &1.pull_request.issue.number)
|
|
339
|
+ |
message = merge_message(stack, numbers)
|
|
340
|
+ |
|
|
341
|
+ |
with {:ok, tree} <- tree_of(repository, top.observed_head_oid),
|
|
342
|
+ |
{:ok, merge_commit} <-
|
|
343
|
+ |
commit_tree(repository, tree, [trunk_tip, top.observed_head_oid], message) do
|
|
344
|
+ |
{:ok, merge_commit, Enum.map(selected, &merged_step(&1, merge_commit))}
|
|
345
|
+ |
end
|
|
346
|
+ |
end
|
|
347
|
+ |
|
|
348
|
+ |
# One squash commit per pull request, chained on the trunk, so each trunk
|
|
349
|
+ |
# commit diffs to exactly one layer.
|
|
350
|
+ |
defp build_merge_result(repository, _stack, trunk_tip, selected, "squash") do
|
|
351
|
+ |
selected
|
|
352
|
+ |
|> Enum.reduce_while({:ok, trunk_tip, []}, fn entry, {:ok, parent, merged} ->
|
|
353
|
+ |
issue = entry.pull_request.issue
|
|
354
|
+ |
message = "#{issue.title} (##{issue.number})"
|
|
355
|
+ |
|
|
356
|
+ |
with {:ok, tree} <- tree_of(repository, entry.observed_head_oid),
|
|
357
|
+ |
{:ok, author} <- commit_author(repository, entry.observed_head_oid),
|
|
358
|
+ |
{:ok, squash} <- commit_tree(repository, tree, [parent], message, author: author) do
|
|
359
|
+ |
{:cont, {:ok, squash, [merged_step(entry, squash) | merged]}}
|
|
360
|
+ |
else
|
|
361
|
+ |
{:error, reason} -> {:halt, {:error, reason}}
|
|
362
|
+ |
end
|
|
363
|
+ |
end)
|
|
364
|
+ |
|> case do
|
|
365
|
+ |
{:ok, trunk_new, merged} -> {:ok, trunk_new, Enum.reverse(merged)}
|
|
366
|
+ |
{:error, reason} -> {:error, reason}
|
|
367
|
+ |
end
|
|
368
|
+ |
end
|
|
369
|
+ |
|
|
370
|
+ |
# Replay every selected layer's unique commits in order; the final tree
|
|
371
|
+ |
# must equal the selected top head's tree.
|
|
372
|
+ |
defp build_merge_result(repository, _stack, trunk_tip, selected, "rebase") do
|
|
373
|
+ |
selected
|
|
374
|
+ |
|> Enum.reduce_while({:ok, trunk_tip, []}, fn entry, {:ok, parent, merged} ->
|
|
375
|
+ |
case GitPlane.replay(
|
|
376
|
+ |
repository.storage_key,
|
|
377
|
+ |
entry.boundary_oid,
|
|
378
|
+ |
entry.observed_head_oid,
|
|
379
|
+ |
parent
|
|
380
|
+ |
) do
|
|
381
|
+ |
{:ok, %{new_head: new_head}} ->
|
|
382
|
+ |
{:cont, {:ok, new_head, [merged_step(entry, new_head) | merged]}}
|
|
383
|
+ |
|
|
384
|
+ |
{:conflict, _conflict} ->
|
|
385
|
+ |
{:halt, {:error, {:replay_failed, entry.position, :conflict}}}
|
|
386
|
+ |
|
|
387
|
+ |
{:error, reason} ->
|
|
388
|
+ |
{:halt, {:error, {:replay_failed, entry.position, reason}}}
|
|
389
|
+ |
end
|
|
390
|
+ |
end)
|
|
391
|
+ |
|> case do
|
|
392
|
+ |
{:ok, trunk_new, merged} ->
|
|
393
|
+ |
top = List.last(selected)
|
|
394
|
+ |
|
|
395
|
+ |
with {:ok, result_tree} <- tree_of(repository, trunk_new),
|
|
396
|
+ |
{:ok, top_tree} <- tree_of(repository, top.observed_head_oid) do
|
|
397
|
+ |
if result_tree == top_tree,
|
|
398
|
+ |
do: {:ok, trunk_new, Enum.reverse(merged)},
|
|
399
|
+ |
else: {:error, :rebase_tree_mismatch}
|
|
400
|
+ |
end
|
|
401
|
+ |
|
|
402
|
+ |
{:error, reason} ->
|
|
403
|
+ |
{:error, reason}
|
|
404
|
+ |
end
|
|
405
|
+ |
end
|
|
406
|
+ |
|
|
407
|
+ |
defp merged_step(entry, merge_commit_sha) do
|
|
408
|
+ |
%{
|
|
409
|
+ |
"position" => entry.position,
|
|
410
|
+ |
"entry_id" => entry.id,
|
|
411
|
+ |
"pull_request_id" => entry.pull_request_id,
|
|
412
|
+ |
"pull_request_number" => entry.pull_request.issue.number,
|
|
413
|
+ |
"ref" => "refs/heads/" <> entry.pull_request.head_ref,
|
|
414
|
+ |
"old_head" => entry.observed_head_oid,
|
|
415
|
+ |
"merge_commit_sha" => merge_commit_sha
|
|
416
|
+ |
}
|
|
417
|
+ |
end
|
|
418
|
+ |
|
|
419
|
+ |
defp merge_message(stack, numbers) do
|
|
420
|
+ |
"Merge pull requests #{Enum.map_join(numbers, ", ", &"##{&1}")} (stack #{stack.number})"
|
|
421
|
+ |
end
|
|
422
|
+ |
|
|
423
|
+ |
# Layers above the selected prefix restack onto the merge result using
|
|
424
|
+ |
# their stored boundaries. A conflict here fails the whole operation
|
|
425
|
+ |
# before any ref moves; the persisted error names the conflicting layer.
|
|
426
|
+ |
defp plan_upper_restacks(repository, upper, trunk_new) do
|
|
427
|
+ |
upper
|
|
428
|
+ |
|> Enum.reduce_while({:ok, trunk_new, []}, fn entry, {:ok, parent, steps} ->
|
|
429
|
+ |
case GitPlane.replay(
|
|
430
|
+ |
repository.storage_key,
|
|
431
|
+ |
entry.boundary_oid,
|
|
432
|
+ |
entry.observed_head_oid,
|
|
433
|
+ |
parent
|
|
434
|
+ |
) do
|
|
435
|
+ |
{:ok, %{new_head: new_head}} ->
|
|
436
|
+ |
step = %{
|
|
437
|
+ |
"position" => entry.position,
|
|
438
|
+ |
"entry_id" => entry.id,
|
|
439
|
+ |
"pull_request_id" => entry.pull_request_id,
|
|
440
|
+ |
"pull_request_number" => entry.pull_request.issue.number,
|
|
441
|
+ |
"ref" => "refs/heads/" <> entry.pull_request.head_ref,
|
|
442
|
+ |
"old_head" => entry.observed_head_oid,
|
|
443
|
+ |
"old_boundary" => entry.boundary_oid,
|
|
444
|
+ |
"new_boundary" => parent,
|
|
445
|
+ |
"new_head" => new_head
|
|
446
|
+ |
}
|
|
447
|
+ |
|
|
448
|
+ |
{:cont, {:ok, new_head, [step | steps]}}
|
|
449
|
+ |
|
|
450
|
+ |
{:conflict, conflict} ->
|
|
451
|
+ |
{:halt,
|
|
452
|
+ |
{:error,
|
|
453
|
+ |
{:upper_restack_conflict,
|
|
454
|
+ |
%{
|
|
455
|
+ |
"position" => entry.position,
|
|
456
|
+ |
"pull_request_number" => entry.pull_request.issue.number,
|
|
457
|
+ |
"onto" => conflict.onto,
|
|
458
|
+ |
"commit" => conflict.commit,
|
|
459
|
+ |
"paths" => conflict.paths,
|
|
460
|
+ |
"messages" => conflict.messages
|
|
461
|
+ |
}}}}
|
|
462
|
+ |
|
|
463
|
+ |
{:error, reason} ->
|
|
464
|
+ |
{:halt, {:error, {:replay_failed, entry.position, reason}}}
|
|
465
|
+ |
end
|
|
466
|
+ |
end)
|
|
467
|
+ |
|> case do
|
|
468
|
+ |
{:ok, _parent, steps} -> {:ok, Enum.reverse(steps)}
|
|
469
|
+ |
{:error, reason} -> {:error, reason}
|
|
470
|
+ |
end
|
|
471
|
+ |
end
|
|
472
|
+ |
|
|
473
|
+ |
defp record_plan!(operation, plan) do
|
|
474
|
+ |
operation
|
|
475
|
+ |
|> Operation.transition_changeset(%{state: operation.state, planned_result: plan})
|
|
476
|
+ |
|> Repo.update!()
|
|
477
|
+ |
end
|
|
478
|
+ |
|
|
479
|
+ |
defp mark_refs_applied!(operation) do
|
|
480
|
+ |
plan = Map.put(operation.planned_result, "refs_applied", true)
|
|
481
|
+ |
|
|
482
|
+ |
operation
|
|
483
|
+ |
|> Operation.transition_changeset(%{state: operation.state, planned_result: plan})
|
|
484
|
+ |
|> Repo.update!()
|
|
485
|
+ |
end
|
|
486
|
+ |
|
|
487
|
+ |
## Ref application
|
|
488
|
+ |
|
|
489
|
+ |
defp apply_refs(operation, repository, plan) do
|
|
490
|
+ |
with {:ok, temp_refs} <- retain_new_commits(operation, repository, plan) do
|
|
491
|
+ |
move_public_refs(repository, plan, temp_refs)
|
|
492
|
+ |
end
|
|
493
|
+ |
end
|
|
494
|
+ |
|
|
495
|
+ |
defp retain_new_commits(operation, repository, plan) do
|
|
496
|
+ |
targets =
|
|
497
|
+ |
[{"trunk", Map.fetch!(plan, "trunk_new")}] ++
|
|
498
|
+ |
Enum.map(Map.fetch!(plan, "restacked"), fn step ->
|
|
499
|
+ |
{"p#{Map.fetch!(step, "position")}", Map.fetch!(step, "new_head")}
|
|
500
|
+ |
end)
|
|
501
|
+ |
|
|
502
|
+ |
temp_refs =
|
|
503
|
+ |
Enum.map(targets, fn {segment, oid} ->
|
|
504
|
+ |
{:ok, ref} =
|
|
505
|
+ |
GitPlane.internal_ref([
|
|
506
|
+ |
"operations",
|
|
507
|
+ |
operation.id,
|
|
508
|
+ |
"a#{operation.attempt_count}",
|
|
509
|
+ |
segment
|
|
510
|
+ |
])
|
|
511
|
+ |
|
|
512
|
+ |
%{ref: ref, expected_old: :absent, new: oid}
|
|
513
|
+ |
end)
|
|
514
|
+ |
|
|
515
|
+ |
case GitPlane.batch_update_refs(repository.storage_key, temp_refs, principal(operation)) do
|
|
516
|
+ |
{:ok, _result} -> {:ok, temp_refs}
|
|
517
|
+ |
{:error, reason} -> {:error, {:retention_failed, reason}}
|
|
518
|
+ |
end
|
|
519
|
+ |
end
|
|
520
|
+ |
|
|
521
|
+ |
# One atomic batch: the trunk and every restacked branch move together
|
|
522
|
+ |
# and the retention refs delete in the same transaction. Merged lower
|
|
523
|
+ |
# branches do not move and are not deleted.
|
|
524
|
+ |
defp move_public_refs(repository, plan, temp_refs) do
|
|
525
|
+ |
updates =
|
|
526
|
+ |
[
|
|
527
|
+ |
%{
|
|
528
|
+ |
ref: Map.fetch!(plan, "trunk_ref"),
|
|
529
|
+ |
expected_old: Map.fetch!(plan, "trunk_old"),
|
|
530
|
+ |
new: Map.fetch!(plan, "trunk_new")
|
|
531
|
+ |
}
|
|
532
|
+ |
] ++
|
|
533
|
+ |
Enum.map(Map.fetch!(plan, "restacked"), fn step ->
|
|
534
|
+ |
%{
|
|
535
|
+ |
ref: Map.fetch!(step, "ref"),
|
|
536
|
+ |
expected_old: Map.fetch!(step, "old_head"),
|
|
537
|
+ |
new: Map.fetch!(step, "new_head")
|
|
538
|
+ |
}
|
|
539
|
+ |
end) ++
|
|
540
|
+ |
Enum.map(temp_refs, fn temp ->
|
|
541
|
+ |
%{ref: temp.ref, expected_old: temp.new, new: :delete}
|
|
542
|
+ |
end)
|
|
543
|
+ |
|
|
544
|
+ |
case GitPlane.batch_update_refs(repository.storage_key, updates, "stack-merge") do
|
|
545
|
+ |
{:ok, _result} ->
|
|
546
|
+ |
:ok
|
|
547
|
+ |
|
|
548
|
+ |
{:error, {:expected_mismatch, ref, actual}} ->
|
|
549
|
+ |
cleanup_temp_refs(repository, temp_refs)
|
|
550
|
+ |
{:error, {:head_changed, ref, actual}}
|
|
551
|
+ |
|
|
552
|
+ |
{:error, reason} ->
|
|
553
|
+ |
cleanup_temp_refs(repository, temp_refs)
|
|
554
|
+ |
{:error, {:ref_update_failed, reason}}
|
|
555
|
+ |
end
|
|
556
|
+ |
end
|
|
557
|
+ |
|
|
558
|
+ |
defp cleanup_temp_refs(repository, temp_refs) do
|
|
559
|
+ |
deletes = Enum.map(temp_refs, &%{ref: &1.ref, expected_old: &1.new, new: :delete})
|
|
560
|
+ |
_result = GitPlane.batch_update_refs(repository.storage_key, deletes, "stack-merge")
|
|
561
|
+ |
:ok
|
|
562
|
+ |
end
|
|
563
|
+ |
|
|
564
|
+ |
defp principal(operation), do: "stack-operation-" <> operation.id
|
|
565
|
+ |
|
|
566
|
+ |
## Finalization
|
|
567
|
+ |
|
|
568
|
+ |
defp finalize(operation, stack, plan) do
|
|
569
|
+ |
result =
|
|
570
|
+ |
Repo.transaction(fn ->
|
|
571
|
+ |
lock_repository_stacks(stack.repository_id)
|
|
572
|
+ |
current = Repo.one!(from s in Stack, where: s.id == ^stack.id, lock: "FOR UPDATE")
|
|
573
|
+ |
|
|
574
|
+ |
if current.version != operation.expected_stack_version do
|
|
575
|
+ |
Repo.rollback({:version_moved, current.version})
|
|
576
|
+ |
end
|
|
577
|
+ |
|
|
578
|
+ |
now = DateTime.utc_now()
|
|
579
|
+ |
Enum.each(Map.fetch!(plan, "merged"), &finalize_merged!(&1, operation, now))
|
|
580
|
+ |
Enum.each(Map.fetch!(plan, "restacked"), &finalize_restacked!(&1, current, plan))
|
|
581
|
+ |
|
|
582
|
+ |
stack_after = complete_or_bump!(current, plan)
|
|
583
|
+ |
record_events!(stack_after, operation, plan)
|
|
584
|
+ |
|
|
585
|
+ |
operation
|
|
586
|
+ |
|> Operation.transition_changeset(%{
|
|
587
|
+ |
state: "succeeded",
|
|
588
|
+ |
planned_result: Map.put(plan, "refs_applied", true),
|
|
589
|
+ |
completed_at: now
|
|
590
|
+ |
})
|
|
591
|
+ |
|> Repo.update!()
|
|
592
|
+ |
end)
|
|
593
|
+ |
|
|
594
|
+ |
case result do
|
|
595
|
+ |
{:ok, operation} -> {:ok, operation}
|
|
596
|
+ |
{:error, reason} -> partially_succeed(operation, plan, reason)
|
|
597
|
+ |
end
|
|
598
|
+ |
end
|
|
599
|
+ |
|
|
600
|
+ |
defp finalize_merged!(step, operation, now) do
|
|
601
|
+ |
entry_id = Map.fetch!(step, "entry_id")
|
|
602
|
+ |
pull_request_id = Map.fetch!(step, "pull_request_id")
|
|
603
|
+ |
|
|
604
|
+ |
{1, _rows} =
|
|
605
|
+ |
Repo.update_all(
|
|
606
|
+ |
from(entry in StackEntry, where: entry.id == ^entry_id and is_nil(entry.removed_at)),
|
|
607
|
+ |
set: [removed_at: now, updated_at: now]
|
|
608
|
+ |
)
|
|
609
|
+ |
|
|
610
|
+ |
pull_request =
|
|
611
|
+ |
Repo.one!(
|
|
612
|
+ |
from pr in PullRequest,
|
|
613
|
+ |
where: pr.id == ^pull_request_id,
|
|
614
|
+ |
lock: "FOR UPDATE"
|
|
615
|
+ |
)
|
|
616
|
+ |
|
|
617
|
+ |
{1, _rows} =
|
|
618
|
+ |
Repo.update_all(
|
|
619
|
+ |
from(pr in PullRequest, where: pr.id == ^pull_request_id),
|
|
620
|
+ |
set: [
|
|
621
|
+ |
state: "closed",
|
|
622
|
+ |
merged_at: now,
|
|
623
|
+ |
merged_by_user_id: operation.created_by_user_id,
|
|
624
|
+ |
merge_commit_sha: Map.fetch!(step, "merge_commit_sha"),
|
|
625
|
+ |
updated_at: now
|
|
626
|
+ |
]
|
|
627
|
+ |
)
|
|
628
|
+ |
|
|
629
|
+ |
{1, _rows} =
|
|
630
|
+ |
Repo.update_all(
|
|
631
|
+ |
from(issue in Issue, where: issue.id == ^pull_request.issue_id),
|
|
632
|
+ |
set: [
|
|
633
|
+ |
state: "closed",
|
|
634
|
+ |
state_reason: "completed",
|
|
635
|
+ |
closed_at: DateTime.truncate(now, :second),
|
|
636
|
+ |
updated_at: DateTime.truncate(now, :second)
|
|
637
|
+ |
]
|
|
638
|
+ |
)
|
|
639
|
+ |
|
|
640
|
+ |
:ok
|
|
641
|
+ |
end
|
|
642
|
+ |
|
|
643
|
+ |
# The lowest remaining layer retargets the trunk; every higher layer
|
|
644
|
+ |
# keeps its direct base and only its boundary and head advance.
|
|
645
|
+ |
defp finalize_restacked!(step, stack, plan) do
|
|
646
|
+ |
entry_id = Map.fetch!(step, "entry_id")
|
|
647
|
+ |
new_boundary = Map.fetch!(step, "new_boundary")
|
|
648
|
+ |
new_head = Map.fetch!(step, "new_head")
|
|
649
|
+ |
lowest? = Map.fetch!(step, "new_boundary") == Map.fetch!(plan, "trunk_new")
|
|
650
|
+ |
|
|
651
|
+ |
Repo.one!(from entry in StackEntry, where: entry.id == ^entry_id, lock: "FOR UPDATE")
|
|
652
|
+ |
|> StackEntry.changeset(%{boundary_oid: new_boundary, observed_head_oid: new_head})
|
|
653
|
+ |
|> Repo.update!()
|
|
654
|
+ |
|
|
655
|
+ |
base_ref_set = if lowest?, do: [base_ref: stack.trunk_ref], else: []
|
|
656
|
+ |
|
|
657
|
+ |
{1, _rows} =
|
|
658
|
+ |
Repo.update_all(
|
|
659
|
+ |
from(pr in PullRequest, where: pr.id == ^Map.fetch!(step, "pull_request_id")),
|
|
660
|
+ |
set: [head_sha: new_head, base_sha: new_boundary] ++ base_ref_set
|
|
661
|
+ |
)
|
|
662
|
+ |
|
|
663
|
+ |
:ok
|
|
664
|
+ |
end
|
|
665
|
+ |
|
|
666
|
+ |
defp complete_or_bump!(stack, plan) do
|
|
667
|
+ |
remaining = Map.fetch!(plan, "restacked") != []
|
|
668
|
+ |
state = if remaining, do: "open", else: "completed"
|
|
669
|
+ |
|
|
670
|
+ |
{1, [stack_after]} =
|
|
671
|
+ |
Repo.update_all(
|
|
672
|
+ |
from(s in Stack,
|
|
673
|
+ |
where: s.id == ^stack.id and s.version == ^stack.version,
|
|
674
|
+ |
select: s
|
|
675
|
+ |
),
|
|
676
|
+ |
set: [
|
|
677
|
+ |
version: stack.version + 1,
|
|
678
|
+ |
health: "healthy",
|
|
679
|
+ |
state: state,
|
|
680
|
+ |
updated_at: DateTime.utc_now()
|
|
681
|
+ |
]
|
|
682
|
+ |
)
|
|
683
|
+ |
|
|
684
|
+ |
stack_after
|
|
685
|
+ |
end
|
|
686
|
+ |
|
|
687
|
+ |
defp record_events!(stack, operation, plan) do
|
|
688
|
+ |
record_event!(stack, operation, "pull_request_stack.merged", %{
|
|
689
|
+ |
"operation_id" => operation.id,
|
|
690
|
+ |
"merge_method" => Map.fetch!(plan, "merge_method"),
|
|
691
|
+ |
"trunk_old" => Map.fetch!(plan, "trunk_old"),
|
|
692
|
+ |
"trunk_new" => Map.fetch!(plan, "trunk_new"),
|
|
693
|
+ |
"merged" => Map.fetch!(plan, "merged")
|
|
694
|
+ |
})
|
|
695
|
+ |
|
|
696
|
+ |
Enum.each(Map.fetch!(plan, "restacked"), fn step ->
|
|
697
|
+ |
record_event!(stack, operation, "pull_request.synchronize", %{
|
|
698
|
+ |
"operation_id" => operation.id,
|
|
699
|
+ |
"pull_request" => Map.fetch!(step, "pull_request_number"),
|
|
700
|
+ |
"ref" => Map.fetch!(step, "ref"),
|
|
701
|
+ |
"before" => Map.fetch!(step, "old_head"),
|
|
702
|
+ |
"after" => Map.fetch!(step, "new_head")
|
|
703
|
+ |
})
|
|
704
|
+ |
end)
|
|
705
|
+ |
end
|
|
706
|
+ |
|
|
707
|
+ |
defp record_event!(stack, operation, event_type, payload) do
|
|
708
|
+ |
%StackEvent{}
|
|
709
|
+ |
|> StackEvent.changeset(%{
|
|
710
|
+ |
stack_id: stack.id,
|
|
711
|
+ |
actor_user_id: operation.created_by_user_id,
|
|
712
|
+ |
event_type: event_type,
|
|
713
|
+ |
stack_version: stack.version,
|
|
714
|
+ |
payload: payload
|
|
715
|
+ |
})
|
|
716
|
+ |
|> Repo.insert!()
|
|
717
|
+ |
end
|
|
718
|
+ |
|
|
719
|
+ |
## Terminal transitions
|
|
720
|
+ |
|
|
721
|
+ |
# The refs landed but the metadata could not finalize: the merged pull
|
|
722
|
+ |
# requests' code is on the trunk, so the operation records exactly what
|
|
723
|
+ |
# applied instead of pretending nothing happened.
|
|
724
|
+ |
defp partially_succeed(operation, plan, reason) do
|
|
725
|
+ |
operation =
|
|
726
|
+ |
operation
|
|
727
|
+ |
|> Operation.transition_changeset(%{
|
|
728
|
+ |
state: "partially_succeeded",
|
|
729
|
+ |
error: error_map(reason),
|
|
730
|
+ |
planned_result: Map.put(plan, "refs_applied", true),
|
|
731
|
+ |
completed_at: DateTime.utc_now()
|
|
732
|
+ |
})
|
|
733
|
+ |
|> Repo.update!()
|
|
734
|
+ |
|
|
735
|
+ |
{:error, operation}
|
|
736
|
+ |
end
|
|
737
|
+ |
|
|
738
|
+ |
defp fail(operation, stack, reason) do
|
|
739
|
+ |
{:ok, operation} =
|
|
740
|
+ |
Repo.transaction(fn ->
|
|
741
|
+ |
current = Repo.one!(from s in Stack, where: s.id == ^stack.id, lock: "FOR UPDATE")
|
|
742
|
+ |
set_health!(current, failure_health(reason))
|
|
743
|
+ |
|
|
744
|
+ |
operation
|
|
745
|
+ |
|> Operation.transition_changeset(%{
|
|
746
|
+ |
state: "failed",
|
|
747
|
+ |
error: error_map(reason),
|
|
748
|
+ |
completed_at: DateTime.utc_now()
|
|
749
|
+ |
})
|
|
750
|
+ |
|> Repo.update!()
|
|
751
|
+ |
end)
|
|
752
|
+ |
|
|
753
|
+ |
{:error, operation}
|
|
754
|
+ |
end
|
|
755
|
+ |
|
|
756
|
+ |
defp failure_health({:head_changed, _ref, _actual}), do: "head_changed"
|
|
757
|
+ |
defp failure_health({:missing_ref, _ref}), do: "missing_ref"
|
|
758
|
+ |
defp failure_health({:upper_restack_conflict, _conflict}), do: "conflicted"
|
|
759
|
+ |
defp failure_health(:needs_rebase), do: "needs_rebase"
|
|
760
|
+ |
defp failure_health(_reason), do: "needs_rebase"
|
|
761
|
+ |
|
|
762
|
+ |
defp error_map({:head_changed, ref, actual}),
|
|
763
|
+ |
do: %{"code" => "head_changed", "ref" => ref, "actual" => stringify_actual(actual)}
|
|
764
|
+ |
|
|
765
|
+ |
defp error_map({:missing_ref, ref}), do: %{"code" => "missing_ref", "ref" => ref}
|
|
766
|
+ |
|
|
767
|
+ |
defp error_map({:upper_restack_conflict, conflict}),
|
|
768
|
+ |
do: Map.put(conflict, "code", "upper_restack_conflict")
|
|
769
|
+ |
|
|
770
|
+ |
defp error_map({:replay_failed, position, reason}),
|
|
771
|
+ |
do: %{"code" => "replay_failed", "position" => position, "reason" => inspect(reason)}
|
|
772
|
+ |
|
|
773
|
+ |
defp error_map({:retention_failed, reason}),
|
|
774
|
+ |
do: %{"code" => "retention_failed", "reason" => inspect(reason)}
|
|
775
|
+ |
|
|
776
|
+ |
defp error_map({:ref_update_failed, reason}),
|
|
777
|
+ |
do: %{"code" => "ref_update_failed", "reason" => inspect(reason)}
|
|
778
|
+ |
|
|
779
|
+ |
defp error_map({:version_moved, version}),
|
|
780
|
+ |
do: %{"code" => "version_moved", "stack_version" => version}
|
|
781
|
+ |
|
|
782
|
+ |
defp error_map(reason) when is_atom(reason), do: %{"code" => Atom.to_string(reason)}
|
|
783
|
+ |
defp error_map(reason), do: %{"code" => "operation_failed", "reason" => inspect(reason)}
|
|
784
|
+ |
|
|
785
|
+ |
defp stringify_actual(:absent), do: "absent"
|
|
786
|
+ |
defp stringify_actual(oid), do: oid
|
|
787
|
+ |
|
|
788
|
+ |
## Request validation
|
|
789
|
+ |
|
|
790
|
+ |
defp authorize(repository, actor) do
|
|
791
|
+ |
if Repositories.writable?(repository, actor), do: :ok, else: {:error, :forbidden}
|
|
792
|
+ |
end
|
|
793
|
+ |
|
|
794
|
+ |
defp parse_merge_request(params) do
|
|
795
|
+ |
with {:ok, number} <- parse_pull_request_number(params),
|
|
796
|
+ |
{:ok, method} <- parse_merge_method(params),
|
|
797
|
+ |
:ok <- parse_merge_action(params),
|
|
798
|
+ |
{:ok, version} <- parse_expected_version(params),
|
|
799
|
+ |
{:ok, expected_heads} <- parse_expected_heads(params) do
|
|
800
|
+ |
{:ok,
|
|
801
|
+ |
%{
|
|
802
|
+ |
"pull_request_number" => number,
|
|
803
|
+ |
"merge_method" => method,
|
|
804
|
+ |
"merge_action" => "direct_merge",
|
|
805
|
+ |
"expected_stack_version" => version,
|
|
806
|
+ |
"expected_heads" => expected_heads
|
|
807
|
+ |
}}
|
|
808
|
+ |
end
|
|
809
|
+ |
end
|
|
810
|
+ |
|
|
811
|
+ |
defp parse_pull_request_number(params) do
|
|
812
|
+ |
case Map.get(params, "pull_request_number") do
|
|
813
|
+ |
number when is_integer(number) and number >= 1 -> {:ok, number}
|
|
814
|
+ |
_other -> {:error, :invalid_request}
|
|
815
|
+ |
end
|
|
816
|
+ |
end
|
|
817
|
+ |
|
|
818
|
+ |
defp parse_merge_method(params) do
|
|
819
|
+ |
case Map.get(params, "merge_method") do
|
|
820
|
+ |
method when method in @merge_methods -> {:ok, method}
|
|
821
|
+ |
_other -> {:error, :invalid_request}
|
|
822
|
+ |
end
|
|
823
|
+ |
end
|
|
824
|
+ |
|
|
825
|
+ |
# The merge queue lands as its own slice; until then only a direct merge
|
|
826
|
+ |
# is available.
|
|
827
|
+ |
defp parse_merge_action(params) do
|
|
828
|
+ |
case Map.get(params, "merge_action", "direct_merge") do
|
|
829
|
+ |
"direct_merge" -> :ok
|
|
830
|
+ |
"queue" -> {:error, :merge_queue_unavailable}
|
|
831
|
+ |
_other -> {:error, :invalid_request}
|
|
832
|
+ |
end
|
|
833
|
+ |
end
|
|
834
|
+ |
|
|
835
|
+ |
defp parse_expected_version(params) do
|
|
836
|
+ |
case Map.get(params, "expected_stack_version") do
|
|
837
|
+ |
nil -> {:ok, nil}
|
|
838
|
+ |
version when is_integer(version) and version >= 1 -> {:ok, version}
|
|
839
|
+ |
_other -> {:error, :invalid_request}
|
|
840
|
+ |
end
|
|
841
|
+ |
end
|
|
842
|
+ |
|
|
843
|
+ |
defp parse_expected_heads(params) do
|
|
844
|
+ |
case Map.get(params, "expected_heads") do
|
|
845
|
+ |
nil ->
|
|
846
|
+ |
{:ok, nil}
|
|
847
|
+ |
|
|
848
|
+ |
heads when is_map(heads) ->
|
|
849
|
+ |
if Enum.all?(heads, fn {_key, oid} -> valid_oid_string?(oid) end),
|
|
850
|
+ |
do: {:ok, heads},
|
|
851
|
+ |
else: {:error, :invalid_request}
|
|
852
|
+ |
|
|
853
|
+ |
_other ->
|
|
854
|
+ |
{:error, :invalid_request}
|
|
855
|
+ |
end
|
|
856
|
+ |
end
|
|
857
|
+ |
|
|
858
|
+ |
defp valid_oid_string?(oid) when is_binary(oid) and byte_size(oid) in [40, 64] do
|
|
859
|
+ |
match?({:ok, _raw}, Base.decode16(oid, case: :lower))
|
|
860
|
+ |
end
|
|
861
|
+ |
|
|
862
|
+ |
defp valid_oid_string?(_oid), do: false
|
|
863
|
+ |
|
|
864
|
+ |
defp selected_position(stack, request, replay) do
|
|
865
|
+ |
number =
|
|
866
|
+ |
case replay do
|
|
867
|
+ |
%Operation{request: replayed} -> Map.fetch!(replayed, "pull_request_number")
|
|
868
|
+ |
nil -> Map.fetch!(request, "pull_request_number")
|
|
869
|
+ |
end
|
|
870
|
+ |
|
|
871
|
+ |
entries = active_entries(stack)
|
|
872
|
+ |
|
|
873
|
+ |
case Enum.find(entries, &(&1.pull_request.issue.number == number)) do
|
|
874
|
+ |
%StackEntry{position: position} -> {:ok, position}
|
|
875
|
+ |
nil -> {:error, :pull_request_not_in_stack}
|
|
876
|
+ |
end
|
|
877
|
+ |
end
|
|
878
|
+ |
|
|
879
|
+ |
defp check_idempotency(stack, key, request) do
|
|
880
|
+ |
operation =
|
|
881
|
+ |
Repo.one(
|
|
882
|
+ |
from operation in Operation,
|
|
883
|
+ |
where: operation.stack_id == ^stack.id and operation.idempotency_key == ^key,
|
|
884
|
+ |
lock: "FOR UPDATE"
|
|
885
|
+ |
)
|
|
886
|
+ |
|
|
887
|
+ |
case operation do
|
|
888
|
+ |
nil ->
|
|
889
|
+ |
{:ok, nil}
|
|
890
|
+ |
|
|
891
|
+ |
%Operation{} = operation ->
|
|
892
|
+ |
if Map.drop(operation.request, ["previous_health"]) == request,
|
|
893
|
+ |
do: {:ok, operation},
|
|
894
|
+ |
else: {:error, :idempotency_conflict}
|
|
895
|
+ |
end
|
|
896
|
+ |
end
|
|
897
|
+ |
|
|
898
|
+ |
defp ensure_no_active_operation(_stack, %Operation{}), do: :ok
|
|
899
|
+ |
|
|
900
|
+ |
defp ensure_no_active_operation(stack, nil) do
|
|
901
|
+ |
active =
|
|
902
|
+ |
Repo.exists?(
|
|
903
|
+ |
from operation in Operation,
|
|
904
|
+ |
where:
|
|
905
|
+ |
operation.stack_id == ^stack.id and
|
|
906
|
+ |
operation.state in ^Operation.active_states()
|
|
907
|
+ |
)
|
|
908
|
+ |
|
|
909
|
+ |
if active, do: {:error, :operation_in_progress}, else: :ok
|
|
910
|
+ |
end
|
|
911
|
+ |
|
|
912
|
+ |
defp validate_expected_version(nil, _stack), do: :ok
|
|
913
|
+ |
defp validate_expected_version(version, %Stack{version: version}), do: :ok
|
|
914
|
+ |
defp validate_expected_version(_version, %Stack{}), do: {:error, :stale_stack_version}
|
|
915
|
+ |
|
|
916
|
+ |
defp insert_operation!(stack, actor, key, request, position) do
|
|
917
|
+ |
%Operation{}
|
|
918
|
+ |
|> Operation.changeset(%{
|
|
919
|
+ |
stack_id: stack.id,
|
|
920
|
+ |
created_by_user_id: actor.id,
|
|
921
|
+ |
kind: "merge",
|
|
922
|
+ |
state: "pending",
|
|
923
|
+ |
target_position: position,
|
|
924
|
+ |
expected_stack_version: request["expected_stack_version"] || stack.version,
|
|
925
|
+ |
idempotency_key: key,
|
|
926
|
+ |
request: Map.put(request, "previous_health", stack.health),
|
|
927
|
+ |
retry_at: DateTime.utc_now()
|
|
928
|
+ |
})
|
|
929
|
+ |
|> Repo.insert!()
|
|
930
|
+ |
end
|
|
931
|
+ |
|
|
932
|
+ |
## Shared helpers
|
|
933
|
+ |
|
|
934
|
+ |
defp tree_of(repository, oid) do
|
|
935
|
+ |
case GitPlane.tree_of(repository.storage_key, oid) do
|
|
936
|
+ |
{:ok, tree} -> {:ok, tree}
|
|
937
|
+ |
{:error, reason} -> {:error, {:tree_read_failed, reason}}
|
|
938
|
+ |
end
|
|
939
|
+ |
end
|
|
940
|
+ |
|
|
941
|
+ |
defp commit_author(repository, oid) do
|
|
942
|
+ |
case GitPlane.commit_author(repository.storage_key, oid) do
|
|
943
|
+ |
{:ok, author} -> {:ok, author}
|
|
944
|
+ |
{:error, reason} -> {:error, {:author_read_failed, reason}}
|
|
945
|
+ |
end
|
|
946
|
+ |
end
|
|
947
|
+ |
|
|
948
|
+ |
defp commit_tree(repository, tree, parents, message, opts \\ []) do
|
|
949
|
+ |
case GitPlane.commit_tree(repository.storage_key, tree, parents, message, opts) do
|
|
950
|
+ |
{:ok, oid} -> {:ok, oid}
|
|
951
|
+ |
{:error, reason} -> {:error, {:commit_build_failed, reason}}
|
|
952
|
+ |
end
|
|
953
|
+ |
end
|
|
954
|
+ |
|
|
955
|
+ |
defp load_stack(stack_id) do
|
|
956
|
+ |
Stack
|
|
957
|
+ |
|> Repo.get!(stack_id)
|
|
958
|
+ |
|> Repo.preload(
|
|
959
|
+ |
entries:
|
|
960
|
+ |
from(entry in StackEntry,
|
|
961
|
+ |
where: is_nil(entry.removed_at),
|
|
962
|
+ |
order_by: [asc: entry.position],
|
|
963
|
+ |
preload: [pull_request: :issue]
|
|
964
|
+ |
)
|
|
965
|
+ |
)
|
|
966
|
+ |
end
|
|
967
|
+ |
|
|
968
|
+ |
defp active_entries(%Stack{id: stack_id}) do
|
|
969
|
+ |
Repo.all(
|
|
970
|
+ |
from entry in StackEntry,
|
|
971
|
+ |
where: entry.stack_id == ^stack_id and is_nil(entry.removed_at),
|
|
972
|
+ |
order_by: [asc: entry.position],
|
|
973
|
+ |
preload: [pull_request: :issue]
|
|
974
|
+ |
)
|
|
975
|
+ |
end
|
|
976
|
+ |
|
|
977
|
+ |
defp get_stack_for_update(%Repository{id: repository_id}, number) do
|
|
978
|
+ |
case Repo.one(
|
|
979
|
+ |
from stack in Stack,
|
|
980
|
+ |
where: stack.repository_id == ^repository_id and stack.number == ^number,
|
|
981
|
+ |
lock: "FOR UPDATE"
|
|
982
|
+ |
) do
|
|
983
|
+ |
nil -> {:error, :stack_not_found}
|
|
984
|
+ |
stack -> {:ok, stack}
|
|
985
|
+ |
end
|
|
986
|
+ |
end
|
|
987
|
+ |
|
|
988
|
+ |
defp validate_open(%Stack{state: "open"}), do: :ok
|
|
989
|
+ |
defp validate_open(%Stack{}), do: {:error, :stack_not_open}
|
|
990
|
+ |
|
|
991
|
+ |
defp lock_repository_stacks(repository_id) do
|
|
992
|
+ |
key = "pull_request_stacks:#{repository_id}"
|
|
993
|
+ |
Repo.query!("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [key])
|
|
994
|
+ |
:ok
|
|
995
|
+ |
end
|
|
996
|
+ |
|
|
997
|
+ |
defp set_health!(%Stack{} = stack, health) do
|
|
998
|
+ |
stack
|
|
999
|
+ |
|> Stack.changeset(%{health: health})
|
|
1000
|
+ |
|> Repo.update!()
|
|
1001
|
+ |
end
|
|
1002
|
+ |
end
|