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.
- iam_policy_validator-1.7.0.dist-info/METADATA +1057 -0
- iam_policy_validator-1.7.0.dist-info/RECORD +83 -0
- iam_policy_validator-1.7.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.7.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.7.0.dist-info/licenses/LICENSE +21 -0
- iam_validator/__init__.py +27 -0
- iam_validator/__main__.py +11 -0
- iam_validator/__version__.py +7 -0
- iam_validator/checks/__init__.py +43 -0
- iam_validator/checks/action_condition_enforcement.py +884 -0
- iam_validator/checks/action_resource_matching.py +441 -0
- iam_validator/checks/action_validation.py +72 -0
- iam_validator/checks/condition_key_validation.py +92 -0
- iam_validator/checks/condition_type_mismatch.py +259 -0
- iam_validator/checks/full_wildcard.py +71 -0
- iam_validator/checks/mfa_condition_check.py +112 -0
- iam_validator/checks/policy_size.py +147 -0
- iam_validator/checks/policy_type_validation.py +305 -0
- iam_validator/checks/principal_validation.py +776 -0
- iam_validator/checks/resource_validation.py +138 -0
- iam_validator/checks/sensitive_action.py +254 -0
- iam_validator/checks/service_wildcard.py +107 -0
- iam_validator/checks/set_operator_validation.py +157 -0
- iam_validator/checks/sid_uniqueness.py +170 -0
- iam_validator/checks/utils/__init__.py +1 -0
- iam_validator/checks/utils/policy_level_checks.py +143 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +294 -0
- iam_validator/checks/utils/wildcard_expansion.py +87 -0
- iam_validator/checks/wildcard_action.py +67 -0
- iam_validator/checks/wildcard_resource.py +135 -0
- iam_validator/commands/__init__.py +25 -0
- iam_validator/commands/analyze.py +531 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +392 -0
- iam_validator/commands/download_services.py +255 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/validate.py +600 -0
- iam_validator/core/__init__.py +14 -0
- iam_validator/core/access_analyzer.py +671 -0
- iam_validator/core/access_analyzer_report.py +640 -0
- iam_validator/core/aws_fetcher.py +940 -0
- iam_validator/core/check_registry.py +607 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/condition_validators.py +626 -0
- iam_validator/core/config/__init__.py +81 -0
- iam_validator/core/config/aws_api.py +35 -0
- iam_validator/core/config/aws_global_conditions.py +160 -0
- iam_validator/core/config/category_suggestions.py +104 -0
- iam_validator/core/config/condition_requirements.py +155 -0
- iam_validator/core/config/config_loader.py +472 -0
- iam_validator/core/config/defaults.py +523 -0
- iam_validator/core/config/principal_requirements.py +421 -0
- iam_validator/core/config/sensitive_actions.py +672 -0
- iam_validator/core/config/service_principals.py +95 -0
- iam_validator/core/config/wildcards.py +124 -0
- iam_validator/core/constants.py +74 -0
- iam_validator/core/formatters/__init__.py +27 -0
- iam_validator/core/formatters/base.py +147 -0
- iam_validator/core/formatters/console.py +59 -0
- iam_validator/core/formatters/csv.py +170 -0
- iam_validator/core/formatters/enhanced.py +440 -0
- iam_validator/core/formatters/html.py +672 -0
- iam_validator/core/formatters/json.py +33 -0
- iam_validator/core/formatters/markdown.py +63 -0
- iam_validator/core/formatters/sarif.py +251 -0
- iam_validator/core/models.py +327 -0
- iam_validator/core/policy_checks.py +656 -0
- iam_validator/core/policy_loader.py +396 -0
- iam_validator/core/pr_commenter.py +424 -0
- iam_validator/core/report.py +872 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +815 -0
- iam_validator/integrations/ms_teams.py +442 -0
- iam_validator/sdk/__init__.py +187 -0
- iam_validator/sdk/arn_matching.py +382 -0
- iam_validator/sdk/context.py +222 -0
- iam_validator/sdk/exceptions.py +48 -0
- iam_validator/sdk/helpers.py +177 -0
- iam_validator/sdk/policy_utils.py +425 -0
- iam_validator/sdk/shortcuts.py +283 -0
- iam_validator/utils/__init__.py +31 -0
- iam_validator/utils/cache.py +105 -0
- 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
|