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], " ", "e_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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
11.9 KiB · text
History
b868907