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/tfs_client.py
ADDED
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TFS/Azure DevOps Module - AI Code Review
|
|
3
|
+
==========================================
|
|
4
|
+
Integration with Team Foundation Server and Azure DevOps for:
|
|
5
|
+
- Listing Pull Requests (with filters by repository, author, status)
|
|
6
|
+
- Getting full Pull Request details (diff, files, commits)
|
|
7
|
+
- Posting review comments on the PR:
|
|
8
|
+
- Inline comments (on specific code lines)
|
|
9
|
+
- General PR comments
|
|
10
|
+
- Comment thread support
|
|
11
|
+
|
|
12
|
+
Uses Azure DevOps REST API v7.0+.
|
|
13
|
+
Works with both on-premises TFS and Azure DevOps Services.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import difflib
|
|
18
|
+
import os
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from .config import ReviewConfig
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TFSError(Exception):
|
|
25
|
+
"""Exception for TFS/Azure DevOps communication errors."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TFSClient:
|
|
30
|
+
"""Client for Azure DevOps / TFS REST API."""
|
|
31
|
+
|
|
32
|
+
API_VERSION = "7.0"
|
|
33
|
+
|
|
34
|
+
def __init__(self, config: ReviewConfig):
|
|
35
|
+
self.config = config
|
|
36
|
+
self.base_url = config.tfs_base_url.rstrip("/")
|
|
37
|
+
self.collection = config.tfs_collection
|
|
38
|
+
self.project = config.tfs_project
|
|
39
|
+
self.pat = config.tfs_pat
|
|
40
|
+
|
|
41
|
+
if not all([self.base_url, self.project, self.pat]):
|
|
42
|
+
raise TFSError(
|
|
43
|
+
"Incomplete TFS configuration. Required:\n"
|
|
44
|
+
" - TFS_BASE_URL (e.g., https://dev.azure.com/org or https://tfs.company.com/tfs)\n"
|
|
45
|
+
" - TFS_PROJECT (project name)\n"
|
|
46
|
+
" - TFS_PAT (Personal Access Token)"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
self._session = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def session(self):
|
|
53
|
+
"""Lazy HTTP session initialization."""
|
|
54
|
+
if self._session is None:
|
|
55
|
+
try:
|
|
56
|
+
import requests
|
|
57
|
+
except ImportError:
|
|
58
|
+
raise TFSError("Module 'requests' required: pip install requests")
|
|
59
|
+
|
|
60
|
+
self._session = requests.Session()
|
|
61
|
+
auth_string = base64.b64encode(f":{self.pat}".encode()).decode()
|
|
62
|
+
self._session.headers.update({
|
|
63
|
+
"Authorization": f"Basic {auth_string}",
|
|
64
|
+
"Content-Type": "application/json",
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
# SSL/TLS: prefer CA bundle for corporate environments; allow opt-out for troubleshooting.
|
|
68
|
+
if self.config.tfs_ca_bundle:
|
|
69
|
+
ca_path = os.path.expandvars(os.path.expanduser(self.config.tfs_ca_bundle))
|
|
70
|
+
if not os.path.isfile(ca_path):
|
|
71
|
+
raise TFSError(
|
|
72
|
+
f"TFS_CA_BUNDLE configured but file does not exist: {ca_path}"
|
|
73
|
+
)
|
|
74
|
+
self._session.verify = ca_path
|
|
75
|
+
else:
|
|
76
|
+
self._session.verify = bool(self.config.tfs_verify_ssl)
|
|
77
|
+
if self._session.verify is False:
|
|
78
|
+
try:
|
|
79
|
+
import urllib3
|
|
80
|
+
|
|
81
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
return self._session
|
|
85
|
+
|
|
86
|
+
def _api_url(self, path: str, api_version: Optional[str] = None) -> str:
|
|
87
|
+
"""Builds the API URL."""
|
|
88
|
+
version = api_version or self.API_VERSION
|
|
89
|
+
|
|
90
|
+
if "dev.azure.com" in self.base_url or "visualstudio.com" in self.base_url:
|
|
91
|
+
base = f"{self.base_url}/{self.project}/_apis"
|
|
92
|
+
else:
|
|
93
|
+
base = f"{self.base_url}/{self.collection}/{self.project}/_apis"
|
|
94
|
+
|
|
95
|
+
url = f"{base}/{path}"
|
|
96
|
+
separator = "&" if "?" in url else "?"
|
|
97
|
+
url += f"{separator}api-version={version}"
|
|
98
|
+
return url
|
|
99
|
+
|
|
100
|
+
def _get(self, path: str, params: Optional[dict] = None,
|
|
101
|
+
api_version: Optional[str] = None) -> dict:
|
|
102
|
+
"""Makes a GET request to the API."""
|
|
103
|
+
url = self._api_url(path, api_version)
|
|
104
|
+
try:
|
|
105
|
+
resp = self.session.get(url, params=params, timeout=30)
|
|
106
|
+
resp.raise_for_status()
|
|
107
|
+
return resp.json()
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
raise TFSError(f"Error accessing TFS API ({path}): {exc}")
|
|
110
|
+
|
|
111
|
+
def _post(self, path: str, data: dict,
|
|
112
|
+
api_version: Optional[str] = None) -> dict:
|
|
113
|
+
"""Makes a POST request to the API."""
|
|
114
|
+
url = self._api_url(path, api_version)
|
|
115
|
+
try:
|
|
116
|
+
resp = self.session.post(url, json=data, timeout=30)
|
|
117
|
+
resp.raise_for_status()
|
|
118
|
+
return resp.json()
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
raise TFSError(f"Error sending to TFS API ({path}): {exc}")
|
|
121
|
+
|
|
122
|
+
def _patch(self, path: str, data: dict,
|
|
123
|
+
api_version: Optional[str] = None) -> dict:
|
|
124
|
+
"""Makes a PATCH request to the API."""
|
|
125
|
+
url = self._api_url(path, api_version)
|
|
126
|
+
try:
|
|
127
|
+
resp = self.session.patch(url, json=data, timeout=30)
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
return resp.json()
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
raise TFSError(f"Error updating TFS API ({path}): {exc}")
|
|
132
|
+
|
|
133
|
+
# ==================================================================
|
|
134
|
+
# Pull Requests - Listing
|
|
135
|
+
# ==================================================================
|
|
136
|
+
def list_pull_requests(self, status: str = "active",
|
|
137
|
+
repository: Optional[str] = None,
|
|
138
|
+
author: Optional[str] = None,
|
|
139
|
+
reviewer: Optional[str] = None,
|
|
140
|
+
source_branch: Optional[str] = None,
|
|
141
|
+
target_branch: Optional[str] = None,
|
|
142
|
+
top: int = 50) -> list[dict]:
|
|
143
|
+
"""
|
|
144
|
+
Lists Pull Requests in the project with advanced filters.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
status: "active", "completed", "abandoned", "all"
|
|
148
|
+
repository: Repository name (if None, lists all).
|
|
149
|
+
author: Filter by author (display name or ID).
|
|
150
|
+
reviewer: Filter by reviewer.
|
|
151
|
+
source_branch: Filter by source branch.
|
|
152
|
+
target_branch: Filter by target branch.
|
|
153
|
+
top: Maximum number of PRs to return.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
List of PRs with summary information.
|
|
157
|
+
"""
|
|
158
|
+
if repository:
|
|
159
|
+
path = f"git/repositories/{repository}/pullrequests"
|
|
160
|
+
else:
|
|
161
|
+
path = "git/pullrequests"
|
|
162
|
+
|
|
163
|
+
params = {"$top": top}
|
|
164
|
+
|
|
165
|
+
if status != "all":
|
|
166
|
+
params["searchCriteria.status"] = status
|
|
167
|
+
|
|
168
|
+
if author:
|
|
169
|
+
params["searchCriteria.creatorId"] = author
|
|
170
|
+
|
|
171
|
+
if reviewer:
|
|
172
|
+
params["searchCriteria.reviewerId"] = reviewer
|
|
173
|
+
|
|
174
|
+
if source_branch:
|
|
175
|
+
if not source_branch.startswith("refs/heads/"):
|
|
176
|
+
source_branch = f"refs/heads/{source_branch}"
|
|
177
|
+
params["searchCriteria.sourceRefName"] = source_branch
|
|
178
|
+
|
|
179
|
+
if target_branch:
|
|
180
|
+
if not target_branch.startswith("refs/heads/"):
|
|
181
|
+
target_branch = f"refs/heads/{target_branch}"
|
|
182
|
+
params["searchCriteria.targetRefName"] = target_branch
|
|
183
|
+
|
|
184
|
+
data = self._get(path, params)
|
|
185
|
+
prs = []
|
|
186
|
+
for pr in data.get("value", []):
|
|
187
|
+
prs.append(self._parse_pr_summary(pr))
|
|
188
|
+
return prs
|
|
189
|
+
|
|
190
|
+
def _parse_pr_summary(self, pr: dict) -> dict:
|
|
191
|
+
"""Extracts summary information from a PR."""
|
|
192
|
+
reviewers = []
|
|
193
|
+
for r in pr.get("reviewers", []):
|
|
194
|
+
vote_map = {10: "✅ Approved", 5: "👍 Approved w/ suggestions",
|
|
195
|
+
0: "⏳ No vote", -5: "⏸️ Waiting for author", -10: "❌ Rejected"}
|
|
196
|
+
reviewers.append({
|
|
197
|
+
"name": r.get("displayName", ""),
|
|
198
|
+
"vote": r.get("vote", 0),
|
|
199
|
+
"vote_label": vote_map.get(r.get("vote", 0), "?"),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"id": pr["pullRequestId"],
|
|
204
|
+
"title": pr["title"],
|
|
205
|
+
"description": pr.get("description", ""),
|
|
206
|
+
"author": pr["createdBy"]["displayName"],
|
|
207
|
+
"author_id": pr["createdBy"].get("id", ""),
|
|
208
|
+
"source_branch": pr["sourceRefName"].replace("refs/heads/", ""),
|
|
209
|
+
"target_branch": pr["targetRefName"].replace("refs/heads/", ""),
|
|
210
|
+
"status": pr["status"],
|
|
211
|
+
"creation_date": pr["creationDate"],
|
|
212
|
+
"repository": pr["repository"]["name"],
|
|
213
|
+
"repository_id": pr["repository"]["id"],
|
|
214
|
+
"merge_status": pr.get("mergeStatus", ""),
|
|
215
|
+
"reviewers": reviewers,
|
|
216
|
+
"labels": [l.get("name", "") for l in pr.get("labels", [])],
|
|
217
|
+
"is_draft": pr.get("isDraft", False),
|
|
218
|
+
"url": pr.get("url", ""),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# ==================================================================
|
|
222
|
+
# Pull Requests - Details
|
|
223
|
+
# ==================================================================
|
|
224
|
+
def get_pull_request_details(self, repository: str, pr_id: int) -> dict:
|
|
225
|
+
"""
|
|
226
|
+
Gets full details of a Pull Request.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
repository: Repository name.
|
|
230
|
+
pr_id: Pull Request ID.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Dict with PR details including diff, changed files, commits.
|
|
234
|
+
"""
|
|
235
|
+
# Base PR details
|
|
236
|
+
path = f"git/repositories/{repository}/pullrequests/{pr_id}"
|
|
237
|
+
pr_data = self._get(path)
|
|
238
|
+
pr_summary = self._parse_pr_summary(pr_data)
|
|
239
|
+
|
|
240
|
+
# PR Commits
|
|
241
|
+
commits_path = f"git/repositories/{repository}/pullrequests/{pr_id}/commits"
|
|
242
|
+
try:
|
|
243
|
+
commits_data = self._get(commits_path)
|
|
244
|
+
commits = []
|
|
245
|
+
for c in commits_data.get("value", []):
|
|
246
|
+
commits.append({
|
|
247
|
+
"id": c.get("commitId", ""),
|
|
248
|
+
"short_id": c.get("commitId", "")[:8],
|
|
249
|
+
"message": c.get("comment", ""),
|
|
250
|
+
"author": c.get("author", {}).get("name", ""),
|
|
251
|
+
"date": c.get("author", {}).get("date", ""),
|
|
252
|
+
})
|
|
253
|
+
except TFSError:
|
|
254
|
+
commits = []
|
|
255
|
+
|
|
256
|
+
# Changed files
|
|
257
|
+
changed_files = self._get_pr_changed_files(repository, pr_id)
|
|
258
|
+
|
|
259
|
+
pr_summary["commits"] = commits
|
|
260
|
+
pr_summary["changed_files"] = changed_files
|
|
261
|
+
return pr_summary
|
|
262
|
+
|
|
263
|
+
def _get_pr_changed_files(self, repository: str, pr_id: int) -> list[dict]:
|
|
264
|
+
"""Gets the list of files changed in a PR."""
|
|
265
|
+
iterations_path = f"git/repositories/{repository}/pullrequests/{pr_id}/iterations"
|
|
266
|
+
try:
|
|
267
|
+
iterations = self._get(iterations_path)
|
|
268
|
+
except TFSError:
|
|
269
|
+
return []
|
|
270
|
+
|
|
271
|
+
if not iterations.get("value"):
|
|
272
|
+
return []
|
|
273
|
+
|
|
274
|
+
last_iteration = iterations["value"][-1]["id"]
|
|
275
|
+
changes_path = (
|
|
276
|
+
f"git/repositories/{repository}/pullrequests/{pr_id}"
|
|
277
|
+
f"/iterations/{last_iteration}/changes"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
changes = self._get(changes_path)
|
|
282
|
+
except TFSError:
|
|
283
|
+
return []
|
|
284
|
+
|
|
285
|
+
files = []
|
|
286
|
+
for change in changes.get("changeEntries", []):
|
|
287
|
+
item = change.get("item", {})
|
|
288
|
+
if item.get("isFolder"):
|
|
289
|
+
continue
|
|
290
|
+
files.append({
|
|
291
|
+
"path": item.get("path", ""),
|
|
292
|
+
"change_type": change.get("changeType", "unknown"),
|
|
293
|
+
"original_path": change.get("originalPath", ""),
|
|
294
|
+
})
|
|
295
|
+
return files
|
|
296
|
+
|
|
297
|
+
def get_pull_request_diff(self, repository: str, pr_id: int,
|
|
298
|
+
review_scope: str = "diff_only") -> str:
|
|
299
|
+
"""
|
|
300
|
+
Gets the diff of a specific Pull Request.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
repository: Repository name.
|
|
304
|
+
pr_id: Pull Request ID.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Diff as text.
|
|
308
|
+
"""
|
|
309
|
+
# Get PR details
|
|
310
|
+
path = f"git/repositories/{repository}/pullrequests/{pr_id}"
|
|
311
|
+
pr_data = self._get(path)
|
|
312
|
+
|
|
313
|
+
source_branch = pr_data["sourceRefName"]
|
|
314
|
+
target_branch = pr_data["targetRefName"]
|
|
315
|
+
|
|
316
|
+
# Get PR iterations
|
|
317
|
+
iterations_path = f"git/repositories/{repository}/pullrequests/{pr_id}/iterations"
|
|
318
|
+
iterations = self._get(iterations_path)
|
|
319
|
+
|
|
320
|
+
if not iterations.get("value"):
|
|
321
|
+
raise TFSError(f"PR #{pr_id} has no iterations/changes.")
|
|
322
|
+
|
|
323
|
+
# Get changes from the last iteration
|
|
324
|
+
last_iteration = iterations["value"][-1]["id"]
|
|
325
|
+
changes_path = (
|
|
326
|
+
f"git/repositories/{repository}/pullrequests/{pr_id}"
|
|
327
|
+
f"/iterations/{last_iteration}/changes"
|
|
328
|
+
)
|
|
329
|
+
changes = self._get(changes_path)
|
|
330
|
+
|
|
331
|
+
review_scope = (review_scope or "diff_only").lower()
|
|
332
|
+
|
|
333
|
+
# Build diff from the changes
|
|
334
|
+
diff_parts = []
|
|
335
|
+
for change in changes.get("changeEntries", []):
|
|
336
|
+
item = change.get("item", {})
|
|
337
|
+
change_type = change.get("changeType", "unknown")
|
|
338
|
+
file_path = item.get("path", "unknown")
|
|
339
|
+
original_path = change.get("originalPath") or file_path
|
|
340
|
+
|
|
341
|
+
if item.get("isFolder"):
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
if review_scope == "full_code":
|
|
345
|
+
diff_parts.extend(
|
|
346
|
+
self._build_full_code_diff_part(
|
|
347
|
+
repository=repository,
|
|
348
|
+
file_path=file_path,
|
|
349
|
+
change_type=change_type,
|
|
350
|
+
source_branch=source_branch,
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
diff_parts.append("")
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
diff_parts.extend(
|
|
357
|
+
self._build_unified_diff_part(
|
|
358
|
+
repository=repository,
|
|
359
|
+
file_path=file_path,
|
|
360
|
+
original_path=original_path,
|
|
361
|
+
change_type=change_type,
|
|
362
|
+
source_branch=source_branch,
|
|
363
|
+
target_branch=target_branch,
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
diff_parts.append("")
|
|
367
|
+
|
|
368
|
+
if not diff_parts:
|
|
369
|
+
raise TFSError(f"PR #{pr_id} contains no file changes.")
|
|
370
|
+
|
|
371
|
+
return "\n".join(diff_parts)
|
|
372
|
+
|
|
373
|
+
def _build_full_code_diff_part(self, repository: str, file_path: str,
|
|
374
|
+
change_type: str, source_branch: str) -> list[str]:
|
|
375
|
+
"""Builds a full_code-style payload with the complete content of the new version."""
|
|
376
|
+
parts = [
|
|
377
|
+
f"diff --git a{file_path} b{file_path}",
|
|
378
|
+
f"--- a{file_path}",
|
|
379
|
+
f"+++ b{file_path}",
|
|
380
|
+
f"@@ Change type: {change_type} @@",
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
if change_type in ("edit", "add", "rename"):
|
|
384
|
+
try:
|
|
385
|
+
content = self._get_file_content(
|
|
386
|
+
repository,
|
|
387
|
+
file_path,
|
|
388
|
+
version=source_branch.replace("refs/heads/", ""),
|
|
389
|
+
version_type="branch",
|
|
390
|
+
)
|
|
391
|
+
if content:
|
|
392
|
+
for line in content.split("\n"):
|
|
393
|
+
parts.append(f"+{line}")
|
|
394
|
+
except Exception:
|
|
395
|
+
parts.append(f"+[Content not available for {file_path}]")
|
|
396
|
+
|
|
397
|
+
return parts
|
|
398
|
+
|
|
399
|
+
def _build_unified_diff_part(self, repository: str, file_path: str,
|
|
400
|
+
original_path: str, change_type: str,
|
|
401
|
+
source_branch: str, target_branch: str) -> list[str]:
|
|
402
|
+
"""Builds a unified diff with only changed lines for diff_only."""
|
|
403
|
+
old_lines: list[str] = []
|
|
404
|
+
new_lines: list[str] = []
|
|
405
|
+
|
|
406
|
+
source_ref = source_branch.replace("refs/heads/", "")
|
|
407
|
+
target_ref = target_branch.replace("refs/heads/", "")
|
|
408
|
+
|
|
409
|
+
if change_type in ("edit", "rename", "delete"):
|
|
410
|
+
try:
|
|
411
|
+
old_content = self._get_file_content(
|
|
412
|
+
repository,
|
|
413
|
+
original_path,
|
|
414
|
+
version=target_ref,
|
|
415
|
+
version_type="branch",
|
|
416
|
+
)
|
|
417
|
+
old_lines = old_content.splitlines()
|
|
418
|
+
except Exception:
|
|
419
|
+
old_lines = []
|
|
420
|
+
|
|
421
|
+
if change_type in ("edit", "rename", "add"):
|
|
422
|
+
try:
|
|
423
|
+
new_content = self._get_file_content(
|
|
424
|
+
repository,
|
|
425
|
+
file_path,
|
|
426
|
+
version=source_ref,
|
|
427
|
+
version_type="branch",
|
|
428
|
+
)
|
|
429
|
+
new_lines = new_content.splitlines()
|
|
430
|
+
except Exception:
|
|
431
|
+
new_lines = []
|
|
432
|
+
|
|
433
|
+
diff = list(difflib.unified_diff(
|
|
434
|
+
old_lines,
|
|
435
|
+
new_lines,
|
|
436
|
+
fromfile=f"a{original_path}",
|
|
437
|
+
tofile=f"b{file_path}",
|
|
438
|
+
lineterm="",
|
|
439
|
+
n=3,
|
|
440
|
+
))
|
|
441
|
+
|
|
442
|
+
if not diff:
|
|
443
|
+
return [
|
|
444
|
+
f"diff --git a{original_path} b{file_path}",
|
|
445
|
+
f"--- a{original_path}",
|
|
446
|
+
f"+++ b{file_path}",
|
|
447
|
+
"@@ No textual differences detected (possible binary/metadata change) @@",
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
# difflib does not include the "diff --git" header — always add it
|
|
451
|
+
# so that filter_diff_by_extensions and _split_diff_sections work correctly.
|
|
452
|
+
return [f"diff --git a{original_path} b{file_path}"] + diff
|
|
453
|
+
|
|
454
|
+
def _get_raw(self, path: str, params: Optional[dict] = None,
|
|
455
|
+
api_version: Optional[str] = None) -> str:
|
|
456
|
+
"""Makes a GET request to the API and returns the response as raw text."""
|
|
457
|
+
url = self._api_url(path, api_version)
|
|
458
|
+
try:
|
|
459
|
+
resp = self.session.get(url, params=params, timeout=30)
|
|
460
|
+
resp.raise_for_status()
|
|
461
|
+
return resp.text
|
|
462
|
+
except Exception as exc:
|
|
463
|
+
raise TFSError(f"Error accessing TFS API ({path}): {exc}")
|
|
464
|
+
|
|
465
|
+
def _get_file_content(self, repository: str, file_path: str,
|
|
466
|
+
version: str = "", version_type: str = "branch") -> str:
|
|
467
|
+
"""Gets the content of a file from the repository."""
|
|
468
|
+
path = f"git/repositories/{repository}/items"
|
|
469
|
+
params = {
|
|
470
|
+
"path": file_path,
|
|
471
|
+
}
|
|
472
|
+
if version:
|
|
473
|
+
params["versionDescriptor.version"] = version
|
|
474
|
+
params["versionDescriptor.versionType"] = version_type
|
|
475
|
+
|
|
476
|
+
return self._get_raw(path, params)
|
|
477
|
+
|
|
478
|
+
# ==================================================================
|
|
479
|
+
# Pull Requests - Comments
|
|
480
|
+
# ==================================================================
|
|
481
|
+
def post_general_comment(self, repository: str, pr_id: int,
|
|
482
|
+
comment: str, status: str = "active") -> dict:
|
|
483
|
+
"""
|
|
484
|
+
Posts a general comment on a Pull Request (not associated with a file).
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
repository: Repository name.
|
|
488
|
+
pr_id: Pull Request ID.
|
|
489
|
+
comment: Comment text (supports Markdown).
|
|
490
|
+
status: Thread status - "active", "fixed", "wontFix",
|
|
491
|
+
"closed", "pending", "byDesign"
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
Created thread data.
|
|
495
|
+
"""
|
|
496
|
+
path = f"git/repositories/{repository}/pullrequests/{pr_id}/threads"
|
|
497
|
+
data = {
|
|
498
|
+
"comments": [
|
|
499
|
+
{
|
|
500
|
+
"parentCommentId": 0,
|
|
501
|
+
"content": comment,
|
|
502
|
+
"commentType": 1, # text
|
|
503
|
+
}
|
|
504
|
+
],
|
|
505
|
+
"status": 1 if status == "active" else self._status_to_int(status),
|
|
506
|
+
}
|
|
507
|
+
return self._post(path, data)
|
|
508
|
+
|
|
509
|
+
def post_inline_comment(self, repository: str, pr_id: int,
|
|
510
|
+
file_path: str, line: int, comment: str,
|
|
511
|
+
status: str = "active",
|
|
512
|
+
right_file: bool = True) -> dict:
|
|
513
|
+
"""
|
|
514
|
+
Posts an inline comment on a specific line of a PR file.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
repository: Repository name.
|
|
518
|
+
pr_id: Pull Request ID.
|
|
519
|
+
file_path: File path (e.g., "/src/auth.py").
|
|
520
|
+
line: Line number where the comment will be posted.
|
|
521
|
+
comment: Comment text (supports Markdown).
|
|
522
|
+
status: Thread status.
|
|
523
|
+
right_file: True for the new version file (right-side),
|
|
524
|
+
False for the old version file (left-side).
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
Created thread data.
|
|
528
|
+
"""
|
|
529
|
+
# Normalize path
|
|
530
|
+
if not file_path.startswith("/"):
|
|
531
|
+
file_path = f"/{file_path}"
|
|
532
|
+
|
|
533
|
+
# Get most recent iteration
|
|
534
|
+
iterations_path = f"git/repositories/{repository}/pullrequests/{pr_id}/iterations"
|
|
535
|
+
iterations = self._get(iterations_path)
|
|
536
|
+
if not iterations.get("value"):
|
|
537
|
+
raise TFSError(f"PR #{pr_id} has no iterations.")
|
|
538
|
+
last_iteration = iterations["value"][-1]["id"]
|
|
539
|
+
|
|
540
|
+
path = f"git/repositories/{repository}/pullrequests/{pr_id}/threads"
|
|
541
|
+
|
|
542
|
+
thread_context = {
|
|
543
|
+
"filePath": file_path,
|
|
544
|
+
"rightFileStart": {"line": line, "offset": 1} if right_file else None,
|
|
545
|
+
"rightFileEnd": {"line": line, "offset": 1} if right_file else None,
|
|
546
|
+
"leftFileStart": {"line": line, "offset": 1} if not right_file else None,
|
|
547
|
+
"leftFileEnd": {"line": line, "offset": 1} if not right_file else None,
|
|
548
|
+
}
|
|
549
|
+
# Remover Nones
|
|
550
|
+
thread_context = {k: v for k, v in thread_context.items() if v is not None}
|
|
551
|
+
|
|
552
|
+
data = {
|
|
553
|
+
"comments": [
|
|
554
|
+
{
|
|
555
|
+
"parentCommentId": 0,
|
|
556
|
+
"content": comment,
|
|
557
|
+
"commentType": 1,
|
|
558
|
+
}
|
|
559
|
+
],
|
|
560
|
+
"status": 1 if status == "active" else self._status_to_int(status),
|
|
561
|
+
"threadContext": thread_context,
|
|
562
|
+
"pullRequestThreadContext": {
|
|
563
|
+
"iterationContext": {
|
|
564
|
+
"firstComparingIteration": 1,
|
|
565
|
+
"secondComparingIteration": last_iteration,
|
|
566
|
+
},
|
|
567
|
+
"changeTrackingId": 0,
|
|
568
|
+
},
|
|
569
|
+
}
|
|
570
|
+
return self._post(path, data)
|
|
571
|
+
|
|
572
|
+
def reply_to_thread(self, repository: str, pr_id: int,
|
|
573
|
+
thread_id: int, comment: str) -> dict:
|
|
574
|
+
"""
|
|
575
|
+
Replies to an existing comment thread.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
repository: Repository name.
|
|
579
|
+
pr_id: Pull Request ID.
|
|
580
|
+
thread_id: Thread ID to reply to.
|
|
581
|
+
comment: Reply text.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
Created comment data.
|
|
585
|
+
"""
|
|
586
|
+
path = (
|
|
587
|
+
f"git/repositories/{repository}/pullrequests/{pr_id}"
|
|
588
|
+
f"/threads/{thread_id}/comments"
|
|
589
|
+
)
|
|
590
|
+
data = {
|
|
591
|
+
"parentCommentId": 1, # Reply to the first comment
|
|
592
|
+
"content": comment,
|
|
593
|
+
"commentType": 1,
|
|
594
|
+
}
|
|
595
|
+
return self._post(path, data)
|
|
596
|
+
|
|
597
|
+
def update_thread_status(self, repository: str, pr_id: int,
|
|
598
|
+
thread_id: int, status: str) -> dict:
|
|
599
|
+
"""
|
|
600
|
+
Updates the status of a comment thread.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
repository: Repository name.
|
|
604
|
+
pr_id: Pull Request ID.
|
|
605
|
+
thread_id: Thread ID.
|
|
606
|
+
status: New status ("active", "fixed", "wontFix", "closed", "pending").
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
Updated thread data.
|
|
610
|
+
"""
|
|
611
|
+
path = (
|
|
612
|
+
f"git/repositories/{repository}/pullrequests/{pr_id}"
|
|
613
|
+
f"/threads/{thread_id}"
|
|
614
|
+
)
|
|
615
|
+
data = {"status": self._status_to_int(status)}
|
|
616
|
+
return self._patch(path, data)
|
|
617
|
+
|
|
618
|
+
def post_review_comments(self, repository: str, pr_id: int,
|
|
619
|
+
comments: list[dict],
|
|
620
|
+
review_scope: str = "diff_only",
|
|
621
|
+
comment_mode: str = "structured") -> list[dict]:
|
|
622
|
+
"""
|
|
623
|
+
Posts multiple review comments on a PR.
|
|
624
|
+
Maps structured LLM comments to the Azure DevOps API.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
repository: Repository name.
|
|
628
|
+
pr_id: Pull Request ID.
|
|
629
|
+
comments: List of structured LLM comments with keys:
|
|
630
|
+
file, line, type, severity, comment, suggestion
|
|
631
|
+
|
|
632
|
+
Returns:
|
|
633
|
+
List of results for each posted comment.
|
|
634
|
+
"""
|
|
635
|
+
results = []
|
|
636
|
+
review_scope = (review_scope or "diff_only").lower()
|
|
637
|
+
comment_mode = (comment_mode or "structured").lower()
|
|
638
|
+
use_inline_comments = comment_mode == "structured"
|
|
639
|
+
|
|
640
|
+
for c in comments:
|
|
641
|
+
# Build formatted comment text
|
|
642
|
+
text = self._format_review_comment(c)
|
|
643
|
+
|
|
644
|
+
file_path = c.get("file", "")
|
|
645
|
+
line = c.get("line", 0)
|
|
646
|
+
comment_type = str(c.get("type", "")).lower()
|
|
647
|
+
is_problem = comment_type not in ("praise", "")
|
|
648
|
+
|
|
649
|
+
try:
|
|
650
|
+
if use_inline_comments and file_path and line > 0:
|
|
651
|
+
# Inline comment
|
|
652
|
+
result = self.post_inline_comment(
|
|
653
|
+
repository, pr_id, file_path, line, text
|
|
654
|
+
)
|
|
655
|
+
else:
|
|
656
|
+
# No inline position: post as general PR comment
|
|
657
|
+
result = self.post_general_comment(
|
|
658
|
+
repository, pr_id, text
|
|
659
|
+
)
|
|
660
|
+
results.append({
|
|
661
|
+
"success": True,
|
|
662
|
+
"file": file_path,
|
|
663
|
+
"line": line,
|
|
664
|
+
"thread_id": result.get("id"),
|
|
665
|
+
})
|
|
666
|
+
except TFSError as exc:
|
|
667
|
+
results.append({
|
|
668
|
+
"success": False,
|
|
669
|
+
"file": file_path,
|
|
670
|
+
"line": line,
|
|
671
|
+
"error": str(exc),
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
return results
|
|
675
|
+
|
|
676
|
+
def _format_review_comment(self, comment: dict) -> str:
|
|
677
|
+
"""Formats a structured comment for Azure DevOps Markdown."""
|
|
678
|
+
type_labels = {
|
|
679
|
+
"bug": "Bug",
|
|
680
|
+
"security": "Security",
|
|
681
|
+
"performance": "Performance",
|
|
682
|
+
"style": "Code Style",
|
|
683
|
+
"suggestion": "Suggestion",
|
|
684
|
+
"praise": "Positive",
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
severity = comment.get("severity", "info")
|
|
688
|
+
comment_type = comment.get("type", "suggestion")
|
|
689
|
+
label = type_labels.get(comment_type, comment_type.title())
|
|
690
|
+
|
|
691
|
+
parts = [f"**{label}** ({severity.upper()})"]
|
|
692
|
+
parts.append("")
|
|
693
|
+
parts.append(comment.get("comment", ""))
|
|
694
|
+
|
|
695
|
+
suggestion = comment.get("suggestion", "")
|
|
696
|
+
if suggestion:
|
|
697
|
+
parts.append("")
|
|
698
|
+
parts.append(f"**Suggestion:** {suggestion}")
|
|
699
|
+
|
|
700
|
+
reference = comment.get("reference", "")
|
|
701
|
+
if reference:
|
|
702
|
+
parts.append("")
|
|
703
|
+
parts.append(f"**Reference:** {reference}")
|
|
704
|
+
|
|
705
|
+
return "\n".join(parts)
|
|
706
|
+
|
|
707
|
+
def _status_to_int(self, status: str) -> int:
|
|
708
|
+
"""Converts status string to API integer."""
|
|
709
|
+
status_map = {
|
|
710
|
+
"active": 1,
|
|
711
|
+
"fixed": 2,
|
|
712
|
+
"wontfix": 3,
|
|
713
|
+
"closed": 4,
|
|
714
|
+
"bydesign": 5,
|
|
715
|
+
"pending": 6,
|
|
716
|
+
}
|
|
717
|
+
return status_map.get(status.lower(), 1)
|
|
718
|
+
|
|
719
|
+
# ==================================================================
|
|
720
|
+
# Pull Requests - Compatibility (legacy method)
|
|
721
|
+
# ==================================================================
|
|
722
|
+
def add_pr_comment(self, repository: str, pr_id: int,
|
|
723
|
+
comment: str, status: str = "active") -> dict:
|
|
724
|
+
"""Legacy alias for post_general_comment."""
|
|
725
|
+
return self.post_general_comment(repository, pr_id, comment, status)
|
|
726
|
+
|
|
727
|
+
# ==================================================================
|
|
728
|
+
# Repositories
|
|
729
|
+
# ==================================================================
|
|
730
|
+
def list_repositories(self) -> list[dict]:
|
|
731
|
+
"""Lists project repositories."""
|
|
732
|
+
data = self._get("git/repositories")
|
|
733
|
+
repos = []
|
|
734
|
+
for repo in data.get("value", []):
|
|
735
|
+
repos.append({
|
|
736
|
+
"id": repo["id"],
|
|
737
|
+
"name": repo["name"],
|
|
738
|
+
"url": repo.get("remoteUrl", ""),
|
|
739
|
+
"default_branch": repo.get("defaultBranch", "").replace(
|
|
740
|
+
"refs/heads/", ""
|
|
741
|
+
),
|
|
742
|
+
})
|
|
743
|
+
return repos
|
|
744
|
+
|
|
745
|
+
def get_repository_id(self, repo_name: str) -> str:
|
|
746
|
+
"""Gets a repository ID by name."""
|
|
747
|
+
repos = self.list_repositories()
|
|
748
|
+
for repo in repos:
|
|
749
|
+
if repo["name"].lower() == repo_name.lower():
|
|
750
|
+
return repo["id"]
|
|
751
|
+
raise TFSError(f"Repository '{repo_name}' not found in project.")
|