lib/openagents/issues/closing_references.ex

main at 58e6347eeb72 · 7 KB

defmodule OpenAgents.Issues.ClosingReferences do
  @moduledoc """
  Close issues that a commit says it closes.

  `OpenAgents.Forge.CommitReferences` reads the message; this module decides
  what to do about it. The decision is deliberately narrow, because the input
  arrives on the push path and a wrong close is expensive to notice:

    * **Same repository only.** A `owner/repo#N` reference is dropped, the
      same boundary the prerequisite edges of #100 draw. Closing across
      repositories asks a second authority question this slice does not
      answer.
    * **Write authority.** The push principal must be a user who can write
      the issue's repository. Any other principal records nothing.
    * **Once.** The `{issue_id, commit_sha}` unique index is the gate. A
      replayed WAL entry, a reconciled receipt, and a force push that
      re-presents the same commit all find the row already there and stop.
    * **Never reopen.** An already-closed issue records the reference and
      keeps its state. A revert is a new commit, and reopening on one is a
      separate policy with its own failure modes.

  The caller decides which commits are eligible. `OpenAgents.Forge.Pushes`
  passes only commits newly reachable from the repository's default branch,
  which is what stops an unmerged topic branch from closing anything.
  """

  import Ecto.Query

  require Logger

  alias OpenAgents.Accounts.User
  alias OpenAgents.Forge.CommitReferences
  alias OpenAgents.Issues
  alias OpenAgents.Issues.{ClosingReference, Evidence, Issue}
  alias OpenAgents.Repo
  alias OpenAgents.Repositories
  alias OpenAgents.Repositories.Repository

  @doc """
  Apply the closing references in `message` for one commit.

  `context` carries the push provenance recorded on each row: `:repo`,
  `:wal_seq`, `:push_receipt_id`, and `:principal`.

  Returns the references recorded by this call, which is `[]` when the
  message names nothing, when the principal cannot write the repository, or
  when every reference was already recorded.
  """
  @spec apply_commit(Repository.t(), User.t() | nil, String.t(), String.t(), keyword()) ::
          [ClosingReference.t()]
  def apply_commit(repository, actor, commit_sha, message, context \\ [])

  def apply_commit(%Repository{} = repository, %User{} = actor, commit_sha, message, context)
      when is_binary(commit_sha) and is_binary(message) do
    references = CommitReferences.closing(message)

    if references == [] or not Repositories.writable?(repository, actor) do
      []
    else
      references
      |> Enum.filter(&CommitReferences.same_repository?/1)
      |> Enum.flat_map(&record(repository, actor, commit_sha, &1, context))
    end
  end

  def apply_commit(_repository, _actor, _commit_sha, _message, _context), do: []

  @doc "The closing references recorded against one issue, oldest first."
  @spec for_issue(Issue.t()) :: [ClosingReference.t()]
  def for_issue(%Issue{id: issue_id}) do
    ClosingReference
    |> where([reference], reference.issue_id == ^issue_id)
    |> order_by([reference], asc: reference.inserted_at)
    |> preload(:closed_by_user)
    |> Repo.all()
  end

  @doc "The closing references one commit recorded, with the issue preloaded."
  @spec for_commit(Repository.t(), String.t()) :: [ClosingReference.t()]
  def for_commit(%Repository{id: repository_id}, commit_sha) when is_binary(commit_sha) do
    ClosingReference
    |> where(
      [reference],
      reference.repository_id == ^repository_id and reference.commit_sha == ^commit_sha
    )
    |> order_by([reference], asc: reference.inserted_at)
    |> preload(:issue)
    |> Repo.all()
  end

  def for_commit(_repository, _commit_sha), do: []

  # ── internals ────────────────────────────────────────────────────────────

  defp record(repository, actor, commit_sha, reference, context) do
    case fetch_issue(repository, reference.number) do
      nil ->
        []

      %Issue{} = issue ->
        insert_and_close(repository, actor, issue, commit_sha, reference, context)
    end
  end

  defp fetch_issue(%Repository{id: repository_id}, number) do
    Repo.one(
      from issue in Issue,
        where: issue.repository_id == ^repository_id and issue.number == ^number
    )
  end

  # The reference row and the close land together. A failure anywhere rolls
  # both back, so reconciliation retries a half-applied close instead of
  # leaving a reference that claims a close that never happened.
  defp insert_and_close(repository, actor, issue, commit_sha, reference, context) do
    Repo.transaction(fn ->
      attrs = %{
        repository_id: repository.id,
        issue_id: issue.id,
        commit_sha: commit_sha,
        repo: Keyword.get(context, :repo),
        wal_seq: Keyword.get(context, :wal_seq),
        principal: Keyword.get(context, :principal) || "user:#{actor.id}",
        verb: reference.verb,
        closed: issue.state == "open",
        closed_by_user_id: actor.id,
        push_receipt_id: Keyword.get(context, :push_receipt_id)
      }

      # The read and the insert share the transaction, so the check and the
      # row it guards cannot disagree. A concurrent writer that beats the
      # insert still meets the unique index, and the constraint error rolls
      # the close back with it rather than closing twice.
      if recorded?(issue, commit_sha) do
        []
      else
        case %ClosingReference{} |> ClosingReference.changeset(attrs) |> Repo.insert() do
          {:ok, %ClosingReference{} = recorded} ->
            # The one moment the issue, the commit, the repository, and the
            # push receipt are all in hand. Reading them back later would need
            # a window scan over the receipts; writing the edge here does not.
            _ =
              Evidence.record_push(
                repository,
                issue,
                commit_sha,
                recorded.push_receipt_id,
                attrs.principal
              )

            close(issue, actor, recorded)

          {:error, changeset} ->
            Repo.rollback(changeset)
        end
      end
    end)
    |> case do
      {:ok, recorded} ->
        recorded

      {:error, reason} ->
        Logger.warning(
          "issue_closing_reference_failed issue=#{issue.number} code=#{OpenAgents.OperationalLog.code(reason)}"
        )

        []
    end
  end

  defp recorded?(%Issue{id: issue_id}, commit_sha) do
    Repo.exists?(
      from reference in ClosingReference,
        where: reference.issue_id == ^issue_id and reference.commit_sha == ^commit_sha
    )
  end

  defp close(%Issue{state: "open"} = issue, actor, recorded) do
    case Issues.update_issue(issue, %{"state" => "closed", "state_reason" => "completed"}, actor) do
      {:ok, _closed} -> [recorded]
      {:error, reason} -> Repo.rollback(reason)
    end
  end

  # Already closed: the reference is worth keeping — it is what links the
  # issue to the commit that shipped it — but the state does not move.
  defp close(%Issue{}, _actor, recorded), do: [recorded]
end