Coverage for lintro / ai / display / fixes.py: 56%
163 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"""Fix suggestion rendering for terminal, GitHub Actions, and Markdown."""
3from __future__ import annotations
5import html
6import io
7from collections import defaultdict
8from collections.abc import Sequence
10from rich.console import Console, Group, RenderableType
11from rich.markup import escape
12from rich.panel import Panel
14from lintro.ai.cost import format_cost
15from lintro.ai.display.shared import (
16 cost_str,
17 is_github_actions,
18 print_code_panel,
19 print_section_header,
20)
21from lintro.ai.enums import RiskLevel
22from lintro.ai.models import AIFixSuggestion
23from lintro.ai.paths import relative_path
24from lintro.utils.console.constants import BORDER_LENGTH
27def render_fixes_terminal(
28 suggestions: Sequence[AIFixSuggestion],
29 *,
30 tool_name: str = "",
31 show_cost: bool = True,
32) -> str:
33 """Render fix suggestions for terminal output.
35 Uses Rich Panels per error-code group, matching the interactive
36 fix review style.
38 Args:
39 suggestions: Fix suggestions to render.
40 tool_name: Name of the tool these suggestions are for.
41 show_cost: Whether to show cost estimates.
43 Returns:
44 Formatted string for terminal display.
45 """
46 if not suggestions:
47 return ""
49 buf = io.StringIO()
50 console = Console(
51 file=buf,
52 force_terminal=True,
53 highlight=False,
54 width=BORDER_LENGTH,
55 )
57 # Compute totals up front for the header
58 count = len(suggestions)
59 total_input = sum(s.input_tokens for s in suggestions)
60 total_output = sum(s.output_tokens for s in suggestions)
61 total_cost = sum(s.cost_estimate for s in suggestions)
63 plural = "s" if count != 1 else ""
64 label = tool_name or "AI FIX SUGGESTIONS"
65 detail = f"{count} fix suggestion{plural}"
66 cost_info = cost_str(total_input, total_output, total_cost) if show_cost else ""
68 print_section_header(
69 console,
70 "\U0001f916",
71 label,
72 detail,
73 cost_info=cost_info,
74 )
76 # Group by code for Panel rendering
77 groups: dict[str, list[AIFixSuggestion]] = defaultdict(list)
78 for s in suggestions:
79 groups[s.code or "unknown"].append(s)
81 total_groups = len(groups)
82 for gi, (code, fixes) in enumerate(groups.items(), 1):
83 parts: list[RenderableType] = []
85 explanation = fixes[0].explanation or ""
86 if explanation:
87 parts.append(f"[cyan]{escape(explanation)}[/cyan]")
89 for fix in fixes:
90 loc = relative_path(fix.file)
91 if fix.line:
92 loc += f":{fix.line}"
93 parts.append(
94 Panel(
95 f"[green]{escape(loc)}[/green]",
96 border_style="dim",
97 padding=(0, 1),
98 ),
99 )
101 content: RenderableType = (
102 Group(*parts) if len(parts) > 1 else (parts[0] if parts else "")
103 )
104 # Use tool_name from first suggestion in group
105 group_tool = fixes[0].tool_name if fixes else ""
106 print_code_panel(
107 console,
108 code=code,
109 index=gi,
110 total=total_groups,
111 count=len(fixes),
112 count_label="file",
113 content=content,
114 tool_name=group_tool,
115 )
117 return buf.getvalue()
120def render_fixes_github(
121 suggestions: Sequence[AIFixSuggestion],
122 *,
123 tool_name: str = "",
124 show_cost: bool = True,
125) -> str:
126 """Render fix suggestions for GitHub Actions logs.
128 Args:
129 suggestions: Fix suggestions to render.
130 tool_name: Name of the tool these suggestions are for.
131 show_cost: Whether to show cost estimates.
133 Returns:
134 Formatted string with GitHub Actions group markers.
135 """
136 if not suggestions:
137 return ""
139 lines: list[str] = []
141 if tool_name:
142 lines.append(f"### AI Fix Suggestions ({tool_name})")
143 lines.append("")
145 total_cost = 0.0
147 for fix in suggestions:
148 total_cost += fix.cost_estimate
149 loc = relative_path(fix.file)
150 if fix.line:
151 loc += f":{fix.line}"
153 code_label = f" [{_escape_annotation(fix.code)}]" if fix.code else ""
154 tool_label = f" ({_escape_annotation(fix.tool_name)})" if fix.tool_name else ""
155 escaped_loc = _escape_annotation(loc)
156 escaped_explanation = _escape_annotation(fix.explanation or "")
157 lines.append(
158 f"::group::{escaped_loc}{code_label}{tool_label}"
159 f" \u2014 {escaped_explanation}",
160 )
162 if fix.diff:
163 sanitized_diff = fix.diff.replace("```", "``\u200b`")
164 lines.append("```diff")
165 lines.append(sanitized_diff)
166 lines.append("```")
168 lines.append(f"Confidence: {fix.confidence}")
169 lines.append("::endgroup::")
171 if show_cost and total_cost > 0:
172 lines.append(f"AI cost: {format_cost(total_cost)}")
174 return "\n".join(lines)
177def render_fixes_markdown(
178 suggestions: Sequence[AIFixSuggestion],
179 *,
180 tool_name: str = "",
181 show_cost: bool = True,
182) -> str:
183 """Render fix suggestions as Markdown with collapsible diffs.
185 Args:
186 suggestions: Fix suggestions to render.
187 tool_name: Name of the tool these suggestions are for.
188 show_cost: Whether to show cost estimates.
190 Returns:
191 Markdown-formatted string.
192 """
193 if not suggestions:
194 return ""
196 lines: list[str] = []
197 label = (
198 f"{tool_name} \u2014 AI Fix Suggestions" if tool_name else "AI Fix Suggestions"
199 )
200 lines.append(f"### {label}")
201 lines.append("")
203 total_cost = 0.0
205 for fix in suggestions:
206 total_cost += fix.cost_estimate
207 rel = html.escape(relative_path(fix.file))
208 loc = f"`{rel}"
209 if fix.line:
210 loc += f":{fix.line}"
211 loc += "`"
213 code_label = f" **[{html.escape(fix.code)}]**" if fix.code else ""
214 tool_label = f" ({html.escape(fix.tool_name)})" if fix.tool_name else ""
216 lines.append("<details>")
217 escaped_explanation = html.escape(fix.explanation) if fix.explanation else ""
218 summary_text = f"{loc}{code_label}{tool_label} \u2014 {escaped_explanation}"
219 lines.append(f"<summary>{summary_text}</summary>")
220 lines.append("")
222 if fix.diff:
223 sanitized_diff = fix.diff.replace("```", "``\u200b`")
224 lines.append("```diff")
225 lines.append(sanitized_diff)
226 lines.append("```")
227 lines.append("")
229 lines.append(f"Confidence: {fix.confidence}")
230 lines.append("")
231 lines.append("</details>")
232 lines.append("")
234 if show_cost and total_cost > 0:
235 lines.append(f"*AI cost: {format_cost(total_cost)}*")
237 return "\n".join(lines)
240def _risk_to_annotation_level(risk_level: str) -> str:
241 """Map AI risk level to a GitHub Actions annotation level.
243 Args:
244 risk_level: Risk classification from the AI fix suggestion
245 (e.g. ``"behavioral-risk"``, ``"safe-style"``).
247 Returns:
248 One of ``"error"``, ``"warning"``, or ``"notice"``.
249 """
250 normalized = risk_level.lower().strip() if risk_level else ""
251 try:
252 return RiskLevel(normalized).to_severity_label(sarif=False)
253 except ValueError:
254 pass
255 if normalized in {"high", "critical"}:
256 return "error"
257 if normalized in {"medium"}:
258 return "warning"
259 if normalized in {"low"}:
260 return "notice"
261 return "warning"
264def _escape_annotation(value: str) -> str:
265 """Escape special characters for GitHub Actions annotation messages.
267 Args:
268 value: Raw string to escape.
270 Returns:
271 Escaped string safe for workflow command messages.
272 """
273 return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
276def _escape_property(value: str) -> str:
277 """Escape a value for use inside a GitHub Actions annotation property.
279 Property values are delimited by ``,`` and terminated by ``::`` so
280 both characters must be percent-encoded in addition to the standard
281 message escapes.
283 Args:
284 value: Raw property value.
286 Returns:
287 Escaped string safe for annotation property positions.
288 """
289 return _escape_annotation(value).replace(",", "%2C").replace(":", "%3A")
292def render_fixes_annotations(suggestions: Sequence[AIFixSuggestion]) -> str:
293 """Emit GitHub Actions annotation commands for fix suggestions.
295 Each suggestion maps its ``risk_level`` to the appropriate annotation
296 level (``::error``, ``::warning``, or ``::notice``) and includes file,
297 line, and title properties so annotations appear inline on PR diffs.
299 Mapping:
300 - ``high`` / ``critical`` -> ``::error``
301 - ``medium`` / ``behavioral-risk`` -> ``::warning``
302 - ``low`` / ``safe-style`` -> ``::notice``
303 - (unset) -> ``::warning`` (default)
305 Args:
306 suggestions: Fix suggestions to annotate.
308 Returns:
309 Newline-joined annotation commands, or empty string if no
310 suggestions are provided.
311 """
312 lines: list[str] = []
313 for s in suggestions:
314 level = _risk_to_annotation_level(s.risk_level)
316 props: list[str] = []
317 if s.file:
318 props.append(f"file={_escape_property(relative_path(s.file))}")
319 if s.line:
320 props.append(f"line={s.line}")
322 title_parts: list[str] = []
323 if s.tool_name:
324 title_parts.append(s.tool_name)
325 if s.code:
326 if title_parts:
327 title_parts[-1] += f"({s.code})"
328 else:
329 title_parts.append(s.code)
330 if title_parts:
331 props.append(f"title={_escape_property(title_parts[0])}")
333 props_str = ",".join(props)
335 explanation = s.explanation or "AI fix available"
336 code_label = f" [{s.code}]" if s.code else ""
337 confidence_label = f" (confidence: {s.confidence})" if s.confidence else ""
338 msg = _escape_annotation(
339 f"AI fix available{code_label}: {explanation}{confidence_label}",
340 )
342 if props_str:
343 lines.append(f"::{level} {props_str}::{msg}")
344 else:
345 lines.append(f"::{level}::{msg}")
346 return "\n".join(lines)
349def render_fixes(
350 suggestions: Sequence[AIFixSuggestion],
351 *,
352 tool_name: str = "",
353 show_cost: bool = True,
354 output_format: str = "auto",
355) -> str:
356 """Render fixes using the appropriate format for the environment.
358 This is the public dispatcher for fix rendering, available for use
359 by future pipeline integrations. Currently used by the interactive
360 review loop and display modules.
362 When running inside GitHub Actions (auto-detected), annotations are
363 appended to the rendered output so they appear as inline warnings in
364 the Actions UI.
366 Args:
367 suggestions: Fix suggestions to render.
368 tool_name: Name of the tool these suggestions are for.
369 show_cost: Whether to show cost estimates.
370 output_format: Output format -- ``"auto"`` (default) selects
371 terminal or GitHub Actions based on environment,
372 ``"markdown"`` uses Markdown with collapsible diffs.
374 Returns:
375 Formatted fix string.
376 """
377 if output_format == "markdown":
378 return render_fixes_markdown(
379 suggestions,
380 tool_name=tool_name,
381 show_cost=show_cost,
382 )
383 if is_github_actions():
384 rendered = render_fixes_github(
385 suggestions,
386 tool_name=tool_name,
387 show_cost=show_cost,
388 )
389 annotations = render_fixes_annotations(suggestions)
390 if annotations:
391 rendered = rendered + "\n" + annotations if rendered else annotations
392 return rendered
393 return render_fixes_terminal(
394 suggestions,
395 tool_name=tool_name,
396 show_cost=show_cost,
397 )