Finish implementation
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1 @@
|
|||||||
coverage.xml
|
.mypy_cache
|
||||||
.coverage
|
|
||||||
|
|||||||
18
Dockerfile
18
Dockerfile
@@ -1,17 +1,19 @@
|
|||||||
FROM python:3-slim
|
FROM python:3-slim
|
||||||
|
|
||||||
WORKDIR /tool
|
WORKDIR /src
|
||||||
WORKDIR /workdir
|
|
||||||
|
|
||||||
COPY tool/requirements.txt /tool/
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
apt-get update; \
|
apt-get update; \
|
||||||
apt-get install -y git; \
|
apt-get install -y git; \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN pip install -r /tool/requirements.txt
|
COPY src/requirements.txt ./
|
||||||
RUN ls /tool
|
RUN pip install -r requirements.txt
|
||||||
COPY tool /tool
|
|
||||||
RUN ls /tool
|
|
||||||
|
|
||||||
CMD [ "/tool/entrypoint.py" ]
|
COPY src/entrypoint /usr/local/bin/
|
||||||
|
COPY src/add-to-wiki /usr/local/bin/
|
||||||
|
COPY src/default.md.j2 /var/
|
||||||
|
|
||||||
|
WORKDIR /workdir
|
||||||
|
|
||||||
|
CMD [ "entrypoint" ]
|
||||||
|
|||||||
33
action.yml
33
action.yml
@@ -1,13 +1,30 @@
|
|||||||
name: 'Coverage'
|
name: 'Coverage'
|
||||||
description: 'Publish diff coverage report as PR comment'
|
description: 'Publish diff coverage report as PR comment'
|
||||||
inputs:
|
inputs:
|
||||||
GITHUB_TOKEN: # id of input
|
GITHUB_TOKEN:
|
||||||
description: 'GitHub token'
|
description: A GitHub token to write comments and write the badge to the wiki.
|
||||||
required: true
|
required: true
|
||||||
|
COVERAGE_FILE:
|
||||||
|
description: Path and filename of the coverage XML file to analyze.
|
||||||
|
default: "coverage.xml"
|
||||||
|
required: false
|
||||||
|
COMMENT_TEMPLATE:
|
||||||
|
description: >
|
||||||
|
Specify a different template for the comments that will be written on the PR.
|
||||||
|
required: false
|
||||||
|
DIFF_COVER_ARGS:
|
||||||
|
description: Additional args to pass to diff cover (one per line)
|
||||||
|
required: false
|
||||||
|
BADGE_ENABLED:
|
||||||
|
description: Whether or not a badge will be generated and stored.
|
||||||
|
default: "true"
|
||||||
|
required: false
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: 'docker'
|
||||||
steps:
|
image: 'Dockerfile'
|
||||||
- run: echo ${{ inputs.GITHUB_TOKEN }} | gh auth login --with-token
|
env:
|
||||||
shell: bash
|
GITHUB_TOKEN: ${{ inputs.GITHUB_TOKEN }}
|
||||||
- run: echo "AAAA" && gh repo view && echo "BBBB"
|
COVERAGE_FILE: ${{ inputs.COVERAGE_FILE }}
|
||||||
shell: bash
|
COMMENT_TEMPLATE: ${{ inputs.COMMENT_TEMPLATE }}
|
||||||
|
DIFF_COVER_ARGS: ${{ inputs.DIFF_COVER_ARGS }}
|
||||||
|
BADGE_ENABLED: ${{ inputs.BADGE_ENABLED }}
|
||||||
|
|||||||
28
report.md
28
report.md
@@ -1,28 +0,0 @@
|
|||||||
# Diff Coverage
|
|
||||||
## Diff: origin/master...HEAD, staged and unstaged changes
|
|
||||||
|
|
||||||
- tests/code.py (0.0%): Missing lines 9
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- **Total**: 1 line
|
|
||||||
- **Missing**: 1 line
|
|
||||||
- **Coverage**: 0%
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## tests/code.py
|
|
||||||
|
|
||||||
Lines 5-10
|
|
||||||
|
|
||||||
```python
|
|
||||||
if arg is None:
|
|
||||||
return "a"
|
|
||||||
elif arg is True:
|
|
||||||
return "b"
|
|
||||||
return "c"
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
11
setup.cfg
11
setup.cfg
@@ -1,6 +1,5 @@
|
|||||||
[isort]
|
[isort]
|
||||||
profile = black
|
profile = black
|
||||||
known_first_party = procrastinate
|
|
||||||
skip = .venv,.tox
|
skip = .venv,.tox
|
||||||
|
|
||||||
[flake8]
|
[flake8]
|
||||||
@@ -9,16 +8,8 @@ skip = .venv,.tox
|
|||||||
extend-ignore = E203,E501
|
extend-ignore = E203,E501
|
||||||
extend-exclude = .venv
|
extend-exclude = .venv
|
||||||
|
|
||||||
[tool:pytest]
|
|
||||||
addopts =
|
|
||||||
--cov-report term-missing --cov-branch --cov-report xml --cov-report term
|
|
||||||
--cov=tests.code -vv --strict-markers -rfE
|
|
||||||
|
|
||||||
filterwarnings =
|
|
||||||
error
|
|
||||||
|
|
||||||
[mypy]
|
[mypy]
|
||||||
no_implicit_optional = True
|
no_implicit_optional = True
|
||||||
|
|
||||||
[mypy-django.*,importlib_metadata.*,psycopg2.*,aiopg.*]
|
[mypy-xmltodict.*]
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
|
|||||||
30
src/add-to-wiki
Executable file
30
src/add-to-wiki
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Usage $0 {owner/repo} {filename} {commit_message}
|
||||||
|
# Stores the content of stdin in a file named {filename} in the wiki of
|
||||||
|
# the provided repo
|
||||||
|
# Reads envvar GITHUB_TOKEN
|
||||||
|
|
||||||
|
set -eux
|
||||||
|
|
||||||
|
stdin="$(cat -)"
|
||||||
|
repo_name="${1}"
|
||||||
|
filename="${2}"
|
||||||
|
commit_message="${3}"
|
||||||
|
dir="$(mktemp -d)"
|
||||||
|
cd $dir
|
||||||
|
|
||||||
|
git clone "https://${GITHUB_TOKEN}@github.com/${repo_name}.wiki.git" .
|
||||||
|
echo $stdin > "${filename}"
|
||||||
|
|
||||||
|
if ! git diff --exit-code
|
||||||
|
then
|
||||||
|
git add "${filename}"
|
||||||
|
|
||||||
|
git config --global user.email "coverage-comment-action"
|
||||||
|
git config --global user.name "Coverage Comment Action"
|
||||||
|
git commit -m "$commit_message"
|
||||||
|
|
||||||
|
git push -u origin
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "https://raw.githubusercontent.com/wiki/${repo_name}/${filename}"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
## Coverage report
|
## Coverage report
|
||||||
{% if previous_coverage -%}
|
{% if previous_coverage -%}
|
||||||
The coverage rate went from `{{ previous_coverage }}%` to `{{ coverage }}%`
|
The coverage rate went from `{{ previous_coverage }}%` to `{{ coverage }}%` {{
|
||||||
{{ ":arrow_up:" if previous_coverage < coverage else
|
":arrow_up:" if previous_coverage < coverage else
|
||||||
":arrow_down:" if previous_coverage > coverage else
|
":arrow_down:" if previous_coverage > coverage else
|
||||||
":arrow_right:"
|
":arrow_right:"
|
||||||
}}
|
}}
|
||||||
@@ -15,14 +15,15 @@ The branch rate is `{{ branch_coverage }}%`
|
|||||||
|
|
||||||
`{{ diff_coverage }}%` of new lines are covered.
|
`{{ diff_coverage }}%` of new lines are covered.
|
||||||
|
|
||||||
|
{%if file_info -%}
|
||||||
<details>
|
<details>
|
||||||
<summary>Diff Coverage details (click to unfold)</summary>
|
<summary>Diff Coverage details (click to unfold)</summary>
|
||||||
|
|
||||||
{% for filename, stats in file_info.items() -%}}
|
{% for filename, stats in file_info.items() -%}}
|
||||||
### {{ filename }}
|
### {{ filename }}
|
||||||
`{{ stats.diff_coverage }}` of new lines are covered
|
`{{ stats.diff_coverage }}%` of new lines are covered
|
||||||
|
|
||||||
{%- endfor %}}
|
|
||||||
|
|
||||||
|
{%- endfor %}
|
||||||
|
{%- endif -%}
|
||||||
</details>
|
</details>
|
||||||
{{ marker }}
|
{{ marker }}
|
||||||
268
src/entrypoint
Executable file
268
src/entrypoint
Executable file
@@ -0,0 +1,268 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import github
|
||||||
|
import jinja2
|
||||||
|
import xmltodict
|
||||||
|
|
||||||
|
MARKER = """<!-- This comment was produced by coverage-comment-action -->"""
|
||||||
|
BADGE_FILENAME = "coverage-comment-badge.json"
|
||||||
|
MINIMUM_GREEN = 100
|
||||||
|
MINIMUM_ORANGE = 70
|
||||||
|
SHIELD_URL = "https://img.shields.io/endpoint?url={url}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
config = Config.from_environ(os.environ)
|
||||||
|
coverage_info = get_coverage_info(config=config)
|
||||||
|
gh = get_api(config=config)
|
||||||
|
|
||||||
|
if config.GITHUB_PR_NUMBER:
|
||||||
|
print(f"Commenting on the coverage on PR {config.GITHUB_PR_NUMBER}")
|
||||||
|
diff_coverage_info = get_diff_coverage_info(config=config)
|
||||||
|
previous_coverage_rate = get_previous_coverage_rate(config=config)
|
||||||
|
|
||||||
|
comment = get_markdown_comment(
|
||||||
|
coverage_info=coverage_info,
|
||||||
|
diff_coverage_info=diff_coverage_info,
|
||||||
|
previous_coverage_rate=previous_coverage_rate,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
post_comment(body=comment, gh=gh, config=config)
|
||||||
|
|
||||||
|
if config.BADGE_ENABLED and is_main_branch(gh=gh, config=config):
|
||||||
|
print("Running on default branch, saving Badge into the repo wiki")
|
||||||
|
badge = compute_badge(coverage_info=coverage_info)
|
||||||
|
url = upload_badge(badge=badge, config=config)
|
||||||
|
print(f"Badge JSON stored at {url}")
|
||||||
|
print(f"Badge URL: {SHIELD_URL.format(url=url)}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class Config:
|
||||||
|
"""This object defines the environment variables"""
|
||||||
|
|
||||||
|
GITHUB_BASE_REF: str
|
||||||
|
GITHUB_TOKEN: str
|
||||||
|
GITHUB_REPOSITORY: str
|
||||||
|
GITHUB_HEAD_REF: str
|
||||||
|
GITHUB_REF: str
|
||||||
|
COVERAGE_FILE: str = "coverage.xml"
|
||||||
|
COMMENT_TEMPLATE: Optional[str] = None
|
||||||
|
DIFF_COVER_ARGS: List[str] = dataclasses.field(default_factory=list)
|
||||||
|
BADGE_ENABLED: bool = True
|
||||||
|
|
||||||
|
# Clean methods
|
||||||
|
@classmethod
|
||||||
|
def clean_diff_cover_args(cls, value: str) -> list:
|
||||||
|
return [e.strip() for e in value.split("\n") if e.strip()]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clean_badge_enabled(cls, value: str) -> bool:
|
||||||
|
return value.lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GITHUB_PR_NUMBER(self) -> Optional[int]:
|
||||||
|
# "refs/pull/2/merge"
|
||||||
|
if "pull" in self.GITHUB_REF:
|
||||||
|
return int(self.GITHUB_REF.split("/")[2])
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_environ(cls, environ):
|
||||||
|
possible_variables = [e for e in inspect.signature(cls).parameters]
|
||||||
|
config = {k: v for k, v in environ.items() if k in possible_variables}
|
||||||
|
for key, value in list(config.items()):
|
||||||
|
if func := getattr(cls, f"clean_{key.lower()}", None):
|
||||||
|
config[key] = func(value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return cls(**config)
|
||||||
|
except TypeError:
|
||||||
|
missing = {
|
||||||
|
name
|
||||||
|
for name, param in inspect.signature(cls).parameters.items()
|
||||||
|
if param.default is inspect.Parameter.empty
|
||||||
|
} - set(os.environ)
|
||||||
|
sys.exit(f" missing environment variable(s): {', '.join(missing)}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_api(config: Config) -> github.Github:
|
||||||
|
return github.Github(config.GITHUB_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def get_coverage_info(config: Config) -> dict:
|
||||||
|
coverage_file = pathlib.Path(config.COVERAGE_FILE)
|
||||||
|
if not coverage_file.exists():
|
||||||
|
raise Exit(f"Coverage file not found at {config.COVERAGE_FILE}")
|
||||||
|
|
||||||
|
def convert(tuple_values):
|
||||||
|
result = []
|
||||||
|
for key, value in tuple_values:
|
||||||
|
result.append(
|
||||||
|
(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"@timestamp": int,
|
||||||
|
"@lines-valid": int,
|
||||||
|
"@lines-covered": int,
|
||||||
|
"@line-rate": float,
|
||||||
|
"@branches-valid": int,
|
||||||
|
"@branches-covered": int,
|
||||||
|
"@branch-rate": float,
|
||||||
|
"@complexity": int,
|
||||||
|
"@hits": int,
|
||||||
|
"@branch": lambda x: x == "true",
|
||||||
|
}.get(key, lambda x: x)(value),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return dict(result)
|
||||||
|
|
||||||
|
return json.loads(
|
||||||
|
json.dumps(xmltodict.parse(pathlib.Path(config.COVERAGE_FILE).read_text())),
|
||||||
|
object_pairs_hook=convert,
|
||||||
|
)["coverage"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_diff_coverage_info(config: Config) -> dict:
|
||||||
|
call("git", "fetch", "--depth=1000")
|
||||||
|
with tempfile.NamedTemporaryFile("r") as f:
|
||||||
|
call(
|
||||||
|
"diff-cover",
|
||||||
|
config.COVERAGE_FILE,
|
||||||
|
f"--compare-branch=origin/{config.GITHUB_BASE_REF}",
|
||||||
|
f"--json-report={f.name}",
|
||||||
|
"--diff-range-notation=..",
|
||||||
|
"--quiet",
|
||||||
|
*config.DIFF_COVER_ARGS,
|
||||||
|
)
|
||||||
|
return json.loads(f.read())
|
||||||
|
|
||||||
|
|
||||||
|
def get_markdown_comment(
|
||||||
|
coverage_info: dict,
|
||||||
|
diff_coverage_info: dict,
|
||||||
|
previous_coverage_rate: Optional[float],
|
||||||
|
config: Config,
|
||||||
|
):
|
||||||
|
env = jinja2.Environment()
|
||||||
|
template = config.COMMENT_TEMPLATE or pathlib.Path("/var/default.md.j2").read_text()
|
||||||
|
previous_coverage = previous_coverage_rate * 100 if previous_coverage_rate else None
|
||||||
|
coverage = coverage_info["@line-rate"] * 100
|
||||||
|
branch_coverage = (
|
||||||
|
coverage_info["@branch-rate"] * 100
|
||||||
|
if coverage_info.get("@branch-rate")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
diff_coverage = diff_coverage_info["total_percent_covered"]
|
||||||
|
file_info = {
|
||||||
|
file: {"diff_coverage": stats["percent_covered"]}
|
||||||
|
for file, stats in diff_coverage_info["src_stats"].items()
|
||||||
|
}
|
||||||
|
return env.from_string(template).render(
|
||||||
|
previous_coverage=previous_coverage,
|
||||||
|
coverage=coverage,
|
||||||
|
branch_coverage=branch_coverage,
|
||||||
|
diff_coverage=diff_coverage,
|
||||||
|
file_info=file_info,
|
||||||
|
marker=MARKER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_main_branch(gh: github.Github, config: Config) -> bool:
|
||||||
|
repo = gh.get_repo(config.GITHUB_REPOSITORY)
|
||||||
|
return repo.default_branch == config.GITHUB_HEAD_REF
|
||||||
|
|
||||||
|
|
||||||
|
def post_comment(body: str, gh: github.Github, config: Config) -> None:
|
||||||
|
try:
|
||||||
|
me = gh.get_user().login
|
||||||
|
except github.GithubException as exc:
|
||||||
|
if exc.status == 403:
|
||||||
|
me = "github-actions[bot]"
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
repo = gh.get_repo(config.GITHUB_REPOSITORY)
|
||||||
|
assert config.GITHUB_PR_NUMBER
|
||||||
|
issue = repo.get_issue(config.GITHUB_PR_NUMBER)
|
||||||
|
for comment in issue.get_comments():
|
||||||
|
if comment.user.login == me and MARKER in comment.body:
|
||||||
|
print("Update previous comment")
|
||||||
|
comment.edit(body=body)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print("Adding new comment")
|
||||||
|
issue.create_comment(body=body)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_badge(coverage_info: dict) -> str:
|
||||||
|
rate = int(coverage_info["@line-rate"] * 100)
|
||||||
|
|
||||||
|
if rate >= MINIMUM_GREEN:
|
||||||
|
color = "green"
|
||||||
|
elif rate >= MINIMUM_ORANGE:
|
||||||
|
color = "orange"
|
||||||
|
else:
|
||||||
|
color = "red"
|
||||||
|
|
||||||
|
badge = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"label": "Coverage",
|
||||||
|
"message": f"{rate}%",
|
||||||
|
"color": color,
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(badge)
|
||||||
|
|
||||||
|
|
||||||
|
def get_previous_coverage_rate(config: Config) -> Optional[float]:
|
||||||
|
return 0.42
|
||||||
|
|
||||||
|
|
||||||
|
def upload_badge(badge: str, config: Config) -> str:
|
||||||
|
try:
|
||||||
|
process = call(
|
||||||
|
"add-to-wiki",
|
||||||
|
config.GITHUB_REPOSITORY,
|
||||||
|
BADGE_FILENAME,
|
||||||
|
"Update badge",
|
||||||
|
input=badge,
|
||||||
|
)
|
||||||
|
except Exit as exc:
|
||||||
|
if "remote error: access denied or repository not exported" in str(exc):
|
||||||
|
print(
|
||||||
|
"Wiki seems not to be activated for this project. Please activate the "
|
||||||
|
"wiki. You may disable it afterwards."
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
return process.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class Exit(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def call(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
args, text=True, check=True, capture_output=True, **kwargs
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
raise Exit("/n".join([exc.stdout, exc.stderr]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exit as exc:
|
||||||
|
print(exc)
|
||||||
|
sys.exit(1)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
diff-cover
|
diff-cover
|
||||||
ghapi
|
|
||||||
jinja2
|
jinja2
|
||||||
|
PyGithub
|
||||||
xmltodict
|
xmltodict
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
def code(arg: Optional[bool]) -> str:
|
|
||||||
if arg is None:
|
|
||||||
return "a"
|
|
||||||
elif arg is True:
|
|
||||||
return "b"
|
|
||||||
return "c"
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
pytest
|
|
||||||
pytest-cov
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from . import code
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"arg, expected",
|
|
||||||
[
|
|
||||||
(None, "a"),
|
|
||||||
(True, "b"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_code(arg, expected):
|
|
||||||
assert code.code(arg) == expected
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Usage $0 {owner/repo} {filename} {commit_message}
|
|
||||||
# Stores the content of stdin in a file named {filename} in the wiki of
|
|
||||||
# the provided repo
|
|
||||||
# Reads envvar GITHUB_TOKEN
|
|
||||||
|
|
||||||
set -eux
|
|
||||||
|
|
||||||
stdin=$(cat -)
|
|
||||||
repo_name=${1}
|
|
||||||
filename=${2}
|
|
||||||
commit_message=${3}
|
|
||||||
dir=$(mktemp -d)
|
|
||||||
cd $dir
|
|
||||||
|
|
||||||
git clone "https://${GITHUB_TOKEN}@github.com/${repo_name}.wiki.git" .
|
|
||||||
echo $stdin > ${filename}
|
|
||||||
git add ${filename}
|
|
||||||
git commit -m $commit_message
|
|
||||||
git push -u origin
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import inspect
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
import ghapi
|
|
||||||
import jinja2
|
|
||||||
import xmltodict
|
|
||||||
|
|
||||||
MARKER = """<!-- This comment was produced by coverage-comment-action -->"""
|
|
||||||
BADGE_FILENAME = "coverage-comment-badge.json"
|
|
||||||
MINIMUM_GREEN = 100
|
|
||||||
MINIMUM_ORANGE = 70
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
config = Config.from_environ(os.environ)
|
|
||||||
coverage_info = get_coverage_info(config=config)
|
|
||||||
diff_coverage_info = get_diff_coverage_info(config=config)
|
|
||||||
previous_coverage_rate = get_previous_coverage_rate(config=config)
|
|
||||||
|
|
||||||
comment = get_markdown_comment(
|
|
||||||
coverage_info=coverage_info,
|
|
||||||
diff_coverage_info=diff_coverage_info,
|
|
||||||
previous_coverage_rate=previous_coverage_rate,
|
|
||||||
config=config,
|
|
||||||
)
|
|
||||||
post_comment(comment=comment, config=config)
|
|
||||||
|
|
||||||
if config.BADGE_ENABLED:
|
|
||||||
badge = compute_badge(coverage_info=coverage_info)
|
|
||||||
upload_badge(badge=badge, config=config)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class Config:
|
|
||||||
"""This object defines the environment variables"""
|
|
||||||
|
|
||||||
GITHUB_BASE_REF: str
|
|
||||||
GITHUB_TOKEN: str
|
|
||||||
GITHUB_OWNER: str
|
|
||||||
GITHUB_REPO: str
|
|
||||||
GITHUB_PR_NUMBER: str
|
|
||||||
COVERAGE_FILE: str = "coverage.xml"
|
|
||||||
COMMENT_TEMPLATE: Optional[str] = None
|
|
||||||
DIFF_COVER_ARGS: List[str] = dataclasses.field(default_factory=list)
|
|
||||||
BADGE_ENABLED: bool = True
|
|
||||||
|
|
||||||
# Clean methods
|
|
||||||
@classmethod
|
|
||||||
def clean_diff_cover_args(cls, value: str) -> list:
|
|
||||||
return [e.strip() for e in value.split("\n")]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def clean_badge_enabled(cls, value: str) -> bool:
|
|
||||||
return value.lower() in ("1", "true", "yes")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_environ(cls, environ):
|
|
||||||
possible_variables = [e for e in inspect.signature(cls).parameters]
|
|
||||||
config = {k: v for k, v in environ.items() if k in possible_variables}
|
|
||||||
for key, value in list(config.items()):
|
|
||||||
if func := getattr(cls, f"clean_{key.lower()}", None):
|
|
||||||
config[key] = func(value)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return cls(**config)
|
|
||||||
except TypeError as exc:
|
|
||||||
sys.exit(f"{exc} environment variable is mandatory")
|
|
||||||
|
|
||||||
|
|
||||||
def get_coverage_info(config: Config) -> dict:
|
|
||||||
def convert(tuple_values):
|
|
||||||
result = []
|
|
||||||
for key, value in tuple_values:
|
|
||||||
result.append(
|
|
||||||
(
|
|
||||||
key,
|
|
||||||
{
|
|
||||||
"@timestamp": int,
|
|
||||||
"@lines-valid": int,
|
|
||||||
"@lines-covered": int,
|
|
||||||
"@line-rate": float,
|
|
||||||
"@branches-valid": int,
|
|
||||||
"@branches-covered": int,
|
|
||||||
"@branch-rate": float,
|
|
||||||
"@complexity": int,
|
|
||||||
"@hits": int,
|
|
||||||
"@branch": lambda x: x == "true",
|
|
||||||
}.get(key, lambda x: x)(value),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return dict(result)
|
|
||||||
|
|
||||||
return json.loads(
|
|
||||||
json.dumps(xmltodict.parse(pathlib.Path(config.COVERAGE_FILE).read_text())),
|
|
||||||
object_pairs_hook=convert,
|
|
||||||
)["coverage"]
|
|
||||||
|
|
||||||
|
|
||||||
def get_diff_coverage_info(config: Config) -> dict:
|
|
||||||
with tempfile.NamedTemporaryFile("r") as f:
|
|
||||||
subprocess.check_call(
|
|
||||||
[
|
|
||||||
"diff-cover",
|
|
||||||
config.COVERAGE_FILE,
|
|
||||||
f"--compare-branch=origin/{config.GITHUB_BASE_REF}",
|
|
||||||
f"--json-report={f.name}",
|
|
||||||
"--quiet",
|
|
||||||
]
|
|
||||||
+ config.DIFF_COVER_ARGS
|
|
||||||
)
|
|
||||||
return json.loads(f.read())
|
|
||||||
|
|
||||||
|
|
||||||
def get_markdown_comment(
|
|
||||||
coverage_info: dict,
|
|
||||||
diff_coverage_info: dict,
|
|
||||||
previous_coverage_rate: Optional[float],
|
|
||||||
config: Config,
|
|
||||||
):
|
|
||||||
env = jinja2.Environment()
|
|
||||||
template = (
|
|
||||||
config.COMMENT_TEMPLATE or pathlib.Path("/tool/default.md.j2").read_text()
|
|
||||||
)
|
|
||||||
previous_coverage = previous_coverage_rate * 100 if previous_coverage_rate else None
|
|
||||||
coverage = coverage_info["@line-rate"] * 100
|
|
||||||
branch_coverage = (
|
|
||||||
coverage["@branch-rate"] * 100 if coverage.get("@branch-rate") else None
|
|
||||||
)
|
|
||||||
diff_coverage = diff_coverage_info["total_percent_covered"]
|
|
||||||
file_info = {
|
|
||||||
file: {"diff_coverage": stats.percent_covered}
|
|
||||||
for file, stats in diff_coverage["src_stats"].items()
|
|
||||||
}
|
|
||||||
return env.from_string(template).render(
|
|
||||||
previous_coverage=previous_coverage,
|
|
||||||
coverage=coverage,
|
|
||||||
branch_coverage=branch_coverage,
|
|
||||||
diff_coverage=diff_coverage,
|
|
||||||
file_info=file_info,
|
|
||||||
marker=MARKER,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def post_comment(body: str, config: Config) -> None:
|
|
||||||
api = ghapi.GhApi(
|
|
||||||
owner=config.GITHUB_OWNER,
|
|
||||||
repo=config.GITHUB_REPO,
|
|
||||||
jwt_token=config.GITHUB_TOKEN,
|
|
||||||
)
|
|
||||||
me = api.users.get_authenticated()
|
|
||||||
for comment in api.issues.list_comments(issue_number=config.GITHUB_PR_NUMBER):
|
|
||||||
if comment.user.login == me.login and MARKER in comment.body:
|
|
||||||
api.issues.update_comment(comment_id=comment.id, body=body)
|
|
||||||
else:
|
|
||||||
api.issues.create_comment(issue_number=config.GITHUB_PR_NUMBER, body=body)
|
|
||||||
|
|
||||||
|
|
||||||
def compute_badge(coverage_info: dict) -> str:
|
|
||||||
rate = int(coverage_info["@line-rate"] * 100)
|
|
||||||
|
|
||||||
if rate >= MINIMUM_GREEN:
|
|
||||||
color = "green"
|
|
||||||
elif rate >= MINIMUM_ORANGE:
|
|
||||||
color = "orange"
|
|
||||||
else:
|
|
||||||
color = "red"
|
|
||||||
|
|
||||||
badge = {
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"label": "Coverage",
|
|
||||||
"message": f"{rate}%",
|
|
||||||
"color": color,
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.dumps(badge)
|
|
||||||
|
|
||||||
|
|
||||||
def get_previous_coverage_rate(config: Config) -> Optional[float]:
|
|
||||||
return 0.4242
|
|
||||||
|
|
||||||
|
|
||||||
def upload_badge(badge: str, config: Config) -> None:
|
|
||||||
owner_repo = f"{config.GITHUB_OWNER}/{config.GITHUB_REPO}"
|
|
||||||
subprocess.run(
|
|
||||||
["./add-to-wiki.sh", owner_repo, BADGE_FILENAME, "Update badge"],
|
|
||||||
input=badge,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user