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