lib/openagents/stacks/merge_queue.ex

main at 58e6347eeb72 · 5 KB

defmodule OpenAgents.Stacks.MergeQueue do
  @moduledoc """
  The stack contract for merge queues (docs/stacked-prs.md section 14).

  No merge queue exists on the forge yet; this module defines the contract
  any queue implementation must satisfy for stacked pull requests, so the
  behavior stays fixed before an implementation lands:

  - A selected stack prefix enters the queue as one logical item
    (`OpenAgents.Stacks.QueueItem`) with ordered members, expected heads,
    the queue base OID, the stack version, and the policy version.
  - Speculation applies the current queue base, then each member bottom
    to top — never another order.
  - The queue may reorder independent items freely, but it must never
    reorder members within one item.
  - Ejecting a member ejects every selected member above it in the same
    speculative group: their prerequisite is no longer present.
  - A speculative result invalidates when the queue base SHA, the stack
    version, any selected head SHA, or the policy version changes.
  """

  alias OpenAgents.Stacks.QueueItem
  alias OpenAgents.Stacks.Stack
  alias OpenAgents.Stacks.StackEntry

  @doc """
  Build one logical queue item from a selected contiguous prefix of a
  stack's active entries.

  `entries` must be the active entries selected for the merge, bottom
  first; the selection must be a contiguous prefix starting at position 1,
  matching the merge contract. `queue_base_oid` is the base the queue will
  speculate onto, and `policy_version` is an opaque identity of the policy
  configuration in force (for example the workflow definition blob OID).
  """
  def item(%Stack{} = stack, entries, queue_base_oid, policy_version)
      when is_list(entries) and entries != [] and is_binary(queue_base_oid) do
    with :ok <- check_prefix(entries) do
      {:ok,
       %QueueItem{
         stack_id: stack.id,
         stack_number: stack.number,
         stack_version: stack.version,
         queue_base_oid: queue_base_oid,
         policy_version: policy_version,
         members:
           Enum.map(entries, fn %StackEntry{} = entry ->
             %{
               position: entry.position,
               pull_request_id: entry.pull_request_id,
               expected_head_oid: entry.observed_head_oid
             }
           end)
       }}
    end
  end

  @doc """
  The ordered application plan for one item's speculative merge group:
  the current queue base, then every member head bottom to top.
  """
  def application_plan(%QueueItem{} = item) do
    [item.queue_base_oid | Enum.map(item.members, & &1.expected_head_oid)]
  end

  @doc """
  Whether a proposed global member order is a legal reordering of the
  given items.

  The queue may interleave and reorder independent items however it wants,
  but the members of one item must keep their relative order. `proposed`
  is the full flattened order as `{stack_id, position}` pairs; it must
  contain exactly the members of `items`.
  """
  def valid_order?(items, proposed) when is_list(items) and is_list(proposed) do
    expected =
      items
      |> Enum.flat_map(fn item ->
        Enum.map(item.members, &{item.stack_id, &1.position})
      end)
      |> Enum.sort()

    same_members = Enum.sort(proposed) == expected

    ordered_within_each_item =
      proposed
      |> Enum.group_by(fn {stack_id, _position} -> stack_id end)
      |> Enum.all?(fn {_stack_id, members} ->
        positions = Enum.map(members, fn {_stack_id, position} -> position end)
        positions == Enum.sort(positions)
      end)

    same_members and ordered_within_each_item
  end

  @doc """
  Eject the member at `position` from the item's speculative group.

  The ejected member's prerequisite chain breaks for everything selected
  above it, so every higher member leaves the group with it. Returns
  `{:ok, %{ejected: members, remaining: members}}` with both halves in
  order, or `{:error, :not_a_member}` when the position is not selected.
  """
  def eject(%QueueItem{} = item, position) when is_integer(position) do
    if Enum.any?(item.members, &(&1.position == position)) do
      {remaining, ejected} = Enum.split_while(item.members, &(&1.position < position))
      {:ok, %{ejected: ejected, remaining: remaining}}
    else
      {:error, :not_a_member}
    end
  end

  @doc """
  Every reason the item's speculative result is no longer valid against
  the observed current state.

  `observed` carries `queue_base_oid`, `stack_version`, `policy_version`,
  and `head_oids` (a map of position to current head OID). An empty list
  means the speculation is still keyed to reality; any entry —
  `:queue_base_changed`, `:stack_version_changed`, `:policy_changed`, or
  `{:head_changed, position}` — invalidates it.
  """
  def invalidations(%QueueItem{} = item, observed) when is_map(observed) do
    base =
      if Map.fetch!(observed, :queue_base_oid) == item.queue_base_oid,
        do: [],
        else: [:queue_base_changed]

    version =
      if Map.fetch!(observed, :stack_version) == item.stack_version,
        do: [],
        else: [:stack_version_changed]

    policy =
      if Map.fetch!(observed, :policy_version) == item.policy_version,
        do: [],
        else: [:policy_changed]

    head_oids = Map.fetch!(observed, :head_oids)

    heads =
      item.members
      |> Enum.filter(&(Map.get(head_oids, &1.position) != &1.expected_head_oid))
      |> Enum.map(&{:head_changed, &1.position})

    base ++ version ++ policy ++ heads
  end

  @doc "Whether the item's speculative result is still valid."
  def valid?(%QueueItem{} = item, observed), do: invalidations(item, observed) == []

  defp check_prefix(entries) do
    positions = Enum.map(entries, & &1.position)

    if positions == Enum.to_list(1..length(entries)) do
      :ok
    else
      {:error, :not_a_prefix}
    end
  end
end