From 06fec5ce93c54e4a8f23447edc171bb742328669 Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Sat, 25 Sep 2021 11:54:56 +0200 Subject: [PATCH] Finish implementation --- .gitignore | 3 +- Dockerfile | 18 ++- action.yml | 33 +++- report.md | 28 ---- setup.cfg | 11 +- src/add-to-wiki | 30 ++++ {tool => src}/default.md.j2 | 11 +- src/entrypoint | 268 +++++++++++++++++++++++++++++++++ {tool => src}/requirements.txt | 2 +- tests/__init__.py | 0 tests/code.py | 9 -- tests/requirements.txt | 2 - tests/test_code.py | 14 -- tool/add-to-wiki.sh | 20 --- tool/entrypoint.py | 202 ------------------------- 15 files changed, 342 insertions(+), 309 deletions(-) delete mode 100644 report.md create mode 100755 src/add-to-wiki rename {tool => src}/default.md.j2 (76%) create mode 100755 src/entrypoint rename {tool => src}/requirements.txt (75%) delete mode 100644 tests/__init__.py delete mode 100644 tests/code.py delete mode 100644 tests/requirements.txt delete mode 100644 tests/test_code.py delete mode 100755 tool/add-to-wiki.sh delete mode 100755 tool/entrypoint.py diff --git a/.gitignore b/.gitignore index ef176e0..d7f7a42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -coverage.xml -.coverage +.mypy_cache diff --git a/Dockerfile b/Dockerfile index df93c0a..2b6d3f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" ] diff --git a/action.yml b/action.yml index f1e2362..4ae030b 100644 --- a/action.yml +++ b/action.yml @@ -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 }} diff --git a/report.md b/report.md deleted file mode 100644 index 391cf9d..0000000 --- a/report.md +++ /dev/null @@ -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" - -``` - - ---- diff --git a/setup.cfg b/setup.cfg index a193373..2200705 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/src/add-to-wiki b/src/add-to-wiki new file mode 100755 index 0000000..4bac70b --- /dev/null +++ b/src/add-to-wiki @@ -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}" diff --git a/tool/default.md.j2 b/src/default.md.j2 similarity index 76% rename from tool/default.md.j2 rename to src/default.md.j2 index 0c0adfa..1e374da 100644 --- a/tool/default.md.j2 +++ b/src/default.md.j2 @@ -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 -%}
Diff Coverage details (click to unfold) {% 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 -%}
{{ marker }} diff --git a/src/entrypoint b/src/entrypoint new file mode 100755 index 0000000..f4016e4 --- /dev/null +++ b/src/entrypoint @@ -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 = """""" +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) diff --git a/tool/requirements.txt b/src/requirements.txt similarity index 75% rename from tool/requirements.txt rename to src/requirements.txt index 62d1894..299e810 100644 --- a/tool/requirements.txt +++ b/src/requirements.txt @@ -1,4 +1,4 @@ diff-cover -ghapi jinja2 +PyGithub xmltodict diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/code.py b/tests/code.py deleted file mode 100644 index dde1ac7..0000000 --- a/tests/code.py +++ /dev/null @@ -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" diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index 9955dec..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pytest -pytest-cov diff --git a/tests/test_code.py b/tests/test_code.py deleted file mode 100644 index 07a7d56..0000000 --- a/tests/test_code.py +++ /dev/null @@ -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 diff --git a/tool/add-to-wiki.sh b/tool/add-to-wiki.sh deleted file mode 100755 index 46cb604..0000000 --- a/tool/add-to-wiki.sh +++ /dev/null @@ -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 diff --git a/tool/entrypoint.py b/tool/entrypoint.py deleted file mode 100755 index ac12d51..0000000 --- a/tool/entrypoint.py +++ /dev/null @@ -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 = """""" -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()