code-review-ai-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/formatter.py ADDED
@@ -0,0 +1,474 @@
1
+ """
2
+ Formatting Module - AI Code Review
3
+ ====================================
4
+ Responsible for formatting and presenting review results.
5
+ Supports terminal output (with colors), Markdown and JSON.
6
+ Includes specific formatting for PRs and structured comments.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ from datetime import datetime
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # ANSI color codes for terminal output
17
+ # ---------------------------------------------------------------------------
18
+ class Colors:
19
+ """ANSI colors for terminal output."""
20
+ RESET = "\033[0m"
21
+ BOLD = "\033[1m"
22
+ DIM = "\033[2m"
23
+ UNDERLINE = "\033[4m"
24
+
25
+ RED = "\033[31m"
26
+ GREEN = "\033[32m"
27
+ YELLOW = "\033[33m"
28
+ BLUE = "\033[34m"
29
+ MAGENTA = "\033[35m"
30
+ CYAN = "\033[36m"
31
+ WHITE = "\033[37m"
32
+
33
+ BG_RED = "\033[41m"
34
+ BG_GREEN = "\033[42m"
35
+ BG_BLUE = "\033[44m"
36
+
37
+ @classmethod
38
+ def disable(cls):
39
+ """Disables colors (for output without ANSI support)."""
40
+ for attr in dir(cls):
41
+ if attr.isupper() and not attr.startswith("_"):
42
+ setattr(cls, attr, "")
43
+
44
+
45
+ def _supports_color() -> bool:
46
+ """Checks whether the terminal supports colors."""
47
+ if os.environ.get("NO_COLOR"):
48
+ return False
49
+ if os.environ.get("FORCE_COLOR"):
50
+ return True
51
+ if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty():
52
+ return False
53
+ if sys.platform == "win32":
54
+ return os.environ.get("TERM") == "xterm" or os.environ.get("WT_SESSION")
55
+ return True
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Main Formatter
60
+ # ---------------------------------------------------------------------------
61
+ class ReviewFormatter:
62
+ """Formats and presents the review result."""
63
+
64
+ def __init__(self, color: bool = True, output_format: str = "terminal"):
65
+ self.output_format = output_format
66
+ if not color or not _supports_color():
67
+ Colors.disable()
68
+
69
+ def format_header(self, review_type: str, repo_name: str = "",
70
+ branch: str = "", extra_info: str = "") -> str:
71
+ """Formats the review header."""
72
+ if self.output_format == "terminal":
73
+ return self._terminal_header(review_type, repo_name, branch, extra_info)
74
+ elif self.output_format == "markdown":
75
+ return self._markdown_header(review_type, repo_name, branch, extra_info)
76
+ return ""
77
+
78
+ def format_files_summary(self, files: list[dict]) -> str:
79
+ """Formats the changed files summary."""
80
+ if self.output_format == "terminal":
81
+ return self._terminal_files_summary(files)
82
+ elif self.output_format == "markdown":
83
+ return self._markdown_files_summary(files)
84
+ return ""
85
+
86
+ def format_review(self, review_text: str) -> str:
87
+ """Formats the review text."""
88
+ if self.output_format == "terminal":
89
+ return self._terminal_review(review_text)
90
+ elif self.output_format == "markdown":
91
+ return review_text
92
+ elif self.output_format == "json":
93
+ return json.dumps({
94
+ "review": review_text,
95
+ "timestamp": datetime.now().isoformat(),
96
+ }, indent=2, ensure_ascii=False)
97
+ return review_text
98
+
99
+ def format_footer(self, truncated: bool = False) -> str:
100
+ """Formats the review footer."""
101
+ if self.output_format == "terminal":
102
+ return self._terminal_footer(truncated)
103
+ elif self.output_format == "markdown":
104
+ return self._markdown_footer(truncated)
105
+ return ""
106
+
107
+ def format_error(self, error_msg: str) -> str:
108
+ """Formats an error message."""
109
+ if self.output_format == "terminal":
110
+ return (
111
+ f"\n{Colors.RED}{Colors.BOLD}❌ ERROR{Colors.RESET}\n"
112
+ f"{Colors.RED}{error_msg}{Colors.RESET}\n"
113
+ )
114
+ return f"\n❌ **ERROR**: {error_msg}\n"
115
+
116
+ def format_warning(self, warning_msg: str) -> str:
117
+ """Formats a warning message."""
118
+ if self.output_format == "terminal":
119
+ return f"{Colors.YELLOW}⚠️ {warning_msg}{Colors.RESET}"
120
+ return f"⚠️ {warning_msg}"
121
+
122
+ def format_info(self, info_msg: str) -> str:
123
+ """Formats an informational message."""
124
+ if self.output_format == "terminal":
125
+ return f"{Colors.CYAN}ℹ️ {info_msg}{Colors.RESET}"
126
+ return f"ℹ️ {info_msg}"
127
+
128
+ def format_progress(self, step: str) -> str:
129
+ """Formats a progress message."""
130
+ if self.output_format == "terminal":
131
+ return f"{Colors.DIM}⏳ {step}...{Colors.RESET}"
132
+ return f"⏳ {step}..."
133
+
134
+ def format_success(self, msg: str) -> str:
135
+ """Formats a success message."""
136
+ if self.output_format == "terminal":
137
+ return f"{Colors.GREEN}✅ {msg}{Colors.RESET}"
138
+ return f"✅ {msg}"
139
+
140
+ # ------------------------------------------------------------------
141
+ # Pull Request formatting
142
+ # ------------------------------------------------------------------
143
+ def format_pr_list(self, prs: list[dict], title: str = "Pull Requests") -> str:
144
+ """Formats the Pull Requests list for the terminal."""
145
+ c = Colors
146
+ if not prs:
147
+ return f"\n{c.DIM}No Pull Requests found.{c.RESET}\n"
148
+
149
+ lines = [f"\n{c.BOLD}📋 {title} ({len(prs)}):{c.RESET}\n"]
150
+
151
+ for i, pr in enumerate(prs, 1):
152
+ draft = f" {c.DIM}[DRAFT]{c.RESET}" if pr.get("is_draft") else ""
153
+ lines.append(
154
+ f" {c.CYAN}{i:>3}){c.RESET} "
155
+ f"{c.BOLD}#{pr['id']:<6}{c.RESET} "
156
+ f"{c.WHITE}{pr['title'][:55]:<55}{c.RESET}{draft}"
157
+ )
158
+ lines.append(
159
+ f" {c.GREEN}{pr['source_branch']}{c.RESET} → "
160
+ f"{c.YELLOW}{pr['target_branch']}{c.RESET} "
161
+ f"{c.DIM}by {pr['author']} ({pr['repository']}){c.RESET}"
162
+ )
163
+
164
+ # Show reviewers if present
165
+ reviewers = pr.get("reviewers", [])
166
+ if reviewers:
167
+ reviewer_strs = [
168
+ f"{r['vote_label']} {r['name']}" for r in reviewers[:3]
169
+ ]
170
+ if len(reviewers) > 3:
171
+ reviewer_strs.append(f"+{len(reviewers)-3} more")
172
+ lines.append(
173
+ f" {c.DIM}Reviewers: {', '.join(reviewer_strs)}{c.RESET}"
174
+ )
175
+ lines.append("")
176
+
177
+ return "\n".join(lines)
178
+
179
+ def format_pr_details(self, pr: dict) -> str:
180
+ """Formats PR details for the terminal."""
181
+ c = Colors
182
+ width = 60
183
+ line = "─" * width
184
+
185
+ output = f"\n{c.BLUE}{c.BOLD}{line}{c.RESET}\n"
186
+ output += f"{c.BOLD} 📝 Pull Request #{pr['id']}{c.RESET}\n"
187
+ output += f"{c.BLUE}{line}{c.RESET}\n"
188
+ output += f"{c.CYAN} Title: {c.WHITE}{pr['title']}{c.RESET}\n"
189
+ output += f"{c.CYAN} Author: {c.WHITE}{pr['author']}{c.RESET}\n"
190
+ output += (
191
+ f"{c.CYAN} Branch: {c.GREEN}{pr['source_branch']}{c.RESET}"
192
+ f" → {c.YELLOW}{pr['target_branch']}{c.RESET}\n"
193
+ )
194
+ output += f"{c.CYAN} Repository: {c.WHITE}{pr['repository']}{c.RESET}\n"
195
+ output += f"{c.CYAN} Status: {c.WHITE}{pr['status']}{c.RESET}\n"
196
+
197
+ if pr.get("description"):
198
+ desc = pr["description"][:200]
199
+ if len(pr["description"]) > 200:
200
+ desc += "..."
201
+ output += f"{c.CYAN} Description:{c.DIM}{desc}{c.RESET}\n"
202
+
203
+ # Commits
204
+ commits = pr.get("commits", [])
205
+ if commits:
206
+ output += f"\n{c.BOLD} 📦 Commits ({len(commits)}):{c.RESET}\n"
207
+ for cm in commits[:10]:
208
+ output += (
209
+ f" {c.YELLOW}{cm['short_id']}{c.RESET} "
210
+ f"{cm['message'][:50]} "
211
+ f"{c.DIM}({cm['author']}){c.RESET}\n"
212
+ )
213
+ if len(commits) > 10:
214
+ output += f" {c.DIM}... +{len(commits)-10} more commits{c.RESET}\n"
215
+
216
+ # Changed files
217
+ changed_files = pr.get("changed_files", [])
218
+ if changed_files:
219
+ output += f"\n{c.BOLD} 📁 Changed Files ({len(changed_files)}):{c.RESET}\n"
220
+ for f in changed_files[:20]:
221
+ change_icon = {"add": "🟢", "edit": "🟡", "delete": "🔴",
222
+ "rename": "🔵"}.get(f["change_type"], "⚪")
223
+ output += f" {change_icon} {f['path']}\n"
224
+ if len(changed_files) > 20:
225
+ output += f" {c.DIM}... +{len(changed_files)-20} more files{c.RESET}\n"
226
+
227
+ output += f"\n{c.BLUE}{line}{c.RESET}\n"
228
+ return output
229
+
230
+ def format_structured_comments(self, comments: list[dict],
231
+ discarded_count: int = 0) -> str:
232
+ """Formats LLM structured comments for terminal preview."""
233
+ c = Colors
234
+ if not comments:
235
+ if discarded_count > 0:
236
+ return (
237
+ f"\n{c.YELLOW}⚠ {discarded_count} comment(s) discarded "
238
+ f"due to missing file/line in diff_only mode.{c.RESET}\n"
239
+ )
240
+ return f"\n{c.DIM}No comments generated.{c.RESET}\n"
241
+
242
+ severity_colors = {
243
+ "critical": c.RED + c.BOLD,
244
+ "high": c.RED,
245
+ "medium": c.YELLOW,
246
+ "low": c.GREEN,
247
+ "info": c.CYAN,
248
+ }
249
+ severity_icons = {
250
+ "critical": "🔴",
251
+ "high": "🟠",
252
+ "medium": "🟡",
253
+ "low": "🟢",
254
+ "info": "ℹ️",
255
+ }
256
+ type_labels = {
257
+ "bug": "🐛 Bug",
258
+ "security": "🔒 Security",
259
+ "performance": "⚡ Performance",
260
+ "style": "📝 Style",
261
+ "suggestion": "💡 Suggestion",
262
+ "praise": "👍 Positive",
263
+ }
264
+
265
+ lines = [f"\n{c.BOLD}🤖 AI Review Comments ({len(comments)}):{c.RESET}\n"]
266
+
267
+ if discarded_count > 0:
268
+ lines.append(
269
+ f" {c.YELLOW}⚠ {discarded_count} comment(s) discarded "
270
+ f"due to missing file/line in diff_only mode.{c.RESET}"
271
+ )
272
+ lines.append("")
273
+
274
+ for i, comment in enumerate(comments, 1):
275
+ severity = comment.get("severity", "info")
276
+ comment_type = comment.get("type", "suggestion")
277
+ sev_color = severity_colors.get(severity, c.WHITE)
278
+ icon = severity_icons.get(severity, "ℹ️")
279
+ label = type_labels.get(comment_type, comment_type.title())
280
+
281
+ file_info = ""
282
+ if comment.get("file"):
283
+ file_info = f"{c.DIM}{comment['file']}"
284
+ if comment.get("line", 0) > 0:
285
+ file_info += f":{comment['line']}"
286
+ file_info += f"{c.RESET} "
287
+
288
+ lines.append(
289
+ f" {c.CYAN}{i:>3}){c.RESET} "
290
+ f"{icon} {sev_color}{label} ({severity.upper()}){c.RESET}"
291
+ )
292
+ if file_info:
293
+ lines.append(f" {file_info}")
294
+ lines.append(f" {comment.get('comment', '')}")
295
+
296
+ suggestion = comment.get("suggestion", "")
297
+ if suggestion:
298
+ lines.append(f" {c.GREEN}💡 {suggestion}{c.RESET}")
299
+
300
+ reference = comment.get("reference", "")
301
+ if reference:
302
+ lines.append(f" {c.DIM}📚 Reference: {reference}{c.RESET}")
303
+
304
+ lines.append("")
305
+
306
+ return "\n".join(lines)
307
+
308
+ def format_post_results(self, results: list[dict]) -> str:
309
+ """Formats the results of posting comments to the PR."""
310
+ c = Colors
311
+ lines = [f"\n{c.BOLD}📤 Comment posting results:{c.RESET}\n"]
312
+
313
+ success_count = sum(1 for r in results if r.get("success"))
314
+ fail_count = len(results) - success_count
315
+
316
+ for r in results:
317
+ if r.get("success"):
318
+ file_info = r.get("file", "geral")
319
+ if r.get("line", 0) > 0:
320
+ file_info += f":{r['line']}"
321
+ lines.append(
322
+ f" {c.GREEN}✅ Posted at {file_info} "
323
+ f"(thread #{r.get('thread_id', '?')}){c.RESET}"
324
+ )
325
+ elif r.get("skipped"):
326
+ lines.append(
327
+ f" {c.YELLOW}⚠ Skipped at {r.get('file', 'general')}: "
328
+ f"{r.get('error', 'no reason')}{c.RESET}"
329
+ )
330
+ else:
331
+ lines.append(
332
+ f" {c.RED}❌ Failed at {r.get('file', 'general')}: "
333
+ f"{r.get('error', 'unknown error')}{c.RESET}"
334
+ )
335
+
336
+ lines.append(
337
+ f"\n {c.BOLD}Total: "
338
+ f"{c.GREEN}{success_count} posted{c.RESET}, "
339
+ f"{c.RED}{fail_count} failed{c.RESET}"
340
+ )
341
+ return "\n".join(lines)
342
+
343
+ # ------------------------------------------------------------------
344
+ # Spinner / Progress helpers
345
+ # ------------------------------------------------------------------
346
+ def format_spinner_frame(self, step: str, frame: int) -> str:
347
+ """Returns a spinner frame for progress display."""
348
+ c = Colors
349
+ spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
350
+ spinner = spinners[frame % len(spinners)]
351
+ return f"\r{c.CYAN}{spinner} {step}...{c.RESET}"
352
+
353
+ # ------------------------------------------------------------------
354
+ # Terminal formatting
355
+ # ------------------------------------------------------------------
356
+ def _terminal_header(self, review_type: str, repo_name: str,
357
+ branch: str, extra_info: str) -> str:
358
+ c = Colors
359
+ width = 60
360
+ line = "═" * width
361
+
362
+ header = f"\n{c.BLUE}{c.BOLD}{line}{c.RESET}\n"
363
+ header += f"{c.BLUE}{c.BOLD} 🤖 AI CODE REVIEW{c.RESET}\n"
364
+ header += f"{c.BLUE}{line}{c.RESET}\n"
365
+
366
+ header += f"{c.CYAN} Type: {c.WHITE}{review_type}{c.RESET}\n"
367
+ if repo_name:
368
+ header += f"{c.CYAN} Repository: {c.WHITE}{repo_name}{c.RESET}\n"
369
+ if branch:
370
+ header += f"{c.CYAN} Branch: {c.WHITE} {branch}{c.RESET}\n"
371
+ if extra_info:
372
+ header += f"{c.CYAN} {extra_info}{c.RESET}\n"
373
+
374
+ header += f"{c.CYAN} Date: {c.WHITE} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}{c.RESET}\n"
375
+ header += f"{c.BLUE}{line}{c.RESET}\n"
376
+ return header
377
+
378
+ def _terminal_files_summary(self, files: list[dict]) -> str:
379
+ c = Colors
380
+ summary = f"\n{c.BOLD}📁 Changed Files ({len(files)}):{c.RESET}\n"
381
+
382
+ total_add = 0
383
+ total_del = 0
384
+ for f in files:
385
+ add = f["additions"]
386
+ delete = f["deletions"]
387
+ total_add += add
388
+ total_del += delete
389
+
390
+ bar_add = "+" * min(add, 20)
391
+ bar_del = "-" * min(delete, 20)
392
+ bar = f"{c.GREEN}{bar_add}{c.RED}{bar_del}{c.RESET}"
393
+
394
+ summary += (
395
+ f" {c.WHITE}{f['file']:<50}{c.RESET} "
396
+ f"{c.GREEN}+{add:<4}{c.RED}-{delete:<4}{c.RESET} {bar}\n"
397
+ )
398
+
399
+ summary += f"\n {c.BOLD}Total: {c.GREEN}+{total_add} {c.RED}-{total_del}{c.RESET}\n"
400
+ return summary
401
+
402
+ def _terminal_review(self, review_text: str) -> str:
403
+ c = Colors
404
+ output = f"\n{c.BOLD}{'─' * 60}{c.RESET}\n"
405
+ output += f"{c.BOLD}📝 REVIEW:{c.RESET}\n"
406
+ output += f"{c.BOLD}{'─' * 60}{c.RESET}\n\n"
407
+ output += review_text
408
+ output += "\n"
409
+ return output
410
+
411
+ def _terminal_footer(self, truncated: bool) -> str:
412
+ c = Colors
413
+ footer = f"\n{c.BLUE}{'═' * 60}{c.RESET}\n"
414
+ if truncated:
415
+ footer += (
416
+ f"{c.YELLOW}⚠️ The diff was truncated. For a full review, "
417
+ f"reduce the scope of changes.{c.RESET}\n"
418
+ )
419
+ footer += (
420
+ f"{c.DIM} Review generated by AI Code Review v2.0.0\n"
421
+ f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}{c.RESET}\n"
422
+ )
423
+ footer += f"{c.BLUE}{'═' * 60}{c.RESET}\n"
424
+ return footer
425
+
426
+ # ------------------------------------------------------------------
427
+ # Markdown formatting
428
+ # ------------------------------------------------------------------
429
+ def _markdown_header(self, review_type: str, repo_name: str,
430
+ branch: str, extra_info: str) -> str:
431
+ header = "# 🤖 AI Code Review\n\n"
432
+ header += f"| Field | Value |\n|-------|-------|\n"
433
+ header += f"| **Type** | {review_type} |\n"
434
+ if repo_name:
435
+ header += f"| **Repository** | {repo_name} |\n"
436
+ if branch:
437
+ header += f"| **Branch** | {branch} |\n"
438
+ if extra_info:
439
+ header += f"| **Info** | {extra_info} |\n"
440
+ header += f"| **Data** | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} |\n"
441
+ header += "\n---\n\n"
442
+ return header
443
+
444
+ def _markdown_files_summary(self, files: list[dict]) -> str:
445
+ summary = "## 📁 Changed Files\n\n"
446
+ summary += "| File | Additions | Deletions |\n"
447
+ summary += "|------|-----------|----------|\n"
448
+ for f in files:
449
+ summary += f"| `{f['file']}` | +{f['additions']} | -{f['deletions']} |\n"
450
+ summary += "\n"
451
+ return summary
452
+
453
+ def _markdown_footer(self, truncated: bool) -> str:
454
+ footer = "\n---\n\n"
455
+ if truncated:
456
+ footer += "> ⚠️ **Note**: The diff was truncated due to size.\n\n"
457
+ footer += (
458
+ f"*Review generated by AI Code Review v2.0.0 at "
459
+ f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n"
460
+ )
461
+ return footer
462
+
463
+
464
+ def save_output(content: str, file_path: str) -> None:
465
+ """
466
+ Saves the review output to a file.
467
+ """
468
+ try:
469
+ os.makedirs(os.path.dirname(os.path.abspath(file_path)), exist_ok=True)
470
+ with open(file_path, "w", encoding="utf-8") as f:
471
+ f.write(content)
472
+ print(f"✅ Review saved to: {file_path}")
473
+ except OSError as exc:
474
+ print(f"❌ Error saving file: {exc}")