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.
- iam_policy_validator-1.11.0.dist-info/METADATA +782 -0
- {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/RECORD +26 -22
- iam_validator/__version__.py +1 -1
- iam_validator/checks/action_condition_enforcement.py +27 -14
- iam_validator/checks/sensitive_action.py +123 -11
- iam_validator/checks/utils/policy_level_checks.py +47 -10
- iam_validator/checks/wildcard_resource.py +29 -7
- iam_validator/commands/__init__.py +6 -0
- iam_validator/commands/completion.py +420 -0
- iam_validator/commands/query.py +485 -0
- iam_validator/commands/validate.py +21 -26
- iam_validator/core/config/category_suggestions.py +77 -0
- iam_validator/core/config/condition_requirements.py +105 -54
- iam_validator/core/config/defaults.py +110 -6
- iam_validator/core/config/wildcards.py +3 -0
- iam_validator/core/diff_parser.py +321 -0
- iam_validator/core/formatters/enhanced.py +34 -27
- iam_validator/core/models.py +2 -0
- iam_validator/core/pr_commenter.py +179 -51
- iam_validator/core/report.py +19 -17
- iam_validator/integrations/github_integration.py +250 -1
- iam_validator/sdk/__init__.py +33 -0
- iam_validator/sdk/query_utils.py +454 -0
- iam_policy_validator-1.10.2.dist-info/METADATA +0 -549
- {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/WHEEL +0 -0
- {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
167
|
-
"
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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"
|
|
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"
|
|
313
|
+
f"Skipped issue in unchanged statement: {relative_path}:{line_number} - {issue.issue_type}"
|
|
181
314
|
)
|
|
182
315
|
|
|
183
|
-
#
|
|
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"
|
|
318
|
+
f"Diff filtering results: {len(inline_comments)} inline comments, "
|
|
319
|
+
f"{context_issue_count} context issues for summary"
|
|
195
320
|
)
|
|
196
321
|
|
|
197
|
-
#
|
|
198
|
-
|
|
199
|
-
logger.
|
|
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(
|
|
335
|
+
logger.info(
|
|
336
|
+
f"Creating PR review with {len(inline_comments)} comments, event: {event.value}"
|
|
337
|
+
)
|
|
212
338
|
|
|
213
|
-
# Post review with
|
|
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.
|
|
218
|
-
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(
|
|
350
|
+
logger.info("Successfully managed PR review comments (update/create/delete)")
|
|
225
351
|
else:
|
|
226
|
-
logger.error("Failed to
|
|
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
|
|
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"])
|
iam_validator/core/report.py
CHANGED
|
@@ -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
|
-
#
|
|
823
|
-
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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:
|