Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
103 changes: 57 additions & 46 deletions codecov/github.py
Original file line number Diff line number Diff line change
@@ -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]'

Expand All @@ -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
Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions codecov/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
27 changes: 0 additions & 27 deletions codecov/log_utils.py

This file was deleted.

46 changes: 28 additions & 18 deletions codecov/main.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
# -*- 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,
headers={'Authorization': f'token {config.GITHUB_TOKEN}'},
)

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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions codecov/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down