iam-policy-validator 1.7.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 (83) hide show
  1. iam_policy_validator-1.7.0.dist-info/METADATA +1057 -0
  2. iam_policy_validator-1.7.0.dist-info/RECORD +83 -0
  3. iam_policy_validator-1.7.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.7.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.7.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 +43 -0
  10. iam_validator/checks/action_condition_enforcement.py +884 -0
  11. iam_validator/checks/action_resource_matching.py +441 -0
  12. iam_validator/checks/action_validation.py +72 -0
  13. iam_validator/checks/condition_key_validation.py +92 -0
  14. iam_validator/checks/condition_type_mismatch.py +259 -0
  15. iam_validator/checks/full_wildcard.py +71 -0
  16. iam_validator/checks/mfa_condition_check.py +112 -0
  17. iam_validator/checks/policy_size.py +147 -0
  18. iam_validator/checks/policy_type_validation.py +305 -0
  19. iam_validator/checks/principal_validation.py +776 -0
  20. iam_validator/checks/resource_validation.py +138 -0
  21. iam_validator/checks/sensitive_action.py +254 -0
  22. iam_validator/checks/service_wildcard.py +107 -0
  23. iam_validator/checks/set_operator_validation.py +157 -0
  24. iam_validator/checks/sid_uniqueness.py +170 -0
  25. iam_validator/checks/utils/__init__.py +1 -0
  26. iam_validator/checks/utils/policy_level_checks.py +143 -0
  27. iam_validator/checks/utils/sensitive_action_matcher.py +294 -0
  28. iam_validator/checks/utils/wildcard_expansion.py +87 -0
  29. iam_validator/checks/wildcard_action.py +67 -0
  30. iam_validator/checks/wildcard_resource.py +135 -0
  31. iam_validator/commands/__init__.py +25 -0
  32. iam_validator/commands/analyze.py +531 -0
  33. iam_validator/commands/base.py +48 -0
  34. iam_validator/commands/cache.py +392 -0
  35. iam_validator/commands/download_services.py +255 -0
  36. iam_validator/commands/post_to_pr.py +86 -0
  37. iam_validator/commands/validate.py +600 -0
  38. iam_validator/core/__init__.py +14 -0
  39. iam_validator/core/access_analyzer.py +671 -0
  40. iam_validator/core/access_analyzer_report.py +640 -0
  41. iam_validator/core/aws_fetcher.py +940 -0
  42. iam_validator/core/check_registry.py +607 -0
  43. iam_validator/core/cli.py +134 -0
  44. iam_validator/core/condition_validators.py +626 -0
  45. iam_validator/core/config/__init__.py +81 -0
  46. iam_validator/core/config/aws_api.py +35 -0
  47. iam_validator/core/config/aws_global_conditions.py +160 -0
  48. iam_validator/core/config/category_suggestions.py +104 -0
  49. iam_validator/core/config/condition_requirements.py +155 -0
  50. iam_validator/core/config/config_loader.py +472 -0
  51. iam_validator/core/config/defaults.py +523 -0
  52. iam_validator/core/config/principal_requirements.py +421 -0
  53. iam_validator/core/config/sensitive_actions.py +672 -0
  54. iam_validator/core/config/service_principals.py +95 -0
  55. iam_validator/core/config/wildcards.py +124 -0
  56. iam_validator/core/constants.py +74 -0
  57. iam_validator/core/formatters/__init__.py +27 -0
  58. iam_validator/core/formatters/base.py +147 -0
  59. iam_validator/core/formatters/console.py +59 -0
  60. iam_validator/core/formatters/csv.py +170 -0
  61. iam_validator/core/formatters/enhanced.py +440 -0
  62. iam_validator/core/formatters/html.py +672 -0
  63. iam_validator/core/formatters/json.py +33 -0
  64. iam_validator/core/formatters/markdown.py +63 -0
  65. iam_validator/core/formatters/sarif.py +251 -0
  66. iam_validator/core/models.py +327 -0
  67. iam_validator/core/policy_checks.py +656 -0
  68. iam_validator/core/policy_loader.py +396 -0
  69. iam_validator/core/pr_commenter.py +424 -0
  70. iam_validator/core/report.py +872 -0
  71. iam_validator/integrations/__init__.py +28 -0
  72. iam_validator/integrations/github_integration.py +815 -0
  73. iam_validator/integrations/ms_teams.py +442 -0
  74. iam_validator/sdk/__init__.py +187 -0
  75. iam_validator/sdk/arn_matching.py +382 -0
  76. iam_validator/sdk/context.py +222 -0
  77. iam_validator/sdk/exceptions.py +48 -0
  78. iam_validator/sdk/helpers.py +177 -0
  79. iam_validator/sdk/policy_utils.py +425 -0
  80. iam_validator/sdk/shortcuts.py +283 -0
  81. iam_validator/utils/__init__.py +31 -0
  82. iam_validator/utils/cache.py +105 -0
  83. iam_validator/utils/regex.py +206 -0
@@ -0,0 +1,815 @@
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
+ is_valid = all([self.token, self.repository, self.pr_number])
242
+
243
+ # Provide helpful debug info when not configured
244
+ if not is_valid:
245
+ missing = []
246
+ if not self.token:
247
+ missing.append("GITHUB_TOKEN")
248
+ if not self.repository:
249
+ missing.append("GITHUB_REPOSITORY")
250
+ if not self.pr_number:
251
+ missing.append("GITHUB_PR_NUMBER")
252
+
253
+ logger.debug(f"GitHub integration missing: {', '.join(missing)}")
254
+ if not self.pr_number and self.token and self.repository:
255
+ logger.info(
256
+ "GitHub PR integration requires GITHUB_PR_NUMBER. "
257
+ "This is only available when running on pull request events. "
258
+ "Current event may not have PR context."
259
+ )
260
+
261
+ return is_valid
262
+
263
+ async def _make_request(
264
+ self, method: str, endpoint: str, **kwargs: Any
265
+ ) -> dict[str, Any] | None:
266
+ """Make an HTTP request to GitHub API.
267
+
268
+ Args:
269
+ method: HTTP method (GET, POST, PATCH, DELETE)
270
+ endpoint: API endpoint path
271
+ **kwargs: Additional arguments to pass to httpx
272
+
273
+ Returns:
274
+ Response JSON or None on error
275
+ """
276
+ if not self.is_configured():
277
+ logger.error("GitHub integration not configured")
278
+ return None
279
+
280
+ url = f"{self.api_url}/repos/{self.repository}/{endpoint}"
281
+
282
+ try:
283
+ if self._client:
284
+ response = await self._client.request(method, url, **kwargs)
285
+ else:
286
+ async with httpx.AsyncClient(headers=self._get_headers()) as client:
287
+ response = await client.request(method, url, **kwargs)
288
+
289
+ response.raise_for_status()
290
+ return response.json() if response.text else {}
291
+
292
+ except httpx.HTTPStatusError as e:
293
+ logger.error(f"HTTP error: {e.response.status_code} - {e.response.text}")
294
+ return None
295
+ except Exception as e:
296
+ logger.error(f"Request failed: {e}")
297
+ return None
298
+
299
+ # ==================== PR Comments ====================
300
+
301
+ async def post_comment(self, comment_body: str) -> bool:
302
+ """Post a general comment to a PR.
303
+
304
+ Args:
305
+ comment_body: The markdown content to post
306
+
307
+ Returns:
308
+ True if successful, False otherwise
309
+ """
310
+ result = await self._make_request(
311
+ "POST",
312
+ f"issues/{self.pr_number}/comments",
313
+ json={"body": comment_body},
314
+ )
315
+
316
+ if result:
317
+ logger.info(f"Successfully posted comment to PR #{self.pr_number}")
318
+ return True
319
+ return False
320
+
321
+ async def update_or_create_comment(
322
+ self, comment_body: str, identifier: str = "<!-- iam-policy-validator -->"
323
+ ) -> bool:
324
+ """Update an existing comment or create a new one.
325
+
326
+ This method will look for an existing comment with the identifier
327
+ and update it, or create a new comment if none exists.
328
+
329
+ Args:
330
+ comment_body: The markdown content to post
331
+ identifier: HTML comment identifier to find existing comments
332
+
333
+ Returns:
334
+ True if successful, False otherwise
335
+ """
336
+ # Add identifier to comment body
337
+ full_body = f"{identifier}\n{comment_body}"
338
+
339
+ # Try to find and update existing comment
340
+ existing_comment_id = await self._find_existing_comment(identifier)
341
+
342
+ if existing_comment_id:
343
+ return await self._update_comment(existing_comment_id, full_body)
344
+ else:
345
+ return await self.post_comment(full_body)
346
+
347
+ async def post_multipart_comments(
348
+ self,
349
+ comment_parts: list[str],
350
+ identifier: str = "<!-- iam-policy-validator -->",
351
+ ) -> bool:
352
+ """Post or update multiple related comments (for large reports).
353
+
354
+ This method will:
355
+ 1. Delete all old comments with the identifier
356
+ 2. Post new comments in sequence with part indicators
357
+ 3. Validate each part stays under GitHub's limit
358
+
359
+ Args:
360
+ comment_parts: List of comment bodies to post (split into parts)
361
+ identifier: HTML comment identifier to find/manage existing comments
362
+
363
+ Returns:
364
+ True if all parts posted successfully, False otherwise
365
+ """
366
+ # GitHub's actual limit
367
+ github_comment_limit = 65536
368
+
369
+ # Delete all existing comments with this identifier
370
+ await self._delete_comments_with_identifier(identifier)
371
+
372
+ # Post each part
373
+ success = True
374
+ total_parts = len(comment_parts)
375
+
376
+ for part_num, part_body in enumerate(comment_parts, 1):
377
+ # Add identifier and part indicator
378
+ part_indicator = f"**(Part {part_num}/{total_parts})**" if total_parts > 1 else ""
379
+ full_body = f"{identifier}\n{part_indicator}\n\n{part_body}"
380
+
381
+ # Safety check: ensure we don't exceed GitHub's limit
382
+ if len(full_body) > github_comment_limit:
383
+ logger.error(
384
+ f"Part {part_num}/{total_parts} exceeds GitHub's comment limit "
385
+ f"({len(full_body)} > {github_comment_limit} chars). "
386
+ f"This part will be truncated."
387
+ )
388
+ # Truncate with warning message
389
+ available_space = github_comment_limit - 500 # Reserve space for truncation message
390
+ truncated_body = part_body[:available_space]
391
+ truncation_warning = (
392
+ "\n\n---\n\n"
393
+ "> ⚠️ **This comment was truncated to fit GitHub's size limit**\n"
394
+ ">\n"
395
+ "> Download the full report using `--output report.json` or "
396
+ "`--format markdown --output report.md`\n"
397
+ )
398
+ full_body = (
399
+ f"{identifier}\n{part_indicator}\n\n{truncated_body}{truncation_warning}"
400
+ )
401
+
402
+ if not await self.post_comment(full_body):
403
+ logger.error(f"Failed to post comment part {part_num}/{total_parts}")
404
+ success = False
405
+ else:
406
+ logger.debug(
407
+ f"Posted part {part_num}/{total_parts} ({len(full_body):,} characters)"
408
+ )
409
+
410
+ if success:
411
+ logger.info(f"Successfully posted {total_parts} comment part(s)")
412
+
413
+ return success
414
+
415
+ async def _delete_comments_with_identifier(self, identifier: str) -> int:
416
+ """Delete all comments with the given identifier.
417
+
418
+ Args:
419
+ identifier: HTML comment identifier to find comments
420
+
421
+ Returns:
422
+ Number of comments deleted
423
+ """
424
+ result = await self._make_request("GET", f"issues/{self.pr_number}/comments")
425
+
426
+ deleted_count = 0
427
+ if result and isinstance(result, list):
428
+ for comment in result:
429
+ if not isinstance(comment, dict):
430
+ continue
431
+
432
+ body = comment.get("body", "")
433
+ comment_id = comment.get("id")
434
+
435
+ if identifier in str(body) and isinstance(comment_id, int):
436
+ delete_result = await self._make_request(
437
+ "DELETE", f"issues/comments/{comment_id}"
438
+ )
439
+ if delete_result is not None:
440
+ deleted_count += 1
441
+
442
+ if deleted_count > 0:
443
+ logger.info(f"Deleted {deleted_count} old comments")
444
+
445
+ return deleted_count
446
+
447
+ async def _find_existing_comment(self, identifier: str) -> int | None:
448
+ """Find an existing comment with the given identifier."""
449
+ result = await self._make_request("GET", f"issues/{self.pr_number}/comments")
450
+
451
+ if result and isinstance(result, list):
452
+ for comment in result:
453
+ if isinstance(comment, dict) and identifier in str(comment.get("body", "")):
454
+ comment_id = comment.get("id")
455
+ if isinstance(comment_id, int):
456
+ return comment_id
457
+
458
+ return None
459
+
460
+ async def _update_comment(self, comment_id: int, comment_body: str) -> bool:
461
+ """Update an existing GitHub comment."""
462
+ result = await self._make_request(
463
+ "PATCH",
464
+ f"issues/comments/{comment_id}",
465
+ json={"body": comment_body},
466
+ )
467
+
468
+ if result:
469
+ logger.info(f"Successfully updated comment {comment_id}")
470
+ return True
471
+ return False
472
+
473
+ # ==================== PR Review Comments (Line-specific) ====================
474
+
475
+ async def get_review_comments(self) -> list[dict[str, Any]]:
476
+ """Get all review comments on the PR.
477
+
478
+ Returns:
479
+ List of review comment dicts
480
+ """
481
+ result = await self._make_request(
482
+ "GET",
483
+ f"pulls/{self.pr_number}/comments",
484
+ )
485
+
486
+ if result and isinstance(result, list):
487
+ return result
488
+ return []
489
+
490
+ async def delete_review_comment(self, comment_id: int) -> bool:
491
+ """Delete a specific review comment.
492
+
493
+ Args:
494
+ comment_id: ID of the comment to delete
495
+
496
+ Returns:
497
+ True if successful, False otherwise
498
+ """
499
+ result = await self._make_request(
500
+ "DELETE",
501
+ f"pulls/comments/{comment_id}",
502
+ )
503
+
504
+ if result is not None: # DELETE returns empty dict on success
505
+ logger.info(f"Successfully deleted review comment {comment_id}")
506
+ return True
507
+ return False
508
+
509
+ async def cleanup_bot_review_comments(self, identifier: str = "🤖 IAM Policy Validator") -> int:
510
+ """Delete all review comments from the bot (from previous runs).
511
+
512
+ This ensures old/outdated comments are removed before posting new ones.
513
+
514
+ Args:
515
+ identifier: String to identify bot comments
516
+
517
+ Returns:
518
+ Number of comments deleted
519
+ """
520
+ comments = await self.get_review_comments()
521
+ deleted_count = 0
522
+
523
+ for comment in comments:
524
+ if not isinstance(comment, dict):
525
+ continue
526
+
527
+ body = comment.get("body", "")
528
+ comment_id = comment.get("id")
529
+
530
+ # Check if this is a bot comment
531
+ if identifier in str(body) and isinstance(comment_id, int):
532
+ if await self.delete_review_comment(comment_id):
533
+ deleted_count += 1
534
+
535
+ if deleted_count > 0:
536
+ logger.info(f"Cleaned up {deleted_count} old review comments")
537
+
538
+ return deleted_count
539
+
540
+ async def create_review_comment(
541
+ self,
542
+ commit_id: str,
543
+ file_path: str,
544
+ line: int,
545
+ body: str,
546
+ side: str = "RIGHT",
547
+ ) -> bool:
548
+ """Create a line-specific review comment on a file in the PR.
549
+
550
+ Args:
551
+ commit_id: The SHA of the commit to comment on
552
+ file_path: The relative path to the file in the repo
553
+ line: The line number in the file to comment on
554
+ body: The comment text (markdown supported)
555
+ side: Which side of the diff ("LEFT" for deletion, "RIGHT" for addition)
556
+
557
+ Returns:
558
+ True if successful, False otherwise
559
+ """
560
+ result = await self._make_request(
561
+ "POST",
562
+ f"pulls/{self.pr_number}/comments",
563
+ json={
564
+ "commit_id": commit_id,
565
+ "path": file_path,
566
+ "line": line,
567
+ "side": side,
568
+ "body": body,
569
+ },
570
+ )
571
+
572
+ if result:
573
+ logger.info(f"Successfully posted review comment on {file_path}:{line}")
574
+ return True
575
+ return False
576
+
577
+ async def create_review_with_comments(
578
+ self,
579
+ comments: list[dict[str, Any]],
580
+ body: str = "",
581
+ event: ReviewEvent = ReviewEvent.COMMENT,
582
+ ) -> bool:
583
+ """Create a review with multiple line-specific comments.
584
+
585
+ Args:
586
+ comments: List of comment dicts with keys: path, line, body, (optional) side
587
+ body: The overall review body text
588
+ event: The review event type (APPROVE, REQUEST_CHANGES, COMMENT)
589
+
590
+ Returns:
591
+ True if successful, False otherwise
592
+
593
+ Example:
594
+ comments = [
595
+ {
596
+ "path": "policies/policy.json",
597
+ "line": 5,
598
+ "body": "Invalid action detected here",
599
+ },
600
+ {
601
+ "path": "policies/policy.json",
602
+ "line": 12,
603
+ "body": "Missing condition key",
604
+ },
605
+ ]
606
+ """
607
+ # Get the latest commit SHA
608
+ pr_info = await self.get_pr_info()
609
+ if not pr_info:
610
+ return False
611
+
612
+ head_info = pr_info.get("head")
613
+ if not isinstance(head_info, dict):
614
+ logger.error("Invalid PR head information")
615
+ return False
616
+
617
+ commit_id = head_info.get("sha")
618
+ if not isinstance(commit_id, str):
619
+ logger.error("Could not get commit SHA from PR")
620
+ return False
621
+
622
+ # Format comments for the review API
623
+ formatted_comments: list[dict[str, Any]] = []
624
+ for comment in comments:
625
+ formatted_comments.append(
626
+ {
627
+ "path": comment["path"],
628
+ "line": comment["line"],
629
+ "body": comment["body"],
630
+ "side": comment.get("side", "RIGHT"),
631
+ }
632
+ )
633
+
634
+ result = await self._make_request(
635
+ "POST",
636
+ f"pulls/{self.pr_number}/reviews",
637
+ json={
638
+ "commit_id": commit_id,
639
+ "body": body,
640
+ "event": event.value,
641
+ "comments": formatted_comments,
642
+ },
643
+ )
644
+
645
+ if result:
646
+ logger.info(f"Successfully created review with {len(comments)} comments")
647
+ return True
648
+ return False
649
+
650
+ # ==================== PR Labels ====================
651
+
652
+ async def add_labels(self, labels: list[str]) -> bool:
653
+ """Add labels to the PR.
654
+
655
+ Args:
656
+ labels: List of label names to add
657
+
658
+ Returns:
659
+ True if successful, False otherwise
660
+ """
661
+ result = await self._make_request(
662
+ "POST",
663
+ f"issues/{self.pr_number}/labels",
664
+ json={"labels": labels},
665
+ )
666
+
667
+ if result:
668
+ logger.info(f"Successfully added labels: {', '.join(labels)}")
669
+ return True
670
+ return False
671
+
672
+ async def remove_label(self, label: str) -> bool:
673
+ """Remove a label from the PR.
674
+
675
+ Args:
676
+ label: Label name to remove
677
+
678
+ Returns:
679
+ True if successful, False otherwise
680
+ """
681
+ result = await self._make_request(
682
+ "DELETE",
683
+ f"issues/{self.pr_number}/labels/{label}",
684
+ )
685
+
686
+ if result is not None: # DELETE returns empty dict on success
687
+ logger.info(f"Successfully removed label: {label}")
688
+ return True
689
+ return False
690
+
691
+ async def get_labels(self) -> list[str]:
692
+ """Get all labels on the PR.
693
+
694
+ Returns:
695
+ List of label names
696
+ """
697
+ result = await self._make_request(
698
+ "GET",
699
+ f"issues/{self.pr_number}/labels",
700
+ )
701
+
702
+ if result and isinstance(result, list):
703
+ labels: list[str] = []
704
+ for label in result:
705
+ if isinstance(label, dict):
706
+ name = label.get("name")
707
+ if isinstance(name, str):
708
+ labels.append(name)
709
+ return labels
710
+ return []
711
+
712
+ async def set_labels(self, labels: list[str]) -> bool:
713
+ """Set labels on the PR, replacing any existing labels.
714
+
715
+ Args:
716
+ labels: List of label names to set
717
+
718
+ Returns:
719
+ True if successful, False otherwise
720
+ """
721
+ result = await self._make_request(
722
+ "PUT",
723
+ f"issues/{self.pr_number}/labels",
724
+ json={"labels": labels},
725
+ )
726
+
727
+ if result:
728
+ logger.info(f"Successfully set labels: {', '.join(labels)}")
729
+ return True
730
+ return False
731
+
732
+ # ==================== PR Information ====================
733
+
734
+ async def get_pr_info(self) -> dict[str, Any] | None:
735
+ """Get detailed information about the PR.
736
+
737
+ Returns:
738
+ PR information dict or None on error
739
+ """
740
+ return await self._make_request("GET", f"pulls/{self.pr_number}")
741
+
742
+ async def get_pr_files(self) -> list[dict[str, Any]]:
743
+ """Get list of files changed in the PR.
744
+
745
+ Returns:
746
+ List of file information dicts
747
+ """
748
+ result = await self._make_request("GET", f"pulls/{self.pr_number}/files")
749
+
750
+ if result and isinstance(result, list):
751
+ return result
752
+ return []
753
+
754
+ async def get_pr_commits(self) -> list[dict[str, Any]]:
755
+ """Get list of commits in the PR.
756
+
757
+ Returns:
758
+ List of commit information dicts
759
+ """
760
+ result = await self._make_request("GET", f"pulls/{self.pr_number}/commits")
761
+
762
+ if result and isinstance(result, list):
763
+ return result
764
+ return []
765
+
766
+ # ==================== PR Status ====================
767
+
768
+ async def set_commit_status(
769
+ self,
770
+ state: str,
771
+ context: str,
772
+ description: str,
773
+ target_url: str | None = None,
774
+ ) -> bool:
775
+ """Set a commit status on the PR's head commit.
776
+
777
+ Args:
778
+ state: Status state ("error", "failure", "pending", "success")
779
+ context: A string label to differentiate this status from others
780
+ description: A short description of the status
781
+ target_url: Optional URL to link to more details
782
+
783
+ Returns:
784
+ True if successful, False otherwise
785
+ """
786
+ pr_info = await self.get_pr_info()
787
+ if not pr_info:
788
+ return False
789
+
790
+ head_info = pr_info.get("head")
791
+ if not isinstance(head_info, dict):
792
+ return False
793
+
794
+ commit_sha = head_info.get("sha")
795
+ if not isinstance(commit_sha, str):
796
+ return False
797
+
798
+ payload: dict[str, Any] = {
799
+ "state": state,
800
+ "context": context,
801
+ "description": description,
802
+ }
803
+ if target_url:
804
+ payload["target_url"] = target_url
805
+
806
+ result = await self._make_request(
807
+ "POST",
808
+ f"statuses/{commit_sha}",
809
+ json=payload,
810
+ )
811
+
812
+ if result:
813
+ logger.info(f"Successfully set commit status: {state}")
814
+ return True
815
+ return False