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,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)