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,674 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
Registry System for DevOps-in-a-Box R2D Action
|
4
|
+
===============================================
|
5
|
+
|
6
|
+
This module provides a persistent registry to track deployment runs and manage
|
7
|
+
issue lifecycle. It ensures proper reuse of umbrella issues for the same SHA
|
8
|
+
and creates new issues for new commits.
|
9
|
+
|
10
|
+
Key Features:
|
11
|
+
- Persistent storage of run metadata
|
12
|
+
- SHA-based run identification
|
13
|
+
- Issue lifecycle management
|
14
|
+
- Helper functions for lookup and updates
|
15
|
+
|
16
|
+
Usage:
|
17
|
+
from diagram_to_iac.core.registry import RunRegistry
|
18
|
+
|
19
|
+
registry = RunRegistry()
|
20
|
+
run_key = registry.create_run(repo_url, commit_sha, job_name)
|
21
|
+
run_data = registry.lookup(run_key)
|
22
|
+
registry.update(run_key, {"status": "completed"})
|
23
|
+
"""
|
24
|
+
|
25
|
+
import json
|
26
|
+
import os
|
27
|
+
import uuid
|
28
|
+
from datetime import datetime, timedelta, timezone
|
29
|
+
from pathlib import Path
|
30
|
+
from typing import Dict, Any, Optional, List
|
31
|
+
from dataclasses import dataclass, asdict, field
|
32
|
+
from enum import Enum
|
33
|
+
|
34
|
+
from pydantic import BaseModel, Field, field_validator
|
35
|
+
|
36
|
+
|
37
|
+
class RunStatus(str, Enum):
|
38
|
+
"""Status values for deployment runs."""
|
39
|
+
CREATED = "created"
|
40
|
+
IN_PROGRESS = "in_progress"
|
41
|
+
WAITING_FOR_PAT = "waiting_for_pat"
|
42
|
+
WAITING_FOR_PR = "waiting_for_pr"
|
43
|
+
COMPLETED = "completed"
|
44
|
+
FAILED = "failed"
|
45
|
+
CANCELLED = "cancelled"
|
46
|
+
|
47
|
+
|
48
|
+
class AgentStatus(BaseModel):
|
49
|
+
"""Status information for individual agents."""
|
50
|
+
agent_name: str
|
51
|
+
status: str
|
52
|
+
last_updated: datetime
|
53
|
+
error_message: Optional[str] = None
|
54
|
+
artifacts: Optional[Dict[str, Any]] = None
|
55
|
+
|
56
|
+
|
57
|
+
class RunMetadata(BaseModel):
|
58
|
+
"""Complete metadata for a deployment run."""
|
59
|
+
run_key: str = Field(..., description="Unique identifier for this run")
|
60
|
+
repo_url: str = Field(..., description="GitHub repository URL")
|
61
|
+
commit_sha: str = Field(..., description="Git commit SHA")
|
62
|
+
job_name: str = Field(..., description="GitHub Actions job name")
|
63
|
+
umbrella_issue_id: Optional[int] = Field(None, description="GitHub issue ID for this run")
|
64
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="Run creation timestamp")
|
65
|
+
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="Last update timestamp")
|
66
|
+
status: RunStatus = Field(default=RunStatus.CREATED, description="Overall run status")
|
67
|
+
agent_statuses: Dict[str, AgentStatus] = Field(default_factory=dict, description="Individual agent statuses")
|
68
|
+
wait_reason: Optional[str] = Field(None, description="Reason for waiting (if applicable)")
|
69
|
+
linked_pr: Optional[int] = Field(None, description="Pull request ID if created")
|
70
|
+
branch_name: Optional[str] = Field(None, description="Target branch name")
|
71
|
+
thread_id: Optional[str] = Field(None, description="Conversation thread ID")
|
72
|
+
artifacts_path: Optional[str] = Field(None, description="Path to collected artifacts")
|
73
|
+
terraform_summary: Optional[str] = Field(None, description="Terraform operation summary")
|
74
|
+
retry_count: int = Field(default=0, description="Number of retry attempts")
|
75
|
+
predecessor_run: Optional[str] = Field(None, description="Previous run key for same repo/issue")
|
76
|
+
|
77
|
+
@field_validator('commit_sha')
|
78
|
+
@classmethod
|
79
|
+
def validate_commit_sha(cls, v):
|
80
|
+
"""Validate that commit SHA is a valid git hash."""
|
81
|
+
if not v or len(v) < 7:
|
82
|
+
raise ValueError("Commit SHA must be at least 7 characters")
|
83
|
+
return v.lower()
|
84
|
+
|
85
|
+
@field_validator('repo_url')
|
86
|
+
@classmethod
|
87
|
+
def validate_repo_url(cls, v):
|
88
|
+
"""Validate that repo URL is a valid GitHub URL."""
|
89
|
+
if not v or 'github.com' not in v:
|
90
|
+
raise ValueError("Repository URL must be a valid GitHub URL")
|
91
|
+
return v
|
92
|
+
|
93
|
+
def update_timestamp(self):
|
94
|
+
"""Update the last modified timestamp."""
|
95
|
+
self.updated_at = datetime.now(timezone.utc)
|
96
|
+
|
97
|
+
def get_sha_prefix(self) -> str:
|
98
|
+
"""Get the short SHA prefix for display."""
|
99
|
+
return self.commit_sha[:7]
|
100
|
+
|
101
|
+
def is_same_commit(self, other_sha: str) -> bool:
|
102
|
+
"""Check if this run is for the same commit."""
|
103
|
+
return self.commit_sha.lower() == other_sha.lower()
|
104
|
+
|
105
|
+
def can_be_resumed(self) -> bool:
|
106
|
+
"""Check if this run can be resumed."""
|
107
|
+
return self.status in [RunStatus.WAITING_FOR_PAT, RunStatus.WAITING_FOR_PR]
|
108
|
+
|
109
|
+
|
110
|
+
class RunRegistry:
|
111
|
+
"""
|
112
|
+
Registry for managing deployment run metadata and lifecycle.
|
113
|
+
|
114
|
+
This class provides persistent storage and management of deployment runs,
|
115
|
+
ensuring proper issue reuse for the same commit SHA and creating new
|
116
|
+
issues for new commits.
|
117
|
+
"""
|
118
|
+
|
119
|
+
def __init__(self, registry_path: Optional[str] = None):
|
120
|
+
"""
|
121
|
+
Initialize the run registry.
|
122
|
+
|
123
|
+
Args:
|
124
|
+
registry_path: Custom path for the registry file. If None, uses default.
|
125
|
+
"""
|
126
|
+
if registry_path:
|
127
|
+
self.registry_path = Path(registry_path)
|
128
|
+
else:
|
129
|
+
# Default to data/state/issue_registry.json
|
130
|
+
base_path = Path(__file__).parent.parent.parent.parent
|
131
|
+
self.registry_path = base_path / "data" / "state" / "issue_registry.json"
|
132
|
+
|
133
|
+
# Ensure the directory exists
|
134
|
+
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
|
135
|
+
|
136
|
+
# Load existing registry or create empty one
|
137
|
+
self._load_registry()
|
138
|
+
|
139
|
+
def _load_registry(self) -> None:
|
140
|
+
"""Load the registry from persistent storage."""
|
141
|
+
if self.registry_path.exists() and self.registry_path.stat().st_size > 0:
|
142
|
+
try:
|
143
|
+
with open(self.registry_path, 'r') as f:
|
144
|
+
data = json.load(f)
|
145
|
+
|
146
|
+
# Handle empty JSON file
|
147
|
+
if not data:
|
148
|
+
self._initialize_empty_registry()
|
149
|
+
return
|
150
|
+
|
151
|
+
# Convert datetime strings back to datetime objects
|
152
|
+
self.runs = {}
|
153
|
+
for run_key, run_data in data.get('runs', {}).items():
|
154
|
+
# Parse datetime fields
|
155
|
+
if 'created_at' in run_data:
|
156
|
+
run_data['created_at'] = datetime.fromisoformat(run_data['created_at'])
|
157
|
+
if 'updated_at' in run_data:
|
158
|
+
run_data['updated_at'] = datetime.fromisoformat(run_data['updated_at'])
|
159
|
+
|
160
|
+
# Convert status string back to enum if needed
|
161
|
+
if 'status' in run_data and isinstance(run_data['status'], str):
|
162
|
+
try:
|
163
|
+
run_data['status'] = RunStatus(run_data['status'])
|
164
|
+
except ValueError:
|
165
|
+
# Fallback to CREATED if invalid status
|
166
|
+
run_data['status'] = RunStatus.CREATED
|
167
|
+
|
168
|
+
# Parse agent statuses
|
169
|
+
if 'agent_statuses' in run_data:
|
170
|
+
agent_statuses = {}
|
171
|
+
for agent_name, status_data in run_data['agent_statuses'].items():
|
172
|
+
if 'last_updated' in status_data:
|
173
|
+
status_data['last_updated'] = datetime.fromisoformat(status_data['last_updated'])
|
174
|
+
agent_statuses[agent_name] = AgentStatus(**status_data)
|
175
|
+
run_data['agent_statuses'] = agent_statuses
|
176
|
+
|
177
|
+
self.runs[run_key] = RunMetadata(**run_data)
|
178
|
+
|
179
|
+
self.metadata = data.get('metadata', {})
|
180
|
+
|
181
|
+
except (json.JSONDecodeError, ValueError, KeyError) as e:
|
182
|
+
print(f"Warning: Could not load registry from {self.registry_path}: {e}")
|
183
|
+
self._initialize_empty_registry()
|
184
|
+
else:
|
185
|
+
self._initialize_empty_registry()
|
186
|
+
|
187
|
+
def _initialize_empty_registry(self) -> None:
|
188
|
+
"""Initialize an empty registry."""
|
189
|
+
self.runs = {}
|
190
|
+
self.metadata = {
|
191
|
+
'version': '1.0.0',
|
192
|
+
'created_at': datetime.now(timezone.utc).isoformat(),
|
193
|
+
'description': 'DevOps-in-a-Box R2D Action Run Registry'
|
194
|
+
}
|
195
|
+
# Save the initialized registry to prevent JSON loading warnings
|
196
|
+
self._save_registry()
|
197
|
+
|
198
|
+
def _save_registry(self) -> None:
|
199
|
+
"""Save the registry to persistent storage."""
|
200
|
+
data = {
|
201
|
+
'metadata': self.metadata,
|
202
|
+
'runs': {}
|
203
|
+
}
|
204
|
+
|
205
|
+
# Convert runs to serializable format
|
206
|
+
for run_key, run_metadata in self.runs.items():
|
207
|
+
run_dict = run_metadata.model_dump()
|
208
|
+
|
209
|
+
# Convert enum values to strings for proper serialization
|
210
|
+
if 'status' in run_dict:
|
211
|
+
if hasattr(run_dict['status'], 'value'):
|
212
|
+
run_dict['status'] = run_dict['status'].value
|
213
|
+
elif isinstance(run_dict['status'], RunStatus):
|
214
|
+
run_dict['status'] = run_dict['status'].value
|
215
|
+
elif isinstance(run_dict['status'], str):
|
216
|
+
# Already a string, keep as is
|
217
|
+
pass
|
218
|
+
|
219
|
+
# Convert datetime objects to ISO strings
|
220
|
+
if 'created_at' in run_dict:
|
221
|
+
run_dict['created_at'] = run_dict['created_at'].isoformat()
|
222
|
+
if 'updated_at' in run_dict:
|
223
|
+
run_dict['updated_at'] = run_dict['updated_at'].isoformat()
|
224
|
+
|
225
|
+
# Convert agent statuses
|
226
|
+
if 'agent_statuses' in run_dict:
|
227
|
+
agent_statuses = {}
|
228
|
+
for agent_name, status in run_dict['agent_statuses'].items():
|
229
|
+
status_dict = status.model_dump() if hasattr(status, 'model_dump') else status
|
230
|
+
if 'last_updated' in status_dict:
|
231
|
+
status_dict['last_updated'] = status_dict['last_updated'].isoformat()
|
232
|
+
agent_statuses[agent_name] = status_dict
|
233
|
+
run_dict['agent_statuses'] = agent_statuses
|
234
|
+
|
235
|
+
data['runs'][run_key] = run_dict
|
236
|
+
|
237
|
+
# Write to file atomically
|
238
|
+
temp_path = self.registry_path.with_suffix('.tmp')
|
239
|
+
try:
|
240
|
+
with open(temp_path, 'w') as f:
|
241
|
+
json.dump(data, f, indent=2, default=str)
|
242
|
+
temp_path.replace(self.registry_path)
|
243
|
+
except Exception as e:
|
244
|
+
if temp_path.exists():
|
245
|
+
temp_path.unlink()
|
246
|
+
raise RuntimeError(f"Failed to save registry: {e}")
|
247
|
+
|
248
|
+
def generate_run_key(self, repo_url: str, commit_sha: str, job_name: str) -> str:
|
249
|
+
"""
|
250
|
+
Generate a unique run key for a deployment.
|
251
|
+
|
252
|
+
Args:
|
253
|
+
repo_url: GitHub repository URL
|
254
|
+
commit_sha: Git commit SHA
|
255
|
+
job_name: GitHub Actions job name
|
256
|
+
|
257
|
+
Returns:
|
258
|
+
Unique run key string
|
259
|
+
"""
|
260
|
+
# Extract repo name from URL
|
261
|
+
repo_name = repo_url.split('/')[-1].replace('.git', '')
|
262
|
+
short_sha = commit_sha[:7]
|
263
|
+
|
264
|
+
# Include microseconds and job name for better uniqueness
|
265
|
+
now = datetime.now(timezone.utc)
|
266
|
+
timestamp = now.strftime('%Y%m%d-%H%M%S')
|
267
|
+
microseconds = f"{now.microsecond:06d}"[:3] # Use first 3 digits of microseconds
|
268
|
+
|
269
|
+
# Sanitize job name for use in key
|
270
|
+
safe_job_name = "".join(c if c.isalnum() or c in '-_' else '_' for c in job_name)[:10]
|
271
|
+
|
272
|
+
base_key = f"{repo_name}-{short_sha}-{safe_job_name}-{timestamp}-{microseconds}"
|
273
|
+
|
274
|
+
# Ensure uniqueness by checking if key already exists
|
275
|
+
counter = 0
|
276
|
+
unique_key = base_key
|
277
|
+
while unique_key in self.runs:
|
278
|
+
counter += 1
|
279
|
+
unique_key = f"{base_key}-{counter}"
|
280
|
+
# Safety break to prevent infinite loop
|
281
|
+
if counter > 1000:
|
282
|
+
# Fallback to UUID if we somehow have 1000+ collisions
|
283
|
+
import uuid
|
284
|
+
unique_key = f"{repo_name}-{short_sha}-{uuid.uuid4().hex[:8]}"
|
285
|
+
break
|
286
|
+
|
287
|
+
return unique_key
|
288
|
+
|
289
|
+
def create_run(self, repo_url: str, commit_sha: str, job_name: str,
|
290
|
+
thread_id: Optional[str] = None, branch_name: Optional[str] = None) -> str:
|
291
|
+
"""
|
292
|
+
Create a new deployment run entry.
|
293
|
+
|
294
|
+
Args:
|
295
|
+
repo_url: GitHub repository URL
|
296
|
+
commit_sha: Git commit SHA
|
297
|
+
job_name: GitHub Actions job name
|
298
|
+
thread_id: Optional conversation thread ID
|
299
|
+
branch_name: Optional target branch name
|
300
|
+
|
301
|
+
Returns:
|
302
|
+
Unique run key for the created run
|
303
|
+
"""
|
304
|
+
run_key = self.generate_run_key(repo_url, commit_sha, job_name)
|
305
|
+
|
306
|
+
run_metadata = RunMetadata(
|
307
|
+
run_key=run_key,
|
308
|
+
repo_url=repo_url,
|
309
|
+
commit_sha=commit_sha.lower(),
|
310
|
+
job_name=job_name,
|
311
|
+
thread_id=thread_id,
|
312
|
+
branch_name=branch_name
|
313
|
+
)
|
314
|
+
|
315
|
+
self.runs[run_key] = run_metadata
|
316
|
+
self._save_registry()
|
317
|
+
|
318
|
+
return run_key
|
319
|
+
|
320
|
+
def lookup(self, run_key: str) -> Optional[RunMetadata]:
|
321
|
+
"""
|
322
|
+
Look up a run by its key.
|
323
|
+
|
324
|
+
Args:
|
325
|
+
run_key: The run key to look up
|
326
|
+
|
327
|
+
Returns:
|
328
|
+
RunMetadata if found, None otherwise
|
329
|
+
"""
|
330
|
+
return self.runs.get(run_key)
|
331
|
+
|
332
|
+
def update(self, run_key: str, data: Dict[str, Any]) -> bool:
|
333
|
+
"""
|
334
|
+
Update a run with new data.
|
335
|
+
|
336
|
+
Args:
|
337
|
+
run_key: The run key to update
|
338
|
+
data: Dictionary of fields to update
|
339
|
+
|
340
|
+
Returns:
|
341
|
+
True if update was successful, False if run not found
|
342
|
+
"""
|
343
|
+
if run_key not in self.runs:
|
344
|
+
return False
|
345
|
+
|
346
|
+
run_metadata = self.runs[run_key]
|
347
|
+
|
348
|
+
# Update fields with proper type conversion
|
349
|
+
for field, value in data.items():
|
350
|
+
if hasattr(run_metadata, field):
|
351
|
+
# Handle RunStatus enum conversion
|
352
|
+
if field == 'status' and isinstance(value, str):
|
353
|
+
try:
|
354
|
+
value = RunStatus(value)
|
355
|
+
except ValueError:
|
356
|
+
# If invalid status string, keep as RunStatus enum
|
357
|
+
if not isinstance(value, RunStatus):
|
358
|
+
value = RunStatus.CREATED
|
359
|
+
setattr(run_metadata, field, value)
|
360
|
+
|
361
|
+
# Always update the timestamp
|
362
|
+
run_metadata.update_timestamp()
|
363
|
+
|
364
|
+
self._save_registry()
|
365
|
+
return True
|
366
|
+
|
367
|
+
def find_by_commit_and_repo(self, repo_url: str, commit_sha: str) -> List[RunMetadata]:
|
368
|
+
"""
|
369
|
+
Find all runs for a specific repository and commit SHA.
|
370
|
+
|
371
|
+
Args:
|
372
|
+
repo_url: GitHub repository URL
|
373
|
+
commit_sha: Git commit SHA
|
374
|
+
|
375
|
+
Returns:
|
376
|
+
List of matching RunMetadata objects
|
377
|
+
"""
|
378
|
+
matches = []
|
379
|
+
for run_metadata in self.runs.values():
|
380
|
+
if (run_metadata.repo_url == repo_url and
|
381
|
+
run_metadata.is_same_commit(commit_sha)):
|
382
|
+
matches.append(run_metadata)
|
383
|
+
|
384
|
+
# Sort by creation time, newest first
|
385
|
+
return sorted(matches, key=lambda x: x.created_at, reverse=True)
|
386
|
+
|
387
|
+
def find_resumable_run(self, repo_url: str, commit_sha: str) -> Optional[RunMetadata]:
|
388
|
+
"""
|
389
|
+
Find a resumable run for the given repository and commit.
|
390
|
+
|
391
|
+
Args:
|
392
|
+
repo_url: GitHub repository URL
|
393
|
+
commit_sha: Git commit SHA
|
394
|
+
|
395
|
+
Returns:
|
396
|
+
RunMetadata for a resumable run, or None if no resumable run exists
|
397
|
+
"""
|
398
|
+
matching_runs = self.find_by_commit_and_repo(repo_url, commit_sha)
|
399
|
+
|
400
|
+
for run in matching_runs:
|
401
|
+
if run.can_be_resumed():
|
402
|
+
return run
|
403
|
+
|
404
|
+
return None
|
405
|
+
|
406
|
+
def mark_run_completed(self, run_key: str, terraform_summary: Optional[str] = None) -> bool:
|
407
|
+
"""
|
408
|
+
Mark a run as completed.
|
409
|
+
|
410
|
+
Args:
|
411
|
+
run_key: The run key to mark as completed
|
412
|
+
terraform_summary: Optional summary of Terraform operations
|
413
|
+
|
414
|
+
Returns:
|
415
|
+
True if update was successful, False if run not found
|
416
|
+
"""
|
417
|
+
update_data = {'status': RunStatus.COMPLETED}
|
418
|
+
if terraform_summary:
|
419
|
+
update_data['terraform_summary'] = terraform_summary
|
420
|
+
|
421
|
+
return self.update(run_key, update_data)
|
422
|
+
|
423
|
+
def mark_run_failed(self, run_key: str, error_reason: str) -> bool:
|
424
|
+
"""
|
425
|
+
Mark a run as failed.
|
426
|
+
|
427
|
+
Args:
|
428
|
+
run_key: The run key to mark as failed
|
429
|
+
error_reason: Reason for the failure
|
430
|
+
|
431
|
+
Returns:
|
432
|
+
True if update was successful, False if run not found
|
433
|
+
"""
|
434
|
+
return self.update(run_key, {
|
435
|
+
'status': RunStatus.FAILED,
|
436
|
+
'wait_reason': error_reason
|
437
|
+
})
|
438
|
+
|
439
|
+
def update_agent_status(self, run_key: str, agent_name: str, status: str,
|
440
|
+
error_message: Optional[str] = None,
|
441
|
+
artifacts: Optional[Dict[str, Any]] = None) -> bool:
|
442
|
+
"""
|
443
|
+
Update the status of a specific agent within a run.
|
444
|
+
|
445
|
+
Args:
|
446
|
+
run_key: The run key
|
447
|
+
agent_name: Name of the agent
|
448
|
+
status: New status for the agent
|
449
|
+
error_message: Optional error message
|
450
|
+
artifacts: Optional artifacts produced by the agent
|
451
|
+
|
452
|
+
Returns:
|
453
|
+
True if update was successful, False if run not found
|
454
|
+
"""
|
455
|
+
if run_key not in self.runs:
|
456
|
+
return False
|
457
|
+
|
458
|
+
agent_status = AgentStatus(
|
459
|
+
agent_name=agent_name,
|
460
|
+
status=status,
|
461
|
+
last_updated=datetime.now(timezone.utc),
|
462
|
+
error_message=error_message,
|
463
|
+
artifacts=artifacts
|
464
|
+
)
|
465
|
+
|
466
|
+
run_metadata = self.runs[run_key]
|
467
|
+
run_metadata.agent_statuses[agent_name] = agent_status
|
468
|
+
run_metadata.update_timestamp()
|
469
|
+
|
470
|
+
self._save_registry()
|
471
|
+
return True
|
472
|
+
|
473
|
+
def get_run_statistics(self) -> Dict[str, Any]:
|
474
|
+
"""
|
475
|
+
Get statistics about all runs in the registry.
|
476
|
+
|
477
|
+
Returns:
|
478
|
+
Dictionary containing various statistics
|
479
|
+
"""
|
480
|
+
if not self.runs:
|
481
|
+
return {
|
482
|
+
'total_runs': 0,
|
483
|
+
'status_breakdown': {},
|
484
|
+
'oldest_run': None,
|
485
|
+
'newest_run': None
|
486
|
+
}
|
487
|
+
|
488
|
+
status_counts = {}
|
489
|
+
for run in self.runs.values():
|
490
|
+
status = run.status.value
|
491
|
+
status_counts[status] = status_counts.get(status, 0) + 1
|
492
|
+
|
493
|
+
sorted_runs = sorted(self.runs.values(), key=lambda x: x.created_at)
|
494
|
+
|
495
|
+
return {
|
496
|
+
'total_runs': len(self.runs),
|
497
|
+
'status_breakdown': status_counts,
|
498
|
+
'oldest_run': sorted_runs[0].created_at.isoformat(),
|
499
|
+
'newest_run': sorted_runs[-1].created_at.isoformat(),
|
500
|
+
'unique_repositories': len(set(run.repo_url for run in self.runs.values())),
|
501
|
+
'unique_commits': len(set(run.commit_sha for run in self.runs.values()))
|
502
|
+
}
|
503
|
+
|
504
|
+
def cleanup_old_runs(self, days_old: int = 30) -> int:
|
505
|
+
"""
|
506
|
+
Clean up runs older than the specified number of days.
|
507
|
+
|
508
|
+
Args:
|
509
|
+
days_old: Number of days old to consider for cleanup
|
510
|
+
|
511
|
+
Returns:
|
512
|
+
Number of runs cleaned up
|
513
|
+
"""
|
514
|
+
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_old)
|
515
|
+
|
516
|
+
old_runs = [
|
517
|
+
run_key for run_key, run_metadata in self.runs.items()
|
518
|
+
if run_metadata.created_at < cutoff_date and
|
519
|
+
run_metadata.status in [RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED]
|
520
|
+
]
|
521
|
+
|
522
|
+
for run_key in old_runs:
|
523
|
+
del self.runs[run_key]
|
524
|
+
|
525
|
+
if old_runs:
|
526
|
+
self._save_registry()
|
527
|
+
|
528
|
+
return len(old_runs)
|
529
|
+
|
530
|
+
def find_latest_run_with_issue(self, repo_url: str) -> Optional[RunMetadata]:
|
531
|
+
"""
|
532
|
+
Find the most recent run for a repository that has an associated issue.
|
533
|
+
|
534
|
+
Args:
|
535
|
+
repo_url: GitHub repository URL
|
536
|
+
|
537
|
+
Returns:
|
538
|
+
RunMetadata for the most recent run with an issue, or None if none found
|
539
|
+
"""
|
540
|
+
matching_runs = []
|
541
|
+
for run_metadata in self.runs.values():
|
542
|
+
if (run_metadata.repo_url == repo_url and
|
543
|
+
run_metadata.umbrella_issue_id is not None):
|
544
|
+
matching_runs.append(run_metadata)
|
545
|
+
|
546
|
+
if not matching_runs:
|
547
|
+
return None
|
548
|
+
|
549
|
+
# Sort by creation time, newest first
|
550
|
+
return sorted(matching_runs, key=lambda x: x.created_at, reverse=True)[0]
|
551
|
+
|
552
|
+
def find_previous_umbrella_issue(self, repo_url: str, exclude_sha: Optional[str] = None) -> Optional[int]:
|
553
|
+
"""
|
554
|
+
Find the umbrella issue ID from the most recent run for a repository.
|
555
|
+
Optionally exclude runs with a specific commit SHA.
|
556
|
+
|
557
|
+
Args:
|
558
|
+
repo_url: GitHub repository URL
|
559
|
+
exclude_sha: Optional commit SHA to exclude from search
|
560
|
+
|
561
|
+
Returns:
|
562
|
+
Issue ID if found, None otherwise
|
563
|
+
"""
|
564
|
+
matching_runs = []
|
565
|
+
for run_metadata in self.runs.values():
|
566
|
+
if (run_metadata.repo_url == repo_url and
|
567
|
+
run_metadata.umbrella_issue_id is not None):
|
568
|
+
# Exclude runs with the specified SHA if provided
|
569
|
+
if exclude_sha and run_metadata.is_same_commit(exclude_sha):
|
570
|
+
continue
|
571
|
+
matching_runs.append(run_metadata)
|
572
|
+
|
573
|
+
if not matching_runs:
|
574
|
+
return None
|
575
|
+
|
576
|
+
# Get the most recent run with an issue
|
577
|
+
latest_run = sorted(matching_runs, key=lambda x: x.created_at, reverse=True)[0]
|
578
|
+
return latest_run.umbrella_issue_id
|
579
|
+
|
580
|
+
def link_predecessor_run(self, current_run_key: str, predecessor_run_key: str) -> bool:
|
581
|
+
"""
|
582
|
+
Link a current run to its predecessor run.
|
583
|
+
|
584
|
+
Args:
|
585
|
+
current_run_key: The current run key
|
586
|
+
predecessor_run_key: The predecessor run key
|
587
|
+
|
588
|
+
Returns:
|
589
|
+
True if linking was successful, False if either run not found
|
590
|
+
"""
|
591
|
+
if current_run_key not in self.runs or predecessor_run_key not in self.runs:
|
592
|
+
return False
|
593
|
+
|
594
|
+
return self.update(current_run_key, {'predecessor_run': predecessor_run_key})
|
595
|
+
|
596
|
+
def get_run_chain(self, run_key: str) -> List[RunMetadata]:
|
597
|
+
"""
|
598
|
+
Get the complete chain of linked runs starting from the given run.
|
599
|
+
|
600
|
+
Args:
|
601
|
+
run_key: The starting run key
|
602
|
+
|
603
|
+
Returns:
|
604
|
+
List of RunMetadata objects in chronological order (oldest first)
|
605
|
+
"""
|
606
|
+
if run_key not in self.runs:
|
607
|
+
return []
|
608
|
+
|
609
|
+
chain = []
|
610
|
+
current_run = self.runs[run_key]
|
611
|
+
|
612
|
+
# First, find the root of the chain by following predecessors
|
613
|
+
visited = set()
|
614
|
+
while current_run.predecessor_run and current_run.predecessor_run not in visited:
|
615
|
+
visited.add(current_run.run_key)
|
616
|
+
if current_run.predecessor_run in self.runs:
|
617
|
+
current_run = self.runs[current_run.predecessor_run]
|
618
|
+
else:
|
619
|
+
break
|
620
|
+
|
621
|
+
# Now collect the chain starting from the root
|
622
|
+
chain.append(current_run)
|
623
|
+
|
624
|
+
# Find all successors
|
625
|
+
def find_successors(run: RunMetadata) -> List[RunMetadata]:
|
626
|
+
successors = []
|
627
|
+
for other_run in self.runs.values():
|
628
|
+
if other_run.predecessor_run == run.run_key:
|
629
|
+
successors.append(other_run)
|
630
|
+
return sorted(successors, key=lambda x: x.created_at)
|
631
|
+
|
632
|
+
# Recursively build the chain
|
633
|
+
current_successors = find_successors(current_run)
|
634
|
+
while current_successors:
|
635
|
+
next_run = current_successors[0] # Take the first (chronologically)
|
636
|
+
chain.append(next_run)
|
637
|
+
current_successors = find_successors(next_run)
|
638
|
+
|
639
|
+
return chain
|
640
|
+
|
641
|
+
def close_old_umbrella_issue(self, run_key: str, new_issue_id: int, new_commit_sha: str) -> bool:
|
642
|
+
"""
|
643
|
+
Mark an umbrella issue as closed due to a new commit and link to the new issue.
|
644
|
+
|
645
|
+
Args:
|
646
|
+
run_key: The run key whose issue should be marked as closed
|
647
|
+
new_issue_id: The new issue ID to link to
|
648
|
+
new_commit_sha: The new commit SHA that triggered the new issue
|
649
|
+
|
650
|
+
Returns:
|
651
|
+
True if update was successful, False if run not found
|
652
|
+
"""
|
653
|
+
return self.update(run_key, {
|
654
|
+
'status': RunStatus.COMPLETED,
|
655
|
+
'wait_reason': f'Superseded by new commit {new_commit_sha[:7]} - see issue #{new_issue_id}'
|
656
|
+
})
|
657
|
+
|
658
|
+
|
659
|
+
# Helper functions for backwards compatibility and convenience
|
660
|
+
def get_default_registry() -> RunRegistry:
|
661
|
+
"""Get the default registry instance."""
|
662
|
+
return RunRegistry()
|
663
|
+
|
664
|
+
|
665
|
+
def lookup_run(run_key: str) -> Optional[RunMetadata]:
|
666
|
+
"""Convenience function to lookup a run using the default registry."""
|
667
|
+
registry = get_default_registry()
|
668
|
+
return registry.lookup(run_key)
|
669
|
+
|
670
|
+
|
671
|
+
def update_run(run_key: str, data: Dict[str, Any]) -> bool:
|
672
|
+
"""Convenience function to update a run using the default registry."""
|
673
|
+
registry = get_default_registry()
|
674
|
+
return registry.update(run_key, data)
|