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