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.
- code_review_ai_cli-1.0.0.dist-info/METADATA +441 -0
- code_review_ai_cli-1.0.0.dist-info/RECORD +13 -0
- code_review_ai_cli-1.0.0.dist-info/WHEEL +5 -0
- code_review_ai_cli-1.0.0.dist-info/entry_points.txt +2 -0
- code_review_ai_cli-1.0.0.dist-info/top_level.txt +1 -0
- src/__init__.py +7 -0
- src/ai_review.py +946 -0
- src/config.py +361 -0
- src/formatter.py +474 -0
- src/git_utils.py +487 -0
- src/llm_client.py +1008 -0
- src/prompts/config.yaml.template +124 -0
- src/tfs_client.py +751 -0
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}")
|