|
11 | 11 |
|
12 | 12 | from snakeoil.formatters import Formatter as snakeoil_Formatter |
13 | 13 | from snakeoil.klass import immutable |
| 14 | +from snakeoil.mappings import ImmutableDict |
14 | 15 |
|
15 | 16 | from . import base |
16 | 17 | from .results import BaseLinesResult, InvalidResult, Result |
@@ -379,6 +380,77 @@ def _consume_reports_generator(self) -> T_process_report: |
379 | 380 | self.out.write(f"{file}:{lineno}:{result.level}:{message}") |
380 | 381 |
|
381 | 382 |
|
| 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 | + |
382 | 454 | class CallbackReporter(Reporter): |
383 | 455 | """Reporter that calls back for every result""" |
384 | 456 |
|
|
0 commit comments