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/git_utils.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git Utilities Module - AI Code Review
|
|
3
|
+
========================================
|
|
4
|
+
Responsible for capturing Git diffs in different scenarios:
|
|
5
|
+
- Staged changes (before commit)
|
|
6
|
+
- Specific commits
|
|
7
|
+
- Differences between branches
|
|
8
|
+
- Working directory changes
|
|
9
|
+
|
|
10
|
+
Works with any Git repository, including TFS/Azure DevOps.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
import os
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GitError(Exception):
|
|
19
|
+
"""Exception for Git-related errors."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GitUtils:
|
|
24
|
+
"""Utility class for Git operations."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, repo_path: Optional[str] = None):
|
|
27
|
+
"""
|
|
28
|
+
Initializes the Git utility.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
repo_path: Path to the repository. If None, uses the current directory.
|
|
32
|
+
"""
|
|
33
|
+
self.repo_path = repo_path or os.getcwd()
|
|
34
|
+
self._validate_repo()
|
|
35
|
+
|
|
36
|
+
# ------------------------------------------------------------------
|
|
37
|
+
# Validation
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
def _validate_repo(self) -> None:
|
|
40
|
+
"""Checks whether we are inside a valid Git repository."""
|
|
41
|
+
try:
|
|
42
|
+
self._run_git("rev-parse", "--git-dir")
|
|
43
|
+
except GitError:
|
|
44
|
+
raise GitError(
|
|
45
|
+
f"Directory '{self.repo_path}' is not a valid Git repository.\n"
|
|
46
|
+
"Make sure you are inside a Git repository."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
# Internal Git commands
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
def _run_git(self, *args: str, check: bool = True) -> str:
|
|
53
|
+
"""
|
|
54
|
+
Runs a git command and returns the output.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
*args: Git command arguments.
|
|
58
|
+
check: If True, raises an exception on error.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Command output as string.
|
|
62
|
+
"""
|
|
63
|
+
cmd = ["git"] + list(args)
|
|
64
|
+
try:
|
|
65
|
+
result = subprocess.run(
|
|
66
|
+
cmd,
|
|
67
|
+
cwd=self.repo_path,
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
encoding="utf-8",
|
|
71
|
+
errors="replace",
|
|
72
|
+
timeout=60,
|
|
73
|
+
)
|
|
74
|
+
if check and result.returncode != 0:
|
|
75
|
+
raise GitError(
|
|
76
|
+
f"Git command failed: {' '.join(cmd)}\n"
|
|
77
|
+
f"Error: {result.stderr.strip()}"
|
|
78
|
+
)
|
|
79
|
+
return result.stdout
|
|
80
|
+
except FileNotFoundError:
|
|
81
|
+
raise GitError(
|
|
82
|
+
"Git not found. Make sure Git is installed "
|
|
83
|
+
"and available in PATH."
|
|
84
|
+
)
|
|
85
|
+
except subprocess.TimeoutExpired:
|
|
86
|
+
raise GitError(f"Timeout executing: {' '.join(cmd)}")
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Repository information
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
def get_current_branch(self) -> str:
|
|
92
|
+
"""Returns the current branch name."""
|
|
93
|
+
return self._run_git("branch", "--show-current").strip()
|
|
94
|
+
|
|
95
|
+
def get_repo_name(self) -> str:
|
|
96
|
+
"""Returns the repository name."""
|
|
97
|
+
try:
|
|
98
|
+
remote_url = self._run_git("remote", "get-url", "origin").strip()
|
|
99
|
+
# Extract repo name from URL
|
|
100
|
+
name = remote_url.rstrip("/").split("/")[-1]
|
|
101
|
+
if name.endswith(".git"):
|
|
102
|
+
name = name[:-4]
|
|
103
|
+
return name
|
|
104
|
+
except GitError:
|
|
105
|
+
return os.path.basename(self.repo_path)
|
|
106
|
+
|
|
107
|
+
def get_remote_url(self) -> str:
|
|
108
|
+
"""Returns the remote origin URL."""
|
|
109
|
+
try:
|
|
110
|
+
return self._run_git("remote", "get-url", "origin").strip()
|
|
111
|
+
except GitError:
|
|
112
|
+
return "(no remote configured)"
|
|
113
|
+
|
|
114
|
+
def list_branches(self, remote: bool = False) -> list[str]:
|
|
115
|
+
"""Lists available branches."""
|
|
116
|
+
args = ["branch"]
|
|
117
|
+
if remote:
|
|
118
|
+
args.append("-r")
|
|
119
|
+
output = self._run_git(*args)
|
|
120
|
+
branches = []
|
|
121
|
+
for line in output.strip().split("\n"):
|
|
122
|
+
branch = line.strip().lstrip("* ").strip()
|
|
123
|
+
if branch and "HEAD" not in branch:
|
|
124
|
+
branches.append(branch)
|
|
125
|
+
return branches
|
|
126
|
+
|
|
127
|
+
def get_recent_commits(self, count: int = 10, branch: Optional[str] = None) -> list[dict]:
|
|
128
|
+
"""
|
|
129
|
+
Returns the most recent commits.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
List of dicts with 'hash', 'short_hash', 'author', 'date', 'message'.
|
|
133
|
+
"""
|
|
134
|
+
args = [
|
|
135
|
+
"log",
|
|
136
|
+
f"-{count}",
|
|
137
|
+
"--pretty=format:%H|%h|%an|%ai|%s",
|
|
138
|
+
]
|
|
139
|
+
if branch:
|
|
140
|
+
args.append(branch)
|
|
141
|
+
|
|
142
|
+
output = self._run_git(*args)
|
|
143
|
+
commits = []
|
|
144
|
+
for line in output.strip().split("\n"):
|
|
145
|
+
if not line.strip():
|
|
146
|
+
continue
|
|
147
|
+
parts = line.split("|", 4)
|
|
148
|
+
if len(parts) == 5:
|
|
149
|
+
commits.append({
|
|
150
|
+
"hash": parts[0],
|
|
151
|
+
"short_hash": parts[1],
|
|
152
|
+
"author": parts[2],
|
|
153
|
+
"date": parts[3],
|
|
154
|
+
"message": parts[4],
|
|
155
|
+
})
|
|
156
|
+
return commits
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
# Diff capture
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
def get_staged_diff(self) -> str:
|
|
162
|
+
"""
|
|
163
|
+
Captures the diff of staged files (git add).
|
|
164
|
+
Used for review before committing.
|
|
165
|
+
"""
|
|
166
|
+
diff = self._run_git("diff", "--cached", "--no-color")
|
|
167
|
+
if not diff.strip():
|
|
168
|
+
raise GitError(
|
|
169
|
+
"No staged changes found.\n"
|
|
170
|
+
"Use 'git add <file>' to add files to staging."
|
|
171
|
+
)
|
|
172
|
+
return diff
|
|
173
|
+
|
|
174
|
+
def get_working_diff(self) -> str:
|
|
175
|
+
"""
|
|
176
|
+
Captures the diff of modified files in the working directory.
|
|
177
|
+
(Changes not yet added to staging.)
|
|
178
|
+
"""
|
|
179
|
+
diff = self._run_git("diff", "--no-color")
|
|
180
|
+
if not diff.strip():
|
|
181
|
+
raise GitError(
|
|
182
|
+
"No changes in working directory.\n"
|
|
183
|
+
"Files may already be staged (use --staged)."
|
|
184
|
+
)
|
|
185
|
+
return diff
|
|
186
|
+
|
|
187
|
+
def get_all_changes_diff(self) -> str:
|
|
188
|
+
"""
|
|
189
|
+
Captures the diff of ALL changes (staged + unstaged).
|
|
190
|
+
"""
|
|
191
|
+
staged = self._run_git("diff", "--cached", "--no-color", check=False)
|
|
192
|
+
unstaged = self._run_git("diff", "--no-color", check=False)
|
|
193
|
+
|
|
194
|
+
combined = ""
|
|
195
|
+
if staged.strip():
|
|
196
|
+
combined += f"# === STAGED CHANGES ===\n{staged}\n"
|
|
197
|
+
if unstaged.strip():
|
|
198
|
+
combined += f"# === WORKING DIRECTORY CHANGES ===\n{unstaged}\n"
|
|
199
|
+
|
|
200
|
+
if not combined.strip():
|
|
201
|
+
raise GitError("No changes (staged or unstaged) in the repository.")
|
|
202
|
+
|
|
203
|
+
return combined
|
|
204
|
+
|
|
205
|
+
def get_commit_diff(self, commit_hash: str) -> str:
|
|
206
|
+
"""
|
|
207
|
+
Captures the diff of a specific commit.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
commit_hash: Commit hash (full or abbreviated).
|
|
211
|
+
"""
|
|
212
|
+
diff = self._run_git("show", commit_hash, "--no-color", "--format=")
|
|
213
|
+
if not diff.strip():
|
|
214
|
+
raise GitError(f"Commit '{commit_hash}' contains no code changes.")
|
|
215
|
+
return diff
|
|
216
|
+
|
|
217
|
+
def get_commit_range_diff(self, from_commit: str, to_commit: str = "HEAD") -> str:
|
|
218
|
+
"""
|
|
219
|
+
Captures the diff between two commits.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
from_commit: Starting commit hash.
|
|
223
|
+
to_commit: Ending commit hash (default: HEAD).
|
|
224
|
+
"""
|
|
225
|
+
diff = self._run_git("diff", f"{from_commit}..{to_commit}", "--no-color")
|
|
226
|
+
if not diff.strip():
|
|
227
|
+
raise GitError(
|
|
228
|
+
f"No differences between '{from_commit}' and '{to_commit}'."
|
|
229
|
+
)
|
|
230
|
+
return diff
|
|
231
|
+
|
|
232
|
+
def get_branch_diff(self, source_branch: str, target_branch: Optional[str] = None) -> str:
|
|
233
|
+
"""
|
|
234
|
+
Captures the diff between two branches.
|
|
235
|
+
Useful to simulate a Pull Request diff.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
source_branch: Branch with changes (feature branch).
|
|
239
|
+
target_branch: Target branch (default: current branch).
|
|
240
|
+
"""
|
|
241
|
+
if target_branch is None:
|
|
242
|
+
target_branch = self.get_current_branch()
|
|
243
|
+
|
|
244
|
+
# Use merge-base to get the correct diff (like a real PR)
|
|
245
|
+
try:
|
|
246
|
+
merge_base = self._run_git(
|
|
247
|
+
"merge-base", target_branch, source_branch
|
|
248
|
+
).strip()
|
|
249
|
+
diff = self._run_git(
|
|
250
|
+
"diff", f"{merge_base}..{source_branch}", "--no-color"
|
|
251
|
+
)
|
|
252
|
+
except GitError:
|
|
253
|
+
# Fallback: direct diff between branches
|
|
254
|
+
diff = self._run_git(
|
|
255
|
+
"diff", f"{target_branch}..{source_branch}", "--no-color"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if not diff.strip():
|
|
259
|
+
raise GitError(
|
|
260
|
+
f"No differences between '{target_branch}' and '{source_branch}'."
|
|
261
|
+
)
|
|
262
|
+
return diff
|
|
263
|
+
|
|
264
|
+
def get_file_diff(self, file_path: str, staged: bool = False) -> str:
|
|
265
|
+
"""
|
|
266
|
+
Captures the diff of a specific file.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
file_path: File path.
|
|
270
|
+
staged: If True, captures diff from staging.
|
|
271
|
+
"""
|
|
272
|
+
args = ["diff", "--no-color"]
|
|
273
|
+
if staged:
|
|
274
|
+
args.append("--cached")
|
|
275
|
+
args.append("--")
|
|
276
|
+
args.append(file_path)
|
|
277
|
+
|
|
278
|
+
diff = self._run_git(*args)
|
|
279
|
+
if not diff.strip():
|
|
280
|
+
raise GitError(f"No changes in file '{file_path}'.")
|
|
281
|
+
return diff
|
|
282
|
+
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
# Filters and Utilities
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
def filter_diff_additions_only(self, diff: str) -> str:
|
|
287
|
+
"""
|
|
288
|
+
Removes context lines and deleted lines (-) from the diff.
|
|
289
|
+
Keeps only added lines (+) and structural headers needed for the LLM.
|
|
290
|
+
|
|
291
|
+
Lines kept:
|
|
292
|
+
- diff --git ...
|
|
293
|
+
- --- a/...
|
|
294
|
+
- +++ b/...
|
|
295
|
+
- @@ ... @@
|
|
296
|
+
- + <content>
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Filtered diff.
|
|
300
|
+
"""
|
|
301
|
+
result = []
|
|
302
|
+
for line in diff.split("\n"):
|
|
303
|
+
if (
|
|
304
|
+
line.startswith("diff --git")
|
|
305
|
+
or line.startswith("--- ")
|
|
306
|
+
or line.startswith("+++ ")
|
|
307
|
+
or line.startswith("@@")
|
|
308
|
+
or (line.startswith("+") and not line.startswith("+++"))
|
|
309
|
+
):
|
|
310
|
+
result.append(line)
|
|
311
|
+
# Context lines, deleted lines and '\ No newline' markers are discarded.
|
|
312
|
+
return "\n".join(result)
|
|
313
|
+
|
|
314
|
+
def _split_diff_sections(self, diff: str) -> tuple[list[list[str]], bool]:
|
|
315
|
+
"""
|
|
316
|
+
Splits the diff into sections per file ("diff --git ...").
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Tuple (sections, has_file_separators).
|
|
320
|
+
"""
|
|
321
|
+
lines = diff.split("\n")
|
|
322
|
+
has_sections = any(line.startswith("diff --git") for line in lines)
|
|
323
|
+
if not has_sections:
|
|
324
|
+
return [lines], False
|
|
325
|
+
|
|
326
|
+
sections: list[list[str]] = []
|
|
327
|
+
current: list[str] = []
|
|
328
|
+
for line in lines:
|
|
329
|
+
if line.startswith("diff --git") and current:
|
|
330
|
+
sections.append(current)
|
|
331
|
+
current = [line]
|
|
332
|
+
else:
|
|
333
|
+
current.append(line)
|
|
334
|
+
if current:
|
|
335
|
+
sections.append(current)
|
|
336
|
+
return sections, True
|
|
337
|
+
|
|
338
|
+
def limit_diff_files(self, diff: str, max_files: int = 50) -> tuple[str, bool, int]:
|
|
339
|
+
"""
|
|
340
|
+
Limits the number of files in the diff ("diff --git" sections).
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Tuple (limited_diff, was_limited, omitted_files).
|
|
344
|
+
"""
|
|
345
|
+
sections, has_file_sections = self._split_diff_sections(diff)
|
|
346
|
+
if not has_file_sections:
|
|
347
|
+
return diff, False, 0
|
|
348
|
+
|
|
349
|
+
total_files = len(sections)
|
|
350
|
+
if total_files <= max_files:
|
|
351
|
+
return diff, False, 0
|
|
352
|
+
|
|
353
|
+
kept_sections = sections[:max_files]
|
|
354
|
+
omitted_files = total_files - max_files
|
|
355
|
+
limited = "\n".join("\n".join(section) for section in kept_sections)
|
|
356
|
+
limited += (
|
|
357
|
+
f"\n\n... [TRUNCATED: {omitted_files} file(s) omitted. "
|
|
358
|
+
f"Total files in diff: {total_files}] ..."
|
|
359
|
+
)
|
|
360
|
+
return limited, True, omitted_files
|
|
361
|
+
|
|
362
|
+
def filter_diff_by_extensions(self, diff: str, extensions: list[str]) -> str:
|
|
363
|
+
"""
|
|
364
|
+
Filters the diff to include only files with specific extensions.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
diff: The full diff.
|
|
368
|
+
extensions: List of extensions (e.g., ['.py', '.js', '.cs']).
|
|
369
|
+
"""
|
|
370
|
+
if not extensions:
|
|
371
|
+
return diff
|
|
372
|
+
|
|
373
|
+
filtered_sections = []
|
|
374
|
+
current_section = []
|
|
375
|
+
include_section = False
|
|
376
|
+
|
|
377
|
+
for line in diff.split("\n"):
|
|
378
|
+
if line.startswith("diff --git"):
|
|
379
|
+
# Save previous section if applicable
|
|
380
|
+
if include_section and current_section:
|
|
381
|
+
filtered_sections.append("\n".join(current_section))
|
|
382
|
+
current_section = [line]
|
|
383
|
+
# Check if file has an allowed extension
|
|
384
|
+
file_path = line.split(" b/")[-1] if " b/" in line else ""
|
|
385
|
+
include_section = any(file_path.endswith(ext) for ext in extensions)
|
|
386
|
+
else:
|
|
387
|
+
current_section.append(line)
|
|
388
|
+
|
|
389
|
+
# Last section
|
|
390
|
+
if include_section and current_section:
|
|
391
|
+
filtered_sections.append("\n".join(current_section))
|
|
392
|
+
|
|
393
|
+
result = "\n".join(filtered_sections)
|
|
394
|
+
if not result.strip():
|
|
395
|
+
raise GitError(
|
|
396
|
+
f"After filtering by extensions {extensions}, no changes remain."
|
|
397
|
+
)
|
|
398
|
+
return result
|
|
399
|
+
|
|
400
|
+
def truncate_diff(self, diff: str, max_lines: int = 2000) -> tuple[str, bool]:
|
|
401
|
+
"""
|
|
402
|
+
Kept for compatibility: applies per-file truncation when
|
|
403
|
+
the diff contains sections in 'diff --git' format.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Tuple (truncated_diff, was_truncated).
|
|
407
|
+
"""
|
|
408
|
+
return self.truncate_diff_per_file(diff, max_lines)
|
|
409
|
+
|
|
410
|
+
def truncate_diff_per_file(self, diff: str, max_lines: int = 2000) -> tuple[str, bool]:
|
|
411
|
+
"""
|
|
412
|
+
Truncates the diff per file if it exceeds the maximum lines per section.
|
|
413
|
+
Falls back to global truncation if no file sections are present.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
Tuple (truncated_diff, was_truncated).
|
|
417
|
+
"""
|
|
418
|
+
sections, has_file_sections = self._split_diff_sections(diff)
|
|
419
|
+
|
|
420
|
+
# Fallback for diffs without file separators
|
|
421
|
+
if not has_file_sections:
|
|
422
|
+
lines = sections[0]
|
|
423
|
+
if len(lines) <= max_lines:
|
|
424
|
+
return diff, False
|
|
425
|
+
truncated = "\n".join(lines[:max_lines])
|
|
426
|
+
truncated += (
|
|
427
|
+
f"\n\n... [TRUNCATED: {len(lines) - max_lines} lines omitted. "
|
|
428
|
+
f"Total: {len(lines)} lines] ..."
|
|
429
|
+
)
|
|
430
|
+
return truncated, True
|
|
431
|
+
|
|
432
|
+
truncated_any = False
|
|
433
|
+
output_sections: list[str] = []
|
|
434
|
+
for section in sections:
|
|
435
|
+
if len(section) <= max_lines:
|
|
436
|
+
output_sections.append("\n".join(section))
|
|
437
|
+
continue
|
|
438
|
+
|
|
439
|
+
truncated_any = True
|
|
440
|
+
omitted = len(section) - max_lines
|
|
441
|
+
part = "\n".join(section[:max_lines])
|
|
442
|
+
part += (
|
|
443
|
+
f"\n... [TRUNCATED IN THIS FILE: {omitted} lines omitted. "
|
|
444
|
+
f"Original section: {len(section)} lines] ..."
|
|
445
|
+
)
|
|
446
|
+
output_sections.append(part)
|
|
447
|
+
|
|
448
|
+
return "\n".join(output_sections), truncated_any
|
|
449
|
+
|
|
450
|
+
def get_changed_files_summary(self, diff: str) -> list[dict]:
|
|
451
|
+
"""
|
|
452
|
+
Extracts a summary of changed files from the diff.
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
List of dicts with 'file', 'additions', 'deletions'.
|
|
456
|
+
"""
|
|
457
|
+
files = []
|
|
458
|
+
current_file = None
|
|
459
|
+
additions = 0
|
|
460
|
+
deletions = 0
|
|
461
|
+
|
|
462
|
+
for line in diff.split("\n"):
|
|
463
|
+
if line.startswith("diff --git"):
|
|
464
|
+
if current_file:
|
|
465
|
+
files.append({
|
|
466
|
+
"file": current_file,
|
|
467
|
+
"additions": additions,
|
|
468
|
+
"deletions": deletions,
|
|
469
|
+
})
|
|
470
|
+
# Extract file name
|
|
471
|
+
parts = line.split(" b/")
|
|
472
|
+
current_file = parts[-1] if len(parts) > 1 else "unknown"
|
|
473
|
+
additions = 0
|
|
474
|
+
deletions = 0
|
|
475
|
+
elif line.startswith("+") and not line.startswith("+++"):
|
|
476
|
+
additions += 1
|
|
477
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
478
|
+
deletions += 1
|
|
479
|
+
|
|
480
|
+
if current_file:
|
|
481
|
+
files.append({
|
|
482
|
+
"file": current_file,
|
|
483
|
+
"additions": additions,
|
|
484
|
+
"deletions": deletions,
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
return files
|