iam-policy-validator 1.10.2__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.
Files changed (27) hide show
  1. iam_policy_validator-1.11.0.dist-info/METADATA +782 -0
  2. {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/RECORD +26 -22
  3. iam_validator/__version__.py +1 -1
  4. iam_validator/checks/action_condition_enforcement.py +27 -14
  5. iam_validator/checks/sensitive_action.py +123 -11
  6. iam_validator/checks/utils/policy_level_checks.py +47 -10
  7. iam_validator/checks/wildcard_resource.py +29 -7
  8. iam_validator/commands/__init__.py +6 -0
  9. iam_validator/commands/completion.py +420 -0
  10. iam_validator/commands/query.py +485 -0
  11. iam_validator/commands/validate.py +21 -26
  12. iam_validator/core/config/category_suggestions.py +77 -0
  13. iam_validator/core/config/condition_requirements.py +105 -54
  14. iam_validator/core/config/defaults.py +110 -6
  15. iam_validator/core/config/wildcards.py +3 -0
  16. iam_validator/core/diff_parser.py +321 -0
  17. iam_validator/core/formatters/enhanced.py +34 -27
  18. iam_validator/core/models.py +2 -0
  19. iam_validator/core/pr_commenter.py +179 -51
  20. iam_validator/core/report.py +19 -17
  21. iam_validator/integrations/github_integration.py +250 -1
  22. iam_validator/sdk/__init__.py +33 -0
  23. iam_validator/sdk/query_utils.py +454 -0
  24. iam_policy_validator-1.10.2.dist-info/METADATA +0 -549
  25. {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/WHEEL +0 -0
  26. {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/entry_points.txt +0 -0
  27. {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -13,6 +13,7 @@ from iam_validator.core.constants import (
13
13
  REVIEW_IDENTIFIER,
14
14
  SUMMARY_IDENTIFIER,
15
15
  )
16
+ from iam_validator.core.diff_parser import DiffParser
16
17
  from iam_validator.core.label_manager import LabelManager
17
18
  from iam_validator.core.models import ValidationIssue, ValidationReport
18
19
  from iam_validator.core.report import ReportGenerator
@@ -21,6 +22,34 @@ from iam_validator.integrations.github_integration import GitHubIntegration, Rev
21
22
  logger = logging.getLogger(__name__)
22
23
 
23
24
 
25
+ class ContextIssue:
26
+ """Represents an issue in a modified statement but on an unchanged line.
27
+
28
+ These issues are shown in the summary comment rather than as inline comments,
29
+ since GitHub only allows comments on lines that appear in the PR diff.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ file_path: str,
35
+ statement_index: int,
36
+ line_number: int,
37
+ issue: ValidationIssue,
38
+ ):
39
+ """Initialize context issue.
40
+
41
+ Args:
42
+ file_path: Relative path to the file
43
+ statement_index: Zero-based statement index
44
+ line_number: Line number where the issue exists
45
+ issue: The validation issue
46
+ """
47
+ self.file_path = file_path
48
+ self.statement_index = statement_index
49
+ self.line_number = line_number
50
+ self.issue = issue
51
+
52
+
24
53
  class PRCommenter:
25
54
  """Posts validation findings as PR comments."""
26
55
 
@@ -41,6 +70,7 @@ class PRCommenter:
41
70
  Args:
42
71
  github: GitHubIntegration instance (will create one if None)
43
72
  cleanup_old_comments: Whether to clean up old bot comments before posting new ones
73
+ (kept for backward compatibility but now handled automatically)
44
74
  fail_on_severities: List of severity levels that should trigger REQUEST_CHANGES
45
75
  (e.g., ["error", "critical", "high"])
46
76
  severity_labels: Mapping of severity levels to label name(s) for automatic label management
@@ -54,6 +84,8 @@ class PRCommenter:
54
84
  self.cleanup_old_comments = cleanup_old_comments
55
85
  self.fail_on_severities = fail_on_severities or ["error", "critical"]
56
86
  self.severity_labels = severity_labels or {}
87
+ # Track issues in modified statements that are on unchanged lines
88
+ self._context_issues: list[ContextIssue] = []
57
89
 
58
90
  async def post_findings_to_pr(
59
91
  self,
@@ -86,10 +118,15 @@ class PRCommenter:
86
118
 
87
119
  success = True
88
120
 
89
- # Clean up old bot comments if enabled
90
- if self.cleanup_old_comments and create_review:
91
- logger.info("Cleaning up old review comments from previous runs...")
92
- await self.github.cleanup_bot_review_comments(self.REVIEW_IDENTIFIER)
121
+ # Note: Cleanup is now handled smartly by update_or_create_review_comments()
122
+ # It will update existing comments, create new ones, and delete resolved ones
123
+
124
+ # Post line-specific review comments FIRST
125
+ # (This populates self._context_issues)
126
+ if create_review:
127
+ if not await self._post_review_comments(report):
128
+ logger.error("Failed to post review comments")
129
+ success = False
93
130
 
94
131
  # Post summary comment (potentially as multiple parts)
95
132
  if add_summary_comment:
@@ -108,12 +145,6 @@ class PRCommenter:
108
145
  else:
109
146
  logger.info("Posted summary comment")
110
147
 
111
- # Post line-specific review comments
112
- if create_review:
113
- if not await self._post_review_comments(report):
114
- logger.error("Failed to post review comments")
115
- success = False
116
-
117
148
  # Manage PR labels based on severity findings
118
149
  if manage_labels and self.severity_labels:
119
150
  label_manager = LabelManager(self.github, self.severity_labels)
@@ -129,7 +160,11 @@ class PRCommenter:
129
160
  return success
130
161
 
131
162
  async def _post_review_comments(self, report: ValidationReport) -> bool:
132
- """Post line-specific review comments.
163
+ """Post line-specific review comments with strict diff filtering.
164
+
165
+ Only posts comments on lines that were actually changed in the PR.
166
+ Issues in modified statements but on unchanged lines are tracked in
167
+ self._context_issues for inclusion in the summary comment.
133
168
 
134
169
  Args:
135
170
  report: Validation report
@@ -140,8 +175,25 @@ class PRCommenter:
140
175
  if not self.github:
141
176
  return False
142
177
 
178
+ # Clear context issues from previous runs
179
+ self._context_issues = []
180
+
181
+ # Fetch PR diff information
182
+ logger.info("Fetching PR diff information for strict filtering...")
183
+ pr_files = await self.github.get_pr_files()
184
+ if not pr_files:
185
+ logger.warning(
186
+ "Could not fetch PR diff information. "
187
+ "Falling back to unfiltered commenting (may fail if lines not in diff)."
188
+ )
189
+ parsed_diffs = {}
190
+ else:
191
+ parsed_diffs = DiffParser.parse_pr_files(pr_files)
192
+ logger.info(f"Parsed diffs for {len(parsed_diffs)} file(s)")
193
+
143
194
  # Group issues by file
144
- comments_by_file: dict[str, list[dict[str, Any]]] = {}
195
+ inline_comments: list[dict[str, Any]] = []
196
+ context_issue_count = 0
145
197
 
146
198
  for result in report.results:
147
199
  if not result.issues:
@@ -155,75 +207,149 @@ class PRCommenter:
155
207
  )
156
208
  continue
157
209
 
158
- # Try to determine line numbers from the policy file
210
+ # Get diff info for this file
211
+ diff_info = parsed_diffs.get(relative_path)
212
+ if not diff_info:
213
+ logger.debug(
214
+ f"{relative_path} not in PR diff or no changes, skipping inline comments"
215
+ )
216
+ # Still process issues for summary
217
+ for issue in result.issues:
218
+ if issue.statement_index is not None:
219
+ line_num = self._find_issue_line(
220
+ issue, result.policy_file, self._get_line_mapping(result.policy_file)
221
+ )
222
+ if line_num:
223
+ self._context_issues.append(
224
+ ContextIssue(relative_path, issue.statement_index, line_num, issue)
225
+ )
226
+ context_issue_count += 1
227
+ continue
228
+
229
+ # Get line mapping and modified statements for this file
159
230
  line_mapping = self._get_line_mapping(result.policy_file)
231
+ modified_statements = DiffParser.get_modified_statements(
232
+ line_mapping, diff_info.changed_lines, result.policy_file
233
+ )
160
234
 
235
+ logger.debug(
236
+ f"{relative_path}: {len(diff_info.changed_lines)} changed lines, "
237
+ f"{len(modified_statements)} modified statements"
238
+ )
239
+
240
+ # Process each issue with strict filtering
161
241
  for issue in result.issues:
162
- # Determine the line number for this issue
163
242
  line_number = self._find_issue_line(issue, result.policy_file, line_mapping)
164
243
 
165
- if line_number:
166
- comment = {
167
- "path": relative_path, # Use relative path for GitHub
168
- "line": line_number,
169
- "body": issue.to_pr_comment(),
170
- }
244
+ if not line_number:
245
+ logger.debug(
246
+ f"Could not determine line number for issue in {relative_path}: {issue.issue_type}"
247
+ )
248
+ continue
171
249
 
172
- if relative_path not in comments_by_file:
173
- comments_by_file[relative_path] = []
174
- comments_by_file[relative_path].append(comment)
250
+ # SPECIAL CASE: Policy-level issues (privilege escalation, etc.)
251
+ # Post to first available line in diff, preferring line 1 if available
252
+ if issue.statement_index == -1:
253
+ # Try to find the best line to post the comment
254
+ comment_line = None
255
+
256
+ if line_number in diff_info.changed_lines:
257
+ # Best case: line 1 is in the diff
258
+ comment_line = line_number
259
+ elif diff_info.changed_lines:
260
+ # Fallback: use the first changed line in the file
261
+ # This ensures policy-level issues always appear as inline comments
262
+ comment_line = min(diff_info.changed_lines)
263
+ logger.debug(
264
+ f"Policy-level issue at line {line_number}, posting to first changed line {comment_line}"
265
+ )
266
+
267
+ if comment_line:
268
+ # Post as inline comment at the determined line
269
+ inline_comments.append(
270
+ {
271
+ "path": relative_path,
272
+ "line": comment_line,
273
+ "body": issue.to_pr_comment(),
274
+ }
275
+ )
276
+ logger.debug(
277
+ f"Policy-level inline comment: {relative_path}:{comment_line} - {issue.issue_type}"
278
+ )
279
+ else:
280
+ # No changed lines in file - add to summary comment
281
+ self._context_issues.append(
282
+ ContextIssue(relative_path, issue.statement_index, line_number, issue)
283
+ )
284
+ context_issue_count += 1
285
+ logger.debug(
286
+ f"Policy-level issue (no diff lines): {relative_path} - {issue.issue_type}"
287
+ )
288
+ # STRICT FILTERING: Only comment if line is in the diff
289
+ elif line_number in diff_info.changed_lines:
290
+ # Exact match - post inline comment
291
+ inline_comments.append(
292
+ {
293
+ "path": relative_path,
294
+ "line": line_number,
295
+ "body": issue.to_pr_comment(),
296
+ }
297
+ )
298
+ logger.debug(
299
+ f"Inline comment: {relative_path}:{line_number} - {issue.issue_type}"
300
+ )
301
+ elif issue.statement_index in modified_statements:
302
+ # Issue in modified statement but on unchanged line - save for summary
303
+ self._context_issues.append(
304
+ ContextIssue(relative_path, issue.statement_index, line_number, issue)
305
+ )
306
+ context_issue_count += 1
175
307
  logger.debug(
176
- f"Prepared review comment for {relative_path}:{line_number} - {issue.issue_type}"
308
+ f"Context issue: {relative_path}:{line_number} (statement {issue.statement_index} modified) - {issue.issue_type}"
177
309
  )
178
310
  else:
311
+ # Issue in completely unchanged statement - ignore for inline and summary
179
312
  logger.debug(
180
- f"Could not determine line number for issue in {relative_path}: {issue.issue_type}"
313
+ f"Skipped issue in unchanged statement: {relative_path}:{line_number} - {issue.issue_type}"
181
314
  )
182
315
 
183
- # If no line-specific comments, skip
184
- if not comments_by_file:
185
- logger.info("No line-specific comments to post")
186
- return True
187
-
188
- # Flatten comments list
189
- all_comments = []
190
- for file_comments in comments_by_file.values():
191
- all_comments.extend(file_comments)
192
-
316
+ # Log filtering results
193
317
  logger.info(
194
- f"Posting {len(all_comments)} review comments across {len(comments_by_file)} file(s)"
318
+ f"Diff filtering results: {len(inline_comments)} inline comments, "
319
+ f"{context_issue_count} context issues for summary"
195
320
  )
196
321
 
197
- # Log files that will receive comments (for debugging)
198
- for file_path, file_comments in comments_by_file.items():
199
- logger.debug(f" {file_path}: {len(file_comments)} comment(s)")
322
+ # If no inline comments, skip review creation but still return success
323
+ if not inline_comments:
324
+ logger.info("No inline comments to post (after diff filtering)")
325
+ return True
200
326
 
201
327
  # Determine review event based on fail_on_severities config
202
- # Check if any issue has a severity that should trigger REQUEST_CHANGES
203
328
  has_blocking_issues = any(
204
329
  issue.severity in self.fail_on_severities
205
330
  for result in report.results
206
331
  for issue in result.issues
207
332
  )
208
333
 
209
- # Set review event: request changes if any blocking issues, else comment
210
334
  event = ReviewEvent.REQUEST_CHANGES if has_blocking_issues else ReviewEvent.COMMENT
211
- logger.info(f"Creating PR review with event: {event.value}")
335
+ logger.info(
336
+ f"Creating PR review with {len(inline_comments)} comments, event: {event.value}"
337
+ )
212
338
 
213
- # Post review with comments (use minimal body since summary comment has the details)
214
- # Only include the identifier for cleanup purposes
339
+ # Post review with smart update-or-create logic
215
340
  review_body = f"{self.REVIEW_IDENTIFIER}"
216
341
 
217
- success = await self.github.create_review_with_comments(
218
- comments=all_comments,
342
+ success = await self.github.update_or_create_review_comments(
343
+ comments=inline_comments,
219
344
  body=review_body,
220
345
  event=event,
346
+ identifier=self.REVIEW_IDENTIFIER,
221
347
  )
222
348
 
223
349
  if success:
224
- logger.info(f"Successfully created PR review with {len(all_comments)} comments")
350
+ logger.info("Successfully managed PR review comments (update/create/delete)")
225
351
  else:
226
- logger.error("Failed to create PR review")
352
+ logger.error("Failed to manage PR review comments")
227
353
 
228
354
  return success
229
355
 
@@ -238,8 +364,8 @@ class PRCommenter:
238
364
  Returns:
239
365
  Relative path from repository root, or None if cannot be determined
240
366
  """
241
- import os
242
- from pathlib import Path
367
+ import os # pylint: disable=import-outside-toplevel
368
+ from pathlib import Path # pylint: disable=import-outside-toplevel
243
369
 
244
370
  # If already relative, use as-is
245
371
  if not os.path.isabs(policy_file):
@@ -421,7 +547,9 @@ async def post_report_to_pr(
421
547
  report = ValidationReport.model_validate(report_data)
422
548
 
423
549
  # Load config to get fail_on_severity and severity_labels settings
424
- from iam_validator.core.config.config_loader import ConfigLoader
550
+ from iam_validator.core.config.config_loader import ( # pylint: disable=import-outside-toplevel
551
+ ConfigLoader,
552
+ )
425
553
 
426
554
  config = ConfigLoader.load_config(config_path)
427
555
  fail_on_severities = config.get_setting("fail_on_severity", ["error", "critical"])
@@ -819,24 +819,26 @@ class ReportGenerator:
819
819
  issue: The validation issue to format
820
820
  policy_file: Optional policy file path (currently unused, kept for compatibility)
821
821
  """
822
- # Use 1-indexed statement numbers for user-facing output
823
- statement_num = issue.statement_index + 1
824
-
825
- # Build statement location reference
826
- # Note: We show plain text here instead of links because:
827
- # 1. GitHub's diff anchor format only works for files in the PR diff
828
- # 2. Inline review comments (posted separately) already provide perfect navigation
829
- # 3. Summary comment is for overview, not detailed navigation
830
- if issue.line_number:
831
- location = f"Statement {statement_num} (Line {issue.line_number})"
832
- if issue.statement_sid:
833
- location = (
834
- f"`{issue.statement_sid}` (statement {statement_num}, line {issue.line_number})"
835
- )
822
+ # Handle policy-level issues (statement_index = -1)
823
+ if issue.statement_index == -1:
824
+ location = "Policy-level"
836
825
  else:
837
- location = f"Statement {statement_num}"
838
- if issue.statement_sid:
839
- location = f"`{issue.statement_sid}` (statement {statement_num})"
826
+ # Use 1-indexed statement numbers for user-facing output
827
+ statement_num = issue.statement_index + 1
828
+
829
+ # Build statement location reference
830
+ # Note: We show plain text here instead of links because:
831
+ # 1. GitHub's diff anchor format only works for files in the PR diff
832
+ # 2. Inline review comments (posted separately) already provide perfect navigation
833
+ # 3. Summary comment is for overview, not detailed navigation
834
+ if issue.line_number:
835
+ location = f"Statement {statement_num} (Line {issue.line_number})"
836
+ if issue.statement_sid:
837
+ location = f"`{issue.statement_sid}` (statement {statement_num}, line {issue.line_number})"
838
+ else:
839
+ location = f"Statement {statement_num}"
840
+ if issue.statement_sid:
841
+ location = f"`{issue.statement_sid}` (statement {statement_num})"
840
842
 
841
843
  parts = []
842
844
 
@@ -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: