superset-showtime 0.3.3__py3-none-any.whl → 0.4.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 superset-showtime might be problematic. Click here for more details.

@@ -0,0 +1,534 @@
1
+ """
2
+ 🎪 PullRequest class - PR-level orchestration and state management
3
+
4
+ Handles atomic transactions, trigger processing, and environment orchestration.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from typing import Any, List, Optional
10
+
11
+ from .aws import AWSInterface
12
+ from .github import GitHubInterface
13
+ from .show import Show, short_sha
14
+
15
+ # Lazy singletons to avoid import-time failures
16
+ _github = None
17
+ _aws = None
18
+
19
+
20
+ def get_github() -> GitHubInterface:
21
+ global _github
22
+ if _github is None:
23
+ _github = GitHubInterface()
24
+ return _github
25
+
26
+
27
+ def get_aws() -> AWSInterface:
28
+ global _aws
29
+ if _aws is None:
30
+ _aws = AWSInterface()
31
+ return _aws
32
+
33
+
34
+ # Use get_github() and get_aws() directly in methods
35
+
36
+
37
+ @dataclass
38
+ class SyncResult:
39
+ """Result of a PullRequest.sync() operation"""
40
+
41
+ success: bool
42
+ action_taken: str # create_environment, rolling_update, cleanup, no_action
43
+ show: Optional[Show] = None
44
+ error: Optional[str] = None
45
+
46
+
47
+ @dataclass
48
+ class AnalysisResult:
49
+ """Result of a PullRequest.analyze() operation"""
50
+
51
+ action_needed: str
52
+ build_needed: bool
53
+ sync_needed: bool
54
+ target_sha: str
55
+
56
+
57
+ class PullRequest:
58
+ """GitHub PR with its shows parsed from circus labels"""
59
+
60
+ def __init__(self, pr_number: int, labels: List[str]):
61
+ self.pr_number = pr_number
62
+ self.labels = labels
63
+ self._shows = self._parse_shows_from_labels()
64
+
65
+ @property
66
+ def shows(self) -> List[Show]:
67
+ """All shows found in labels"""
68
+ return self._shows
69
+
70
+ @property
71
+ def current_show(self) -> Optional[Show]:
72
+ """The currently active show (from 🎯 label)"""
73
+ # Find the SHA that's marked as active (🎯)
74
+ active_sha = None
75
+ for label in self.labels:
76
+ if label.startswith("🎪 🎯 "):
77
+ active_sha = label.split(" ")[2]
78
+ break
79
+
80
+ if not active_sha:
81
+ return None
82
+
83
+ # Find the show with that SHA
84
+ for show in self.shows:
85
+ if show.sha == active_sha:
86
+ return show
87
+
88
+ return None
89
+
90
+ @property
91
+ def building_show(self) -> Optional[Show]:
92
+ """The currently building show (from 🏗️ label)"""
93
+ building_sha = None
94
+ for label in self.labels:
95
+ if label.startswith("🎪 🏗️ "):
96
+ building_sha = label.split(" ")[2]
97
+ break
98
+
99
+ if not building_sha:
100
+ return None
101
+
102
+ for show in self.shows:
103
+ if show.sha == building_sha:
104
+ return show
105
+
106
+ return None
107
+
108
+ @property
109
+ def circus_labels(self) -> List[str]:
110
+ """All circus tent emoji labels"""
111
+ return [label for label in self.labels if label.startswith("🎪")]
112
+
113
+ @property
114
+ def has_shows(self) -> bool:
115
+ """Check if PR has any active shows"""
116
+ return len(self.shows) > 0
117
+
118
+ def get_show_by_sha(self, sha: str) -> Optional[Show]:
119
+ """Get show by SHA"""
120
+ for show in self.shows:
121
+ if show.sha == sha:
122
+ return show
123
+ return None
124
+
125
+ def _parse_shows_from_labels(self) -> List[Show]:
126
+ """Parse all shows from circus tent labels"""
127
+ # Find all unique SHAs from circus labels
128
+ shas = set()
129
+ for label in self.labels:
130
+ if not label.startswith("🎪"):
131
+ continue
132
+ parts = label.split(" ")
133
+ if len(parts) >= 3 and len(parts[1]) == 7: # SHA is 7 chars
134
+ shas.add(parts[1])
135
+
136
+ # Create Show objects for each SHA
137
+ shows = []
138
+ for sha in shas:
139
+ show = Show.from_circus_labels(self.pr_number, self.labels, sha)
140
+ if show:
141
+ shows.append(show)
142
+
143
+ return shows
144
+
145
+ @classmethod
146
+ def from_id(cls, pr_number: int) -> "PullRequest":
147
+ """Load PR with current labels from GitHub"""
148
+ labels = get_github().get_labels(pr_number)
149
+ return cls(pr_number, labels)
150
+
151
+ def refresh_labels(self) -> None:
152
+ """Refresh labels from GitHub and reparse shows"""
153
+ self.labels = get_github().get_labels(self.pr_number)
154
+ self._shows = self._parse_shows_from_labels()
155
+
156
+ def analyze(self, target_sha: str, pr_state: str = "open") -> AnalysisResult:
157
+ """Analyze what actions are needed (read-only, for --check-only)
158
+
159
+ Args:
160
+ target_sha: Target commit SHA to analyze
161
+ pr_state: PR state (open/closed)
162
+
163
+ Returns:
164
+ AnalysisResult with action plan and flags
165
+ """
166
+ # Handle closed PRs
167
+ if pr_state == "closed":
168
+ return AnalysisResult(
169
+ action_needed="cleanup", build_needed=False, sync_needed=True, target_sha=target_sha
170
+ )
171
+
172
+ # Determine action needed
173
+ action_needed = self._determine_action(target_sha)
174
+
175
+ # Determine if Docker build is needed
176
+ build_needed = action_needed in ["create_environment", "rolling_update", "auto_sync"]
177
+
178
+ # Determine if sync execution is needed
179
+ sync_needed = action_needed != "no_action"
180
+
181
+ return AnalysisResult(
182
+ action_needed=action_needed,
183
+ build_needed=build_needed,
184
+ sync_needed=sync_needed,
185
+ target_sha=target_sha,
186
+ )
187
+
188
+ def sync(
189
+ self,
190
+ target_sha: str,
191
+ dry_run_github: bool = False,
192
+ dry_run_aws: bool = False,
193
+ dry_run_docker: bool = False,
194
+ ) -> SyncResult:
195
+ """Sync PR to desired state with atomic transaction management
196
+
197
+ Args:
198
+ target_sha: Target commit SHA to sync to
199
+ github: GitHub interface for label operations
200
+ aws: AWS interface for environment operations
201
+ dry_run_github: Skip GitHub operations if True
202
+ dry_run_aws: Skip AWS operations if True
203
+ dry_run_docker: Skip Docker operations if True
204
+
205
+ Returns:
206
+ SyncResult with success status and details
207
+
208
+ Raises:
209
+ Exception: On unrecoverable errors (caller should handle)
210
+ """
211
+
212
+ # 1. Determine what action is needed
213
+ action_needed = self._determine_action(target_sha)
214
+
215
+ # 2. Atomic claim for environment changes (PR-level lock)
216
+ if action_needed in ["create_environment", "rolling_update", "auto_sync"]:
217
+ print(f"🔒 Claiming environment for {action_needed}...")
218
+ if not self._atomic_claim(target_sha, action_needed, dry_run_github):
219
+ print("❌ Claim failed - another job is active")
220
+ return SyncResult(
221
+ success=False,
222
+ action_taken="claim_failed",
223
+ error="Another job is already active",
224
+ )
225
+ print("✅ Environment claimed successfully")
226
+
227
+ try:
228
+ # 3. Execute action with error handling
229
+ if action_needed == "create_environment":
230
+ show = self._create_new_show(target_sha)
231
+ print(f"🏗️ Creating environment {show.sha}...")
232
+ self._post_building_comment(show, dry_run_github)
233
+
234
+ # Phase 1: Docker build
235
+ print("🐳 Building Docker image...")
236
+ show.build_docker(dry_run_docker)
237
+ show.status = "built"
238
+ print("✅ Docker build completed")
239
+ self._update_show_labels(show, dry_run_github)
240
+
241
+ # Phase 2: AWS deployment
242
+ print("☁️ Deploying to AWS ECS...")
243
+ show.deploy_aws(dry_run_aws)
244
+ show.status = "running"
245
+ print(f"✅ Deployment completed - environment running at {show.ip}:8080")
246
+ self._update_show_labels(show, dry_run_github)
247
+
248
+ self._post_success_comment(show, dry_run_github)
249
+ return SyncResult(success=True, action_taken="create_environment", show=show)
250
+
251
+ elif action_needed in ["rolling_update", "auto_sync"]:
252
+ old_show = self.current_show
253
+ if not old_show:
254
+ return SyncResult(
255
+ success=False,
256
+ action_taken="no_current_show",
257
+ error="No current show for rolling update",
258
+ )
259
+ new_show = self._create_new_show(target_sha)
260
+ print(f"🔄 Rolling update: {old_show.sha} → {new_show.sha}")
261
+ self._post_rolling_start_comment(old_show, new_show, dry_run_github)
262
+
263
+ # Phase 1: Docker build
264
+ print("🐳 Building updated Docker image...")
265
+ new_show.build_docker(dry_run_docker)
266
+ new_show.status = "built"
267
+ print("✅ Docker build completed")
268
+ self._update_show_labels(new_show, dry_run_github)
269
+
270
+ # Phase 2: Blue-green deployment
271
+ print("☁️ Deploying updated environment...")
272
+ new_show.deploy_aws(dry_run_aws)
273
+ new_show.status = "running"
274
+ print(f"✅ Rolling update completed - new environment at {new_show.ip}:8080")
275
+ self._update_show_labels(new_show, dry_run_github)
276
+
277
+ self._post_rolling_success_comment(old_show, new_show, dry_run_github)
278
+ return SyncResult(success=True, action_taken=action_needed, show=new_show)
279
+
280
+ elif action_needed == "destroy_environment":
281
+ if self.current_show:
282
+ print(f"🗑️ Destroying environment {self.current_show.sha}...")
283
+ self.current_show.stop(dry_run_github=dry_run_github, dry_run_aws=dry_run_aws)
284
+ print("☁️ AWS resources deleted")
285
+ self._post_cleanup_comment(self.current_show, dry_run_github)
286
+ # Remove all circus labels after successful stop
287
+ if not dry_run_github:
288
+ get_github().remove_circus_labels(self.pr_number)
289
+ print("🏷️ GitHub labels cleaned up")
290
+ print("✅ Environment destroyed")
291
+ return SyncResult(success=True, action_taken="destroy_environment")
292
+
293
+ else:
294
+ return SyncResult(success=True, action_taken="no_action")
295
+
296
+ except Exception as e:
297
+ # Transaction failed - set failed state and update labels
298
+ if "show" in locals():
299
+ show.status = "failed"
300
+ self._update_show_labels(show, dry_run_github)
301
+ # TODO: Post failure comment
302
+ return SyncResult(success=False, action_taken="failed", error=str(e))
303
+
304
+ def start_environment(self, sha: Optional[str] = None, **kwargs: Any) -> SyncResult:
305
+ """Start a new environment (CLI start command logic)"""
306
+ target_sha = sha or get_github().get_latest_commit_sha(self.pr_number)
307
+ return self.sync(target_sha, **kwargs)
308
+
309
+ def stop_environment(self, **kwargs: Any) -> SyncResult:
310
+ """Stop current environment (CLI stop command logic)"""
311
+ if not self.current_show:
312
+ return SyncResult(
313
+ success=True, action_taken="no_environment", error="No environment to stop"
314
+ )
315
+
316
+ try:
317
+ self.current_show.stop(**kwargs)
318
+ # Remove all circus labels after successful stop
319
+ if not kwargs.get("dry_run_github", False):
320
+ get_github().remove_circus_labels(self.pr_number)
321
+ return SyncResult(success=True, action_taken="stopped")
322
+ except Exception as e:
323
+ return SyncResult(success=False, action_taken="stop_failed", error=str(e))
324
+
325
+ def get_status(self) -> dict:
326
+ """Get current status (CLI status command logic)"""
327
+ if not self.current_show:
328
+ return {"status": "no_environment", "show": None}
329
+
330
+ return {
331
+ "status": "active",
332
+ "show": {
333
+ "sha": self.current_show.sha,
334
+ "status": self.current_show.status,
335
+ "ip": self.current_show.ip,
336
+ "ttl": self.current_show.ttl,
337
+ "requested_by": self.current_show.requested_by,
338
+ "created_at": self.current_show.created_at,
339
+ "aws_service_name": self.current_show.aws_service_name,
340
+ },
341
+ }
342
+
343
+ @classmethod
344
+ def list_all_environments(cls) -> List[dict]:
345
+ """List all environments across all PRs (CLI list command logic)"""
346
+ # Find all PRs with circus tent labels
347
+ pr_numbers = get_github().find_prs_with_shows()
348
+
349
+ all_environments = []
350
+ for pr_number in pr_numbers:
351
+ pr = cls.from_id(pr_number)
352
+ if pr.current_show:
353
+ status = pr.get_status()
354
+ status["pr_number"] = pr_number
355
+ all_environments.append(status)
356
+
357
+ return all_environments
358
+
359
+ def _determine_action(self, target_sha: str) -> str:
360
+ """Determine what sync action is needed"""
361
+ # Check for explicit trigger labels
362
+ trigger_labels = [label for label in self.labels if "showtime-trigger-" in label]
363
+
364
+ if trigger_labels:
365
+ for trigger in trigger_labels:
366
+ if "showtime-trigger-start" in trigger:
367
+ if self.current_show and self.current_show.needs_update(target_sha):
368
+ return "rolling_update"
369
+ elif self.current_show:
370
+ return "no_action" # Same commit
371
+ else:
372
+ return "create_environment"
373
+ elif "showtime-trigger-stop" in trigger:
374
+ return "destroy_environment"
375
+
376
+ # No explicit triggers - check for auto-sync or creation
377
+ if self.current_show and self.current_show.status != "failed" and self.current_show.needs_update(target_sha):
378
+ return "auto_sync"
379
+ elif not self.current_show or self.current_show.status == "failed":
380
+ # No environment exists OR failed environment - allow creation without trigger (for CLI start)
381
+ return "create_environment"
382
+
383
+ return "no_action"
384
+
385
+ def _atomic_claim(self, target_sha: str, action: str, dry_run: bool = False) -> bool:
386
+ """Atomically claim this PR for the current job"""
387
+ # 1. Validate current state allows this action
388
+ if action in ["create_environment", "rolling_update", "auto_sync"]:
389
+ if self.current_show and self.current_show.status in [
390
+ "building",
391
+ "built",
392
+ "deploying",
393
+ "running",
394
+ ]:
395
+ return False # Another job active
396
+
397
+ if dry_run:
398
+ print(f"🎪 [DRY-RUN] Would atomically claim PR for {action}")
399
+ return True
400
+
401
+ # 2. Remove trigger labels (atomic operation)
402
+ trigger_labels = [label for label in self.labels if "showtime-trigger-" in label]
403
+ for trigger_label in trigger_labels:
404
+ get_github().remove_label(self.pr_number, trigger_label)
405
+
406
+ # 3. Set building state immediately (claim the PR)
407
+ if action in ["create_environment", "rolling_update", "auto_sync"]:
408
+ building_show = self._create_new_show(target_sha)
409
+ building_show.status = "building"
410
+ # Update labels to reflect building state
411
+ get_github().remove_circus_labels(self.pr_number)
412
+ for label in building_show.to_circus_labels():
413
+ get_github().add_label(self.pr_number, label)
414
+
415
+ return True
416
+
417
+ def _create_new_show(self, target_sha: str) -> Show:
418
+ """Create a new Show object for the target SHA"""
419
+ return Show(
420
+ pr_number=self.pr_number,
421
+ sha=short_sha(target_sha),
422
+ status="building",
423
+ created_at=datetime.utcnow().strftime("%Y-%m-%dT%H-%M"),
424
+ ttl="24h",
425
+ requested_by="github_actor", # TODO: Get from context
426
+ )
427
+
428
+ def _post_building_comment(self, show: Show, dry_run: bool = False) -> None:
429
+ """Post building comment for new environment"""
430
+ from .github_messages import building_comment
431
+
432
+ if not dry_run:
433
+ comment = building_comment(show)
434
+ get_github().post_comment(self.pr_number, comment)
435
+
436
+ def _post_success_comment(self, show: Show, dry_run: bool = False) -> None:
437
+ """Post success comment for completed environment"""
438
+ from .github_messages import success_comment
439
+
440
+ if not dry_run:
441
+ comment = success_comment(show)
442
+ get_github().post_comment(self.pr_number, comment)
443
+
444
+ def _post_rolling_start_comment(
445
+ self, old_show: Show, new_show: Show, dry_run: bool = False
446
+ ) -> None:
447
+ """Post rolling update start comment"""
448
+ from .github_messages import rolling_start_comment
449
+
450
+ if not dry_run:
451
+ full_sha = new_show.sha + "0" * (40 - len(new_show.sha))
452
+ comment = rolling_start_comment(old_show, full_sha)
453
+ get_github().post_comment(self.pr_number, comment)
454
+
455
+ def _post_rolling_success_comment(
456
+ self, old_show: Show, new_show: Show, dry_run: bool = False
457
+ ) -> None:
458
+ """Post rolling update success comment"""
459
+ from .github_messages import rolling_success_comment
460
+
461
+ if not dry_run:
462
+ comment = rolling_success_comment(old_show, new_show)
463
+ get_github().post_comment(self.pr_number, comment)
464
+
465
+ def _post_cleanup_comment(self, show: Show, dry_run: bool = False) -> None:
466
+ """Post cleanup completion comment"""
467
+ from .github_messages import cleanup_comment
468
+
469
+ if not dry_run:
470
+ comment = cleanup_comment(show)
471
+ get_github().post_comment(self.pr_number, comment)
472
+
473
+ def stop_if_expired(self, max_age_hours: int, dry_run: bool = False) -> bool:
474
+ """Stop environment if it's expired based on age
475
+
476
+ Args:
477
+ max_age_hours: Maximum age in hours before expiration
478
+ dry_run: If True, just check don't actually stop
479
+
480
+ Returns:
481
+ True if environment was expired (and stopped), False otherwise
482
+ """
483
+ if not self.current_show:
484
+ return False
485
+
486
+ # Use Show's expiration logic
487
+ if self.current_show.is_expired(max_age_hours):
488
+ if dry_run:
489
+ print(f"🎪 [DRY-RUN] Would stop expired environment: PR #{self.pr_number}")
490
+ return True
491
+
492
+ print(f"🧹 Stopping expired environment: PR #{self.pr_number}")
493
+ result = self.stop_environment(dry_run_github=False, dry_run_aws=False)
494
+ return result.success
495
+
496
+ return False # Not expired
497
+
498
+ @classmethod
499
+ def find_all_with_environments(cls) -> List[int]:
500
+ """Find all PR numbers that have active environments"""
501
+ return get_github().find_prs_with_shows()
502
+
503
+ def _update_show_labels(self, show: Show, dry_run: bool = False) -> None:
504
+ """Update GitHub labels to reflect show state with proper status replacement"""
505
+ if dry_run:
506
+ return
507
+
508
+ # First, remove any existing status labels for this SHA to ensure clean transitions
509
+ sha_status_labels = [
510
+ label for label in self.labels
511
+ if label.startswith(f"🎪 {show.sha} 🚦 ")
512
+ ]
513
+ for old_status_label in sha_status_labels:
514
+ get_github().remove_label(self.pr_number, old_status_label)
515
+
516
+ # Now do normal differential updates
517
+ current_labels = {label for label in self.labels if label.startswith("🎪")}
518
+ desired_labels = set(show.to_circus_labels())
519
+
520
+ # Remove the status labels we already cleaned up from the differential
521
+ current_labels = current_labels - set(sha_status_labels)
522
+
523
+ # Only add labels that don't exist
524
+ labels_to_add = desired_labels - current_labels
525
+ for label in labels_to_add:
526
+ get_github().add_label(self.pr_number, label)
527
+
528
+ # Only remove labels that shouldn't exist (excluding status labels already handled)
529
+ labels_to_remove = current_labels - desired_labels
530
+ for label in labels_to_remove:
531
+ get_github().remove_label(self.pr_number, label)
532
+
533
+ # Refresh our label cache
534
+ self.refresh_labels()