iam-policy-validator 1.4.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.

Potentially problematic release.


This version of iam-policy-validator might be problematic. Click here for more details.

Files changed (56) hide show
  1. iam_policy_validator-1.4.0.dist-info/METADATA +1022 -0
  2. iam_policy_validator-1.4.0.dist-info/RECORD +56 -0
  3. iam_policy_validator-1.4.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.4.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.4.0.dist-info/licenses/LICENSE +21 -0
  6. iam_validator/__init__.py +27 -0
  7. iam_validator/__main__.py +11 -0
  8. iam_validator/__version__.py +7 -0
  9. iam_validator/checks/__init__.py +27 -0
  10. iam_validator/checks/action_condition_enforcement.py +727 -0
  11. iam_validator/checks/action_resource_constraint.py +151 -0
  12. iam_validator/checks/action_validation.py +72 -0
  13. iam_validator/checks/condition_key_validation.py +70 -0
  14. iam_validator/checks/policy_size.py +151 -0
  15. iam_validator/checks/policy_type_validation.py +299 -0
  16. iam_validator/checks/principal_validation.py +282 -0
  17. iam_validator/checks/resource_validation.py +108 -0
  18. iam_validator/checks/security_best_practices.py +536 -0
  19. iam_validator/checks/sid_uniqueness.py +170 -0
  20. iam_validator/checks/utils/__init__.py +1 -0
  21. iam_validator/checks/utils/policy_level_checks.py +143 -0
  22. iam_validator/checks/utils/sensitive_action_matcher.py +252 -0
  23. iam_validator/checks/utils/wildcard_expansion.py +87 -0
  24. iam_validator/commands/__init__.py +25 -0
  25. iam_validator/commands/analyze.py +434 -0
  26. iam_validator/commands/base.py +48 -0
  27. iam_validator/commands/cache.py +392 -0
  28. iam_validator/commands/download_services.py +260 -0
  29. iam_validator/commands/post_to_pr.py +86 -0
  30. iam_validator/commands/validate.py +539 -0
  31. iam_validator/core/__init__.py +14 -0
  32. iam_validator/core/access_analyzer.py +666 -0
  33. iam_validator/core/access_analyzer_report.py +643 -0
  34. iam_validator/core/aws_fetcher.py +880 -0
  35. iam_validator/core/aws_global_conditions.py +137 -0
  36. iam_validator/core/check_registry.py +469 -0
  37. iam_validator/core/cli.py +134 -0
  38. iam_validator/core/config_loader.py +452 -0
  39. iam_validator/core/defaults.py +393 -0
  40. iam_validator/core/formatters/__init__.py +27 -0
  41. iam_validator/core/formatters/base.py +147 -0
  42. iam_validator/core/formatters/console.py +59 -0
  43. iam_validator/core/formatters/csv.py +170 -0
  44. iam_validator/core/formatters/enhanced.py +434 -0
  45. iam_validator/core/formatters/html.py +672 -0
  46. iam_validator/core/formatters/json.py +33 -0
  47. iam_validator/core/formatters/markdown.py +63 -0
  48. iam_validator/core/formatters/sarif.py +187 -0
  49. iam_validator/core/models.py +298 -0
  50. iam_validator/core/policy_checks.py +656 -0
  51. iam_validator/core/policy_loader.py +396 -0
  52. iam_validator/core/pr_commenter.py +338 -0
  53. iam_validator/core/report.py +859 -0
  54. iam_validator/integrations/__init__.py +28 -0
  55. iam_validator/integrations/github_integration.py +795 -0
  56. iam_validator/integrations/ms_teams.py +442 -0
@@ -0,0 +1,795 @@
1
+ """GitHub Integration Module.
2
+
3
+ This module provides functionality to interact with GitHub,
4
+ including posting PR comments, line comments, labels, and retrieving PR information.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ from enum import Enum
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class PRState(str, Enum):
18
+ """GitHub PR state."""
19
+
20
+ OPEN = "open"
21
+ CLOSED = "closed"
22
+ ALL = "all"
23
+
24
+
25
+ class ReviewEvent(str, Enum):
26
+ """GitHub PR review event types."""
27
+
28
+ APPROVE = "APPROVE"
29
+ REQUEST_CHANGES = "REQUEST_CHANGES"
30
+ COMMENT = "COMMENT"
31
+
32
+
33
+ class GitHubIntegration:
34
+ """Handles comprehensive GitHub API interactions for PRs.
35
+
36
+ This class provides methods to:
37
+ - Post general PR comments
38
+ - Add line-specific review comments
39
+ - Manage PR labels
40
+ - Submit PR reviews
41
+ - Retrieve PR information and files
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ token: str | None = None,
47
+ repository: str | None = None,
48
+ pr_number: str | None = None,
49
+ ):
50
+ """Initialize GitHub integration.
51
+
52
+ Args:
53
+ token: GitHub API token (defaults to GITHUB_TOKEN env var)
54
+ repository: Repository in format 'owner/repo' (defaults to GITHUB_REPOSITORY env var)
55
+ pr_number: PR number (defaults to GITHUB_PR_NUMBER env var)
56
+ """
57
+ self.token = self._validate_token(token or os.environ.get("GITHUB_TOKEN"))
58
+ self.repository = self._validate_repository(
59
+ repository or os.environ.get("GITHUB_REPOSITORY")
60
+ )
61
+ self.pr_number = self._validate_pr_number(pr_number or os.environ.get("GITHUB_PR_NUMBER"))
62
+ self.api_url = self._validate_api_url(
63
+ os.environ.get("GITHUB_API_URL", "https://api.github.com")
64
+ )
65
+ self._client: httpx.AsyncClient | None = None
66
+
67
+ def _validate_token(self, token: str | None) -> str | None:
68
+ """Validate and sanitize GitHub token.
69
+
70
+ Args:
71
+ token: GitHub token to validate
72
+
73
+ Returns:
74
+ Validated token or None
75
+ """
76
+ if token is None:
77
+ return None
78
+
79
+ # Basic validation - ensure it's a string and not empty
80
+ if not isinstance(token, str) or not token.strip():
81
+ logger.warning("Invalid GitHub token provided (empty or non-string)")
82
+ return None
83
+
84
+ # Sanitize - remove any whitespace
85
+ token = token.strip()
86
+
87
+ # Basic format check - GitHub tokens have specific patterns
88
+ # Personal access tokens: ghp_*, fine-grained: github_pat_*
89
+ # GitHub App tokens start with different prefixes
90
+ # Just ensure it's reasonable length and ASCII
91
+ if len(token) < 10 or len(token) > 500:
92
+ logger.warning(f"GitHub token has unusual length: {len(token)}")
93
+ return None
94
+
95
+ # Ensure only ASCII characters (tokens should be ASCII)
96
+ if not token.isascii():
97
+ logger.warning("GitHub token contains non-ASCII characters")
98
+ return None
99
+
100
+ return token
101
+
102
+ def _validate_repository(self, repository: str | None) -> str | None:
103
+ """Validate repository format (owner/repo).
104
+
105
+ Args:
106
+ repository: Repository string to validate
107
+
108
+ Returns:
109
+ Validated repository or None
110
+ """
111
+ if repository is None:
112
+ return None
113
+
114
+ if not isinstance(repository, str) or not repository.strip():
115
+ logger.warning("Invalid repository provided (empty or non-string)")
116
+ return None
117
+
118
+ repository = repository.strip()
119
+
120
+ # Must be in format owner/repo
121
+ if "/" not in repository:
122
+ logger.warning(f"Invalid repository format: {repository} (expected owner/repo)")
123
+ return None
124
+
125
+ parts = repository.split("/")
126
+ if len(parts) != 2:
127
+ logger.warning(f"Invalid repository format: {repository} (expected exactly one slash)")
128
+ return None
129
+
130
+ owner, repo = parts
131
+ if not owner or not repo:
132
+ logger.warning(f"Invalid repository format: {repository} (empty owner or repo)")
133
+ return None
134
+
135
+ # Basic sanitization - alphanumeric, hyphens, underscores, dots
136
+ # GitHub allows these characters in usernames and repo names
137
+ import re
138
+
139
+ valid_pattern = re.compile(r"^[a-zA-Z0-9._-]+$")
140
+ if not valid_pattern.match(owner) or not valid_pattern.match(repo):
141
+ logger.warning(
142
+ f"Invalid characters in repository: {repository} "
143
+ "(only alphanumeric, ., -, _ allowed)"
144
+ )
145
+ return None
146
+
147
+ return repository
148
+
149
+ def _validate_pr_number(self, pr_number: str | None) -> str | None:
150
+ """Validate PR number.
151
+
152
+ Args:
153
+ pr_number: PR number to validate
154
+
155
+ Returns:
156
+ Validated PR number or None
157
+ """
158
+ if pr_number is None:
159
+ return None
160
+
161
+ if not isinstance(pr_number, str) or not pr_number.strip():
162
+ logger.warning("Invalid PR number provided (empty or non-string)")
163
+ return None
164
+
165
+ pr_number = pr_number.strip()
166
+
167
+ # Must be a positive integer
168
+ try:
169
+ pr_int = int(pr_number)
170
+ if pr_int <= 0:
171
+ logger.warning(f"Invalid PR number: {pr_number} (must be positive)")
172
+ return None
173
+ except ValueError:
174
+ logger.warning(f"Invalid PR number: {pr_number} (must be an integer)")
175
+ return None
176
+
177
+ return pr_number
178
+
179
+ def _validate_api_url(self, api_url: str) -> str:
180
+ """Validate GitHub API URL.
181
+
182
+ Args:
183
+ api_url: API URL to validate
184
+
185
+ Returns:
186
+ Validated API URL or default
187
+ """
188
+ if not api_url or not isinstance(api_url, str):
189
+ logger.warning("Invalid API URL provided, using default")
190
+ return "https://api.github.com"
191
+
192
+ api_url = api_url.strip()
193
+
194
+ # Must be HTTPS (security requirement)
195
+ if not api_url.startswith("https://"):
196
+ logger.warning(
197
+ f"API URL must use HTTPS: {api_url}, using default https://api.github.com"
198
+ )
199
+ return "https://api.github.com"
200
+
201
+ # Basic URL validation
202
+ import re
203
+
204
+ # Simple URL pattern check
205
+ url_pattern = re.compile(r"^https://[a-zA-Z0-9.-]+(?:/.*)?$")
206
+ if not url_pattern.match(api_url):
207
+ logger.warning(f"Invalid API URL format: {api_url}, using default")
208
+ return "https://api.github.com"
209
+
210
+ return api_url
211
+
212
+ async def __aenter__(self) -> "GitHubIntegration":
213
+ """Async context manager entry."""
214
+ self._client = httpx.AsyncClient(
215
+ timeout=httpx.Timeout(30.0),
216
+ headers=self._get_headers(),
217
+ )
218
+ return self
219
+
220
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
221
+ """Async context manager exit."""
222
+ del exc_type, exc_val, exc_tb # Unused
223
+ if self._client:
224
+ await self._client.aclose()
225
+ self._client = None
226
+
227
+ def _get_headers(self) -> dict[str, str]:
228
+ """Get common request headers."""
229
+ return {
230
+ "Authorization": f"token {self.token}",
231
+ "Accept": "application/vnd.github.v3+json",
232
+ "Content-Type": "application/json",
233
+ }
234
+
235
+ def is_configured(self) -> bool:
236
+ """Check if GitHub integration is properly configured.
237
+
238
+ Returns:
239
+ True if all required environment variables are set
240
+ """
241
+ return all([self.token, self.repository, self.pr_number])
242
+
243
+ async def _make_request(
244
+ self, method: str, endpoint: str, **kwargs: Any
245
+ ) -> dict[str, Any] | None:
246
+ """Make an HTTP request to GitHub API.
247
+
248
+ Args:
249
+ method: HTTP method (GET, POST, PATCH, DELETE)
250
+ endpoint: API endpoint path
251
+ **kwargs: Additional arguments to pass to httpx
252
+
253
+ Returns:
254
+ Response JSON or None on error
255
+ """
256
+ if not self.is_configured():
257
+ logger.error("GitHub integration not configured")
258
+ return None
259
+
260
+ url = f"{self.api_url}/repos/{self.repository}/{endpoint}"
261
+
262
+ try:
263
+ if self._client:
264
+ response = await self._client.request(method, url, **kwargs)
265
+ else:
266
+ async with httpx.AsyncClient(headers=self._get_headers()) as client:
267
+ response = await client.request(method, url, **kwargs)
268
+
269
+ response.raise_for_status()
270
+ return response.json() if response.text else {}
271
+
272
+ except httpx.HTTPStatusError as e:
273
+ logger.error(f"HTTP error: {e.response.status_code} - {e.response.text}")
274
+ return None
275
+ except Exception as e:
276
+ logger.error(f"Request failed: {e}")
277
+ return None
278
+
279
+ # ==================== PR Comments ====================
280
+
281
+ async def post_comment(self, comment_body: str) -> bool:
282
+ """Post a general comment to a PR.
283
+
284
+ Args:
285
+ comment_body: The markdown content to post
286
+
287
+ Returns:
288
+ True if successful, False otherwise
289
+ """
290
+ result = await self._make_request(
291
+ "POST",
292
+ f"issues/{self.pr_number}/comments",
293
+ json={"body": comment_body},
294
+ )
295
+
296
+ if result:
297
+ logger.info(f"Successfully posted comment to PR #{self.pr_number}")
298
+ return True
299
+ return False
300
+
301
+ async def update_or_create_comment(
302
+ self, comment_body: str, identifier: str = "<!-- iam-policy-validator -->"
303
+ ) -> bool:
304
+ """Update an existing comment or create a new one.
305
+
306
+ This method will look for an existing comment with the identifier
307
+ and update it, or create a new comment if none exists.
308
+
309
+ Args:
310
+ comment_body: The markdown content to post
311
+ identifier: HTML comment identifier to find existing comments
312
+
313
+ Returns:
314
+ True if successful, False otherwise
315
+ """
316
+ # Add identifier to comment body
317
+ full_body = f"{identifier}\n{comment_body}"
318
+
319
+ # Try to find and update existing comment
320
+ existing_comment_id = await self._find_existing_comment(identifier)
321
+
322
+ if existing_comment_id:
323
+ return await self._update_comment(existing_comment_id, full_body)
324
+ else:
325
+ return await self.post_comment(full_body)
326
+
327
+ async def post_multipart_comments(
328
+ self,
329
+ comment_parts: list[str],
330
+ identifier: str = "<!-- iam-policy-validator -->",
331
+ ) -> bool:
332
+ """Post or update multiple related comments (for large reports).
333
+
334
+ This method will:
335
+ 1. Delete all old comments with the identifier
336
+ 2. Post new comments in sequence with part indicators
337
+ 3. Validate each part stays under GitHub's limit
338
+
339
+ Args:
340
+ comment_parts: List of comment bodies to post (split into parts)
341
+ identifier: HTML comment identifier to find/manage existing comments
342
+
343
+ Returns:
344
+ True if all parts posted successfully, False otherwise
345
+ """
346
+ # GitHub's actual limit
347
+ github_comment_limit = 65536
348
+
349
+ # Delete all existing comments with this identifier
350
+ await self._delete_comments_with_identifier(identifier)
351
+
352
+ # Post each part
353
+ success = True
354
+ total_parts = len(comment_parts)
355
+
356
+ for part_num, part_body in enumerate(comment_parts, 1):
357
+ # Add identifier and part indicator
358
+ part_indicator = f"**(Part {part_num}/{total_parts})**" if total_parts > 1 else ""
359
+ full_body = f"{identifier}\n{part_indicator}\n\n{part_body}"
360
+
361
+ # Safety check: ensure we don't exceed GitHub's limit
362
+ if len(full_body) > github_comment_limit:
363
+ logger.error(
364
+ f"Part {part_num}/{total_parts} exceeds GitHub's comment limit "
365
+ f"({len(full_body)} > {github_comment_limit} chars). "
366
+ f"This part will be truncated."
367
+ )
368
+ # Truncate with warning message
369
+ available_space = github_comment_limit - 500 # Reserve space for truncation message
370
+ truncated_body = part_body[:available_space]
371
+ truncation_warning = (
372
+ "\n\n---\n\n"
373
+ "> ⚠️ **This comment was truncated to fit GitHub's size limit**\n"
374
+ ">\n"
375
+ "> Download the full report using `--output report.json` or "
376
+ "`--format markdown --output report.md`\n"
377
+ )
378
+ full_body = (
379
+ f"{identifier}\n{part_indicator}\n\n{truncated_body}{truncation_warning}"
380
+ )
381
+
382
+ if not await self.post_comment(full_body):
383
+ logger.error(f"Failed to post comment part {part_num}/{total_parts}")
384
+ success = False
385
+ else:
386
+ logger.debug(
387
+ f"Posted part {part_num}/{total_parts} ({len(full_body):,} characters)"
388
+ )
389
+
390
+ if success:
391
+ logger.info(f"Successfully posted {total_parts} comment part(s)")
392
+
393
+ return success
394
+
395
+ async def _delete_comments_with_identifier(self, identifier: str) -> int:
396
+ """Delete all comments with the given identifier.
397
+
398
+ Args:
399
+ identifier: HTML comment identifier to find comments
400
+
401
+ Returns:
402
+ Number of comments deleted
403
+ """
404
+ result = await self._make_request("GET", f"issues/{self.pr_number}/comments")
405
+
406
+ deleted_count = 0
407
+ if result and isinstance(result, list):
408
+ for comment in result:
409
+ if not isinstance(comment, dict):
410
+ continue
411
+
412
+ body = comment.get("body", "")
413
+ comment_id = comment.get("id")
414
+
415
+ if identifier in str(body) and isinstance(comment_id, int):
416
+ delete_result = await self._make_request(
417
+ "DELETE", f"issues/comments/{comment_id}"
418
+ )
419
+ if delete_result is not None:
420
+ deleted_count += 1
421
+
422
+ if deleted_count > 0:
423
+ logger.info(f"Deleted {deleted_count} old comments")
424
+
425
+ return deleted_count
426
+
427
+ async def _find_existing_comment(self, identifier: str) -> int | None:
428
+ """Find an existing comment with the given identifier."""
429
+ result = await self._make_request("GET", f"issues/{self.pr_number}/comments")
430
+
431
+ if result and isinstance(result, list):
432
+ for comment in result:
433
+ if isinstance(comment, dict) and identifier in str(comment.get("body", "")):
434
+ comment_id = comment.get("id")
435
+ if isinstance(comment_id, int):
436
+ return comment_id
437
+
438
+ return None
439
+
440
+ async def _update_comment(self, comment_id: int, comment_body: str) -> bool:
441
+ """Update an existing GitHub comment."""
442
+ result = await self._make_request(
443
+ "PATCH",
444
+ f"issues/comments/{comment_id}",
445
+ json={"body": comment_body},
446
+ )
447
+
448
+ if result:
449
+ logger.info(f"Successfully updated comment {comment_id}")
450
+ return True
451
+ return False
452
+
453
+ # ==================== PR Review Comments (Line-specific) ====================
454
+
455
+ async def get_review_comments(self) -> list[dict[str, Any]]:
456
+ """Get all review comments on the PR.
457
+
458
+ Returns:
459
+ List of review comment dicts
460
+ """
461
+ result = await self._make_request(
462
+ "GET",
463
+ f"pulls/{self.pr_number}/comments",
464
+ )
465
+
466
+ if result and isinstance(result, list):
467
+ return result
468
+ return []
469
+
470
+ async def delete_review_comment(self, comment_id: int) -> bool:
471
+ """Delete a specific review comment.
472
+
473
+ Args:
474
+ comment_id: ID of the comment to delete
475
+
476
+ Returns:
477
+ True if successful, False otherwise
478
+ """
479
+ result = await self._make_request(
480
+ "DELETE",
481
+ f"pulls/comments/{comment_id}",
482
+ )
483
+
484
+ if result is not None: # DELETE returns empty dict on success
485
+ logger.info(f"Successfully deleted review comment {comment_id}")
486
+ return True
487
+ return False
488
+
489
+ async def cleanup_bot_review_comments(self, identifier: str = "🤖 IAM Policy Validator") -> int:
490
+ """Delete all review comments from the bot (from previous runs).
491
+
492
+ This ensures old/outdated comments are removed before posting new ones.
493
+
494
+ Args:
495
+ identifier: String to identify bot comments
496
+
497
+ Returns:
498
+ Number of comments deleted
499
+ """
500
+ comments = await self.get_review_comments()
501
+ deleted_count = 0
502
+
503
+ for comment in comments:
504
+ if not isinstance(comment, dict):
505
+ continue
506
+
507
+ body = comment.get("body", "")
508
+ comment_id = comment.get("id")
509
+
510
+ # Check if this is a bot comment
511
+ if identifier in str(body) and isinstance(comment_id, int):
512
+ if await self.delete_review_comment(comment_id):
513
+ deleted_count += 1
514
+
515
+ if deleted_count > 0:
516
+ logger.info(f"Cleaned up {deleted_count} old review comments")
517
+
518
+ return deleted_count
519
+
520
+ async def create_review_comment(
521
+ self,
522
+ commit_id: str,
523
+ file_path: str,
524
+ line: int,
525
+ body: str,
526
+ side: str = "RIGHT",
527
+ ) -> bool:
528
+ """Create a line-specific review comment on a file in the PR.
529
+
530
+ Args:
531
+ commit_id: The SHA of the commit to comment on
532
+ file_path: The relative path to the file in the repo
533
+ line: The line number in the file to comment on
534
+ body: The comment text (markdown supported)
535
+ side: Which side of the diff ("LEFT" for deletion, "RIGHT" for addition)
536
+
537
+ Returns:
538
+ True if successful, False otherwise
539
+ """
540
+ result = await self._make_request(
541
+ "POST",
542
+ f"pulls/{self.pr_number}/comments",
543
+ json={
544
+ "commit_id": commit_id,
545
+ "path": file_path,
546
+ "line": line,
547
+ "side": side,
548
+ "body": body,
549
+ },
550
+ )
551
+
552
+ if result:
553
+ logger.info(f"Successfully posted review comment on {file_path}:{line}")
554
+ return True
555
+ return False
556
+
557
+ async def create_review_with_comments(
558
+ self,
559
+ comments: list[dict[str, Any]],
560
+ body: str = "",
561
+ event: ReviewEvent = ReviewEvent.COMMENT,
562
+ ) -> bool:
563
+ """Create a review with multiple line-specific comments.
564
+
565
+ Args:
566
+ comments: List of comment dicts with keys: path, line, body, (optional) side
567
+ body: The overall review body text
568
+ event: The review event type (APPROVE, REQUEST_CHANGES, COMMENT)
569
+
570
+ Returns:
571
+ True if successful, False otherwise
572
+
573
+ Example:
574
+ comments = [
575
+ {
576
+ "path": "policies/policy.json",
577
+ "line": 5,
578
+ "body": "Invalid action detected here",
579
+ },
580
+ {
581
+ "path": "policies/policy.json",
582
+ "line": 12,
583
+ "body": "Missing condition key",
584
+ },
585
+ ]
586
+ """
587
+ # Get the latest commit SHA
588
+ pr_info = await self.get_pr_info()
589
+ if not pr_info:
590
+ return False
591
+
592
+ head_info = pr_info.get("head")
593
+ if not isinstance(head_info, dict):
594
+ logger.error("Invalid PR head information")
595
+ return False
596
+
597
+ commit_id = head_info.get("sha")
598
+ if not isinstance(commit_id, str):
599
+ logger.error("Could not get commit SHA from PR")
600
+ return False
601
+
602
+ # Format comments for the review API
603
+ formatted_comments: list[dict[str, Any]] = []
604
+ for comment in comments:
605
+ formatted_comments.append(
606
+ {
607
+ "path": comment["path"],
608
+ "line": comment["line"],
609
+ "body": comment["body"],
610
+ "side": comment.get("side", "RIGHT"),
611
+ }
612
+ )
613
+
614
+ result = await self._make_request(
615
+ "POST",
616
+ f"pulls/{self.pr_number}/reviews",
617
+ json={
618
+ "commit_id": commit_id,
619
+ "body": body,
620
+ "event": event.value,
621
+ "comments": formatted_comments,
622
+ },
623
+ )
624
+
625
+ if result:
626
+ logger.info(f"Successfully created review with {len(comments)} comments")
627
+ return True
628
+ return False
629
+
630
+ # ==================== PR Labels ====================
631
+
632
+ async def add_labels(self, labels: list[str]) -> bool:
633
+ """Add labels to the PR.
634
+
635
+ Args:
636
+ labels: List of label names to add
637
+
638
+ Returns:
639
+ True if successful, False otherwise
640
+ """
641
+ result = await self._make_request(
642
+ "POST",
643
+ f"issues/{self.pr_number}/labels",
644
+ json={"labels": labels},
645
+ )
646
+
647
+ if result:
648
+ logger.info(f"Successfully added labels: {', '.join(labels)}")
649
+ return True
650
+ return False
651
+
652
+ async def remove_label(self, label: str) -> bool:
653
+ """Remove a label from the PR.
654
+
655
+ Args:
656
+ label: Label name to remove
657
+
658
+ Returns:
659
+ True if successful, False otherwise
660
+ """
661
+ result = await self._make_request(
662
+ "DELETE",
663
+ f"issues/{self.pr_number}/labels/{label}",
664
+ )
665
+
666
+ if result is not None: # DELETE returns empty dict on success
667
+ logger.info(f"Successfully removed label: {label}")
668
+ return True
669
+ return False
670
+
671
+ async def get_labels(self) -> list[str]:
672
+ """Get all labels on the PR.
673
+
674
+ Returns:
675
+ List of label names
676
+ """
677
+ result = await self._make_request(
678
+ "GET",
679
+ f"issues/{self.pr_number}/labels",
680
+ )
681
+
682
+ if result and isinstance(result, list):
683
+ labels: list[str] = []
684
+ for label in result:
685
+ if isinstance(label, dict):
686
+ name = label.get("name")
687
+ if isinstance(name, str):
688
+ labels.append(name)
689
+ return labels
690
+ return []
691
+
692
+ async def set_labels(self, labels: list[str]) -> bool:
693
+ """Set labels on the PR, replacing any existing labels.
694
+
695
+ Args:
696
+ labels: List of label names to set
697
+
698
+ Returns:
699
+ True if successful, False otherwise
700
+ """
701
+ result = await self._make_request(
702
+ "PUT",
703
+ f"issues/{self.pr_number}/labels",
704
+ json={"labels": labels},
705
+ )
706
+
707
+ if result:
708
+ logger.info(f"Successfully set labels: {', '.join(labels)}")
709
+ return True
710
+ return False
711
+
712
+ # ==================== PR Information ====================
713
+
714
+ async def get_pr_info(self) -> dict[str, Any] | None:
715
+ """Get detailed information about the PR.
716
+
717
+ Returns:
718
+ PR information dict or None on error
719
+ """
720
+ return await self._make_request("GET", f"pulls/{self.pr_number}")
721
+
722
+ async def get_pr_files(self) -> list[dict[str, Any]]:
723
+ """Get list of files changed in the PR.
724
+
725
+ Returns:
726
+ List of file information dicts
727
+ """
728
+ result = await self._make_request("GET", f"pulls/{self.pr_number}/files")
729
+
730
+ if result and isinstance(result, list):
731
+ return result
732
+ return []
733
+
734
+ async def get_pr_commits(self) -> list[dict[str, Any]]:
735
+ """Get list of commits in the PR.
736
+
737
+ Returns:
738
+ List of commit information dicts
739
+ """
740
+ result = await self._make_request("GET", f"pulls/{self.pr_number}/commits")
741
+
742
+ if result and isinstance(result, list):
743
+ return result
744
+ return []
745
+
746
+ # ==================== PR Status ====================
747
+
748
+ async def set_commit_status(
749
+ self,
750
+ state: str,
751
+ context: str,
752
+ description: str,
753
+ target_url: str | None = None,
754
+ ) -> bool:
755
+ """Set a commit status on the PR's head commit.
756
+
757
+ Args:
758
+ state: Status state ("error", "failure", "pending", "success")
759
+ context: A string label to differentiate this status from others
760
+ description: A short description of the status
761
+ target_url: Optional URL to link to more details
762
+
763
+ Returns:
764
+ True if successful, False otherwise
765
+ """
766
+ pr_info = await self.get_pr_info()
767
+ if not pr_info:
768
+ return False
769
+
770
+ head_info = pr_info.get("head")
771
+ if not isinstance(head_info, dict):
772
+ return False
773
+
774
+ commit_sha = head_info.get("sha")
775
+ if not isinstance(commit_sha, str):
776
+ return False
777
+
778
+ payload: dict[str, Any] = {
779
+ "state": state,
780
+ "context": context,
781
+ "description": description,
782
+ }
783
+ if target_url:
784
+ payload["target_url"] = target_url
785
+
786
+ result = await self._make_request(
787
+ "POST",
788
+ f"statuses/{commit_sha}",
789
+ json=payload,
790
+ )
791
+
792
+ if result:
793
+ logger.info(f"Successfully set commit status: {state}")
794
+ return True
795
+ return False