7.0 KiB · text History b868907
defmodule GitGud.Workflows.CommitStatusTest do
  @moduledoc """
  One commit status per job, named `<workflow> / <job> (<event>)` as
  Forgejo names them.

  Runs are made by `rerun/1` of a stored document, which creates jobs by
  the same path a dispatch does.
  """
  use GitGud.DataCase, async: false

  import GitGud.ForgeFixtures

  alias GitGud.Workflows
  alias GitGud.Workflows.CommitStatus
  alias GitGud.Workflows.WorkflowJob
  alias GitGud.Workflows.WorkflowRun
  alias GitGud.Workflows.Yaml

  @ci """
  name: ci
  on: [push, pull_request]
  jobs:
    test:
      runs-on: ubuntu-latest
      steps:
        - run: mix test
    lint:
      runs-on: ubuntu-latest
      steps:
        - run: mix format --check-formatted
  """

  setup do
    {_user, repo} = repository_fixture()
    {:ok, repo: repo, sha: :crypto.strong_rand_bytes(20)}
  end

  defp run_workflow(repo, sha, yaml, trigger \\ "push") do
    {:ok, doc} = Yaml.parse(yaml)

    seed =
      %WorkflowRun{repository_id: repo.id}
      |> WorkflowRun.create_changeset(%{
        "number" => System.unique_integer([:positive]),
        "workflow_path" => ".forgejo/workflows/ci.yml",
        "workflow_name" => doc.name,
        "trigger" => trigger,
        "head_ref" => "refs/heads/main",
        "head_sha" => sha,
        "workflow_doc" => doc.raw
      })
      |> Repo.insert!()

    {:ok, run} = Workflows.rerun(seed)
    run
  end

  defp statuses(repo, sha),
    do: repo |> Workflows.list_commit_statuses(sha) |> Map.new(&{&1.context, &1})

  defp job(run, job_id) do
    Repo.one!(from j in WorkflowJob, where: j.workflow_run_id == ^run.id and j.job_id == ^job_id)
  end

  test "each job gets its own pending status, and fails on its own", %{repo: repo, sha: sha} do
    run = run_workflow(repo, sha, @ci)

    assert %{
             "ci / lint (push)" => %CommitStatus{state: "pending", description: "Waiting to run"},
             "ci / test (push)" => %CommitStatus{state: "pending"} = test_status
           } = statuses(repo, sha)

    assert test_status.target_url =~ ~r"/actions/#{run.number}#job-\d+$"

    {:ok, _} = Workflows.update_job_status(job(run, "lint"), "failure")

    assert %{
             "ci / lint (push)" => %CommitStatus{state: "failure", description: "Failing" <> _},
             "ci / test (push)" => %CommitStatus{state: "pending"}
           } = statuses(repo, sha)

    {:ok, _} = Workflows.update_job_status(job(run, "test"), "success")

    assert %{"ci / test (push)" => %CommitStatus{state: "success"}} = statuses(repo, sha)
  end

  test "a push run and a pull_request run on one head don't overwrite each other",
       %{repo: repo, sha: sha} do
    push = run_workflow(repo, sha, @ci, "push")
    pr = run_workflow(repo, sha, @ci, "pull_request")

    assert statuses(repo, sha) |> Map.keys() |> Enum.sort() == [
             "ci / lint (pull_request)",
             "ci / lint (push)",
             "ci / test (pull_request)",
             "ci / test (push)"
           ]

    {:ok, _} = Workflows.update_job_status(job(push, "test"), "success")
    {:ok, _} = Workflows.update_job_status(job(pr, "test"), "failure")

    assert %{
             "ci / test (push)" => %CommitStatus{state: "success"},
             "ci / test (pull_request)" => %CommitStatus{state: "failure"}
           } = statuses(repo, sha)
  end

  test "cancelling a run fails each cancelled job's status", %{repo: repo, sha: sha} do
    run = run_workflow(repo, sha, @ci)
    {:ok, _} = Workflows.update_job_status(job(run, "lint"), "success")

    {:ok, _} = Workflows.cancel(Repo.get!(WorkflowRun, run.id))

    assert %{
             "ci / test (push)" => %CommitStatus{state: "failure", description: "Cancelled"},
             "ci / lint (push)" => %CommitStatus{state: "success"}
           } = statuses(repo, sha)
  end

  test "a run superseded in its concurrency group shows as cancelled, not failed",
       %{repo: repo} do
    yaml = """
    name: deploy
    on: push
    concurrency: {group: deploy, cancel-in-progress: true}
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - run: ./deploy
    """

    old_sha = :crypto.strong_rand_bytes(20)
    _old = run_workflow(repo, old_sha, yaml)
    _new = run_workflow(repo, :crypto.strong_rand_bytes(20), yaml)

    assert %{
             "deploy / deploy (push)" => %CommitStatus{
               state: "cancelled",
               description: "Cancelled by run #" <> _
             }
           } = statuses(repo, old_sha)
  end

  test "a skipped job passes, saying so", %{repo: repo, sha: sha} do
    run =
      run_workflow(repo, sha, """
      name: ci
      on: push
      jobs:
        build:
          runs-on: ubuntu-latest
          steps:
            - run: make
        deploy:
          needs: build
          runs-on: ubuntu-latest
          steps:
            - run: ./deploy
      """)

    assert %{
             "ci / deploy (push)" => %CommitStatus{
               state: "pending",
               description: "Waiting for build"
             }
           } =
             statuses(repo, sha)

    {:ok, _} = Workflows.update_job_status(job(run, "build"), "failure")

    assert %{"ci / deploy (push)" => %CommitStatus{state: "success", description: "Skipped"}} =
             statuses(repo, sha)
  end

  test "a warning job reports warning, for either reason", %{repo: repo, sha: sha} do
    run =
      run_workflow(repo, sha, """
      name: ci
      on: push
      jobs:
        experimental:
          continue-on-error: true
          runs-on: ubuntu-latest
          steps:
            - run: false
        audit:
          runs-on: ubuntu-latest
          outputs:
            gitgud-conclusion: ${{ steps.scan.outputs.conclusion }}
          steps:
            - id: scan
              run: ./scan
      """)

    {:ok, _} = Workflows.update_job_status(job(run, "experimental"), "failure")
    {:ok, audit} = Workflows.set_outputs(job(run, "audit"), %{"gitgud-conclusion" => "warning"})
    {:ok, _} = Workflows.update_job_status(audit, "success")

    assert %{
             "ci / experimental (push)" => %CommitStatus{
               state: "warning",
               description: "Failing" <> experimental
             },
             "ci / audit (push)" => %CommitStatus{state: "warning", description: "Warning" <> _}
           } = statuses(repo, sha)

    assert experimental =~ "continue-on-error"
    assert Repo.get!(WorkflowRun, run.id).status == "success"
  end

  test "a re-run drops the old per-workflow status", %{repo: repo, sha: sha} do
    Repo.insert!(%CommitStatus{
      repository_id: repo.id,
      sha: sha,
      context: "gitgud-actions/ci",
      state: "failure"
    })

    _run = run_workflow(repo, sha, @ci)

    refute Map.has_key?(statuses(repo, sha), "gitgud-actions/ci")
  end

  test "every status write wakes the CI topic", %{repo: repo, sha: sha} do
    run = run_workflow(repo, sha, @ci)
    Phoenix.PubSub.subscribe(GitGud.PubSub, Workflows.ci_topic())
    run_id = run.id

    {:ok, _} = Workflows.update_job_status(job(run, "lint"), "failure")

    assert_receive {:ci_run_changed, ^run_id}
    assert Workflows.run_repository_id(run_id) == repo.id
  end
end