Create and extend stacks through the /api/v3 surface

8adfd9b1d242 · Devin AI · · parent 589a7039c554

Create and extend stacks through the /api/v3 surface

Expose stack creation, extension, and reads on the GitHub-shaped API.
POST /repos/:owner/:repo/stacks builds a stack from ordered pull request
numbers, an explicit trunk ref, optional expected head OIDs, and one
Idempotency-Key header. POST .../stacks/:number/append validates that the
new pull request targets the current top head and bumps the stack version.
GET endpoints return entries with positions, boundary OIDs, observed head
OIDs, health, and version.

Every mutation validates inside one metadata transaction with row locks
and a per-repository advisory lock, writes a transactional outbox event,
and records an idempotency row. A retried key replays the original result;
the same key with a different request returns 409, as does a stale
expected_stack_version or a mismatched expected head OID. Writes sit
behind the forge:write scope and the route authority inventory names the
new routes.

Closes #48

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

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
  • added lib/openagents/stacks/idempotency_request.ex
  • added lib/openagents/stacks/stack_event.ex
  • modified lib/openagents_web/api_route_authority.ex
  • added lib/openagents_web/controllers/stack_controller.ex
  • added lib/openagents_web/controllers/stack_json.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823054500_create_pull_request_stack_api_tables.exs
  • added test/openagents_web/controllers/stack_controller_test.exs

Diff

10 files changed, +1168 -1

lib/openagents/stacks.ex modified +428

@@ -9,11 +9,15 @@ defmodule OpenAgents.Stacks do

9 9
  import Ecto.Query, warn: false
10 10
11 11
  alias OpenAgents.Accounts.User
12
  alias OpenAgents.Forge.Browse
12 13
  alias OpenAgents.PullRequests.PullRequest
13 14
  alias OpenAgents.Repo
15
  alias OpenAgents.Repositories
14 16
  alias OpenAgents.Repositories.Repository
17
  alias OpenAgents.Stacks.IdempotencyRequest
15 18
  alias OpenAgents.Stacks.Stack
16 19
  alias OpenAgents.Stacks.StackEntry
20
  alias OpenAgents.Stacks.StackEvent
17 21
18 22
  @doc """
19 23
  Creates a stack from pull requests ordered bottom to top.

@@ -30,6 +34,61 @@ defmodule OpenAgents.Stacks do

30 34
    end
31 35
  end
32 36
37
  @doc "Lists a repository's stacks with active entries, newest first."
38
  def list(%Repository{id: repository_id}) do
39
    Stack
40
    |> where(repository_id: ^repository_id)
41
    |> order_by(desc: :number)
42
    |> Repo.all()
43
    |> Repo.preload(entries: active_entries_query())
44
  end
45
46
  @doc """
47
  Creates a stack from an API request.
48
49
  The request names the trunk ref, the pull request numbers bottom to top,
50
  and optionally the head OIDs the caller observed. The whole validation
51
  chain runs inside one metadata transaction with row locks. A retried
52
  idempotency key replays the original result; the same key with a different
53
  request fails with `:idempotency_conflict`.
54
  """
55
  def create_from_api(%Repository{} = repository, params, %User{} = actor, idempotency_key)
56
      when is_binary(idempotency_key) do
57
    with :ok <- authorize(repository, actor),
58
         {:ok, request} <- parse_create_request(params) do
59
      request_digest = digest({:create, request})
60
61
      run_idempotent(repository, actor, "stack_create", idempotency_key, request_digest, fn ->
62
        create_stack_rows(repository, request, actor, idempotency_key, request_digest)
63
      end)
64
    end
65
  end
66
67
  @doc """
68
  Appends one pull request to the top of an open stack.
69
70
  The new pull request must target the current top head. A caller-supplied
71
  `expected_stack_version` that mismatches the current version fails with
72
  `:stale_stack_version`. A successful append bumps the stack version.
73
  """
74
  def append_from_api(
75
        %Repository{} = repository,
76
        number,
77
        params,
78
        %User{} = actor,
79
        idempotency_key
80
      )
81
      when is_integer(number) and is_binary(idempotency_key) do
82
    with :ok <- authorize(repository, actor),
83
         {:ok, request} <- parse_append_request(params) do
84
      request_digest = digest({:append, number, request})
85
86
      run_idempotent(repository, actor, "stack_append", idempotency_key, request_digest, fn ->
87
        append_stack_rows(repository, number, request, actor, idempotency_key, request_digest)
88
      end)
89
    end
90
  end
91
33 92
  def get_by_number!(%Repository{id: repository_id}, number) when is_integer(number) do
34 93
    Stack
35 94
    |> Repo.get_by!(repository_id: repository_id, number: number)

@@ -244,4 +303,373 @@ defmodule OpenAgents.Stacks do

244 303
  end
245 304
246 305
  defp number_conflict?(_other), do: false
306
307
  defp authorize(repository, actor) do
308
    if Repositories.writable?(repository, actor), do: :ok, else: {:error, :forbidden}
309
  end
310
311
  defp run_idempotent(repository, actor, operation, idempotency_key, request_digest, fun) do
312
    Repo.transaction(fn ->
313
      lock_repository_stacks(repository.id)
314
315
      case get_idempotency_request(actor.id, operation, idempotency_key) do
316
        %IdempotencyRequest{request_digest: ^request_digest} = request ->
317
          {reload_stack(request.stack_id), :replayed}
318
319
        %IdempotencyRequest{} ->
320
          Repo.rollback(:idempotency_conflict)
321
322
        nil ->
323
          fun.()
324
      end
325
    end)
326
  end
327
328
  defp lock_repository_stacks(repository_id) do
329
    key = "pull_request_stacks:#{repository_id}"
330
    Repo.query!("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [key])
331
    :ok
332
  end
333
334
  defp get_idempotency_request(user_id, operation, idempotency_key) do
335
    Repo.one(
336
      from request in IdempotencyRequest,
337
        where:
338
          request.user_id == ^user_id and request.operation == ^operation and
339
            request.idempotency_key == ^idempotency_key,
340
        lock: "FOR UPDATE"
341
    )
342
  end
343
344
  defp reload_stack(stack_id) do
345
    Stack
346
    |> Repo.get!(stack_id)
347
    |> Repo.preload(entries: active_entries_query())
348
  end
349
350
  defp create_stack_rows(repository, request, actor, idempotency_key, request_digest) do
351
    with {:ok, pull_requests} <- load_pull_requests(repository, request.pull_requests),
352
         :ok <- validate_structure(repository, pull_requests),
353
         :ok <- validate_trunk(request.trunk_ref, pull_requests),
354
         {:ok, trunk_oid} <- resolve_oid(repository, request.trunk_ref),
355
         {:ok, heads} <- snapshot_heads(repository, pull_requests),
356
         :ok <- validate_expected_heads(request.expected_heads, pull_requests, heads) do
357
      stack = insert_stack!(repository, actor, request.trunk_ref)
358
      entries = insert_entry_rows!(stack, entry_specs(pull_requests, heads, trunk_oid))
359
360
      record_event!(stack, "pull_request_stack.created", actor, %{
361
        "number" => stack.number,
362
        "trunk_ref" => stack.trunk_ref,
363
        "trunk_oid" => trunk_oid,
364
        "entries" => Enum.map(entries, &event_entry/1)
365
      })
366
367
      record_idempotency!(actor, "stack_create", idempotency_key, request_digest, stack.id)
368
      {%{stack | entries: entries}, :created}
369
    else
370
      {:error, reason} -> Repo.rollback(reason)
371
    end
372
  end
373
374
  defp append_stack_rows(repository, number, request, actor, idempotency_key, request_digest) do
375
    with {:ok, stack} <- get_stack_for_update(repository, number),
376
         :ok <- validate_open(stack),
377
         :ok <- validate_expected_version(request.expected_stack_version, stack),
378
         {:ok, top, entries} <- top_entry(stack),
379
         {:ok, [pull_request]} <- load_pull_requests(repository, [request.pull_request]),
380
         :ok <- validate_append(repository, stack, entries, top, pull_request),
381
         {:ok, head_oid} <- resolve_oid(repository, pull_request.head_ref),
382
         :ok <- validate_expected_head(request.expected_head, head_oid),
383
         {:ok, stack} <- bump_version(stack) do
384
      entry =
385
        insert_entry_row!(stack, pull_request, top.position + 1, top.observed_head_oid, head_oid)
386
387
      record_event!(stack, "pull_request_stack.appended", actor, %{
388
        "number" => stack.number,
389
        "trunk_ref" => stack.trunk_ref,
390
        "entries" => [event_entry(entry)]
391
      })
392
393
      record_idempotency!(actor, "stack_append", idempotency_key, request_digest, stack.id)
394
      {%{stack | entries: entries ++ [entry]}, :created}
395
    else
396
      {:error, reason} -> Repo.rollback(reason)
397
    end
398
  end
399
400
  defp parse_create_request(params) do
401
    with {:ok, trunk_ref} <- ref_param(params, "trunk_ref"),
402
         {:ok, numbers} <- pull_request_numbers(Map.get(params, "pull_requests")),
403
         {:ok, expected_heads} <- expected_heads(Map.get(params, "expected_heads"), numbers) do
404
      {:ok, %{trunk_ref: trunk_ref, pull_requests: numbers, expected_heads: expected_heads}}
405
    end
406
  end
407
408
  defp parse_append_request(params) do
409
    with {:ok, number} <- pull_request_number(Map.get(params, "pull_request")),
410
         {:ok, version} <- expected_version(Map.get(params, "expected_stack_version")),
411
         {:ok, expected_head} <- optional_oid(Map.get(params, "expected_head")) do
412
      {:ok,
413
       %{pull_request: number, expected_stack_version: version, expected_head: expected_head}}
414
    end
415
  end
416
417
  defp ref_param(params, key) do
418
    case Map.get(params, key) do
419
      value when is_binary(value) and value != "" -> {:ok, value}
420
      _other -> {:error, :invalid_request}
421
    end
422
  end
423
424
  defp pull_request_numbers(numbers) when is_list(numbers) and numbers != [] do
425
    if Enum.all?(numbers, &(is_integer(&1) and &1 > 0)),
426
      do: {:ok, numbers},
427
      else: {:error, :invalid_request}
428
  end
429
430
  defp pull_request_numbers(_other), do: {:error, :invalid_request}
431
432
  defp pull_request_number(number) when is_integer(number) and number > 0, do: {:ok, number}
433
  defp pull_request_number(_other), do: {:error, :invalid_request}
434
435
  defp expected_version(nil), do: {:ok, nil}
436
  defp expected_version(version) when is_integer(version) and version >= 1, do: {:ok, version}
437
  defp expected_version(_other), do: {:error, :invalid_request}
438
439
  defp expected_heads(nil, _numbers), do: {:ok, %{}}
440
441
  defp expected_heads(heads, numbers) when is_map(heads) do
442
    Enum.reduce_while(heads, {:ok, %{}}, fn {key, value}, {:ok, acc} ->
443
      with {number, ""} <- Integer.parse(to_string(key)),
444
           true <- number in numbers,
445
           {:ok, oid} when is_binary(oid) <- optional_oid(value) do
446
        {:cont, {:ok, Map.put(acc, number, oid)}}
447
      else
448
        _invalid -> {:halt, {:error, :invalid_request}}
449
      end
450
    end)
451
  end
452
453
  defp expected_heads(_other, _numbers), do: {:error, :invalid_request}
454
455
  defp optional_oid(nil), do: {:ok, nil}
456
457
  defp optional_oid(value) when is_binary(value) and byte_size(value) in [40, 64] do
458
    case Base.decode16(value, case: :lower) do
459
      {:ok, _raw} -> {:ok, value}
460
      :error -> {:error, :invalid_request}
461
    end
462
  end
463
464
  defp optional_oid(_other), do: {:error, :invalid_request}
465
466
  defp load_pull_requests(%Repository{id: repository_id}, numbers) do
467
    rows =
468
      Repo.all(
469
        from pr in PullRequest,
470
          join: issue in assoc(pr, :issue),
471
          where: pr.repository_id == ^repository_id and issue.number in ^numbers,
472
          preload: [issue: issue],
473
          lock: "FOR UPDATE"
474
      )
475
476
    by_number = Map.new(rows, &{&1.issue.number, &1})
477
478
    numbers
479
    |> Enum.reduce_while({:ok, []}, fn number, {:ok, acc} ->
480
      case Map.get(by_number, number) do
481
        nil -> {:halt, {:error, :pull_request_not_found}}
482
        pull_request -> {:cont, {:ok, [pull_request | acc]}}
483
      end
484
    end)
485
    |> case do
486
      {:ok, pull_requests} -> {:ok, Enum.reverse(pull_requests)}
487
      {:error, reason} -> {:error, reason}
488
    end
489
  end
490
491
  defp validate_trunk(trunk_ref, [bottom | _rest]) do
492
    if bottom.base_ref == trunk_ref, do: :ok, else: {:error, :trunk_mismatch}
493
  end
494
495
  defp snapshot_heads(repository, pull_requests) do
496
    Enum.reduce_while(pull_requests, {:ok, %{}}, fn pull_request, {:ok, acc} ->
497
      case resolve_oid(repository, pull_request.head_ref) do
498
        {:ok, oid} -> {:cont, {:ok, Map.put(acc, pull_request.id, oid)}}
499
        {:error, reason} -> {:halt, {:error, reason}}
500
      end
501
    end)
502
  end
503
504
  defp resolve_oid(repository, ref) do
505
    case Browse.resolve_commit(repository, ref) do
506
      {:ok, oid} -> {:ok, oid}
507
      _other -> {:error, :invalid_ref}
508
    end
509
  end
510
511
  defp validate_expected_heads(expected_heads, pull_requests, heads) do
512
    by_number = Map.new(pull_requests, &{&1.issue.number, &1})
513
514
    Enum.reduce_while(expected_heads, :ok, fn {number, expected}, :ok ->
515
      pull_request = Map.fetch!(by_number, number)
516
517
      if Map.fetch!(heads, pull_request.id) == expected,
518
        do: {:cont, :ok},
519
        else: {:halt, {:error, :expected_head_mismatch}}
520
    end)
521
  end
522
523
  defp validate_expected_head(nil, _head_oid), do: :ok
524
  defp validate_expected_head(expected, expected), do: :ok
525
  defp validate_expected_head(_expected, _head_oid), do: {:error, :expected_head_mismatch}
526
527
  defp insert_stack!(repository, actor, trunk_ref) do
528
    %Stack{}
529
    |> Stack.changeset(%{
530
      repository_id: repository.id,
531
      created_by_user_id: actor.id,
532
      number: next_number(repository.id),
533
      trunk_ref: trunk_ref
534
    })
535
    |> Repo.insert!()
536
  end
537
538
  defp entry_specs(pull_requests, heads, trunk_oid) do
539
    {specs, _previous_head} =
540
      pull_requests
541
      |> Enum.with_index(1)
542
      |> Enum.map_reduce(trunk_oid, fn {pull_request, position}, boundary ->
543
        head = Map.fetch!(heads, pull_request.id)
544
        {{pull_request, position, boundary, head}, head}
545
      end)
546
547
    specs
548
  end
549
550
  defp insert_entry_rows!(stack, specs) do
551
    Enum.map(specs, fn {pull_request, position, boundary, head} ->
552
      insert_entry_row!(stack, pull_request, position, boundary, head)
553
    end)
554
  end
555
556
  defp insert_entry_row!(stack, pull_request, position, boundary, head) do
557
    entry =
558
      %StackEntry{}
559
      |> StackEntry.changeset(%{
560
        stack_id: stack.id,
561
        pull_request_id: pull_request.id,
562
        position: position,
563
        boundary_oid: boundary,
564
        observed_head_oid: head
565
      })
566
      |> Repo.insert!()
567
568
    %{entry | pull_request: pull_request}
569
  end
570
571
  defp event_entry(entry) do
572
    %{
573
      "position" => entry.position,
574
      "pull_request" => entry.pull_request.issue.number,
575
      "boundary_oid" => entry.boundary_oid,
576
      "observed_head_oid" => entry.observed_head_oid
577
    }
578
  end
579
580
  defp record_event!(stack, event_type, actor, payload) do
581
    %StackEvent{}
582
    |> StackEvent.changeset(%{
583
      stack_id: stack.id,
584
      actor_user_id: actor.id,
585
      event_type: event_type,
586
      stack_version: stack.version,
587
      payload: payload
588
    })
589
    |> Repo.insert!()
590
  end
591
592
  defp record_idempotency!(actor, operation, idempotency_key, request_digest, stack_id) do
593
    %IdempotencyRequest{}
594
    |> IdempotencyRequest.changeset(
595
      actor.id,
596
      operation,
597
      idempotency_key,
598
      request_digest,
599
      stack_id
600
    )
601
    |> Repo.insert!()
602
  end
603
604
  defp get_stack_for_update(%Repository{id: repository_id}, number) do
605
    case Repo.one(
606
           from stack in Stack,
607
             where: stack.repository_id == ^repository_id and stack.number == ^number,
608
             lock: "FOR UPDATE"
609
         ) do
610
      nil -> {:error, :stack_not_found}
611
      stack -> {:ok, stack}
612
    end
613
  end
614
615
  defp validate_open(%Stack{state: "open"}), do: :ok
616
  defp validate_open(%Stack{}), do: {:error, :stack_not_open}
617
618
  defp validate_expected_version(nil, _stack), do: :ok
619
  defp validate_expected_version(version, %Stack{version: version}), do: :ok
620
  defp validate_expected_version(_version, %Stack{}), do: {:error, :stale_stack_version}
621
622
  defp top_entry(stack) do
623
    case active_entries(stack) do
624
      [] -> {:error, :empty_stack}
625
      entries -> {:ok, List.last(entries), entries}
626
    end
627
  end
628
629
  defp validate_append(repository, stack, entries, top, pull_request) do
630
    branches = [stack.trunk_ref | Enum.map(entries, & &1.pull_request.head_ref)]
631
632
    cond do
633
      pull_request.head_repository_id != repository.id ->
634
        {:error, :cross_repository_head}
635
636
      pull_request.state != "open" ->
637
        {:error, :pull_request_not_open}
638
639
      pull_request.head_ref in branches ->
640
        {:error, :duplicate_branch}
641
642
      pull_request.base_ref != top.pull_request.head_ref ->
643
        {:error, :not_stack_top}
644
645
      not is_nil(active_entry_for_pull_request(pull_request)) ->
646
        {:error, :already_stacked}
647
648
      true ->
649
        :ok
650
    end
651
  end
652
653
  defp bump_version(%Stack{id: id, version: version}) do
654
    {count, updated} =
655
      Repo.update_all(
656
        from(stack in Stack,
657
          where: stack.id == ^id and stack.version == ^version and stack.state == "open",
658
          select: stack
659
        ),
660
        set: [version: version + 1, updated_at: DateTime.utc_now()]
661
      )
662
663
    case {count, updated} do
664
      {1, [stack]} -> {:ok, stack}
665
      {0, _} -> {:error, :stale_stack_version}
666
    end
667
  end
668
669
  defp digest(value) do
670
    value
671
    |> :erlang.term_to_binary([:deterministic])
672
    |> then(&:crypto.hash(:sha256, &1))
673
    |> Base.encode16(case: :lower)
674
  end
247 675
end
lib/openagents/stacks/idempotency_request.ex added +38

@@ -0,0 +1,38 @@

1
defmodule OpenAgents.Stacks.IdempotencyRequest do
2
  @moduledoc false
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: true}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
11
  schema "pull_request_stack_idempotency_requests" do
12
    field :operation, :string
13
    field :idempotency_key, :string
14
    field :request_digest, :string
15
16
    belongs_to :user, OpenAgents.Accounts.User
17
    belongs_to :stack, OpenAgents.Stacks.Stack
18
    timestamps()
19
  end
20
21
  def changeset(request, user_id, operation, idempotency_key, request_digest, stack_id) do
22
    request
23
    |> change()
24
    |> put_change(:user_id, user_id)
25
    |> put_change(:operation, operation)
26
    |> put_change(:idempotency_key, idempotency_key)
27
    |> put_change(:request_digest, request_digest)
28
    |> put_change(:stack_id, stack_id)
29
    |> validate_required([:user_id, :operation, :idempotency_key, :request_digest, :stack_id])
30
    |> validate_length(:operation, min: 1, max: 40)
31
    |> validate_length(:idempotency_key, min: 1, max: 255)
32
    |> validate_format(:request_digest, ~r/\A[0-9a-f]{64}\z/)
33
    |> unique_constraint([:user_id, :operation, :idempotency_key])
34
    |> check_constraint(:request_digest, name: :pull_request_stack_idempotency_digest_check)
35
    |> foreign_key_constraint(:user_id)
36
    |> foreign_key_constraint(:stack_id)
37
  end
38
end
lib/openagents/stacks/stack_event.ex added +39

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

1
defmodule OpenAgents.Stacks.StackEvent do
2
  @moduledoc """
3
  A transactional outbox row for one stack mutation.
4
5
  Every explicit stack mutation writes an event inside the same metadata
6
  transaction, so consumers replay the mutation history without racing the
7
  request handler. Consumers deduplicate by event ID.
8
  """
9
  use Ecto.Schema
10
  import Ecto.Changeset
11
12
  @primary_key {:id, :binary_id, autogenerate: true}
13
  @foreign_key_type :binary_id
14
  @timestamps_opts [type: :utc_datetime_usec]
15
16
  @event_types ~w(pull_request_stack.created pull_request_stack.appended)
17
18
  schema "pull_request_stack_events" do
19
    belongs_to :stack, OpenAgents.Stacks.Stack
20
    field :event_type, :string
21
    field :stack_version, :integer
22
    belongs_to :actor_user, OpenAgents.Accounts.User
23
    field :payload, :map, default: %{}
24
    timestamps()
25
  end
26
27
  def changeset(event, attrs) do
28
    event
29
    |> cast(attrs, [:event_type, :stack_version, :payload])
30
    |> put_change(:stack_id, Map.fetch!(attrs, :stack_id))
31
    |> put_change(:actor_user_id, Map.get(attrs, :actor_user_id))
32
    |> validate_required([:stack_id, :event_type, :stack_version, :payload])
33
    |> validate_inclusion(:event_type, @event_types)
34
    |> validate_number(:stack_version, greater_than_or_equal_to: 1)
35
    |> check_constraint(:stack_version, name: :pull_request_stack_events_version_check)
36
    |> foreign_key_constraint(:stack_id)
37
    |> foreign_key_constraint(:actor_user_id)
38
  end
39
end
lib/openagents_web/api_route_authority.ex modified +4

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

64 64
      "get /api/v3/repos/:owner/:repo/issues/:issue_number/dependencies" => :optional_bearer,
65 65
      "get /api/v3/repos/:owner/:repo/pulls" => :optional_bearer,
66 66
      "get /api/v3/repos/:owner/:repo/pulls/:pull_number" => :optional_bearer,
67
      "get /api/v3/repos/:owner/:repo/stacks" => :optional_bearer,
68
      "get /api/v3/repos/:owner/:repo/stacks/:stack_number" => :optional_bearer,
67 69
      "get /api/v3/repos/:owner/:repo/projectsV2" => :optional_bearer,
68 70
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number" => :optional_bearer,
69 71
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number/items" => :optional_bearer,

@@ -99,6 +101,8 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

99 101
      "post /api/v3/orgs/:org/repos/imports" => :required_bearer,
100 102
      "post /api/v3/repos/:owner/:repo/issues" => :required_bearer,
101 103
      "post /api/v3/repos/:owner/:repo/pulls" => :required_bearer,
104
      "post /api/v3/repos/:owner/:repo/stacks" => :required_bearer,
105
      "post /api/v3/repos/:owner/:repo/stacks/:stack_number/append" => :required_bearer,
102 106
      # pipe_through :forge_write_api — forum writes and identity claims.
103 107
      "post /api/v3/forum/topics" => :required_bearer,
104 108
      "post /api/v3/forum/topics/:topic_id/posts" => :required_bearer,
lib/openagents_web/controllers/stack_controller.ex added +134

@@ -0,0 +1,134 @@

1
defmodule OpenAgentsWeb.StackController do
2
  use OpenAgentsWeb, :controller
3
4
  alias OpenAgents.Repositories
5
  alias OpenAgents.Stacks
6
  alias OpenAgentsWeb.ControllerHelpers
7
8
  def index(conn, %{"owner" => owner, "repo" => repo}) do
9
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])
10
    render(conn, :index, stacks: Stacks.list(repository), owner: owner, repo: repo)
11
  rescue
12
    Ecto.NoResultsError -> not_found(conn)
13
  end
14
15
  def show(conn, %{"owner" => owner, "repo" => repo, "stack_number" => number}) do
16
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])
17
    stack = Stacks.get_by_number!(repository, ControllerHelpers.integer_param!(number))
18
    render(conn, :show, stack: stack, owner: owner, repo: repo)
19
  rescue
20
    Ecto.NoResultsError -> not_found(conn)
21
  end
22
23
  def create(conn, %{"owner" => owner, "repo" => repo} = params) do
24
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns.current_user)
25
26
    with {:ok, idempotency_key} <- idempotency_key(conn),
27
         {:ok, {stack, replay_state}} <-
28
           Stacks.create_from_api(repository, params, conn.assigns.current_user, idempotency_key) do
29
      conn
30
      |> put_status(:created)
31
      |> render(:show, stack: stack, owner: owner, repo: repo, replay_state: replay_state)
32
    else
33
      {:error, reason} -> render_error(conn, reason)
34
    end
35
  rescue
36
    Ecto.NoResultsError -> not_found(conn)
37
  end
38
39
  def append(conn, %{"owner" => owner, "repo" => repo, "stack_number" => number} = params) do
40
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns.current_user)
41
42
    with {:ok, idempotency_key} <- idempotency_key(conn),
43
         {:ok, {stack, replay_state}} <-
44
           Stacks.append_from_api(
45
             repository,
46
             ControllerHelpers.integer_param!(number),
47
             params,
48
             conn.assigns.current_user,
49
             idempotency_key
50
           ) do
51
      render(conn, :show, stack: stack, owner: owner, repo: repo, replay_state: replay_state)
52
    else
53
      {:error, reason} -> render_error(conn, reason)
54
    end
55
  rescue
56
    Ecto.NoResultsError -> not_found(conn)
57
  end
58
59
  defp idempotency_key(conn) do
60
    case get_req_header(conn, "idempotency-key") do
61
      [key] when byte_size(key) in 1..200 ->
62
        if String.contains?(key, ["\r", "\n", "\0"]),
63
          do: {:error, :invalid_idempotency_key},
64
          else: {:ok, key}
65
66
      _invalid ->
67
        {:error, :invalid_idempotency_key}
68
    end
69
  end
70
71
  defp render_error(conn, :invalid_idempotency_key),
72
    do: error(conn, :bad_request, "Provide one Idempotency-Key header")
73
74
  defp render_error(conn, :forbidden),
75
    do: error(conn, :forbidden, "You cannot modify stacks in this repository.")
76
77
  defp render_error(conn, reason) when reason in [:stack_not_found, :pull_request_not_found],
78
    do: not_found(conn)
79
80
  defp render_error(conn, reason)
81
       when reason in [
82
              :idempotency_conflict,
83
              :stale_stack_version,
84
              :expected_head_mismatch,
85
              :stack_not_open
86
            ],
87
       do: conflict(conn, reason)
88
89
  defp render_error(conn, reason)
90
       when reason in [
91
              :invalid_request,
92
              :invalid_ref,
93
              :trunk_mismatch,
94
              :empty_stack,
95
              :repository_mismatch,
96
              :cross_repository_head,
97
              :pull_request_not_open,
98
              :duplicate_pull_request,
99
              :duplicate_branch,
100
              :broken_base_chain,
101
              :already_stacked,
102
              :not_stack_top
103
            ] do
104
    conn
105
    |> put_status(:unprocessable_entity)
106
    |> json(%{message: message(reason), code: Atom.to_string(reason)})
107
  end
108
109
  defp conflict(conn, reason) do
110
    conn
111
    |> put_status(:conflict)
112
    |> json(%{message: message(reason), code: Atom.to_string(reason)})
113
  end
114
115
  defp message(:idempotency_conflict), do: "The idempotency key is already in use."
116
  defp message(:stale_stack_version), do: "The stack version does not match."
117
  defp message(:expected_head_mismatch), do: "An expected head OID does not match."
118
  defp message(:stack_not_open), do: "The stack is not open."
119
  defp message(:invalid_request), do: "The request body is invalid."
120
  defp message(:invalid_ref), do: "A ref did not resolve to a commit."
121
  defp message(:trunk_mismatch), do: "The bottom pull request does not target the trunk ref."
122
  defp message(:empty_stack), do: "A stack needs at least one pull request."
123
  defp message(:repository_mismatch), do: "Every pull request must belong to this repository."
124
  defp message(:cross_repository_head), do: "Every head branch must live in this repository."
125
  defp message(:pull_request_not_open), do: "Every pull request must be open."
126
  defp message(:duplicate_pull_request), do: "A pull request appears more than once."
127
  defp message(:duplicate_branch), do: "A branch appears more than once."
128
  defp message(:broken_base_chain), do: "The direct-base chain is broken."
129
  defp message(:already_stacked), do: "A pull request already belongs to an active stack."
130
  defp message(:not_stack_top), do: "The pull request does not target the current top head."
131
132
  defp not_found(conn), do: error(conn, :not_found, "Not Found")
133
  defp error(conn, status, message), do: conn |> put_status(status) |> json(%{message: message})
134
end
lib/openagents_web/controllers/stack_json.ex added +52

@@ -0,0 +1,52 @@

1
defmodule OpenAgentsWeb.StackJSON do
2
  @moduledoc "Renders pull request stacks with their ordered entries."
3
4
  def render("index.json", %{stacks: stacks} = assigns),
5
    do: Enum.map(stacks, &stack(&1, assigns))
6
7
  def render("show.json", %{stack: stack} = assigns) do
8
    json = stack(stack, assigns)
9
10
    case Map.get(assigns, :replay_state) do
11
      nil -> json
12
      replay_state -> Map.put(json, :replayed, replay_state == :replayed)
13
    end
14
  end
15
16
  defp stack(stack, assigns) do
17
    base_url = String.trim_trailing(OpenAgentsWeb.Endpoint.url(), "/")
18
    owner = assigns.owner
19
    repo = assigns.repo
20
21
    %{
22
      id: stack.id,
23
      number: stack.number,
24
      trunk_ref: stack.trunk_ref,
25
      state: stack.state,
26
      health: stack.health,
27
      version: stack.version,
28
      size: length(stack.entries),
29
      entries: Enum.map(stack.entries, &entry(&1, owner, repo, base_url)),
30
      created_at: stack.inserted_at,
31
      updated_at: stack.updated_at,
32
      url: "#{base_url}/api/v3/repos/#{owner}/#{repo}/stacks/#{stack.number}"
33
    }
34
  end
35
36
  defp entry(entry, owner, repo, base_url) do
37
    pull_request = entry.pull_request
38
39
    %{
40
      position: entry.position,
41
      boundary_oid: entry.boundary_oid,
42
      observed_head_oid: entry.observed_head_oid,
43
      pull_request: %{
44
        number: pull_request.issue.number,
45
        state: pull_request.issue.state,
46
        head: %{ref: pull_request.head_ref, sha: pull_request.head_sha},
47
        base: %{ref: pull_request.base_ref, sha: pull_request.base_sha},
48
        url: "#{base_url}/api/v3/repos/#{owner}/#{repo}/pulls/#{pull_request.issue.number}"
49
      }
50
    }
51
  end
52
end
lib/openagents_web/router.ex modified +4

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

321 321
322 322
    get "/repos/:owner/:repo/pulls", PullRequestController, :index
323 323
    get "/repos/:owner/:repo/pulls/:pull_number", PullRequestController, :show
324
    get "/repos/:owner/:repo/stacks", StackController, :index
325
    get "/repos/:owner/:repo/stacks/:stack_number", StackController, :show
324 326
    get "/repos/:owner/:repo/projectsV2", ProjectController, :index
325 327
    get "/repos/:owner/:repo/projectsV2/:project_number", ProjectController, :show
326 328
    get "/repos/:owner/:repo/projectsV2/:project_number/items", ProjectController, :items

@@ -368,6 +370,8 @@ defmodule OpenAgentsWeb.Router do

368 370
    patch "/repos/:owner/:repo/issues/:issue_number", IssueController, :update
369 371
    post "/repos/:owner/:repo/pulls", PullRequestController, :create
370 372
    patch "/repos/:owner/:repo/pulls/:pull_number", PullRequestController, :update
373
    post "/repos/:owner/:repo/stacks", StackController, :create
374
    post "/repos/:owner/:repo/stacks/:stack_number/append", StackController, :append
371 375
    post "/repos/:owner/:repo/issues/:issue_number/comments", CommentController, :create
372 376
    put "/repos/:owner/:repo/issues/comments/:id", CommentController, :update
373 377
    patch "/repos/:owner/:repo/issues/comments/:id", CommentController, :update
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -242,7 +242,8 @@

242 242
    20260823050000,
243 243
    20260823051500,
244 244
    20260823052000,
245
    20260823053000
245
    20260823053000,
246
    20260823054500
246 247
  ],
247 248
  "required_tables": [
248 249
    "users",
priv/repo/migrations/20260823054500_create_pull_request_stack_api_tables.exs added +51

@@ -0,0 +1,51 @@

1
defmodule OpenAgents.Repo.Migrations.CreatePullRequestStackApiTables do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:pull_request_stack_events, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
8
      add :stack_id,
9
          references(:pull_request_stacks, type: :binary_id, on_delete: :delete_all),
10
          null: false
11
12
      add :event_type, :string, null: false
13
      add :stack_version, :bigint, null: false
14
      add :actor_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
15
      add :payload, :map, null: false, default: %{}
16
      timestamps(type: :utc_datetime_usec)
17
    end
18
19
    create index(:pull_request_stack_events, [:stack_id])
20
21
    create constraint(:pull_request_stack_events, :pull_request_stack_events_version_check,
22
             check: "stack_version >= 1"
23
           )
24
25
    create table(:pull_request_stack_idempotency_requests, primary_key: false) do
26
      add :id, :binary_id, primary_key: true
27
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
28
      add :operation, :string, null: false
29
      add :idempotency_key, :string, null: false
30
      add :request_digest, :string, null: false
31
32
      add :stack_id,
33
          references(:pull_request_stacks, type: :binary_id, on_delete: :delete_all),
34
          null: false
35
36
      timestamps(type: :utc_datetime_usec)
37
    end
38
39
    create unique_index(:pull_request_stack_idempotency_requests, [
40
             :user_id,
41
             :operation,
42
             :idempotency_key
43
           ])
44
45
    create constraint(
46
             :pull_request_stack_idempotency_requests,
47
             :pull_request_stack_idempotency_digest_check,
48
             check: "request_digest ~ '^[0-9a-f]{64}$'"
49
           )
50
  end
51
end
test/openagents_web/controllers/stack_controller_test.exs added +416

@@ -0,0 +1,416 @@

1
defmodule OpenAgentsWeb.StackControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  alias OpenAgents.Forge.Repos
5
  alias OpenAgents.PullRequests.PullRequest
6
  alias OpenAgents.Repo
7
  alias OpenAgents.Stacks.IdempotencyRequest
8
  alias OpenAgents.Stacks.Stack
9
  alias OpenAgents.Stacks.StackEvent
10
11
  import Ecto.Query
12
  import OpenAgents.IssuesFixtures
13
14
  setup do
15
    base =
16
      Path.join(
17
        System.tmp_dir!(),
18
        "stack-controller-#{System.unique_integer([:positive])}"
19
      )
20
21
    previous_data = Application.get_env(:openagents, :forge_data_dir)
22
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
23
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
24
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
25
26
    on_exit(fn ->
27
      restore_env(:forge_data_dir, previous_data)
28
      restore_env(:forge_wal_dir, previous_wal)
29
      File.rm_rf(base)
30
    end)
31
32
    :ok
33
  end
34
35
  describe "POST /api/v3/repos/:owner/:repo/stacks" do
36
    test "creates a stack, reads it back, and replays the idempotency key", %{conn: conn} do
37
      repository = repository_fixture()
38
      oids = seed_chain(repository, ["layer-1", "layer-2", "layer-3"])
39
      [pr_1, pr_2, pr_3] = pull_request_chain(repository, oids, ["layer-1", "layer-2", "layer-3"])
40
      conn = put_forge_api_token(conn, "stack-create", repository)
41
42
      body = %{
43
        trunk_ref: "main",
44
        pull_requests: [pr_1, pr_2, pr_3],
45
        expected_heads: %{
46
          "#{pr_1}" => oids["layer-1"],
47
          "#{pr_2}" => oids["layer-2"],
48
          "#{pr_3}" => oids["layer-3"]
49
        }
50
      }
51
52
      create_conn =
53
        conn
54
        |> put_req_header("idempotency-key", "stack-create-1")
55
        |> post(path(repository), body)
56
57
      assert %{
58
               "number" => 1,
59
               "trunk_ref" => "main",
60
               "state" => "open",
61
               "health" => "healthy",
62
               "version" => 1,
63
               "size" => 3,
64
               "replayed" => false,
65
               "entries" => [entry_1, entry_2, entry_3]
66
             } = json_response(create_conn, 201)
67
68
      assert %{"position" => 1, "boundary_oid" => boundary_1} = entry_1
69
      assert boundary_1 == oids["main"]
70
      assert %{"position" => 2, "boundary_oid" => boundary_2} = entry_2
71
      assert boundary_2 == oids["layer-1"]
72
      assert %{"position" => 3, "observed_head_oid" => head_3} = entry_3
73
      assert head_3 == oids["layer-3"]
74
75
      assert [%StackEvent{event_type: "pull_request_stack.created", stack_version: 1}] =
76
               Repo.all(StackEvent)
77
78
      replay_conn =
79
        conn
80
        |> put_req_header("idempotency-key", "stack-create-1")
81
        |> post(path(repository), body)
82
83
      assert %{"number" => 1, "replayed" => true} = json_response(replay_conn, 201)
84
      assert Repo.aggregate(Stack, :count) == 1
85
      assert Repo.aggregate(IdempotencyRequest, :count) == 1
86
87
      show_conn = get(conn, "#{path(repository)}/1")
88
      assert %{"number" => 1, "size" => 3} = json_response(show_conn, 200)
89
90
      index_conn = get(conn, path(repository))
91
      assert [%{"number" => 1}] = json_response(index_conn, 200)
92
    end
93
94
    test "rejects a reused idempotency key with a different request", %{conn: conn} do
95
      repository = repository_fixture()
96
      oids = seed_chain(repository, ["layer-1"])
97
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
98
      conn = put_forge_api_token(conn, "stack-idem-conflict", repository)
99
100
      first =
101
        conn
102
        |> put_req_header("idempotency-key", "stack-idem-1")
103
        |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
104
105
      assert %{"number" => 1} = json_response(first, 201)
106
107
      second =
108
        conn
109
        |> put_req_header("idempotency-key", "stack-idem-1")
110
        |> post(path(repository), %{trunk_ref: "other", pull_requests: [pr_1]})
111
112
      assert %{"code" => "idempotency_conflict"} = json_response(second, 409)
113
    end
114
115
    test "requires one Idempotency-Key header", %{conn: conn} do
116
      repository = repository_fixture()
117
      conn = put_forge_api_token(conn, "stack-no-key", repository)
118
119
      conn = post(conn, path(repository), %{trunk_ref: "main", pull_requests: [1]})
120
      assert %{"message" => "Provide one Idempotency-Key header"} = json_response(conn, 400)
121
    end
122
123
    test "refuses a caller without write access", %{conn: conn} do
124
      repository = repository_fixture()
125
      oids = seed_chain(repository, ["layer-1"])
126
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
127
      conn = put_forge_api_token(conn, "stack-nonmember")
128
129
      conn =
130
        conn
131
        |> put_req_header("idempotency-key", "stack-forbidden-1")
132
        |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
133
134
      assert json_response(conn, 403)
135
    end
136
137
    test "rejects each structural violation with an explanation", %{conn: conn} do
138
      repository = repository_fixture()
139
      oids = seed_chain(repository, ["layer-1", "layer-2", "layer-3"])
140
      [pr_1, pr_2, pr_3] = pull_request_chain(repository, oids, ["layer-1", "layer-2", "layer-3"])
141
      conn = put_forge_api_token(conn, "stack-rejections", repository)
142
143
      post_stack = fn body, key ->
144
        conn
145
        |> put_req_header("idempotency-key", key)
146
        |> post(path(repository), body)
147
      end
148
149
      response = post_stack.(%{trunk_ref: "main"}, "reject-1")
150
      assert %{"code" => "invalid_request"} = json_response(response, 422)
151
152
      response = post_stack.(%{trunk_ref: "main", pull_requests: [999]}, "reject-2")
153
      assert json_response(response, 404)
154
155
      response =
156
        post_stack.(%{trunk_ref: "wrong-trunk", pull_requests: [pr_1, pr_2]}, "reject-3")
157
158
      assert %{"code" => "trunk_mismatch"} = json_response(response, 422)
159
160
      response = post_stack.(%{trunk_ref: "main", pull_requests: [pr_1, pr_3]}, "reject-4")
161
      assert %{"code" => "broken_base_chain"} = json_response(response, 422)
162
163
      response = post_stack.(%{trunk_ref: "main", pull_requests: [pr_1, pr_1]}, "reject-5")
164
      assert %{"code" => "duplicate_pull_request"} = json_response(response, 422)
165
166
      response =
167
        post_stack.(
168
          %{
169
            trunk_ref: "main",
170
            pull_requests: [pr_1],
171
            expected_heads: %{"#{pr_1}" => String.duplicate("a", 40)}
172
          },
173
          "reject-6"
174
        )
175
176
      assert %{"code" => "expected_head_mismatch"} = json_response(response, 409)
177
178
      closed = pull_request(repository, "orphan", "main", oids["main"], oids["main"], "closed")
179
180
      response = post_stack.(%{trunk_ref: "main", pull_requests: [closed]}, "reject-7")
181
      assert %{"code" => "pull_request_not_open"} = json_response(response, 422)
182
183
      assert %{"number" => 1} =
184
               post_stack.(%{trunk_ref: "main", pull_requests: [pr_1, pr_2]}, "accept-1")
185
               |> json_response(201)
186
187
      response = post_stack.(%{trunk_ref: "main", pull_requests: [pr_1]}, "reject-8")
188
      assert %{"code" => "already_stacked"} = json_response(response, 422)
189
    end
190
191
    test "rejects a head ref the git service cannot resolve", %{conn: conn} do
192
      repository = repository_fixture()
193
      oids = seed_chain(repository, [])
194
      missing = pull_request(repository, "ghost", "main", oids["main"], oids["main"], "open")
195
      conn = put_forge_api_token(conn, "stack-missing-ref", repository)
196
197
      conn =
198
        conn
199
        |> put_req_header("idempotency-key", "stack-missing-1")
200
        |> post(path(repository), %{trunk_ref: "main", pull_requests: [missing]})
201
202
      assert %{"code" => "invalid_ref"} = json_response(conn, 422)
203
    end
204
  end
205
206
  describe "POST /api/v3/repos/:owner/:repo/stacks/:stack_number/append" do
207
    test "appends to the top, bumps the version, and replays retries", %{conn: conn} do
208
      repository = repository_fixture()
209
      oids = seed_chain(repository, ["layer-1", "layer-2"])
210
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
211
      pr_2 = pull_request(repository, "layer-2", "layer-1", oids["layer-1"], oids["layer-2"])
212
      conn = put_forge_api_token(conn, "stack-append", repository)
213
214
      assert %{"number" => 1, "version" => 1} =
215
               conn
216
               |> put_req_header("idempotency-key", "append-create-1")
217
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
218
               |> json_response(201)
219
220
      append_conn =
221
        conn
222
        |> put_req_header("idempotency-key", "append-1")
223
        |> post("#{path(repository)}/1/append", %{
224
          pull_request: pr_2,
225
          expected_stack_version: 1,
226
          expected_head: oids["layer-2"]
227
        })
228
229
      assert %{
230
               "version" => 2,
231
               "size" => 2,
232
               "replayed" => false,
233
               "entries" => [_bottom, %{"position" => 2, "boundary_oid" => boundary}]
234
             } = json_response(append_conn, 200)
235
236
      assert boundary == oids["layer-1"]
237
238
      assert Repo.exists?(
239
               from event in StackEvent,
240
                 where: event.event_type == "pull_request_stack.appended"
241
             )
242
243
      replay_conn =
244
        conn
245
        |> put_req_header("idempotency-key", "append-1")
246
        |> post("#{path(repository)}/1/append", %{
247
          pull_request: pr_2,
248
          expected_stack_version: 1,
249
          expected_head: oids["layer-2"]
250
        })
251
252
      assert %{"version" => 2, "replayed" => true} = json_response(replay_conn, 200)
253
      assert Repo.aggregate(Stack, :count) == 1
254
    end
255
256
    test "rejects a stale expected version, a non-top target, and a missing stack", %{conn: conn} do
257
      repository = repository_fixture()
258
      oids = seed_chain(repository, ["layer-1", "layer-2"])
259
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
260
      pr_2 = pull_request(repository, "layer-2", "layer-1", oids["layer-1"], oids["layer-2"])
261
      orphan = pull_request(repository, "orphan", "main", oids["main"], oids["main"], "open")
262
      conn = put_forge_api_token(conn, "stack-append-reject", repository)
263
264
      assert %{"number" => 1} =
265
               conn
266
               |> put_req_header("idempotency-key", "append-reject-create")
267
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
268
               |> json_response(201)
269
270
      stale =
271
        conn
272
        |> put_req_header("idempotency-key", "append-reject-1")
273
        |> post("#{path(repository)}/1/append", %{pull_request: pr_2, expected_stack_version: 9})
274
275
      assert %{"code" => "stale_stack_version"} = json_response(stale, 409)
276
277
      not_top =
278
        conn
279
        |> put_req_header("idempotency-key", "append-reject-2")
280
        |> post("#{path(repository)}/1/append", %{pull_request: orphan})
281
282
      assert %{"code" => "not_stack_top"} = json_response(not_top, 422)
283
284
      missing =
285
        conn
286
        |> put_req_header("idempotency-key", "append-reject-3")
287
        |> post("#{path(repository)}/9/append", %{pull_request: pr_2})
288
289
      assert json_response(missing, 404)
290
    end
291
292
    test "refuses to extend a stack that is no longer open", %{conn: conn} do
293
      repository = repository_fixture()
294
      oids = seed_chain(repository, ["layer-1", "layer-2"])
295
      [pr_1] = pull_request_chain(repository, oids, ["layer-1"])
296
      pr_2 = pull_request(repository, "layer-2", "layer-1", oids["layer-1"], oids["layer-2"])
297
      conn = put_forge_api_token(conn, "stack-append-closed", repository)
298
299
      assert %{"number" => 1} =
300
               conn
301
               |> put_req_header("idempotency-key", "append-closed-create")
302
               |> post(path(repository), %{trunk_ref: "main", pull_requests: [pr_1]})
303
               |> json_response(201)
304
305
      {:ok, _stack} =
306
        OpenAgents.Stacks.dissolve(Repo.get_by!(Stack, repository_id: repository.id))
307
308
      conn =
309
        conn
310
        |> put_req_header("idempotency-key", "append-closed-1")
311
        |> post("#{path(repository)}/1/append", %{pull_request: pr_2})
312
313
      assert %{"code" => "stack_not_open"} = json_response(conn, 409)
314
    end
315
  end
316
317
  describe "GET /api/v3/repos/:owner/:repo/stacks" do
318
    test "reads are public for a public repository", %{conn: conn} do
319
      repository = repository_fixture()
320
321
      assert [] == json_response(get(conn, path(repository)), 200)
322
      assert json_response(get(conn, "#{path(repository)}/1"), 404)
323
    end
324
  end
325
326
  defp path(repository), do: "/api/v3/repos/#{repository.owner}/#{repository.name}/stacks"
327
328
  defp seed_chain(repository, branches) do
329
    path = Repos.ensure_repo!(repository.storage_key, repository.default_branch)
330
331
    {main, _tree} = commit(path, nil, "Seed repository", "README.md")
332
    {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", main])
333
334
    branches
335
    |> Enum.with_index(1)
336
    |> Enum.reduce(%{"main" => main}, fn {branch, index}, oids ->
337
      parent = Map.fetch!(oids, Enum.at(["main" | branches], index - 1))
338
      {oid, _tree} = commit(path, parent, "Layer #{branch}", "#{branch}.md")
339
      {_, 0} = Repos.git(path, ["update-ref", "refs/heads/#{branch}", oid])
340
      Map.put(oids, branch, oid)
341
    end)
342
  end
343
344
  defp commit(path, parent, message, file) do
345
    blob = git!(path, ["hash-object", "-w", "--stdin"], "#{file}\n")
346
    tree = git!(path, ["mktree"], "100644 blob #{blob}\t#{file}\n")
347
    parent_args = if parent, do: ["-p", parent], else: []
348
349
    oid =
350
      git!(path, ["commit-tree", tree] ++ parent_args ++ ["-m", message], "",
351
        env: [
352
          {"GIT_AUTHOR_NAME", "Test Author"},
353
          {"GIT_AUTHOR_EMAIL", "author@example.test"},
354
          {"GIT_COMMITTER_NAME", "Test Author"},
355
          {"GIT_COMMITTER_EMAIL", "author@example.test"}
356
        ]
357
      )
358
359
    {oid, tree}
360
  end
361
362
  defp pull_request_chain(repository, oids, branches) do
363
    branches
364
    |> Enum.with_index()
365
    |> Enum.map(fn {branch, index} ->
366
      base = Enum.at(["main" | branches], index)
367
      pull_request(repository, branch, base, oids[base], oids[branch])
368
    end)
369
  end
370
371
  defp pull_request(repository, head_ref, base_ref, base_sha, head_sha, state \\ "open") do
372
    issue = issue_fixture(repository, %{title: "PR #{head_ref}"})
373
374
    {:ok, _pull_request} =
375
      %PullRequest{}
376
      |> PullRequest.changeset(%{
377
        repository_id: repository.id,
378
        issue_id: issue.id,
379
        head_repository_id: repository.id,
380
        head_ref: head_ref,
381
        head_sha: head_sha,
382
        base_ref: base_ref,
383
        base_sha: base_sha,
384
        state: state
385
      })
386
      |> Repo.insert()
387
388
    issue.number
389
  end
390
391
  defp git!(git_dir, args, input, options \\ []) do
392
    input_path =
393
      Path.join(
394
        System.tmp_dir!(),
395
        "stack-controller-input-#{System.unique_integer([:positive])}"
396
      )
397
398
    File.write!(input_path, input)
399
400
    try do
401
      {output, 0} =
402
        System.cmd(
403
          "sh",
404
          ["-c", ~s(exec git --git-dir "$GIT_DIR" "$@" < "$INPUT"), "sh"] ++ args,
405
          env: [{"GIT_DIR", git_dir}, {"INPUT", input_path}] ++ Keyword.get(options, :env, [])
406
        )
407
408
      String.trim(output)
409
    after
410
      File.rm(input_path)
411
    end
412
  end
413
414
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
415
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
416
end

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