superset-showtime 0.5.12__tar.gz → 0.6.3__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.
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/.claude/settings.local.json +5 -2
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/CLAUDE.md +1 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/PKG-INFO +1 -1
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/showtime/__init__.py +1 -1
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/showtime/cli.py +106 -128
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/showtime/core/emojis.py +1 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/showtime/core/github.py +67 -4
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/showtime/core/label_colors.py +4 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/showtime/core/pull_request.py +123 -30
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/showtime/core/show.py +23 -17
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/tests/unit/test_pull_request.py +296 -20
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/tests/unit/test_show.py +33 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/workflows-reference/showtime-cleanup.yml +0 -1
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/workflows-reference/showtime-trigger.yml +72 -5
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/.gitignore +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/.pre-commit-config.yaml +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/Makefile +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/README.md +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/dev-setup.sh +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/pypi-push.sh +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/pyproject.toml +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/requirements-dev.txt +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/requirements.txt +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/showtime/__main__.py +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/showtime/core/__init__.py +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/showtime/core/aws.py +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/showtime/core/git_validation.py +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/showtime/core/github_messages.py +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/showtime/data/ecs-task-definition.json +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/tests/__init__.py +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/tests/unit/__init__.py +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/tests/unit/test_label_transitions.py +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/tests/unit/test_sha_specific_logic.py +0 -0
- {superset_showtime-0.5.12 → superset_showtime-0.6.3}/uv.lock +0 -0
|
@@ -44,13 +44,16 @@
|
|
|
44
44
|
"Bash(AWS_PROFILE=\"\" showtime sync 34831 --dry-run-aws --dry-run-github)",
|
|
45
45
|
"Bash(git stash:*)",
|
|
46
46
|
"Bash(showtime list:*)",
|
|
47
|
-
"Bash(showtime git-check)"
|
|
47
|
+
"Bash(showtime git-check)",
|
|
48
|
+
"Read(//^def aws_cleanup/,/**)",
|
|
49
|
+
"WebFetch(domain:github.com)"
|
|
48
50
|
],
|
|
49
51
|
"deny": [],
|
|
50
52
|
"ask": [],
|
|
51
53
|
"additionalDirectories": [
|
|
52
54
|
"/private/tmp",
|
|
53
|
-
"/Users/max/code/superset"
|
|
55
|
+
"/Users/max/code/superset",
|
|
56
|
+
"/Users/max/.claudette/worktrees/showtime_gha/.github/workflows"
|
|
54
57
|
]
|
|
55
58
|
}
|
|
56
59
|
}
|
|
@@ -84,6 +84,7 @@ The system uses GitHub labels as a distributed state machine:
|
|
|
84
84
|
- `🎪 ⚡ showtime-trigger-start` - Create environment
|
|
85
85
|
- `🎪 🛑 showtime-trigger-stop` - Destroy environment
|
|
86
86
|
- `🎪 🧊 showtime-freeze` - Prevent auto-sync
|
|
87
|
+
- `🎪 🔒 showtime-blocked` - Block ALL operations (maintenance mode)
|
|
87
88
|
|
|
88
89
|
**State Labels (System Managed):**
|
|
89
90
|
- `🎪 {sha} 🚦 {status}` - Environment status
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: superset-showtime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.3
|
|
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/
|
|
@@ -584,36 +584,6 @@ def sync(
|
|
|
584
584
|
raise typer.Exit(1) from e
|
|
585
585
|
|
|
586
586
|
|
|
587
|
-
@app.command()
|
|
588
|
-
def handle_sync(pr_number: int) -> None:
|
|
589
|
-
"""🎪 Handle new commit sync (called by GitHub Actions on PR synchronize)"""
|
|
590
|
-
try:
|
|
591
|
-
pr = PullRequest.from_id(pr_number)
|
|
592
|
-
|
|
593
|
-
# Only sync if there's an active environment
|
|
594
|
-
if not pr.current_show:
|
|
595
|
-
p(f"🎪 No active environment for PR #{pr_number} - skipping sync")
|
|
596
|
-
return
|
|
597
|
-
|
|
598
|
-
# Get latest commit SHA
|
|
599
|
-
from .core.pull_request import get_github
|
|
600
|
-
|
|
601
|
-
latest_sha = get_github().get_latest_commit_sha(pr_number)
|
|
602
|
-
|
|
603
|
-
# Check if update is needed
|
|
604
|
-
if not pr.current_show.needs_update(latest_sha):
|
|
605
|
-
p(f"🎪 Environment already up to date for PR #{pr_number}")
|
|
606
|
-
return
|
|
607
|
-
|
|
608
|
-
p(f"🎪 Syncing PR #{pr_number} to commit {latest_sha[:7]}")
|
|
609
|
-
|
|
610
|
-
# TODO: Implement rolling update logic
|
|
611
|
-
p("🎪 [bold yellow]Sync logic not yet implemented[/bold yellow]")
|
|
612
|
-
|
|
613
|
-
except Exception as e:
|
|
614
|
-
p(f"🎪 [bold red]Error handling sync:[/bold red] {e}")
|
|
615
|
-
|
|
616
|
-
|
|
617
587
|
@app.command()
|
|
618
588
|
def setup_labels(
|
|
619
589
|
dry_run: bool = typer.Option(False, "--dry-run", help="Show what labels would be created"),
|
|
@@ -662,107 +632,10 @@ def setup_labels(
|
|
|
662
632
|
p(f"🎪 [bold red]Error setting up labels:[/bold red] {e}")
|
|
663
633
|
|
|
664
634
|
|
|
665
|
-
@app.command()
|
|
666
|
-
def aws_cleanup(
|
|
667
|
-
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be cleaned"),
|
|
668
|
-
force: bool = typer.Option(False, "--force", help="Delete all showtime AWS resources"),
|
|
669
|
-
) -> None:
|
|
670
|
-
"""🧹 Clean up orphaned AWS resources without GitHub labels"""
|
|
671
|
-
try:
|
|
672
|
-
from .core.aws import AWSInterface
|
|
673
|
-
|
|
674
|
-
aws = AWSInterface()
|
|
675
|
-
|
|
676
|
-
p("🔍 [bold blue]Scanning for orphaned AWS resources...[/bold blue]")
|
|
677
|
-
|
|
678
|
-
# 1. Get all GitHub PRs with circus labels
|
|
679
|
-
github_services = set()
|
|
680
|
-
try:
|
|
681
|
-
all_pr_numbers = PullRequest.find_all_with_environments()
|
|
682
|
-
p(f"📋 Found {len(all_pr_numbers)} PRs with circus labels:")
|
|
683
|
-
|
|
684
|
-
for pr_number in all_pr_numbers:
|
|
685
|
-
pr = PullRequest.from_id(pr_number)
|
|
686
|
-
p(
|
|
687
|
-
f" 🎪 PR #{pr_number}: {len(pr.shows)} shows, {len(pr.circus_labels)} circus labels"
|
|
688
|
-
)
|
|
689
|
-
|
|
690
|
-
for show in pr.shows:
|
|
691
|
-
service_name = show.ecs_service_name
|
|
692
|
-
github_services.add(service_name)
|
|
693
|
-
p(f" 📝 Expected service: {service_name}")
|
|
694
|
-
|
|
695
|
-
# Show labels for debugging
|
|
696
|
-
if not pr.shows:
|
|
697
|
-
p(f" ⚠️ No shows found, labels: {pr.circus_labels[:3]}...") # First 3 labels
|
|
698
|
-
|
|
699
|
-
except Exception as e:
|
|
700
|
-
p(f"⚠️ GitHub scan failed: {e}")
|
|
701
|
-
github_services = set()
|
|
702
|
-
|
|
703
|
-
# 2. Get all AWS ECS services matching showtime pattern
|
|
704
|
-
p("\n☁️ [bold blue]Scanning AWS ECS services...[/bold blue]")
|
|
705
|
-
try:
|
|
706
|
-
aws_services = aws.find_showtime_services()
|
|
707
|
-
p(f"🔍 Found {len(aws_services)} AWS services with pr-* pattern")
|
|
708
|
-
|
|
709
|
-
for service in aws_services:
|
|
710
|
-
p(f" ☁️ AWS: {service}")
|
|
711
|
-
except Exception as e:
|
|
712
|
-
p(f"❌ AWS scan failed: {e}")
|
|
713
|
-
return
|
|
714
|
-
|
|
715
|
-
# 3. Find orphaned services
|
|
716
|
-
orphaned = [service for service in aws_services if service not in github_services]
|
|
717
|
-
|
|
718
|
-
if not orphaned:
|
|
719
|
-
p("\n✅ [bold green]No orphaned AWS resources found![/bold green]")
|
|
720
|
-
return
|
|
721
|
-
|
|
722
|
-
p(f"\n🚨 [bold red]Found {len(orphaned)} orphaned AWS resources:[/bold red]")
|
|
723
|
-
for service in orphaned:
|
|
724
|
-
p(f" 💰 {service} (consuming resources)")
|
|
725
|
-
|
|
726
|
-
if dry_run:
|
|
727
|
-
p(f"\n🎪 [bold yellow]DRY RUN[/bold yellow] - Would delete {len(orphaned)} services")
|
|
728
|
-
return
|
|
729
|
-
|
|
730
|
-
if not force:
|
|
731
|
-
confirm = typer.confirm(f"Delete {len(orphaned)} orphaned AWS services?")
|
|
732
|
-
if not confirm:
|
|
733
|
-
p("🎪 Cancelled")
|
|
734
|
-
return
|
|
735
|
-
|
|
736
|
-
# 4. Delete orphaned resources
|
|
737
|
-
deleted_count = 0
|
|
738
|
-
for service in orphaned:
|
|
739
|
-
p(f"🗑️ Deleting {service}...")
|
|
740
|
-
try:
|
|
741
|
-
# Extract PR number for delete_environment call
|
|
742
|
-
pr_match = service.replace("pr-", "").replace("-service", "")
|
|
743
|
-
parts = pr_match.split("-")
|
|
744
|
-
if len(parts) >= 2:
|
|
745
|
-
pr_number = int(parts[0])
|
|
746
|
-
success = aws.delete_environment(service, pr_number)
|
|
747
|
-
if success:
|
|
748
|
-
p(f"✅ Deleted {service}")
|
|
749
|
-
deleted_count += 1
|
|
750
|
-
else:
|
|
751
|
-
p(f"❌ Failed to delete {service}")
|
|
752
|
-
else:
|
|
753
|
-
p(f"❌ Invalid service name format: {service}")
|
|
754
|
-
except Exception as e:
|
|
755
|
-
p(f"❌ Error deleting {service}: {e}")
|
|
756
|
-
|
|
757
|
-
p(f"\n🎪 ✅ Cleanup complete: deleted {deleted_count}/{len(orphaned)} services")
|
|
758
|
-
|
|
759
|
-
except Exception as e:
|
|
760
|
-
p(f"❌ AWS cleanup failed: {e}")
|
|
761
|
-
|
|
762
|
-
|
|
763
635
|
@app.command()
|
|
764
636
|
def cleanup(
|
|
765
637
|
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be cleaned"),
|
|
638
|
+
force: bool = typer.Option(False, "--force", help="Skip interactive prompts"),
|
|
766
639
|
older_than: str = typer.Option(
|
|
767
640
|
"48h", "--older-than", help="Clean environments older than this (ignored if --respect-ttl)"
|
|
768
641
|
),
|
|
@@ -777,6 +650,11 @@ def cleanup(
|
|
|
777
650
|
"--cleanup-labels/--no-cleanup-labels",
|
|
778
651
|
help="Also cleanup SHA-based label definitions from repository",
|
|
779
652
|
),
|
|
653
|
+
cleanup_aws_orphans: bool = typer.Option(
|
|
654
|
+
True,
|
|
655
|
+
"--cleanup-aws-orphans/--no-cleanup-aws-orphans",
|
|
656
|
+
help="Also cleanup orphaned AWS resources",
|
|
657
|
+
),
|
|
780
658
|
) -> None:
|
|
781
659
|
"""🎪 Clean up orphaned or expired environments and labels"""
|
|
782
660
|
try:
|
|
@@ -811,6 +689,106 @@ def cleanup(
|
|
|
811
689
|
else:
|
|
812
690
|
p("🎪 No expired environments found")
|
|
813
691
|
|
|
692
|
+
# Phase 2: AWS orphan cleanup
|
|
693
|
+
aws_cleaned_count = 0
|
|
694
|
+
if cleanup_aws_orphans:
|
|
695
|
+
from .core.aws import AWSInterface
|
|
696
|
+
|
|
697
|
+
p("\n☁️ [bold blue]Scanning for orphaned AWS resources...[/bold blue]")
|
|
698
|
+
aws = AWSInterface()
|
|
699
|
+
|
|
700
|
+
try:
|
|
701
|
+
# Get expected services from GitHub
|
|
702
|
+
github_services = set()
|
|
703
|
+
for pr_number in pr_numbers:
|
|
704
|
+
pr = PullRequest.from_id(pr_number)
|
|
705
|
+
for show in pr.shows:
|
|
706
|
+
github_services.add(show.ecs_service_name)
|
|
707
|
+
|
|
708
|
+
# Find AWS orphans
|
|
709
|
+
aws_services = aws.list_circus_environments()
|
|
710
|
+
aws_orphans = [
|
|
711
|
+
svc for svc in aws_services if svc.get("service_name") not in github_services
|
|
712
|
+
]
|
|
713
|
+
|
|
714
|
+
if aws_orphans:
|
|
715
|
+
p(f"☁️ Found {len(aws_orphans)} orphaned AWS resources:")
|
|
716
|
+
for orphan in aws_orphans[:3]:
|
|
717
|
+
p(f" • {orphan['service_name']}")
|
|
718
|
+
if len(aws_orphans) > 3:
|
|
719
|
+
p(f" ... and {len(aws_orphans) - 3} more")
|
|
720
|
+
|
|
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:
|
|
731
|
+
aws_cleaned_count = len(aws_orphans)
|
|
732
|
+
if not dry_run:
|
|
733
|
+
for orphan in aws_orphans:
|
|
734
|
+
aws.delete_service(orphan["service_name"])
|
|
735
|
+
|
|
736
|
+
if aws_cleaned_count > 0:
|
|
737
|
+
p(f"☁️ ✅ Cleaned up {aws_cleaned_count} orphaned AWS resources")
|
|
738
|
+
else:
|
|
739
|
+
p("☁️ No orphaned AWS resources found")
|
|
740
|
+
|
|
741
|
+
except Exception as e:
|
|
742
|
+
p(f"⚠️ AWS orphan scan failed: {e}")
|
|
743
|
+
|
|
744
|
+
# Phase 3: Repository label cleanup
|
|
745
|
+
label_cleaned_count = 0
|
|
746
|
+
if cleanup_labels:
|
|
747
|
+
from .core.pull_request import get_github
|
|
748
|
+
|
|
749
|
+
p("\n🏷️ [bold blue]Scanning for orphaned repository labels...[/bold blue]")
|
|
750
|
+
github = get_github()
|
|
751
|
+
|
|
752
|
+
try:
|
|
753
|
+
orphaned_labels = github.find_orphaned_labels(dry_run=True) # Preview
|
|
754
|
+
|
|
755
|
+
if orphaned_labels:
|
|
756
|
+
p(f"🏷️ Found {len(orphaned_labels)} orphaned repository labels:")
|
|
757
|
+
for label in orphaned_labels[:3]:
|
|
758
|
+
p(f" • {label}")
|
|
759
|
+
if len(orphaned_labels) > 3:
|
|
760
|
+
p(f" ... and {len(orphaned_labels) - 3} more")
|
|
761
|
+
|
|
762
|
+
if not force and not dry_run:
|
|
763
|
+
if typer.confirm(
|
|
764
|
+
f"Delete {len(orphaned_labels)} orphaned labels from repository?"
|
|
765
|
+
):
|
|
766
|
+
deleted_labels = github.find_orphaned_labels(dry_run=False)
|
|
767
|
+
label_cleaned_count = len(deleted_labels)
|
|
768
|
+
else:
|
|
769
|
+
p("❌ Skipping repository label cleanup")
|
|
770
|
+
elif force or dry_run:
|
|
771
|
+
label_cleaned_count = len(orphaned_labels)
|
|
772
|
+
if not dry_run:
|
|
773
|
+
github.find_orphaned_labels(dry_run=False)
|
|
774
|
+
|
|
775
|
+
if label_cleaned_count > 0:
|
|
776
|
+
p(f"🏷️ ✅ Cleaned up {label_cleaned_count} orphaned repository labels")
|
|
777
|
+
else:
|
|
778
|
+
p("🏷️ No orphaned repository labels found")
|
|
779
|
+
|
|
780
|
+
except Exception as e:
|
|
781
|
+
p(f"⚠️ Repository label scan failed: {e}")
|
|
782
|
+
|
|
783
|
+
# Final summary
|
|
784
|
+
total_cleaned = cleaned_count + aws_cleaned_count + label_cleaned_count
|
|
785
|
+
if total_cleaned > 0:
|
|
786
|
+
p(
|
|
787
|
+
f"\n🎉 [bold green]Total cleanup: {cleaned_count} environments + {aws_cleaned_count} AWS orphans + {label_cleaned_count} labels[/bold green]"
|
|
788
|
+
)
|
|
789
|
+
else:
|
|
790
|
+
p("\n✨ [bold green]No cleanup needed - everything is clean![/bold green]")
|
|
791
|
+
|
|
814
792
|
except Exception as e:
|
|
815
793
|
p(f"❌ Cleanup failed: {e}")
|
|
816
794
|
|
|
@@ -13,6 +13,7 @@ EMOJI_MEANINGS = {
|
|
|
13
13
|
"🚦": "status", # Traffic light for environment status
|
|
14
14
|
"🏗️": "building", # Construction for building environments
|
|
15
15
|
"🎯": "active", # Target for currently active environment
|
|
16
|
+
"🔒": "blocked", # Lock for blocking all operations
|
|
16
17
|
# Metadata
|
|
17
18
|
"📅": "created_at", # Calendar for creation timestamp
|
|
18
19
|
"🌐": "ip", # Globe for IP address
|
|
@@ -143,9 +143,9 @@ class GitHubInterface:
|
|
|
143
143
|
url = f"{self.base_url}/search/issues"
|
|
144
144
|
# Search for PRs with any circus tent labels
|
|
145
145
|
params = {
|
|
146
|
-
"q": f"repo:{self.org}/{self.repo} is:pr 🎪",
|
|
146
|
+
"q": f"repo:{self.org}/{self.repo} is:pr is:open 🎪",
|
|
147
147
|
"per_page": "100",
|
|
148
|
-
} #
|
|
148
|
+
} # Only open PRs - closed PRs should have cleaned up labels
|
|
149
149
|
|
|
150
150
|
with httpx.Client() as client:
|
|
151
151
|
response = client.get(url, headers=self.headers, params=params)
|
|
@@ -209,8 +209,8 @@ class GitHubInterface:
|
|
|
209
209
|
all_labels = self.get_repository_labels()
|
|
210
210
|
sha_labels = []
|
|
211
211
|
|
|
212
|
-
# Find labels with SHA patterns (7+ hex chars
|
|
213
|
-
sha_pattern = re.compile(r"^🎪 .*
|
|
212
|
+
# Find labels with SHA patterns (7+ hex chars anywhere in label)
|
|
213
|
+
sha_pattern = re.compile(r"^🎪 .*[a-f0-9]{7,}.*$")
|
|
214
214
|
|
|
215
215
|
for label in all_labels:
|
|
216
216
|
if sha_pattern.match(label):
|
|
@@ -225,6 +225,69 @@ class GitHubInterface:
|
|
|
225
225
|
|
|
226
226
|
return sha_labels
|
|
227
227
|
|
|
228
|
+
def find_orphaned_labels(self, dry_run: bool = False) -> List[str]:
|
|
229
|
+
"""Find labels that exist in repository but aren't used on any PR"""
|
|
230
|
+
import re
|
|
231
|
+
|
|
232
|
+
print("🔍 Scanning repository labels...")
|
|
233
|
+
|
|
234
|
+
# 1. Get all repository labels with SHA patterns
|
|
235
|
+
all_repo_labels = self.get_repository_labels()
|
|
236
|
+
sha_pattern = re.compile(r"^🎪 .*[a-f0-9]{7,}.*$")
|
|
237
|
+
sha_repo_labels = {label for label in all_repo_labels if sha_pattern.match(label)}
|
|
238
|
+
|
|
239
|
+
print(f"📋 Found {len(sha_repo_labels)} SHA-containing labels in repository")
|
|
240
|
+
|
|
241
|
+
# 2. Get all labels actually used on PRs with circus labels
|
|
242
|
+
print("🔍 Scanning PRs with circus labels...")
|
|
243
|
+
|
|
244
|
+
# Import here to avoid circular import
|
|
245
|
+
import importlib
|
|
246
|
+
|
|
247
|
+
pull_request_module = importlib.import_module("showtime.core.pull_request")
|
|
248
|
+
PullRequest = pull_request_module.PullRequest
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
pr_numbers = PullRequest.find_all_with_environments()
|
|
252
|
+
print(f"📋 Found {len(pr_numbers)} PRs with circus labels")
|
|
253
|
+
|
|
254
|
+
used_labels = set()
|
|
255
|
+
for pr_number in pr_numbers:
|
|
256
|
+
pr_labels = self.get_labels(pr_number)
|
|
257
|
+
circus_labels = {label for label in pr_labels if label.startswith("🎪 ")}
|
|
258
|
+
used_labels.update(circus_labels)
|
|
259
|
+
|
|
260
|
+
print(f"📋 Found {len(used_labels)} circus labels actually used on PRs")
|
|
261
|
+
|
|
262
|
+
# 3. Set difference to find orphaned labels
|
|
263
|
+
orphaned_labels = sha_repo_labels - used_labels
|
|
264
|
+
|
|
265
|
+
print(f"🗑️ Found {len(orphaned_labels)} truly orphaned labels")
|
|
266
|
+
|
|
267
|
+
# Debug: Show some examples if in dry run
|
|
268
|
+
if dry_run and orphaned_labels:
|
|
269
|
+
print("🔍 Examples of orphaned labels:")
|
|
270
|
+
for label in list(orphaned_labels)[:5]:
|
|
271
|
+
print(f" • {label}")
|
|
272
|
+
if dry_run and used_labels:
|
|
273
|
+
print("🔍 Examples of used labels:")
|
|
274
|
+
for label in list(used_labels)[:5]:
|
|
275
|
+
print(f" • {label}")
|
|
276
|
+
|
|
277
|
+
if not dry_run and orphaned_labels:
|
|
278
|
+
deleted_labels = []
|
|
279
|
+
for label in orphaned_labels:
|
|
280
|
+
if self.delete_repository_label(label):
|
|
281
|
+
deleted_labels.append(label)
|
|
282
|
+
return deleted_labels
|
|
283
|
+
|
|
284
|
+
return list(orphaned_labels)
|
|
285
|
+
|
|
286
|
+
except Exception as e:
|
|
287
|
+
print(f"⚠️ Error during orphan detection: {e}")
|
|
288
|
+
# Fallback to old pattern-based method
|
|
289
|
+
return self.cleanup_sha_labels(dry_run)
|
|
290
|
+
|
|
228
291
|
def create_or_update_label(self, name: str, color: str, description: str) -> bool:
|
|
229
292
|
"""Create or update a label with color and description"""
|
|
230
293
|
import urllib.parse
|
|
@@ -31,6 +31,10 @@ LABEL_DEFINITIONS = {
|
|
|
31
31
|
"color": "FFE4B5", # Light orange
|
|
32
32
|
"description": "Freeze PR - prevent auto-sync on new commits",
|
|
33
33
|
},
|
|
34
|
+
"🎪 🔒 showtime-blocked": {
|
|
35
|
+
"color": "dc3545", # Red - blocking/danger
|
|
36
|
+
"description": "Block all Showtime operations - maintenance mode",
|
|
37
|
+
},
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
# Status-specific label patterns (generated dynamically)
|
|
@@ -90,20 +90,10 @@ class PullRequest:
|
|
|
90
90
|
|
|
91
91
|
@property
|
|
92
92
|
def building_show(self) -> Optional[Show]:
|
|
93
|
-
"""The currently building show (from
|
|
94
|
-
building_sha = None
|
|
95
|
-
for label in self.labels:
|
|
96
|
-
if label.startswith("🎪 🏗️ "):
|
|
97
|
-
building_sha = label.split(" ")[2]
|
|
98
|
-
break
|
|
99
|
-
|
|
100
|
-
if not building_sha:
|
|
101
|
-
return None
|
|
102
|
-
|
|
93
|
+
"""The currently building show (from building/deploying status)"""
|
|
103
94
|
for show in self.shows:
|
|
104
|
-
if show.
|
|
95
|
+
if show.status in ["building", "deploying"]:
|
|
105
96
|
return show
|
|
106
|
-
|
|
107
97
|
return None
|
|
108
98
|
|
|
109
99
|
@property
|
|
@@ -185,6 +175,88 @@ class PullRequest:
|
|
|
185
175
|
for label in circus_labels:
|
|
186
176
|
self.remove_label(label)
|
|
187
177
|
|
|
178
|
+
def set_show_status(self, show: Show, new_status: str) -> None:
|
|
179
|
+
"""Atomically update show status with thorough label cleanup"""
|
|
180
|
+
show.status = new_status
|
|
181
|
+
|
|
182
|
+
# 1. Refresh labels to get current GitHub state
|
|
183
|
+
self.refresh_labels()
|
|
184
|
+
|
|
185
|
+
# 2. Remove ALL existing status labels for this SHA (not just the "expected" one)
|
|
186
|
+
status_labels_to_remove = [
|
|
187
|
+
label for label in self.labels if label.startswith(f"🎪 {show.sha} 🚦 ")
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
for label in status_labels_to_remove:
|
|
191
|
+
self.remove_label(label)
|
|
192
|
+
|
|
193
|
+
# 3. Add the new status label
|
|
194
|
+
new_status_label = f"🎪 {show.sha} 🚦 {new_status}"
|
|
195
|
+
self.add_label(new_status_label)
|
|
196
|
+
|
|
197
|
+
def set_active_show(self, show: Show) -> None:
|
|
198
|
+
"""Atomically set this show as the active environment"""
|
|
199
|
+
from .emojis import CIRCUS_PREFIX, MEANING_TO_EMOJI
|
|
200
|
+
|
|
201
|
+
# 1. Refresh to get current state
|
|
202
|
+
self.refresh_labels()
|
|
203
|
+
|
|
204
|
+
# 2. Remove ALL existing active pointers (ensure only one)
|
|
205
|
+
active_emoji = MEANING_TO_EMOJI["active"] # Gets 🎯
|
|
206
|
+
active_prefix = f"{CIRCUS_PREFIX} {active_emoji} " # "🎪 🎯 "
|
|
207
|
+
active_pointers = [label for label in self.labels if label.startswith(active_prefix)]
|
|
208
|
+
|
|
209
|
+
for pointer in active_pointers:
|
|
210
|
+
self.remove_label(pointer)
|
|
211
|
+
|
|
212
|
+
# 3. Set this show as the new active one
|
|
213
|
+
active_pointer = f"{active_prefix}{show.sha}" # "🎪 🎯 abc123f"
|
|
214
|
+
self.add_label(active_pointer)
|
|
215
|
+
|
|
216
|
+
def _check_authorization(self) -> bool:
|
|
217
|
+
"""Check if current GitHub actor is authorized for operations"""
|
|
218
|
+
import os
|
|
219
|
+
|
|
220
|
+
import httpx
|
|
221
|
+
|
|
222
|
+
# Only check in GitHub Actions context
|
|
223
|
+
if os.getenv("GITHUB_ACTIONS") != "true":
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
actor = os.getenv("GITHUB_ACTOR")
|
|
227
|
+
if not actor:
|
|
228
|
+
return True # No actor info, allow operation
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
# Use existing GitHubInterface for consistency
|
|
232
|
+
github = get_github()
|
|
233
|
+
|
|
234
|
+
# Check collaborator permissions
|
|
235
|
+
perm_url = f"{github.base_url}/repos/{github.org}/{github.repo}/collaborators/{actor}/permission"
|
|
236
|
+
|
|
237
|
+
with httpx.Client() as client:
|
|
238
|
+
response = client.get(perm_url, headers=github.headers)
|
|
239
|
+
if response.status_code == 404:
|
|
240
|
+
return False # Not a collaborator
|
|
241
|
+
response.raise_for_status()
|
|
242
|
+
|
|
243
|
+
data = response.json()
|
|
244
|
+
permission = data.get("permission", "none")
|
|
245
|
+
|
|
246
|
+
# Allow write and admin permissions only
|
|
247
|
+
authorized = permission in ["write", "admin"]
|
|
248
|
+
|
|
249
|
+
if not authorized:
|
|
250
|
+
print(f"🚨 Unauthorized actor {actor} (permission: {permission})")
|
|
251
|
+
# Set blocked label for security
|
|
252
|
+
self.add_label("🎪 🔒 showtime-blocked")
|
|
253
|
+
|
|
254
|
+
return authorized
|
|
255
|
+
|
|
256
|
+
except Exception as e:
|
|
257
|
+
print(f"⚠️ Authorization check failed: {e}")
|
|
258
|
+
return True # Fail open for non-security operations
|
|
259
|
+
|
|
188
260
|
def analyze(self, target_sha: str, pr_state: str = "open") -> AnalysisResult:
|
|
189
261
|
"""Analyze what actions are needed (read-only, for --check-only)
|
|
190
262
|
|
|
@@ -208,7 +280,7 @@ class PullRequest:
|
|
|
208
280
|
build_needed = action_needed in ["create_environment", "rolling_update", "auto_sync"]
|
|
209
281
|
|
|
210
282
|
# Determine if sync execution is needed
|
|
211
|
-
sync_needed = action_needed
|
|
283
|
+
sync_needed = action_needed not in ["no_action", "blocked"]
|
|
212
284
|
|
|
213
285
|
return AnalysisResult(
|
|
214
286
|
action_needed=action_needed,
|
|
@@ -244,8 +316,21 @@ class PullRequest:
|
|
|
244
316
|
# 1. Determine what action is needed
|
|
245
317
|
action_needed = self._determine_action(target_sha)
|
|
246
318
|
|
|
247
|
-
# 2.
|
|
248
|
-
if action_needed
|
|
319
|
+
# 2. Check for blocked state (fast bailout)
|
|
320
|
+
if action_needed == "blocked":
|
|
321
|
+
return SyncResult(
|
|
322
|
+
success=False,
|
|
323
|
+
action_taken="blocked",
|
|
324
|
+
error="🔒 Showtime operations are blocked for this PR. Remove '🎪 🔒 showtime-blocked' label to re-enable.",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# 3. Atomic claim for environment changes (PR-level lock)
|
|
328
|
+
if action_needed in [
|
|
329
|
+
"create_environment",
|
|
330
|
+
"rolling_update",
|
|
331
|
+
"auto_sync",
|
|
332
|
+
"destroy_environment",
|
|
333
|
+
]:
|
|
249
334
|
print(f"🔒 Claiming environment for {action_needed}...")
|
|
250
335
|
if not self._atomic_claim(target_sha, action_needed, dry_run_github):
|
|
251
336
|
print("❌ Claim failed - another job is active")
|
|
@@ -266,14 +351,14 @@ class PullRequest:
|
|
|
266
351
|
# Phase 1: Docker build
|
|
267
352
|
print("🐳 Building Docker image...")
|
|
268
353
|
show.build_docker(dry_run_docker)
|
|
269
|
-
show.status = "built"
|
|
270
354
|
print("✅ Docker build completed")
|
|
271
|
-
self._update_show_labels(show, dry_run_github)
|
|
272
355
|
|
|
273
356
|
# Phase 2: AWS deployment
|
|
274
357
|
print("☁️ Deploying to AWS ECS...")
|
|
358
|
+
self.set_show_status(show, "deploying")
|
|
275
359
|
show.deploy_aws(dry_run_aws)
|
|
276
|
-
show
|
|
360
|
+
self.set_show_status(show, "running")
|
|
361
|
+
self.set_active_show(show)
|
|
277
362
|
print(f"✅ Deployment completed - environment running at {show.ip}:8080")
|
|
278
363
|
self._update_show_labels(show, dry_run_github)
|
|
279
364
|
|
|
@@ -303,14 +388,14 @@ class PullRequest:
|
|
|
303
388
|
# Phase 1: Docker build
|
|
304
389
|
print("🐳 Building updated Docker image...")
|
|
305
390
|
new_show.build_docker(dry_run_docker)
|
|
306
|
-
new_show.status = "built"
|
|
307
391
|
print("✅ Docker build completed")
|
|
308
|
-
self._update_show_labels(new_show, dry_run_github)
|
|
309
392
|
|
|
310
393
|
# Phase 2: Blue-green deployment
|
|
311
394
|
print("☁️ Deploying updated environment...")
|
|
395
|
+
self.set_show_status(new_show, "deploying")
|
|
312
396
|
new_show.deploy_aws(dry_run_aws)
|
|
313
|
-
new_show
|
|
397
|
+
self.set_show_status(new_show, "running")
|
|
398
|
+
self.set_active_show(new_show)
|
|
314
399
|
print(f"✅ Rolling update completed - new environment at {new_show.ip}:8080")
|
|
315
400
|
self._update_show_labels(new_show, dry_run_github)
|
|
316
401
|
|
|
@@ -406,7 +491,7 @@ class PullRequest:
|
|
|
406
491
|
if any(label == f"🎪 🎯 {show.sha}" for label in pr.labels):
|
|
407
492
|
show_type = "active"
|
|
408
493
|
# Check for building pointer
|
|
409
|
-
elif
|
|
494
|
+
elif show.status in ["building", "deploying"]:
|
|
410
495
|
show_type = "building"
|
|
411
496
|
# No pointer = orphaned
|
|
412
497
|
|
|
@@ -433,6 +518,14 @@ class PullRequest:
|
|
|
433
518
|
# CRITICAL: Get fresh labels before any decisions
|
|
434
519
|
self.refresh_labels()
|
|
435
520
|
|
|
521
|
+
# Check for blocked state first (fast bailout)
|
|
522
|
+
if "🎪 🔒 showtime-blocked" in self.labels:
|
|
523
|
+
return "blocked"
|
|
524
|
+
|
|
525
|
+
# Check authorization (security layer)
|
|
526
|
+
if not self._check_authorization():
|
|
527
|
+
return "blocked"
|
|
528
|
+
|
|
436
529
|
target_sha_short = target_sha[:7] # Ensure we're working with short SHA
|
|
437
530
|
|
|
438
531
|
# Get the specific show for the target SHA
|
|
@@ -455,10 +548,14 @@ class PullRequest:
|
|
|
455
548
|
elif "showtime-trigger-stop" in trigger:
|
|
456
549
|
return "destroy_environment"
|
|
457
550
|
|
|
458
|
-
# No explicit triggers -
|
|
551
|
+
# No explicit triggers - only auto-create if there's ANY previous environment
|
|
459
552
|
if not target_show:
|
|
460
|
-
# Target SHA doesn't exist - create
|
|
461
|
-
|
|
553
|
+
# Target SHA doesn't exist - only create if there's any previous environment
|
|
554
|
+
if self.shows: # Any previous environment exists
|
|
555
|
+
return "create_environment"
|
|
556
|
+
else:
|
|
557
|
+
# No previous environments - don't auto-create without explicit trigger
|
|
558
|
+
return "no_action"
|
|
462
559
|
elif target_show.status == "failed":
|
|
463
560
|
# Target SHA failed - rebuild it
|
|
464
561
|
return "create_environment"
|
|
@@ -515,7 +612,6 @@ class PullRequest:
|
|
|
515
612
|
for label in new_labels:
|
|
516
613
|
try:
|
|
517
614
|
self.add_label(label)
|
|
518
|
-
print(f" ✅ Added: {label}")
|
|
519
615
|
except Exception as e:
|
|
520
616
|
print(f" ❌ Failed to add {label}: {e}")
|
|
521
617
|
raise
|
|
@@ -646,7 +742,6 @@ class PullRequest:
|
|
|
646
742
|
and (
|
|
647
743
|
label.startswith(f"🎪 {show.sha} ") # SHA-first format: 🎪 abc123f 📅 ...
|
|
648
744
|
or label.startswith(f"🎪 🎯 {show.sha}") # Pointer format: 🎪 🎯 abc123f
|
|
649
|
-
or label.startswith(f"🎪 🏗️ {show.sha}") # Building pointer: 🎪 🏗️ abc123f
|
|
650
745
|
)
|
|
651
746
|
}
|
|
652
747
|
desired_labels = set(show.to_circus_labels())
|
|
@@ -704,9 +799,7 @@ class PullRequest:
|
|
|
704
799
|
existing_labels = [
|
|
705
800
|
label
|
|
706
801
|
for label in self.labels
|
|
707
|
-
if label.startswith(f"🎪 {show.sha} ")
|
|
708
|
-
or label == f"🎪 🎯 {show.sha}"
|
|
709
|
-
or label == f"🎪 🏗️ {show.sha}"
|
|
802
|
+
if label.startswith(f"🎪 {show.sha} ") or label == f"🎪 🎯 {show.sha}"
|
|
710
803
|
]
|
|
711
804
|
print(f"🏷️ Removing existing labels for {show.sha}: {existing_labels}")
|
|
712
805
|
for label in existing_labels:
|