11.9 KiB · text History b868907
defmodule GitGud.Workflows.PullRequestDispatchTest do
  @moduledoc """
  `on: pull_request` starts runs when a pull request is opened, pushed to
  (`synchronize`) and reopened.

  Nothing ever dispatched that trigger before: the only caller of
  `Workflows.dispatch/2` was the push worker, which always said `"push"`, so
  a workflow written to test pull requests never ran and the PR page showed
  whatever a push to the same SHA happened to produce.

  On disk, with real commits and real pull requests, through the same
  paths the app takes: `PullRequests.create_pull_request/3`, the
  post-receive worker, `PullRequests.set_state/2`, and the Oban job they
  all enqueue.
  """
  use GitGud.DataCase, async: false
  use Oban.Testing, repo: GitGud.Repo

  alias GitGud.PullRequests
  alias GitGud.PullRequests.PullRequest
  alias GitGud.Repositories.Repository
  alias GitGud.Repositories.Workers.CommitBackfill
  alias GitGud.Workflows
  alias GitGud.Workflows.WorkflowRun

  import GitGud.ForgeFixtures

  setup do
    {user, repo} = repository_fixture()
    {:ok, repo: repo, user: user}
  end

  defp workflow(on) do
    """
    name: ci
    on:
    #{on}
    jobs:
      build:
        runs-on: self-hosted
        steps:
          - run: echo hello
    """
  end

  @wf ".forgejo/workflows/ci.yml"

  # A commit holding exactly `files` (path => contents) on top of `parents`.
  defp commit!(%Repository{disk_path: path}, files, parents) do
    index = Path.join(System.tmp_dir!(), "gitidx-#{System.unique_integer([:positive])}")
    env = [{"GIT_INDEX_FILE", index}]

    try do
      for {file, contents} <- files do
        blob = git!(path, ["hash-object", "-w", "--stdin"], contents)
        git!(path, ["update-index", "--add", "--cacheinfo", "100644,#{blob},#{file}"], nil, env)
      end

      tree = git!(path, ["write-tree"], nil, env)
      git!(path, ["commit-tree", tree | Enum.flat_map(parents, &["-p", &1])] ++ ["-m", "c"])
    after
      File.rm(index)
    end
  end

  defp git!(path, args, input \\ nil, env \\ []) do
    {out, 0} =
      case input do
        nil ->
          System.cmd("git", args, cd: path, stderr_to_stdout: true, env: env)

        _ ->
          file = Path.join(System.tmp_dir!(), "gitin-#{System.unique_integer([:positive])}")
          File.write!(file, input)

          try do
            command = Enum.map_join(["git" | args] ++ ["<", file], " ", &quote_arg/1)
            System.cmd("sh", ["-c", command], cd: path, stderr_to_stdout: true, env: env)
          after
            File.rm(file)
          end
      end

    String.trim(out)
  end

  defp quote_arg("<"), do: "<"
  defp quote_arg(arg), do: "'" <> String.replace(arg, "'", "'\\''") <> "'"

  defp sha!(hex), do: elem(GitGud.Git.from_hex(hex), 1)

  # `main` with `base_files`, and `feature` one commit ahead of it with
  # `feature_files` added. Returns {main_hex, feature_hex}.
  defp branches!(repo, base_files, feature_files, target \\ "main") do
    main = commit!(repo, base_files, [])
    git!(repo.disk_path, ["update-ref", "refs/heads/#{target}", main])
    feature = commit!(repo, Map.merge(base_files, feature_files), [main])
    git!(repo.disk_path, ["update-ref", "refs/heads/feature", feature])
    {main, feature}
  end

  defp open_pr!(repo, user, target \\ "main") do
    {:ok, pr} = PullRequests.create_pull_request(repo, user, pr_attrs("feature", target))
    drain!()
    pr
  end

  defp drain!, do: Oban.drain_queue(queue: :ci)

  defp pr_runs(%Repository{id: rid}) do
    from(r in WorkflowRun,
      where: r.repository_id == ^rid and r.trigger == "pull_request",
      order_by: r.id
    )
    |> Repo.all()
  end

  defp runs(%Repository{id: rid}, trigger) do
    from(r in WorkflowRun, where: r.repository_id == ^rid and r.trigger == ^trigger)
    |> Repo.all()
  end

  # What the post-receive hook does for a push to `feature`.
  # `job_token: true` is a push made with a CI job's token.
  defp push_feature!(repo, user, files, parent_hex, opts \\ []) do
    new = commit!(repo, files, [parent_hex])
    git!(repo.disk_path, ["update-ref", "refs/heads/feature", new])

    :ok =
      perform_job(CommitBackfill, %{
        "repository_id" => repo.id,
        "ref" => "refs/heads/feature",
        "old_sha_hex" => parent_hex,
        "new_sha_hex" => new,
        "pusher_id" => user.id,
        "job_token" => Keyword.get(opts, :job_token, false)
      })

    drain!()
    new
  end

  @on_pr "  pull_request:\n"
  @on_both "  push:\n  pull_request:\n"

  describe "opened" do
    test "runs on the PR head, under refs/pull/N/head, and pins that ref", %{
      repo: repo,
      user: user
    } do
      {_main, feature} = branches!(repo, %{@wf => workflow(@on_pr)}, %{"lib/a" => "a\n"})
      pr = open_pr!(repo, user)

      assert [run] = pr_runs(repo)
      assert run.head_sha == sha!(feature)
      assert run.head_ref == "refs/pull/#{pr.number}/head"
      assert run.pull_request_number == pr.number
      assert run.pr_head_ref == "feature"
      assert run.pr_base_ref == "main"
      assert run.triggered_by_id == user.id

      assert git!(repo.disk_path, ["rev-parse", "refs/pull/#{pr.number}/head"]) == feature
    end

    test "stores the pull_request event payload", %{repo: repo, user: user} do
      {main, feature} = branches!(repo, %{@wf => workflow(@on_pr)}, %{"lib/a" => "a\n"})
      pr = open_pr!(repo, user)

      assert [%WorkflowRun{event_payload: payload}] = pr_runs(repo)
      assert payload["action"] == "opened"
      assert payload["number"] == pr.number
      assert payload["pull_request"]["number"] == pr.number
      assert payload["pull_request"]["title"] == pr.title
      assert payload["pull_request"]["user"]["login"] == user.handle
      assert payload["pull_request"]["head"]["ref"] == "feature"
      assert payload["pull_request"]["head"]["sha"] == feature
      assert payload["pull_request"]["base"]["ref"] == "main"
      assert payload["pull_request"]["base"]["sha"] == main
      assert payload["sender"]["login"] == user.handle
    end

    test "reads the workflow from the PR head, so a PR can add its own CI", %{
      repo: repo,
      user: user
    } do
      branches!(repo, %{"README.md" => "hi\n"}, %{@wf => workflow(@on_pr)})
      open_pr!(repo, user)

      assert [_run] = pr_runs(repo)
    end

    test "branches: is the target branch", %{repo: repo, user: user} do
      on = "  pull_request:\n    branches: [main]\n"
      branches!(repo, %{@wf => workflow(on)}, %{"lib/a" => "a\n"})
      open_pr!(repo, user)
      assert [_run] = pr_runs(repo)

      {_user, other} = repository_fixture(%{owner: user})
      branches!(other, %{@wf => workflow(on)}, %{"lib/a" => "a\n"}, "release")
      open_pr!(other, user, "release")
      assert pr_runs(other) == []
    end

    test "paths: is the PR's diff from its merge-base", %{repo: repo, user: user} do
      on = "  pull_request:\n    paths: [\"lib/**\"]\n"
      branches!(repo, %{@wf => workflow(on)}, %{"docs/a" => "a\n"})
      open_pr!(repo, user)

      assert pr_runs(repo) == []
    end

    test "a push-only workflow doesn't run for a pull request", %{repo: repo, user: user} do
      branches!(repo, %{@wf => workflow("  push:\n")}, %{"lib/a" => "a\n"})
      open_pr!(repo, user)

      assert pr_runs(repo) == []
    end
  end

  describe "synchronize" do
    test "a push to the source branch runs it, as well as the push run", %{
      repo: repo,
      user: user
    } do
      files = %{@wf => workflow(@on_both), "lib/a" => "a\n"}
      {_main, feature} = branches!(repo, %{@wf => workflow(@on_both)}, %{"lib/a" => "a\n"})
      pr = open_pr!(repo, user)

      new = push_feature!(repo, user, Map.put(files, "lib/b", "b\n"), feature)

      assert [opened, synced] = pr_runs(repo)
      assert opened.head_sha == sha!(feature)
      assert synced.head_sha == sha!(new)
      assert synced.head_ref == "refs/pull/#{pr.number}/head"
      assert synced.triggered_by_id == user.id
      assert git!(repo.disk_path, ["rev-parse", "refs/pull/#{pr.number}/head"]) == new

      assert [push_run] = runs(repo, "push")
      assert push_run.head_ref == "refs/heads/feature"
    end

    test "types: [opened] doesn't run on synchronize", %{repo: repo, user: user} do
      on = "  pull_request:\n    types: [opened]\n"
      files = %{@wf => workflow(on), "lib/a" => "a\n"}
      {_main, feature} = branches!(repo, %{@wf => workflow(on)}, %{"lib/a" => "a\n"})
      open_pr!(repo, user)
      assert [_opened] = pr_runs(repo)

      push_feature!(repo, user, Map.put(files, "lib/b", "b\n"), feature)
      assert [_opened] = pr_runs(repo)
    end

    test "a job refreshing a pull request a job opened does start its synchronize", %{
      repo: repo,
      user: user
    } do
      files = %{@wf => workflow(@on_both), "lib/a" => "a\n"}
      {_main, feature} = branches!(repo, %{@wf => workflow(@on_both)}, %{"lib/a" => "a\n"})
      pr = open_pr!(repo, user)

      # As if a job had opened it: the run it came from.
      opener =
        %WorkflowRun{repository_id: repo.id}
        |> WorkflowRun.create_changeset(%{
          number: 999,
          workflow_path: @wf,
          trigger: "schedule",
          head_sha: :crypto.strong_rand_bytes(20)
        })
        |> Repo.insert!()

      Repo.update!(Ecto.Changeset.change(pr, workflow_run_id: opener.id))

      push_feature!(repo, user, Map.put(files, "lib/b", "b\n"), feature, job_token: true)

      assert [_opened, synchronize] = pr_runs(repo)
      assert synchronize.pull_request_number == pr.number
      # Still no push run, and the refreshed PR's run is one hop: it can't
      # open anything itself.
      assert runs(repo, "push") == []
      assert GitGud.Workflows.opened_by_job?(synchronize)
    end

    test "a push made with a job token starts nothing, but still moves the PR", %{
      repo: repo,
      user: user
    } do
      files = %{@wf => workflow(@on_both), "lib/a" => "a\n"}
      {_main, feature} = branches!(repo, %{@wf => workflow(@on_both)}, %{"lib/a" => "a\n"})
      pr = open_pr!(repo, user)

      new =
        push_feature!(repo, user, Map.put(files, "lib/b", "b\n"), feature, job_token: true)

      assert [_opened] = pr_runs(repo)
      assert runs(repo, "push") == []
      assert Repo.get!(PullRequest, pr.id).head_sha == sha!(new)
      assert git!(repo.disk_path, ["rev-parse", "refs/pull/#{pr.number}/head"]) == new
    end
  end

  describe "reopened" do
    test "closing and reopening runs it again", %{repo: repo, user: user} do
      branches!(repo, %{@wf => workflow(@on_pr)}, %{"lib/a" => "a\n"})
      pr = open_pr!(repo, user)

      {:ok, closed} = PullRequests.set_state(pr, "closed")
      drain!()
      assert [_opened] = pr_runs(repo)

      {:ok, _reopened} = PullRequests.set_state(closed, "open")
      drain!()
      assert [_opened, _reopened] = pr_runs(repo)
    end

    test "runs on where the branch is now, not where it was when closed", %{
      repo: repo,
      user: user
    } do
      files = %{@wf => workflow(@on_pr), "lib/a" => "a\n"}
      {_main, feature} = branches!(repo, %{@wf => workflow(@on_pr)}, %{"lib/a" => "a\n"})
      pr = open_pr!(repo, user)
      {:ok, closed} = PullRequests.set_state(pr, "closed")

      # Pushed while closed: the PR row isn't refreshed for closed PRs.
      new = commit!(repo, Map.put(files, "lib/b", "b\n"), [feature])
      git!(repo.disk_path, ["update-ref", "refs/heads/feature", new])

      {:ok, reopened} = PullRequests.set_state(closed, "open")
      assert reopened.head_sha == sha!(new)
      drain!()

      assert [_opened, run] = pr_runs(repo)
      assert run.head_sha == sha!(new)
    end
  end

  describe "pull requests that don't run" do
    # Fork and federated pull requests do run, as fork runs:
    # `fork_pull_request_test.exs`.

    test "a closed pull request starts nothing", %{repo: repo, user: user} do
      branches!(repo, %{@wf => workflow(@on_pr)}, %{"lib/a" => "a\n"})
      {:ok, pr} = PullRequests.create_pull_request(repo, user, pr_attrs("feature", "main"))

      assert {:ok, []} = Workflows.dispatch_pull_request(%{pr | state: "closed"}, "opened")
    end
  end
end