17.7 KiB · text History a0aa462
defmodule GitGud.BadgesTest do
  use GitGud.DataCase, async: false

  import GitGud.ForgeFixtures

  alias GitGud.Badges
  alias GitGud.Git
  alias GitGud.WorkflowGitHelpers
  alias GitGud.Workflows
  alias GitGud.Workflows.WorkflowJob
  alias GitGud.Workflows.WorkflowRun
  alias GitGud.Workflows.Yaml

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

  defp run!(repo, attrs) do
    %WorkflowRun{repository_id: repo.id}
    |> WorkflowRun.create_changeset(
      Map.merge(
        %{
          "number" => System.unique_integer([:positive]),
          "workflow_path" => ".forgejo/workflows/ci.yml",
          "trigger" => "push",
          "head_ref" => "refs/heads/#{repo.default_branch}",
          "head_sha" => :crypto.strong_rand_bytes(20)
        },
        attrs
      )
    )
    |> Ecto.Changeset.change(status: attrs["status"] || "success")
    |> Repo.insert!()
  end

  describe "ci" do
    test "none before any run", %{repo: repo} do
      assert Badges.content(repo, "ci") == %{label: "ci", value: "none", slot: "muted"}
    end

    test "the newest push to the default branch decides", %{repo: repo} do
      run!(repo, %{"status" => "failure"})
      run!(repo, %{"status" => "success"})
      assert %{value: "passing", slot: "success"} = Badges.content(repo, "ci")
    end

    test "scheduled and hand-run runs don't count", %{repo: repo} do
      run!(repo, %{"status" => "success"})
      run!(repo, %{"status" => "failure", "trigger" => "schedule"})
      run!(repo, %{"status" => "failure", "trigger" => "workflow_dispatch"})
      assert %{value: "passing"} = Badges.content(repo, "ci")
    end

    test "pull request runs and other branches don't count", %{repo: repo} do
      run!(repo, %{"status" => "success"})
      run!(repo, %{"status" => "failure", "head_ref" => "refs/heads/feature"})

      run!(repo, %{
        "status" => "failure",
        "head_ref" => "refs/heads/#{repo.default_branch}",
        "pull_request_number" => 7
      })

      assert %{value: "passing"} = Badges.content(repo, "ci")
    end

    test "running is info and queued is warning" do
      assert %{slot: "info"} = Badges.ci("running")
      assert %{slot: "warning"} = Badges.ci("queued")
      assert %{slot: "muted"} = Badges.ci("cancelled")
    end
  end

  describe "last-commit" do
    test "none on an empty repository", %{repo: repo} do
      assert %{value: "none", slot: "muted"} = Badges.content(repo, "last-commit")
    end

    test "today for a fresh commit", %{repo: repo} do
      sha = WorkflowGitHelpers.commit!(repo, %{"README.md" => "hi"}, [])
      {:ok, bin} = Git.from_hex(sha)
      :ok = Git.update_ref(repo.disk_path, "refs/heads/#{repo.default_branch}", bin, nil)

      assert %{value: "today", slot: "success"} = Badges.content(repo, "last-commit")
    end

    test "ages turn yellow at 30 days and red at 180" do
      assert %{value: "1 day", slot: "success"} = Badges.age(1)
      assert %{slot: "warning"} = Badges.age(30)
      assert %{value: "213 days", slot: "error"} = Badges.age(213)
    end
  end

  describe "coverage" do
    test "a job's gitgud-coverage output becomes the badge", %{repo: repo} do
      sha = :crypto.strong_rand_bytes(20)

      {:ok, doc} =
        Yaml.parse("""
        name: ci
        on: push
        jobs:
          test:
            runs-on: ubuntu-latest
            outputs:
              gitgud-coverage: ${{ steps.cov.outputs.percent }}
            steps:
              - id: cov
                run: ./cover
        """)

      seed =
        run!(repo, %{
          "head_sha" => sha,
          "workflow_name" => doc.name,
          "workflow_doc" => doc.raw,
          "status" => "queued"
        })

      {:ok, run} = Workflows.rerun(seed)
      job = Repo.get_by!(WorkflowJob, workflow_run_id: run.id)

      assert %{value: "none"} = Badges.content(repo, "coverage")

      {:ok, job} = Workflows.set_outputs(job, %{"gitgud-coverage" => "67.25%"})
      {:ok, _} = Workflows.update_job_status(job, "success")

      assert %{value: "67.3%", slot: "warning"} = Badges.content(repo, "coverage")

      assert Enum.any?(
               Workflows.list_commit_statuses(repo, sha),
               &(&1.context == "coverage" and &1.description == "67.3%")
             )
    end

    test "a failed job reports no coverage", %{repo: repo} do
      {:ok, doc} =
        Yaml.parse("""
        name: ci
        on: push
        jobs:
          test:
            runs-on: ubuntu-latest
            steps:
              - run: ./cover
        """)

      seed = run!(repo, %{"workflow_doc" => doc.raw, "status" => "queued"})
      {:ok, run} = Workflows.rerun(seed)
      job = Repo.get_by!(WorkflowJob, workflow_run_id: run.id)
      {:ok, job} = Workflows.set_outputs(job, %{"gitgud-coverage" => "99"})
      {:ok, _} = Workflows.update_job_status(job, "failure")

      assert %{value: "none"} = Badges.content(repo, "coverage")
    end

    test "parsing the reported figure" do
      assert Badges.parse_percent("87.3") == 87.3
      assert Badges.parse_percent(" 87.3% ") == 87.3
      assert Badges.parse_percent("100") == 100.0
      assert Badges.parse_percent("101") == nil
      assert Badges.parse_percent("lots") == nil
      assert Badges.parse_percent(nil) == nil
    end

    test "thresholds" do
      assert %{slot: "success"} = Badges.coverage(80.0)
      assert %{slot: "warning"} = Badges.coverage(60.0)
      assert %{slot: "error", value: "41.0%"} = Badges.coverage(41)
    end
  end

  describe "workflow" do
    @audit """
    name: audit
    on:
      schedule:
        - cron: "17 6 * * *"
    jobs:
      osv:
        runs-on: ubuntu-latest
        outputs:
          gitgud-conclusion: ${{ steps.scan.outputs.conclusion }}
          gitgud-summary: ${{ steps.scan.outputs.summary }}
        steps:
          - id: scan
            run: ./scan
      image:
        runs-on: ubuntu-latest
        steps:
          - run: ./image
    """

    defp audit_run!(repo, yaml \\ @audit, attrs \\ %{}) do
      {:ok, doc} = Yaml.parse(yaml)

      seed =
        run!(
          repo,
          Map.merge(
            %{
              "trigger" => "schedule",
              "workflow_path" => ".forgejo/workflows/audit.yml",
              "workflow_name" => doc.name,
              "workflow_doc" => doc.raw,
              "status" => "queued"
            },
            attrs
          )
        )

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

      Map.new(
        Repo.all(from j in WorkflowJob, where: j.workflow_run_id == ^run.id),
        &{&1.job_id, &1}
      )
    end

    defp finish!(job, status, outputs \\ %{}) do
      {:ok, job} = Workflows.set_outputs(job, outputs)
      {:ok, job} = Workflows.update_job_status(job, status)
      job
    end

    test "none before it has run", %{repo: repo} do
      assert Badges.workflow(repo, "audit") == %{label: "audit", value: "none", slot: "muted"}
    end

    test "a warning says what the job found", %{repo: repo} do
      jobs = audit_run!(repo)

      finish!(jobs["osv"], "success", %{
        "gitgud-conclusion" => "warning",
        "gitgud-summary" => "1 no fix"
      })

      finish!(jobs["image"], "success")

      assert Badges.workflow(repo, "audit") == %{
               label: "audit",
               value: "1 no fix",
               slot: "warning"
             }

      assert "audit" in Badges.workflow_names(repo)
    end

    test "the worst job decides, and only its summary shows", %{repo: repo} do
      jobs = audit_run!(repo)
      finish!(jobs["osv"], "failure", %{"gitgud-summary" => "2 to fix, 1 no fix"})

      finish!(jobs["image"], "success", %{
        "gitgud-conclusion" => "warning",
        "gitgud-summary" => "4 image updates"
      })

      assert %{value: "2 to fix, 1 no fix", slot: "error"} = Badges.workflow(repo, "audit")
    end

    test "all passing says passing", %{repo: repo} do
      jobs = audit_run!(repo)
      finish!(jobs["osv"], "success", %{"gitgud-conclusion" => "success"})
      finish!(jobs["image"], "success")

      assert %{value: "passing", slot: "success"} = Badges.workflow(repo, "audit")
    end

    test "a warning without a summary says warning", %{repo: repo} do
      jobs = audit_run!(repo)
      finish!(jobs["osv"], "success", %{"gitgud-conclusion" => "warning"})
      finish!(jobs["image"], "running")

      assert %{value: "warning", slot: "warning"} = Badges.workflow(repo, "audit")
    end

    test "the newest run counts, and pull request runs don't", %{repo: repo} do
      jobs = audit_run!(repo)
      finish!(jobs["osv"], "failure")
      finish!(jobs["image"], "success")

      jobs = audit_run!(repo)
      finish!(jobs["osv"], "success")
      finish!(jobs["image"], "success")

      jobs = audit_run!(repo, @audit, %{"trigger" => "pull_request", "pull_request_number" => 3})
      finish!(jobs["osv"], "failure")

      assert %{value: "passing"} = Badges.workflow(repo, "audit")
    end

    test "a nameless workflow goes by its file", %{repo: repo} do
      jobs =
        audit_run!(repo, String.replace(@audit, "name: audit\n", ""), %{
          "workflow_path" => ".forgejo/workflows/nightly.yml"
        })

      finish!(jobs["osv"], "success")
      finish!(jobs["image"], "success")

      assert %{label: "nightly", value: "passing"} = Badges.workflow(repo, "nightly")
      assert "nightly" in Badges.workflow_names(repo)
    end

    test "the summary also lands on the job's check", %{repo: repo} do
      jobs = audit_run!(repo)

      osv =
        finish!(jobs["osv"], "success", %{
          "gitgud-conclusion" => "warning",
          "gitgud-summary" => "1 no fix"
        })

      run = Repo.get!(WorkflowRun, osv.workflow_run_id)

      assert [status] =
               repo
               |> Workflows.list_commit_statuses(run.head_sha)
               |> Enum.filter(&(&1.context =~ "osv"))

      assert status.state == "warning"
      assert status.description =~ ~r/^Warning.*: 1 no fix$/
    end

    test "summaries are one trimmed line" do
      assert Badges.job_summary(%WorkflowJob{outputs: %{"gitgud-summary" => "  1 no fix\n"}}) ==
               "1 no fix"

      assert Badges.job_summary(%WorkflowJob{outputs: %{"gitgud-summary" => " "}}) == nil
      assert Badges.job_summary(%WorkflowJob{outputs: %{}}) == nil
      long = String.duplicate("x", 80)

      assert String.length(Badges.job_summary(%WorkflowJob{outputs: %{"gitgud-summary" => long}})) ==
               40
    end
  end

  describe "counts" do
    test "open issues and pull requests", %{repo: repo} do
      {user, _} = repository_fixture()
      issue_fixture(repo, user)
      issue_fixture(repo, user)

      assert Badges.content(repo, "issues") == %{label: "issues", value: "2 open", slot: "info"}
      assert %{label: "pulls", value: "0 open"} = Badges.content(repo, "pulls")
    end

    test "commits on the default branch", %{repo: repo} do
      assert %{value: "none", slot: "muted"} = Badges.content(repo, "commits")

      first = WorkflowGitHelpers.commit!(repo, %{"a" => "1"}, [])
      second = WorkflowGitHelpers.commit!(repo, %{"a" => "2"}, [first])
      {:ok, bin} = Git.from_hex(second)
      :ok = Git.update_ref(repo.disk_path, "refs/heads/#{repo.default_branch}", bin, nil)

      assert %{label: "commits", value: "2", slot: "info"} = Badges.content(repo, "commits")
    end

    test "digits are grouped" do
      assert Badges.group_digits(7) == "7"
      assert Badges.group_digits(1234) == "1,234"
      assert Badges.group_digits(1_234_567) == "1,234,567"
    end
  end

  describe "package" do
    test "install with the format's tool, at the latest version", %{repo: repo} do
      {user, _} = repository_fixture()

      for version <- ~w(0.4.0 0.4.1) do
        {:ok, _, _, _} =
          GitGud.Packages.publish_file(repo, "pypi", "Widget", version, %{
            filename: "widget-#{version}.tar.gz",
            body: "sdist #{version}",
            content_type: "application/x-tar",
            uploader: user
          })
      end

      assert Badges.package(repo, "pypi", "widget") ==
               %{label: "install with pip", value: "Widget 0.4.1", slot: "info"}

      assert {"pypi", "Widget"} in Badges.packages(repo)
    end

    test "none for a package that isn't there", %{repo: repo} do
      assert %{label: "install with cargo", value: "none", slot: "muted"} =
               Badges.package(repo, "cargo", "nope")
    end
  end

  describe "more of the repository" do
    defp commit_row(repo, email) do
      %{
        repository_id: repo.id,
        sha: :crypto.strong_rand_bytes(20),
        tree_sha: :crypto.strong_rand_bytes(20),
        parent_shas: [],
        author_name: "t",
        author_email: email,
        authored_at: DateTime.utc_now(:second),
        committer_name: "t",
        committer_email: email,
        committed_at: DateTime.utc_now(:second),
        message: "m"
      }
    end

    test "contributors are distinct authors", %{repo: repo} do
      Repo.insert_all(GitGud.Repositories.GitCommit, [
        commit_row(repo, "a@e"),
        commit_row(repo, "a@e"),
        commit_row(repo, "b@e")
      ])

      assert %{label: "contributors", value: "2"} = Badges.content(repo, "contributors")
    end

    test "the newest tag", %{repo: repo} do
      assert %{value: "none"} = Badges.content(repo, "release")

      sha = WorkflowGitHelpers.commit!(repo, %{"a" => "1"}, [])
      {:ok, bin} = Git.from_hex(sha)

      for tag <- ~w(v1.9.0 v1.10.0 v2.0.0-rc.1 nightly) do
        :ok = Git.update_ref(repo.disk_path, "refs/tags/#{tag}", bin, nil)
      end

      assert %{label: "release", value: "v1.10.0", slot: "info"} = Badges.content(repo, "release")
    end

    test "picking a tag" do
      assert Badges.pick_tag(~w(v1.2.0 v1.10.0 v1.9.9)) == "v1.10.0"
      assert Badges.pick_tag(~w(1.0.0-rc.1 1.0.0-rc.2)) == "1.0.0-rc.2"
      assert Badges.pick_tag(~w(build-9 build-10 build-2)) == "build-10"
      assert Badges.pick_tag([]) == nil
    end

    test "license", %{repo: repo} do
      assert %{value: "none", slot: "muted"} = Badges.content(repo, "license")
      assert %{value: "MIT", slot: "info"} = Badges.content(%{repo | license: "MIT"}, "license")
    end

    test "mirror", %{repo: repo} do
      now = ~U[2026-11-09 12:00:00Z]
      mirror = %{repo | upstream_url: "https://example.com/x.git"}

      assert %{value: "not a mirror", slot: "muted"} = Badges.mirror(repo, now)

      # Imported once and never synced since: developed here, not a mirror.
      refute Badges.mirror?(mirror)
      assert %{value: "not a mirror"} = Badges.mirror(mirror, now)

      # Set to sync, but hasn't yet.
      assert %{value: "never synced", slot: "warning"} =
               Badges.mirror(%{mirror | upstream_auto_sync: true}, now)

      assert %{value: "synced 3h ago", slot: "success"} =
               Badges.mirror(%{mirror | upstream_synced_at: ~U[2026-11-09 09:00:00Z]}, now)

      assert %{value: "synced 9d ago", slot: "warning"} =
               Badges.mirror(%{mirror | upstream_synced_at: ~U[2026-10-31 12:00:00Z]}, now)

      assert %{slot: "error"} =
               Badges.mirror(%{mirror | upstream_synced_at: ~U[2026-09-01 12:00:00Z]}, now)

      assert %{value: "sync failing", slot: "error"} =
               Badges.mirror(
                 %{mirror | upstream_synced_at: now, upstream_sync_error: "boom"},
                 now
               )
    end

    test "the top language and its share", %{repo: repo} do
      assert %{value: "none"} = Badges.content(repo, "language")

      Repo.insert!(%GitGud.Repositories.GitCommitLanguageStats{
        repository_id: repo.id,
        sha: :crypto.strong_rand_bytes(20),
        languages: %{
          "items" => [
            %{"language" => "Elixir", "bytes" => 780, "files" => 3},
            %{"language" => "Rust", "bytes" => 220, "files" => 1}
          ]
        },
        total_bytes: 1000,
        file_count: 4,
        computed_at: DateTime.utc_now(:second)
      })

      assert %{label: "language", value: "Elixir 78%"} = Badges.content(repo, "language")
    end

    test "syms, in the configured order", %{repo: repo} do
      assert %{value: "none"} = Badges.content(repo, "syms")
      [first, second | _] = GitGud.Syms.symbols()

      for _ <- 1..2 do
        {user, _} = repository_fixture()
        {:ok, _} = GitGud.Syms.toggle(user, repo, second)
      end

      {user, _} = repository_fixture()
      {:ok, _} = GitGud.Syms.toggle(user, repo, first)

      assert %{label: "syms", value: value} = Badges.content(repo, "syms")
      assert value == "#{first} 1 #{second} 2"
    end

    test "ci duration skips a push still running", %{repo: repo} do
      assert %{value: "none"} = Badges.content(repo, "ci-duration")

      t0 = ~U[2026-11-09 10:00:00Z]
      event = Ecto.UUID.generate()

      for {start, finish} <- [{0, 120}, {30, 252}] do
        run!(repo, %{"status" => "success"})
        |> Ecto.Changeset.change(
          event_id: event,
          started_at: DateTime.add(t0, start),
          completed_at: DateTime.add(t0, finish)
        )
        |> Repo.update!()
      end

      run!(repo, %{"status" => "running"})
      |> Ecto.Changeset.change(event_id: Ecto.UUID.generate(), started_at: DateTime.add(t0, 600))
      |> Repo.update!()

      assert %{label: "ci duration", value: "4m 12s"} = Badges.content(repo, "ci-duration")
    end

    test "durations" do
      assert Badges.duration(45) == "45s"
      assert Badges.duration(252) == "4m 12s"
      assert Badges.duration(3780) == "1h 3m"
    end
  end

  test "samples are a fixed set" do
    assert {:ok, %{value: "failing"}} = Badges.sample("ci", "failing")
    assert {:ok, :mark} = Badges.sample("mark", "gg")
    assert Badges.sample("ci", "exploded") == :error
    assert Badges.sample("coverage", "12") == :error
    assert {:ok, %{label: "install with podman"}} = Badges.sample("package", "oci")
    assert {:ok, %{value: "1,234"}} = Badges.sample("commits", "1234")
  end
end