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.
@@ -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