test/openagents/stacks/checks_test.exs

58e6347eeb72 · 16 KB

defmodule OpenAgents.Stacks.ChecksTest do
  @moduledoc """
  Stack-aware checks (#53): full-context run identity, trunk-advance
  invalidation, immutable synthetic snapshot refs whose trees layer the
  current trunk plus every layer through the checked pull request, and
  every declared `run_on` policy.
  """

  use OpenAgents.DataCase, async: false

  alias OpenAgents.Forge.Repos
  alias OpenAgents.PullRequests.PullRequest
  alias OpenAgents.Repo
  alias OpenAgents.Stacks
  alias OpenAgents.Stacks.CheckRun
  alias OpenAgents.Stacks.Checks

  import OpenAgents.AccountsFixtures
  import OpenAgents.IssuesFixtures

  setup do
    base = Path.join(System.tmp_dir!(), "stack-checks-#{System.unique_integer([:positive])}")

    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_env(:forge_data_dir, previous_data)
      restore_env(:forge_wal_dir, previous_wal)
      File.rm_rf(base)
    end)

    actor = repository_user_fixture("checks-actor")
    repository = repository_with_member_fixture(actor)

    %{actor: actor, repository: repository}
  end

  describe "workflow definitions" do
    test "an absent definitions file returns no_workflows", context do
      %{repository: repository, actor: actor} = context
      %{stack: stack} = seed_stack(repository, actor, workflows: nil)

      assert {:error, :no_workflows} = Checks.plan(repository, stack)
    end

    test "a malformed definitions file is rejected", context do
      %{repository: repository, actor: actor} = context
      %{stack: stack} = seed_stack(repository, actor, workflows: "not json")

      assert {:error, {:invalid_workflows, :malformed_json}} = Checks.plan(repository, stack)
    end

    test "an unknown run_on policy is rejected", context do
      %{repository: repository, actor: actor} = context

      %{stack: stack} =
        seed_stack(repository, actor,
          workflows: workflows_json([%{"name" => "unit", "run_on" => "sometimes"}])
        )

      assert {:error, {:invalid_workflows, {:unknown_run_on, "unit", "sometimes"}}} =
               Checks.plan(repository, stack)
    end
  end

  describe "run identity" do
    test "runs key to head, effective base, and workflow definition", context do
      %{repository: repository, actor: actor} = context

      %{stack: stack, oids: oids, pull_requests: [pr_1, pr_2]} =
        seed_stack(repository, actor, workflows: every_layer_workflows())

      {:ok, plan} = Checks.plan(repository, stack)

      assert plan.invalidated == 0
      assert plan.skipped == []
      assert length(plan.runs) == 2

      run_1 = Enum.find(plan.runs, &(&1.pull_request_id == pr_1.id))
      run_2 = Enum.find(plan.runs, &(&1.pull_request_id == pr_2.id))

      assert run_1.head_oid == oids["layer-1"]
      assert run_2.head_oid == oids["layer-2"]
      assert run_1.effective_base_oid == plan.trunk_oid
      assert run_2.effective_base_oid == plan.trunk_oid
      assert run_1.workflow_definition_oid == plan.definition_oid
      assert run_1.state == "pending"

      # A healthy stack's heads already contain every lower layer, so the
      # heads are their own snapshots and no synthetic ref publishes.
      assert run_1.tested_oid == oids["layer-1"]
      assert run_2.tested_oid == oids["layer-2"]
      assert is_nil(run_1.synthetic_ref)
      assert is_nil(run_2.synthetic_ref)
    end

    test "planning twice reuses runs instead of duplicating identity", context do
      %{repository: repository, actor: actor} = context

      %{stack: stack} = seed_stack(repository, actor, workflows: every_layer_workflows())

      {:ok, first} = Checks.plan(repository, stack)
      {:ok, second} = Checks.plan(repository, stack)

      assert Enum.map(first.runs, & &1.id) |> Enum.sort() ==
               Enum.map(second.runs, & &1.id) |> Enum.sort()

      assert Repo.aggregate(CheckRun, :count) == 2
    end

    test "a trunk advance invalidates existing runs and re-keys new ones", context do
      %{repository: repository, actor: actor} = context

      %{stack: stack, path: path, oids: oids} =
        seed_stack(repository, actor, workflows: every_layer_workflows())

      {:ok, before_advance} = Checks.plan(repository, stack)
      old_trunk = before_advance.trunk_oid

      advance = commit(path, oids["main"], "Advance trunk", %{"advance.md" => "advance\n"})
      {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", advance])

      {:ok, after_advance} = Checks.plan(repository, stack)

      assert after_advance.trunk_oid == advance
      assert after_advance.invalidated == 2

      for run <- before_advance.runs do
        assert Repo.get!(CheckRun, run.id).state == "stale"
      end

      for run <- after_advance.runs do
        assert run.effective_base_oid == advance
        assert run.effective_base_oid != old_trunk
        assert run.state == "pending"
      end
    end

    test "refresh alone marks affected runs stale after a trunk advance", context do
      %{repository: repository, actor: actor} = context

      %{stack: stack, path: path, oids: oids} =
        seed_stack(repository, actor, workflows: every_layer_workflows())

      {:ok, plan} = Checks.plan(repository, stack)
      {:ok, run} = Checks.report(hd(plan.runs), "passed")
      assert run.state == "passed"

      advance = commit(path, oids["main"], "Advance trunk", %{"advance.md" => "advance\n"})
      {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", advance])

      {:ok, %{trunk_oid: ^advance, invalidated: 2}} = Checks.refresh(repository, stack)

      assert Repo.get!(CheckRun, run.id).state == "stale"
    end
  end

  describe "synthetic snapshot refs" do
    test "a stack behind trunk tests a synthetic snapshot of trunk plus its layers",
         context do
      %{repository: repository, actor: actor} = context

      %{stack: stack, path: path, oids: oids, pull_requests: [pr_1, pr_2]} =
        seed_stack(repository, actor, workflows: every_layer_workflows())

      advance = commit(path, oids["main"], "Advance trunk", %{"advance.md" => "advance\n"})
      {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", advance])

      {:ok, plan} = Checks.plan(repository, stack)

      run_1 = Enum.find(plan.runs, &(&1.pull_request_id == pr_1.id))
      run_2 = Enum.find(plan.runs, &(&1.pull_request_id == pr_2.id))

      for run <- [run_1, run_2] do
        assert run.synthetic_ref == "refs/internal/checks/" <> run.id
        assert run.tested_oid != run.head_oid
        assert show(path, ["rev-parse", run.synthetic_ref]) == run.tested_oid
      end

      # The snapshot tree is the current trunk plus every layer through the
      # checked pull request.
      names_1 = tree_names(path, run_1.tested_oid)
      assert "advance.md" in names_1
      assert "layer-1.md" in names_1
      refute "layer-2.md" in names_1

      names_2 = tree_names(path, run_2.tested_oid)
      assert "advance.md" in names_2
      assert "layer-1.md" in names_2
      assert "layer-2.md" in names_2
    end

    test "a synthetic ref publishes once and never moves", context do
      %{repository: repository, actor: actor} = context

      %{stack: stack, path: path, oids: oids} =
        seed_stack(repository, actor, workflows: every_layer_workflows())

      advance = commit(path, oids["main"], "Advance trunk", %{"advance.md" => "advance\n"})
      {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", advance])

      {:ok, first} = Checks.plan(repository, stack)
      {:ok, second} = Checks.plan(repository, stack)

      assert Enum.map(first.runs, & &1.id) |> Enum.sort() ==
               Enum.map(second.runs, & &1.id) |> Enum.sort()

      for run <- second.runs do
        assert show(path, ["rev-parse", run.synthetic_ref]) == run.tested_oid
        assert Repo.get!(CheckRun, run.id).tested_oid == run.tested_oid
      end
    end
  end

  describe "run_on policies" do
    test "top_layer_only plans only the top layer, with explicit skips", context do
      %{repository: repository, actor: actor} = context

      %{stack: stack, pull_requests: [_pr_1, pr_2]} =
        seed_stack(repository, actor,
          workflows: workflows_json([%{"name" => "e2e", "run_on" => "top_layer_only"}])
        )

      {:ok, plan} = Checks.plan(repository, stack)

      assert [run] = plan.runs
      assert run.pull_request_id == pr_2.id
      assert plan.skipped == [%{position: 1, workflow: "e2e", policy: "top_layer_only"}]
    end

    test "bottom_layer_only plans only the bottom layer", context do
      %{repository: repository, actor: actor} = context

      %{stack: stack, pull_requests: [pr_1, _pr_2]} =
        seed_stack(repository, actor,
          workflows: workflows_json([%{"name" => "smoke", "run_on" => "bottom_layer_only"}])
        )

      {:ok, plan} = Checks.plan(repository, stack)

      assert [run] = plan.runs
      assert run.pull_request_id == pr_1.id
      assert plan.skipped == [%{position: 2, workflow: "smoke", policy: "bottom_layer_only"}]
    end

    test "changed_paths plans only layers that touch the declared paths", context do
      %{repository: repository, actor: actor} = context

      %{stack: stack, pull_requests: [pr_1, _pr_2]} =
        seed_stack(repository, actor,
          workflows:
            workflows_json([
              %{
                "name" => "layer-1-only",
                "run_on" => "changed_paths",
                "paths" => ["layer-1.md"]
              }
            ])
        )

      {:ok, plan} = Checks.plan(repository, stack)

      assert [run] = plan.runs
      assert run.pull_request_id == pr_1.id

      assert plan.skipped == [
               %{position: 2, workflow: "layer-1-only", policy: "changed_paths"}
             ]
    end

    test "merge_group_only skips every layer outside a merge group", context do
      %{repository: repository, actor: actor} = context

      %{stack: stack} =
        seed_stack(repository, actor,
          workflows:
            workflows_json([
              %{"name" => "queue-gate", "run_on" => "merge_group_only", "required" => true}
            ])
        )

      {:ok, plan} = Checks.plan(repository, stack)

      assert plan.runs == []

      assert plan.skipped == [
               %{position: 1, workflow: "queue-gate", policy: "merge_group_only"},
               %{position: 2, workflow: "queue-gate", policy: "merge_group_only"}
             ]
    end

    test "the merge_group context runs every workflow: required checks never skip",
         context do
      %{repository: repository, actor: actor} = context

      %{stack: stack} =
        seed_stack(repository, actor,
          workflows:
            workflows_json([
              %{"name" => "queue-gate", "run_on" => "merge_group_only", "required" => true},
              %{"name" => "e2e", "run_on" => "top_layer_only", "required" => true}
            ])
        )

      {:ok, plan} = Checks.plan(repository, stack, context: "merge_group")

      assert plan.skipped == []
      assert length(plan.runs) == 4
      assert Enum.all?(plan.runs, &(&1.context == "merge_group"))

      # Layer-context runs and merge-group runs have distinct identities.
      {:ok, layer_plan} = Checks.plan(repository, stack)
      assert [%{workflow_name: "e2e", context: "layer"}] = layer_plan.runs
    end
  end

  describe "reporting" do
    test "a pending run concludes once", context do
      %{repository: repository, actor: actor} = context

      %{stack: stack} = seed_stack(repository, actor, workflows: every_layer_workflows())

      {:ok, plan} = Checks.plan(repository, stack)
      [run | _rest] = plan.runs

      {:ok, passed} = Checks.report(run, "passed")
      assert passed.state == "passed"
      refute is_nil(passed.concluded_at)

      assert {:error, :not_pending} = Checks.report(passed, "failed")
    end
  end

  ## Seeding

  defp every_layer_workflows do
    workflows_json([%{"name" => "unit", "run_on" => "every_layer", "required" => true}])
  end

  defp workflows_json(workflows), do: Jason.encode!(%{"workflows" => workflows})

  defp seed_stack(repository, actor, opts) do
    path = Repos.ensure_repo!(repository.storage_key, repository.default_branch)
    branches = ["layer-1", "layer-2"]

    seed = commit(path, nil, "Seed repository", %{"README.md" => "readme\n"})

    main =
      case Keyword.fetch!(opts, :workflows) do
        nil -> seed
        json -> commit_workflows(path, seed, json)
      end

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

    {oids, _parent} =
      Enum.reduce(branches, {%{"main" => main}, main}, fn branch, {oids, parent} ->
        oid = commit(path, parent, "Layer #{branch}", %{"#{branch}.md" => "#{branch}\n"})
        {_, 0} = Repos.git(path, ["update-ref", "refs/heads/#{branch}", oid])
        {Map.put(oids, branch, oid), oid}
      end)

    pull_requests =
      branches
      |> Enum.with_index()
      |> Enum.map(fn {branch, index} ->
        base = Enum.at(["main" | branches], index)
        pull_request(repository, branch, base, oids[base], oids[branch])
      end)

    {:ok, stack} = Stacks.create(repository, pull_requests, actor)

    %{path: path, oids: oids, stack: stack, pull_requests: pull_requests}
  end

  # Commits `.forge/workflows.json` as a nested tree over the parent's tree.
  defp commit_workflows(path, parent, json) do
    blob = git!(path, ["hash-object", "-w", "--stdin"], json)
    forge_tree = git!(path, ["mktree"], "100644 blob #{blob}\tworkflows.json\n")

    {listing, 0} = Repos.git(path, ["ls-tree", parent])

    entries =
      listing
      |> String.split("\n", trim: true)
      |> Enum.reject(&String.ends_with?(&1, "\t.forge"))
      |> Enum.concat(["040000 tree #{forge_tree}\t.forge"])

    tree = git!(path, ["mktree"], Enum.map_join(entries, "", &(&1 <> "\n")))

    git!(path, ["commit-tree", tree, "-p", parent, "-m", "Declare workflows"], "",
      env: committer_env()
    )
  end

  defp pull_request(repository, head_ref, base_ref, base_sha, head_sha) do
    issue = issue_fixture(repository, %{title: "PR #{head_ref}"})

    {:ok, pull_request} =
      %PullRequest{}
      |> PullRequest.changeset(%{
        repository_id: repository.id,
        issue_id: issue.id,
        head_repository_id: repository.id,
        head_ref: head_ref,
        head_sha: head_sha,
        base_ref: base_ref,
        base_sha: base_sha,
        state: "open"
      })
      |> Repo.insert()

    Repo.preload(pull_request, :issue)
  end

  # Commits a tree that layers the given files over the parent's tree.
  defp commit(path, parent, message, files) do
    parent_entries =
      if parent do
        {listing, 0} = Repos.git(path, ["ls-tree", parent])

        listing
        |> String.split("\n", trim: true)
        |> Map.new(fn line ->
          [meta, name] = String.split(line, "\t", parts: 2)
          {name, meta <> "\t" <> name}
        end)
      else
        %{}
      end

    new_entries =
      Map.new(files, fn {name, content} ->
        blob = git!(path, ["hash-object", "-w", "--stdin"], content)
        {name, "100644 blob #{blob}\t#{name}"}
      end)

    listing =
      parent_entries
      |> Map.merge(new_entries)
      |> Map.values()
      |> Enum.map_join("", &(&1 <> "\n"))

    tree = git!(path, ["mktree"], listing)
    parent_args = if parent, do: ["-p", parent], else: []

    git!(path, ["commit-tree", tree] ++ parent_args ++ ["-m", message], "", env: committer_env())
  end

  defp committer_env do
    [
      {"GIT_AUTHOR_NAME", "Test Author"},
      {"GIT_AUTHOR_EMAIL", "author@example.test"},
      {"GIT_COMMITTER_NAME", "Test Author"},
      {"GIT_COMMITTER_EMAIL", "author@example.test"}
    ]
  end

  defp show(path, args) do
    {output, 0} = Repos.git(path, args)
    String.trim(output)
  end

  defp tree_names(path, commit_oid) do
    {listing, 0} = Repos.git(path, ["ls-tree", "-r", "--name-only", commit_oid])
    String.split(listing, "\n", trim: true)
  end

  defp git!(git_dir, args, input, options \\ []) do
    input_path =
      Path.join(System.tmp_dir!(), "checks-input-#{System.unique_integer([:positive])}")

    File.write!(input_path, input)

    try do
      {output, 0} =
        System.cmd(
          "sh",
          ["-c", ~s(exec git --git-dir "$GIT_DIR" "$@" < "$INPUT"), "sh"] ++ args,
          env: [{"GIT_DIR", git_dir}, {"INPUT", input_path}] ++ Keyword.get(options, :env, [])
        )

      String.trim(output)
    after
      File.rm(input_path)
    end
  end

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