test/openagents/issues/releases_test.exs

main at 58e6347eeb72 · 9 KB

defmodule OpenAgents.Issues.ReleasesTest do
  @moduledoc """
  #10, the half of the traceability chain a sha comparison cannot answer.

  An issue's closing commit is an ancestor of the revision a release was
  promoted at, never that revision itself, so `OpenAgents.Forge.receipts_for/2`
  — which matches a fleet target by comparing shas — finds nothing for the
  commit that actually shipped. These tests run against a real bare forge
  repository with a real commit graph and real promoted targets, because the
  only thing that makes the answer true is git's own containment relation.

  The graph every test reads:

      c0 ── c1 ── c2 ── c3      (refs/heads/main = c3)
        └── side                (refs/heads/side, never merged)
  """

  use OpenAgents.DataCase, async: false

  alias OpenAgents.Forge.Repos
  alias OpenAgents.Forge.Targets
  alias OpenAgents.Issues
  alias OpenAgents.Issues.{Activity, ClosingReference, Releases}
  alias OpenAgents.Repo
  alias OpenAgents.Repositories

  @repo "openagents.com"

  setup do
    Ecto.Adapters.SQL.Sandbox.mode(OpenAgents.Repo, {:shared, self()})

    base = Path.join(System.tmp_dir!(), "issue-releases-#{System.unique_integer([:positive])}")
    File.mkdir_p!(base)

    previous_data = Application.get_env(:openagents, :forge_data_dir)
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))

    on_exit(fn ->
      restore(:forge_data_dir, previous_data)
      restore(:forge_wal_dir, previous_wal)
      File.rm_rf(base)
    end)

    repository = Repositories.get_by_path!("OpenAgentsInc", @repo)
    {:ok, issue} = Issues.create_issue(repository, %{title: "Ship the release link"})

    Map.merge(seed_graph(), %{repository: repository, issue: issue})
  end

  defp restore(key, nil), do: Application.delete_env(:openagents, key)
  defp restore(key, value), do: Application.put_env(:openagents, key, value)

  describe "for_issue/2" do
    test "an issue no commit claims has no commits and no release", context do
      assert Releases.for_issue(context.repository, context.issue) == Releases.empty()
    end

    test "a release promoted at a descendant of the issue's commit carried it", context do
      claim(context, context.c1)
      target = release(context.c2, "live")

      assert %{commits: [commit], released_in: released, truncated: false} =
               Releases.for_issue(context.repository, context.issue)

      assert commit.sha == context.c1
      assert [carried] = commit.releases
      assert carried.sha == context.c2
      assert carried.status == "live"
      assert released.id == target.id
      assert released.sha == context.c2
    end

    test "a release promoted before the commit existed did not carry it", context do
      claim(context, context.c2)
      release(context.c0, "live")

      assert %{commits: [commit], released_in: nil} =
               Releases.for_issue(context.repository, context.issue)

      assert commit.releases == []
    end

    test "a commit that never reached the mainline is carried by nothing", context do
      claim(context, context.side)
      release(context.c3, "live")

      assert %{commits: [commit], released_in: nil} =
               Releases.for_issue(context.repository, context.issue)

      assert commit.releases == []
    end

    test "every release that contains the commit is listed, oldest first", context do
      claim(context, context.c1)
      first = release(context.c2, "live")
      second = release(context.c3, "live")

      assert %{commits: [commit], released_in: released} =
               Releases.for_issue(context.repository, context.issue)

      assert Enum.map(commit.releases, & &1.id) == [first.id, second.id]
      assert released.id == first.id
    end

    test "released_in is the oldest live release that carried every commit", context do
      claim(context, context.c1)
      claim(context, context.c3)
      partial = release(context.c2, "live")
      whole = release(context.c3, "live")

      assert %{commits: commits, released_in: released} =
               Releases.for_issue(context.repository, context.issue)

      by_sha = Map.new(commits, &{&1.sha, &1})

      assert Enum.map(by_sha[context.c1].releases, & &1.id) == [partial.id, whole.id]
      assert Enum.map(by_sha[context.c3].releases, & &1.id) == [whole.id]
      assert released.id == whole.id
    end

    test "a promoted release that never went live is listed but never released_in", context do
      claim(context, context.c1)
      promoted = release(context.c3, "promoted")

      assert %{commits: [commit], released_in: nil} =
               Releases.for_issue(context.repository, context.issue)

      assert [carried] = commit.releases
      assert carried.id == promoted.id
      assert carried.status == "promoted"
    end

    test "the target window is bounded and says so when it cuts something off", context do
      claim(context, context.c1)
      for _each <- 1..13, do: release(context.c3, "live")

      assert %{commits: [commit], truncated: true} =
               Releases.for_issue(context.repository, context.issue)

      assert length(commit.releases) == 12
    end

    test "a repository that is not the issue's own answers with nothing", context do
      claim(context, context.c1)
      release(context.c2, "live")

      other = %{context.repository | id: Ecto.UUID.generate()}

      assert Releases.for_issue(other, context.issue) == Releases.empty()
    end
  end

  describe "the activity read and its JSON" do
    test "activity carries the release that shipped the issue", context do
      claim(context, context.c1)
      target = release(context.c2, "live")

      activity = Activity.for_issue(context.issue)

      assert activity.releases.released_in.id == target.id

      rendered = OpenAgentsWeb.IssueJSON.render("activity.json", %{activity: activity})

      assert %{released_in: %{sha: sha, status: "live"}, truncated: false} = rendered.releases
      assert sha == context.c2
      assert [%{sha: ^sha}] = hd(rendered.releases.commits).releases
    end

    test "an issue nothing shipped renders an empty release answer", context do
      rendered =
        OpenAgentsWeb.IssueJSON.render("activity.json", %{
          activity: Activity.for_issue(context.issue)
        })

      assert rendered.releases == %{commits: [], released_in: nil, truncated: false}
    end
  end

  # ── fixture ──────────────────────────────────────────────────────────────

  # One `Closes #N` reference, written the way `OpenAgents.Issues.ClosingReferences`
  # writes it. What the reference means is proved by `OpenAgents.Forge.PushClosesIssuesTest`;
  # what it is worth here is the commit it names.
  defp claim(%{repository: repository, issue: issue}, sha) do
    %ClosingReference{}
    |> ClosingReference.changeset(%{
      repository_id: repository.id,
      issue_id: issue.id,
      commit_sha: sha,
      repo: @repo,
      principal: "test:releases",
      verb: "closes",
      closed: true
    })
    |> Repo.insert!()
  end

  # One promotion through the real lane, so the sha precondition and the
  # transition table both run. `status` is where the target is left.
  defp release(sha, status) do
    {:ok, target} = Targets.promote(@repo, sha, "operator:releases-test")

    Enum.reduce_while(["building", "built", "deploying", "live"], target, fn step, current ->
      if current.status == status, do: {:halt, current}, else: {:cont, advance(current, step)}
    end)
  end

  defp advance(target, step) do
    {:ok, advanced} = Targets.advance(target.id, step)
    advanced
  end

  defp seed_graph do
    path = Repos.ensure_repo!(@repo)

    c0 = commit(path, [{"f.txt", "zero\n"}], [])
    c1 = commit(path, [{"f.txt", "zero\n"}, {"one.txt", "one\n"}], ["-p", c0])
    c2 = commit(path, [{"f.txt", "zero\n"}, {"two.txt", "two\n"}], ["-p", c1])
    c3 = commit(path, [{"f.txt", "zero\n"}, {"three.txt", "three\n"}], ["-p", c2])
    side = commit(path, [{"f.txt", "zero\n"}, {"side.txt", "side\n"}], ["-p", c0])

    {_output, 0} = Repos.git(path, ["update-ref", "refs/heads/main", c3])
    {_output, 0} = Repos.git(path, ["update-ref", "refs/heads/side", side])

    %{path: path, c0: c0, c1: c1, c2: c2, c3: c3, side: side}
  end

  defp commit(path, files, parents) do
    listing =
      files
      |> Enum.map(fn {name, content} -> "100644 blob #{blob(path, content)}\t#{name}\n" end)
      |> Enum.join()

    {tree, 0} = plumb(path, ["mktree"], listing)

    {sha, 0} =
      plumb(path, ["commit-tree", String.trim(tree)] ++ parents, "commit #{listing}",
        env: [
          {"GIT_AUTHOR_NAME", "Release Test"},
          {"GIT_AUTHOR_EMAIL", "release@example.test"},
          {"GIT_AUTHOR_DATE", "2026-01-01T00:00:00Z"},
          {"GIT_COMMITTER_NAME", "Release Test"},
          {"GIT_COMMITTER_EMAIL", "release@example.test"},
          {"GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z"}
        ]
      )

    String.trim(sha)
  end

  defp blob(path, content) do
    {sha, 0} = plumb(path, ["hash-object", "-w", "--stdin"], content)
    String.trim(sha)
  end

  defp plumb(path, args, stdin, opts \\ []) do
    input = Path.join(System.tmp_dir!(), "plumb-#{System.unique_integer([:positive])}")
    File.write!(input, stdin)

    try do
      System.cmd(
        "sh",
        ["-c", ~s(exec git --git-dir "$GD" "$@" < "$IN"), "sh"] ++ args,
        env: [{"GD", path}, {"IN", input}] ++ Keyword.get(opts, :env, [])
      )
    after
      File.rm(input)
    end
  end
end