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/ai_review.py
ADDED
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
AI Code Review - Main Script
|
|
4
|
+
==============================
|
|
5
|
+
Automated code review tool using Artificial Intelligence.
|
|
6
|
+
Main mode: Pull Request review on Azure DevOps/TFS with
|
|
7
|
+
automatic comments posted directly to the PR.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python ai_review.py # Interactive mode (main menu)
|
|
11
|
+
python ai_review.py pr-review # List PRs and do interactive review
|
|
12
|
+
python ai_review.py pr-review <id> # Direct review of a specific PR
|
|
13
|
+
python ai_review.py list-prs # List active Pull Requests
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--quick # Quick and concise review
|
|
17
|
+
--detailed # Detailed review (default)
|
|
18
|
+
--security # Security-focused review
|
|
19
|
+
--review-scope <diff_only|full_code> # Review scope (default: diff_only)
|
|
20
|
+
--max-diff-files <n> # Max files in diff (overrides config)
|
|
21
|
+
--dry-run # Review without posting comments
|
|
22
|
+
--auto-post # Post comments without confirmation
|
|
23
|
+
--model <name> # LLM model to use
|
|
24
|
+
--provider <name> # LLM provider (openai/gemini/claude/ollama/copilot/bedrock)
|
|
25
|
+
--output <file> # Save review to a file
|
|
26
|
+
--format <terminal|markdown|json> # Output format
|
|
27
|
+
--context "<text>" # Additional context for the review
|
|
28
|
+
--config <file> # Configuration file
|
|
29
|
+
--help # Show this help
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
python ai_review.py # Interactive menu
|
|
33
|
+
python ai_review.py pr-review # Select PR interactively
|
|
34
|
+
python ai_review.py pr-review 42 --dry-run # Review PR without posting
|
|
35
|
+
python ai_review.py pr-review 42 --provider bedrock
|
|
36
|
+
python ai_review.py list-prs --author "John Smith"
|
|
37
|
+
|
|
38
|
+
Author: Development Team
|
|
39
|
+
Version: see pyproject.toml
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import os
|
|
44
|
+
import sys
|
|
45
|
+
import time
|
|
46
|
+
import threading
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _configure_console_streams() -> None:
|
|
50
|
+
"""Configures console streams to handle Unicode output safely."""
|
|
51
|
+
for stream_name in ("stdout", "stderr"):
|
|
52
|
+
stream = getattr(sys, stream_name, None)
|
|
53
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
54
|
+
if not callable(reconfigure):
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
encoding = (getattr(stream, "encoding", "") or "").lower()
|
|
58
|
+
if encoding == "utf-8":
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
63
|
+
except (LookupError, OSError, ValueError):
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ensure_project_root_on_path(script_path: str) -> str:
|
|
68
|
+
"""Ensures the repository root is available on sys.path."""
|
|
69
|
+
script_dir = os.path.dirname(os.path.abspath(script_path))
|
|
70
|
+
project_root = os.path.dirname(script_dir)
|
|
71
|
+
if project_root not in sys.path:
|
|
72
|
+
sys.path.insert(0, project_root)
|
|
73
|
+
return project_root
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_configure_console_streams()
|
|
77
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
78
|
+
PROJECT_ROOT = _ensure_project_root_on_path(__file__)
|
|
79
|
+
|
|
80
|
+
from src.config import ReviewConfig, VALID_PROVIDERS
|
|
81
|
+
from src.git_utils import GitUtils, GitError
|
|
82
|
+
from src.llm_client import LLMClient, LLMError
|
|
83
|
+
from src.formatter import ReviewFormatter, Colors, save_output
|
|
84
|
+
from src import __version__ as VERSION
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_spinner_frames() -> list[str]:
|
|
88
|
+
"""Returns spinner frames compatible with the current stdout encoding."""
|
|
89
|
+
unicode_frames = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"]
|
|
90
|
+
encoding = getattr(sys.stdout, "encoding", None) or "utf-8"
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
"".join(unicode_frames).encode(encoding)
|
|
94
|
+
except (LookupError, UnicodeEncodeError):
|
|
95
|
+
return ["|", "/", "-", "\\"]
|
|
96
|
+
|
|
97
|
+
return unicode_frames
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Progress Indicator
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
class ProgressIndicator:
|
|
104
|
+
"""Animated progress indicator for long-running operations."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, message: str = "Processing"):
|
|
107
|
+
self.message = message
|
|
108
|
+
self._running = False
|
|
109
|
+
self._thread = None
|
|
110
|
+
self._spinners = _get_spinner_frames()
|
|
111
|
+
|
|
112
|
+
def start(self):
|
|
113
|
+
"""Starts the progress indicator."""
|
|
114
|
+
self._running = True
|
|
115
|
+
self._thread = threading.Thread(target=self._animate, daemon=True)
|
|
116
|
+
self._thread.start()
|
|
117
|
+
|
|
118
|
+
def stop(self, final_message: str = ""):
|
|
119
|
+
"""Stops the progress indicator."""
|
|
120
|
+
self._running = False
|
|
121
|
+
if self._thread:
|
|
122
|
+
self._thread.join(timeout=1)
|
|
123
|
+
# Clear line
|
|
124
|
+
sys.stdout.write("\r" + " " * 80 + "\r")
|
|
125
|
+
sys.stdout.flush()
|
|
126
|
+
if final_message:
|
|
127
|
+
print(final_message)
|
|
128
|
+
|
|
129
|
+
def _animate(self):
|
|
130
|
+
i = 0
|
|
131
|
+
while self._running:
|
|
132
|
+
sys.stdout.write(
|
|
133
|
+
f"\r{Colors.CYAN}{self._spinners[i % len(self._spinners)]} "
|
|
134
|
+
f"{self.message}...{Colors.RESET}"
|
|
135
|
+
)
|
|
136
|
+
sys.stdout.flush()
|
|
137
|
+
time.sleep(0.1)
|
|
138
|
+
i += 1
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# CLI Arguments
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
145
|
+
"""Builds the CLI argument parser."""
|
|
146
|
+
|
|
147
|
+
parser = argparse.ArgumentParser(
|
|
148
|
+
prog="ai_review",
|
|
149
|
+
description=(
|
|
150
|
+
"🤖 AI Code Review - Automated code review using AI.\n"
|
|
151
|
+
"Main mode: Pull Request review with comments on Azure DevOps.\n"
|
|
152
|
+
"Providers: OpenAI GPT-4 | Google Gemini | Anthropic Claude | Ollama | GitHub Copilot | AWS Bedrock"
|
|
153
|
+
),
|
|
154
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
155
|
+
epilog=(
|
|
156
|
+
"Usage examples:\n"
|
|
157
|
+
" %(prog)s # Interactive menu\n"
|
|
158
|
+
" %(prog)s pr-review # List PRs and select\n"
|
|
159
|
+
" %(prog)s pr-review 42 --dry-run # Review PR #42 without posting\n"
|
|
160
|
+
" %(prog)s pr-review 42 --provider bedrock # Review PR #42 with Bedrock\n"
|
|
161
|
+
" %(prog)s list-prs --status active # List active PRs\n"
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Subcommands
|
|
166
|
+
subparsers = parser.add_subparsers(dest="command", help="Review type")
|
|
167
|
+
|
|
168
|
+
# --- pr-review (MAIN MODE) ---
|
|
169
|
+
sub_pr_review = subparsers.add_parser(
|
|
170
|
+
"pr-review", help="🌟 Pull Request Review (recommended main mode)"
|
|
171
|
+
)
|
|
172
|
+
sub_pr_review.add_argument("pr_id", type=int, nargs="?", default=None,
|
|
173
|
+
help="PR ID (if omitted, shows list for selection)")
|
|
174
|
+
sub_pr_review.add_argument("--repo-name", "-r", default=None,
|
|
175
|
+
help="Repository name in TFS")
|
|
176
|
+
sub_pr_review.add_argument("--dry-run", action="store_true",
|
|
177
|
+
help="Review without posting comments")
|
|
178
|
+
sub_pr_review.add_argument("--auto-post", action="store_true",
|
|
179
|
+
help="Post comments without confirmation")
|
|
180
|
+
sub_pr_review.add_argument("--author", default=None,
|
|
181
|
+
help="Filter PRs by author")
|
|
182
|
+
sub_pr_review.add_argument("--target-branch", default=None,
|
|
183
|
+
help="Filter PRs by target branch")
|
|
184
|
+
|
|
185
|
+
# --- init ---
|
|
186
|
+
subparsers.add_parser(
|
|
187
|
+
"init", help="Create a config.yaml template in the current directory"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# --- list-prs ---
|
|
191
|
+
sub_list_prs = subparsers.add_parser(
|
|
192
|
+
"list-prs", help="List active Pull Requests"
|
|
193
|
+
)
|
|
194
|
+
sub_list_prs.add_argument("--repo-name", "-r", default=None,
|
|
195
|
+
help="Repository name in TFS")
|
|
196
|
+
sub_list_prs.add_argument("--status", default="active",
|
|
197
|
+
choices=["active", "completed", "abandoned", "all"])
|
|
198
|
+
sub_list_prs.add_argument("--author", default=None,
|
|
199
|
+
help="Filter by author")
|
|
200
|
+
|
|
201
|
+
# --- Global options ---
|
|
202
|
+
all_subs = [sub_pr_review, sub_list_prs]
|
|
203
|
+
for sub in all_subs:
|
|
204
|
+
_add_global_options(sub)
|
|
205
|
+
|
|
206
|
+
# Version
|
|
207
|
+
parser.add_argument("--version", "-v", action="version",
|
|
208
|
+
version=f"AI Code Review v{VERSION}")
|
|
209
|
+
|
|
210
|
+
return parser
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _add_global_options(parser: argparse.ArgumentParser) -> None:
|
|
214
|
+
"""Adds global options to a subparser."""
|
|
215
|
+
|
|
216
|
+
group_review = parser.add_argument_group("Review Options")
|
|
217
|
+
group_review.add_argument(
|
|
218
|
+
"--quick", "-q", action="store_const", dest="verbosity",
|
|
219
|
+
const="quick", help="Quick and concise review"
|
|
220
|
+
)
|
|
221
|
+
group_review.add_argument(
|
|
222
|
+
"--detailed", "-d", action="store_const", dest="verbosity",
|
|
223
|
+
const="detailed", help="Detailed review (default)"
|
|
224
|
+
)
|
|
225
|
+
group_review.add_argument(
|
|
226
|
+
"--security", "-S", action="store_const", dest="verbosity",
|
|
227
|
+
const="security", help="Security-focused review"
|
|
228
|
+
)
|
|
229
|
+
group_review.add_argument(
|
|
230
|
+
"--review-scope", default=None,
|
|
231
|
+
choices=["diff_only", "full_code"],
|
|
232
|
+
help="Review scope: diff_only (default) or full_code"
|
|
233
|
+
)
|
|
234
|
+
group_review.add_argument(
|
|
235
|
+
"--max-diff-files", default=None, type=int, metavar="N",
|
|
236
|
+
help="Max diff files sent to LLM (overrides review.max_diff_files)"
|
|
237
|
+
)
|
|
238
|
+
group_review.add_argument(
|
|
239
|
+
"--context", "-c", default="",
|
|
240
|
+
help="Additional context for the review"
|
|
241
|
+
)
|
|
242
|
+
group_output = parser.add_argument_group("Output Options")
|
|
243
|
+
group_output.add_argument(
|
|
244
|
+
"--format", dest="output_format",
|
|
245
|
+
choices=["terminal", "markdown", "json"],
|
|
246
|
+
help="Output format"
|
|
247
|
+
)
|
|
248
|
+
group_output.add_argument(
|
|
249
|
+
"--output", "-o", default="",
|
|
250
|
+
help="Save review to a file"
|
|
251
|
+
)
|
|
252
|
+
group_output.add_argument(
|
|
253
|
+
"--no-color", action="store_true",
|
|
254
|
+
help="Disable terminal colors"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
group_config = parser.add_argument_group("Configuration")
|
|
258
|
+
group_config.add_argument(
|
|
259
|
+
"--model", "-m", default=None,
|
|
260
|
+
help="LLM model to use (e.g., gpt-4o, gemini-1.5-pro, claude-3-sonnet)"
|
|
261
|
+
)
|
|
262
|
+
group_config.add_argument(
|
|
263
|
+
"--provider", "-p", default=None,
|
|
264
|
+
choices=VALID_PROVIDERS,
|
|
265
|
+
help="LLM provider"
|
|
266
|
+
)
|
|
267
|
+
group_config.add_argument(
|
|
268
|
+
"--config", default=None,
|
|
269
|
+
help="Configuration file (config.yaml)"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _save_pr_review_output(output_file: str, pr_id: int, repo_name: str,
|
|
274
|
+
pr_details: dict, review_text: str,
|
|
275
|
+
was_truncated: bool) -> None:
|
|
276
|
+
"""Saves the PR review output if a target file was provided."""
|
|
277
|
+
if not output_file:
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
md_formatter = ReviewFormatter(color=False, output_format="markdown")
|
|
281
|
+
md_output = "\n".join([
|
|
282
|
+
md_formatter.format_header(
|
|
283
|
+
review_type=f"Pull Request #{pr_id}",
|
|
284
|
+
repo_name=repo_name,
|
|
285
|
+
branch=f"{pr_details['source_branch']} → {pr_details['target_branch']}",
|
|
286
|
+
),
|
|
287
|
+
review_text,
|
|
288
|
+
md_formatter.format_footer(truncated=was_truncated),
|
|
289
|
+
])
|
|
290
|
+
save_output(md_output, output_file)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Command execution functions
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
def run_review(args: argparse.Namespace) -> int:
|
|
297
|
+
"""Executes the code review based on the provided arguments."""
|
|
298
|
+
|
|
299
|
+
# --- Load configuration ---
|
|
300
|
+
config = ReviewConfig.load(config_path=getattr(args, "config", None))
|
|
301
|
+
|
|
302
|
+
# Override configuration with CLI arguments
|
|
303
|
+
if getattr(args, "verbosity", None):
|
|
304
|
+
config.verbosity = args.verbosity
|
|
305
|
+
if getattr(args, "model", None):
|
|
306
|
+
config.model = args.model
|
|
307
|
+
if getattr(args, "provider", None):
|
|
308
|
+
config.llm_provider = args.provider
|
|
309
|
+
if getattr(args, "review_scope", None):
|
|
310
|
+
config.review_scope = args.review_scope
|
|
311
|
+
if getattr(args, "max_diff_files", None) is not None:
|
|
312
|
+
config.max_diff_files = args.max_diff_files
|
|
313
|
+
if getattr(args, "output_format", None):
|
|
314
|
+
config.output_format = args.output_format
|
|
315
|
+
if getattr(args, "output", None):
|
|
316
|
+
config.output_file = args.output
|
|
317
|
+
if getattr(args, "no_color", False):
|
|
318
|
+
config.color_output = False
|
|
319
|
+
if getattr(args, "dry_run", False):
|
|
320
|
+
config.dry_run = True
|
|
321
|
+
if getattr(args, "auto_post", False):
|
|
322
|
+
config.auto_post_comments = True
|
|
323
|
+
|
|
324
|
+
# --- Validate configuration ---
|
|
325
|
+
issues = config.validate()
|
|
326
|
+
if issues:
|
|
327
|
+
formatter = ReviewFormatter(color=config.color_output)
|
|
328
|
+
for issue in issues:
|
|
329
|
+
print(formatter.format_error(issue))
|
|
330
|
+
print("\nTip: Configure the config.yaml file.")
|
|
331
|
+
print("Check README.md for detailed instructions.")
|
|
332
|
+
return 1
|
|
333
|
+
|
|
334
|
+
# --- Initialize components ---
|
|
335
|
+
formatter = ReviewFormatter(
|
|
336
|
+
color=config.color_output,
|
|
337
|
+
output_format=config.output_format,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# --- Commands that don't need a Git repo ---
|
|
341
|
+
command = args.command
|
|
342
|
+
|
|
343
|
+
if command == "pr-review":
|
|
344
|
+
return run_pr_review_workflow(args, config, formatter)
|
|
345
|
+
elif command == "list-prs":
|
|
346
|
+
return run_list_prs(args, config, formatter)
|
|
347
|
+
|
|
348
|
+
print(formatter.format_error(
|
|
349
|
+
"Unrecognized command. Use --help to see available commands."
|
|
350
|
+
))
|
|
351
|
+
return 1
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def run_pr_review_workflow(args: argparse.Namespace, config: ReviewConfig,
|
|
355
|
+
formatter: ReviewFormatter) -> int:
|
|
356
|
+
"""
|
|
357
|
+
Main Pull Request review workflow.
|
|
358
|
+
1. Lists active PRs
|
|
359
|
+
2. Allows selecting a PR
|
|
360
|
+
3. Reviews with AI
|
|
361
|
+
4. Shows comments preview
|
|
362
|
+
5. Allows posting comments to the PR
|
|
363
|
+
"""
|
|
364
|
+
from src.tfs_client import TFSClient, TFSError
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
tfs = TFSClient(config)
|
|
368
|
+
except TFSError as exc:
|
|
369
|
+
print(formatter.format_error(str(exc)))
|
|
370
|
+
print("\nTip: Configure tfs.base_url, tfs.project and tfs.pat in config.yaml")
|
|
371
|
+
return 1
|
|
372
|
+
|
|
373
|
+
c = Colors
|
|
374
|
+
repo_name = getattr(args, "repo_name", None) or config.tfs_repository or None
|
|
375
|
+
pr_id = getattr(args, "pr_id", None)
|
|
376
|
+
|
|
377
|
+
# --- If no PR ID, list and select ---
|
|
378
|
+
if pr_id is None:
|
|
379
|
+
pr_id, repo_name = _select_pr_interactive(
|
|
380
|
+
tfs, formatter, repo_name,
|
|
381
|
+
author=getattr(args, "author", None),
|
|
382
|
+
target_branch=getattr(args, "target_branch", None),
|
|
383
|
+
)
|
|
384
|
+
if pr_id is None:
|
|
385
|
+
return 0 # User cancelled
|
|
386
|
+
|
|
387
|
+
# --- Get PR details ---
|
|
388
|
+
print(formatter.format_progress(f"Getting details for PR #{pr_id}"))
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
if not repo_name:
|
|
392
|
+
# Try to get repo_name from PRs
|
|
393
|
+
prs = tfs.list_pull_requests(repository=None, top=100)
|
|
394
|
+
for pr in prs:
|
|
395
|
+
if pr["id"] == pr_id:
|
|
396
|
+
repo_name = pr["repository"]
|
|
397
|
+
break
|
|
398
|
+
if not repo_name:
|
|
399
|
+
print(formatter.format_error(
|
|
400
|
+
f"PR #{pr_id} not found. Specify the repository with --repo-name."
|
|
401
|
+
))
|
|
402
|
+
return 1
|
|
403
|
+
|
|
404
|
+
pr_details = tfs.get_pull_request_details(repo_name, pr_id)
|
|
405
|
+
except TFSError as exc:
|
|
406
|
+
print(formatter.format_error(str(exc)))
|
|
407
|
+
return 1
|
|
408
|
+
|
|
409
|
+
# Show PR details
|
|
410
|
+
print(formatter.format_pr_details(pr_details))
|
|
411
|
+
|
|
412
|
+
# --- Get PR diff ---
|
|
413
|
+
print(formatter.format_progress("Getting Pull Request diff"))
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
diff = tfs.get_pull_request_diff(
|
|
417
|
+
repo_name,
|
|
418
|
+
pr_id,
|
|
419
|
+
review_scope=config.review_scope,
|
|
420
|
+
)
|
|
421
|
+
except TFSError as exc:
|
|
422
|
+
print(formatter.format_error(str(exc)))
|
|
423
|
+
return 1
|
|
424
|
+
|
|
425
|
+
if not diff.strip():
|
|
426
|
+
print(formatter.format_warning("PR contains no code changes."))
|
|
427
|
+
return 0
|
|
428
|
+
|
|
429
|
+
# --- AI Analysis ---
|
|
430
|
+
print(formatter.format_info(
|
|
431
|
+
f"Provider: {config.llm_provider} | Model: {config.model} | "
|
|
432
|
+
f"Mode: {config.verbosity} | Scope: {config.review_scope}"
|
|
433
|
+
))
|
|
434
|
+
|
|
435
|
+
dry_run = config.dry_run or getattr(args, "dry_run", False)
|
|
436
|
+
auto_post = config.auto_post_comments or getattr(args, "auto_post", False)
|
|
437
|
+
|
|
438
|
+
if dry_run:
|
|
439
|
+
print(formatter.format_info("🔍 DRY-RUN mode: comments will NOT be posted"))
|
|
440
|
+
|
|
441
|
+
# Get diff files summary
|
|
442
|
+
git_utils = GitUtils.__new__(GitUtils)
|
|
443
|
+
git_utils.repo_path = os.getcwd()
|
|
444
|
+
|
|
445
|
+
# Filter extensions before limiting/truncating the diff sent to the LLM
|
|
446
|
+
if config.file_extensions_filter:
|
|
447
|
+
try:
|
|
448
|
+
diff = git_utils.filter_diff_by_extensions(
|
|
449
|
+
diff,
|
|
450
|
+
config.file_extensions_filter,
|
|
451
|
+
)
|
|
452
|
+
except GitError as exc:
|
|
453
|
+
print(formatter.format_warning(str(exc)))
|
|
454
|
+
return 0
|
|
455
|
+
|
|
456
|
+
# Keep only added lines (+): ignore context and removed lines
|
|
457
|
+
diff = git_utils.filter_diff_additions_only(diff)
|
|
458
|
+
if not diff.strip():
|
|
459
|
+
print(formatter.format_warning(
|
|
460
|
+
"After filtering additions only, the diff is empty. No new code to review."
|
|
461
|
+
))
|
|
462
|
+
return 0
|
|
463
|
+
|
|
464
|
+
# Limit number of diff files if needed
|
|
465
|
+
diff_limited, files_limited, omitted_files = git_utils.limit_diff_files(
|
|
466
|
+
diff,
|
|
467
|
+
config.max_diff_files,
|
|
468
|
+
)
|
|
469
|
+
if files_limited:
|
|
470
|
+
print(formatter.format_warning(
|
|
471
|
+
f"Diff truncated to {config.max_diff_files} files. "
|
|
472
|
+
f"{omitted_files} file(s) omitted."
|
|
473
|
+
))
|
|
474
|
+
|
|
475
|
+
files_summary = git_utils.get_changed_files_summary(diff_limited)
|
|
476
|
+
|
|
477
|
+
# Truncate diff if needed
|
|
478
|
+
diff_truncated, was_truncated = git_utils.truncate_diff(
|
|
479
|
+
diff_limited,
|
|
480
|
+
config.max_diff_lines,
|
|
481
|
+
)
|
|
482
|
+
if was_truncated:
|
|
483
|
+
print(formatter.format_warning(
|
|
484
|
+
f"Diff truncated to {config.max_diff_lines} lines per file."
|
|
485
|
+
))
|
|
486
|
+
|
|
487
|
+
# --- Perform structured review ---
|
|
488
|
+
progress = ProgressIndicator("Analyzing code with AI (may take 30-60s)")
|
|
489
|
+
progress.start()
|
|
490
|
+
start_time = time.time()
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
llm = LLMClient(config)
|
|
494
|
+
|
|
495
|
+
# Get general review as text
|
|
496
|
+
review_text = llm.review(
|
|
497
|
+
diff=diff_truncated,
|
|
498
|
+
files_summary=files_summary,
|
|
499
|
+
context=getattr(args, "context", ""),
|
|
500
|
+
review_scope=config.review_scope,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# Get structured comments to post
|
|
504
|
+
structured_comments = llm.review_pr_structured(
|
|
505
|
+
diff=diff_truncated,
|
|
506
|
+
files_summary=files_summary,
|
|
507
|
+
context=getattr(args, "context", ""),
|
|
508
|
+
review_scope=config.review_scope,
|
|
509
|
+
)
|
|
510
|
+
except LLMError as exc:
|
|
511
|
+
progress.stop()
|
|
512
|
+
print(formatter.format_error(str(exc)))
|
|
513
|
+
return 1
|
|
514
|
+
|
|
515
|
+
without_inline = [
|
|
516
|
+
c for c in structured_comments
|
|
517
|
+
if not (bool(c.get("file")) and int(c.get("line", 0)) > 0)
|
|
518
|
+
and str(c.get("type", "")).lower() not in ("praise", "")
|
|
519
|
+
]
|
|
520
|
+
if without_inline:
|
|
521
|
+
print(formatter.format_info(
|
|
522
|
+
f"{len(without_inline)} comment(s) without file/line will be posted as a general PR comment."
|
|
523
|
+
))
|
|
524
|
+
discarded_comments = [] # nothing is discarded
|
|
525
|
+
|
|
526
|
+
elapsed = time.time() - start_time
|
|
527
|
+
progress.stop(formatter.format_success(f"Review completed in {elapsed:.1f}s"))
|
|
528
|
+
|
|
529
|
+
# --- Show general review ---
|
|
530
|
+
print(formatter.format_review(review_text))
|
|
531
|
+
|
|
532
|
+
# --- Show structured comments preview ---
|
|
533
|
+
print(formatter.format_structured_comments(
|
|
534
|
+
structured_comments,
|
|
535
|
+
discarded_count=len(discarded_comments),
|
|
536
|
+
))
|
|
537
|
+
|
|
538
|
+
output_file = config.output_file or getattr(args, "output", "")
|
|
539
|
+
_save_pr_review_output(
|
|
540
|
+
output_file=output_file,
|
|
541
|
+
pr_id=pr_id,
|
|
542
|
+
repo_name=repo_name,
|
|
543
|
+
pr_details=pr_details,
|
|
544
|
+
review_text=review_text,
|
|
545
|
+
was_truncated=was_truncated,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# --- Post comments to PR ---
|
|
549
|
+
if dry_run:
|
|
550
|
+
print(f"\n{c.YELLOW}{c.BOLD}🔍 DRY-RUN mode: "
|
|
551
|
+
f"No comments were posted to the PR.{c.RESET}")
|
|
552
|
+
print(f"{c.DIM} Remove --dry-run to post the comments.{c.RESET}\n")
|
|
553
|
+
return 0
|
|
554
|
+
|
|
555
|
+
if not structured_comments:
|
|
556
|
+
print(formatter.format_info("No comments to post."))
|
|
557
|
+
return 0
|
|
558
|
+
|
|
559
|
+
# --- Confirmation and selection ---
|
|
560
|
+
if auto_post:
|
|
561
|
+
comments_to_post = structured_comments
|
|
562
|
+
else:
|
|
563
|
+
comments_to_post = _select_comments_to_post(structured_comments, formatter)
|
|
564
|
+
|
|
565
|
+
if not comments_to_post:
|
|
566
|
+
print(formatter.format_info("No comments selected for posting."))
|
|
567
|
+
return 0
|
|
568
|
+
|
|
569
|
+
# --- Post ---
|
|
570
|
+
print(formatter.format_progress(
|
|
571
|
+
f"Posting {len(comments_to_post)} comments to PR #{pr_id}"
|
|
572
|
+
))
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
results = tfs.post_review_comments(
|
|
576
|
+
repo_name,
|
|
577
|
+
pr_id,
|
|
578
|
+
comments_to_post,
|
|
579
|
+
review_scope=config.review_scope,
|
|
580
|
+
comment_mode=config.pr_comment_mode,
|
|
581
|
+
)
|
|
582
|
+
print(formatter.format_post_results(results))
|
|
583
|
+
except TFSError as exc:
|
|
584
|
+
print(formatter.format_error(f"Error posting comments: {exc}"))
|
|
585
|
+
return 1
|
|
586
|
+
|
|
587
|
+
# --- Post general summary as comment ---
|
|
588
|
+
try:
|
|
589
|
+
summary_comment = (
|
|
590
|
+
f"## 🤖 AI Code Review\n\n"
|
|
591
|
+
f"**Provider:** {config.llm_provider} | "
|
|
592
|
+
f"**Model:** {config.model} | "
|
|
593
|
+
f"**Mode:** {config.verbosity}\n\n"
|
|
594
|
+
f"{review_text}\n\n"
|
|
595
|
+
f"---\n"
|
|
596
|
+
f"*Automatic review generated by AI Code Review v{VERSION}*"
|
|
597
|
+
)
|
|
598
|
+
tfs.post_general_comment(repo_name, pr_id, summary_comment)
|
|
599
|
+
print(formatter.format_success("General summary posted to PR."))
|
|
600
|
+
except TFSError as exc:
|
|
601
|
+
print(formatter.format_warning(f"Could not post general summary: {exc}"))
|
|
602
|
+
|
|
603
|
+
return 0
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _select_pr_interactive(tfs, formatter: ReviewFormatter,
|
|
607
|
+
repo_name=None, author=None,
|
|
608
|
+
target_branch=None) -> tuple:
|
|
609
|
+
"""Interactive PR selection. Returns (pr_id, repo_name) or (None, None)."""
|
|
610
|
+
c = Colors
|
|
611
|
+
|
|
612
|
+
print(formatter.format_progress("Fetching Pull Requests list"))
|
|
613
|
+
|
|
614
|
+
try:
|
|
615
|
+
prs = tfs.list_pull_requests(
|
|
616
|
+
status="active",
|
|
617
|
+
repository=repo_name,
|
|
618
|
+
author=author,
|
|
619
|
+
target_branch=target_branch,
|
|
620
|
+
)
|
|
621
|
+
except Exception as exc:
|
|
622
|
+
print(formatter.format_error(str(exc)))
|
|
623
|
+
return None, None
|
|
624
|
+
|
|
625
|
+
if not prs:
|
|
626
|
+
print(formatter.format_info("No active Pull Requests found."))
|
|
627
|
+
return None, None
|
|
628
|
+
|
|
629
|
+
# Show list
|
|
630
|
+
print(formatter.format_pr_list(prs, "Active Pull Requests"))
|
|
631
|
+
|
|
632
|
+
# Select
|
|
633
|
+
try:
|
|
634
|
+
choice = input(
|
|
635
|
+
f"\n{c.BOLD}Select PR (list number or PR ID, 0 to cancel): {c.RESET}"
|
|
636
|
+
).strip()
|
|
637
|
+
except (KeyboardInterrupt, EOFError):
|
|
638
|
+
print("\n")
|
|
639
|
+
return None, None
|
|
640
|
+
|
|
641
|
+
if not choice or choice == "0":
|
|
642
|
+
return None, None
|
|
643
|
+
|
|
644
|
+
try:
|
|
645
|
+
num = int(choice)
|
|
646
|
+
# If it's a small number, it's a list index
|
|
647
|
+
if 1 <= num <= len(prs):
|
|
648
|
+
selected = prs[num - 1]
|
|
649
|
+
return selected["id"], selected["repository"]
|
|
650
|
+
else:
|
|
651
|
+
# It's a direct PR ID
|
|
652
|
+
for pr in prs:
|
|
653
|
+
if pr["id"] == num:
|
|
654
|
+
return pr["id"], pr["repository"]
|
|
655
|
+
# Not found in list, try using as direct ID
|
|
656
|
+
return num, repo_name
|
|
657
|
+
except ValueError:
|
|
658
|
+
print(f"{c.RED}Invalid option.{c.RESET}")
|
|
659
|
+
return None, None
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _select_comments_to_post(comments: list[dict],
|
|
663
|
+
formatter: ReviewFormatter) -> list[dict]:
|
|
664
|
+
"""Allows the user to select which comments to post."""
|
|
665
|
+
c = Colors
|
|
666
|
+
|
|
667
|
+
print(f"\n{c.BOLD}What would you like to do with the comments?{c.RESET}")
|
|
668
|
+
print(f" {c.CYAN}1{c.RESET}) Post ALL comments")
|
|
669
|
+
print(f" {c.CYAN}2{c.RESET}) Select which to post")
|
|
670
|
+
print(f" {c.CYAN}3{c.RESET}) Post none (cancel)")
|
|
671
|
+
|
|
672
|
+
try:
|
|
673
|
+
choice = input(f"\n{c.BOLD}Choose [1-3]: {c.RESET}").strip()
|
|
674
|
+
except (KeyboardInterrupt, EOFError):
|
|
675
|
+
print("\n")
|
|
676
|
+
return []
|
|
677
|
+
|
|
678
|
+
if choice == "1":
|
|
679
|
+
return comments
|
|
680
|
+
elif choice == "3" or not choice:
|
|
681
|
+
return []
|
|
682
|
+
elif choice == "2":
|
|
683
|
+
# Individual selection
|
|
684
|
+
selected = []
|
|
685
|
+
for i, comment in enumerate(comments, 1):
|
|
686
|
+
severity = comment.get("severity", "info")
|
|
687
|
+
comment_type = comment.get("type", "suggestion")
|
|
688
|
+
file_info = comment.get("file", "general")
|
|
689
|
+
if comment.get("line", 0) > 0:
|
|
690
|
+
file_info += f":{comment['line']}"
|
|
691
|
+
|
|
692
|
+
try:
|
|
693
|
+
ans = input(
|
|
694
|
+
f" {c.CYAN}[{i}/{len(comments)}]{c.RESET} "
|
|
695
|
+
f"{comment_type} ({severity}) at {file_info} - "
|
|
696
|
+
f"Post? [{c.GREEN}Y{c.RESET}/{c.RED}n{c.RESET}]: "
|
|
697
|
+
).strip().lower()
|
|
698
|
+
except (KeyboardInterrupt, EOFError):
|
|
699
|
+
print("\n")
|
|
700
|
+
break
|
|
701
|
+
|
|
702
|
+
if ans in ("", "y", "yes"):
|
|
703
|
+
selected.append(comment)
|
|
704
|
+
|
|
705
|
+
return selected
|
|
706
|
+
else:
|
|
707
|
+
return []
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
# ---------------------------------------------------------------------------
|
|
711
|
+
# List PRs
|
|
712
|
+
# ---------------------------------------------------------------------------
|
|
713
|
+
def run_list_prs(args: argparse.Namespace, config: ReviewConfig,
|
|
714
|
+
formatter: ReviewFormatter) -> int:
|
|
715
|
+
"""Lists Pull Requests from TFS/Azure DevOps."""
|
|
716
|
+
from src.tfs_client import TFSClient, TFSError
|
|
717
|
+
|
|
718
|
+
try:
|
|
719
|
+
tfs = TFSClient(config)
|
|
720
|
+
except TFSError as exc:
|
|
721
|
+
print(formatter.format_error(str(exc)))
|
|
722
|
+
return 1
|
|
723
|
+
|
|
724
|
+
repo_name = getattr(args, "repo_name", None) or config.tfs_repository or None
|
|
725
|
+
status = getattr(args, "status", "active")
|
|
726
|
+
author = getattr(args, "author", None)
|
|
727
|
+
|
|
728
|
+
print(formatter.format_progress("Fetching Pull Requests list"))
|
|
729
|
+
|
|
730
|
+
try:
|
|
731
|
+
prs = tfs.list_pull_requests(
|
|
732
|
+
status=status,
|
|
733
|
+
repository=repo_name,
|
|
734
|
+
author=author,
|
|
735
|
+
)
|
|
736
|
+
except TFSError as exc:
|
|
737
|
+
print(formatter.format_error(str(exc)))
|
|
738
|
+
return 1
|
|
739
|
+
|
|
740
|
+
print(formatter.format_pr_list(prs, f"Pull Requests ({status})"))
|
|
741
|
+
return 0
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# ---------------------------------------------------------------------------
|
|
745
|
+
# Interactive mode
|
|
746
|
+
# ---------------------------------------------------------------------------
|
|
747
|
+
def interactive_mode() -> int:
|
|
748
|
+
"""Main interactive mode with selection menu."""
|
|
749
|
+
c = Colors
|
|
750
|
+
|
|
751
|
+
print(f"\n{c.BLUE}{c.BOLD}{'═' * 60}{c.RESET}")
|
|
752
|
+
print(f"{c.BLUE}{c.BOLD} 🤖 AI Code Review v{VERSION} - Interactive Mode{c.RESET}")
|
|
753
|
+
print(f"{c.BLUE}{c.BOLD}{'═' * 60}{c.RESET}")
|
|
754
|
+
|
|
755
|
+
# Load config to show current state
|
|
756
|
+
config = ReviewConfig.load()
|
|
757
|
+
print(f"\n{c.CYAN} LLM: {config.get_provider_info()}{c.RESET}")
|
|
758
|
+
|
|
759
|
+
# Main menu
|
|
760
|
+
print(f"\n{c.BOLD}What would you like to do?{c.RESET}\n")
|
|
761
|
+
print(f" {c.CYAN}{c.BOLD}── Pull Requests (recommended) ──{c.RESET}")
|
|
762
|
+
print(f" {c.CYAN}1{c.RESET}) 🌟 Pull Request Review (list PRs and select)")
|
|
763
|
+
print(f" {c.CYAN}2{c.RESET}) 📋 List active Pull Requests")
|
|
764
|
+
print(f"")
|
|
765
|
+
print(f" {c.CYAN}{c.BOLD}── Other ──{c.RESET}")
|
|
766
|
+
print(f" {c.CYAN}3{c.RESET}) Current configuration")
|
|
767
|
+
print(f" {c.CYAN}0{c.RESET}) Exit")
|
|
768
|
+
|
|
769
|
+
try:
|
|
770
|
+
choice = input(f"\n{c.BOLD}Choose [0-3]: {c.RESET}").strip()
|
|
771
|
+
except (KeyboardInterrupt, EOFError):
|
|
772
|
+
print("\n")
|
|
773
|
+
return 0
|
|
774
|
+
|
|
775
|
+
if choice == "0":
|
|
776
|
+
return 0
|
|
777
|
+
|
|
778
|
+
# --- PR Review ---
|
|
779
|
+
if choice == "1":
|
|
780
|
+
return _interactive_pr_review(config)
|
|
781
|
+
|
|
782
|
+
if choice == "2":
|
|
783
|
+
return _interactive_list_prs(config)
|
|
784
|
+
|
|
785
|
+
if choice == "3":
|
|
786
|
+
_show_config(config)
|
|
787
|
+
return 0
|
|
788
|
+
|
|
789
|
+
print(f"{c.RED}Invalid option.{c.RESET}")
|
|
790
|
+
return 1
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _interactive_pr_review(config: ReviewConfig) -> int:
|
|
794
|
+
"""Interactive PR review workflow."""
|
|
795
|
+
c = Colors
|
|
796
|
+
|
|
797
|
+
# Ask for mode
|
|
798
|
+
print(f"\n{c.BOLD}Review options:{c.RESET}")
|
|
799
|
+
print(f" {c.CYAN}1{c.RESET}) Full review with comments on PR")
|
|
800
|
+
print(f" {c.CYAN}2{c.RESET}) Dry-run (review without posting comments)")
|
|
801
|
+
|
|
802
|
+
try:
|
|
803
|
+
mode = input(f"{c.BOLD}Choose [1-2, default=1]: {c.RESET}").strip()
|
|
804
|
+
except (KeyboardInterrupt, EOFError):
|
|
805
|
+
print("\n")
|
|
806
|
+
return 0
|
|
807
|
+
|
|
808
|
+
dry_run = mode == "2"
|
|
809
|
+
|
|
810
|
+
# Ask for verbosity
|
|
811
|
+
verbosity = _ask_verbosity()
|
|
812
|
+
|
|
813
|
+
# Build arguments
|
|
814
|
+
argv = ["pr-review"]
|
|
815
|
+
if dry_run:
|
|
816
|
+
argv.append("--dry-run")
|
|
817
|
+
if verbosity:
|
|
818
|
+
argv.append(f"--{verbosity}")
|
|
819
|
+
|
|
820
|
+
parser = build_parser()
|
|
821
|
+
parsed = parser.parse_args(argv)
|
|
822
|
+
return run_review(parsed)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _interactive_list_prs(config: ReviewConfig) -> int:
|
|
826
|
+
"""Lists PRs interactively."""
|
|
827
|
+
argv = ["list-prs"]
|
|
828
|
+
parser = build_parser()
|
|
829
|
+
parsed = parser.parse_args(argv)
|
|
830
|
+
return run_review(parsed)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _ask_verbosity() -> str:
|
|
834
|
+
"""Asks for the verbosity mode."""
|
|
835
|
+
c = Colors
|
|
836
|
+
print(f"\n{c.BOLD}Review mode:{c.RESET}")
|
|
837
|
+
print(f" {c.CYAN}1{c.RESET}) Quick")
|
|
838
|
+
print(f" {c.CYAN}2{c.RESET}) Detailed")
|
|
839
|
+
print(f" {c.CYAN}3{c.RESET}) Security")
|
|
840
|
+
|
|
841
|
+
try:
|
|
842
|
+
mode_choice = input(f"{c.BOLD}Choose [1-3, default=2]: {c.RESET}").strip()
|
|
843
|
+
except (KeyboardInterrupt, EOFError):
|
|
844
|
+
print("\n")
|
|
845
|
+
return "detailed"
|
|
846
|
+
|
|
847
|
+
verbosity_map = {"1": "quick", "2": "detailed", "3": "security"}
|
|
848
|
+
return verbosity_map.get(mode_choice, "detailed")
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _show_config(config: ReviewConfig) -> None:
|
|
852
|
+
"""Shows the current configuration."""
|
|
853
|
+
c = Colors
|
|
854
|
+
has_effective_key = bool(config.get_effective_api_key())
|
|
855
|
+
print(f"\n{c.BOLD}⚙️ Current Configuration:{c.RESET}\n")
|
|
856
|
+
print(f" {c.CYAN}LLM Provider:{c.RESET} {config.llm_provider}")
|
|
857
|
+
print(f" {c.CYAN}Model:{c.RESET} {config.get_effective_model()}")
|
|
858
|
+
print(f" {c.CYAN}API Key:{c.RESET} {'✅ Configured' if has_effective_key else '❌ Not configured'}")
|
|
859
|
+
print(f" {c.CYAN}Temperature:{c.RESET} {config.temperature}")
|
|
860
|
+
print(f" {c.CYAN}Max Tokens:{c.RESET} {config.max_tokens}")
|
|
861
|
+
print(f" {c.CYAN}Language:{c.RESET} {config.review_language}")
|
|
862
|
+
print(f" {c.CYAN}Verbosity:{c.RESET} {config.verbosity}")
|
|
863
|
+
print(f" {c.CYAN}Format:{c.RESET} {config.output_format}")
|
|
864
|
+
print(f"\n {c.CYAN}TFS URL:{c.RESET} {config.tfs_base_url or '(not configured)'}")
|
|
865
|
+
print(f" {c.CYAN}TFS Project:{c.RESET} {config.tfs_project or '(not configured)'}")
|
|
866
|
+
print(f" {c.CYAN}TFS PAT:{c.RESET} {'✅ Configured' if config.tfs_pat else '❌ Not configured'}")
|
|
867
|
+
print(f" {c.CYAN}Dry Run:{c.RESET} {config.dry_run}")
|
|
868
|
+
print()
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
# ---------------------------------------------------------------------------
|
|
872
|
+
# Init command
|
|
873
|
+
# ---------------------------------------------------------------------------
|
|
874
|
+
def cmd_init() -> int:
|
|
875
|
+
"""Copies a config.yaml template to the current working directory.
|
|
876
|
+
|
|
877
|
+
Creates a ``config.yaml`` file pre-populated with all available options
|
|
878
|
+
and inline documentation. If the file already exists in the current
|
|
879
|
+
directory the user is prompted for confirmation before overwriting.
|
|
880
|
+
|
|
881
|
+
The template is bundled with the package at
|
|
882
|
+
``src/prompts/config.yaml.template`` and is resolved at runtime via
|
|
883
|
+
:mod:`importlib.resources`, so it works regardless of how the package
|
|
884
|
+
was installed.
|
|
885
|
+
|
|
886
|
+
Returns:
|
|
887
|
+
int: Exit code. ``0`` on success or user cancellation, ``1`` on error.
|
|
888
|
+
|
|
889
|
+
Example:
|
|
890
|
+
Run from any directory to bootstrap a new configuration::
|
|
891
|
+
|
|
892
|
+
$ ai-review init
|
|
893
|
+
✅ config.yaml created at: /home/user/my-project/config.yaml
|
|
894
|
+
Edit it to add your credentials and preferences.
|
|
895
|
+
"""
|
|
896
|
+
import importlib.resources as pkg_resources
|
|
897
|
+
|
|
898
|
+
dest = os.path.join(os.getcwd(), "config.yaml")
|
|
899
|
+
c = Colors()
|
|
900
|
+
|
|
901
|
+
if os.path.exists(dest):
|
|
902
|
+
print(f"{c.YELLOW}config.yaml already exists in the current directory.{c.RESET}")
|
|
903
|
+
answer = input("Overwrite? [y/N] ").strip().lower()
|
|
904
|
+
if answer != "y":
|
|
905
|
+
print("Aborted.")
|
|
906
|
+
return 0
|
|
907
|
+
|
|
908
|
+
try:
|
|
909
|
+
ref = pkg_resources.files("src.prompts").joinpath("config.yaml.template")
|
|
910
|
+
template_content = ref.read_text(encoding="utf-8")
|
|
911
|
+
except (FileNotFoundError, TypeError) as exc:
|
|
912
|
+
print(f"{c.RED}Error: could not locate template file: {exc}{c.RESET}")
|
|
913
|
+
return 1
|
|
914
|
+
|
|
915
|
+
with open(dest, "w", encoding="utf-8") as fh:
|
|
916
|
+
fh.write(template_content)
|
|
917
|
+
|
|
918
|
+
print(f"{c.GREEN}✅ config.yaml created at:{c.RESET} {dest}")
|
|
919
|
+
print(f" Edit it to add your credentials and preferences.")
|
|
920
|
+
return 0
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
# ---------------------------------------------------------------------------
|
|
924
|
+
# Entry point
|
|
925
|
+
# ---------------------------------------------------------------------------
|
|
926
|
+
def main() -> int:
|
|
927
|
+
"""Main entry point."""
|
|
928
|
+
# If no arguments, use interactive mode
|
|
929
|
+
if len(sys.argv) == 1:
|
|
930
|
+
return interactive_mode()
|
|
931
|
+
|
|
932
|
+
parser = build_parser()
|
|
933
|
+
args = parser.parse_args()
|
|
934
|
+
|
|
935
|
+
if not args.command:
|
|
936
|
+
parser.print_help()
|
|
937
|
+
return 0
|
|
938
|
+
|
|
939
|
+
if args.command == "init":
|
|
940
|
+
return cmd_init()
|
|
941
|
+
|
|
942
|
+
return run_review(args)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
if __name__ == "__main__":
|
|
946
|
+
sys.exit(main())
|