iam-policy-validator 1.10.3__py3-none-any.whl → 1.11.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.
@@ -291,7 +291,7 @@ class GitHubIntegration:
291
291
  except httpx.HTTPStatusError as e:
292
292
  logger.error(f"HTTP error: {e.response.status_code} - {e.response.text}")
293
293
  return None
294
- except Exception as e:
294
+ except Exception as e: # pylint: disable=broad-exception-caught
295
295
  logger.error(f"Request failed: {e}")
296
296
  return None
297
297
 
@@ -486,6 +486,57 @@ class GitHubIntegration:
486
486
  return result
487
487
  return []
488
488
 
489
+ async def get_bot_review_comments_with_location(
490
+ self, identifier: str = constants.BOT_IDENTIFIER
491
+ ) -> dict[tuple[str, int, str], dict[str, Any]]:
492
+ """Get bot review comments indexed by file path, line number, and issue type.
493
+
494
+ This enables efficient lookup to update existing comments.
495
+ Uses (path, line, issue_type) as key to support multiple issues at the same line.
496
+
497
+ Args:
498
+ identifier: String to identify bot comments
499
+
500
+ Returns:
501
+ Dict mapping (file_path, line_number, issue_type) to comment metadata dict
502
+ Comment dict contains: id, body, path, line, issue_type, commit_id
503
+ """
504
+ comments = await self.get_review_comments()
505
+ bot_comments_map: dict[tuple[str, int, str], dict[str, Any]] = {}
506
+
507
+ for comment in comments:
508
+ if not isinstance(comment, dict):
509
+ continue
510
+
511
+ body = comment.get("body", "")
512
+ comment_id = comment.get("id")
513
+ path = comment.get("path")
514
+ line = comment.get("line") or comment.get("original_line")
515
+
516
+ # Check if this is a bot comment with valid location
517
+ if (
518
+ identifier in str(body)
519
+ and isinstance(comment_id, int)
520
+ and isinstance(path, str)
521
+ and isinstance(line, int)
522
+ ):
523
+ # Extract issue type from HTML comment
524
+ issue_type_match = re.search(r"<!-- issue-type: (\w+) -->", body)
525
+ issue_type = issue_type_match.group(1) if issue_type_match else "unknown"
526
+
527
+ key = (path, line, issue_type)
528
+ bot_comments_map[key] = {
529
+ "id": comment_id,
530
+ "body": body,
531
+ "path": path,
532
+ "line": line,
533
+ "issue_type": issue_type,
534
+ "commit_id": comment.get("commit_id"),
535
+ }
536
+
537
+ logger.debug(f"Found {len(bot_comments_map)} bot review comments at specific locations")
538
+ return bot_comments_map
539
+
489
540
  async def delete_review_comment(self, comment_id: int) -> bool:
490
541
  """Delete a specific review comment.
491
542
 
@@ -505,6 +556,47 @@ class GitHubIntegration:
505
556
  return True
506
557
  return False
507
558
 
559
+ async def resolve_review_comment(self, comment_id: int) -> bool:
560
+ """Resolve a specific review comment.
561
+
562
+ Args:
563
+ comment_id: ID of the comment to resolve
564
+
565
+ Returns:
566
+ True if successful, False otherwise
567
+ """
568
+ result = await self._make_request(
569
+ "PATCH",
570
+ f"pulls/comments/{comment_id}",
571
+ json={"state": "resolved"},
572
+ )
573
+
574
+ if result is not None:
575
+ logger.info(f"Successfully resolved review comment {comment_id}")
576
+ return True
577
+ return False
578
+
579
+ async def update_review_comment(self, comment_id: int, new_body: str) -> bool:
580
+ """Update the body text of an existing review comment.
581
+
582
+ Args:
583
+ comment_id: ID of the comment to update
584
+ new_body: New comment text (markdown supported)
585
+
586
+ Returns:
587
+ True if successful, False otherwise
588
+ """
589
+ result = await self._make_request(
590
+ "PATCH",
591
+ f"pulls/comments/{comment_id}",
592
+ json={"body": new_body},
593
+ )
594
+
595
+ if result is not None:
596
+ logger.debug(f"Successfully updated review comment {comment_id}")
597
+ return True
598
+ return False
599
+
508
600
  async def cleanup_bot_review_comments(self, identifier: str = constants.BOT_IDENTIFIER) -> int:
509
601
  """Delete all review comments from the bot (from previous runs).
510
602
 
@@ -536,6 +628,40 @@ class GitHubIntegration:
536
628
 
537
629
  return deleted_count
538
630
 
631
+ async def cleanup_bot_review_comments_by_resolving(
632
+ self, identifier: str = constants.BOT_IDENTIFIER
633
+ ) -> int:
634
+ """Resolve all review comments from the bot (from previous runs).
635
+
636
+ This marks old/outdated comments as resolved instead of deleting them,
637
+ preserving them in the PR for audit trail purposes.
638
+
639
+ Args:
640
+ identifier: String to identify bot comments
641
+
642
+ Returns:
643
+ Number of comments resolved
644
+ """
645
+ comments = await self.get_review_comments()
646
+ resolved_count = 0
647
+
648
+ for comment in comments:
649
+ if not isinstance(comment, dict):
650
+ continue
651
+
652
+ body = comment.get("body", "")
653
+ comment_id = comment.get("id")
654
+
655
+ # Check if this is a bot comment
656
+ if identifier in str(body) and isinstance(comment_id, int):
657
+ if await self.resolve_review_comment(comment_id):
658
+ resolved_count += 1
659
+
660
+ if resolved_count > 0:
661
+ logger.info(f"Resolved {resolved_count} old review comments")
662
+
663
+ return resolved_count
664
+
539
665
  async def create_review_comment(
540
666
  self,
541
667
  commit_id: str,
@@ -646,6 +772,129 @@ class GitHubIntegration:
646
772
  return True
647
773
  return False
648
774
 
775
+ async def update_or_create_review_comments(
776
+ self,
777
+ comments: list[dict[str, Any]],
778
+ body: str = "",
779
+ event: ReviewEvent = ReviewEvent.COMMENT,
780
+ identifier: str = constants.REVIEW_IDENTIFIER,
781
+ ) -> bool:
782
+ """Smart comment management: update existing, create new, delete resolved.
783
+
784
+ This method implements a three-step process:
785
+ 1. Fetch existing bot comments at each location
786
+ 2. For each new comment: update if exists, create if new
787
+ 3. Delete old comments where issues have been resolved
788
+
789
+ Args:
790
+ comments: List of comment dicts with keys: path, line, body, (optional) side
791
+ body: The overall review body text
792
+ event: The review event type (APPROVE, REQUEST_CHANGES, COMMENT)
793
+ identifier: String to identify bot comments (for matching existing)
794
+
795
+ Returns:
796
+ True if successful, False otherwise
797
+
798
+ Example:
799
+ # First run: Creates 3 comments
800
+ comments = [
801
+ {"path": "policy.json", "line": 5, "body": "Issue A"},
802
+ {"path": "policy.json", "line": 10, "body": "Issue B"},
803
+ {"path": "policy.json", "line": 15, "body": "Issue C"},
804
+ ]
805
+
806
+ # Second run: Updates Issue A, keeps B, deletes C (resolved), adds D
807
+ comments = [
808
+ {"path": "policy.json", "line": 5, "body": "Issue A (updated)"},
809
+ {"path": "policy.json", "line": 10, "body": "Issue B"}, # Same = no update
810
+ {"path": "policy.json", "line": 20, "body": "Issue D"}, # New
811
+ ]
812
+ # Result: line 15 comment deleted (resolved), line 5 updated, line 20 created
813
+ """
814
+ # Step 1: Get existing bot comments mapped by location
815
+ existing_comments = await self.get_bot_review_comments_with_location(identifier)
816
+ logger.debug(f"Found {len(existing_comments)} existing bot comments")
817
+
818
+ # Track which existing comments we've seen (to know what to delete later)
819
+ seen_locations: set[tuple[str, int, str]] = set()
820
+ updated_count = 0
821
+ created_count = 0
822
+
823
+ # Step 2: Update or create each new comment
824
+ new_comments_for_review: list[dict[str, Any]] = []
825
+
826
+ for comment in comments:
827
+ path = comment["path"]
828
+ line = comment["line"]
829
+ new_body = comment["body"]
830
+
831
+ # Extract issue type from comment body HTML comment
832
+ issue_type_match = re.search(r"<!-- issue-type: (\w+) -->", new_body)
833
+ issue_type = issue_type_match.group(1) if issue_type_match else "unknown"
834
+
835
+ location = (path, line, issue_type)
836
+ seen_locations.add(location)
837
+
838
+ existing = existing_comments.get(location)
839
+
840
+ if existing:
841
+ # Comment exists at this location - check if body changed
842
+ if existing["body"] != new_body:
843
+ # Update the existing comment
844
+ success = await self.update_review_comment(existing["id"], new_body)
845
+ if success:
846
+ updated_count += 1
847
+ logger.debug(f"Updated comment at {path}:{line}")
848
+ else:
849
+ logger.warning(f"Failed to update comment at {path}:{line}")
850
+ else:
851
+ # Body unchanged, skip update
852
+ logger.debug(f"Comment at {path}:{line} unchanged, skipping update")
853
+ else:
854
+ # New comment - collect for batch creation
855
+ new_comments_for_review.append(comment)
856
+
857
+ # Step 3: Create new comments via review API (if any)
858
+ if new_comments_for_review:
859
+ success = await self.create_review_with_comments(
860
+ new_comments_for_review,
861
+ body=body,
862
+ event=event,
863
+ )
864
+ if success:
865
+ created_count = len(new_comments_for_review)
866
+ logger.info(f"Created {created_count} new review comments")
867
+ else:
868
+ logger.error("Failed to create new review comments")
869
+ return False
870
+
871
+ # Step 4: Delete comments for resolved issues (not in new comment set)
872
+ # IMPORTANT: Only delete comments for files that are in the current batch
873
+ # to avoid deleting comments from other files processed in the same run
874
+ deleted_count = 0
875
+ files_in_batch = {comment["path"] for comment in comments}
876
+
877
+ for location, existing in existing_comments.items():
878
+ # Only delete if:
879
+ # 1. This location is not in the new comment set (resolved issue)
880
+ # 2. AND this file is in the current batch (don't touch other files' comments)
881
+ if location not in seen_locations and existing["path"] in files_in_batch:
882
+ # This comment location is no longer in the new issues - delete it
883
+ success = await self.delete_review_comment(existing["id"])
884
+ if success:
885
+ deleted_count += 1
886
+ logger.debug(
887
+ f"Deleted resolved comment at {existing['path']}:{existing['line']}"
888
+ )
889
+
890
+ # Summary
891
+ logger.info(
892
+ f"Review comment management: {updated_count} updated, "
893
+ f"{created_count} created, {deleted_count} deleted (resolved)"
894
+ )
895
+
896
+ return True
897
+
649
898
  # ==================== PR Labels ====================
650
899
 
651
900
  async def add_labels(self, labels: list[str]) -> bool:
@@ -23,6 +23,14 @@ Quick Start:
23
23
  >>> summary = get_policy_summary(policy)
24
24
  >>> print(f"Actions: {summary['action_count']}")
25
25
 
26
+ Query AWS service definitions:
27
+ >>> from iam_validator.sdk import AWSServiceFetcher, query_actions, query_arn_formats
28
+ >>> async with AWSServiceFetcher() as fetcher:
29
+ ... # Query all S3 write actions
30
+ ... write_actions = await query_actions(fetcher, "s3", access_level="write")
31
+ ... # Get ARN formats for S3
32
+ ... arns = await query_arn_formats(fetcher, "s3")
33
+
26
34
  Custom check development:
27
35
  >>> from iam_validator.sdk import PolicyCheck, CheckHelper
28
36
  >>> class MyCheck(PolicyCheck):
@@ -108,6 +116,20 @@ from iam_validator.sdk.policy_utils import (
108
116
  policy_to_dict,
109
117
  policy_to_json,
110
118
  )
119
+
120
+ # === Query utilities (AWS service definition queries) ===
121
+ from iam_validator.sdk.query_utils import (
122
+ get_actions_by_access_level,
123
+ get_actions_supporting_condition,
124
+ get_wildcard_only_actions,
125
+ query_action_details,
126
+ query_actions,
127
+ query_arn_format,
128
+ query_arn_formats,
129
+ query_arn_types,
130
+ query_condition_key,
131
+ query_condition_keys,
132
+ )
111
133
  from iam_validator.sdk.shortcuts import (
112
134
  count_issues_by_severity,
113
135
  get_issues,
@@ -143,6 +165,17 @@ __all__ = [
143
165
  "policy_to_dict",
144
166
  "is_resource_policy",
145
167
  "has_public_access",
168
+ # === Query utilities ===
169
+ "query_actions",
170
+ "query_action_details",
171
+ "query_arn_formats",
172
+ "query_arn_types",
173
+ "query_arn_format",
174
+ "query_condition_keys",
175
+ "query_condition_key",
176
+ "get_actions_by_access_level",
177
+ "get_wildcard_only_actions",
178
+ "get_actions_supporting_condition",
146
179
  # === ARN utilities ===
147
180
  "arn_matches",
148
181
  "arn_strictly_valid",