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/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.")