superset-showtime 0.6.4__py3-none-any.whl → 0.6.6__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.

@@ -4,14 +4,13 @@
4
4
  Handles atomic transactions, trigger processing, and environment orchestration.
5
5
  """
6
6
 
7
- import os
8
7
  from dataclasses import dataclass
9
- from datetime import datetime
10
8
  from typing import Any, List, Optional
11
9
 
12
10
  from .aws import AWSInterface
13
11
  from .github import GitHubInterface
14
12
  from .show import Show, short_sha
13
+ from .sync_state import ActionNeeded, AuthStatus, BlockedReason, SyncState
15
14
 
16
15
  # Lazy singletons to avoid import-time failures
17
16
  _github = None
@@ -127,7 +126,7 @@ class PullRequest:
127
126
  # Create Show objects for each SHA
128
127
  shows = []
129
128
  for sha in shas:
130
- show = Show.from_circus_labels(self.pr_number, self.labels, sha)
129
+ show = Show.from_circus_labels(self.pr_number, list(self.labels), sha)
131
130
  if show:
132
131
  shows.append(show)
133
132
 
@@ -213,19 +212,27 @@ class PullRequest:
213
212
  active_pointer = f"{active_prefix}{show.sha}" # "🎪 🎯 abc123f"
214
213
  self.add_label(active_pointer)
215
214
 
216
- def _check_authorization(self) -> bool:
217
- """Check if current GitHub actor is authorized for operations"""
218
- import os
215
+ def _check_authorization(self) -> tuple[bool, dict]:
216
+ """Check if current GitHub actor is authorized for operations
219
217
 
218
+ Returns:
219
+ tuple: (is_authorized, debug_info_dict)
220
+ """
220
221
  import httpx
221
222
 
223
+ # Get actor info using centralized function
224
+ actor_info = GitHubInterface.get_actor_debug_info()
225
+ debug_info = {**actor_info, "permission": "unknown", "auth_status": "unknown"}
226
+
222
227
  # Only check in GitHub Actions context
223
- if os.getenv("GITHUB_ACTIONS") != "true":
224
- return True
228
+ if not debug_info["is_github_actions"]:
229
+ debug_info["auth_status"] = "skipped_not_actions"
230
+ return True, debug_info
225
231
 
226
- actor = os.getenv("GITHUB_ACTOR")
227
- if not actor:
228
- return True # No actor info, allow operation
232
+ actor = debug_info["actor"]
233
+ if not actor or actor == "unknown":
234
+ debug_info["auth_status"] = "allowed_no_actor"
235
+ return True, debug_info
229
236
 
230
237
  try:
231
238
  # Use existing GitHubInterface for consistency
@@ -237,58 +244,202 @@ class PullRequest:
237
244
  with httpx.Client() as client:
238
245
  response = client.get(perm_url, headers=github.headers)
239
246
  if response.status_code == 404:
240
- return False # Not a collaborator
247
+ debug_info["permission"] = "not_collaborator"
248
+ debug_info["auth_status"] = "denied_404"
249
+ return False, debug_info
250
+
241
251
  response.raise_for_status()
242
252
 
243
253
  data = response.json()
244
254
  permission = data.get("permission", "none")
255
+ debug_info["permission"] = permission
245
256
 
246
257
  # Allow write and admin permissions only
247
258
  authorized = permission in ["write", "admin"]
248
259
 
249
260
  if not authorized:
261
+ debug_info["auth_status"] = "denied_insufficient_perms"
250
262
  print(f"🚨 Unauthorized actor {actor} (permission: {permission})")
251
263
  # Set blocked label for security
252
264
  self.add_label("🎪 🔒 showtime-blocked")
265
+ else:
266
+ debug_info["auth_status"] = "authorized"
253
267
 
254
- return authorized
268
+ return authorized, debug_info
255
269
 
256
270
  except Exception as e:
271
+ debug_info["auth_status"] = f"error_{type(e).__name__}"
272
+ debug_info["error"] = str(e)
257
273
  print(f"⚠️ Authorization check failed: {e}")
258
- return True # Fail open for non-security operations
274
+ return True, debug_info # Fail open for non-security operations
259
275
 
260
- def analyze(self, target_sha: str, pr_state: str = "open") -> AnalysisResult:
261
- """Analyze what actions are needed (read-only, for --check-only)
276
+ def analyze(self, target_sha: str, pr_state: str = "open") -> SyncState:
277
+ """Analyze what actions are needed with comprehensive debugging info
262
278
 
263
279
  Args:
264
280
  target_sha: Target commit SHA to analyze
265
281
  pr_state: PR state (open/closed)
266
282
 
267
283
  Returns:
268
- AnalysisResult with action plan and flags
284
+ SyncState with complete analysis and debug info
269
285
  """
286
+ import os
287
+
270
288
  # Handle closed PRs
271
289
  if pr_state == "closed":
272
- return AnalysisResult(
273
- action_needed="cleanup", build_needed=False, sync_needed=True, target_sha=target_sha
290
+ return SyncState(
291
+ action_needed=ActionNeeded.DESTROY_ENVIRONMENT,
292
+ build_needed=False,
293
+ sync_needed=True,
294
+ target_sha=target_sha,
295
+ github_actor=GitHubInterface.get_current_actor(),
296
+ is_github_actions=os.getenv("GITHUB_ACTIONS") == "true",
297
+ permission_level="cleanup",
298
+ auth_status=AuthStatus.SKIPPED_NOT_ACTIONS,
299
+ action_reason="pr_closed",
274
300
  )
275
301
 
276
- # Determine action needed
277
- action_needed = self._determine_action(target_sha)
302
+ # Get fresh labels
303
+ self.refresh_labels()
304
+
305
+ # Initialize state tracking
306
+ target_sha_short = target_sha[:7]
307
+ target_show = self.get_show_by_sha(target_sha_short)
308
+ trigger_labels = [label for label in self.labels if "showtime-trigger-" in label]
278
309
 
279
- # Determine if Docker build is needed
280
- build_needed = action_needed in ["create_environment", "rolling_update", "auto_sync"]
310
+ # Check for existing blocked label
311
+ blocked_reason = BlockedReason.NOT_BLOCKED
312
+ if "🎪 🔒 showtime-blocked" in self.labels:
313
+ blocked_reason = BlockedReason.EXISTING_BLOCKED_LABEL
281
314
 
282
- # Determine if sync execution is needed
283
- sync_needed = action_needed not in ["no_action", "blocked"]
315
+ # Check authorization
316
+ is_authorized, auth_debug = self._check_authorization()
317
+ if not is_authorized and blocked_reason == BlockedReason.NOT_BLOCKED:
318
+ blocked_reason = BlockedReason.AUTHORIZATION_FAILED
319
+
320
+ # Determine action needed
321
+ action_needed_str = (
322
+ "blocked"
323
+ if blocked_reason != BlockedReason.NOT_BLOCKED
324
+ else self._evaluate_action_logic(target_sha_short, target_show, trigger_labels)
325
+ )
326
+
327
+ # Map string to enum
328
+ action_map = {
329
+ "no_action": ActionNeeded.NO_ACTION,
330
+ "create_environment": ActionNeeded.CREATE_ENVIRONMENT,
331
+ "rolling_update": ActionNeeded.ROLLING_UPDATE,
332
+ "auto_sync": ActionNeeded.AUTO_SYNC,
333
+ "destroy_environment": ActionNeeded.DESTROY_ENVIRONMENT,
334
+ "blocked": ActionNeeded.BLOCKED,
335
+ }
336
+ action_needed = action_map.get(action_needed_str, ActionNeeded.NO_ACTION)
284
337
 
285
- return AnalysisResult(
338
+ # Build sync state
339
+ return SyncState(
286
340
  action_needed=action_needed,
287
- build_needed=build_needed,
288
- sync_needed=sync_needed,
341
+ build_needed=action_needed
342
+ in [
343
+ ActionNeeded.CREATE_ENVIRONMENT,
344
+ ActionNeeded.ROLLING_UPDATE,
345
+ ActionNeeded.AUTO_SYNC,
346
+ ],
347
+ sync_needed=action_needed not in [ActionNeeded.NO_ACTION, ActionNeeded.BLOCKED],
289
348
  target_sha=target_sha,
349
+ github_actor=auth_debug.get("actor", "unknown"),
350
+ is_github_actions=auth_debug.get("is_github_actions", False),
351
+ permission_level=auth_debug.get("permission", "unknown"),
352
+ auth_status=self._parse_auth_status(auth_debug.get("auth_status", "unknown")),
353
+ blocked_reason=blocked_reason,
354
+ trigger_labels=trigger_labels,
355
+ target_show_status=target_show.status if target_show else None,
356
+ has_previous_shows=len(self.shows) > 0,
357
+ action_reason=self._get_action_reason(action_needed_str, target_show, trigger_labels),
358
+ auth_error=auth_debug.get("error"),
290
359
  )
291
360
 
361
+ def _evaluate_action_logic(
362
+ self, target_sha_short: str, target_show: Optional[Show], trigger_labels: List[str]
363
+ ) -> str:
364
+ """Pure logic for evaluating what action is needed (no side effects, for testability)"""
365
+ if trigger_labels:
366
+ for trigger in trigger_labels:
367
+ if "showtime-trigger-start" in trigger:
368
+ if not target_show or target_show.status == "failed":
369
+ return "create_environment" # New SHA or failed SHA
370
+ elif target_show.status in ["building", "built", "deploying"]:
371
+ return "no_action" # Target SHA already in progress
372
+ elif target_show.status == "running":
373
+ return "create_environment" # Force rebuild with trigger
374
+ else:
375
+ return "create_environment" # Default for unknown states
376
+ elif "showtime-trigger-stop" in trigger:
377
+ return "destroy_environment"
378
+
379
+ # No explicit triggers - only auto-create if there's ANY previous environment
380
+ if not target_show:
381
+ # Target SHA doesn't exist - only create if there's any previous environment
382
+ if self.shows: # Any previous environment exists
383
+ return "create_environment"
384
+ else:
385
+ # No previous environments - don't auto-create without explicit trigger
386
+ return "no_action"
387
+ elif target_show.status == "failed":
388
+ # Target SHA failed - rebuild it
389
+ return "create_environment"
390
+ elif target_show.status in ["building", "built", "deploying"]:
391
+ # Target SHA in progress - wait
392
+ return "no_action"
393
+ elif target_show.status == "running":
394
+ # Target SHA already running - no action needed
395
+ return "no_action"
396
+
397
+ return "no_action"
398
+
399
+ def _get_action_reason(
400
+ self, action_needed: str, target_show: Optional[Show], trigger_labels: List[str]
401
+ ) -> str:
402
+ """Get human-readable reason for the action"""
403
+ if action_needed == "blocked":
404
+ return "operation_blocked"
405
+ elif trigger_labels:
406
+ if any("trigger-start" in label for label in trigger_labels):
407
+ if not target_show:
408
+ return "explicit_start_new_sha"
409
+ elif target_show.status == "failed":
410
+ return "explicit_start_failed_sha"
411
+ elif target_show.status == "running":
412
+ return "explicit_start_force_rebuild"
413
+ else:
414
+ return "explicit_start_trigger"
415
+ elif any("trigger-stop" in label for label in trigger_labels):
416
+ return "explicit_stop_trigger"
417
+ elif action_needed == "create_environment":
418
+ if not target_show:
419
+ return "auto_sync_new_commit"
420
+ elif target_show.status == "failed":
421
+ return "auto_rebuild_failed"
422
+ else:
423
+ return "create_environment"
424
+ elif action_needed == "no_action":
425
+ if target_show and target_show.status == "running":
426
+ return "already_running"
427
+ elif target_show and target_show.status in ["building", "deploying"]:
428
+ return "in_progress"
429
+ else:
430
+ return "no_previous_environments"
431
+ return action_needed
432
+
433
+ def _parse_auth_status(self, auth_status_str: str) -> AuthStatus:
434
+ """Parse auth status string to enum, handling errors gracefully"""
435
+ try:
436
+ return AuthStatus(auth_status_str)
437
+ except ValueError:
438
+ # Handle error cases that include exception type (e.g., "error_UnsupportedProtocol")
439
+ if auth_status_str.startswith("error_"):
440
+ return AuthStatus.ERROR
441
+ return AuthStatus.ERROR
442
+
292
443
  def sync(
293
444
  self,
294
445
  target_sha: str,
@@ -414,8 +565,13 @@ class PullRequest:
414
565
  # Stop the current environment if it exists
415
566
  if self.current_show:
416
567
  print(f"🗑️ Destroying environment {self.current_show.sha}...")
417
- self.current_show.stop(dry_run_github=dry_run_github, dry_run_aws=dry_run_aws)
418
- print("☁️ AWS resources deleted")
568
+ success = self.current_show.stop(
569
+ dry_run_github=dry_run_github, dry_run_aws=dry_run_aws
570
+ )
571
+ if success:
572
+ print("☁️ AWS resources deleted")
573
+ else:
574
+ print("⚠️ AWS resource deletion may have failed")
419
575
  self._post_cleanup_comment(self.current_show, dry_run_github)
420
576
  else:
421
577
  print("🗑️ No current environment to destroy")
@@ -448,8 +604,12 @@ class PullRequest:
448
604
  try:
449
605
  # Stop the current environment if it exists
450
606
  if self.current_show:
451
- self.current_show.stop(**kwargs)
452
- print("☁️ AWS resources deleted")
607
+ success = self.current_show.stop(**kwargs)
608
+ if success:
609
+ print("☁️ AWS resources deleted")
610
+ else:
611
+ print("⚠️ AWS resource deletion may have failed")
612
+ return SyncResult(success=False, action_taken="stop_environment")
453
613
  else:
454
614
  print("🗑️ No current environment to destroy")
455
615
 
@@ -511,6 +671,7 @@ class PullRequest:
511
671
  "ttl": show.ttl,
512
672
  "requested_by": show.requested_by,
513
673
  "created_at": show.created_at,
674
+ "age": show.age_display(), # Add age display
514
675
  "aws_service_name": show.aws_service_name,
515
676
  "show_type": show_type, # New field for display
516
677
  },
@@ -520,7 +681,7 @@ class PullRequest:
520
681
  return all_environments
521
682
 
522
683
  def _determine_action(self, target_sha: str) -> str:
523
- """Determine what sync action is needed based on target SHA state"""
684
+ """Determine what sync action is needed (includes all checks and refreshes labels)"""
524
685
  # CRITICAL: Get fresh labels before any decisions
525
686
  self.refresh_labels()
526
687
 
@@ -529,7 +690,8 @@ class PullRequest:
529
690
  return "blocked"
530
691
 
531
692
  # Check authorization (security layer)
532
- if not self._check_authorization():
693
+ is_authorized, _ = self._check_authorization()
694
+ if not is_authorized:
533
695
  return "blocked"
534
696
 
535
697
  target_sha_short = target_sha[:7] # Ensure we're working with short SHA
@@ -626,13 +788,16 @@ class PullRequest:
626
788
 
627
789
  def _create_new_show(self, target_sha: str) -> Show:
628
790
  """Create a new Show object for the target SHA"""
791
+ from .constants import DEFAULT_TTL
792
+ from .date_utils import format_utc_now
793
+
629
794
  return Show(
630
795
  pr_number=self.pr_number,
631
796
  sha=short_sha(target_sha),
632
797
  status="building",
633
- created_at=datetime.utcnow().strftime("%Y-%m-%dT%H-%M"),
634
- ttl="24h",
635
- requested_by=os.getenv("GITHUB_ACTOR", "unknown"),
798
+ created_at=format_utc_now(),
799
+ ttl=DEFAULT_TTL,
800
+ requested_by=GitHubInterface.get_current_actor(),
636
801
  )
637
802
 
638
803
  def _post_building_comment(self, show: Show, dry_run: bool = False) -> None:
@@ -705,6 +870,48 @@ class PullRequest:
705
870
 
706
871
  return False # Not expired
707
872
 
873
+ def cleanup_orphaned_shows(self, max_age_hours: int, dry_run: bool = False) -> int:
874
+ """Clean up orphaned shows (environments without pointer labels)
875
+
876
+ Args:
877
+ max_age_hours: Maximum age in hours before considering orphaned environment for cleanup
878
+ dry_run: If True, just check don't actually stop
879
+
880
+ Returns:
881
+ Number of orphaned environments cleaned up
882
+ """
883
+ cleaned_count = 0
884
+
885
+ # Find orphaned shows (shows without active or building pointers)
886
+ orphaned_shows = []
887
+ for show in self.shows:
888
+ has_pointer = any(
889
+ label in self.labels for label in [f"🎪 🎯 {show.sha}", f"🎪 🏗️ {show.sha}"]
890
+ )
891
+ if not has_pointer and show.is_expired(max_age_hours):
892
+ orphaned_shows.append(show)
893
+
894
+ # Clean up each orphaned show
895
+ for show in orphaned_shows:
896
+ if dry_run:
897
+ print(
898
+ f"🎪 [DRY-RUN] Would clean orphaned environment: PR #{self.pr_number} SHA {show.sha}"
899
+ )
900
+ cleaned_count += 1
901
+ else:
902
+ print(f"🧹 Cleaning orphaned environment: PR #{self.pr_number} SHA {show.sha}")
903
+ # Stop the specific show (AWS resources)
904
+ success = show.stop(dry_run_github=False, dry_run_aws=False)
905
+ if success:
906
+ # Also clean up GitHub labels for this specific show
907
+ self.remove_sha_labels(show.sha)
908
+ cleaned_count += 1
909
+ print(f"✅ Cleaned orphaned environment: {show.sha}")
910
+ else:
911
+ print(f"⚠️ Failed to clean orphaned environment: {show.sha}")
912
+
913
+ return cleaned_count
914
+
708
915
  @classmethod
709
916
  def find_all_with_environments(cls) -> List[int]:
710
917
  """Find all PR numbers that have active environments"""
@@ -727,9 +934,11 @@ class PullRequest:
727
934
 
728
935
  # For running environments, ensure only ONE active pointer exists
729
936
  if show.status == "running":
730
- # Remove ALL existing active pointers (there should only be one)
937
+ # Remove ALL existing active pointers EXCEPT for this SHA's pointer
731
938
  existing_active_pointers = [
732
- label for label in self.labels if label.startswith("🎪 🎯 ")
939
+ label
940
+ for label in self.labels
941
+ if label.startswith("🎪 🎯 ") and label != f"🎪 🎯 {show.sha}"
733
942
  ]
734
943
  for old_pointer in existing_active_pointers:
735
944
  print(f"🎯 Removing old active pointer: {old_pointer}")
@@ -0,0 +1,104 @@
1
+ """Service name parsing and generation utilities."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class ServiceName:
10
+ """
11
+ Handles ECS service name parsing and generation.
12
+
13
+ Service names follow the pattern: pr-{pr_number}-{sha}-service
14
+ Where sha can be either full or short (7 chars).
15
+
16
+ Examples:
17
+ pr-34868-service (legacy, no SHA)
18
+ pr-34868-abc123f-service (with short SHA)
19
+ pr-34868-abc123f456def-service (with full SHA)
20
+ """
21
+
22
+ pr_number: int
23
+ sha: Optional[str] = None
24
+
25
+ @classmethod
26
+ def from_service_name(cls, service_name: str) -> "ServiceName":
27
+ """
28
+ Parse a service name into its components.
29
+
30
+ Args:
31
+ service_name: ECS service name like "pr-34868-abc123f-service"
32
+
33
+ Returns:
34
+ ServiceName instance with parsed components
35
+ """
36
+ # Remove -service suffix if present
37
+ base_name = service_name.replace("-service", "")
38
+
39
+ # Parse pattern: pr-{number}[-{sha}]
40
+ match = re.match(r"pr-(\d+)(?:-([a-f0-9]+))?", base_name)
41
+ if not match:
42
+ raise ValueError(f"Invalid service name format: {service_name}")
43
+
44
+ pr_number = int(match.group(1))
45
+ sha = match.group(2) # May be None for legacy services
46
+
47
+ return cls(pr_number=pr_number, sha=sha)
48
+
49
+ @classmethod
50
+ def from_base_name(cls, base_name: str, pr_number: int) -> "ServiceName":
51
+ """
52
+ Create from base name (without pr- prefix and -service suffix).
53
+
54
+ Args:
55
+ base_name: Name like "pr-34868-abc123f"
56
+ pr_number: PR number for validation
57
+
58
+ Returns:
59
+ ServiceName instance
60
+ """
61
+ # Remove pr- prefix if present
62
+ if base_name.startswith("pr-"):
63
+ base_name = base_name[3:]
64
+
65
+ # Parse pattern: {number}[-{sha}]
66
+ parts = base_name.split("-", 1)
67
+ parsed_pr = int(parts[0])
68
+
69
+ if parsed_pr != pr_number:
70
+ raise ValueError(f"PR number mismatch: expected {pr_number}, got {parsed_pr}")
71
+
72
+ sha = parts[1] if len(parts) > 1 else None
73
+
74
+ return cls(pr_number=pr_number, sha=sha)
75
+
76
+ @property
77
+ def base_name(self) -> str:
78
+ """Get base name without -service suffix (e.g., pr-34868-abc123f)"""
79
+ if self.sha:
80
+ return f"pr-{self.pr_number}-{self.sha}"
81
+ return f"pr-{self.pr_number}"
82
+
83
+ @property
84
+ def service_name(self) -> str:
85
+ """Get full ECS service name (e.g., pr-34868-abc123f-service)"""
86
+ return f"{self.base_name}-service"
87
+
88
+ @property
89
+ def image_tag(self) -> str:
90
+ """Get Docker image tag (e.g., pr-34868-abc123f-ci)"""
91
+ if not self.sha:
92
+ raise ValueError("SHA is required for image tag")
93
+ return f"{self.base_name}-ci"
94
+
95
+ @property
96
+ def short_sha(self) -> Optional[str]:
97
+ """Get short SHA (first 7 chars) if available"""
98
+ if self.sha:
99
+ return self.sha[:7]
100
+ return None
101
+
102
+ def __str__(self) -> str:
103
+ """String representation returns the full service name"""
104
+ return self.service_name
showtime/core/show.py CHANGED
@@ -8,6 +8,8 @@ from dataclasses import dataclass
8
8
  from datetime import datetime
9
9
  from typing import List, Optional
10
10
 
11
+ from .constants import DEFAULT_TTL
12
+
11
13
 
12
14
  # Import interfaces for singleton access
13
15
  # Note: These will be imported when the module loads, creating singletons
@@ -28,7 +30,7 @@ class Show:
28
30
  status: str # building, built, deploying, running, updating, failed
29
31
  ip: Optional[str] = None # Environment IP address
30
32
  created_at: Optional[str] = None # ISO timestamp
31
- ttl: str = "24h" # 24h, 48h, close, etc.
33
+ ttl: str = DEFAULT_TTL # 24h, 48h, close, etc.
32
34
  requested_by: Optional[str] = None # GitHub username
33
35
 
34
36
  @property
@@ -80,26 +82,38 @@ class Show:
80
82
  """Check if environment needs update to latest SHA"""
81
83
  return self.sha != latest_sha[:7]
82
84
 
85
+ @property
86
+ def created_datetime(self) -> Optional[datetime]:
87
+ """Parse created_at timestamp into datetime object (UTC)"""
88
+ from .date_utils import parse_circus_time
89
+
90
+ if self.created_at is None:
91
+ return None
92
+ return parse_circus_time(self.created_at)
93
+
83
94
  def is_expired(self, max_age_hours: int) -> bool:
84
95
  """Check if this environment is expired based on age"""
85
- if not self.created_at:
96
+ from .date_utils import is_expired
97
+
98
+ if self.created_at is None:
86
99
  return False
100
+ return is_expired(self.created_at, max_age_hours)
87
101
 
88
- try:
89
- from datetime import datetime, timedelta
102
+ def age_display(self) -> str:
103
+ """Get human-readable age of this environment"""
104
+ from .date_utils import age_display
90
105
 
91
- created_time = datetime.fromisoformat(self.created_at.replace("-", ":"))
92
- expiry_time = created_time + timedelta(hours=max_age_hours)
93
- return datetime.now() > expiry_time
94
- except (ValueError, AttributeError):
95
- return False # If we can't parse, assume not expired
106
+ if self.created_at is None:
107
+ return "unknown"
108
+ return age_display(self.created_at)
96
109
 
97
110
  def to_circus_labels(self) -> List[str]:
98
111
  """Convert show state to circus tent emoji labels (per-SHA format)"""
112
+ from .date_utils import format_utc_now
99
113
  from .emojis import CIRCUS_PREFIX, MEANING_TO_EMOJI
100
114
 
101
115
  if not self.created_at:
102
- self.created_at = datetime.utcnow().strftime("%Y-%m-%dT%H-%M")
116
+ self.created_at = format_utc_now()
103
117
 
104
118
  labels = [
105
119
  f"{CIRCUS_PREFIX} {self.sha} {MEANING_TO_EMOJI['status']} {self.status}", # SHA-first status
@@ -142,21 +156,20 @@ class Show:
142
156
  # Mock successful deployment for dry-run
143
157
  self.ip = "52.1.2.3"
144
158
 
145
- def stop(self, dry_run_github: bool = False, dry_run_aws: bool = False) -> None:
159
+ def stop(self, dry_run_github: bool = False, dry_run_aws: bool = False) -> bool:
146
160
  """Stop this environment (cleanup AWS resources)
147
161
 
148
- Raises:
149
- Exception: On cleanup failure
162
+ Returns:
163
+ True if successful, False otherwise
150
164
  """
151
165
  github, aws = get_interfaces()
152
166
 
153
167
  # Delete AWS resources (pure technical work)
154
168
  if not dry_run_aws:
155
- success = aws.delete_environment(self.aws_service_name, self.pr_number)
156
- if not success:
157
- raise Exception(f"Failed to delete AWS service: {self.aws_service_name}")
169
+ result = aws.delete_environment(self.aws_service_name, self.pr_number)
170
+ return bool(result)
158
171
 
159
- # No comments - PullRequest handles that!
172
+ return True # Dry run is always "successful"
160
173
 
161
174
  def _build_docker_image(self) -> None:
162
175
  """Build Docker image for this environment"""