superset-showtime 0.3.3__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of superset-showtime might be problematic. Click here for more details.

showtime/cli.py CHANGED
@@ -4,133 +4,36 @@
4
4
  Main command-line interface for Apache Superset circus tent environment management.
5
5
  """
6
6
 
7
- import subprocess
8
- from typing import Optional
7
+ from typing import Dict, Optional
9
8
 
10
9
  import typer
11
10
  from rich.console import Console
12
11
  from rich.table import Table
13
12
 
14
- from .core.circus import PullRequest, Show, short_sha
15
13
  from .core.emojis import STATUS_DISPLAY
16
14
  from .core.github import GitHubError, GitHubInterface
17
15
  from .core.github_messages import (
18
- building_comment,
19
- failure_comment,
20
16
  get_aws_console_urls,
21
- rolling_failure_comment,
22
- rolling_start_comment,
23
- rolling_success_comment,
24
- start_comment,
25
- success_comment,
26
17
  )
18
+ from .core.pull_request import PullRequest
19
+ from .core.show import Show
27
20
 
28
21
  # Constants
29
22
  DEFAULT_GITHUB_ACTOR = "unknown"
30
23
 
31
24
 
32
- def _get_service_urls(show):
25
+ def _get_service_urls(show: Show) -> Dict[str, str]:
33
26
  """Get AWS Console URLs for a service"""
34
27
  return get_aws_console_urls(show.ecs_service_name)
35
28
 
36
29
 
37
- def _show_service_urls(show, context: str = "deployment"):
30
+ def _show_service_urls(show: Show, context: str = "deployment") -> None:
38
31
  """Show helpful AWS Console URLs for monitoring service"""
39
32
  urls = _get_service_urls(show)
40
- console.print(f"\n🎪 [bold blue]Monitor {context} progress:[/bold blue]")
41
- console.print(f"📝 Logs: {urls['logs']}")
42
- console.print(f"📊 Service: {urls['service']}")
43
- console.print("")
44
-
45
-
46
- def _determine_sync_action(pr, pr_state: str, target_sha: str) -> str:
47
- """Determine what action is needed based on PR state and labels"""
48
-
49
- # 1. Closed PRs always need cleanup
50
- if pr_state == "closed":
51
- return "cleanup"
52
-
53
- # 2. Check for explicit trigger labels
54
- trigger_labels = [label for label in pr.labels if "showtime-trigger-" in label]
55
-
56
- # 3. Check for freeze label (PR-level) - only if no explicit triggers
57
- freeze_labels = [label for label in pr.labels if "showtime-freeze" in label]
58
- if freeze_labels and not trigger_labels:
59
- return "frozen_no_action" # Frozen and no explicit triggers to override
60
-
61
- if trigger_labels:
62
- # Explicit triggers take priority
63
- for trigger in trigger_labels:
64
- if "showtime-trigger-start" in trigger:
65
- if pr.current_show:
66
- if pr.current_show.needs_update(target_sha):
67
- return "rolling_update" # New commit with existing env
68
- else:
69
- return "no_action" # Same commit, no change needed
70
- else:
71
- return "create_environment" # New environment
72
- elif "showtime-trigger-stop" in trigger:
73
- return "destroy_environment"
74
-
75
- # 3. No explicit triggers - check for implicit sync needs
76
- if pr.current_show:
77
- if pr.current_show.needs_update(target_sha):
78
- return "auto_sync" # Auto-update on new commits
79
- else:
80
- return "no_action" # Everything in sync
81
- else:
82
- return "no_action" # No environment, no triggers
83
-
84
-
85
- def _schedule_blue_cleanup(pr_number: int, blue_services: list):
86
- """Schedule cleanup of blue services after successful green deployment"""
87
- import threading
88
- import time
89
-
90
- def cleanup_after_delay():
91
- """Background cleanup of blue services"""
92
- try:
93
- # Wait 5 minutes before cleanup
94
- time.sleep(300) # 5 minutes
95
-
96
- console.print(
97
- f"\n🧹 [bold blue]Starting scheduled cleanup of blue services for PR #{pr_number}[/bold blue]"
98
- )
99
-
100
- from .core.aws import AWSInterface
101
-
102
- aws = AWSInterface()
103
-
104
- for blue_svc in blue_services:
105
- service_name = blue_svc["service_name"]
106
- console.print(f"🗑️ Cleaning up blue service: {service_name}")
107
-
108
- try:
109
- # Delete ECS service
110
- if aws._delete_ecs_service(service_name):
111
- # Delete ECR image
112
- pr_match = service_name.split("-")
113
- if len(pr_match) >= 2:
114
- pr_num = pr_match[1]
115
- image_tag = f"pr-{pr_num}-ci" # Legacy format for old services
116
- aws._delete_ecr_image(image_tag)
117
-
118
- console.print(f"✅ Cleaned up blue service: {service_name}")
119
- else:
120
- console.print(f"⚠️ Failed to clean up: {service_name}")
121
-
122
- except Exception as e:
123
- console.print(f"❌ Cleanup error for {service_name}: {e}")
124
-
125
- console.print("🧹 ✅ Blue service cleanup completed")
126
-
127
- except Exception as e:
128
- console.print(f"❌ Background cleanup failed: {e}")
129
-
130
- # Start cleanup in background thread
131
- cleanup_thread = threading.Thread(target=cleanup_after_delay, daemon=True)
132
- cleanup_thread.start()
133
- console.print("🕐 Background cleanup scheduled")
33
+ p(f"\n🎪 [bold blue]Monitor {context} progress:[/bold blue]")
34
+ p(f"📝 Logs: {urls['logs']}")
35
+ p(f"📊 Service: {urls['service']}")
36
+ p("")
134
37
 
135
38
 
136
39
  app = typer.Typer(
@@ -152,9 +55,11 @@ app = typer.Typer(
152
55
 
153
56
  [dim]CLI commands work with existing environments or dry-run new ones.[/dim]""",
154
57
  rich_markup_mode="rich",
58
+ no_args_is_help=True,
155
59
  )
156
60
 
157
61
  console = Console()
62
+ p = console.print # Shorthand for cleaner code
158
63
 
159
64
 
160
65
  def _get_github_workflow_url() -> str:
@@ -176,336 +81,15 @@ def _get_github_actor() -> str:
176
81
 
177
82
  def _get_showtime_footer() -> str:
178
83
  """Get consistent Showtime footer for PR comments"""
179
- return "{_get_showtime_footer()}"
180
-
181
-
182
- def _validate_non_active_state(pr: PullRequest) -> bool:
183
- """Check if PR is in a state where new work can begin
184
-
185
- Args:
186
- pr: PullRequest object with current state
187
-
188
- Returns:
189
- True if safe to start new work, False if another job is already active
190
- """
191
- if pr.current_show:
192
- active_states = ["building", "built", "deploying", "running", "updating"]
193
- if pr.current_show.status in active_states:
194
- return False # Already active
195
- return True # Safe to proceed
196
-
197
-
198
- def _atomic_claim_environment(
199
- pr_number: int, target_sha: str, github: GitHubInterface, dry_run: bool = False
200
- ) -> bool:
201
- """Atomically claim environment for this job using compare-and-swap pattern
202
-
203
- Args:
204
- pr_number: PR number to claim
205
- target_sha: Target commit SHA
206
- github: GitHub interface for label operations
207
- dry_run: If True, simulate operations
208
-
209
- Returns:
210
- True if successfully claimed, False if another job already active or no triggers
211
- """
212
- from datetime import datetime
213
-
214
- try:
215
- # 1. CHECK: Load current PR state
216
- pr = PullRequest.from_id(pr_number, github)
217
-
218
- # 2. VALIDATE: Ensure non-active state (compare part of compare-and-swap)
219
- if not _validate_non_active_state(pr):
220
- current_state = pr.current_show.status if pr.current_show else "unknown"
221
- console.print(
222
- f"🎪 Environment already active (state: {current_state}) - another job is running"
223
- )
224
- return False
225
-
226
- # 3. FIND TRIGGERS: Must have triggers to claim
227
- trigger_labels = [label for label in pr.labels if "showtime-trigger-" in label]
228
- if not trigger_labels:
229
- console.print("🎪 No trigger labels found - nothing to claim")
230
- return False
231
-
232
- # 4. VALIDATE TRIGGER-SPECIFIC STATE REQUIREMENTS
233
- for trigger_label in trigger_labels:
234
- if "showtime-trigger-start" in trigger_label:
235
- # Start trigger: should NOT already be building/running
236
- if pr.current_show and pr.current_show.status in [
237
- "building",
238
- "built",
239
- "deploying",
240
- "running",
241
- ]:
242
- console.print(
243
- f"🎪 Start trigger invalid - environment already {pr.current_show.status}"
244
- )
245
- return False
246
- elif "showtime-trigger-stop" in trigger_label:
247
- # Stop trigger: should HAVE an active environment
248
- if not pr.current_show or pr.current_show.status in ["failed"]:
249
- console.print("🎪 Stop trigger invalid - no active environment to stop")
250
- return False
251
-
252
- console.print(f"🎪 Claiming environment for PR #{pr_number} SHA {target_sha[:7]}")
253
- console.print(f"🎪 Found {len(trigger_labels)} valid trigger(s) to process")
254
-
255
- if dry_run:
256
- console.print(
257
- "🎪 [bold yellow]DRY-RUN[/bold yellow] - Would atomically claim environment"
258
- )
259
- return True
260
-
261
- # 4. ATOMIC SWAP: Remove triggers + Set building state (swap part)
262
- console.print("🎪 Executing atomic claim (remove triggers + set building)...")
263
-
264
- # Remove all trigger labels first
265
- for trigger_label in trigger_labels:
266
- console.print(f" 🗑️ Removing trigger: {trigger_label}")
267
- github.remove_label(pr_number, trigger_label)
268
-
269
- # Immediately set building state to claim the environment
270
- building_show = Show(
271
- pr_number=pr_number,
272
- sha=short_sha(target_sha),
273
- status="building",
274
- created_at=datetime.utcnow().strftime("%Y-%m-%dT%H-%M"),
275
- ttl="24h",
276
- requested_by=_get_github_actor(),
277
- )
278
-
279
- # Clear any stale state and set building labels atomically
280
- github.remove_circus_labels(pr_number)
281
- for label in building_show.to_circus_labels():
282
- github.add_label(pr_number, label)
283
-
284
- console.print("🎪 ✅ Environment claimed successfully")
285
- return True
286
-
287
- except Exception as e:
288
- console.print(f"🎪 ❌ Failed to claim environment: {e}")
289
- return False
290
-
291
-
292
- def _build_docker_image(pr_number: int, sha: str, dry_run: bool = False) -> bool:
293
- """Build Docker image directly without supersetbot dependency
294
-
295
- Args:
296
- pr_number: PR number for tagging
297
- sha: Full commit SHA
298
- dry_run: If True, print command but don't execute
299
-
300
- Returns:
301
- True if build succeeded, False if failed
302
- """
303
- tag = f"apache/superset:pr-{pr_number}-{short_sha(sha)}-ci"
304
-
305
- cmd = [
306
- "docker",
307
- "buildx",
308
- "build",
309
- "--push",
310
- "--load",
311
- "--platform",
312
- "linux/amd64",
313
- "--target",
314
- "ci",
315
- "--cache-from",
316
- "type=registry,ref=apache/superset-cache:3.10-slim-bookworm",
317
- "--cache-to",
318
- "type=registry,mode=max,ref=apache/superset-cache:3.10-slim-bookworm",
319
- "--build-arg",
320
- "INCLUDE_CHROMIUM=false",
321
- "--build-arg",
322
- "LOAD_EXAMPLES_DUCKDB=true",
323
- "-t",
324
- tag,
325
- ".",
326
- ]
327
-
328
- console.print(f"🐳 Building Docker image: {tag}")
329
- if dry_run:
330
- console.print(f"🎪 [bold yellow]DRY-RUN[/bold yellow] - Would run: {' '.join(cmd)}")
331
- return True
332
-
333
- try:
334
- console.print(f"🎪 Running: {' '.join(cmd)}")
335
- console.print("🎪 Streaming Docker build output...")
336
-
337
- # Stream output in real-time for better user experience
338
- import time
339
-
340
- process = subprocess.Popen(
341
- cmd,
342
- stdout=subprocess.PIPE,
343
- stderr=subprocess.STDOUT,
344
- text=True,
345
- bufsize=1,
346
- universal_newlines=True,
347
- )
348
-
349
- # Stream output line by line with progress monitoring
350
- last_output_time = time.time()
351
- progress_timeout = 1800 # 30 min without output = stuck
352
-
353
- for line in process.stdout:
354
- console.print(f"🐳 {line.rstrip()}")
355
- last_output_time = time.time() # Reset timeout on any output
356
-
357
- # Check if process finished or we need to wait
358
- if process.poll() is None:
359
- # Process still running, wait with timeout
360
- try:
361
- return_code = process.wait(timeout=300) # 5 min final wait
362
- except subprocess.TimeoutExpired:
363
- process.kill()
364
- console.print("🎪 ❌ Docker build hung - no progress for 30+ minutes")
365
- return False
366
- else:
367
- return_code = process.returncode
368
-
369
- if return_code == 0:
370
- console.print(f"🎪 ✅ Docker build succeeded: {tag}")
371
- return True
372
- else:
373
- console.print(f"🎪 ❌ Docker build failed with exit code: {return_code}")
374
- return False
375
- except Exception as e:
376
- console.print(f"🎪 ❌ Docker build error: {e}")
377
- return False
378
-
379
-
380
- def _set_state_internal(
381
- state: str,
382
- pr_number: int,
383
- show: Show,
384
- github: GitHubInterface,
385
- dry_run_github: bool = False,
386
- error_msg: Optional[str] = None,
387
- ) -> None:
388
- """Internal helper to set state and handle comments/labels
389
-
390
- Used by sync and other commands to set final state transitions
391
- """
392
- console.print(f"🎪 Setting state to '{state}' for PR #{pr_number} SHA {show.sha}")
393
-
394
- # Update show state
395
- show.status = state
396
-
397
- # Handle state-specific logic
398
- comment_text = None
399
-
400
- if state == "building":
401
- comment_text = building_comment(show)
402
- console.print("🎪 Posting building comment...")
403
-
404
- elif state == "running":
405
- comment_text = success_comment(show)
406
- console.print("🎪 Posting success comment...")
407
-
408
- elif state == "failed":
409
- error_message = error_msg or "Build or deployment failed"
410
- comment_text = failure_comment(show, error_message)
411
- console.print("🎪 Posting failure comment...")
412
-
413
- elif state in ["built", "deploying"]:
414
- console.print(f"🎪 Silent state change to '{state}' - no comment posted")
415
-
416
- # Post comment if needed
417
- if comment_text and not dry_run_github:
418
- github.post_comment(pr_number, comment_text)
419
- console.print("🎪 ✅ Comment posted!")
420
- elif comment_text:
421
- console.print("🎪 [bold yellow]DRY-RUN-GITHUB[/bold yellow] - Would post comment")
422
-
423
- # Set state labels
424
- state_labels = show.to_circus_labels()
425
-
426
- if not dry_run_github:
427
- github.remove_circus_labels(pr_number)
428
- for label in state_labels:
429
- github.add_label(pr_number, label)
430
- console.print("🎪 ✅ Labels updated!")
431
- else:
432
- console.print("🎪 [bold yellow]DRY-RUN-GITHUB[/bold yellow] - Would update labels")
84
+ return "🎪 *Managed by [Superset Showtime](https://github.com/your-org/superset-showtime)*"
433
85
 
434
86
 
435
87
  @app.command()
436
- def version():
88
+ def version() -> None:
437
89
  """Show version information"""
438
90
  from . import __version__
439
91
 
440
- console.print(f"🎪 Superset Showtime v{__version__}")
441
-
442
-
443
- @app.command()
444
- def set_state(
445
- state: str = typer.Argument(
446
- ..., help="State to set (building, built, deploying, running, failed)"
447
- ),
448
- pr_number: int = typer.Argument(..., help="PR number to update"),
449
- sha: Optional[str] = typer.Option(None, "--sha", help="Specific commit SHA (default: latest)"),
450
- error_msg: Optional[str] = typer.Option(
451
- None, "--error-msg", help="Error message for failed state"
452
- ),
453
- dry_run_github: bool = typer.Option(
454
- False, "--dry-run-github", help="Skip GitHub operations, show what would happen"
455
- ),
456
- ):
457
- """🎪 Set environment state (generic state transition command)
458
-
459
- States:
460
- • building - Docker image is being built (posts comment)
461
- • built - Docker build complete, ready for deployment (silent)
462
- • deploying - AWS deployment in progress (silent)
463
- • running - Environment is live and ready (posts success comment)
464
- • failed - Build or deployment failed (posts error comment)
465
- """
466
- from datetime import datetime
467
-
468
- try:
469
- github = GitHubInterface()
470
-
471
- # Get SHA - use provided SHA or default to latest
472
- if sha:
473
- target_sha = sha
474
- console.print(f"🎪 Using specified SHA: {target_sha[:7]}")
475
- else:
476
- target_sha = github.get_latest_commit_sha(pr_number)
477
- console.print(f"🎪 Using latest SHA: {target_sha[:7]}")
478
-
479
- # Validate state
480
- valid_states = ["building", "built", "deploying", "running", "failed"]
481
- if state not in valid_states:
482
- console.print(f"❌ Invalid state: {state}. Must be one of: {', '.join(valid_states)}")
483
- raise typer.Exit(1)
484
-
485
- # Get GitHub actor
486
- github_actor = _get_github_actor()
487
-
488
- # Create or update show object
489
- show = Show(
490
- pr_number=pr_number,
491
- sha=short_sha(target_sha),
492
- status=state,
493
- created_at=datetime.utcnow().strftime("%Y-%m-%dT%H-%M"),
494
- ttl="24h",
495
- requested_by=github_actor,
496
- )
497
-
498
- # Use internal helper to set state
499
- _set_state_internal(state, pr_number, show, github, dry_run_github, error_msg)
500
-
501
- console.print(f"🎪 [bold green]State successfully set to '{state}'[/bold green]")
502
-
503
- except GitHubError as e:
504
- console.print(f"❌ GitHub error: {e}")
505
- raise typer.Exit(1) from e
506
- except Exception as e:
507
- console.print(f"❌ Error: {e}")
508
- raise typer.Exit(1) from e
92
+ p(f"🎪 Superset Showtime v{__version__}")
509
93
 
510
94
 
511
95
  @app.command()
@@ -528,73 +112,77 @@ def start(
528
112
  force: bool = typer.Option(
529
113
  False, "--force", help="Force re-deployment by deleting existing service"
530
114
  ),
531
- ):
115
+ ) -> None:
532
116
  """Create ephemeral environment for PR"""
533
117
  try:
534
- github = GitHubInterface()
535
-
536
- # Get SHA - use provided SHA or default to latest
537
- if not sha:
538
- sha = github.get_latest_commit_sha(pr_number)
539
- console.print(f"🎪 Using latest SHA: {sha[:7]}")
118
+ pr = PullRequest.from_id(pr_number)
119
+
120
+ # Check if working environment already exists (unless force)
121
+ if pr.current_show and pr.current_show.status not in ["failed"] and not force:
122
+ p(f"🎪 [bold yellow]Environment already exists for PR #{pr_number}[/bold yellow]")
123
+ ip_info = f" at {pr.current_show.ip}" if pr.current_show.ip else ""
124
+ p(f"Current: {pr.current_show.sha}{ip_info} ({pr.current_show.status})")
125
+ p("Use 'showtime sync' to update or 'showtime stop' to clean up first")
126
+ return
127
+
128
+ # Handle failed environment replacement
129
+ if pr.current_show and pr.current_show.status == "failed":
130
+ p(f"🎪 [bold orange]Replacing failed environment for PR #{pr_number}[/bold orange]")
131
+ p(f"Failed: {pr.current_show.sha} at {pr.current_show.created_at}")
132
+ p("🔄 Creating new environment...")
133
+ elif pr.current_show:
134
+ p(f"🎪 [bold blue]Creating environment for PR #{pr_number}[/bold blue]")
540
135
  else:
541
- console.print(f"🎪 Using specified SHA: {sha[:7]}")
136
+ p(f"🎪 [bold green]Creating new environment for PR #{pr_number}[/bold green]")
542
137
 
543
138
  if dry_run:
544
- console.print("🎪 [bold yellow]DRY RUN[/bold yellow] - Would create environment:")
545
- console.print(f" PR: #{pr_number}")
546
- console.print(f" SHA: {sha[:7]}")
547
- console.print(f" AWS Service: pr-{pr_number}-{sha[:7]}")
548
- console.print(f" TTL: {ttl}")
549
- console.print(" Labels to add:")
550
- console.print(" 🎪 🚦 building")
551
- console.print(f" 🎪 🎯 {sha[:7]}")
552
- console.print(f" 🎪 ⌛ {ttl}")
139
+ from .core.pull_request import get_github
140
+
141
+ target_sha = sha or get_github().get_latest_commit_sha(pr_number)
142
+ p("🎪 [bold yellow]DRY RUN[/bold yellow] - Would create environment:")
143
+ p(f" PR: #{pr_number}")
144
+ p(f" SHA: {target_sha[:7]}")
145
+ p(f" AWS Service: pr-{pr_number}-{target_sha[:7]}")
146
+ p(f" TTL: {ttl}")
553
147
  return
554
148
 
555
- # Check if environment already exists
556
- pr = PullRequest.from_id(pr_number, github)
557
- if pr.current_show:
558
- console.print(
559
- f"🎪 [bold yellow]Environment already exists for PR #{pr_number}[/bold yellow]"
560
- )
561
- console.print(f"Current: {pr.current_show.sha} at {pr.current_show.ip}")
562
- console.print("Use 'showtime sync' to update or 'showtime stop' to clean up first")
563
- return
149
+ # Use PullRequest method for all logic
150
+ result = pr.start_environment(sha=sha, dry_run_github=False, dry_run_aws=dry_run_aws)
564
151
 
565
- # Create environment using trigger handler logic
566
- console.print(f"🎪 [bold blue]Creating environment for PR #{pr_number}...[/bold blue]")
567
- _handle_start_trigger(
568
- pr_number, github, dry_run_aws, (dry_run or False), aws_sleep, docker_tag, force
569
- )
152
+ if result.success:
153
+ if result.show:
154
+ p(f"🎪 ✅ Environment created: {result.show.sha}")
155
+ else:
156
+ p("🎪 ✅ Environment created")
157
+ else:
158
+ p(f"🎪 ❌ Failed to create environment: {result.error}")
159
+ raise typer.Exit(1)
570
160
 
571
161
  except GitHubError as e:
572
- console.print(f"🎪 [bold red]GitHub error:[/bold red] {e.message}")
162
+ p(f" GitHub error: {e}")
163
+ raise typer.Exit(1) from e
573
164
  except Exception as e:
574
- console.print(f"🎪 [bold red]Error:[/bold red] {e}")
165
+ p(f" Error: {e}")
166
+ raise typer.Exit(1) from e
575
167
 
576
168
 
577
169
  @app.command()
578
170
  def status(
579
171
  pr_number: int = typer.Argument(..., help="PR number to check status for"),
580
172
  verbose: bool = typer.Option(False, "-v", "--verbose", help="Show detailed information"),
581
- ):
173
+ ) -> None:
582
174
  """Show environment status for PR"""
583
175
  try:
584
- github = GitHubInterface()
176
+ pr = PullRequest.from_id(pr_number)
585
177
 
586
- pr = PullRequest.from_id(pr_number, github)
178
+ # Use PullRequest method for data
179
+ status_data = pr.get_status()
587
180
 
588
- if not pr.has_shows():
589
- console.print(f"🎪 No environment found for PR #{pr_number}")
181
+ if status_data["status"] == "no_environment":
182
+ p(f"🎪 No environment found for PR #{pr_number}")
590
183
  return
591
184
 
592
- show = pr.current_show
593
- if not show:
594
- console.print(f"🎪 No active environment for PR #{pr_number}")
595
- if pr.building_show:
596
- console.print(f"🏗️ Building environment: {pr.building_show.sha}")
597
- return
185
+ show_data = status_data["show"]
598
186
 
599
187
  # Create status table
600
188
  table = Table(title=f"🎪 Environment Status - PR #{pr_number}")
@@ -602,37 +190,37 @@ def status(
602
190
  table.add_column("Value", style="white")
603
191
 
604
192
  status_emoji = STATUS_DISPLAY
193
+ table.add_row(
194
+ "Status", f"{status_emoji.get(show_data['status'], '❓')} {show_data['status'].title()}"
195
+ )
196
+ table.add_row("Environment", f"`{show_data['sha']}`")
197
+ table.add_row("AWS Service", f"`{show_data['aws_service_name']}`")
605
198
 
606
- table.add_row("Status", f"{status_emoji.get(show.status, '❓')} {show.status.title()}")
607
- table.add_row("Environment", f"`{show.sha}`")
608
- table.add_row("AWS Service", f"`{show.aws_service_name}`")
609
-
610
- if show.ip:
611
- table.add_row("URL", f"http://{show.ip}:8080")
612
-
613
- if show.created_at:
614
- table.add_row("Created", show.created_at)
199
+ if show_data["ip"]:
200
+ table.add_row("URL", f"http://{show_data['ip']}:8080")
201
+ if show_data["created_at"]:
202
+ table.add_row("Created", show_data["created_at"])
615
203
 
616
- table.add_row("TTL", show.ttl)
204
+ table.add_row("TTL", show_data["ttl"])
617
205
 
618
- if show.requested_by:
619
- table.add_row("Requested by", f"@{show.requested_by}")
206
+ if show_data["requested_by"]:
207
+ table.add_row("Requested by", f"@{show_data['requested_by']}")
620
208
 
621
209
  if verbose:
622
210
  table.add_row("All Labels", ", ".join(pr.circus_labels))
623
211
 
624
- console.print(table)
212
+ p(table)
625
213
 
626
214
  # Show building environment if exists
627
- if pr.building_show and pr.building_show != show:
628
- console.print(
629
- f"🏗️ [bold yellow]Building new environment:[/bold yellow] {pr.building_show.sha}"
630
- )
215
+ if pr.building_show and pr.building_show.sha != show_data["sha"]:
216
+ p(f"🏗️ [bold yellow]Building new environment:[/bold yellow] {pr.building_show.sha}")
631
217
 
632
218
  except GitHubError as e:
633
- console.print(f"🎪 [bold red]GitHub error:[/bold red] {e.message}")
219
+ p(f" GitHub error: {e}")
220
+ raise typer.Exit(1) from e
634
221
  except Exception as e:
635
- console.print(f"🎪 [bold red]Error:[/bold red] {e}")
222
+ p(f" Error: {e}")
223
+ raise typer.Exit(1) from e
636
224
 
637
225
 
638
226
  @app.command()
@@ -644,114 +232,47 @@ def stop(
644
232
  False, "--dry-run-aws", help="Skip AWS operations, use mock data"
645
233
  ),
646
234
  aws_sleep: int = typer.Option(0, "--aws-sleep", help="Seconds to sleep during AWS operations"),
647
- ):
235
+ ) -> None:
648
236
  """Delete environment for PR"""
649
237
  try:
650
- github = GitHubInterface()
651
-
652
- pr = PullRequest.from_id(pr_number, github)
238
+ pr = PullRequest.from_id(pr_number)
653
239
 
654
240
  if not pr.current_show:
655
- console.print(f"🎪 No active environment found for PR #{pr_number}")
241
+ p(f"🎪 No active environment found for PR #{pr_number}")
656
242
  return
657
243
 
658
244
  show = pr.current_show
659
- console.print(f"🎪 [bold yellow]Stopping environment for PR #{pr_number}...[/bold yellow]")
660
- console.print(f"Environment: {show.sha} at {show.ip}")
245
+ p(f"🎪 [bold yellow]Stopping environment for PR #{pr_number}...[/bold yellow]")
246
+ p(f"Environment: {show.sha} at {show.ip}")
661
247
 
662
248
  if dry_run:
663
- console.print("🎪 [bold yellow]DRY RUN[/bold yellow] - Would delete environment:")
664
- console.print(f" AWS Service: {show.aws_service_name}")
665
- console.print(f" ECR Image: {show.aws_image_tag}")
666
- console.print(f" Circus Labels: {len(pr.circus_labels)} labels")
249
+ p("🎪 [bold yellow]DRY RUN[/bold yellow] - Would delete environment:")
250
+ p(f" AWS Service: {show.aws_service_name}")
251
+ p(f" ECR Image: {show.aws_image_tag}")
252
+ p(f" Circus Labels: {len(pr.circus_labels)} labels")
667
253
  return
668
254
 
669
255
  if not force:
670
256
  confirm = typer.confirm(f"Delete environment {show.aws_service_name}?")
671
257
  if not confirm:
672
- console.print("🎪 Cancelled")
258
+ p("🎪 Cancelled")
673
259
  return
674
260
 
675
- if dry_run_aws:
676
- console.print("🎪 [bold yellow]DRY-RUN-AWS[/bold yellow] - Would delete AWS resources:")
677
- console.print(f" - ECS service: {show.aws_service_name}")
678
- console.print(f" - ECR image: {show.aws_image_tag}")
679
- if aws_sleep > 0:
680
- import time
261
+ # Use PullRequest method for all logic
262
+ result = pr.stop_environment(dry_run_github=False, dry_run_aws=dry_run_aws)
681
263
 
682
- console.print(f"🎪 Sleeping {aws_sleep}s to simulate AWS cleanup...")
683
- time.sleep(aws_sleep)
684
- console.print("🎪 [bold green]Mock AWS cleanup complete![/bold green]")
264
+ if result.success:
265
+ p("🎪 ✅ Environment stopped and cleaned up!")
685
266
  else:
686
- # Real AWS cleanup
687
- from .core.aws import AWSInterface
688
-
689
- console.print("🎪 [bold blue]Starting AWS cleanup...[/bold blue]")
690
- aws = AWSInterface()
691
-
692
- try:
693
- # Get current environment info
694
- pr = PullRequest.from_id(pr_number, github)
695
-
696
- if pr.current_show:
697
- show = pr.current_show
698
-
699
- # Show logs URL for monitoring cleanup
700
- _show_service_urls(show, "cleanup")
701
- console.print(f"🎪 Destroying environment: {show.aws_service_name}")
702
-
703
- # Step 1: Check if ECS service exists and is active
704
- service_name = show.ecs_service_name
705
- console.print(f"🎪 Checking ECS service: {service_name}")
706
-
707
- service_exists = aws._service_exists(service_name)
708
-
709
- if service_exists:
710
- console.print(f"🎪 Found active ECS service: {service_name}")
711
-
712
- # Step 2: Delete ECS service
713
- console.print("🎪 Deleting ECS service...")
714
- success = aws._delete_ecs_service(service_name)
715
-
716
- if success:
717
- console.print("🎪 ✅ ECS service deleted successfully")
718
-
719
- # Step 3: Delete ECR image tag
720
- image_tag = f"pr-{pr_number}-ci" # Match GHA image tag format
721
- console.print(f"🎪 Deleting ECR image tag: {image_tag}")
722
-
723
- ecr_success = aws._delete_ecr_image(image_tag)
724
-
725
- if ecr_success:
726
- console.print("🎪 ✅ ECR image deleted successfully")
727
- else:
728
- console.print("🎪 ⚠️ ECR image deletion failed (may not exist)")
729
-
730
- console.print(
731
- "🎪 [bold green]✅ AWS cleanup completed successfully![/bold green]"
732
- )
733
-
734
- else:
735
- console.print("🎪 [bold red]❌ ECS service deletion failed[/bold red]")
736
-
737
- else:
738
- console.print(f"🎪 No active ECS service found: {service_name}")
739
- console.print("🎪 ✅ No AWS resources to clean up")
740
- else:
741
- console.print(f"🎪 No active environment found for PR #{pr_number}")
742
-
743
- except Exception as e:
744
- console.print(f"🎪 [bold red]❌ AWS cleanup failed:[/bold red] {e}")
745
-
746
- # Remove circus labels
747
- github.remove_circus_labels(pr_number)
748
-
749
- console.print("🎪 [bold green]Environment stopped and labels cleaned up![/bold green]")
267
+ p(f"🎪 Failed to stop environment: {result.error}")
268
+ raise typer.Exit(1)
750
269
 
751
270
  except GitHubError as e:
752
- console.print(f"🎪 [bold red]GitHub error:[/bold red] {e.message}")
271
+ p(f" GitHub error: {e}")
272
+ raise typer.Exit(1) from e
753
273
  except Exception as e:
754
- console.print(f"🎪 [bold red]Error:[/bold red] {e}")
274
+ p(f" Error: {e}")
275
+ raise typer.Exit(1) from e
755
276
 
756
277
 
757
278
  @app.command()
@@ -760,37 +281,33 @@ def list(
760
281
  None, "--status", help="Filter by status (running, building, etc.)"
761
282
  ),
762
283
  user: Optional[str] = typer.Option(None, "--user", help="Filter by user"),
763
- ):
284
+ ) -> None:
764
285
  """List all environments"""
765
286
  try:
766
- github = GitHubInterface()
287
+ # Use PullRequest method for data collection
288
+ all_environments = PullRequest.list_all_environments()
767
289
 
768
- # Find all PRs with circus tent labels
769
- pr_numbers = github.find_prs_with_shows()
770
-
771
- if not pr_numbers:
772
- console.print("🎪 No environments currently running")
290
+ if not all_environments:
291
+ p("🎪 No environments currently running")
773
292
  return
774
293
 
775
- # Collect all shows
776
- all_shows = []
777
- for pr_number in pr_numbers:
778
- pr = PullRequest.from_id(pr_number, github)
779
- for show in pr.shows:
780
- # Apply filters
781
- if status_filter and show.status != status_filter:
782
- continue
783
- if user and show.requested_by != user:
784
- continue
785
- all_shows.append(show)
786
-
787
- if not all_shows:
294
+ # Apply filters
295
+ filtered_envs = []
296
+ for env in all_environments:
297
+ show_data = env["show"]
298
+ if status_filter and show_data["status"] != status_filter:
299
+ continue
300
+ if user and show_data["requested_by"] != user:
301
+ continue
302
+ filtered_envs.append(env)
303
+
304
+ if not filtered_envs:
788
305
  filter_msg = ""
789
306
  if status_filter:
790
307
  filter_msg += f" with status '{status_filter}'"
791
308
  if user:
792
309
  filter_msg += f" by user '{user}'"
793
- console.print(f"🎪 No environments found{filter_msg}")
310
+ p(f"🎪 No environments found{filter_msg}")
794
311
  return
795
312
 
796
313
  # Create table with full terminal width
@@ -805,50 +322,56 @@ def list(
805
322
 
806
323
  status_emoji = STATUS_DISPLAY
807
324
 
808
- for show in sorted(all_shows, key=lambda s: s.pr_number):
325
+ for env in sorted(filtered_envs, key=lambda e: e["pr_number"]):
326
+ show_data = env["show"]
327
+ pr_number = env["pr_number"]
809
328
  # Make Superset URL clickable and show full URL
810
- if show.ip:
811
- full_url = f"http://{show.ip}:8080"
329
+ if show_data["ip"]:
330
+ full_url = f"http://{show_data['ip']}:8080"
812
331
  superset_url = f"[link={full_url}]{full_url}[/link]"
813
332
  else:
814
333
  superset_url = "-"
815
334
 
816
335
  # Get AWS service URLs - iTerm2 supports Rich clickable links
817
- aws_urls = _get_service_urls(show)
336
+ from .core.github_messages import get_aws_console_urls
337
+
338
+ aws_urls = get_aws_console_urls(show_data["aws_service_name"])
818
339
  aws_logs_link = f"[link={aws_urls['logs']}]View[/link]"
819
340
 
820
341
  # Make PR number clickable
821
- pr_url = f"https://github.com/apache/superset/pull/{show.pr_number}"
822
- clickable_pr = f"[link={pr_url}]{show.pr_number}[/link]"
342
+ pr_url = f"https://github.com/apache/superset/pull/{pr_number}"
343
+ clickable_pr = f"[link={pr_url}]{pr_number}[/link]"
823
344
 
824
345
  table.add_row(
825
346
  clickable_pr,
826
- f"{status_emoji.get(show.status, '❓')} {show.status}",
827
- show.sha,
347
+ f"{status_emoji.get(show_data['status'], '❓')} {show_data['status']}",
348
+ show_data["sha"],
828
349
  superset_url,
829
350
  aws_logs_link,
830
- show.ttl,
831
- f"@{show.requested_by}" if show.requested_by else "-",
351
+ show_data["ttl"],
352
+ f"@{show_data['requested_by']}" if show_data["requested_by"] else "-",
832
353
  )
833
354
 
834
- console.print(table)
355
+ p(table)
835
356
 
836
357
  except GitHubError as e:
837
- console.print(f"🎪 [bold red]GitHub error:[/bold red] {e.message}")
358
+ p(f" GitHub error: {e}")
359
+ raise typer.Exit(1) from e
838
360
  except Exception as e:
839
- console.print(f"🎪 [bold red]Error:[/bold red] {e}")
361
+ p(f" Error: {e}")
362
+ raise typer.Exit(1) from e
840
363
 
841
364
 
842
365
  @app.command()
843
- def labels():
366
+ def labels() -> None:
844
367
  """🎪 Show complete circus tent label reference"""
845
368
  from .core.label_colors import LABEL_DEFINITIONS
846
369
 
847
- console.print("🎪 [bold blue]Circus Tent Label Reference[/bold blue]")
848
- console.print()
370
+ p("🎪 [bold blue]Circus Tent Label Reference[/bold blue]")
371
+ p()
849
372
 
850
373
  # User Action Labels (from LABEL_DEFINITIONS)
851
- console.print("[bold yellow]🎯 User Action Labels (Add these to GitHub PR):[/bold yellow]")
374
+ p("[bold yellow]🎯 User Action Labels (Add these to GitHub PR):[/bold yellow]")
852
375
  trigger_table = Table()
853
376
  trigger_table.add_column("Label", style="green")
854
377
  trigger_table.add_column("Description", style="dim")
@@ -856,11 +379,11 @@ def labels():
856
379
  for label_name, definition in LABEL_DEFINITIONS.items():
857
380
  trigger_table.add_row(f"`{label_name}`", definition["description"])
858
381
 
859
- console.print(trigger_table)
860
- console.print()
382
+ p(trigger_table)
383
+ p()
861
384
 
862
385
  # State Labels
863
- console.print("[bold cyan]📊 State Labels (Automatically managed):[/bold cyan]")
386
+ p("[bold cyan]📊 State Labels (Automatically managed):[/bold cyan]")
864
387
  state_table = Table()
865
388
  state_table.add_column("Label", style="cyan")
866
389
  state_table.add_column("Meaning", style="white")
@@ -876,93 +399,43 @@ def labels():
876
399
  state_table.add_row("🎪 {sha} ⌛ {ttl-policy}", "TTL policy", "🎪 abc123f ⌛ 24h")
877
400
  state_table.add_row("🎪 {sha} 🤡 {username}", "Requested by", "🎪 abc123f 🤡 maxime")
878
401
 
879
- console.print(state_table)
880
- console.print()
402
+ p(state_table)
403
+ p()
881
404
 
882
405
  # Workflow Examples
883
- console.print("[bold magenta]🎪 Complete Workflow Examples:[/bold magenta]")
884
- console.print()
406
+ p("[bold magenta]🎪 Complete Workflow Examples:[/bold magenta]")
407
+ p()
885
408
 
886
- console.print("[bold]1. Create Environment:[/bold]")
887
- console.print(" • Add label: [green]🎪 ⚡ showtime-trigger-start[/green]")
888
- console.print(
889
- " • Watch for: [blue]🎪 abc123f 🚦 building[/blue] → [green]🎪 abc123f 🚦 running[/green]"
890
- )
891
- console.print(
892
- " • Get URL from: [cyan]🎪 abc123f 🌐 52.1.2.3:8080[/cyan] → http://52.1.2.3:8080"
893
- )
894
- console.print()
895
-
896
- console.print("[bold]2. Freeze Environment (Optional):[/bold]")
897
- console.print(" • Add label: [orange]🎪 🧊 showtime-freeze[/orange]")
898
- console.print(" • Result: Environment won't auto-update on new commits")
899
- console.print(" • Use case: Test specific SHA while continuing development")
900
- console.print()
901
-
902
- console.print("[bold]3. Update to New Commit (Automatic):[/bold]")
903
- console.print(" • New commit pushed → Automatic blue-green rolling update")
904
- console.print(
905
- " • Watch for: [blue]🎪 abc123f 🚦 updating[/blue] → [green]🎪 def456a 🚦 running[/green]"
906
- )
907
- console.print(" • SHA changes: [cyan]🎪 🎯 abc123f[/cyan] → [cyan]🎪 🎯 def456a[/cyan]")
908
- console.print()
409
+ p("[bold]1. Create Environment:[/bold]")
410
+ p(" • Add label: [green]🎪 ⚡ showtime-trigger-start[/green]")
411
+ p(" • Watch for: [blue]🎪 abc123f 🚦 building[/blue] → [green]🎪 abc123f 🚦 running[/green]")
412
+ p(" • Get URL from: [cyan]🎪 abc123f 🌐 52.1.2.3:8080[/cyan] → http://52.1.2.3:8080")
413
+ p()
909
414
 
910
- console.print("[bold]4. Clean Up:[/bold]")
911
- console.print(" • Add label: [red]🎪 🛑 showtime-trigger-stop[/red]")
912
- console.print(" • Result: All 🎪 labels removed, AWS resources deleted")
913
- console.print()
415
+ p("[bold]2. Freeze Environment (Optional):[/bold]")
416
+ p(" • Add label: [orange]🎪 🧊 showtime-freeze[/orange]")
417
+ p(" • Result: Environment won't auto-update on new commits")
418
+ p(" • Use case: Test specific SHA while continuing development")
419
+ p()
914
420
 
915
- console.print("[bold]📊 Understanding State:[/bold]")
916
- console.print("• [dim]TTL labels show policy (24h, 48h, close) not time remaining[/dim]")
917
- console.print("• [dim]Use 'showtime status {pr-id}' to calculate actual time remaining[/dim]")
918
- console.print("• [dim]Multiple SHA labels during updates (🎯 active, 🏗️ building)[/dim]")
919
- console.print()
421
+ p("[bold]3. Update to New Commit (Automatic):[/bold]")
422
+ p(" New commit pushed Automatic blue-green rolling update")
423
+ p(" Watch for: [blue]🎪 abc123f 🚦 updating[/blue] [green]🎪 def456a 🚦 running[/green]")
424
+ p(" SHA changes: [cyan]🎪 🎯 abc123f[/cyan] [cyan]🎪 🎯 def456a[/cyan]")
425
+ p()
920
426
 
921
- console.print("[dim]💡 Tip: Only maintainers with write access can add trigger labels[/dim]")
427
+ p("[bold]4. Clean Up:[/bold]")
428
+ p(" • Add label: [red]🎪 🛑 showtime-trigger-stop[/red]")
429
+ p(" • Result: All 🎪 labels removed, AWS resources deleted")
430
+ p()
922
431
 
432
+ p("[bold]📊 Understanding State:[/bold]")
433
+ p("• [dim]TTL labels show policy (24h, 48h, close) not time remaining[/dim]")
434
+ p("• [dim]Use 'showtime status {pr-id}' to calculate actual time remaining[/dim]")
435
+ p("• [dim]Multiple SHA labels during updates (🎯 active, 🏗️ building)[/dim]")
436
+ p()
923
437
 
924
- @app.command()
925
- def test_lifecycle(
926
- pr_number: int,
927
- dry_run_aws: bool = typer.Option(
928
- True, "--dry-run-aws/--real-aws", help="Use mock AWS operations"
929
- ),
930
- dry_run_github: bool = typer.Option(
931
- True, "--dry-run-github/--real-github", help="Use mock GitHub operations"
932
- ),
933
- aws_sleep: int = typer.Option(10, "--aws-sleep", help="Seconds to sleep during AWS operations"),
934
- ):
935
- """🎪 Test full environment lifecycle with mock triggers"""
936
-
937
- console.print(f"🎪 [bold blue]Testing full lifecycle for PR #{pr_number}[/bold blue]")
938
- console.print(
939
- f"AWS: {'DRY-RUN' if dry_run_aws else 'REAL'}, GitHub: {'DRY-RUN' if dry_run_github else 'REAL'}"
940
- )
941
- console.print()
942
-
943
- try:
944
- github = GitHubInterface()
945
-
946
- console.print("🎪 [bold]Step 1: Simulate trigger-start[/bold]")
947
- _handle_start_trigger(pr_number, github, dry_run_aws, dry_run_github, aws_sleep, None)
948
-
949
- console.print()
950
- console.print("🎪 [bold]Step 2: Simulate config update[/bold]")
951
- console.print("🎪 [dim]Config changes now done via code commits, not labels[/dim]")
952
-
953
- console.print()
954
- console.print("🎪 [bold]Step 3: Simulate trigger-sync (new commit)[/bold]")
955
- _handle_sync_trigger(pr_number, github, dry_run_aws, dry_run_github, aws_sleep)
956
-
957
- console.print()
958
- console.print("🎪 [bold]Step 4: Simulate trigger-stop[/bold]")
959
- _handle_stop_trigger(pr_number, github, dry_run_aws, dry_run_github)
960
-
961
- console.print()
962
- console.print("🎪 [bold green]Full lifecycle test complete![/bold green]")
963
-
964
- except Exception as e:
965
- console.print(f"🎪 [bold red]Lifecycle test failed:[/bold red] {e}")
438
+ p("[dim]💡 Tip: Only maintainers with write access can add trigger labels[/dim]")
966
439
 
967
440
 
968
441
  @app.command()
@@ -987,224 +460,118 @@ def sync(
987
460
  docker_tag: Optional[str] = typer.Option(
988
461
  None, "--docker-tag", help="Override Docker image tag (e.g., pr-34639-9a82c20-ci, latest)"
989
462
  ),
990
- ):
463
+ ) -> None:
991
464
  """🎪 Intelligently sync PR to desired state (called by GitHub Actions)"""
992
465
  try:
993
- github = GitHubInterface()
994
- pr = PullRequest.from_id(pr_number, github)
995
-
996
- # Get PR metadata for state-based decisions
997
- pr_data = github.get_pr_data(pr_number)
998
- pr_state = pr_data.get("state", "open") # open, closed
466
+ # Use singletons - no interface creation needed
467
+ pr = PullRequest.from_id(pr_number)
999
468
 
1000
- # Get SHA - use provided SHA or default to latest
469
+ # Get target SHA - use provided SHA or default to latest
1001
470
  if sha:
1002
471
  target_sha = sha
1003
- console.print(f"🎪 Using specified SHA: {target_sha[:7]}")
472
+ p(f"🎪 Using specified SHA: {target_sha[:7]}")
1004
473
  else:
1005
- target_sha = github.get_latest_commit_sha(pr_number)
1006
- console.print(f"🎪 Using latest SHA: {target_sha[:7]}")
474
+ from .core.pull_request import get_github
475
+
476
+ target_sha = get_github().get_latest_commit_sha(pr_number)
477
+ p(f"🎪 Using latest SHA: {target_sha[:7]}")
478
+
479
+ # Get PR state for analysis
480
+ from .core.pull_request import get_github
1007
481
 
1008
- # Determine what actions are needed
1009
- action_needed = _determine_sync_action(pr, pr_state, target_sha)
482
+ pr_data = get_github().get_pr_data(pr_number)
483
+ pr_state = pr_data.get("state", "open")
1010
484
 
1011
485
  if check_only:
1012
- # Output simple results for GitHub Actions
1013
- build_needed = action_needed in ["create_environment", "rolling_update", "auto_sync"]
1014
- sync_needed = action_needed != "no_action"
1015
-
1016
- console.print(f"build_needed={str(build_needed).lower()}")
1017
- console.print(f"sync_needed={str(sync_needed).lower()}")
1018
- console.print(f"pr_number={pr_number}")
1019
- console.print(f"target_sha={target_sha}")
486
+ # Analysis mode - just return what's needed
487
+ analysis_result = pr.analyze(target_sha, pr_state)
488
+ p(f"build_needed={str(analysis_result.build_needed).lower()}")
489
+ p(f"sync_needed={str(analysis_result.sync_needed).lower()}")
490
+ p(f"pr_number={pr_number}")
491
+ p(f"target_sha={target_sha}")
1020
492
  return
1021
493
 
1022
- # Default behavior: execute the sync (directive command)
1023
- # Use --check-only to override this and do read-only analysis
1024
- if not check_only:
1025
- console.print(
1026
- f"🎪 [bold blue]Syncing PR #{pr_number}[/bold blue] (SHA: {target_sha[:7]})"
1027
- )
1028
- console.print(f"🎪 Action needed: {action_needed}")
1029
-
1030
- # For trigger-based actions, use atomic claim to prevent race conditions
1031
- if action_needed in ["create_environment", "rolling_update", "destroy_environment"]:
1032
- if not _atomic_claim_environment(pr_number, target_sha, github, dry_run_github):
1033
- console.print("🎪 Unable to claim environment - exiting")
1034
- return
1035
- console.print("🎪 ✅ Environment claimed - proceeding with work")
1036
-
1037
- # Execute based on determined action
1038
- if action_needed == "cleanup":
1039
- console.print("🎪 PR is closed - cleaning up environment")
1040
- if pr.current_show:
1041
- _handle_stop_trigger(pr_number, github, dry_run_aws, dry_run_github)
1042
- else:
1043
- console.print("🎪 No environment to clean up")
1044
- return
494
+ # Execution mode - do the sync
495
+ p(f"🎪 [bold blue]Syncing PR #{pr_number}[/bold blue] (SHA: {target_sha[:7]})")
1045
496
 
1046
- elif action_needed in ["create_environment", "rolling_update"]:
1047
- # These require Docker build + deployment
1048
- console.print(f"🎪 Starting {action_needed} workflow")
1049
-
1050
- # Post building comment (atomic claim already set building state)
1051
- if action_needed == "create_environment":
1052
- console.print("🎪 Posting building comment...")
1053
- elif action_needed == "rolling_update":
1054
- console.print("🎪 Posting rolling update comment...")
1055
-
1056
- # Build Docker image
1057
- build_success = _build_docker_image(pr_number, target_sha, dry_run_docker)
1058
- if not build_success:
1059
- _set_state_internal(
1060
- "failed",
1061
- pr_number,
1062
- Show(
1063
- pr_number=pr_number,
1064
- sha=short_sha(target_sha),
1065
- status="failed",
1066
- requested_by=_get_github_actor(),
1067
- ),
1068
- github,
1069
- dry_run_github,
1070
- "Docker build failed",
1071
- )
1072
- return
1073
-
1074
- # Continue with AWS deployment (reuse existing logic)
1075
- _handle_start_trigger(
1076
- pr_number,
1077
- github,
1078
- dry_run_aws,
1079
- dry_run_github,
1080
- aws_sleep,
1081
- docker_tag,
1082
- force=True,
497
+ # Handle closed PRs specially
498
+ if pr_state == "closed":
499
+ p("🎪 PR is closed - cleaning up environment")
500
+ if pr.current_show:
501
+ stop_result = pr.stop_environment(
502
+ dry_run_github=dry_run_github, dry_run_aws=dry_run_aws
1083
503
  )
1084
- return
1085
-
1086
- elif action_needed == "destroy_environment":
1087
- console.print("🎪 Destroying environment")
1088
- _handle_stop_trigger(pr_number, github, dry_run_aws, dry_run_github)
1089
- return
1090
-
1091
- elif action_needed == "auto_sync":
1092
- console.print("🎪 Auto-sync on new commit")
1093
- # This also requires build + deployment
1094
- build_success = _build_docker_image(pr_number, target_sha, dry_run_docker)
1095
- if build_success:
1096
- _handle_sync_trigger(pr_number, github, dry_run_aws, dry_run_github, aws_sleep)
504
+ if stop_result.success:
505
+ p("🎪 ✅ Cleanup completed")
1097
506
  else:
1098
- console.print("🎪 ❌ Auto-sync failed due to build failure")
1099
- return
1100
-
1101
- else:
1102
- console.print(f"🎪 No action needed ({action_needed})")
1103
- return
1104
-
1105
- console.print(
1106
- f"🎪 [bold blue]Syncing PR #{pr_number}[/bold blue] (state: {pr_state}, SHA: {target_sha[:7]})"
1107
- )
1108
- console.print(f"🎪 Action needed: {action_needed}")
1109
-
1110
- # Execute the determined action
1111
- if action_needed == "cleanup":
1112
- console.print("🎪 PR is closed - cleaning up environment")
1113
- if pr.current_show:
1114
- _handle_stop_trigger(pr_number, github, dry_run_aws, dry_run_github)
507
+ p(f"🎪 ❌ Cleanup failed: {stop_result.error}")
1115
508
  else:
1116
- console.print("🎪 No environment to clean up")
509
+ p("🎪 No environment to clean up")
1117
510
  return
1118
511
 
1119
- # 2. Find explicit trigger labels
1120
- trigger_labels = [label for label in pr.labels if "showtime-trigger-" in label]
1121
-
1122
- # 3. Handle explicit triggers first
1123
- if trigger_labels:
1124
- console.print(f"🎪 Processing {len(trigger_labels)} explicit trigger(s)")
1125
-
1126
- for trigger in trigger_labels:
1127
- console.print(f"🎪 Processing: {trigger}")
1128
-
1129
- # Remove trigger label immediately (atomic operation)
1130
- if not dry_run_github:
1131
- github.remove_label(pr_number, trigger)
1132
- else:
1133
- console.print(
1134
- f"🎪 [bold yellow]DRY-RUN-GITHUB[/bold yellow] - Would remove: {trigger}"
1135
- )
1136
-
1137
- # Process the trigger
1138
- if "showtime-trigger-start" in trigger:
1139
- _handle_start_trigger(
1140
- pr_number, github, dry_run_aws, dry_run_github, aws_sleep, docker_tag
1141
- )
1142
- elif "showtime-trigger-stop" in trigger:
1143
- _handle_stop_trigger(pr_number, github, dry_run_aws, dry_run_github)
1144
-
1145
- console.print("🎪 All explicit triggers processed!")
1146
- return
1147
-
1148
- # 4. No explicit triggers - check for implicit sync needs
1149
- console.print("🎪 No explicit triggers found - checking for implicit sync needs")
512
+ # Regular sync for open PRs
513
+ result = pr.sync(
514
+ target_sha,
515
+ dry_run_github=dry_run_github,
516
+ dry_run_aws=dry_run_aws,
517
+ dry_run_docker=dry_run_docker,
518
+ )
1150
519
 
1151
- if pr.current_show:
1152
- # Environment exists - check if it needs updating
1153
- if pr.current_show.needs_update(target_sha):
1154
- console.print(
1155
- f"🎪 Environment outdated ({pr.current_show.sha} → {target_sha[:7]}) - auto-syncing"
1156
- )
1157
- _handle_sync_trigger(pr_number, github, dry_run_aws, dry_run_github, aws_sleep)
1158
- else:
1159
- console.print(f"🎪 Environment is up to date ({pr.current_show.sha})")
520
+ if result.success:
521
+ p(f"🎪 Sync completed: {result.action_taken}")
1160
522
  else:
1161
- console.print(f"🎪 No environment exists for PR #{pr_number} - no action needed")
1162
- console.print("🎪 💡 Add '🎪 trigger-start' label to create an environment")
523
+ p(f"🎪 Sync failed: {result.error}")
524
+ raise typer.Exit(1)
1163
525
 
526
+ except GitHubError as e:
527
+ p(f"❌ GitHub error: {e}")
528
+ raise typer.Exit(1) from e
1164
529
  except Exception as e:
1165
- console.print(f"🎪 [bold red]Error processing triggers:[/bold red] {e}")
530
+ p(f" Error: {e}")
531
+ raise typer.Exit(1) from e
1166
532
 
1167
533
 
1168
534
  @app.command()
1169
- def handle_sync(pr_number: int):
535
+ def handle_sync(pr_number: int) -> None:
1170
536
  """🎪 Handle new commit sync (called by GitHub Actions on PR synchronize)"""
1171
537
  try:
1172
- github = GitHubInterface()
1173
- pr = PullRequest.from_id(pr_number, github)
538
+ pr = PullRequest.from_id(pr_number)
1174
539
 
1175
540
  # Only sync if there's an active environment
1176
541
  if not pr.current_show:
1177
- console.print(f"🎪 No active environment for PR #{pr_number} - skipping sync")
542
+ p(f"🎪 No active environment for PR #{pr_number} - skipping sync")
1178
543
  return
1179
544
 
1180
545
  # Get latest commit SHA
1181
- latest_sha = github.get_latest_commit_sha(pr_number)
546
+ from .core.pull_request import get_github
547
+
548
+ latest_sha = get_github().get_latest_commit_sha(pr_number)
1182
549
 
1183
550
  # Check if update is needed
1184
551
  if not pr.current_show.needs_update(latest_sha):
1185
- console.print(f"🎪 Environment already up to date for PR #{pr_number}")
552
+ p(f"🎪 Environment already up to date for PR #{pr_number}")
1186
553
  return
1187
554
 
1188
- console.print(f"🎪 Syncing PR #{pr_number} to commit {latest_sha[:7]}")
555
+ p(f"🎪 Syncing PR #{pr_number} to commit {latest_sha[:7]}")
1189
556
 
1190
557
  # TODO: Implement rolling update logic
1191
- console.print("🎪 [bold yellow]Sync logic not yet implemented[/bold yellow]")
558
+ p("🎪 [bold yellow]Sync logic not yet implemented[/bold yellow]")
1192
559
 
1193
560
  except Exception as e:
1194
- console.print(f"🎪 [bold red]Error handling sync:[/bold red] {e}")
561
+ p(f"🎪 [bold red]Error handling sync:[/bold red] {e}")
1195
562
 
1196
563
 
1197
564
  @app.command()
1198
565
  def setup_labels(
1199
566
  dry_run: bool = typer.Option(False, "--dry-run", help="Show what labels would be created"),
1200
- ):
567
+ ) -> None:
1201
568
  """🎪 Set up GitHub label definitions with colors and descriptions"""
1202
569
  try:
1203
570
  from .core.label_colors import LABEL_DEFINITIONS
1204
571
 
1205
572
  github = GitHubInterface()
1206
573
 
1207
- console.print("🎪 [bold blue]Setting up circus tent label definitions...[/bold blue]")
574
+ p("🎪 [bold blue]Setting up circus tent label definitions...[/bold blue]")
1208
575
 
1209
576
  created_count = 0
1210
577
  updated_count = 0
@@ -1214,32 +581,32 @@ def setup_labels(
1214
581
  description = definition["description"]
1215
582
 
1216
583
  if dry_run:
1217
- console.print(f"🏷️ Would create: [bold]{label_name}[/bold]")
1218
- console.print(f" Color: #{color}")
1219
- console.print(f" Description: {description}")
584
+ p(f"🏷️ Would create: [bold]{label_name}[/bold]")
585
+ p(f" Color: #{color}")
586
+ p(f" Description: {description}")
1220
587
  else:
1221
588
  try:
1222
589
  # Try to create or update the label
1223
590
  success = github.create_or_update_label(label_name, color, description)
1224
591
  if success:
1225
592
  created_count += 1
1226
- console.print(f"✅ Created: [bold]{label_name}[/bold]")
593
+ p(f"✅ Created: [bold]{label_name}[/bold]")
1227
594
  else:
1228
595
  updated_count += 1
1229
- console.print(f"🔄 Updated: [bold]{label_name}[/bold]")
596
+ p(f"🔄 Updated: [bold]{label_name}[/bold]")
1230
597
  except Exception as e:
1231
- console.print(f"❌ Failed to create {label_name}: {e}")
598
+ p(f"❌ Failed to create {label_name}: {e}")
1232
599
 
1233
600
  if not dry_run:
1234
- console.print("\n🎪 [bold green]Label setup complete![/bold green]")
1235
- console.print(f" 📊 Created: {created_count}")
1236
- console.print(f" 🔄 Updated: {updated_count}")
1237
- console.print(
601
+ p("\n🎪 [bold green]Label setup complete![/bold green]")
602
+ p(f" 📊 Created: {created_count}")
603
+ p(f" 🔄 Updated: {updated_count}")
604
+ p(
1238
605
  "\n🎪 [dim]Note: Dynamic labels (with SHA) are created automatically during deployment[/dim]"
1239
606
  )
1240
607
 
1241
608
  except Exception as e:
1242
- console.print(f"🎪 [bold red]Error setting up labels:[/bold red] {e}")
609
+ p(f"🎪 [bold red]Error setting up labels:[/bold red] {e}")
1243
610
 
1244
611
 
1245
612
  @app.command()
@@ -1259,667 +626,45 @@ def cleanup(
1259
626
  "--cleanup-labels/--no-cleanup-labels",
1260
627
  help="Also cleanup SHA-based label definitions from repository",
1261
628
  ),
1262
- ):
629
+ ) -> None:
1263
630
  """🎪 Clean up orphaned or expired environments and labels"""
1264
631
  try:
1265
- github = GitHubInterface()
1266
-
1267
- # Step 1: Clean up expired AWS ECS services
1268
- console.print("🎪 [bold blue]Checking AWS ECS services for cleanup...[/bold blue]")
1269
-
1270
- from .core.aws import AWSInterface
1271
-
1272
- aws = AWSInterface()
1273
-
1274
- try:
1275
- expired_services = aws.find_expired_services(older_than)
1276
-
1277
- if expired_services:
1278
- console.print(f"🎪 Found {len(expired_services)} expired ECS services")
1279
-
1280
- for service_info in expired_services:
1281
- service_name = service_info["service_name"]
1282
- pr_number = service_info["pr_number"]
1283
- age_hours = service_info["age_hours"]
1284
-
1285
- if dry_run:
1286
- console.print(
1287
- f"🎪 [yellow]Would delete service {service_name} (PR #{pr_number}, {age_hours:.1f}h old)[/yellow]"
1288
- )
1289
- console.print(
1290
- f"🎪 [dim]Monitor at: https://us-west-2.console.aws.amazon.com/ecs/v2/clusters/superset-ci/services/{service_name}/logs?region=us-west-2[/dim]"
1291
- )
1292
- else:
1293
- console.print(
1294
- f"🎪 Deleting expired service {service_name} (PR #{pr_number}, {age_hours:.1f}h old)"
1295
- )
1296
- # Create minimal Show object for URL generation
1297
- from .core.circus import Show
1298
-
1299
- temp_show = Show(
1300
- pr_number=pr_number, sha=service_name.split("-")[2], status="cleanup"
1301
- )
1302
- _show_service_urls(temp_show, "cleanup")
1303
-
1304
- # Delete ECS service
1305
- if aws._delete_ecs_service(service_name):
1306
- # Delete ECR image
1307
- image_tag = f"pr-{pr_number}-ci"
1308
- aws._delete_ecr_image(image_tag)
1309
- console.print(f"🎪 ✅ Cleaned up {service_name}")
1310
- else:
1311
- console.print(f"🎪 ❌ Failed to clean up {service_name}")
1312
- else:
1313
- console.print("🎪 [dim]No expired ECS services found[/dim]")
1314
-
1315
- except Exception as e:
1316
- console.print(f"🎪 [bold red]AWS cleanup failed:[/bold red] {e}")
1317
-
1318
- # Step 2: Find and clean up expired environments from PRs
1319
- if respect_ttl:
1320
- console.print("🎪 Finding environments expired based on individual TTL labels")
1321
- else:
1322
- console.print(f"🎪 Finding environments older than {older_than}")
1323
- prs_with_shows = github.find_prs_with_shows()
1324
-
1325
- if not prs_with_shows:
1326
- console.print("🎪 [dim]No PRs with circus tent labels found[/dim]")
1327
- else:
1328
- console.print(f"🎪 Found {len(prs_with_shows)} PRs with shows")
1329
-
1330
- import re
1331
- from datetime import datetime, timedelta
1332
-
1333
- from .core.circus import PullRequest, get_effective_ttl, parse_ttl_days
1334
-
1335
- # Parse max_age if provided (safety ceiling)
1336
- max_age_days = None
1337
- if max_age:
1338
- max_age_days = parse_ttl_days(max_age)
1339
-
1340
- cleaned_prs = 0
1341
- for pr_number in prs_with_shows:
1342
- try:
1343
- pr = PullRequest.from_id(pr_number, github)
1344
- expired_shows = []
1345
-
1346
- if respect_ttl:
1347
- # Use individual TTL labels
1348
- effective_ttl_days = get_effective_ttl(pr)
1349
-
1350
- if effective_ttl_days is None:
1351
- # "never" label found - skip cleanup
1352
- console.print(
1353
- f"🎪 [blue]PR #{pr_number} marked as 'never expire' - skipping[/blue]"
1354
- )
1355
- continue
1356
-
1357
- # Apply max_age ceiling if specified
1358
- if max_age_days and effective_ttl_days > max_age_days:
1359
- console.print(
1360
- f"🎪 [yellow]PR #{pr_number} TTL ({effective_ttl_days}d) exceeds max-age ({max_age_days}d)[/yellow]"
1361
- )
1362
- effective_ttl_days = max_age_days
1363
-
1364
- cutoff_time = datetime.now() - timedelta(days=effective_ttl_days)
1365
- console.print(
1366
- f"🎪 PR #{pr_number} effective TTL: {effective_ttl_days} days"
1367
- )
1368
-
1369
- else:
1370
- # Use global older_than parameter (current behavior)
1371
- time_match = re.match(r"(\d+)([hd])", older_than)
1372
- if not time_match:
1373
- console.print(
1374
- f"🎪 [bold red]Invalid time format:[/bold red] {older_than}"
1375
- )
1376
- return
1377
-
1378
- hours = int(time_match.group(1))
1379
- if time_match.group(2) == "d":
1380
- hours *= 24
1381
-
1382
- cutoff_time = datetime.now() - timedelta(hours=hours)
1383
-
1384
- # Check all shows in the PR for expiration
1385
- for show in pr.shows:
1386
- if show.created_at:
1387
- try:
1388
- # Parse timestamp (format: 2024-01-15T14-30)
1389
- show_time = datetime.fromisoformat(
1390
- show.created_at.replace("-", ":")
1391
- )
1392
- if show_time < cutoff_time:
1393
- expired_shows.append(show)
1394
- except (ValueError, AttributeError):
1395
- # If we can't parse the timestamp, consider it expired
1396
- expired_shows.append(show)
1397
-
1398
- if expired_shows:
1399
- if dry_run:
1400
- console.print(
1401
- f"🎪 [yellow]Would clean {len(expired_shows)} expired shows from PR #{pr_number}[/yellow]"
1402
- )
1403
- for show in expired_shows:
1404
- console.print(f" - SHA {show.sha} ({show.status})")
1405
- else:
1406
- console.print(
1407
- f"🎪 Cleaning {len(expired_shows)} expired shows from PR #{pr_number}"
1408
- )
1409
-
1410
- # Remove circus labels for expired shows
1411
- current_labels = github.get_circus_labels(pr_number)
1412
- labels_to_keep = []
1413
-
1414
- for label in current_labels:
1415
- # Keep labels that don't belong to expired shows
1416
- should_keep = True
1417
- for expired_show in expired_shows:
1418
- if expired_show.sha in label:
1419
- should_keep = False
1420
- break
1421
- if should_keep:
1422
- labels_to_keep.append(label)
1423
-
1424
- # Update PR labels
1425
- github.remove_circus_labels(pr_number)
1426
- for label in labels_to_keep:
1427
- github.add_label(pr_number, label)
1428
-
1429
- cleaned_prs += 1
1430
-
1431
- except Exception as e:
1432
- console.print(f"🎪 [red]Error processing PR #{pr_number}:[/red] {e}")
1433
-
1434
- if not dry_run and cleaned_prs > 0:
1435
- console.print(f"🎪 [green]Cleaned up environments from {cleaned_prs} PRs[/green]")
1436
-
1437
- # Step 2: Clean up SHA-based label definitions from repository
1438
- if cleanup_labels:
1439
- console.print("🎪 Finding SHA-based labels in repository")
1440
- sha_labels = github.cleanup_sha_labels(dry_run=dry_run)
1441
-
1442
- if sha_labels:
1443
- if dry_run:
1444
- console.print(
1445
- f"🎪 [yellow]Would delete {len(sha_labels)} SHA-based label definitions:[/yellow]"
1446
- )
1447
- for label in sha_labels[:10]: # Show first 10
1448
- console.print(f" - {label}")
1449
- if len(sha_labels) > 10:
1450
- console.print(f" ... and {len(sha_labels) - 10} more")
1451
- else:
1452
- console.print(
1453
- f"🎪 [green]Deleted {len(sha_labels)} SHA-based label definitions[/green]"
1454
- )
1455
- else:
1456
- console.print("🎪 [dim]No SHA-based labels found to clean[/dim]")
1457
-
1458
- except Exception as e:
1459
- console.print(f"🎪 [bold red]Error during cleanup:[/bold red] {e}")
1460
-
1461
-
1462
- # Helper functions for trigger processing
1463
- def _handle_start_trigger(
1464
- pr_number: int,
1465
- github: GitHubInterface,
1466
- dry_run_aws: bool = False,
1467
- dry_run_github: bool = False,
1468
- aws_sleep: int = 0,
1469
- docker_tag_override: Optional[str] = None,
1470
- force: bool = False,
1471
- ):
1472
- """Handle start trigger"""
1473
- import time
1474
- from datetime import datetime
1475
-
1476
- console.print(f"🎪 Starting environment for PR #{pr_number}")
1477
-
1478
- try:
1479
- # Get latest SHA and GitHub actor
1480
- latest_sha = github.get_latest_commit_sha(pr_number)
1481
- github_actor = _get_github_actor()
1482
-
1483
- # Create new show
1484
- show = Show(
1485
- pr_number=pr_number,
1486
- sha=short_sha(latest_sha),
1487
- status="building",
1488
- created_at=datetime.utcnow().strftime("%Y-%m-%dT%H-%M"),
1489
- ttl="24h",
1490
- requested_by=github_actor,
1491
- )
1492
-
1493
- console.print(f"🎪 Creating environment {show.aws_service_name}")
1494
-
1495
- # Post confirmation comment
1496
- confirmation_comment = start_comment(show)
1497
-
1498
- if not dry_run_github:
1499
- github.post_comment(pr_number, confirmation_comment)
1500
-
1501
- # Set building state labels
1502
- building_labels = show.to_circus_labels()
1503
- console.print("🎪 Setting building state labels:")
1504
- for label in building_labels:
1505
- console.print(f" + {label}")
1506
-
1507
- # Set building labels
1508
- if not dry_run_github:
1509
- # Actually set the labels for real testing
1510
- console.print("🎪 Setting labels on GitHub...")
1511
- # Remove existing circus labels first
1512
- github.remove_circus_labels(pr_number)
1513
- # Add new labels one by one
1514
- for label in building_labels:
1515
- github.add_label(pr_number, label)
1516
- console.print("🎪 ✅ Labels set on GitHub!")
1517
- else:
1518
- console.print("🎪 [bold yellow]DRY-RUN-GITHUB[/bold yellow] - Would set labels")
1519
-
1520
- if dry_run_aws:
1521
- console.print("🎪 [bold yellow]DRY-RUN-AWS[/bold yellow] - Skipping AWS operations")
1522
- if aws_sleep > 0:
1523
- console.print(f"🎪 Sleeping {aws_sleep}s to simulate AWS build time...")
1524
- time.sleep(aws_sleep)
1525
-
1526
- # Mock successful deployment
1527
- mock_ip = "52.1.2.3"
1528
- console.print(
1529
- f"🎪 [bold green]Mock AWS deployment successful![/bold green] IP: {mock_ip}"
1530
- )
1531
-
1532
- # Update to running state
1533
- show.status = "running"
1534
- show.ip = mock_ip
1535
-
1536
- running_labels = show.to_circus_labels()
1537
- console.print("🎪 Setting running state labels:")
1538
- for label in running_labels:
1539
- console.print(f" + {label}")
1540
-
1541
- # Set running labels
1542
- if not dry_run_github:
1543
- console.print("🎪 Updating to running state...")
1544
- # Remove existing circus labels first
1545
- github.remove_circus_labels(pr_number)
1546
- # Add new running labels
1547
- for label in running_labels:
1548
- github.add_label(pr_number, label)
1549
- console.print("🎪 ✅ Labels updated to running state!")
1550
- else:
1551
- console.print(
1552
- "🎪 [bold yellow]DRY-RUN-GITHUB[/bold yellow] - Would update to running state"
1553
- )
1554
-
1555
- # Post success comment (only in dry-run-aws mode since we have mock IP)
1556
- # Create mock show with IP for success comment
1557
- mock_show = Show(
1558
- pr_number=show.pr_number,
1559
- sha=show.sha,
1560
- status="running",
1561
- ip=mock_ip,
1562
- ttl=show.ttl,
1563
- requested_by=show.requested_by,
1564
- )
1565
- success_comment_text = success_comment(mock_show)
1566
-
1567
- if not dry_run_github:
1568
- github.post_comment(pr_number, success_comment_text)
1569
-
1570
- else:
1571
- # Real AWS operations
1572
- from .core.aws import AWSInterface, EnvironmentResult
1573
-
1574
- console.print("🎪 [bold blue]Starting AWS deployment...[/bold blue]")
1575
- aws = AWSInterface()
1576
-
1577
- # Show logs URL immediately for monitoring
1578
- _show_service_urls(show, "deployment")
1579
-
1580
- # Parse feature flags from PR description (replicate GHA feature flag logic)
1581
- feature_flags = _extract_feature_flags_from_pr(pr_number, github)
1582
-
1583
- # Create environment (synchronous, matches GHA wait behavior)
1584
- result: EnvironmentResult = aws.create_environment(
1585
- pr_number=pr_number,
1586
- sha=latest_sha,
1587
- github_user=github_actor,
1588
- feature_flags=feature_flags,
1589
- image_tag_override=docker_tag_override,
1590
- force=force,
1591
- )
1592
-
1593
- if result.success:
1594
- console.print(
1595
- f"🎪 [bold green]✅ Green service deployed successfully![/bold green] IP: {result.ip}"
1596
- )
1597
-
1598
- # Show helpful links for the new service
1599
- console.print("\n🎪 [bold blue]Useful Links:[/bold blue]")
1600
- console.print(f" 🌐 Environment: http://{result.ip}:8080")
1601
-
1602
- # Use centralized URL generation
1603
- urls = _get_service_urls(show)
1604
- console.print(f" 📊 ECS Service: {urls['service']}")
1605
- console.print(f" 📝 Service Logs: {urls['logs']}")
1606
- console.print(f" 🏥 Health Checks: {urls['health']}")
1607
- console.print(
1608
- f" 🔍 GitHub PR: https://github.com/apache/superset/pull/{pr_number}"
1609
- )
1610
- console.print(
1611
- "\n🎪 [dim]Note: Superset takes 2-3 minutes to initialize after container starts[/dim]"
1612
- )
1613
-
1614
- # Blue-Green Traffic Switch: Update GitHub labels to point to new service
1615
- console.print(
1616
- f"\n🎪 [bold blue]Switching traffic to green service {latest_sha[:7]}...[/bold blue]"
1617
- )
1618
-
1619
- # Check for existing services to show blue-green transition
1620
- from .core.aws import AWSInterface
1621
-
1622
- aws = AWSInterface()
1623
- existing_services = aws._find_pr_services(pr_number)
1624
-
1625
- if len(existing_services) > 1:
1626
- console.print("🔄 Blue-Green Deployment:")
1627
- blue_services = []
1628
- for svc in existing_services:
1629
- if svc["sha"] == latest_sha[:7]:
1630
- console.print(
1631
- f" 🟢 Green: {svc['service_name']} (NEW - receiving traffic)"
1632
- )
1633
- else:
1634
- console.print(
1635
- f" 🔵 Blue: {svc['service_name']} (OLD - will be cleaned up in 5 minutes)"
1636
- )
1637
- blue_services.append(svc)
1638
-
1639
- # Schedule cleanup of blue services
1640
- if blue_services:
1641
- console.print(
1642
- f"\n🧹 Scheduling cleanup of {len(blue_services)} blue service(s) in 5 minutes..."
1643
- )
1644
- _schedule_blue_cleanup(pr_number, blue_services)
1645
-
1646
- # Update show with deployment result
1647
- show.ip = result.ip
1648
-
1649
- # Use internal helper to set running state (posts success comment)
1650
- console.print("\n🎪 Traffic switching to running state:")
1651
- _set_state_internal("running", pr_number, show, github, dry_run_github)
1652
-
1653
- else:
1654
- console.print(f"🎪 [bold red]❌ AWS deployment failed:[/bold red] {result.error}")
1655
-
1656
- # Use internal helper to set failed state (posts failure comment)
1657
- _set_state_internal("failed", pr_number, show, github, dry_run_github, result.error)
1658
-
1659
- except Exception as e:
1660
- console.print(f"🎪 [bold red]Start trigger failed:[/bold red] {e}")
1661
-
1662
-
1663
- def _extract_feature_flags_from_pr(pr_number: int, github: GitHubInterface) -> list:
1664
- """Extract feature flags from PR description (replicate GHA eval-feature-flags step)"""
1665
- import re
1666
-
1667
- try:
1668
- # Get PR description
1669
- pr_data = github.get_pr_data(pr_number)
1670
- description = pr_data.get("body") or ""
1671
-
1672
- # Replicate exact GHA regex pattern: FEATURE_(\w+)=(\w+)
1673
- pattern = r"FEATURE_(\w+)=(\w+)"
1674
- results = []
1675
-
1676
- for match in re.finditer(pattern, description):
1677
- feature_config = {"name": f"SUPERSET_FEATURE_{match.group(1)}", "value": match.group(2)}
1678
- results.append(feature_config)
1679
- console.print(
1680
- f"🎪 Found feature flag: {feature_config['name']}={feature_config['value']}"
1681
- )
1682
-
1683
- return results
1684
-
1685
- except Exception as e:
1686
- console.print(f"🎪 Warning: Could not extract feature flags: {e}")
1687
- return []
1688
-
1689
-
1690
- def _handle_stop_trigger(
1691
- pr_number: int, github: GitHubInterface, dry_run_aws: bool = False, dry_run_github: bool = False
1692
- ):
1693
- """Handle stop trigger"""
1694
-
1695
- console.print(f"🎪 Stopping environment for PR #{pr_number}")
1696
-
1697
- try:
1698
- pr = PullRequest.from_id(pr_number, github)
632
+ # Parse older_than to hours
633
+ import re
1699
634
 
1700
- if not pr.current_show:
1701
- console.print(f"🎪 No active environment found for PR #{pr_number}")
635
+ time_match = re.match(r"(\d+)([hd])", older_than)
636
+ if not time_match:
637
+ p(f"❌ Invalid time format: {older_than}")
1702
638
  return
1703
639
 
1704
- show = pr.current_show
1705
- console.print(f"🎪 Destroying environment: {show.aws_service_name}")
1706
-
1707
- if dry_run_aws:
1708
- console.print("🎪 [bold yellow]DRY-RUN-AWS[/bold yellow] - Would delete AWS resources")
1709
- console.print(f" - ECS service: {show.aws_service_name}")
1710
- console.print(f" - ECR image: {show.aws_image_tag}")
1711
- else:
1712
- # Real AWS cleanup (replicate ephemeral-env-pr-close.yml logic)
1713
- from .core.aws import AWSInterface
1714
-
1715
- console.print("🎪 [bold blue]Starting AWS cleanup...[/bold blue]")
1716
- aws = AWSInterface()
1717
-
1718
- try:
1719
- # Show logs URL for monitoring cleanup
1720
- _show_service_urls(show, "cleanup")
1721
-
1722
- # Step 1: Check if ECS service exists and is active (replicate GHA describe-services)
1723
- service_name = show.ecs_service_name
1724
- console.print(f"🎪 Checking ECS service: {service_name}")
1725
-
1726
- service_exists = aws._service_exists(service_name)
1727
-
1728
- if service_exists:
1729
- console.print(f"🎪 Found active ECS service: {service_name}")
1730
-
1731
- # Step 2: Delete ECS service (replicate GHA delete-service)
1732
- console.print("🎪 Deleting ECS service...")
1733
- success = aws._delete_ecs_service(service_name)
1734
-
1735
- if success:
1736
- console.print("🎪 ✅ ECS service deleted successfully")
1737
-
1738
- # Step 3: Delete ECR image tag (replicate GHA batch-delete-image)
1739
- image_tag = f"pr-{pr_number}-ci" # Match GHA image tag format
1740
- console.print(f"🎪 Deleting ECR image tag: {image_tag}")
1741
-
1742
- ecr_success = aws._delete_ecr_image(image_tag)
1743
-
1744
- if ecr_success:
1745
- console.print("🎪 ✅ ECR image deleted successfully")
1746
- else:
1747
- console.print("🎪 ⚠️ ECR image deletion failed (may not exist)")
1748
-
1749
- console.print(
1750
- "🎪 [bold green]✅ AWS cleanup completed successfully![/bold green]"
1751
- )
1752
-
1753
- else:
1754
- console.print("🎪 [bold red]❌ ECS service deletion failed[/bold red]")
1755
-
1756
- else:
1757
- console.print(f"🎪 No active ECS service found: {service_name}")
1758
- console.print("🎪 ✅ No AWS resources to clean up")
1759
-
1760
- except Exception as e:
1761
- console.print(f"🎪 [bold red]❌ AWS cleanup failed:[/bold red] {e}")
1762
-
1763
- # Remove all circus labels for this PR
1764
- console.print(f"🎪 Removing all circus labels for PR #{pr_number}")
1765
- if not dry_run_github:
1766
- github.remove_circus_labels(pr_number)
1767
-
1768
- # Post cleanup comment
1769
- github_actor = _get_github_actor()
1770
- cleanup_comment = f"""🎪 @{github_actor} Environment `{show.sha}` cleaned up
1771
-
1772
- **AWS Resources:** ECS service and ECR image deleted
1773
- **Cost Impact:** No further charges
1774
-
1775
- Add `🎪 trigger-start` to create a new environment.
1776
-
1777
- {_get_showtime_footer()}"""
1778
-
1779
- if not dry_run_github:
1780
- github.post_comment(pr_number, cleanup_comment)
1781
-
1782
- console.print("🎪 [bold green]Environment stopped![/bold green]")
1783
-
1784
- except Exception as e:
1785
- console.print(f"🎪 [bold red]Stop trigger failed:[/bold red] {e}")
1786
-
1787
-
1788
- def _handle_sync_trigger(
1789
- pr_number: int,
1790
- github: GitHubInterface,
1791
- dry_run_aws: bool = False,
1792
- dry_run_github: bool = False,
1793
- aws_sleep: int = 0,
1794
- ):
1795
- """Handle sync trigger"""
1796
- import time
1797
- from datetime import datetime
1798
-
1799
- console.print(f"🎪 Syncing environment for PR #{pr_number}")
640
+ max_age_hours = int(time_match.group(1))
641
+ if time_match.group(2) == "d":
642
+ max_age_hours *= 24
1800
643
 
1801
- try:
1802
- pr = PullRequest.from_id(pr_number, github)
644
+ p(f"🎪 [bold blue]Cleaning environments older than {max_age_hours}h...[/bold blue]")
1803
645
 
1804
- if not pr.current_show:
1805
- console.print(f"🎪 No active environment for PR #{pr_number}")
1806
- return
1807
-
1808
- latest_sha = github.get_latest_commit_sha(pr_number)
1809
-
1810
- if not pr.current_show.needs_update(latest_sha):
1811
- console.print(f"🎪 Environment already up to date: {pr.current_show.sha}")
646
+ # Get all PRs with environments
647
+ pr_numbers = PullRequest.find_all_with_environments()
648
+ if not pr_numbers:
649
+ p("🎪 No environments found to clean")
1812
650
  return
1813
651
 
1814
- console.print(f"🎪 Rolling update: {pr.current_show.sha} → {latest_sha[:7]}")
1815
-
1816
- # Create new show for building
1817
- new_show = Show(
1818
- pr_number=pr_number,
1819
- sha=latest_sha[:7],
1820
- status="building",
1821
- created_at=datetime.utcnow().strftime("%Y-%m-%dT%H-%M"),
1822
- ttl=pr.current_show.ttl,
1823
- requested_by=pr.current_show.requested_by,
1824
- )
1825
-
1826
- console.print(f"🎪 Building new environment: {new_show.aws_service_name}")
1827
-
1828
- if dry_run_aws:
1829
- console.print("🎪 [bold yellow]DRY-RUN-AWS[/bold yellow] - Mocking rolling update")
1830
- if aws_sleep > 0:
1831
- console.print(f"🎪 Sleeping {aws_sleep}s to simulate build + deploy...")
1832
- time.sleep(aws_sleep)
1833
-
1834
- # Mock successful update
1835
- new_show.status = "running"
1836
- new_show.ip = "52.4.5.6" # New mock IP
1837
-
1838
- console.print("🎪 [bold green]Mock rolling update complete![/bold green]")
1839
- console.print(f"🎪 Traffic switched to {new_show.sha} at {new_show.ip}")
1840
-
1841
- # Post rolling update success comment
1842
- update_comment = f"""🎪 Environment updated: {pr.current_show.sha} → `{new_show.sha}`
1843
-
1844
- **New Environment:** http://{new_show.ip}:8080
1845
- **Update:** Zero-downtime rolling deployment
1846
- **Old Environment:** Automatically cleaned up
1847
-
1848
- Your latest changes are now live.
1849
-
1850
- {_get_showtime_footer()}"""
1851
-
1852
- if not dry_run_github:
1853
- github.post_comment(pr_number, update_comment)
652
+ cleaned_count = 0
653
+ for pr_number in pr_numbers:
654
+ pr = PullRequest.from_id(pr_number)
655
+ if pr.stop_if_expired(max_age_hours, dry_run):
656
+ cleaned_count += 1
1854
657
 
658
+ if cleaned_count > 0:
659
+ p(f"🎪 ✅ Cleaned up {cleaned_count} expired environments")
1855
660
  else:
1856
- # Real rolling update - use same blue-green deployment logic
1857
-
1858
- from .core.aws import AWSInterface, EnvironmentResult
1859
-
1860
- console.print("🎪 [bold blue]Starting real rolling update...[/bold blue]")
1861
-
1862
- # Post rolling update start comment
1863
- start_comment_text = rolling_start_comment(pr.current_show, latest_sha)
1864
-
1865
- if not dry_run_github:
1866
- github.post_comment(pr_number, start_comment_text)
1867
-
1868
- aws = AWSInterface()
1869
-
1870
- # Get feature flags from PR description
1871
- feature_flags = _extract_feature_flags_from_pr(pr_number, github)
1872
- github_actor = _get_github_actor()
1873
-
1874
- # Use blue-green deployment (create_environment handles existing services)
1875
- result: EnvironmentResult = aws.create_environment(
1876
- pr_number=pr_number,
1877
- sha=latest_sha,
1878
- github_user=github_actor,
1879
- feature_flags=feature_flags,
1880
- force=False, # Don't force - let blue-green handle it
1881
- )
1882
-
1883
- if result.success:
1884
- console.print(
1885
- f"🎪 [bold green]✅ Rolling update complete![/bold green] New IP: {result.ip}"
1886
- )
1887
-
1888
- # Update labels to point to new service
1889
- pr.refresh_labels(github)
1890
- new_show = pr.get_show_by_sha(latest_sha)
1891
- if new_show:
1892
- new_show.status = "running"
1893
- new_show.ip = result.ip
1894
-
1895
- # Update GitHub labels
1896
- github.remove_circus_labels(pr_number)
1897
- for label in new_show.to_circus_labels():
1898
- github.add_label(pr_number, label)
1899
-
1900
- console.print("🎪 ✅ Labels updated to point to new environment")
1901
-
1902
- # Post rolling update success comment
1903
- success_comment_text = rolling_success_comment(pr.current_show, new_show)
1904
-
1905
- if not dry_run_github:
1906
- github.post_comment(pr_number, success_comment_text)
1907
- else:
1908
- console.print(f"🎪 [bold red]❌ Rolling update failed:[/bold red] {result.error}")
1909
-
1910
- # Post rolling update failure comment
1911
- failure_comment_text = rolling_failure_comment(
1912
- pr.current_show, latest_sha, result.error
1913
- )
1914
-
1915
- if not dry_run_github:
1916
- github.post_comment(pr_number, failure_comment_text)
661
+ p("🎪 No expired environments found")
1917
662
 
1918
663
  except Exception as e:
1919
- console.print(f"🎪 [bold red]Sync trigger failed:[/bold red] {e}")
664
+ p(f" Cleanup failed: {e}")
1920
665
 
1921
666
 
1922
- def main():
667
+ def main() -> None:
1923
668
  """Main entry point for the CLI"""
1924
669
  app()
1925
670