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

1"""Fix suggestion rendering for terminal, GitHub Actions, and Markdown.""" 

2 

3from __future__ import annotations 

4 

5import html 

6import io 

7from collections import defaultdict 

8from collections.abc import Sequence 

9 

10from rich.console import Console, Group, RenderableType 

11from rich.markup import escape 

12from rich.panel import Panel 

13 

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 

25 

26 

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. 

34 

35 Uses Rich Panels per error-code group, matching the interactive 

36 fix review style. 

37 

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. 

42 

43 Returns: 

44 Formatted string for terminal display. 

45 """ 

46 if not suggestions: 

47 return "" 

48 

49 buf = io.StringIO() 

50 console = Console( 

51 file=buf, 

52 force_terminal=True, 

53 highlight=False, 

54 width=BORDER_LENGTH, 

55 ) 

56 

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) 

62 

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 "" 

67 

68 print_section_header( 

69 console, 

70 "\U0001f916", 

71 label, 

72 detail, 

73 cost_info=cost_info, 

74 ) 

75 

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) 

80 

81 total_groups = len(groups) 

82 for gi, (code, fixes) in enumerate(groups.items(), 1): 

83 parts: list[RenderableType] = [] 

84 

85 explanation = fixes[0].explanation or "" 

86 if explanation: 

87 parts.append(f"[cyan]{escape(explanation)}[/cyan]") 

88 

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 ) 

100 

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 ) 

116 

117 return buf.getvalue() 

118 

119 

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. 

127 

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. 

132 

133 Returns: 

134 Formatted string with GitHub Actions group markers. 

135 """ 

136 if not suggestions: 

137 return "" 

138 

139 lines: list[str] = [] 

140 

141 if tool_name: 

142 lines.append(f"### AI Fix Suggestions ({tool_name})") 

143 lines.append("") 

144 

145 total_cost = 0.0 

146 

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}" 

152 

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 ) 

161 

162 if fix.diff: 

163 sanitized_diff = fix.diff.replace("```", "``\u200b`") 

164 lines.append("```diff") 

165 lines.append(sanitized_diff) 

166 lines.append("```") 

167 

168 lines.append(f"Confidence: {fix.confidence}") 

169 lines.append("::endgroup::") 

170 

171 if show_cost and total_cost > 0: 

172 lines.append(f"AI cost: {format_cost(total_cost)}") 

173 

174 return "\n".join(lines) 

175 

176 

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. 

184 

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. 

189 

190 Returns: 

191 Markdown-formatted string. 

192 """ 

193 if not suggestions: 

194 return "" 

195 

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("") 

202 

203 total_cost = 0.0 

204 

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 += "`" 

212 

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 "" 

215 

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("") 

221 

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("") 

228 

229 lines.append(f"Confidence: {fix.confidence}") 

230 lines.append("") 

231 lines.append("</details>") 

232 lines.append("") 

233 

234 if show_cost and total_cost > 0: 

235 lines.append(f"*AI cost: {format_cost(total_cost)}*") 

236 

237 return "\n".join(lines) 

238 

239 

240def _risk_to_annotation_level(risk_level: str) -> str: 

241 """Map AI risk level to a GitHub Actions annotation level. 

242 

243 Args: 

244 risk_level: Risk classification from the AI fix suggestion 

245 (e.g. ``"behavioral-risk"``, ``"safe-style"``). 

246 

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" 

262 

263 

264def _escape_annotation(value: str) -> str: 

265 """Escape special characters for GitHub Actions annotation messages. 

266 

267 Args: 

268 value: Raw string to escape. 

269 

270 Returns: 

271 Escaped string safe for workflow command messages. 

272 """ 

273 return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") 

274 

275 

276def _escape_property(value: str) -> str: 

277 """Escape a value for use inside a GitHub Actions annotation property. 

278 

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. 

282 

283 Args: 

284 value: Raw property value. 

285 

286 Returns: 

287 Escaped string safe for annotation property positions. 

288 """ 

289 return _escape_annotation(value).replace(",", "%2C").replace(":", "%3A") 

290 

291 

292def render_fixes_annotations(suggestions: Sequence[AIFixSuggestion]) -> str: 

293 """Emit GitHub Actions annotation commands for fix suggestions. 

294 

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. 

298 

299 Mapping: 

300 - ``high`` / ``critical`` -> ``::error`` 

301 - ``medium`` / ``behavioral-risk`` -> ``::warning`` 

302 - ``low`` / ``safe-style`` -> ``::notice`` 

303 - (unset) -> ``::warning`` (default) 

304 

305 Args: 

306 suggestions: Fix suggestions to annotate. 

307 

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) 

315 

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}") 

321 

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])}") 

332 

333 props_str = ",".join(props) 

334 

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 ) 

341 

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) 

347 

348 

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. 

357 

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. 

361 

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. 

365 

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. 

373 

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 )