From c55650abc9fc3b750db9fee0de7a0392955acf38 Mon Sep 17 00:00:00 2001 From: Brian Berlin Date: Tue, 31 Oct 2023 15:45:36 -0500 Subject: [PATCH] Fixes issues with annotations and adds tests (#1) --- .github/workflows/push.yml | 101 +++++++++ action.yml | 18 +- lib/coverage_reporter.ex | 355 ++++++++++++++++++-------------- mix.exs | 8 +- mix.lock | 22 +- test/coverage_reporter_test.exs | 334 ++++++++++++++++++++++++++++++ 6 files changed, 675 insertions(+), 163 deletions(-) create mode 100644 .github/workflows/push.yml create mode 100644 test/coverage_reporter_test.exs diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..06e9303 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,101 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +env: + OTP_VERSION: 26.1.1 + ELIXIR_VERSION: 1.15.6-otp-26 + +jobs: + test: + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: ${{ env.OTP_VERSION }} + elixir-version: ${{ env.ELIXIR_VERSION }} + + - name: Build Cache + uses: actions/cache/restore@v3 + id: build-cache + with: + path: _build + key: build-${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-${{ hashFiles('mix.lock') }} + + - name: Deps Cache + uses: actions/cache/restore@v3 + id: deps-cache + with: + path: deps + key: deps-${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-${{ hashFiles('mix.lock') }} + + - name: Install Mix Dependencies + if: steps.deps-cache.outputs.cache-hit != 'true' + run: mix deps.get + + - name: Compile + if: steps.build-cache.outputs.cache-hit != 'true' + run: mix compile + + - name: Check Formatting + run: mix format --check-formatted + + - name: Run Credo + run: mix credo --strict + + - name: Run Tests + run: mix lcov + + - name: Coverage Reporter + uses: peek-travel/coverage-reporter@cygnus + id: coverage-reporter + if: github.event_name == 'pull_request' + continue-on-error: true + with: + lcov_path: cover/lcov.info + coverage_threshold: 90 + + - name: Restore PLT cache + uses: actions/cache@v2 + id: plt-cache + with: + key: plt-${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }} + path: priv/plts + + - name: Create PLTs + if: steps.plt-cache.outputs.cache-hit != 'true' + run: MIX_ENV=dev mix dialyzer --plt + + - name: Run dialyzer + run: MIX_ENV=dev mix dialyzer --format github + + - name: Save Deps Cache + id: deps-cache-save + uses: actions/cache/save@v3 + with: + path: deps + key: deps-${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-${{ hashFiles('mix.lock') }} + + - name: Save Build Cache + id: build-cache-save + uses: actions/cache/save@v3 + with: + path: _build + key: build-${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-${{ hashFiles('mix.lock') }} + + - name: Save PLT cache + id: plt-cache-save + uses: actions/cache/save@v3 + with: + path: priv/plts + key: plt-${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }} diff --git a/action.yml b/action.yml index cacc728..b1b29f1 100644 --- a/action.yml +++ b/action.yml @@ -1,19 +1,23 @@ -name: 'Coverage Reporter' -description: 'Coverage Reporter' +name: "Coverage Reporter" +description: "Coverage Reporter" inputs: GITHUB_TOKEN: - description: 'Github token of the repository (automatically created by Github)' + description: "Github token of the repository (automatically created by Github)" default: ${{ github.token }} required: false coverage_threshold: required: true - description: 'Coverage Threshold' - default: 80 + description: "Coverage Threshold" + default: "80" lcov_path: required: true description: LCOV file path. The path may contain wildcards. [Reference](https://hexdocs.pm/elixir/Path.html#wildcard/2) + lcov_path_prefix: + required: false + description: If the path returned on the review summary includes the absolute path, you can use this option to remove the prefix. + default: "" runs: - using: 'docker' - image: 'Dockerfile' + using: "docker" + image: "Dockerfile" diff --git a/lib/coverage_reporter.ex b/lib/coverage_reporter.ex index aeaeb85..12f7f67 100644 --- a/lib/coverage_reporter.ex +++ b/lib/coverage_reporter.ex @@ -20,41 +20,43 @@ defmodule CoverageReporter do However, The LCOV files produced by excoveralls only include SF, DA, LF, LH, and end_of_record lines. """ - def run do - changed_files = get_changed_files() - {total, module_results} = get_coverage_from_lcov_files() - changed_module_results = module_results_for_changed_files(module_results, changed_files) - - title = "Code Coverage for ##{pull_number()}" - badge = create_total_coverage_badge(total) - coverage_by_file = get_coverage_by_file(changed_module_results) - annotations = create_annotations(changed_module_results, changed_files) + def run(opts \\ []) do + config = get_config(opts) + %{pull_number: pull_number, head_branch: head_branch, repository: repository} = config + changed_files = get_changed_files(config) + {total, module_results} = get_coverage_from_lcov_files(config) + changed_module_results = module_results_for_changed_files(module_results, changed_files) + title = "Code Coverage for ##{pull_number}" + badge = create_total_coverage_badge(config, total) + coverage_by_file = get_coverage_by_file(config, changed_module_results) + annotations = create_annotations(config, changed_module_results, changed_files) summary = Enum.join(["#### #{title}", badge, coverage_by_file], "\n\n") - params = %{ - name: "Code Coverage", - head_sha: head_branch(), - status: "completed", - conclusion: get_conclusion(total), - output: %{ - title: title, - # Maximum length for summary and text is 65535 characters. - summary: String.slice(Enum.join([badge, coverage_by_file], "\n\n"), 0, 65_535), - # 50 is the max number of annotations allowed by GitHub. - annotations: Enum.take(annotations, 50) + params = + %{ + name: "Code Coverage", + head_sha: head_branch, + status: "completed", + conclusion: get_conclusion(config, total), + output: %{ + title: title, + # Maximum length for summary and text is 65535 characters. + summary: String.slice(Enum.join([badge, coverage_by_file], "\n\n"), 0, 65_535), + # 50 is the max number of annotations allowed by GitHub. + annotations: Enum.take(annotations, 50) + } } - } - github_request(:post, "#{repository()}/check-runs", params) - create_or_update_review_comment(summary) + github_request(config, method: :post, url: "repos/#{repository}/check-runs", json: params) + create_or_update_review_comment(config, summary) - :ok + {:ok, params} end - defp create_total_coverage_badge(total) do + defp create_total_coverage_badge(config, total) do params = "logo=elixir&logoColor=purple&labelColor=white" - url = "Total%20Coverage-#{format_percentage(total)}%25-#{get_conclusion_color(total)}" + url = "Total%20Coverage-#{format_percentage(total)}%25-#{get_conclusion_color(config, total)}" "![Total Coverage](https://img.shields.io/badge/#{url}?#{params})" end @@ -63,14 +65,14 @@ defmodule CoverageReporter do Enum.filter(module_results, fn {_, module_path, _} -> Enum.any?(changed_file_names, fn changed_file_name -> - String.ends_with?(changed_file_name, module_path) + String.ends_with?(module_path, changed_file_name) end) end) end - defp get_coverage_by_file([]), do: nil + defp get_coverage_by_file(_config, []), do: nil - defp get_coverage_by_file(module_results) do + defp get_coverage_by_file(config, module_results) do padding = module_results |> Enum.max_by(fn {_, name, _} -> String.length(name) end) @@ -82,14 +84,15 @@ defmodule CoverageReporter do "**Coverage by file**\n", "| Percentage | #{format_name("Module", padding)} |", "| ---------- | #{String.duplicate("-", padding)} |", - "#{create_module_results(module_results, padding)}" + "#{create_module_results(config, module_results, padding)}" ], "\n" ) end - defp get_coverage_from_lcov_files do - lcov_paths = Path.wildcard("#{github_workspace()}/#{lcov_path()}") + defp get_coverage_from_lcov_files(config) do + %{github_workspace: github_workspace, lcov_path: lcov_path} = config + lcov_paths = Path.wildcard("#{github_workspace}/#{lcov_path}") table = :ets.new(__MODULE__, [:set, :private]) module_results = @@ -121,13 +124,7 @@ defmodule CoverageReporter do |> String.split(",") |> Enum.map(&String.to_integer(&1)) - line_number = line_number - 1 - - if count == 0 do - :ets.insert(table, {{path, line_number}, false}) - else - Enum.each(1..count, fn _ -> :ets.insert(table, {{path, line_number}, true}) end) - end + insert_line_coverage(table, count, path, line_number) {line_number, count} end) @@ -137,9 +134,19 @@ defmodule CoverageReporter do {percentage(covered, not_covered), path, coverage_by_line} end - defp get_changed_files do + defp insert_line_coverage(table, count, path, line_number) do + if count == 0 do + :ets.insert(table, {{path, line_number}, false}) + else + Enum.each(1..count, fn _ -> :ets.insert(table, {{path, line_number}, true}) end) + end + end + + defp get_changed_files(config) do + %{repository: repository, pull_number: pull_number} = config + {:ok, 200, files} = - github_request_all("#{repository()}/pulls/#{pull_number()}/files") + github_request_all(config, "repos/#{repository}/pulls/#{pull_number}/files") files |> Enum.reject(&String.equivalent?(&1["status"], "removed")) @@ -148,11 +155,19 @@ defmodule CoverageReporter do end) end - defp create_module_results([], padding), - do: "| No Changes | #{String.duplicate(" ", padding)} |" + defp create_module_results(config, module_results, padding) do + module_results + |> Enum.map(fn {percentage, name, coverage} -> + name = + if is_nil(config.lcov_path_prefix) or config.lcov_path_prefix == "" do + name + else + String.replace_leading(name, config.lcov_path_prefix, "") + end - defp create_module_results(module_results, padding) do - Enum.map_join(module_results, "\n", &display(elem(&1, 0), elem(&1, 1), padding)) + {percentage, name, coverage} + end) + |> Enum.map_join("\n", &display(elem(&1, 0), elem(&1, 1), padding)) end defp percentage(0, 0), do: 100.0 @@ -175,60 +190,69 @@ defmodule CoverageReporter do String.pad_trailing(name, padding, " ") end - defp get_conclusion(total) do - if total >= coverage_threshold() do + defp get_conclusion(config, total) do + if total >= config.coverage_threshold do "success" else "neutral" end end - defp get_conclusion_color(total) do - if total >= coverage_threshold() do + defp get_conclusion_color(config, total) do + if total >= config.coverage_threshold do "green" else "yellow" end end - defp create_or_update_review_comment(summary) do + defp create_or_update_review_comment(config, summary) do + %{repository: repository, pull_number: pull_number} = config + {:ok, 200, reviews} = - github_request_all("#{repository()}/pulls/#{pull_number()}/reviews") + github_request_all(config, "repos/#{repository}/pulls/#{pull_number}/reviews") - review = Enum.find(reviews, &(&1["body"] =~ "Code Coverage for ##{pull_number()}")) + review = Enum.find(reviews, &(&1["body"] =~ "Code Coverage for ##{pull_number}")) if is_nil(review) do - github_request(:post, "#{repository()}/pulls/#{pull_number()}/reviews", %{ - body: summary, - event: "COMMENT" - }) + github_request(config, + method: :post, + url: "repos/#{repository}/pulls/#{pull_number}/reviews", + json: %{body: summary, event: "COMMENT"} + ) else github_request( - :put, - "#{repository()}/pulls/#{pull_number()}/reviews/#{review["id"]}", - %{ - body: summary - } + config, + method: :put, + url: "repos/#{repository}/pulls/#{pull_number}/reviews/#{review["id"]}", + json: %{body: summary} ) end end - defp create_annotations(changed_module_results, changed_files) do + defp create_annotations(config, changed_module_results, changed_files) do + %{github_workspace: github_workspace} = config + Enum.flat_map(changed_module_results, fn {_percentage, module_path, coverage_by_line} -> %{file: file, changed_lines: changed_lines} = - Enum.find(changed_files, fn %{file: file} -> String.ends_with?(file, module_path) end) + Enum.find(changed_files, fn %{file: file} -> String.ends_with?(module_path, file) end) source_code_lines = - "#{github_workspace()}/#{file}" + github_workspace + |> Path.join(file) |> File.read!() |> String.split("\n") - |> Enum.with_index(0) + |> Enum.with_index(1) |> Enum.map(fn {line, index} -> [nil, line, index] end) source_code = coverage_by_line |> Enum.reduce(source_code_lines, fn {line_number, count}, source_code_lines -> - List.update_at(source_code_lines, line_number, &[count, Enum.at(&1, 1), Enum.at(&1, 2)]) + List.update_at( + source_code_lines, + line_number - 1, + &[count, Enum.at(&1, 1), Enum.at(&1, 2)] + ) end) |> Enum.map(&add_source_code_line/1) @@ -236,32 +260,39 @@ defmodule CoverageReporter do |> Enum.filter(fn {_line_number, count} -> count == 0 end) |> Enum.map(fn {line_number, _} -> line_number end) |> Enum.reduce(_groups = [], &add_line_to_groups/2) - |> Enum.reduce(_annotations = [], fn line_number_group, annotations -> - end_line = List.first(line_number_group) - start_line = List.last(line_number_group) - add_annotation? = Enum.any?(changed_lines, &(&1 >= start_line and &1 <= end_line)) - - if add_annotation? do - annotation = - Map.merge( - %{ - title: "Code Coverage", - start_line: start_line, - end_line: end_line, - annotation_level: "warning", - path: file - }, - create_annotation_message(start_line, end_line, source_code) - ) - - [annotation] ++ annotations - else - annotations - end - end) + |> Enum.reduce( + _annotations = [], + &create_annotations(&1, &2, changed_lines, file, source_code) + ) end) end + defp create_annotations(line_number_group, annotations, changed_lines, file, source_code) do + end_line = List.first(line_number_group) + start_line = List.last(line_number_group) + + add_annotation? = + Enum.any?(changed_lines, &(&1 >= start_line and &1 <= end_line)) + + if add_annotation? do + annotation = + Map.merge( + %{ + title: "Code Coverage", + start_line: start_line, + end_line: end_line, + annotation_level: "warning", + path: file + }, + create_annotation_message(start_line, end_line, source_code) + ) + + [annotation] ++ annotations + else + annotations + end + end + defp add_source_code_line([nil, line, line_number]) do "#{String.duplicate(" ", 5)} #{String.pad_trailing("#{line_number}", 3)} #{line}" end @@ -273,7 +304,7 @@ defmodule CoverageReporter do defp create_annotation_message(start_line, end_line, source_code) do source_code = source_code - |> Enum.slice((start_line - 5)..(end_line + 5)) + |> Enum.slice((start_line - 1)..(end_line - 1)) |> Enum.join("\n") %{ @@ -283,19 +314,22 @@ defmodule CoverageReporter do end defp add_line_to_groups(line_number, groups) do - cond do - Enum.empty?(groups) -> - [[line_number]] - - [current_group | remaining_groups] = groups -> - previous_line_number = List.first(current_group) + group = + cond do + Enum.empty?(groups) -> + [[line_number]] + + [current_group | remaining_groups] = groups -> + previous_line_number = List.first(current_group) + + if line_number - previous_line_number < 4 do + [[line_number] ++ current_group] ++ remaining_groups + else + [[line_number]] ++ groups + end + end - if line_number - previous_line_number < 4 do - [[line_number] ++ current_group] ++ remaining_groups - else - [[line_number]] ++ groups - end - end + Enum.sort(group) end defp extract_changed_lines(nil), do: [] @@ -309,26 +343,41 @@ defmodule CoverageReporter do {String.to_integer(start_line), changes} _ -> - case line do - "+" <> _ -> {current_line + 1, [current_line | changes]} - "-" <> _ -> {current_line, changes} - " " <> _ -> {current_line + 1, changes} - _ -> {nil, changes} - end + add_changed_line(line, current_line, changes) end end) |> elem(1) |> Enum.reverse() end - defp github_request_all(path, params \\ %{page: 1}, accumulator \\ []) do - case github_request(:get, path, params) do + defp add_changed_line(_line, nil, changes) do + {nil, changes} + end + + defp add_changed_line(line, current_line, changes) do + case line do + "+" <> _ -> + {current_line + 1, [current_line | changes]} + + "-" <> _ -> + {current_line, changes} + + " " <> _ -> + {current_line + 1, changes} + + _ -> + {nil, changes} + end + end + + defp github_request_all(config, path, params \\ %{page: 1}, accumulator \\ []) do + case github_request(config, method: :get, url: path, params: params) do {:ok, 200, []} -> {:ok, 200, accumulator} {:ok, 200, results} -> params = Map.put(params, :page, params[:page] + 1) - github_request_all(path, params, results ++ accumulator) + github_request_all(config, path, params, results ++ accumulator) {:ok, status_code, result} -> {:ok, status_code, result} @@ -338,58 +387,62 @@ defmodule CoverageReporter do end end - defp github_request(method, path, params) do - :inets.start() - :ssl.start() - - url = ~c"#{github_api_url()}/repos/#{path}" + defp github_request(config, opts) do + %{ + github_api_url: github_api_url, + github_token: github_token + } = config headers = [ - {~c"Authorization", ~c"Bearer #{github_token()}"}, - {~c"Accept", ~c"application/vnd.github+json"}, - {~c"X-GitHub-Api-Version", ~c"2022-11-28"}, - {~c"User-Agent", ~c"CoverageReporter"} + {:authorization, "Bearer #{github_token}"}, + {:accept, "application/vnd.github+json"}, + {:x_github_api_version, "2022-11-28"}, + {:user_agent, "CoverageReporter"} ] - request = - case method do - :get -> - {~c"#{url}?#{URI.encode_query(params)}", headers} - - :post -> - {url, headers, ~c"application/json", Jason.encode!(params)} - - :put -> - {url, headers, ~c"application/json", Jason.encode!(params)} - end - - ssl = [ - verify: :verify_peer, - cacerts: :public_key.cacerts_get(), - customize_hostname_check: [ - match_fun: :public_key.pkix_verify_hostname_match_fun(:https) - ] - ] + options = Keyword.merge(opts, base_url: github_api_url, headers: headers) + request = Req.new(options) - case :httpc.request(method, request, [ssl: ssl], []) do - {:ok, {{_http_version, status_code, _response_string}, _headers, body}} -> - {:ok, status_code, Jason.decode!(body)} + case Req.request(request) do + {_request, %{status: status, body: body}} -> + {:ok, status, body} {:error, reason} -> {:error, reason} end end - defp coverage_threshold, do: "INPUT_COVERAGE_THRESHOLD" |> System.get_env("80") |> String.to_integer() - defp lcov_path, do: System.get_env("INPUT_LCOV_PATH") - defp head_branch, do: System.get_env("GITHUB_HEAD_REF") - defp repository, do: System.get_env("GITHUB_REPOSITORY") - defp github_workspace, do: System.get_env("GITHUB_WORKSPACE") - defp github_token, do: System.get_env("INPUT_GITHUB_TOKEN") - defp github_api_url, do: System.get_env("GITHUB_API_URL") + defp get_config(opts) do + %{ + coverage_threshold: coverage_threshold(opts), + lcov_path: lcov_path(opts), + head_branch: head_branch(opts), + repository: repository(opts), + github_workspace: github_workspace(opts), + github_token: github_token(opts), + github_api_url: github_api_url(opts), + pull_number: pull_number(opts), + lcov_path_prefix: lcov_path_prefix(opts) + } + end + + defp coverage_threshold(opts) do + value = opts[:coverage_threshold] || System.get_env("INPUT_COVERAGE_THRESHOLD", "80") + String.to_integer(value) + end + + defp lcov_path(opts), do: opts[:input_lcov_path] || System.get_env("INPUT_LCOV_PATH") + defp head_branch(opts), do: opts[:github_head_ref] || System.get_env("GITHUB_HEAD_REF") + defp repository(opts), do: opts[:github_repository] || System.get_env("GITHUB_REPOSITORY") + defp github_workspace(opts), do: opts[:github_workspace] || System.get_env("GITHUB_WORKSPACE") + defp github_token(opts), do: opts[:input_github_token] || System.get_env("INPUT_GITHUB_TOKEN") + defp github_api_url(opts), do: opts[:github_api_url] || System.get_env("GITHUB_API_URL") + + defp lcov_path_prefix(opts), + do: opts[:lcov_path_prefix] || System.get_env("INPUT_LCOV_PATH_PREFIX") - defp pull_number do - github_ref = System.get_env("GITHUB_REF") |> String.split("/") + defp pull_number(opts) do + github_ref = (opts[:github_ref] || System.get_env("GITHUB_REF")) |> String.split("/") case github_ref do ["refs", "pull", pr_number, "merge"] -> pr_number diff --git a/mix.exs b/mix.exs index b66abd8..750c934 100644 --- a/mix.exs +++ b/mix.exs @@ -21,7 +21,13 @@ defmodule CoverageReporter.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:jason, "~> 1.2"} + {:briefly, "~> 0.4.0", only: :test}, + {:bypass, "~> 2.1", only: :test}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:jason, "~> 1.2"}, + {:lcov_ex, "~> 0.3", only: [:dev, :test], runtime: false}, + {:req, "~> 0.4.4"} ] end end diff --git a/mix.lock b/mix.lock index 8f43b4f..35e7eda 100644 --- a/mix.lock +++ b/mix.lock @@ -1,22 +1,36 @@ %{ - "castore": {:hex, :castore, "1.0.2", "0c6292ecf3e3f20b7c88408f00096337c4bfd99bd46cc2fe63413ddbe45b3573", [:mix], [], "hexpm", "40b2dd2836199203df8500e4a270f10fc006cc95adc8a319e148dc3077391d96"}, + "briefly": {:hex, :briefly, "0.4.1", "c90c0511e64bde1fe8da7e244e14acf5bc78c3f6d033db778205e1fa2feafa5c", [:mix], [], "hexpm", "fc0cafcd19c4ed0d0906ae5cf627cc6ce76b8652a160c6bde0ab9d77304ebb0a"}, + "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, + "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, + "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, + "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, + "dialyxir": {:hex, :dialyxir, "1.4.2", "764a6e8e7a354f0ba95d58418178d486065ead1f69ad89782817c296d0d746a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "516603d8067b2fd585319e4b13d3674ad4f314a5902ba8130cd97dc902ce6bbd"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "excoveralls": {:hex, :excoveralls, "0.16.1", "0bd42ed05c7d2f4d180331a20113ec537be509da31fed5c8f7047ce59ee5a7c5", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dae763468e2008cf7075a64cb1249c97cb4bc71e236c5c2b5e5cdf1cfa2bf138"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"}, "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "lcov_ex": {:hex, :lcov_ex, "0.3.3", "1745a88e46606c4f86408299f54878b7d0cd22ea3e9c54b0018b6ed631a9ce87", [:mix], [], "hexpm", "ea373ec4d2df213357c5a464be16ab08d1e58e61ea2de784a483780c22a1e74a"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"}, "mix_test_watch": {:hex, :mix_test_watch, "1.1.0", "330bb91c8ed271fe408c42d07e0773340a7938d8a0d281d57a14243eae9dc8c3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "52b6b1c476cbb70fd899ca5394506482f12e5f6b0d6acff9df95c7f1e0812ec3"}, "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "req": {:hex, :req, "0.3.6", "541350d2cc359a8ad17059f2629c18be56d0c85ce0e4ddb27694b6ba482fe923", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "9181047f32b05f8737f6b5917af5ee5385219158bbe4e507f4ec57791a0a78c3"}, + "plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, + "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "req": {:hex, :req, "0.4.4", "a17b6bec956c9af4f08b5d8e8a6fc6e4edf24ccc0ac7bf363a90bba7a0f0138c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2618c0493444fee927d12073afb42e9154e766b3f4448e1011f0d3d551d1a011"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, diff --git a/test/coverage_reporter_test.exs b/test/coverage_reporter_test.exs new file mode 100644 index 0000000..4ac8260 --- /dev/null +++ b/test/coverage_reporter_test.exs @@ -0,0 +1,334 @@ +defmodule CoverageReporterTest do + use ExUnit.Case, async: true + + setup do + bypass = Bypass.open() + {:ok, workspace} = Briefly.create(directory: true) + + config = [ + coverage_threshold: "80", + input_lcov_path: "lcov.info", + github_ref: "refs/pull/1/merge", + input_github_token: "github-token", + github_workspace: workspace, + github_api_url: "http://localhost:#{bypass.port()}", + github_repository: "owner/repo", + github_head_ref: "feature-branch" + ] + + %{bypass: bypass, config: config} + end + + test "with a single uncovered line", ctx do + %{bypass: bypass, config: config} = ctx + + setup_changes( + bypass, + config, + path: "path/to/file", + status: "added", + changed_lines: [1, 2, 3, 4, 5, 6, 7, 8], + patch: "@@ -0,0 +1,8 @@\n+one\n+two\n+three\n+four\n+five\n+six\n+seven\n+eight\n", + lcov: + "TN:\nSF:path/to/file\nDA:1,1\nDA:2,1\nDA:3,1\nDA:4,0\nDA:5,1\nDA:6,1\nDA:7,1\nDA:8,1\nend_of_record", + source_code: "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight" + ) + + assert {:ok, + %{ + conclusion: "success", + output: %{ + summary: summary, + annotations: [ + %{ + start_line: 4, + end_line: 4, + raw_details: "0.... 4 four" + } + ] + } + }} = CoverageReporter.run(config) + + assert summary =~ "87.5%" + end + + test "with a two uncovered lines", ctx do + %{bypass: bypass, config: config} = ctx + + setup_changes( + bypass, + config, + path: "path/to/file", + status: "added", + changed_lines: [1, 2, 3, 4, 5, 6, 7, 8], + patch: "@@ -0,0 +1,8 @@\n+one\n+two\n+three\n+four\n+five\n+six\n+seven\n+eight\n", + lcov: + "TN:\nSF:path/to/file\nDA:1,1\nDA:2,1\nDA:3,1\nDA:4,0\nDA:5,0\nDA:6,1\nDA:7,1\nDA:8,1\nend_of_record", + source_code: "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight" + ) + + assert {:ok, + %{ + conclusion: "neutral", + output: %{ + summary: summary, + annotations: [ + %{ + start_line: 4, + end_line: 5, + raw_details: "0.... 4 four\n0.... 5 five" + } + ] + } + }} = + CoverageReporter.run(config) + + assert summary =~ "75.0%" + end + + test "with disjointed uncovered lines", ctx do + %{bypass: bypass, config: config} = ctx + + setup_changes( + bypass, + config, + path: "path/to/file", + status: "added", + changed_lines: [1, 2, 3, 4, 5, 6, 7, 8], + patch: "@@ -0,0 +1,8 @@\n+one\n+two\n+three\n+four\n+five\n+six\n+seven\n+eight\n", + lcov: + "TN:\nSF:path/to/file\nDA:1,1\nDA:2,1\nDA:3,1\nDA:4,0\nDA:5,1\nDA:6,0\nDA:7,1\nDA:8,1\nend_of_record", + source_code: "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight" + ) + + assert {:ok, + %{ + conclusion: "neutral", + output: %{ + summary: summary, + annotations: [ + %{ + start_line: 4, + end_line: 6, + raw_details: "0.... 4 four\n1.... 5 five\n0.... 6 six" + } + ] + } + }} = CoverageReporter.run(config) + + assert summary =~ "75.0%" + end + + test "with disjointed uncovered lines creating multiple annotations", ctx do + %{bypass: bypass, config: config} = ctx + + setup_changes( + bypass, + config, + path: "path/to/file", + status: "added", + changed_lines: [1, 2, 3, 4, 5, 6, 7, 8], + patch: "@@ -0,0 +1,8 @@\n+one\n+two\n+three\n+four\n+five\n+six\n+seven\n+eight\n", + lcov: + "TN:\nSF:path/to/file\nDA:1,0\nDA:2,1\nDA:3,1\nDA:4,1\nDA:5,1\nDA:6,1\nDA:7,1\nDA:8,0\nend_of_record", + source_code: "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight" + ) + + assert {:ok, + %{ + conclusion: "neutral", + output: %{ + summary: summary, + annotations: [ + %{ + start_line: 8, + end_line: 8, + raw_details: "0.... 8 eight" + }, + %{ + start_line: 1, + end_line: 1, + raw_details: "0.... 1 one" + } + ] + } + }} = CoverageReporter.run(config) + + assert summary =~ "75.0%" + end + + test "without changed files", ctx do + %{bypass: bypass, config: config} = ctx + + setup_changes( + bypass, + config, + path: "path/to/file", + status: "added", + changed_lines: [], + patch: "", + lcov: "", + source_code: "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight" + ) + + assert {:ok, + %{ + conclusion: "success", + output: %{ + annotations: [] + } + }} = CoverageReporter.run(config) + end + + test "without annotations", ctx do + %{bypass: bypass, config: config} = ctx + + setup_changes( + bypass, + config, + path: "path/to/file", + status: "added", + changed_lines: [1], + patch: "@@ -0,0 +1,1 @@\n+one", + lcov: "TN:\nSF:path/to/file\nDA:1,1\nend_of_record", + source_code: "one", + new_pull_request?: true + ) + + assert {:ok, + %{ + conclusion: "success", + output: %{ + summary: summary, + annotations: [] + } + }} = CoverageReporter.run(config) + + assert summary =~ "100.0%" + end + + test "with deletions and empty lines", ctx do + %{bypass: bypass, config: config} = ctx + + setup_changes( + bypass, + config, + path: "path/to/file", + status: "added", + changed_lines: [1], + patch: "@@ -1,1 +1,1 @@\n-one\n+two\n ", + lcov: "TN:\nSF:path/to/file\nDA:1,1\nend_of_record", + source_code: "one", + new_pull_request?: true + ) + + assert {:ok, + %{ + conclusion: "success", + output: %{ + summary: summary, + annotations: [] + } + }} = CoverageReporter.run(config) + + assert summary =~ "100.0%" + end + + test "lcov path prefix", ctx do + %{bypass: bypass, config: config} = ctx + + config = Keyword.put(config, :lcov_path_prefix, "system/") + + setup_changes( + bypass, + config, + path: "path/to/file", + status: "added", + changed_lines: [1, 2, 3], + patch: "@@ -0,0 +1,8 @@\n+one\n+two\n+three", + lcov: "TN:\nSF:system/path/to/file\nDA:1,1\nDA:2,1\nDA:3,1\nend_of_record", + source_code: "one\ntwo\nthree" + ) + + assert {:ok, %{output: %{summary: summary}}} = CoverageReporter.run(config) + + assert summary =~ " path/to/file " + end + + defp setup_changes(bypass, config, opts) do + changed_lines = Keyword.fetch!(opts, :changed_lines) + patch = Keyword.fetch!(opts, :patch) + path = Keyword.fetch!(opts, :path) + status = Keyword.get(opts, :status, "added") + lcov = Keyword.fetch!(opts, :lcov) + source_code = Keyword.fetch!(opts, :source_code) + new_pull_request? = Keyword.get(opts, :new_pull_request?, false) + pull_number = config[:github_ref] |> String.split("/") |> Enum.at(2) + + Bypass.expect(bypass, "GET", "repos/owner/repo/pulls/#{pull_number}/files", fn conn -> + if conn.params["page"] == "1" do + json(conn, 200, [ + %{ + status: status, + changed_lines: changed_lines, + filename: path, + patch: patch + } + ]) + else + json(conn, 200, []) + end + end) + + Bypass.expect(bypass, "POST", "repos/owner/repo/check-runs", &json(&1, 200, [])) + + Bypass.expect(bypass, "GET", "repos/owner/repo/pulls/#{pull_number}/reviews", fn conn -> + if conn.params["page"] == "1" and new_pull_request? do + json(conn, 200, [%{body: "Code Coverage for ##{pull_number}"}]) + else + json(conn, 200, []) + end + end) + + if new_pull_request? do + Bypass.expect( + bypass, + "PUT", + "repos/owner/repo/pulls/#{pull_number}/reviews", + &json(&1, 200, []) + ) + else + Bypass.expect( + bypass, + "POST", + "repos/owner/repo/pulls/#{pull_number}/reviews", + &json(&1, 200, []) + ) + end + + File.write!(config[:github_workspace] <> "/" <> config[:input_lcov_path], lcov) + + directory = + String.split(path, "/") + |> Enum.reverse() + |> Enum.drop(1) + |> Enum.reverse() + |> Enum.join("/") + + File.mkdir_p!(config[:github_workspace] <> "/" <> directory) + File.write!(config[:github_workspace] <> "/" <> path, source_code) + end + + defp json(conn, status, data) do + conn = + case Plug.Conn.get_resp_header(conn, "content-type") do + [] -> + Plug.Conn.put_resp_content_type(conn, "application/json") + + _ -> + conn + end + + Plug.Conn.send_resp(conn, status, Jason.encode_to_iodata!(data)) + end +end