Skip to content

Commit b90d978

Browse files
committed
reporters: add GithubReporter
Emit results as GitHub Actions workflow commands, so a CI scan renders them as error/warning/notice annotations on the run summary and inline in the pull request diff. Signed-off-by: Arthur Zamarin <arthurzam@gentoo.org>
1 parent fce02f3 commit b90d978

3 files changed

Lines changed: 107 additions & 0 deletions

File tree

‎NEWS.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ pkgcheck 0.10.45 (unreleased)
2525
others, instead of alone on the main process once the rest had finished
2626
(Arthur Zamarin)
2727

28+
- New ``GithubReporter``, emitting results as GitHub Actions workflow commands
29+
so they render as error/warning/notice annotations on the run summary and
30+
inline in pull request diffs (Arthur Zamarin)
31+
2832
**Fixes:**
2933

3034
- ``pkgcheck scan``: fail the scan when a worker process dies from a signal

‎src/pkgcheck/reporters.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from snakeoil.formatters import Formatter as snakeoil_Formatter
1313
from snakeoil.klass import immutable
14+
from snakeoil.mappings import ImmutableDict
1415

1516
from . import base
1617
from .results import BaseLinesResult, InvalidResult, Result
@@ -379,6 +380,77 @@ def _consume_reports_generator(self) -> T_process_report:
379380
self.out.write(f"{file}:{lineno}:{result.level}:{message}")
380381

381382

383+
class GithubReporter(StreamReporter):
384+
"""Reporter formatting results as GitHub Actions workflow commands [#]_.
385+
386+
Results are emitted as error, warning, or notice annotations anchored to
387+
the relevant file and line, so they show up on the workflow run summary
388+
and inline in pull request diffs.
389+
390+
.. [#] https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions
391+
"""
392+
393+
__slots__ = ()
394+
priority = -1001
395+
396+
# result level to annotation level mapping, anything else is a notice
397+
level_map = ImmutableDict({"error": "error", "warning": "warning"})
398+
# scope to repo relative path mapping
399+
path_map = ImmutableDict(
400+
{
401+
base.version_scope: "{category}/{package}/{package}-{version}.ebuild",
402+
# results naming a file relative to the pkg/category dir point at it, the
403+
# rest fall back to the dir itself once the trailing slash is stripped
404+
base.package_scope: "{category}/{package}/{filename}",
405+
base.category_scope: "{category}/{filename}",
406+
base.eclass_scope: "eclass/{eclass}.eclass",
407+
base.profiles_scope: "profiles/{path}",
408+
base.profile_node_scope: "profiles/{path}",
409+
}
410+
)
411+
412+
@staticmethod
413+
def _escape(s: str) -> str:
414+
return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
415+
416+
@classmethod
417+
def _escape_property(cls, s: str) -> str:
418+
return cls._escape(s).replace(":", "%3A").replace(",", "%2C")
419+
420+
def _consume_reports_generator(self) -> T_process_report:
421+
while True:
422+
result = yield
423+
level = self.level_map.get(result.level, "notice")
424+
425+
file_prop = ()
426+
if template := self.path_map.get(result.scope):
427+
# missing attrs collapse to empty strings, leaving the enclosing dir
428+
path = template.format_map(defaultdict(str, vars(result))).rstrip("/")
429+
if path:
430+
file_prop = (f"file={self._escape_property(path)}",)
431+
title_prop = f"title={self._escape_property(result.name)}"
432+
433+
desc = result.desc
434+
if isinstance(result, BaseLinesResult):
435+
# the line numbers are carried by the annotations themselves
436+
desc = desc.replace(result.lines_str, "").strip()
437+
linenos = result.lines
438+
elif lineno := getattr(result, "lineno", 0):
439+
linenos = (lineno,)
440+
else:
441+
linenos = ()
442+
443+
msg = self._escape(desc)
444+
if linenos:
445+
for lineno in linenos:
446+
props = ",".join(file_prop + (f"line={lineno}", title_prop))
447+
self.out.write(f"::{level} {props}::{msg}")
448+
else:
449+
props = ",".join(file_prop + (title_prop,))
450+
self.out.write(f"::{level} {props}::{msg}")
451+
self.out.stream.flush()
452+
453+
382454
class CallbackReporter(Reporter):
383455
"""Reporter that calls back for every result"""
384456

‎tests/test_reporters.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,37 @@ class TestFlycheckReporter(BaseReporter):
219219
)
220220

221221

222+
class TestGithubReporter(BaseReporter):
223+
reporter_cls = reporters.GithubReporter
224+
add_report_output = dedent(
225+
"""\
226+
::warning file=profiles,title=ProfileWarning::profile warning
227+
::error file=profiles,title=ProfileError::profile error
228+
::notice title=InvalidCommitMessage::commit 8d86269bb4c7: no commit message
229+
::error file=dev-libs/metadata.xml,title=CatMissingMetadataXml::category is missing metadata.xml
230+
::error file=dev-libs/foo,title=InvalidPN::invalid package names: [ bar, baz ]
231+
::warning file=dev-libs/foo/foo-0.ebuild,title=BadFilename::bad filenames: [ 0.tar.gz, foo.tar.gz ]
232+
::warning file=dev-libs/foo/foo-0.ebuild,line=7,title=ReadonlyVariable::read-only variable 'P' assigned, line 7: P=6
233+
::warning file=dev-libs/foo/foo-0.ebuild,line=5,title=UnquotedVariable::unquoted variable D
234+
::warning file=dev-libs/foo/foo-0.ebuild,line=7,title=UnquotedVariable::unquoted variable D
235+
"""
236+
)
237+
238+
def test_escapes(self, capsys):
239+
result = metadata_xml.PkgInvalidXml(
240+
"metadata.xml", "line 1: 100% bad\nline 2:", pkg=self.pkg
241+
)
242+
with self.mk_reporter() as report:
243+
report(result)
244+
out, err = capsys.readouterr()
245+
assert not err
246+
# colons are only escaped in property values, newlines and percents everywhere
247+
assert out == (
248+
"::error file=dev-libs/foo/metadata.xml,title=PkgInvalidXml::"
249+
"package metadata.xml violates metadata.xsd:%0Aline 1: 100%25 bad%0Aline 2:\n"
250+
)
251+
252+
222253
class TestCallbackReporter:
223254
results = BaseReporter.results
224255

0 commit comments

Comments
 (0)