Unstack and dissolve stacks through the API

47de26d4f45e · Devin AI · · parent 9606cbce32e8

Unstack and dissolve stacks through the API

POST /stacks/:stack_number/unstack releases the top layer - the only
layer that can leave without breaking the direct-base chain - and POST
/stacks/:stack_number/dissolve releases every layer at once and closes
the stack. Both run in the same idempotent metadata transaction as
create and append, refuse while a durable operation is active, and
leave the pull requests and branches untouched. An emptied stack
dissolves itself.

A stack now holds at most 100 entries, enforced on create and append.

Part of #113.

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

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents/stacks.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/stack_controller.ex
  • modified lib/openagents_web/router.ex
  • modified test/openagents/stacks_test.exs
  • modified test/openagents_web/controllers/stack_controller_test.exs

Diff

6 files changed, +453 -0

lib/openagents/stacks.ex modified +212

@@ -16,10 +16,16 @@ defmodule OpenAgents.Stacks do

16 16
  alias OpenAgents.Repositories
17 17
  alias OpenAgents.Repositories.Repository
18 18
  alias OpenAgents.Stacks.IdempotencyRequest
19
  alias OpenAgents.Stacks.Operation
19 20
  alias OpenAgents.Stacks.Stack
20 21
  alias OpenAgents.Stacks.StackEntry
21 22
  alias OpenAgents.Stacks.StackEvent
22 23
24
  @max_entries 100
25
26
  @doc "The maximum number of active entries one stack may hold."
27
  def max_entries, do: @max_entries
28
23 29
  @doc """
24 30
  Creates a stack from pull requests ordered bottom to top.
25 31

@@ -90,6 +96,57 @@ defmodule OpenAgents.Stacks do

90 96
    end
91 97
  end
92 98
99
  @doc """
100
  Removes the top pull request from an open stack.
101
102
  Only the top layer can leave without breaking the direct-base chain, so
103
  the named pull request must hold the highest active position. The pull
104
  request and its branch stay untouched; only the stack membership ends.
105
  An emptied stack dissolves.
106
  """
107
  def unstack_from_api(
108
        %Repository{} = repository,
109
        number,
110
        params,
111
        %User{} = actor,
112
        idempotency_key
113
      )
114
      when is_integer(number) and is_binary(idempotency_key) do
115
    with :ok <- authorize(repository, actor),
116
         {:ok, request} <- parse_unstack_request(params) do
117
      request_digest = digest({:unstack, number, request})
118
119
      run_idempotent(repository, actor, "stack_unstack", idempotency_key, request_digest, fn ->
120
        unstack_rows(repository, number, request, actor, idempotency_key, request_digest)
121
      end)
122
    end
123
  end
124
125
  @doc """
126
  Dissolves an open stack, releasing every layer at once.
127
128
  Every active entry is removed and the stack transitions to `dissolved`.
129
  The pull requests and their branches stay untouched; each becomes an
130
  ordinary standalone pull request against its current base.
131
  """
132
  def dissolve_from_api(
133
        %Repository{} = repository,
134
        number,
135
        params,
136
        %User{} = actor,
137
        idempotency_key
138
      )
139
      when is_integer(number) and is_binary(idempotency_key) do
140
    with :ok <- authorize(repository, actor),
141
         {:ok, request} <- parse_dissolve_request(params) do
142
      request_digest = digest({:dissolve, number, request})
143
144
      run_idempotent(repository, actor, "stack_dissolve", idempotency_key, request_digest, fn ->
145
        dissolve_rows(repository, number, request, actor, idempotency_key, request_digest)
146
      end)
147
    end
148
  end
149
93 150
  @doc """
94 151
  The review ranges for a stacked pull request.
95 152

@@ -290,6 +347,9 @@ defmodule OpenAgents.Stacks do

290 347
291 348
  defp validate_structure(%Repository{id: repository_id}, pull_requests) do
292 349
    cond do
350
      length(pull_requests) > @max_entries ->
351
        {:error, :stack_too_large}
352
293 353
      Enum.any?(pull_requests, &(&1.repository_id != repository_id)) ->
294 354
        {:error, :repository_mismatch}
295 355

@@ -556,6 +616,155 @@ defmodule OpenAgents.Stacks do

556 616
    end
557 617
  end
558 618
619
  defp parse_unstack_request(params) do
620
    with {:ok, number} <- pull_request_number(Map.get(params, "pull_request")),
621
         {:ok, version} <- expected_version(Map.get(params, "expected_stack_version")) do
622
      {:ok, %{pull_request: number, expected_stack_version: version}}
623
    end
624
  end
625
626
  defp parse_dissolve_request(params) do
627
    with {:ok, version} <- expected_version(Map.get(params, "expected_stack_version")) do
628
      {:ok, %{expected_stack_version: version}}
629
    end
630
  end
631
632
  defp unstack_rows(repository, number, request, actor, idempotency_key, request_digest) do
633
    with {:ok, stack} <- get_stack_for_update(repository, number),
634
         :ok <- validate_open(stack),
635
         :ok <- ensure_no_active_operation(stack),
636
         :ok <- validate_expected_version(request.expected_stack_version, stack),
637
         {:ok, top, entries} <- top_entry(stack),
638
         :ok <- validate_unstack_target(entries, top, request.pull_request),
639
         {:ok, stack} <- bump_version(stack) do
640
      now = DateTime.utc_now()
641
      remaining = List.delete(entries, top)
642
643
      {1, _rows} =
644
        Repo.update_all(
645
          from(entry in StackEntry, where: entry.id == ^top.id and is_nil(entry.removed_at)),
646
          set: [removed_at: now, updated_at: now]
647
        )
648
649
      record_event!(stack, "pull_request.unstacked", actor, %{
650
        "stack_number" => stack.number,
651
        "trunk_ref" => stack.trunk_ref,
652
        "pull_request" => top.pull_request.issue.number,
653
        "reason" => "unstacked",
654
        "ordering_old" => Enum.map(entries, & &1.pull_request.issue.number),
655
        "ordering_new" => Enum.map(remaining, & &1.pull_request.issue.number)
656
      })
657
658
      stack = dissolve_when_empty!(stack, remaining, actor)
659
      record_idempotency!(actor, "stack_unstack", idempotency_key, request_digest, stack.id)
660
      {%{stack | entries: remaining}, :created}
661
    else
662
      {:error, reason} -> Repo.rollback(reason)
663
    end
664
  end
665
666
  defp dissolve_rows(repository, number, request, actor, idempotency_key, request_digest) do
667
    with {:ok, stack} <- get_stack_for_update(repository, number),
668
         :ok <- validate_open(stack),
669
         :ok <- ensure_no_active_operation(stack),
670
         :ok <- validate_expected_version(request.expected_stack_version, stack) do
671
      entries = active_entries(stack)
672
      now = DateTime.utc_now()
673
      entry_ids = Enum.map(entries, & &1.id)
674
675
      {_count, _rows} =
676
        Repo.update_all(
677
          from(entry in StackEntry,
678
            where: entry.id in ^entry_ids and is_nil(entry.removed_at)
679
          ),
680
          set: [removed_at: now, updated_at: now]
681
        )
682
683
      {1, [stack_after]} =
684
        Repo.update_all(
685
          from(stack_row in Stack,
686
            where:
687
              stack_row.id == ^stack.id and stack_row.version == ^stack.version and
688
                stack_row.state == "open",
689
            select: stack_row
690
          ),
691
          set: [state: "dissolved", version: stack.version + 1, updated_at: now]
692
        )
693
694
      Enum.each(entries, fn entry ->
695
        record_event!(stack_after, "pull_request.unstacked", actor, %{
696
          "stack_number" => stack_after.number,
697
          "trunk_ref" => stack_after.trunk_ref,
698
          "pull_request" => entry.pull_request.issue.number,
699
          "reason" => "dissolved"
700
        })
701
      end)
702
703
      record_event!(stack_after, "pull_request_stack.dissolved", actor, %{
704
        "stack_number" => stack_after.number,
705
        "trunk_ref" => stack_after.trunk_ref,
706
        "ordering_old" => Enum.map(entries, & &1.pull_request.issue.number),
707
        "ordering_new" => []
708
      })
709
710
      record_idempotency!(actor, "stack_dissolve", idempotency_key, request_digest, stack.id)
711
      {%{stack_after | entries: []}, :created}
712
    else
713
      {:error, reason} -> Repo.rollback(reason)
714
    end
715
  end
716
717
  defp validate_unstack_target(entries, top, number) do
718
    cond do
719
      Enum.all?(entries, &(&1.pull_request.issue.number != number)) ->
720
        {:error, :pull_request_not_in_stack}
721
722
      top.pull_request.issue.number != number ->
723
        {:error, :not_stack_top}
724
725
      true ->
726
        :ok
727
    end
728
  end
729
730
  defp dissolve_when_empty!(stack, [], actor) do
731
    {1, [stack_after]} =
732
      Repo.update_all(
733
        from(stack_row in Stack,
734
          where: stack_row.id == ^stack.id and stack_row.state == "open",
735
          select: stack_row
736
        ),
737
        set: [state: "dissolved", version: stack.version + 1, updated_at: DateTime.utc_now()]
738
      )
739
740
    record_event!(stack_after, "pull_request_stack.dissolved", actor, %{
741
      "stack_number" => stack_after.number,
742
      "trunk_ref" => stack_after.trunk_ref,
743
      "ordering_old" => [],
744
      "ordering_new" => []
745
    })
746
747
    stack_after
748
  end
749
750
  defp dissolve_when_empty!(stack, _remaining, _actor), do: stack
751
752
  defp ensure_no_active_operation(stack) do
753
    active =
754
      Repo.one(
755
        from operation in Operation,
756
          where:
757
            operation.stack_id == ^stack.id and
758
              operation.state in ^Operation.active_states(),
759
          limit: 1
760
      )
761
762
    case active do
763
      nil -> :ok
764
      %Operation{id: id} -> {:error, {:operation_in_progress, id}}
765
    end
766
  end
767
559 768
  defp ref_param(params, key) do
560 769
    case Map.get(params, key) do
561 770
      value when is_binary(value) and value != "" -> {:ok, value}

@@ -772,6 +981,9 @@ defmodule OpenAgents.Stacks do

772 981
    branches = [stack.trunk_ref | Enum.map(entries, & &1.pull_request.head_ref)]
773 982
774 983
    cond do
984
      length(entries) >= @max_entries ->
985
        {:error, :stack_too_large}
986
775 987
      pull_request.head_repository_id != repository.id ->
776 988
        {:error, :cross_repository_head}
777 989
lib/openagents_web/api_route_authority.ex modified +2

@@ -130,6 +130,8 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

130 130
      "put /api/v3/repos/:owner/:repo/pulls/:pull_number/merge-async" => :required_bearer,
131 131
      "post /api/v3/repos/:owner/:repo/stacks" => :required_bearer,
132 132
      "post /api/v3/repos/:owner/:repo/stacks/:stack_number/append" => :required_bearer,
133
      "post /api/v3/repos/:owner/:repo/stacks/:stack_number/unstack" => :required_bearer,
134
      "post /api/v3/repos/:owner/:repo/stacks/:stack_number/dissolve" => :required_bearer,
133 135
      "post /api/v3/repos/:owner/:repo/stacks/:stack_number/rebase" => :required_bearer,
134 136
      "post /api/v3/repos/:owner/:repo/stacks/:stack_number/merge" => :required_bearer,
135 137
      "post /api/v3/repos/:owner/:repo/stacks/:stack_number/operations/:operation_id/continue" =>
lib/openagents_web/controllers/stack_controller.ex modified +44

@@ -58,6 +58,46 @@ defmodule OpenAgentsWeb.StackController do

58 58
    Ecto.NoResultsError -> not_found(conn)
59 59
  end
60 60
61
  def unstack(conn, %{"owner" => owner, "repo" => repo, "stack_number" => number} = params) do
62
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns.current_user)
63
64
    with {:ok, idempotency_key} <- idempotency_key(conn),
65
         {:ok, {stack, replay_state}} <-
66
           Stacks.unstack_from_api(
67
             repository,
68
             ControllerHelpers.integer_param!(number),
69
             params,
70
             conn.assigns.current_user,
71
             idempotency_key
72
           ) do
73
      render(conn, :show, stack: stack, owner: owner, repo: repo, replay_state: replay_state)
74
    else
75
      {:error, reason} -> render_error(conn, reason)
76
    end
77
  rescue
78
    Ecto.NoResultsError -> not_found(conn)
79
  end
80
81
  def dissolve(conn, %{"owner" => owner, "repo" => repo, "stack_number" => number} = params) do
82
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns.current_user)
83
84
    with {:ok, idempotency_key} <- idempotency_key(conn),
85
         {:ok, {stack, replay_state}} <-
86
           Stacks.dissolve_from_api(
87
             repository,
88
             ControllerHelpers.integer_param!(number),
89
             params,
90
             conn.assigns.current_user,
91
             idempotency_key
92
           ) do
93
      render(conn, :show, stack: stack, owner: owner, repo: repo, replay_state: replay_state)
94
    else
95
      {:error, reason} -> render_error(conn, reason)
96
    end
97
  rescue
98
    Ecto.NoResultsError -> not_found(conn)
99
  end
100
61 101
  def rebase(conn, %{"owner" => owner, "repo" => repo, "stack_number" => number} = params) do
62 102
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns.current_user)
63 103

@@ -259,6 +299,7 @@ defmodule OpenAgentsWeb.StackController do

259 299
              :already_stacked,
260 300
              :not_stack_top,
261 301
              :not_stacked,
302
              :stack_too_large,
262 303
              :resolution_not_found,
263 304
              :resolution_parent_mismatch,
264 305
              :pull_request_not_in_stack

@@ -304,6 +345,9 @@ defmodule OpenAgentsWeb.StackController do

304 345
  defp message(:not_stacked),
305 346
    do: "The pull request does not belong to an active stack."
306 347
348
  defp message(:stack_too_large),
349
    do: "A stack holds at most #{Stacks.max_entries()} pull requests."
350
307 351
  defp message(:resolution_parent_mismatch),
308 352
    do: "The resolution commit does not build on the persisted parent."
309 353
lib/openagents_web/router.ex modified +2

@@ -549,6 +549,8 @@ defmodule OpenAgentsWeb.Router do

549 549
    put "/repos/:owner/:repo/pulls/:pull_number/merge-async", StackController, :merge_async
550 550
    post "/repos/:owner/:repo/stacks", StackController, :create
551 551
    post "/repos/:owner/:repo/stacks/:stack_number/append", StackController, :append
552
    post "/repos/:owner/:repo/stacks/:stack_number/unstack", StackController, :unstack
553
    post "/repos/:owner/:repo/stacks/:stack_number/dissolve", StackController, :dissolve
552 554
    post "/repos/:owner/:repo/stacks/:stack_number/rebase", StackController, :rebase
553 555
    post "/repos/:owner/:repo/stacks/:stack_number/merge", StackController, :merge
554 556
test/openagents/stacks_test.exs modified +10

@@ -86,6 +86,16 @@ defmodule OpenAgents.StacksTest do

86 86
      assert {:ok, %Stack{number: 2}} = Stacks.create(first_repository, [next], user)
87 87
    end
88 88
89
    test "rejects a stack above the maximum size" do
90
      user = repository_user_fixture("stack-author")
91
      repository = repository_fixture()
92
93
      refs = Enum.map(1..(Stacks.max_entries() + 1), &"layer-#{&1}")
94
      pull_requests = chain(repository, refs)
95
96
      assert {:error, :stack_too_large} = Stacks.create(repository, pull_requests, user)
97
    end
98
89 99
    test "rejects an empty stack" do
90 100
      user = repository_user_fixture("stack-author")
91 101
      repository = repository_fixture()
test/openagents_web/controllers/stack_controller_test.exs modified +183

@@ -318,6 +318,189 @@ defmodule OpenAgentsWeb.StackControllerTest do

318 318
    end
319 319
  end
320 320
321
  describe "POST /api/v3/repos/:owner/:repo/stacks/:stack_number/unstack" do
322
    test "removes the top layer, records the event, and replays retries", %{conn: conn} do
323
      repository = repository_fixture()
324
      oids = seed_chain(repository, ["layer-1", "layer-2"])
325
      [pr_1, pr_2] = pull_request_chain(repository, oids, ["layer-1", "layer-2"])
326
      conn = put_forge_api_token(conn, "stack-unstack", repository)
327
328
      assert %{"number" => 1, "version" => 1} =
329
               conn
330
               |> put_req_header("idempotency-key", "unstack-create-1")
331
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1, pr_2]})
332
               |> json_response(201)
333
334
      unstack_conn =
335
        conn
336
        |> put_req_header("idempotency-key", "unstack-1")
337
        |> post("#{path(repository)}/1/unstack", %{
338
          pull_request: pr_2,
339
          expected_stack_version: 1
340
        })
341
342
      assert %{
343
               "state" => "open",
344
               "version" => 2,
345
               "size" => 1,
346
               "replayed" => false,
347
               "entries" => [%{"position" => 1}]
348
             } = json_response(unstack_conn, 200)
349
350
      assert Repo.exists?(
351
               from event in StackEvent,
352
                 where:
353
                   event.event_type == "pull_request.unstacked" and
354
                     fragment("?->>'reason' = 'unstacked'", event.payload)
355
             )
356
357
      replay_conn =
358
        conn
359
        |> put_req_header("idempotency-key", "unstack-1")
360
        |> post("#{path(repository)}/1/unstack", %{
361
          pull_request: pr_2,
362
          expected_stack_version: 1
363
        })
364
365
      assert %{"size" => 1, "replayed" => true} = json_response(replay_conn, 200)
366
    end
367
368
    test "dissolves the stack when the last layer leaves", %{conn: conn} do
369
      repository = repository_fixture()
370
      oids = seed_chain(repository, ["layer-1"])
371
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
372
      conn = put_forge_api_token(conn, "stack-unstack-last", repository)
373
374
      assert %{"number" => 1} =
375
               conn
376
               |> put_req_header("idempotency-key", "unstack-last-create")
377
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
378
               |> json_response(201)
379
380
      unstack_conn =
381
        conn
382
        |> put_req_header("idempotency-key", "unstack-last-1")
383
        |> post("#{path(repository)}/1/unstack", %{pull_request: pr_1})
384
385
      assert %{"state" => "dissolved", "size" => 0, "entries" => []} =
386
               json_response(unstack_conn, 200)
387
388
      assert Repo.exists?(
389
               from event in StackEvent,
390
                 where: event.event_type == "pull_request_stack.dissolved"
391
             )
392
    end
393
394
    test "rejects a non-top layer and an unknown pull request", %{conn: conn} do
395
      repository = repository_fixture()
396
      oids = seed_chain(repository, ["layer-1", "layer-2"])
397
      [pr_1, _pr_2] = pull_request_chain(repository, oids, ["layer-1", "layer-2"])
398
      conn = put_forge_api_token(conn, "stack-unstack-reject", repository)
399
400
      assert %{"number" => 1} =
401
               conn
402
               |> put_req_header("idempotency-key", "unstack-reject-create")
403
               |> post(path(repository), %{
404
                 trunk_ref: "main",
405
                 pull_requests: [pr_1, pr_1 + 1]
406
               })
407
               |> json_response(201)
408
409
      not_top =
410
        conn
411
        |> put_req_header("idempotency-key", "unstack-reject-1")
412
        |> post("#{path(repository)}/1/unstack", %{pull_request: pr_1})
413
414
      assert %{"code" => "not_stack_top"} = json_response(not_top, 422)
415
416
      unknown =
417
        conn
418
        |> put_req_header("idempotency-key", "unstack-reject-2")
419
        |> post("#{path(repository)}/1/unstack", %{pull_request: 999})
420
421
      assert %{"code" => "pull_request_not_in_stack"} = json_response(unknown, 422)
422
    end
423
  end
424
425
  describe "POST /api/v3/repos/:owner/:repo/stacks/:stack_number/dissolve" do
426
    test "releases every layer, dissolves the stack, and replays retries", %{conn: conn} do
427
      repository = repository_fixture()
428
      oids = seed_chain(repository, ["layer-1", "layer-2"])
429
      [pr_1, pr_2] = pull_request_chain(repository, oids, ["layer-1", "layer-2"])
430
      conn = put_forge_api_token(conn, "stack-dissolve", repository)
431
432
      assert %{"number" => 1} =
433
               conn
434
               |> put_req_header("idempotency-key", "dissolve-create-1")
435
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1, pr_2]})
436
               |> json_response(201)
437
438
      dissolve_conn =
439
        conn
440
        |> put_req_header("idempotency-key", "dissolve-1")
441
        |> post("#{path(repository)}/1/dissolve", %{expected_stack_version: 1})
442
443
      assert %{"state" => "dissolved", "size" => 0, "entries" => [], "replayed" => false} =
444
               json_response(dissolve_conn, 200)
445
446
      unstacked =
447
        Repo.all(
448
          from event in StackEvent,
449
            where:
450
              event.event_type == "pull_request.unstacked" and
451
                fragment("?->>'reason' = 'dissolved'", event.payload)
452
        )
453
454
      assert length(unstacked) == 2
455
456
      assert Repo.exists?(
457
               from event in StackEvent,
458
                 where: event.event_type == "pull_request_stack.dissolved"
459
             )
460
461
      replay_conn =
462
        conn
463
        |> put_req_header("idempotency-key", "dissolve-1")
464
        |> post("#{path(repository)}/1/dissolve", %{expected_stack_version: 1})
465
466
      assert %{"state" => "dissolved", "replayed" => true} = json_response(replay_conn, 200)
467
468
      again =
469
        conn
470
        |> put_req_header("idempotency-key", "dissolve-2")
471
        |> post("#{path(repository)}/1/dissolve", %{})
472
473
      assert %{"code" => "stack_not_open"} = json_response(again, 409)
474
    end
475
476
    test "refuses while an operation is active", %{conn: conn} do
477
      repository = repository_fixture()
478
      oids = seed_chain(repository, ["layer-1"])
479
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
480
      conn = put_forge_api_token(conn, "stack-dissolve-active", repository)
481
482
      assert %{"number" => 1} =
483
               conn
484
               |> put_req_header("idempotency-key", "dissolve-active-create")
485
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
486
               |> json_response(201)
487
488
      assert %{"id" => operation_id} =
489
               conn
490
               |> put_req_header("idempotency-key", "dissolve-active-rebase")
491
               |> post("#{path(repository)}/1/rebase", %{})
492
               |> json_response(202)
493
494
      blocked =
495
        conn
496
        |> put_req_header("idempotency-key", "dissolve-active-1")
497
        |> post("#{path(repository)}/1/dissolve", %{})
498
499
      assert %{"code" => "operation_in_progress", "operation_id" => ^operation_id} =
500
               json_response(blocked, 409)
501
    end
502
  end
503
321 504
  describe "POST /api/v3/repos/:owner/:repo/stacks/:stack_number/rebase" do
322 505
    test "accepts a rebase, exposes the operation, and replays retries", %{conn: conn} do
323 506
      repository = repository_fixture()

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