diagram-to-iac 1.0.2__py3-none-any.whl → 1.0.4__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.
- diagram_to_iac/actions/supervisor_entry.py +165 -3
- diagram_to_iac/agents/git_langgraph/agent.py +60 -28
- diagram_to_iac/agents/supervisor_langgraph/agent.py +362 -33
- diagram_to_iac/agents/supervisor_langgraph/github_listener.py +433 -0
- diagram_to_iac/core/registry.py +674 -0
- diagram_to_iac/services/commenter.py +589 -0
- diagram_to_iac/tools/llm_utils/__init__.py +3 -1
- diagram_to_iac/tools/llm_utils/grok_driver.py +71 -0
- diagram_to_iac/tools/llm_utils/router.py +220 -30
- {diagram_to_iac-1.0.2.dist-info → diagram_to_iac-1.0.4.dist-info}/METADATA +5 -4
- {diagram_to_iac-1.0.2.dist-info → diagram_to_iac-1.0.4.dist-info}/RECORD +14 -10
- {diagram_to_iac-1.0.2.dist-info → diagram_to_iac-1.0.4.dist-info}/WHEEL +0 -0
- {diagram_to_iac-1.0.2.dist-info → diagram_to_iac-1.0.4.dist-info}/entry_points.txt +0 -0
- {diagram_to_iac-1.0.2.dist-info → diagram_to_iac-1.0.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,433 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
GitHub Comment Listener for DevOps-in-a-Box R2D Action
|
4
|
+
======================================================
|
5
|
+
|
6
|
+
This module provides GitHub comment monitoring and retry dispatch functionality.
|
7
|
+
It polls umbrella issues for retry commands and dispatches appropriate actions
|
8
|
+
based on commit SHA matching and available secrets.
|
9
|
+
|
10
|
+
Key Features:
|
11
|
+
- Poll GitHub issues for retry keywords (retry, run again, continue)
|
12
|
+
- SHA-based decision making: resume existing or start new pipeline
|
13
|
+
- PAT detection and automatic workflow resumption
|
14
|
+
- Integration with RunRegistry for state management
|
15
|
+
- Webhook-style comment handling for real-time responses
|
16
|
+
|
17
|
+
Usage:
|
18
|
+
from diagram_to_iac.agents.supervisor_langgraph.github_listener import GitHubListener
|
19
|
+
|
20
|
+
listener = GitHubListener(github_token=token)
|
21
|
+
listener.start_polling(issue_id=123, repo_url="https://github.com/user/repo")
|
22
|
+
"""
|
23
|
+
|
24
|
+
import re
|
25
|
+
import time
|
26
|
+
import logging
|
27
|
+
from datetime import datetime, timedelta
|
28
|
+
from typing import Dict, Any, Optional, List, Callable
|
29
|
+
from dataclasses import dataclass
|
30
|
+
from enum import Enum
|
31
|
+
|
32
|
+
from pydantic import BaseModel, Field
|
33
|
+
|
34
|
+
from diagram_to_iac.core.registry import RunRegistry, RunMetadata, RunStatus
|
35
|
+
from diagram_to_iac.agents.git_langgraph.agent import GitAgent, GitAgentInput, GitAgentOutput
|
36
|
+
|
37
|
+
|
38
|
+
class RetryCommand(str, Enum):
|
39
|
+
"""Supported retry command keywords."""
|
40
|
+
RETRY = "retry"
|
41
|
+
RUN_AGAIN = "run again"
|
42
|
+
CONTINUE = "continue"
|
43
|
+
RESUME = "resume"
|
44
|
+
|
45
|
+
|
46
|
+
@dataclass
|
47
|
+
class CommentEvent:
|
48
|
+
"""Represents a GitHub issue comment event."""
|
49
|
+
comment_id: int
|
50
|
+
author: str
|
51
|
+
body: str
|
52
|
+
created_at: datetime
|
53
|
+
issue_id: int
|
54
|
+
repo_url: str
|
55
|
+
|
56
|
+
|
57
|
+
@dataclass
|
58
|
+
class RetryContext:
|
59
|
+
"""Context information for retry operations."""
|
60
|
+
command: RetryCommand
|
61
|
+
comment_event: CommentEvent
|
62
|
+
target_sha: Optional[str] = None
|
63
|
+
is_manual_request: bool = False
|
64
|
+
should_resume: bool = False
|
65
|
+
existing_run: Optional[RunMetadata] = None
|
66
|
+
|
67
|
+
|
68
|
+
class GitHubListener:
|
69
|
+
"""
|
70
|
+
GitHub comment listener for retry dispatch functionality.
|
71
|
+
|
72
|
+
Monitors GitHub issues for retry commands and coordinates with the
|
73
|
+
RunRegistry to determine appropriate actions (resume vs new run).
|
74
|
+
"""
|
75
|
+
|
76
|
+
def __init__(self, github_token: Optional[str] = None, registry: Optional[RunRegistry] = None):
|
77
|
+
"""
|
78
|
+
Initialize the GitHub listener.
|
79
|
+
|
80
|
+
Args:
|
81
|
+
github_token: GitHub personal access token
|
82
|
+
registry: RunRegistry instance for state management
|
83
|
+
"""
|
84
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
85
|
+
self.github_token = github_token
|
86
|
+
self.registry = registry or RunRegistry()
|
87
|
+
self.git_agent = GitAgent()
|
88
|
+
|
89
|
+
# Compile retry command patterns
|
90
|
+
self.retry_patterns = [
|
91
|
+
re.compile(r'\b(retry)\b', re.IGNORECASE),
|
92
|
+
re.compile(r'\b(run\s+again)\b', re.IGNORECASE),
|
93
|
+
re.compile(r'\b(continue)\b', re.IGNORECASE),
|
94
|
+
re.compile(r'\b(resume)\b', re.IGNORECASE),
|
95
|
+
]
|
96
|
+
|
97
|
+
# Callbacks for different actions
|
98
|
+
self.resume_callback: Optional[Callable[[RetryContext], bool]] = None
|
99
|
+
self.new_run_callback: Optional[Callable[[RetryContext], bool]] = None
|
100
|
+
|
101
|
+
self.logger.info("GitHub listener initialized")
|
102
|
+
|
103
|
+
def set_callbacks(self,
|
104
|
+
resume_callback: Optional[Callable[[RetryContext], bool]] = None,
|
105
|
+
new_run_callback: Optional[Callable[[RetryContext], bool]] = None):
|
106
|
+
"""
|
107
|
+
Set callback functions for handling retry actions.
|
108
|
+
|
109
|
+
Args:
|
110
|
+
resume_callback: Function to call when resuming existing run
|
111
|
+
new_run_callback: Function to call when starting new run
|
112
|
+
"""
|
113
|
+
self.resume_callback = resume_callback
|
114
|
+
self.new_run_callback = new_run_callback
|
115
|
+
self.logger.info("Retry callbacks configured")
|
116
|
+
|
117
|
+
def detect_retry_command(self, comment_body: str) -> Optional[RetryCommand]:
|
118
|
+
"""
|
119
|
+
Detect retry commands in a comment body.
|
120
|
+
|
121
|
+
Args:
|
122
|
+
comment_body: The text content of the comment
|
123
|
+
|
124
|
+
Returns:
|
125
|
+
RetryCommand if found, None otherwise
|
126
|
+
"""
|
127
|
+
for pattern in self.retry_patterns:
|
128
|
+
match = pattern.search(comment_body)
|
129
|
+
if match:
|
130
|
+
command_text = match.group(1).lower().replace(" ", "_")
|
131
|
+
try:
|
132
|
+
return RetryCommand(command_text)
|
133
|
+
except ValueError:
|
134
|
+
# Handle "run again" case
|
135
|
+
if "run" in command_text and "again" in comment_body.lower():
|
136
|
+
return RetryCommand.RUN_AGAIN
|
137
|
+
return None
|
138
|
+
|
139
|
+
def extract_sha_from_comment(self, comment_body: str) -> Optional[str]:
|
140
|
+
"""
|
141
|
+
Extract commit SHA from comment if specified.
|
142
|
+
|
143
|
+
Args:
|
144
|
+
comment_body: The text content of the comment
|
145
|
+
|
146
|
+
Returns:
|
147
|
+
Commit SHA if found, None otherwise
|
148
|
+
"""
|
149
|
+
# Look for SHA patterns (7+ hex characters)
|
150
|
+
sha_pattern = re.compile(r'\b([a-f0-9]{7,40})\b', re.IGNORECASE)
|
151
|
+
match = sha_pattern.search(comment_body)
|
152
|
+
if match:
|
153
|
+
return match.group(1).lower()
|
154
|
+
return None
|
155
|
+
|
156
|
+
def get_latest_commit_sha(self, repo_url: str) -> Optional[str]:
|
157
|
+
"""
|
158
|
+
Get the latest commit SHA from the repository.
|
159
|
+
|
160
|
+
Args:
|
161
|
+
repo_url: GitHub repository URL
|
162
|
+
|
163
|
+
Returns:
|
164
|
+
Latest commit SHA if available, None otherwise
|
165
|
+
"""
|
166
|
+
try:
|
167
|
+
# Use GitAgent to get latest commit info
|
168
|
+
result = self.git_agent.run(GitAgentInput(
|
169
|
+
query=f"get latest commit SHA for repository {repo_url}",
|
170
|
+
thread_id="github_listener"
|
171
|
+
))
|
172
|
+
|
173
|
+
if result.success and result.answer:
|
174
|
+
# Extract SHA from the response
|
175
|
+
sha_match = re.search(r'\b([a-f0-9]{7,40})\b', result.answer, re.IGNORECASE)
|
176
|
+
if sha_match:
|
177
|
+
return sha_match.group(1).lower()
|
178
|
+
|
179
|
+
self.logger.warning(f"Could not retrieve latest commit SHA for {repo_url}")
|
180
|
+
return None
|
181
|
+
|
182
|
+
except Exception as e:
|
183
|
+
self.logger.error(f"Error getting latest commit SHA: {e}")
|
184
|
+
return None
|
185
|
+
|
186
|
+
def analyze_retry_context(self, comment_event: CommentEvent, command: RetryCommand) -> RetryContext:
|
187
|
+
"""
|
188
|
+
Analyze retry context to determine appropriate action.
|
189
|
+
|
190
|
+
Args:
|
191
|
+
comment_event: The comment that triggered the retry
|
192
|
+
command: The detected retry command
|
193
|
+
|
194
|
+
Returns:
|
195
|
+
RetryContext with analysis results
|
196
|
+
"""
|
197
|
+
context = RetryContext(
|
198
|
+
command=command,
|
199
|
+
comment_event=comment_event
|
200
|
+
)
|
201
|
+
|
202
|
+
# Extract SHA from comment or get latest
|
203
|
+
target_sha = self.extract_sha_from_comment(comment_event.body)
|
204
|
+
if not target_sha:
|
205
|
+
target_sha = self.get_latest_commit_sha(comment_event.repo_url)
|
206
|
+
|
207
|
+
context.target_sha = target_sha
|
208
|
+
|
209
|
+
if not target_sha:
|
210
|
+
self.logger.warning("Could not determine target SHA for retry")
|
211
|
+
return context
|
212
|
+
|
213
|
+
# Find existing runs for this repo and SHA
|
214
|
+
existing_runs = self.registry.find_by_commit_and_repo(
|
215
|
+
comment_event.repo_url, target_sha
|
216
|
+
)
|
217
|
+
|
218
|
+
if existing_runs:
|
219
|
+
# Same SHA - check if we can resume
|
220
|
+
latest_run = existing_runs[0] # Already sorted by creation time
|
221
|
+
context.existing_run = latest_run
|
222
|
+
|
223
|
+
# Check if run can be resumed (waiting for PAT, etc.)
|
224
|
+
if latest_run.can_be_resumed():
|
225
|
+
context.should_resume = True
|
226
|
+
self.logger.info(f"Found resumable run for SHA {target_sha[:7]}: {latest_run.run_key}")
|
227
|
+
else:
|
228
|
+
# Run exists but can't be resumed - check if it's completed/failed
|
229
|
+
if latest_run.status in [RunStatus.COMPLETED, RunStatus.FAILED]:
|
230
|
+
context.is_manual_request = True
|
231
|
+
self.logger.info(f"Found completed run for SHA {target_sha[:7]}, treating as manual request")
|
232
|
+
else:
|
233
|
+
self.logger.warning(f"Found non-resumable run for SHA {target_sha[:7]}: {latest_run.status}")
|
234
|
+
else:
|
235
|
+
# No existing runs for this SHA - treat as manual request
|
236
|
+
context.is_manual_request = True
|
237
|
+
self.logger.info(f"No existing runs found for SHA {target_sha[:7]}, treating as manual request")
|
238
|
+
|
239
|
+
return context
|
240
|
+
|
241
|
+
def add_resumption_comment(self, issue_id: int, repo_url: str, commit_sha: str) -> bool:
|
242
|
+
"""
|
243
|
+
Add a comment to the issue indicating resumption.
|
244
|
+
|
245
|
+
Args:
|
246
|
+
issue_id: GitHub issue ID
|
247
|
+
repo_url: Repository URL
|
248
|
+
commit_sha: Commit SHA being resumed
|
249
|
+
|
250
|
+
Returns:
|
251
|
+
True if comment was added successfully, False otherwise
|
252
|
+
"""
|
253
|
+
try:
|
254
|
+
short_sha = commit_sha[:7]
|
255
|
+
comment_text = f"🔄 **Resuming action on commit `{short_sha}`**\n\nPipeline resumption requested. Checking for updated secrets and continuing from previous state."
|
256
|
+
|
257
|
+
result = self.git_agent.run(GitAgentInput(
|
258
|
+
query=f"add comment to issue {issue_id} in repository {repo_url}: {comment_text}",
|
259
|
+
issue_id=issue_id,
|
260
|
+
thread_id="github_listener"
|
261
|
+
))
|
262
|
+
|
263
|
+
if result.success:
|
264
|
+
self.logger.info(f"Added resumption comment to issue #{issue_id}")
|
265
|
+
return True
|
266
|
+
else:
|
267
|
+
self.logger.warning(f"Failed to add resumption comment: {result.error_message}")
|
268
|
+
return False
|
269
|
+
|
270
|
+
except Exception as e:
|
271
|
+
self.logger.error(f"Error adding resumption comment: {e}")
|
272
|
+
return False
|
273
|
+
|
274
|
+
def handle_retry_command(self, comment_event: CommentEvent) -> bool:
|
275
|
+
"""
|
276
|
+
Handle a detected retry command.
|
277
|
+
|
278
|
+
Args:
|
279
|
+
comment_event: The comment event containing the retry command
|
280
|
+
|
281
|
+
Returns:
|
282
|
+
True if retry was handled successfully, False otherwise
|
283
|
+
"""
|
284
|
+
# Detect the retry command
|
285
|
+
command = self.detect_retry_command(comment_event.body)
|
286
|
+
if not command:
|
287
|
+
return False
|
288
|
+
|
289
|
+
self.logger.info(f"Detected retry command '{command.value}' from {comment_event.author}")
|
290
|
+
|
291
|
+
# Analyze the retry context
|
292
|
+
context = self.analyze_retry_context(comment_event, command)
|
293
|
+
|
294
|
+
if context.should_resume and context.existing_run:
|
295
|
+
# Resume existing run
|
296
|
+
self.logger.info(f"Resuming run {context.existing_run.run_key} for SHA {context.target_sha[:7]}")
|
297
|
+
|
298
|
+
# Add resumption comment
|
299
|
+
self.add_resumption_comment(
|
300
|
+
comment_event.issue_id,
|
301
|
+
comment_event.repo_url,
|
302
|
+
context.target_sha
|
303
|
+
)
|
304
|
+
|
305
|
+
# Call resume callback if set
|
306
|
+
if self.resume_callback:
|
307
|
+
return self.resume_callback(context)
|
308
|
+
else:
|
309
|
+
self.logger.warning("No resume callback configured")
|
310
|
+
return False
|
311
|
+
|
312
|
+
elif context.is_manual_request:
|
313
|
+
# Start new run
|
314
|
+
self.logger.info(f"Starting new run for SHA {context.target_sha[:7]} (manual request)")
|
315
|
+
|
316
|
+
# Call new run callback if set
|
317
|
+
if self.new_run_callback:
|
318
|
+
return self.new_run_callback(context)
|
319
|
+
else:
|
320
|
+
self.logger.warning("No new run callback configured")
|
321
|
+
return False
|
322
|
+
else:
|
323
|
+
self.logger.warning(f"Could not determine appropriate action for retry command")
|
324
|
+
return False
|
325
|
+
|
326
|
+
def poll_issue_comments(self, issue_id: int, repo_url: str,
|
327
|
+
poll_interval: int = 30, max_polls: Optional[int] = None) -> None:
|
328
|
+
"""
|
329
|
+
Poll an issue for new comments containing retry commands.
|
330
|
+
|
331
|
+
Args:
|
332
|
+
issue_id: GitHub issue ID to monitor
|
333
|
+
repo_url: Repository URL
|
334
|
+
poll_interval: Seconds between polls
|
335
|
+
max_polls: Maximum number of polls (None for infinite)
|
336
|
+
"""
|
337
|
+
self.logger.info(f"Starting comment polling for issue #{issue_id} in {repo_url}")
|
338
|
+
|
339
|
+
last_check = datetime.utcnow()
|
340
|
+
poll_count = 0
|
341
|
+
|
342
|
+
while max_polls is None or poll_count < max_polls:
|
343
|
+
try:
|
344
|
+
# Get recent comments
|
345
|
+
result = self.git_agent.run(GitAgentInput(
|
346
|
+
query=f"get recent comments for issue {issue_id} in repository {repo_url} since {last_check.isoformat()}",
|
347
|
+
thread_id="github_listener"
|
348
|
+
))
|
349
|
+
|
350
|
+
if result.success and result.answer:
|
351
|
+
# Parse comments from response (this would need actual GitHub API integration)
|
352
|
+
# For now, simulate comment detection
|
353
|
+
self.logger.debug(f"Checked for new comments on issue #{issue_id}")
|
354
|
+
|
355
|
+
last_check = datetime.utcnow()
|
356
|
+
poll_count += 1
|
357
|
+
|
358
|
+
if max_polls is None or poll_count < max_polls:
|
359
|
+
time.sleep(poll_interval)
|
360
|
+
|
361
|
+
except KeyboardInterrupt:
|
362
|
+
self.logger.info("Polling interrupted by user")
|
363
|
+
break
|
364
|
+
except Exception as e:
|
365
|
+
self.logger.error(f"Error during comment polling: {e}")
|
366
|
+
time.sleep(poll_interval)
|
367
|
+
poll_count += 1
|
368
|
+
|
369
|
+
self.logger.info(f"Comment polling completed after {poll_count} polls")
|
370
|
+
|
371
|
+
def simulate_comment_event(self, issue_id: int, repo_url: str,
|
372
|
+
comment_body: str, author: str = "test-user") -> CommentEvent:
|
373
|
+
"""
|
374
|
+
Simulate a comment event for testing purposes.
|
375
|
+
|
376
|
+
Args:
|
377
|
+
issue_id: GitHub issue ID
|
378
|
+
repo_url: Repository URL
|
379
|
+
comment_body: Comment text content
|
380
|
+
author: Comment author
|
381
|
+
|
382
|
+
Returns:
|
383
|
+
Simulated CommentEvent
|
384
|
+
"""
|
385
|
+
return CommentEvent(
|
386
|
+
comment_id=12345,
|
387
|
+
author=author,
|
388
|
+
body=comment_body,
|
389
|
+
created_at=datetime.utcnow(),
|
390
|
+
issue_id=issue_id,
|
391
|
+
repo_url=repo_url
|
392
|
+
)
|
393
|
+
|
394
|
+
|
395
|
+
# Helper functions for integration
|
396
|
+
def create_github_listener(github_token: Optional[str] = None,
|
397
|
+
registry: Optional[RunRegistry] = None) -> GitHubListener:
|
398
|
+
"""Create a configured GitHub listener instance."""
|
399
|
+
return GitHubListener(github_token=github_token, registry=registry)
|
400
|
+
|
401
|
+
|
402
|
+
def handle_webhook_comment(webhook_payload: Dict[str, Any],
|
403
|
+
listener: GitHubListener) -> bool:
|
404
|
+
"""
|
405
|
+
Handle a GitHub webhook comment event.
|
406
|
+
|
407
|
+
Args:
|
408
|
+
webhook_payload: GitHub webhook payload
|
409
|
+
listener: Configured GitHubListener instance
|
410
|
+
|
411
|
+
Returns:
|
412
|
+
True if comment was handled successfully, False otherwise
|
413
|
+
"""
|
414
|
+
try:
|
415
|
+
# Extract comment information from webhook payload
|
416
|
+
comment_data = webhook_payload.get('comment', {})
|
417
|
+
issue_data = webhook_payload.get('issue', {})
|
418
|
+
repo_data = webhook_payload.get('repository', {})
|
419
|
+
|
420
|
+
comment_event = CommentEvent(
|
421
|
+
comment_id=comment_data.get('id', 0),
|
422
|
+
author=comment_data.get('user', {}).get('login', 'unknown'),
|
423
|
+
body=comment_data.get('body', ''),
|
424
|
+
created_at=datetime.fromisoformat(comment_data.get('created_at', datetime.utcnow().isoformat())),
|
425
|
+
issue_id=issue_data.get('number', 0),
|
426
|
+
repo_url=repo_data.get('html_url', '')
|
427
|
+
)
|
428
|
+
|
429
|
+
return listener.handle_retry_command(comment_event)
|
430
|
+
|
431
|
+
except Exception as e:
|
432
|
+
logging.error(f"Error handling webhook comment: {e}")
|
433
|
+
return False
|