superset-showtime 0.6.4__tar.gz → 0.6.7__tar.gz

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.

Files changed (39) hide show
  1. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/.claude/settings.local.json +9 -1
  2. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/PKG-INFO +1 -1
  3. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/showtime/__init__.py +1 -1
  4. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/showtime/cli.py +115 -62
  5. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/showtime/core/aws.py +38 -54
  6. superset_showtime-0.6.7/showtime/core/constants.py +10 -0
  7. superset_showtime-0.6.7/showtime/core/date_utils.py +80 -0
  8. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/showtime/core/git_validation.py +21 -12
  9. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/showtime/core/github.py +19 -0
  10. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/showtime/core/github_messages.py +3 -1
  11. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/showtime/core/pull_request.py +307 -39
  12. superset_showtime-0.6.7/showtime/core/service_name.py +104 -0
  13. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/showtime/core/show.py +30 -17
  14. superset_showtime-0.6.7/showtime/core/sync_state.py +137 -0
  15. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/tests/unit/test_label_transitions.py +162 -146
  16. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/tests/unit/test_pull_request.py +262 -143
  17. superset_showtime-0.6.7/tests/unit/test_sha_specific_logic.py +205 -0
  18. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/tests/unit/test_show.py +41 -37
  19. superset_showtime-0.6.4/tests/unit/test_sha_specific_logic.py +0 -146
  20. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/.gitignore +0 -0
  21. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/.pre-commit-config.yaml +0 -0
  22. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/CLAUDE.md +0 -0
  23. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/Makefile +0 -0
  24. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/README.md +0 -0
  25. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/dev-setup.sh +0 -0
  26. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/pypi-push.sh +0 -0
  27. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/pyproject.toml +0 -0
  28. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/requirements-dev.txt +0 -0
  29. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/requirements.txt +0 -0
  30. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/showtime/__main__.py +0 -0
  31. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/showtime/core/__init__.py +0 -0
  32. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/showtime/core/emojis.py +0 -0
  33. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/showtime/core/label_colors.py +0 -0
  34. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/showtime/data/ecs-task-definition.json +0 -0
  35. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/tests/__init__.py +0 -0
  36. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/tests/unit/__init__.py +0 -0
  37. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/uv.lock +0 -0
  38. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/workflows-reference/showtime-cleanup.yml +0 -0
  39. {superset_showtime-0.6.4 → superset_showtime-0.6.7}/workflows-reference/showtime-trigger.yml +0 -0
@@ -46,7 +46,15 @@
46
46
  "Bash(showtime list:*)",
47
47
  "Bash(showtime git-check)",
48
48
  "Read(//^def aws_cleanup/,/**)",
49
- "WebFetch(domain:github.com)"
49
+ "WebFetch(domain:github.com)",
50
+ "Bash(showtime labels:*)",
51
+ "Read(//Users/max/**)",
52
+ "Bash(showtime cleanup:*)",
53
+ "Bash(gh pr view:*)",
54
+ "Bash(for pr in 34842 35033 35152)",
55
+ "Bash(do echo \"PR $pr:\")",
56
+ "Bash(done)",
57
+ "Bash(cat:*)"
50
58
  ],
51
59
  "deny": [],
52
60
  "ask": [],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superset-showtime
3
- Version: 0.6.4
3
+ Version: 0.6.7
4
4
  Summary: 🎪 Apache Superset ephemeral environment management with circus tent emoji state tracking
5
5
  Project-URL: Homepage, https://github.com/apache/superset-showtime
6
6
  Project-URL: Documentation, https://superset-showtime.readthedocs.io/
@@ -4,7 +4,7 @@
4
4
  Circus tent emoji state tracking for Apache Superset ephemeral environments.
5
5
  """
6
6
 
7
- __version__ = "0.6.4"
7
+ __version__ = "0.6.7"
8
8
  __author__ = "Maxime Beauchemin"
9
9
  __email__ = "maximebeauchemin@gmail.com"
10
10
 
@@ -8,9 +8,9 @@ from typing import Dict, Optional
8
8
 
9
9
  import typer
10
10
  from rich.console import Console
11
+ from rich.progress import Progress, SpinnerColumn, TextColumn
11
12
  from rich.table import Table
12
13
 
13
- from .core.emojis import STATUS_DISPLAY
14
14
  from .core.github import GitHubError, GitHubInterface
15
15
  from .core.github_messages import (
16
16
  get_aws_console_urls,
@@ -73,10 +73,10 @@ def _get_github_workflow_url() -> str:
73
73
 
74
74
 
75
75
  def _get_github_actor() -> str:
76
- """Get current GitHub actor with fallback"""
77
- import os
76
+ """Get current GitHub actor with fallback (DEPRECATED: Use GitHubInterface.get_current_actor())"""
77
+ from .core.github import GitHubInterface
78
78
 
79
- return os.getenv("GITHUB_ACTOR", DEFAULT_GITHUB_ACTOR)
79
+ return GitHubInterface.get_current_actor()
80
80
 
81
81
 
82
82
  def _get_showtime_footer() -> str:
@@ -96,7 +96,7 @@ def version() -> None:
96
96
  def start(
97
97
  pr_number: int = typer.Argument(..., help="PR number to create environment for"),
98
98
  sha: Optional[str] = typer.Option(None, "--sha", help="Specific commit SHA (default: latest)"),
99
- ttl: Optional[str] = typer.Option("24h", help="Time to live (24h, 48h, 1w, close)"),
99
+ ttl: Optional[str] = typer.Option("48h", help="Time to live (24h, 48h, 1w, close)"),
100
100
  size: Optional[str] = typer.Option("standard", help="Environment size (standard, large)"),
101
101
  dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be done"),
102
102
  dry_run_aws: bool = typer.Option(
@@ -173,7 +173,15 @@ def status(
173
173
  ) -> None:
174
174
  """Show environment status for PR"""
175
175
  try:
176
- pr = PullRequest.from_id(pr_number)
176
+ # Show spinner while fetching PR status
177
+ with Progress(
178
+ SpinnerColumn(),
179
+ TextColumn("[progress.description]{task.description}"),
180
+ console=Console(),
181
+ transient=True,
182
+ ) as progress:
183
+ progress.add_task(description=f"🎪 Fetching status for PR #{pr_number}...", total=None)
184
+ pr = PullRequest.from_id(pr_number)
177
185
 
178
186
  # Use PullRequest method for data
179
187
  status_data = pr.get_status()
@@ -184,20 +192,22 @@ def status(
184
192
 
185
193
  show_data = status_data["show"]
186
194
 
187
- # Create status table
188
- table = Table(title=f"🎪 Environment Status - PR #{pr_number}")
195
+ # Create status table with clickable PR link
196
+ pr_url = f"https://github.com/apache/superset/pull/{pr_number}"
197
+ table = Table(title=f"🎪 Environment Status - [link={pr_url}]PR #{pr_number}[/link]")
189
198
  table.add_column("Property", style="cyan")
190
199
  table.add_column("Value", style="white")
191
200
 
192
- status_emoji = STATUS_DISPLAY
193
- table.add_row(
194
- "Status", f"{status_emoji.get(show_data['status'], '')} {show_data['status'].title()}"
195
- )
201
+ from .core.emojis import STATUS_DISPLAY
202
+
203
+ status_display = STATUS_DISPLAY.get(show_data["status"], "")
204
+ table.add_row("Status", f"{status_display} {show_data['status'].title()}")
196
205
  table.add_row("Environment", f"`{show_data['sha']}`")
197
- table.add_row("AWS Service", f"`{show_data['aws_service_name']}`")
206
+ table.add_row("Service Name", f"`{show_data['aws_service_name']}`")
198
207
 
199
208
  if show_data["ip"]:
200
- table.add_row("URL", f"http://{show_data['ip']}:8080")
209
+ superset_url = f"http://{show_data['ip']}:8080"
210
+ table.add_row("Superset URL", f"[link={superset_url}]{superset_url}[/link]")
201
211
  if show_data["created_at"]:
202
212
  table.add_row("Created", show_data["created_at"])
203
213
 
@@ -206,6 +216,13 @@ def status(
206
216
  if show_data["requested_by"]:
207
217
  table.add_row("Requested by", f"@{show_data['requested_by']}")
208
218
 
219
+ # Add AWS Console URLs - clickable
220
+ from .core.github_messages import get_aws_console_urls
221
+
222
+ aws_urls = get_aws_console_urls(show_data["aws_service_name"])
223
+ table.add_row("AWS Logs", f"[link={aws_urls['logs']}]View Logs ↗[/link]")
224
+ table.add_row("AWS Service", f"[link={aws_urls['service']}]View Service ↗[/link]")
225
+
209
226
  # Show active triggers
210
227
  trigger_labels = [label for label in pr.labels if "showtime-trigger-" in label]
211
228
  if trigger_labels:
@@ -290,8 +307,16 @@ def list(
290
307
  ) -> None:
291
308
  """List all environments"""
292
309
  try:
293
- # Use PullRequest method for data collection
294
- all_environments = PullRequest.list_all_environments()
310
+ # Show spinner while fetching environments
311
+ with Progress(
312
+ SpinnerColumn(),
313
+ TextColumn("[progress.description]{task.description}"),
314
+ console=Console(),
315
+ transient=True,
316
+ ) as progress:
317
+ progress.add_task(description="🎪 Fetching environments...", total=None)
318
+ # Use PullRequest method for data collection
319
+ all_environments = PullRequest.list_all_environments()
295
320
 
296
321
  if not all_environments:
297
322
  p("🎪 No environments currently running")
@@ -319,19 +344,19 @@ def list(
319
344
  # Create table with full terminal width
320
345
  table = Table(title="🎪 Environment List", expand=True)
321
346
  table.add_column("PR", style="cyan", min_width=6)
322
- table.add_column("Type", style="white", min_width=8)
323
- table.add_column("Status", style="white", min_width=12)
347
+ table.add_column("Service Name", style="white", min_width=20)
348
+ table.add_column("", style="white", min_width=2) # Emoji type column
349
+ table.add_column("🟢", style="white", min_width=2)
324
350
  table.add_column("SHA", style="green", min_width=11)
325
- table.add_column("Created", style="dim white", min_width=12)
351
+ table.add_column("Age", style="dim white", min_width=12)
326
352
  table.add_column("Superset URL", style="blue", min_width=25)
327
- table.add_column("AWS Logs", style="dim blue", min_width=15)
353
+ table.add_column("Logs", style="dim blue", min_width=10)
328
354
  table.add_column("TTL", style="yellow", min_width=6)
329
355
  table.add_column("User", style="magenta", min_width=10)
330
356
 
331
- status_emoji = STATUS_DISPLAY
332
-
333
- # Sort by PR number, then by show type (active first, then building, then orphaned)
334
- type_priority = {"active": 1, "building": 2, "orphaned": 3}
357
+ # Sort by PR number, then by show type (active first, then building, then orphaned, legacy last)
358
+ # TODO: Remove after legacy cleanup - legacy type priority
359
+ type_priority = {"active": 1, "building": 2, "orphaned": 3, "legacy": 4}
335
360
  sorted_envs = sorted(
336
361
  filtered_envs,
337
362
  key=lambda e: (
@@ -344,14 +369,16 @@ def list(
344
369
  show_data = env["show"]
345
370
  pr_number = env["pr_number"]
346
371
 
347
- # Show type with appropriate styling (using single-width chars for alignment)
372
+ # Show type with emoji indicators
348
373
  show_type = show_data.get("show_type", "orphaned")
349
374
  if show_type == "active":
350
- type_display = "* active"
375
+ type_display = "🎯" # Active environment (has pointer)
351
376
  elif show_type == "building":
352
- type_display = "# building"
377
+ type_display = "🔨" # Building environment (hammer is single-width)
378
+ elif show_type == "legacy": # TODO: Remove after legacy cleanup
379
+ type_display = "⚠️" # Legacy environment (no SHA in service name)
353
380
  else:
354
- type_display = "! orphaned"
381
+ type_display = "👻" # Orphaned environment (no pointer)
355
382
 
356
383
  # Make Superset URL clickable and show full URL
357
384
  if show_data["ip"]:
@@ -370,26 +397,32 @@ def list(
370
397
  pr_url = f"https://github.com/apache/superset/pull/{pr_number}"
371
398
  clickable_pr = f"[link={pr_url}]{pr_number}[/link]"
372
399
 
373
- # Format creation time for display
374
- created_display = show_data.get("created_at", "-")
375
- if created_display and created_display != "-":
376
- # Convert 2025-08-25T05-18 to more readable format
377
- try:
378
- parts = created_display.replace("T", " ").replace("-", ":")
379
- created_display = parts[-8:] # Show just HH:MM:SS
380
- except Exception:
381
- pass # Keep original if parsing fails
400
+ # Get age display from show
401
+ age_display = show_data.get("age", "-")
402
+
403
+ # Simple status display - just emoji
404
+ status_display = "🟢" if show_data["status"] == "running" else "❌"
405
+
406
+ # TODO: Remove after legacy cleanup - handle missing legacy fields
407
+ sha_display = show_data["sha"] if show_data["sha"] != "-" else "-"
408
+ ttl_display = show_data["ttl"] if show_data["ttl"] else "-"
409
+ user_display = (
410
+ f"@{show_data['requested_by']}"
411
+ if show_data["requested_by"] and show_data["requested_by"] != "-"
412
+ else "-"
413
+ )
382
414
 
383
415
  table.add_row(
384
416
  clickable_pr,
417
+ f"{show_data['aws_service_name']}-service",
385
418
  type_display,
386
- f"{status_emoji.get(show_data['status'], '❓')} {show_data['status']}",
387
- show_data["sha"],
388
- created_display,
419
+ status_display,
420
+ sha_display,
421
+ age_display,
389
422
  superset_url,
390
423
  aws_logs_link,
391
- show_data["ttl"],
392
- f"@{show_data['requested_by']}" if show_data["requested_by"] else "-",
424
+ ttl_display,
425
+ user_display,
393
426
  )
394
427
 
395
428
  p(table)
@@ -537,11 +570,8 @@ def sync(
537
570
 
538
571
  if check_only:
539
572
  # Analysis mode - just return what's needed
540
- analysis_result = pr.analyze(target_sha, pr_state)
541
- p(f"build_needed={str(analysis_result.build_needed).lower()}")
542
- p(f"sync_needed={str(analysis_result.sync_needed).lower()}")
543
- p(f"pr_number={pr_number}")
544
- p(f"target_sha={target_sha}")
573
+ sync_state = pr.analyze(target_sha, pr_state)
574
+ p(sync_state.to_gha_stdout(pr_number))
545
575
  return
546
576
 
547
577
  # Execution mode - do the sync
@@ -679,13 +709,23 @@ def cleanup(
679
709
  return
680
710
 
681
711
  cleaned_count = 0
712
+ orphan_cleaned_count = 0
713
+
682
714
  for pr_number in pr_numbers:
683
715
  pr = PullRequest.from_id(pr_number)
716
+
717
+ # Clean expired environments with pointers
684
718
  if pr.stop_if_expired(max_age_hours, dry_run):
685
719
  cleaned_count += 1
686
720
 
687
- if cleaned_count > 0:
688
- p(f"🎪 Cleaned up {cleaned_count} expired environments")
721
+ # Clean orphaned environments without pointers
722
+ orphan_cleaned_count += pr.cleanup_orphaned_shows(max_age_hours, dry_run)
723
+
724
+ if cleaned_count > 0 or orphan_cleaned_count > 0:
725
+ if cleaned_count > 0:
726
+ p(f"🎪 ✅ Cleaned up {cleaned_count} expired environments")
727
+ if orphan_cleaned_count > 0:
728
+ p(f"🎪 ✅ Cleaned up {orphan_cleaned_count} orphaned environments")
689
729
  else:
690
730
  p("🎪 No expired environments found")
691
731
 
@@ -718,20 +758,33 @@ def cleanup(
718
758
  if len(aws_orphans) > 3:
719
759
  p(f" ... and {len(aws_orphans) - 3} more")
720
760
 
721
- if not force and not dry_run:
722
- if typer.confirm(f"Delete {len(aws_orphans)} orphaned AWS resources?"):
723
- # Clean up AWS orphans
724
- for orphan in aws_orphans:
725
- if not dry_run:
726
- aws.delete_service(orphan["service_name"])
727
- aws_cleaned_count += 1
728
- else:
729
- p("❌ Skipping AWS orphan cleanup")
730
- elif force or dry_run:
761
+ # Determine if we should proceed with cleanup
762
+ should_cleanup = False
763
+ if dry_run:
764
+ should_cleanup = True
731
765
  aws_cleaned_count = len(aws_orphans)
732
- if not dry_run:
733
- for orphan in aws_orphans:
734
- aws.delete_service(orphan["service_name"])
766
+ elif force:
767
+ should_cleanup = True
768
+ elif typer.confirm(f"Delete {len(aws_orphans)} orphaned AWS resources?"):
769
+ should_cleanup = True
770
+ else:
771
+ p("❌ Skipping AWS orphan cleanup")
772
+
773
+ # Perform cleanup if approved
774
+ if should_cleanup and not dry_run:
775
+ from .core.service_name import ServiceName
776
+
777
+ for orphan in aws_orphans:
778
+ service_name_str = orphan["service_name"]
779
+ try:
780
+ # Parse service name to get PR number
781
+ svc = ServiceName.from_service_name(service_name_str)
782
+ # Pass base name (without -service) to delete_environment
783
+ aws.delete_environment(svc.base_name, svc.pr_number)
784
+ aws_cleaned_count += 1
785
+ except ValueError as e:
786
+ p(f"⚠️ Skipping invalid service name {service_name_str}: {e}")
787
+ continue
735
788
 
736
789
  if aws_cleaned_count > 0:
737
790
  p(f"☁️ ✅ Cleaned up {aws_cleaned_count} orphaned AWS resources")
@@ -74,16 +74,16 @@ class AWSInterface:
74
74
  4. Deploy and wait for stability
75
75
  5. Health check and return IP
76
76
  """
77
- from datetime import datetime
78
77
 
78
+ # Create Show object for consistent AWS naming
79
+ from .date_utils import format_utc_now
79
80
  from .show import Show
80
81
 
81
- # Create Show object for consistent AWS naming
82
82
  show = Show(
83
83
  pr_number=pr_number,
84
84
  sha=sha[:7], # Truncate to 7 chars like GitHub
85
85
  status="building",
86
- created_at=datetime.utcnow().strftime("%Y-%m-%dT%H-%M"),
86
+ created_at=format_utc_now(),
87
87
  requested_by=github_user,
88
88
  )
89
89
 
@@ -173,67 +173,46 @@ class AWSInterface:
173
173
  except Exception as e:
174
174
  return EnvironmentResult(success=False, error=str(e))
175
175
 
176
- def delete_environment(self, service_name: str, pr_number: int) -> bool:
176
+ def delete_environment(self, base_name: str, pr_number: int) -> bool:
177
177
  """
178
- Delete ephemeral environment with proper verification
178
+ Delete ephemeral environment
179
179
 
180
- Steps:
181
- 1. Check if ECS service exists
182
- 2. Delete ECS service with force and wait for completion
183
- 3. Delete ECR image tag
184
- 4. Verify deletion completed
180
+ Args:
181
+ base_name: Base service name WITHOUT -service suffix (e.g., "pr-34868-abc123f")
182
+ pr_number: PR number for this environment
185
183
  """
186
184
  try:
187
- ecs_service_name = f"{service_name}-service" if not service_name.endswith("-service") else service_name
185
+ # Simple: always add -service suffix
186
+ ecs_service_name = f"{base_name}-service"
188
187
  print(f"🗑️ Deleting ECS service: {ecs_service_name}")
189
-
190
- # Step 1: Check if service exists
191
- if not self._service_exists(ecs_service_name):
192
- print(f"✅ Service {ecs_service_name} already deleted")
193
- return True
194
-
195
- # Step 2: Delete ECS service (force delete) and wait
196
- print(f"☁️ Initiating ECS service deletion...")
197
- delete_response = self.ecs_client.delete_service(
198
- cluster=self.cluster,
199
- service=ecs_service_name,
200
- force=True
201
- )
202
- print(f"🔄 Delete initiated: {delete_response.get('service', {}).get('status', 'unknown')}")
203
-
204
- # Step 3: Wait for deletion to complete (crucial!)
205
- print(f"⏳ Waiting for service deletion to complete...")
206
- deletion_success = self._wait_for_service_deletion(ecs_service_name, timeout_minutes=10)
207
-
208
- if not deletion_success:
209
- print(f"⚠️ Service deletion timeout - service may still exist")
210
- return False
211
188
 
212
- # Step 4: Delete ECR image tag
213
- print(f"🐳 Cleaning up Docker image...")
214
- # Fix SHA extraction: pr-34831-ac533ec-service → ac533ec
215
- # Remove "pr-" prefix and "-service" suffix, then get SHA (last part)
216
- base_name = service_name.replace("pr-", "").replace("-service", "")
217
- parts = base_name.split("-")
218
- sha = parts[-1] if len(parts) > 1 else base_name # Last part is SHA
219
- image_tag = f"pr-{pr_number}-{sha}-ci"
189
+ # Delete ECS service with force flag (AWS will handle cleanup)
190
+ try:
191
+ self.ecs_client.delete_service(
192
+ cluster=self.cluster, service=ecs_service_name, force=True
193
+ )
194
+ print(f"✅ ECS service deletion initiated: {ecs_service_name}")
195
+ except self.ecs_client.exceptions.ServiceNotFoundException:
196
+ print(f"ℹ️ Service {ecs_service_name} already deleted")
197
+ except Exception as e:
198
+ print(f"❌ AWS deletion failed: {e}")
199
+ return False
220
200
 
201
+ # Try to clean up ECR image - for showtime services, tag is base_name + "-ci"
221
202
  try:
203
+ image_tag = f"{base_name}-ci"
222
204
  self.ecr_client.batch_delete_image(
223
205
  repositoryName=self.repository, imageIds=[{"imageTag": image_tag}]
224
206
  )
225
207
  print(f"✅ Deleted ECR image: {image_tag}")
226
- except self.ecr_client.exceptions.ImageNotFoundException:
227
- print(f"ℹ️ ECR image {image_tag} already deleted")
208
+ except Exception:
209
+ pass # Image cleanup is optional
228
210
 
229
- print(f"✅ Environment {service_name} fully deleted")
230
211
  return True
231
212
 
232
213
  except Exception as e:
233
- print(f"❌ AWS deletion failed: {e}")
234
- raise AWSError(
235
- message=str(e), operation="delete_environment", resource=service_name
236
- ) from e
214
+ print(f"❌ Unexpected error: {e}")
215
+ return False
237
216
 
238
217
  def get_environment_ip(self, service_name: str) -> Optional[str]:
239
218
  """
@@ -701,19 +680,19 @@ class AWSInterface:
701
680
  try:
702
681
  # List all services in cluster
703
682
  response = self.ecs_client.list_services(cluster=self.cluster)
704
-
683
+
705
684
  if not response.get("serviceArns"):
706
685
  return []
707
-
686
+
708
687
  # Extract service names and filter for showtime pattern
709
688
  showtime_services = []
710
689
  for service_arn in response["serviceArns"]:
711
690
  service_name = service_arn.split("/")[-1] # Extract name from ARN
712
691
  if service_name.startswith("pr-") and "-service" in service_name:
713
692
  showtime_services.append(service_name)
714
-
693
+
715
694
  return sorted(showtime_services)
716
-
695
+
717
696
  except Exception as e:
718
697
  print(f"❌ Failed to find showtime services: {e}")
719
698
  return []
@@ -845,10 +824,15 @@ class AWSInterface:
845
824
  for attempt in range(max_attempts):
846
825
  # Check if service still exists
847
826
  if not self._service_exists(service_name):
848
- print(f"✅ Service {service_name} fully deleted after {attempt * 5}s")
827
+ if attempt == 0:
828
+ print(
829
+ f"✅ Service {service_name} deletion confirmed (was already draining)"
830
+ )
831
+ else:
832
+ print(f"✅ Service {service_name} fully deleted after {attempt * 5}s")
849
833
  return True
850
834
 
851
- if attempt % 6 == 0: # Every 30s
835
+ if attempt % 6 == 0 and attempt > 0: # Every 30s after first check
852
836
  print(f"⏳ Waiting for service deletion... ({attempt * 5}s elapsed)")
853
837
 
854
838
  time.sleep(5) # Check every 5 seconds
@@ -0,0 +1,10 @@
1
+ """Constants used throughout the showtime codebase."""
2
+
3
+ # Default time-to-live for new environments
4
+ DEFAULT_TTL = "48h"
5
+
6
+ # TTL options for environments
7
+ TTL_OPTIONS = ["24h", "48h", "72h", "1w", "close"]
8
+
9
+ # Maximum age for considering environments stale/orphaned
10
+ DEFAULT_CLEANUP_AGE = "48h"
@@ -0,0 +1,80 @@
1
+ """Date and time utilities for consistent timestamp handling."""
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ # Custom timestamp format used in circus labels
7
+ # Format: YYYY-MM-DDTHH-MM (using dashes instead of colons for GitHub label compatibility)
8
+ CIRCUS_TIME_FORMAT = "%Y-%m-%dT%H-%M"
9
+
10
+
11
+ def format_utc_now() -> str:
12
+ """Get current UTC time formatted for circus labels."""
13
+ return datetime.utcnow().strftime(CIRCUS_TIME_FORMAT)
14
+
15
+
16
+ def parse_circus_time(timestamp: str) -> Optional[datetime]:
17
+ """Parse a circus timestamp string into a datetime object.
18
+
19
+ Args:
20
+ timestamp: String in format "YYYY-MM-DDTHH-MM"
21
+
22
+ Returns:
23
+ datetime object or None if parsing fails
24
+ """
25
+ if not timestamp:
26
+ return None
27
+
28
+ try:
29
+ return datetime.strptime(timestamp, CIRCUS_TIME_FORMAT)
30
+ except (ValueError, AttributeError):
31
+ return None
32
+
33
+
34
+ def age_display(created_at: str) -> str:
35
+ """Convert a circus timestamp to human-readable age.
36
+
37
+ Args:
38
+ created_at: Timestamp string in circus format
39
+
40
+ Returns:
41
+ Human-readable age like "2d 5h" or "45m"
42
+ """
43
+ created_dt = parse_circus_time(created_at)
44
+ if not created_dt:
45
+ return "-"
46
+
47
+ # Compare UTC to UTC for accurate age
48
+ age = datetime.utcnow() - created_dt
49
+
50
+ # Format age nicely
51
+ days = age.days
52
+ hours = age.seconds // 3600
53
+ minutes = (age.seconds % 3600) // 60
54
+
55
+ if days > 0:
56
+ return f"{days}d {hours}h"
57
+ elif hours > 0:
58
+ return f"{hours}h {minutes}m"
59
+ else:
60
+ return f"{minutes}m"
61
+
62
+
63
+ def is_expired(created_at: str, max_age_hours: int) -> bool:
64
+ """Check if a timestamp is older than the specified hours.
65
+
66
+ Args:
67
+ created_at: Timestamp string in circus format
68
+ max_age_hours: Maximum age in hours
69
+
70
+ Returns:
71
+ True if timestamp is older than max_age_hours
72
+ """
73
+ created_dt = parse_circus_time(created_at)
74
+ if not created_dt:
75
+ return False
76
+
77
+ from datetime import timedelta
78
+
79
+ expiry_time = created_dt + timedelta(hours=max_age_hours)
80
+ return datetime.utcnow() > expiry_time
@@ -5,14 +5,17 @@ Validates that the current Git repository contains required commit SHA to preven
5
5
  usage with outdated releases.
6
6
  """
7
7
 
8
- from typing import Optional, Tuple
8
+ from typing import TYPE_CHECKING, Optional, Tuple
9
9
 
10
- try:
10
+ if TYPE_CHECKING:
11
11
  from git import InvalidGitRepositoryError, Repo
12
- except ImportError:
13
- # Fallback if GitPython is not available
14
- Repo = None
15
- InvalidGitRepositoryError = Exception
12
+ else:
13
+ try:
14
+ from git import InvalidGitRepositoryError, Repo
15
+ except ImportError:
16
+ # Fallback if GitPython is not available
17
+ Repo = None
18
+ InvalidGitRepositoryError = Exception
16
19
 
17
20
 
18
21
  # Hard-coded required SHA - update this when needed
@@ -36,7 +39,9 @@ def is_git_repository(path: str = ".") -> bool:
36
39
  Returns:
37
40
  True if it's a Git repository, False otherwise
38
41
  """
39
- if Repo is None:
42
+ try:
43
+ from git import InvalidGitRepositoryError, Repo
44
+ except ImportError:
40
45
  # GitPython not available, assume not a git repo
41
46
  return False
42
47
 
@@ -69,9 +74,12 @@ def validate_required_sha(required_sha: Optional[str] = None) -> Tuple[bool, Opt
69
74
  return _validate_sha_via_github_api(sha_to_check)
70
75
  except Exception as e:
71
76
  print(f"⚠️ GitHub API validation failed: {e}")
77
+ # Fall through to Git validation
72
78
 
73
79
  # Fallback to Git validation for non-GitHub origins
74
- if Repo is None:
80
+ try:
81
+ from git import Repo
82
+ except ImportError:
75
83
  print("⚠️ GitPython not available, skipping SHA validation")
76
84
  return True, None
77
85
 
@@ -84,9 +92,6 @@ def validate_required_sha(required_sha: Optional[str] = None) -> Tuple[bool, Opt
84
92
  print(f"⚠️ Git validation failed: {error}")
85
93
  return True, None # Allow operation to continue
86
94
 
87
- except InvalidGitRepositoryError:
88
- print("⚠️ Not a Git repository, skipping SHA validation")
89
- return True, None
90
95
  except Exception as e:
91
96
  print(f"⚠️ Git validation error: {e}")
92
97
  return True, None
@@ -181,13 +186,17 @@ def get_validation_error_message(required_sha: Optional[str] = None) -> str:
181
186
 
182
187
  This branch requires commit {sha_to_check} to be present in your Git history.
183
188
 
189
+ [bold yellow]Why this commit is required:[/bold yellow]
190
+ Showtime depends on Docker build infrastructure (LOAD_EXAMPLES_DUCKDB) and DuckDB
191
+ examples support that was introduced in this commit. Without it, Docker builds will fail.
192
+
184
193
  [bold yellow]To resolve this:[/bold yellow]
185
194
  1. Ensure you're on the correct branch (usually main)
186
195
  2. Pull the latest changes: [cyan]git pull origin main[/cyan]
187
196
  3. Verify the commit exists: [cyan]git log --oneline | grep {sha_to_check[:7]}[/cyan]
188
197
  4. If needed, switch to main branch: [cyan]git checkout main[/cyan]
189
198
 
190
- [dim]This check prevents Showtime from running on outdated releases.[/dim]
199
+ [dim]This prevents Docker build failures on PRs missing required infrastructure.[/dim]
191
200
  """.strip()
192
201
 
193
202