diff --git a/README.md b/README.md index 8c7e554..47cd2ef 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ Note: Either `GITHUB_PR_NUMBER` or `GITHUB_REF` is required. - `SUBPROJECT_ID`: The ID or URL of the subproject or report. - `MINIMUM_GREEN`: The minimum coverage percentage for green status. Default is 100. - `MINIMUM_ORANGE`: The minimum coverage percentage for orange status. Default is 70. +- `SKIP_COVERAGE`: Skip coverage reporting as github comment and generate only annotaions. Default is False. +- `ANNOTATIONS_OUTPUT_PATH`: The path where the annotaions should be stored. Should be a .json file. - `ANNOTATE_MISSING_LINES`: Whether to annotate missing lines in the coverage report. Default is False. - `ANNOTATION_TYPE`: The type of annotation to use for missing lines. Default is 'warning'. - `MAX_FILES_IN_COMMENT`: The maximum number of files to include in the coverage report comment. Default is 25. diff --git a/codecov/github.py b/codecov/github.py index 51cd1f7..0d91c7f 100644 --- a/codecov/github.py +++ b/codecov/github.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- import dataclasses +import json import pathlib -import sys +from collections.abc import Iterable -from codecov import github_client, log, settings +from codecov import github_client, groups, log, settings GITHUB_ACTIONS_LOGIN = 'CI-codecov[bot]' @@ -24,6 +25,39 @@ class NoArtifact(Exception): pass +@dataclasses.dataclass +class Annotation: + file: pathlib.Path + line_start: int + line_end: int + title: str + message_type: str + message: str + + def __str__(self) -> str: + return f'{self.message_type} {self.message} in {self.file}:{self.line_start}-{self.line_end}' + + def __repr__(self) -> str: + return f'{self.message_type} {self.message} in {self.file}:{self.line_start}-{self.line_end}' + + def to_dict(self): + return { + 'file': str(self.file), + 'line_start': self.line_start, + 'line_end': self.line_end, + 'title': self.title, + 'message_type': self.message_type, + 'message': self.message, + } + + +class AnnotationEncoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, Annotation): + return o.to_dict() + return super().default(o) + + @dataclasses.dataclass class RepositoryInfo: default_branch: str @@ -134,54 +168,31 @@ def post_comment( # pylint: disable=too-many-arguments raise CannotPostComment from exc -def escape_property(s: str) -> str: - return s.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A').replace(':', '%3A').replace(',', '%2C') - - -def escape_data(s: str) -> str: - return s.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') - - -def get_workflow_command(command: str, command_value: str, **kwargs: str) -> str: - """ - Returns a string that can be printed to send a workflow command - https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions - """ - values_listed = [f'{key}={escape_property(value)}' for key, value in kwargs.items()] - - context = f" {','.join(values_listed)}" if values_listed else '' - return f'::{command}{context}::{escape_data(command_value)}' - - -def send_workflow_command(command: str, command_value: str, **kwargs: str) -> None: - print( - get_workflow_command(command=command, command_value=command_value, **kwargs), - file=sys.stderr, - ) - - -def create_missing_coverage_annotations(annotation_type: str, annotations: list[tuple[pathlib.Path, int, int]]): +def create_missing_coverage_annotations( + annotation_type: str, + annotations: Iterable[groups.Group], +) -> list[Annotation]: """ Create annotations for lines with missing coverage. - annotation_type: The type of annotation to create. Can be either "error" or "warning". + annotation_type: The type of annotation to create. Can be either "error" or "warning" or "notice". annotations: A list of tuples of the form (file, line_start, line_end) """ - send_workflow_command(command='group', command_value='Annotations of lines with missing coverage') - for file, line_start, line_end in annotations: - if line_start == line_end: - message = f'Missing coverage on line {line_start}' + formatted_annotations: list[Annotation] = [] + for group in annotations: + if group.line_start == group.line_end: + message = f'Missing coverage on line {group.line_start}' else: - message = f'Missing coverage on lines {line_start}-{line_end}' - - send_workflow_command( - command=annotation_type, - command_value=message, - # This will produce \ paths when running on windows. - # GHA doc is unclear whether this is right or not. - file=str(file), - line=str(line_start), - endLine=str(line_end), - title='Missing coverage', + message = f'Missing coverage on lines {group.line_start}-{group.line_end}' + + formatted_annotations.append( + Annotation( + file=group.file, + line_start=group.line_start, + line_end=group.line_end, + title='Missing coverage', + message_type=annotation_type, + message=message, + ) ) - send_workflow_command(command='endgroup', command_value='') + return formatted_annotations diff --git a/codecov/log.py b/codecov/log.py index d3f3b61..c59b9f0 100644 --- a/codecov/log.py +++ b/codecov/log.py @@ -6,3 +6,11 @@ def __getattr__(name): return getattr(logger, name) + + +def setup(debug: bool = False): + logging.basicConfig( + level='DEBUG' if debug else 'INFO', + format='%(asctime)s.%(msecs)03d %(levelname)s %(name)s %(module)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + ) diff --git a/codecov/log_utils.py b/codecov/log_utils.py deleted file mode 100644 index 3e2d0ee..0000000 --- a/codecov/log_utils.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -import logging - -from codecov import github - -LEVEL_MAPPING = { - 50: 'error', - 40: 'error', - 30: 'warning', - 20: 'notice', - 10: 'debug', -} - - -class ConsoleFormatter(logging.Formatter): - def format(self, record) -> str: - log = super().format(record) - - return f'{int(record.created)} {record.levelname} {record.name} - {log}' - - -class GitHubFormatter(logging.Formatter): - def format(self, record) -> str: - log = super().format(record) - level = LEVEL_MAPPING[record.levelno] - - return github.get_workflow_command(command=level, command_value=log) diff --git a/codecov/main.py b/codecov/main.py index e2f1a87..1b25a48 100644 --- a/codecov/main.py +++ b/codecov/main.py @@ -1,23 +1,23 @@ # -*- coding: utf-8 -*- -import logging +import json import os import sys import httpx -from codecov import coverage as coverage_module, diff_grouper, github, github_client, log, log_utils, settings, template +from codecov import coverage as coverage_module, diff_grouper, github, github_client, log, settings, template def main(): try: config = settings.Config.from_environ(environ=os.environ) + log.setup(debug=config.DEBUG) - logging.basicConfig(level='DEBUG' if config.DEBUG else 'INFO') - logging.getLogger().handlers[0].formatter = ( - log_utils.ConsoleFormatter() if config.DEBUG else log_utils.GitHubFormatter() - ) + if config.SKIP_COVERAGE and not config.ANNOTATE_MISSING_LINES: + log.info('Nothing to do since both SKIP_COVERAGE and ANNOTATE_MISSING_LINES are set to False. Exiting.') + sys.exit(0) - log.info('Starting action') + log.info('Starting...') github_session = httpx.Client( base_url=github_client.BASE_URL, follow_redirects=True, @@ -25,7 +25,7 @@ def main(): ) exit_code = action(config=config, github_session=github_session) - log.info('Ending action') + log.info('Ending...') sys.exit(exit_code) except Exception: # pylint: disable=broad-except @@ -66,14 +66,32 @@ def process_pr( # pylint: disable=too-many-locals repo_info: github.RepositoryInfo, pr_number: int, ) -> int: - log.info('Generating comment for PR') _, coverage = coverage_module.get_coverage_info(coverage_path=config.COVERAGE_PATH) base_ref = config.GITHUB_BASE_REF or repo_info.default_branch pr_diff = github.get_pr_diff(github=gh, repository=config.GITHUB_REPOSITORY, pr_number=pr_number) added_lines = coverage_module.parse_diff_output(diff=pr_diff) diff_coverage = coverage_module.get_diff_coverage_info(coverage=coverage, added_lines=added_lines) - marker = template.get_marker(marker_id=config.SUBPROJECT_ID) + if config.ANNOTATE_MISSING_LINES: + log.info('Generating annotations for missing lines.') + annotations = diff_grouper.get_diff_missing_groups(coverage=coverage, diff_coverage=diff_coverage) + formatted_annotations = github.create_missing_coverage_annotations( + annotation_type=config.ANNOTATION_TYPE, + annotations=annotations, + ) + print(*formatted_annotations, sep='\n') + if config.ANNOTATIONS_OUTPUT_PATH: + log.info('Writing annotations to file.') + with config.ANNOTATIONS_OUTPUT_PATH.open('w+') as annotations_file: + json.dump(formatted_annotations, annotations_file, cls=github.AnnotationEncoder) + log.info('Annotations generated.') + + if config.SKIP_COVERAGE: + log.info('Skipping coverage report generation') + return 0 + + log.info('Generating comment for PR') + marker = template.get_marker(marker_id=config.SUBPROJECT_ID) files_info, count_files, changed_files_info = template.select_changed_files( coverage=coverage, diff_coverage=diff_coverage, @@ -121,14 +139,6 @@ def process_pr( # pylint: disable=too-many-locals ) return 1 - # TODO: Disable this for now now and make it work through Github APIs - if pr_number and config.ANNOTATE_MISSING_LINES: - annotations = diff_grouper.get_diff_missing_groups(coverage=coverage, diff_coverage=diff_coverage) - github.create_missing_coverage_annotations( - annotation_type=config.ANNOTATION_TYPE, - annotations=[(annotation.file, annotation.line_start, annotation.line_end) for annotation in annotations], - ) - try: github.post_comment( github=gh, diff --git a/codecov/settings.py b/codecov/settings.py index 7a29a83..b7c1146 100644 --- a/codecov/settings.py +++ b/codecov/settings.py @@ -50,8 +50,10 @@ class Config: SUBPROJECT_ID: str | None = None MINIMUM_GREEN: decimal.Decimal = decimal.Decimal('100') MINIMUM_ORANGE: decimal.Decimal = decimal.Decimal('70') + SKIP_COVERAGE: bool = False ANNOTATE_MISSING_LINES: bool = False ANNOTATION_TYPE: str = 'warning' + ANNOTATIONS_OUTPUT_PATH: pathlib.Path | None = None MAX_FILES_IN_COMMENT: int = 25 COMPLETE_PROJECT_REPORT: bool = False COVERAGE_REPORT_URL: str | None = None @@ -75,6 +77,10 @@ def clean_minimum_orange(cls, value: str) -> decimal.Decimal: def clean_annotate_missing_lines(cls, value: str) -> bool: return str_to_bool(value) + @classmethod + def clean_skip_coverage(cls, value: str) -> bool: + return str_to_bool(value) + @classmethod def clean_complete_project_report(cls, value: str) -> bool: return str_to_bool(value) @@ -99,6 +105,10 @@ def clean_github_pr_number(cls, value: str) -> int: def clean_coverage_path(cls, value: str) -> pathlib.Path: return path_below(value) + @classmethod + def clean_annotations_output_path(cls, value: str) -> pathlib.Path: + return pathlib.Path(value) + # We need to type environ as a MutableMapping because that's what # os.environ is, and `dict[str, str]` is not enough @classmethod