lib/openagents/issues/releases.ex

58e6347eeb72 · 9 KB

defmodule OpenAgents.Issues.Releases do
  @moduledoc """
  Which release carried an issue.

  The chain from an issue to its receipts already existed and already joined,
  but it joined on an exact revision. `OpenAgents.Issues.Evidence` binds an
  issue to the receipts that evaluated the precise commit its work produced,
  and `OpenAgents.Forge.receipts_for/2` matches a fleet target by comparing
  shas. That is the right rule for a build receipt, which evaluated one tree
  and no other. It is the wrong rule for a release: a release is promoted at
  the revision the fleet should converge to, and the commit that closed an
  issue is almost never that revision. It is an ancestor of it.

  So a reader could see that an issue's commit was pushed and built, and
  could not see that it shipped. This module answers the missing half by
  asking git the question the sha comparison cannot: is the issue's commit
  contained in the release's revision.

  ## What it reads

  Two records, both of which already exist:

    * `issue_closing_references` — the commits that say they close the issue.
      `OpenAgents.Issues.ClosingReferences` verified the pusher could write
      the issue and required the commit to be reachable from the default
      branch before recording one, so a topic branch claims nothing here.
    * `forge_fleet_targets` — the operator-approved revisions the fleet was
      told to converge to, with the deploy-lane status each reached.

  It writes nothing. There is no issue-to-release table, because there is no
  fact to store: containment is a property of the commit graph the forge
  already holds, and a stored copy of it would be a second authority that
  could disagree with git.

  ## Why only closing references

  `forge_assignments.terminal_commit` is the other commit-to-issue source the
  evidence chain reads, and this module deliberately does not. An attempt's
  self-reported revision is the executor's claim rather than a merge, and the
  evidence chain gates it behind `OpenAgents.Transparency.WorkDisclosure`
  because a branch name and a revision an attempt produced can restate private
  repository content. Everything this module returns is derived from commits
  on the default branch of a repository the caller has already been admitted
  to read, so it needs no second disclosure ladder — and a module that needed
  one would be the wrong place to add it.

  ## What it bounds

  A read path that spawns a git subprocess per pair needs a ceiling in both
  directions: at most four claiming commits and at most twelve recent release
  targets, which is inside the pair limit
  `OpenAgents.Forge.GitPlane.containing/3` enforces. `truncated` says the
  window cut something off, so a caller can tell "nothing shipped it" from
  "the window did not reach far enough".
  """

  import Ecto.Query

  require Logger

  alias OpenAgents.Forge.{GitPlane, Target}
  alias OpenAgents.Issues.{ClosingReferences, Issue}
  alias OpenAgents.Repo
  alias OpenAgents.Repositories.Repository

  # The newest claiming commits and the newest release targets this read looks
  # at. Their product stays inside `GitPlane.containing/3`'s pair limit.
  @commit_limit 4
  @target_limit 12

  @typedoc "One release target that contains a commit, projected."
  @type release :: %{
          id: binary(),
          sha: String.t(),
          status: String.t(),
          promoted_at: DateTime.t(),
          settled_at: DateTime.t()
        }

  @typedoc """
  The release answer for one issue.

  `released_in` is the oldest release that reached `live` and contains every
  claiming commit in the window — the first release that shipped the whole of
  what the issue asked for. It is `nil` when no such release is in the window,
  including when only some of the commits shipped.
  """
  @type t :: %{
          commits: [
            %{
              sha: String.t(),
              verb: String.t() | nil,
              referenced_at: DateTime.t(),
              releases: [release()]
            }
          ],
          released_in: release() | nil,
          truncated: boolean()
        }

  @doc """
  The releases that carried `issue`, resolved through the commit graph.

  Never raises. A repository whose bare cache cannot be read, a WAL that is
  unreachable, and an issue no commit claims all answer the same way: an empty
  list, which is the honest reading of "nothing here says this shipped".
  """
  @spec for_issue(Issue.t()) :: t()
  def for_issue(%Issue{repository_id: repository_id} = issue) do
    case Repo.get(Repository, repository_id) do
      %Repository{} = repository -> for_issue(repository, issue)
      nil -> empty()
    end
  end

  @doc """
  The releases that carried `issue` in a repository the caller already
  resolved.

  `repository` must be the issue's own repository; a caller that passes
  another one gets `empty/0` rather than another repository's releases.
  """
  @spec for_issue(Repository.t(), Issue.t()) :: t()
  def for_issue(%Repository{id: repository_id} = repository, %Issue{} = issue) do
    if issue.repository_id == repository_id do
      resolve(repository, issue)
    else
      empty()
    end
  rescue
    error ->
      Logger.warning("issue_releases_failed code=#{OpenAgents.OperationalLog.code(error)}")
      empty()
  end

  @doc "The answer for an issue nothing claims: no commits, no release."
  @spec empty() :: t()
  def empty, do: %{commits: [], released_in: nil, truncated: false}

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

  defp resolve(%Repository{} = repository, %Issue{} = issue) do
    references = issue |> ClosingReferences.for_issue() |> Enum.uniq_by(& &1.commit_sha)
    kept = Enum.take(references, @commit_limit)
    targets = targets(repository)

    truncated? = length(references) > @commit_limit or length(targets) == @target_limit
    containment = containment(repository, kept, targets)

    commits =
      Enum.map(kept, fn reference ->
        %{
          sha: reference.commit_sha,
          verb: reference.verb,
          referenced_at: reference.inserted_at,
          releases: containment |> Map.get(reference.commit_sha, []) |> Enum.map(&release/1)
        }
      end)

    %{
      commits: commits,
      released_in: released_in(kept, containment),
      truncated: truncated?
    }
  end

  # A target names its repository by string, and two strings reach the same
  # repository: the storage key the git plane reads and the repository name a
  # promotion may have been recorded under. Both are read, and neither is
  # allowed to reach a repository other than this one.
  defp targets(%Repository{} = repository) do
    keys =
      [repository.storage_key, repository.name]
      |> Enum.reject(&(is_nil(&1) or &1 == ""))
      |> Enum.uniq()

    Target
    |> where([target], target.repo in ^keys)
    |> order_by([target], desc: target.inserted_at, desc: target.id)
    |> limit(@target_limit)
    |> Repo.all()
  end

  defp containment(_repository, [], _targets), do: %{}
  defp containment(_repository, _references, []), do: %{}

  defp containment(%Repository{} = repository, references, targets) do
    commits = Enum.map(references, & &1.commit_sha)
    by_sha = Enum.group_by(targets, & &1.sha)
    shas = Map.keys(by_sha)

    case GitPlane.containing(repository.storage_key, commits, shas) do
      {:ok, matrix} ->
        Map.new(matrix, fn {commit, matched} ->
          {commit,
           matched
           |> Enum.flat_map(&Map.get(by_sha, &1, []))
           |> Enum.sort_by(& &1.inserted_at, DateTime)}
        end)

      # An unreachable WAL, a cache this node cannot read, and a matrix past
      # the pair bound are all "this read cannot answer", which is not the
      # same fact as "nothing shipped it" — but the caller sees the same empty
      # list either way, because an issue page that guessed would be worse
      # than one that says nothing.
      {:error, reason} ->
        Logger.debug(
          "issue_releases_containment_unavailable code=#{OpenAgents.OperationalLog.code(reason)}"
        )

        %{}
    end
  end

  # The first release that carried the whole issue: the oldest `live` target
  # that contains every claiming commit in the window. A target that carried
  # some of them is on each of those commits' own lists and is not this.
  defp released_in([], _containment), do: nil

  defp released_in([first | rest], containment) do
    carried = Map.get(containment, first.commit_sha, [])

    shared =
      Enum.reduce(rest, MapSet.new(carried, & &1.id), fn reference, acc ->
        MapSet.intersection(
          acc,
          containment |> Map.get(reference.commit_sha, []) |> MapSet.new(& &1.id)
        )
      end)

    carried
    |> Enum.filter(&(&1.status == "live" and MapSet.member?(shared, &1.id)))
    |> Enum.min_by(& &1.inserted_at, DateTime, fn -> nil end)
    |> case do
      %Target{} = target -> release(target)
      nil -> nil
    end
  end

  defp release(%Target{} = target) do
    %{
      id: target.id,
      sha: target.sha,
      status: target.status,
      promoted_at: target.inserted_at,
      settled_at: target.updated_at
    }
  end
end