superset-showtime 0.6.4__tar.gz → 0.6.6__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.6}/.claude/settings.local.json +9 -1
  2. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/PKG-INFO +1 -1
  3. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/showtime/__init__.py +1 -1
  4. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/showtime/cli.py +65 -50
  5. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/showtime/core/aws.py +38 -54
  6. superset_showtime-0.6.6/showtime/core/constants.py +10 -0
  7. superset_showtime-0.6.6/showtime/core/date_utils.py +80 -0
  8. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/showtime/core/git_validation.py +21 -12
  9. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/showtime/core/github.py +19 -0
  10. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/showtime/core/github_messages.py +3 -1
  11. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/showtime/core/pull_request.py +248 -39
  12. superset_showtime-0.6.6/showtime/core/service_name.py +104 -0
  13. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/showtime/core/show.py +30 -17
  14. superset_showtime-0.6.6/showtime/core/sync_state.py +137 -0
  15. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/tests/unit/test_label_transitions.py +162 -146
  16. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/tests/unit/test_pull_request.py +262 -143
  17. superset_showtime-0.6.6/tests/unit/test_sha_specific_logic.py +205 -0
  18. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/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.6}/.gitignore +0 -0
  21. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/.pre-commit-config.yaml +0 -0
  22. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/CLAUDE.md +0 -0
  23. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/Makefile +0 -0
  24. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/README.md +0 -0
  25. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/dev-setup.sh +0 -0
  26. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/pypi-push.sh +0 -0
  27. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/pyproject.toml +0 -0
  28. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/requirements-dev.txt +0 -0
  29. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/requirements.txt +0 -0
  30. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/showtime/__main__.py +0 -0
  31. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/showtime/core/__init__.py +0 -0
  32. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/showtime/core/emojis.py +0 -0
  33. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/showtime/core/label_colors.py +0 -0
  34. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/showtime/data/ecs-task-definition.json +0 -0
  35. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/tests/__init__.py +0 -0
  36. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/tests/unit/__init__.py +0 -0
  37. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/uv.lock +0 -0
  38. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/workflows-reference/showtime-cleanup.yml +0 -0
  39. {superset_showtime-0.6.4 → superset_showtime-0.6.6}/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.6
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.6"
8
8
  __author__ = "Maxime Beauchemin"
9
9
  __email__ = "maximebeauchemin@gmail.com"
10
10
 
@@ -10,7 +10,6 @@ import typer
10
10
  from rich.console import Console
11
11
  from rich.table import Table
12
12
 
13
- from .core.emojis import STATUS_DISPLAY
14
13
  from .core.github import GitHubError, GitHubInterface
15
14
  from .core.github_messages import (
16
15
  get_aws_console_urls,
@@ -73,10 +72,10 @@ def _get_github_workflow_url() -> str:
73
72
 
74
73
 
75
74
  def _get_github_actor() -> str:
76
- """Get current GitHub actor with fallback"""
77
- import os
75
+ """Get current GitHub actor with fallback (DEPRECATED: Use GitHubInterface.get_current_actor())"""
76
+ from .core.github import GitHubInterface
78
77
 
79
- return os.getenv("GITHUB_ACTOR", DEFAULT_GITHUB_ACTOR)
78
+ return GitHubInterface.get_current_actor()
80
79
 
81
80
 
82
81
  def _get_showtime_footer() -> str:
@@ -96,7 +95,7 @@ def version() -> None:
96
95
  def start(
97
96
  pr_number: int = typer.Argument(..., help="PR number to create environment for"),
98
97
  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)"),
98
+ ttl: Optional[str] = typer.Option("48h", help="Time to live (24h, 48h, 1w, close)"),
100
99
  size: Optional[str] = typer.Option("standard", help="Environment size (standard, large)"),
101
100
  dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be done"),
102
101
  dry_run_aws: bool = typer.Option(
@@ -189,10 +188,10 @@ def status(
189
188
  table.add_column("Property", style="cyan")
190
189
  table.add_column("Value", style="white")
191
190
 
192
- status_emoji = STATUS_DISPLAY
193
- table.add_row(
194
- "Status", f"{status_emoji.get(show_data['status'], '')} {show_data['status'].title()}"
195
- )
191
+ from .core.emojis import STATUS_DISPLAY
192
+
193
+ status_display = STATUS_DISPLAY.get(show_data["status"], "")
194
+ table.add_row("Status", f"{status_display} {show_data['status'].title()}")
196
195
  table.add_row("Environment", f"`{show_data['sha']}`")
197
196
  table.add_row("AWS Service", f"`{show_data['aws_service_name']}`")
198
197
 
@@ -319,17 +318,16 @@ def list(
319
318
  # Create table with full terminal width
320
319
  table = Table(title="🎪 Environment List", expand=True)
321
320
  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)
321
+ table.add_column("Service Name", style="white", min_width=20)
322
+ table.add_column("", style="white", min_width=2) # Emoji type column
323
+ table.add_column("🟢", style="white", min_width=2)
324
324
  table.add_column("SHA", style="green", min_width=11)
325
- table.add_column("Created", style="dim white", min_width=12)
325
+ table.add_column("Age", style="dim white", min_width=12)
326
326
  table.add_column("Superset URL", style="blue", min_width=25)
327
- table.add_column("AWS Logs", style="dim blue", min_width=15)
327
+ table.add_column("Logs", style="dim blue", min_width=10)
328
328
  table.add_column("TTL", style="yellow", min_width=6)
329
329
  table.add_column("User", style="magenta", min_width=10)
330
330
 
331
- status_emoji = STATUS_DISPLAY
332
-
333
331
  # Sort by PR number, then by show type (active first, then building, then orphaned)
334
332
  type_priority = {"active": 1, "building": 2, "orphaned": 3}
335
333
  sorted_envs = sorted(
@@ -344,14 +342,14 @@ def list(
344
342
  show_data = env["show"]
345
343
  pr_number = env["pr_number"]
346
344
 
347
- # Show type with appropriate styling (using single-width chars for alignment)
345
+ # Show type with emoji indicators
348
346
  show_type = show_data.get("show_type", "orphaned")
349
347
  if show_type == "active":
350
- type_display = "* active"
348
+ type_display = "🎯" # Active environment (has pointer)
351
349
  elif show_type == "building":
352
- type_display = "# building"
350
+ type_display = "🔨" # Building environment (hammer is single-width)
353
351
  else:
354
- type_display = "! orphaned"
352
+ type_display = "👻" # Orphaned environment (no pointer)
355
353
 
356
354
  # Make Superset URL clickable and show full URL
357
355
  if show_data["ip"]:
@@ -370,22 +368,19 @@ def list(
370
368
  pr_url = f"https://github.com/apache/superset/pull/{pr_number}"
371
369
  clickable_pr = f"[link={pr_url}]{pr_number}[/link]"
372
370
 
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
371
+ # Get age display from show
372
+ age_display = show_data.get("age", "-")
373
+
374
+ # Simple status display - just emoji
375
+ status_display = "🟢" if show_data["status"] == "running" else "❌"
382
376
 
383
377
  table.add_row(
384
378
  clickable_pr,
379
+ f"{show_data['aws_service_name']}-service",
385
380
  type_display,
386
- f"{status_emoji.get(show_data['status'], '❓')} {show_data['status']}",
381
+ status_display,
387
382
  show_data["sha"],
388
- created_display,
383
+ age_display,
389
384
  superset_url,
390
385
  aws_logs_link,
391
386
  show_data["ttl"],
@@ -537,11 +532,8 @@ def sync(
537
532
 
538
533
  if check_only:
539
534
  # 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}")
535
+ sync_state = pr.analyze(target_sha, pr_state)
536
+ p(sync_state.to_gha_stdout(pr_number))
545
537
  return
546
538
 
547
539
  # Execution mode - do the sync
@@ -679,13 +671,23 @@ def cleanup(
679
671
  return
680
672
 
681
673
  cleaned_count = 0
674
+ orphan_cleaned_count = 0
675
+
682
676
  for pr_number in pr_numbers:
683
677
  pr = PullRequest.from_id(pr_number)
678
+
679
+ # Clean expired environments with pointers
684
680
  if pr.stop_if_expired(max_age_hours, dry_run):
685
681
  cleaned_count += 1
686
682
 
687
- if cleaned_count > 0:
688
- p(f"🎪 Cleaned up {cleaned_count} expired environments")
683
+ # Clean orphaned environments without pointers
684
+ orphan_cleaned_count += pr.cleanup_orphaned_shows(max_age_hours, dry_run)
685
+
686
+ if cleaned_count > 0 or orphan_cleaned_count > 0:
687
+ if cleaned_count > 0:
688
+ p(f"🎪 ✅ Cleaned up {cleaned_count} expired environments")
689
+ if orphan_cleaned_count > 0:
690
+ p(f"🎪 ✅ Cleaned up {orphan_cleaned_count} orphaned environments")
689
691
  else:
690
692
  p("🎪 No expired environments found")
691
693
 
@@ -718,20 +720,33 @@ def cleanup(
718
720
  if len(aws_orphans) > 3:
719
721
  p(f" ... and {len(aws_orphans) - 3} more")
720
722
 
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:
723
+ # Determine if we should proceed with cleanup
724
+ should_cleanup = False
725
+ if dry_run:
726
+ should_cleanup = True
731
727
  aws_cleaned_count = len(aws_orphans)
732
- if not dry_run:
733
- for orphan in aws_orphans:
734
- aws.delete_service(orphan["service_name"])
728
+ elif force:
729
+ should_cleanup = True
730
+ elif typer.confirm(f"Delete {len(aws_orphans)} orphaned AWS resources?"):
731
+ should_cleanup = True
732
+ else:
733
+ p("❌ Skipping AWS orphan cleanup")
734
+
735
+ # Perform cleanup if approved
736
+ if should_cleanup and not dry_run:
737
+ from .core.service_name import ServiceName
738
+
739
+ for orphan in aws_orphans:
740
+ service_name_str = orphan["service_name"]
741
+ try:
742
+ # Parse service name to get PR number
743
+ svc = ServiceName.from_service_name(service_name_str)
744
+ # Pass base name (without -service) to delete_environment
745
+ aws.delete_environment(svc.base_name, svc.pr_number)
746
+ aws_cleaned_count += 1
747
+ except ValueError as e:
748
+ p(f"⚠️ Skipping invalid service name {service_name_str}: {e}")
749
+ continue
735
750
 
736
751
  if aws_cleaned_count > 0:
737
752
  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
 
@@ -11,6 +11,9 @@ from typing import Any, Dict, List, Optional
11
11
 
12
12
  import httpx
13
13
 
14
+ # Constants
15
+ DEFAULT_GITHUB_ACTOR = "unknown"
16
+
14
17
 
15
18
  @dataclass
16
19
  class GitHubError(Exception):
@@ -53,6 +56,22 @@ class GitHubInterface:
53
56
 
54
57
  return None
55
58
 
59
+ # GitHub Actor Resolution
60
+ @staticmethod
61
+ def get_current_actor() -> str:
62
+ """Get current GitHub actor with consistent fallback across the codebase"""
63
+ return os.getenv("GITHUB_ACTOR", DEFAULT_GITHUB_ACTOR)
64
+
65
+ @staticmethod
66
+ def get_actor_debug_info() -> dict:
67
+ """Get debug information about GitHub actor context"""
68
+ raw_actor = os.getenv("GITHUB_ACTOR") # Could be None/empty
69
+ return {
70
+ "actor": GitHubInterface.get_current_actor(),
71
+ "is_github_actions": os.getenv("GITHUB_ACTIONS") == "true",
72
+ "raw_actor": raw_actor or "none",
73
+ }
74
+
56
75
  @property
57
76
  def headers(self) -> Dict[str, str]:
58
77
  """HTTP headers for GitHub API requests"""
@@ -16,7 +16,9 @@ AWS_REGION = "us-west-2"
16
16
 
17
17
  def get_github_actor() -> str:
18
18
  """Get current GitHub actor with fallback"""
19
- return os.getenv("GITHUB_ACTOR", "unknown")
19
+ from .github import GitHubInterface
20
+
21
+ return GitHubInterface.get_current_actor()
20
22
 
21
23
 
22
24
  def get_github_workflow_url() -> str: