lib/openagents/forge/receipt_repository.ex

58e6347eeb72 · 4 KB

defmodule OpenAgents.Forge.ReceiptRepository do
  @moduledoc """
  Which repository a forge receipt belongs to.

  `forge_builds.repo` and `forge_deploys.repo` hold `Target.repo`, which
  `OpenAgents.Forge.Targets` constrains to a member of `:forge_repos` — a
  repository *name*, or `owner/name`. `repositories` is unique on
  `{namespace_id, name_key}` rather than on `name`, so a name can answer for
  two repositories and a receipt keyed only by that name names neither.

  `forge_pushes.repo` is a different value with a different property: it is
  `Repository.storage_key`, which carries a unique index, so a push receipt
  already names exactly one repository. It has no `repository_id` and does not
  need one — see `EXIT-003`, which requires every `forge_pushes` column to be
  re-derivable from the WAL.

  Two operations, and they are deliberately asymmetric:

    * `resolve/1` runs once, at receipt time, and turns a string into a
      repository or into nothing. A name two repositories answer to resolves to
      nothing, so the receipt records a null key rather than a guess.
    * `scope/3` runs at read time and does not consult a string at all for a
      receipt that carries a key. The string stays as the fallback for the rows
      the backfill could not settle, and it is read only for those rows, so a
      shared name can no longer pull one repository's receipts into another's
      answer.
  """

  import Ecto.Query

  alias OpenAgents.Repo
  alias OpenAgents.Repositories.Repository

  @doc """
  The one repository `repo` names, or `nil`.

  Zero candidates and two candidates are the same answer on purpose: attaching
  a receipt to the wrong repository is worse than attaching it to none.
  """
  @spec resolve(term()) :: Repository.t() | nil
  def resolve(repo) when is_binary(repo) do
    Repository
    |> where(
      [repository],
      repository.storage_key == ^repo or repository.name == ^repo or
        fragment("? || '/' || ?", repository.owner, repository.name) == ^repo
    )
    |> limit(2)
    |> Repo.all()
    |> case do
      [%Repository{} = repository] -> repository
      _ambiguous_or_absent -> nil
    end
  end

  def resolve(_repo), do: nil

  @doc "The id of the one repository `repo` names, or `nil`."
  @spec resolve_id(term()) :: Ecto.UUID.t() | nil
  def resolve_id(repo) do
    case resolve(repo) do
      %Repository{id: id} -> id
      nil -> nil
    end
  end

  @doc """
  Narrows a `forge_builds` or `forge_deploys` query to one repository.

  With a repository in hand, a receipt matches on its key, and the string is
  consulted only for a receipt that has no key. Without one — a name that
  settles to nothing — the string is all there is, which is the same answer
  this surface gave before the key existed.
  """
  @spec scope(Ecto.Queryable.t(), Repository.t() | nil, [String.t()]) :: Ecto.Query.t()
  def scope(query, nil, repo_keys) do
    from receipt in query, where: receipt.repo in ^repo_keys
  end

  def scope(query, %Repository{id: repository_id}, repo_keys) do
    from receipt in query,
      where:
        receipt.repository_id == ^repository_id or
          (is_nil(receipt.repository_id) and receipt.repo in ^repo_keys)
  end

  @doc """
  Fills `repository_id` for the rows of `table` whose `repo` string settles.

  The migration that added the column runs exactly this statement, and it lives
  here rather than inside the migration so the rule is proven by a test instead
  of asserted by a comment. It is idempotent: a row that already carries a key
  is left alone, and a row whose name answers for two repositories, or for
  none, is left null. A null means "not settled", never "no repository".

  `forge_deploys` carries the `forge_deploy_receipts_immutable` trigger, which
  refuses every `UPDATE`. The caller suspends it; this function does not, so a
  backfill cannot quietly acquire the authority to rewrite a receipt.
  """
  @spec backfill!(String.t()) :: non_neg_integer()
  def backfill!(table) when table in ~w(forge_builds forge_deploys) do
    %Postgrex.Result{num_rows: filled} =
      Repo.query!("""
      WITH candidate AS (
        SELECT
          receipt.id AS receipt_id,
          repository.id AS repository_id,
          count(*) OVER (PARTITION BY receipt.id) AS matches
        FROM #{table} AS receipt
        JOIN repositories AS repository
          ON repository.storage_key = receipt.repo
          OR repository.name = receipt.repo
          OR repository.owner || '/' || repository.name = receipt.repo
      )
      UPDATE #{table} AS receipt
      SET repository_id = candidate.repository_id
      FROM candidate
      WHERE candidate.receipt_id = receipt.id
        AND candidate.matches = 1
        AND receipt.repository_id IS NULL
      """)

    filled
  end
end