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