Finish implementation

This commit is contained in:
Joachim Jablon
2021-09-25 11:54:56 +02:00
parent ebcebf7ed6
commit 06fec5ce93
15 changed files with 342 additions and 309 deletions

3
.gitignore vendored
View File

@@ -1,2 +1 @@
coverage.xml
.coverage
.mypy_cache

View File

@@ -1,17 +1,19 @@
FROM python:3-slim
WORKDIR /tool
WORKDIR /workdir
WORKDIR /src
COPY tool/requirements.txt /tool/
RUN set -eux; \
apt-get update; \
apt-get install -y git; \
rm -rf /var/lib/apt/lists/*
RUN pip install -r /tool/requirements.txt
RUN ls /tool
COPY tool /tool
RUN ls /tool
COPY src/requirements.txt ./
RUN pip install -r requirements.txt
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" ]

View File

@@ -1,13 +1,30 @@
name: 'Coverage'
description: 'Publish diff coverage report as PR comment'
inputs:
GITHUB_TOKEN: # id of input
description: 'GitHub token'
GITHUB_TOKEN:
description: A GitHub token to write comments and write the badge to the wiki.
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:
using: "composite"
steps:
- run: echo ${{ inputs.GITHUB_TOKEN }} | gh auth login --with-token
shell: bash
- run: echo "AAAA" && gh repo view && echo "BBBB"
shell: bash
using: 'docker'
image: 'Dockerfile'
env:
GITHUB_TOKEN: ${{ inputs.GITHUB_TOKEN }}
COVERAGE_FILE: ${{ inputs.COVERAGE_FILE }}
COMMENT_TEMPLATE: ${{ inputs.COMMENT_TEMPLATE }}
DIFF_COVER_ARGS: ${{ inputs.DIFF_COVER_ARGS }}
BADGE_ENABLED: ${{ inputs.BADGE_ENABLED }}

View File

@@ -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"
```
---

View File

@@ -1,6 +1,5 @@
[isort]
profile = black
known_first_party = procrastinate
skip = .venv,.tox
[flake8]
@@ -9,16 +8,8 @@ skip = .venv,.tox
extend-ignore = E203,E501
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]
no_implicit_optional = True
[mypy-django.*,importlib_metadata.*,psycopg2.*,aiopg.*]
[mypy-xmltodict.*]
ignore_missing_imports = True

30
src/add-to-wiki Executable file
View 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}"

View File

@@ -1,7 +1,7 @@
## Coverage report
{% if previous_coverage -%}
The coverage rate went from `{{ previous_coverage }}%` to `{{ coverage }}%`
{{ ":arrow_up:" if previous_coverage < coverage else
The coverage rate went from `{{ previous_coverage }}%` to `{{ coverage }}%` {{
":arrow_up:" if previous_coverage < coverage else
":arrow_down:" if previous_coverage > coverage else
":arrow_right:"
}}
@@ -15,14 +15,15 @@ The branch rate is `{{ branch_coverage }}%`
`{{ diff_coverage }}%` of new lines are covered.
{%if file_info -%}
<details>
<summary>Diff Coverage details (click to unfold)</summary>
{% for filename, stats in file_info.items() -%}}
### {{ filename }}
`{{ stats.diff_coverage }}` of new lines are covered
{%- endfor %}}
`{{ stats.diff_coverage }}%` of new lines are covered
{%- endfor %}
{%- endif -%}
</details>
{{ marker }}

268
src/entrypoint Executable file
View 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)

View File

@@ -1,4 +1,4 @@
diff-cover
ghapi
jinja2
PyGithub
xmltodict

View File

View File

@@ -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"

View File

@@ -1,2 +0,0 @@
pytest
pytest-cov

View File

@@ -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

View File

@@ -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

View File

@@ -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()