Coverage for lintro / formatters / styles / github.py: 96%
52 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""GitHub Actions workflow-command output style.
3Emits ``::error``, ``::warning``, and ``::notice`` annotations that GitHub
4Actions surfaces inline on pull-request diffs.
6Reference: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#setting-a-warning-message
7"""
9from __future__ import annotations
11import contextlib
12from typing import Any
14from lintro.enums.severity_level import SeverityLevel, normalize_severity_level
15from lintro.formatters.core.format_registry import OutputStyle
17_SEVERITY_TO_COMMAND: dict[SeverityLevel, str] = {
18 SeverityLevel.ERROR: "error",
19 SeverityLevel.WARNING: "warning",
20 SeverityLevel.INFO: "notice",
21}
24def _escape(value: str) -> str:
25 """Escape special characters for GitHub Actions workflow commands.
27 Args:
28 value: Raw string to escape.
30 Returns:
31 Escaped string safe for workflow command messages.
32 """
33 return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
36def _cell(
37 name: str,
38 col_index: dict[str, int],
39 row: list[Any],
40) -> str:
41 """Extract a cell value from a row by column name.
43 Args:
44 name: Lowercase column name to look up.
45 col_index: Mapping of column names to indices.
46 row: The current row of values.
48 Returns:
49 Cell value as string, or empty string if not found.
50 """
51 idx = col_index.get(name)
52 if idx is None or idx >= len(row):
53 return ""
54 return str(row[idx]) if row[idx] else ""
57class GitHubStyle(OutputStyle):
58 """Output style that emits GitHub Actions annotation commands."""
60 def format(
61 self,
62 columns: list[str],
63 rows: list[list[Any]],
64 tool_name: str | None = None,
65 **kwargs: Any,
66 ) -> str:
67 """Format rows as GitHub Actions annotation commands.
69 Args:
70 columns: List of column header names.
71 rows: List of row values (each row is a list of cell values).
72 tool_name: Name of the tool that generated the data.
73 **kwargs: Extra options (ignored).
75 Returns:
76 One annotation command per line.
77 """
78 if not rows:
79 return ""
81 # Build a case-insensitive column-name → index map
82 col_index: dict[str, int] = {
83 col.lower().replace(" ", "_"): i for i, col in enumerate(columns)
84 }
86 lines: list[str] = []
87 for row in rows:
88 file_val = _cell("file", col_index, row)
89 line_val = _cell("line", col_index, row)
90 col_val = _cell("column", col_index, row)
91 code_val = _cell("code", col_index, row)
92 severity_val = _cell("severity", col_index, row)
93 message_val = _cell("message", col_index, row)
95 # Resolve severity → GitHub command level
96 level = "warning" # default
97 if severity_val:
98 with contextlib.suppress(ValueError, KeyError):
99 level = _SEVERITY_TO_COMMAND[normalize_severity_level(severity_val)]
101 # Build the properties portion
102 props: list[str] = []
103 if file_val:
104 props.append(f"file={file_val}")
105 if line_val and line_val != "-":
106 props.append(f"line={line_val}")
107 if col_val and col_val != "-":
108 props.append(f"col={col_val}")
110 # Title: "tool(CODE)" or just "tool"
111 title_parts: list[str] = []
112 if tool_name:
113 title_parts.append(tool_name)
114 if code_val:
115 if title_parts:
116 title_parts[-1] += f"({code_val})"
117 else:
118 title_parts.append(code_val)
119 if title_parts:
120 props.append(f"title={title_parts[0]}")
122 props_str = ",".join(props)
123 escaped_msg = _escape(message_val)
125 if props_str:
126 lines.append(f"::{level} {props_str}::{escaped_msg}")
127 else:
128 lines.append(f"::{level}::{escaped_msg}")
130 return "\n".join(lines)