superset-showtime 0.2.8__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,130 +4,36 @@
4
4
  Main command-line interface for Apache Superset circus tent environment management.
5
5
  """
6
6
 
7
- from typing import Optional
7
+ from typing import Dict, Optional
8
8
 
9
9
  import typer
10
10
  from rich.console import Console
11
11
  from rich.table import Table
12
12
 
13
- from .core.circus import PullRequest, Show, short_sha
14
13
  from .core.emojis import STATUS_DISPLAY
15
14
  from .core.github import GitHubError, GitHubInterface
16
15
  from .core.github_messages import (
17
16
  get_aws_console_urls,
18
- rolling_failure_comment,
19
- rolling_start_comment,
20
- rolling_success_comment,
21
- start_comment,
22
- success_comment,
23
17
  )
18
+ from .core.pull_request import PullRequest
19
+ from .core.show import Show
24
20
 
25
21
  # Constants
26
22
  DEFAULT_GITHUB_ACTOR = "unknown"
27
23
 
28
24
 
29
- def _get_service_urls(show):
25
+ def _get_service_urls(show: Show) -> Dict[str, str]:
30
26
  """Get AWS Console URLs for a service"""
31
27
  return get_aws_console_urls(show.ecs_service_name)
32
28
 
33
29
 
34
- def _show_service_urls(show, context: str = "deployment"):
30
+ def _show_service_urls(show: Show, context: str = "deployment") -> None:
35
31
  """Show helpful AWS Console URLs for monitoring service"""
36
32
  urls = _get_service_urls(show)
37
- console.print(f"\n🎪 [bold blue]Monitor {context} progress:[/bold blue]")
38
- console.print(f"📝 Logs: {urls['logs']}")
39
- console.print(f"📊 Service: {urls['service']}")
40
- console.print("")
41
-
42
-
43
- def _determine_sync_action(pr, pr_state: str, target_sha: str) -> str:
44
- """Determine what action is needed based on PR state and labels"""
45
-
46
- # 1. Closed PRs always need cleanup
47
- if pr_state == "closed":
48
- return "cleanup"
49
-
50
- # 2. Check for explicit trigger labels
51
- trigger_labels = [label for label in pr.labels if "showtime-trigger-" in label]
52
-
53
- # 3. Check for freeze label (PR-level) - only if no explicit triggers
54
- freeze_labels = [label for label in pr.labels if "showtime-freeze" in label]
55
- if freeze_labels and not trigger_labels:
56
- return "frozen_no_action" # Frozen and no explicit triggers to override
57
-
58
- if trigger_labels:
59
- # Explicit triggers take priority
60
- for trigger in trigger_labels:
61
- if "showtime-trigger-start" in trigger:
62
- if pr.current_show:
63
- if pr.current_show.needs_update(target_sha):
64
- return "rolling_update" # New commit with existing env
65
- else:
66
- return "no_action" # Same commit, no change needed
67
- else:
68
- return "create_environment" # New environment
69
- elif "showtime-trigger-stop" in trigger:
70
- return "destroy_environment"
71
-
72
- # 3. No explicit triggers - check for implicit sync needs
73
- if pr.current_show:
74
- if pr.current_show.needs_update(target_sha):
75
- return "auto_sync" # Auto-update on new commits
76
- else:
77
- return "no_action" # Everything in sync
78
- else:
79
- return "no_action" # No environment, no triggers
80
-
81
-
82
- def _schedule_blue_cleanup(pr_number: int, blue_services: list):
83
- """Schedule cleanup of blue services after successful green deployment"""
84
- import threading
85
- import time
86
-
87
- def cleanup_after_delay():
88
- """Background cleanup of blue services"""
89
- try:
90
- # Wait 5 minutes before cleanup
91
- time.sleep(300) # 5 minutes
92
-
93
- console.print(
94
- f"\n🧹 [bold blue]Starting scheduled cleanup of blue services for PR #{pr_number}[/bold blue]"
95
- )
96
-
97
- from .core.aws import AWSInterface
98
-
99
- aws = AWSInterface()
100
-
101
- for blue_svc in blue_services:
102
- service_name = blue_svc["service_name"]
103
- console.print(f"🗑️ Cleaning up blue service: {service_name}")
104
-
105
- try:
106
- # Delete ECS service
107
- if aws._delete_ecs_service(service_name):
108
- # Delete ECR image
109
- pr_match = service_name.split("-")
110
- if len(pr_match) >= 2:
111
- pr_num = pr_match[1]
112
- image_tag = f"pr-{pr_num}-ci" # Legacy format for old services
113
- aws._delete_ecr_image(image_tag)
114
-
115
- console.print(f"✅ Cleaned up blue service: {service_name}")
116
- else:
117
- console.print(f"⚠️ Failed to clean up: {service_name}")
118
-
119
- except Exception as e:
120
- console.print(f"❌ Cleanup error for {service_name}: {e}")
121
-
122
- console.print("🧹 ✅ Blue service cleanup completed")
123
-
124
- except Exception as e:
125
- console.print(f"❌ Background cleanup failed: {e}")
126
-
127
- # Start cleanup in background thread
128
- cleanup_thread = threading.Thread(target=cleanup_after_delay, daemon=True)
129
- cleanup_thread.start()
130
- 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("")
131
37
 
132
38
 
133
39
  app = typer.Typer(
@@ -149,9 +55,11 @@ app = typer.Typer(
149
55
 
150
56
  [dim]CLI commands work with existing environments or dry-run new ones.[/dim]""",
151
57
  rich_markup_mode="rich",
58
+ no_args_is_help=True,
152
59
  )
153
60
 
154
61
  console = Console()
62
+ p = console.print # Shorthand for cleaner code
155
63
 
156
64
 
157
65
  def _get_github_workflow_url() -> str:
@@ -173,15 +81,15 @@ def _get_github_actor() -> str:
173
81
 
174
82
  def _get_showtime_footer() -> str:
175
83
  """Get consistent Showtime footer for PR comments"""
176
- return "{_get_showtime_footer()}"
84
+ return "🎪 *Managed by [Superset Showtime](https://github.com/your-org/superset-showtime)*"
177
85
 
178
86
 
179
87
  @app.command()
180
- def version():
88
+ def version() -> None:
181
89
  """Show version information"""
182
90
  from . import __version__
183
91
 
184
- console.print(f"🎪 Superset Showtime v{__version__}")
92
+ p(f"🎪 Superset Showtime v{__version__}")
185
93
 
186
94
 
187
95
  @app.command()
@@ -204,73 +112,77 @@ def start(
204
112
  force: bool = typer.Option(
205
113
  False, "--force", help="Force re-deployment by deleting existing service"
206
114
  ),
207
- ):
115
+ ) -> None:
208
116
  """Create ephemeral environment for PR"""
209
117
  try:
210
- github = GitHubInterface()
211
-
212
- # Get SHA - use provided SHA or default to latest
213
- if not sha:
214
- sha = github.get_latest_commit_sha(pr_number)
215
- 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]")
216
135
  else:
217
- console.print(f"🎪 Using specified SHA: {sha[:7]}")
136
+ p(f"🎪 [bold green]Creating new environment for PR #{pr_number}[/bold green]")
218
137
 
219
138
  if dry_run:
220
- console.print("🎪 [bold yellow]DRY RUN[/bold yellow] - Would create environment:")
221
- console.print(f" PR: #{pr_number}")
222
- console.print(f" SHA: {sha[:7]}")
223
- console.print(f" AWS Service: pr-{pr_number}-{sha[:7]}")
224
- console.print(f" TTL: {ttl}")
225
- console.print(" Labels to add:")
226
- console.print(" 🎪 🚦 building")
227
- console.print(f" 🎪 🎯 {sha[:7]}")
228
- 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}")
229
147
  return
230
148
 
231
- # Check if environment already exists
232
- pr = PullRequest.from_id(pr_number, github)
233
- if pr.current_show:
234
- console.print(
235
- f"🎪 [bold yellow]Environment already exists for PR #{pr_number}[/bold yellow]"
236
- )
237
- console.print(f"Current: {pr.current_show.sha} at {pr.current_show.ip}")
238
- console.print("Use 'showtime sync' to update or 'showtime stop' to clean up first")
239
- 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)
240
151
 
241
- # Create environment using trigger handler logic
242
- console.print(f"🎪 [bold blue]Creating environment for PR #{pr_number}...[/bold blue]")
243
- _handle_start_trigger(
244
- pr_number, github, dry_run_aws, (dry_run or False), aws_sleep, docker_tag, force
245
- )
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)
246
160
 
247
161
  except GitHubError as e:
248
- console.print(f"🎪 [bold red]GitHub error:[/bold red] {e.message}")
162
+ p(f" GitHub error: {e}")
163
+ raise typer.Exit(1) from e
249
164
  except Exception as e:
250
- console.print(f"🎪 [bold red]Error:[/bold red] {e}")
165
+ p(f" Error: {e}")
166
+ raise typer.Exit(1) from e
251
167
 
252
168
 
253
169
  @app.command()
254
170
  def status(
255
171
  pr_number: int = typer.Argument(..., help="PR number to check status for"),
256
172
  verbose: bool = typer.Option(False, "-v", "--verbose", help="Show detailed information"),
257
- ):
173
+ ) -> None:
258
174
  """Show environment status for PR"""
259
175
  try:
260
- github = GitHubInterface()
176
+ pr = PullRequest.from_id(pr_number)
261
177
 
262
- pr = PullRequest.from_id(pr_number, github)
178
+ # Use PullRequest method for data
179
+ status_data = pr.get_status()
263
180
 
264
- if not pr.has_shows():
265
- 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}")
266
183
  return
267
184
 
268
- show = pr.current_show
269
- if not show:
270
- console.print(f"🎪 No active environment for PR #{pr_number}")
271
- if pr.building_show:
272
- console.print(f"🏗️ Building environment: {pr.building_show.sha}")
273
- return
185
+ show_data = status_data["show"]
274
186
 
275
187
  # Create status table
276
188
  table = Table(title=f"🎪 Environment Status - PR #{pr_number}")
@@ -278,37 +190,43 @@ def status(
278
190
  table.add_column("Value", style="white")
279
191
 
280
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']}`")
281
198
 
282
- table.add_row("Status", f"{status_emoji.get(show.status, '❓')} {show.status.title()}")
283
- table.add_row("Environment", f"`{show.sha}`")
284
- table.add_row("AWS Service", f"`{show.aws_service_name}`")
285
-
286
- if show.ip:
287
- 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"])
288
203
 
289
- if show.created_at:
290
- table.add_row("Created", show.created_at)
204
+ table.add_row("TTL", show_data["ttl"])
291
205
 
292
- table.add_row("TTL", show.ttl)
206
+ if show_data["requested_by"]:
207
+ table.add_row("Requested by", f"@{show_data['requested_by']}")
293
208
 
294
- if show.requested_by:
295
- 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)
296
214
 
297
215
  if verbose:
298
216
  table.add_row("All Labels", ", ".join(pr.circus_labels))
299
217
 
300
- console.print(table)
218
+ p(table)
301
219
 
302
220
  # Show building environment if exists
303
- if pr.building_show and pr.building_show != show:
304
- console.print(
305
- f"🏗️ [bold yellow]Building new environment:[/bold yellow] {pr.building_show.sha}"
306
- )
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}")
307
223
 
308
224
  except GitHubError as e:
309
- console.print(f"🎪 [bold red]GitHub error:[/bold red] {e.message}")
225
+ p(f" GitHub error: {e}")
226
+ raise typer.Exit(1) from e
310
227
  except Exception as e:
311
- console.print(f"🎪 [bold red]Error:[/bold red] {e}")
228
+ p(f" Error: {e}")
229
+ raise typer.Exit(1) from e
312
230
 
313
231
 
314
232
  @app.command()
@@ -320,114 +238,47 @@ def stop(
320
238
  False, "--dry-run-aws", help="Skip AWS operations, use mock data"
321
239
  ),
322
240
  aws_sleep: int = typer.Option(0, "--aws-sleep", help="Seconds to sleep during AWS operations"),
323
- ):
241
+ ) -> None:
324
242
  """Delete environment for PR"""
325
243
  try:
326
- github = GitHubInterface()
327
-
328
- pr = PullRequest.from_id(pr_number, github)
244
+ pr = PullRequest.from_id(pr_number)
329
245
 
330
246
  if not pr.current_show:
331
- console.print(f"🎪 No active environment found for PR #{pr_number}")
247
+ p(f"🎪 No active environment found for PR #{pr_number}")
332
248
  return
333
249
 
334
250
  show = pr.current_show
335
- console.print(f"🎪 [bold yellow]Stopping environment for PR #{pr_number}...[/bold yellow]")
336
- 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}")
337
253
 
338
254
  if dry_run:
339
- console.print("🎪 [bold yellow]DRY RUN[/bold yellow] - Would delete environment:")
340
- console.print(f" AWS Service: {show.aws_service_name}")
341
- console.print(f" ECR Image: {show.aws_image_tag}")
342
- 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")
343
259
  return
344
260
 
345
261
  if not force:
346
262
  confirm = typer.confirm(f"Delete environment {show.aws_service_name}?")
347
263
  if not confirm:
348
- console.print("🎪 Cancelled")
264
+ p("🎪 Cancelled")
349
265
  return
350
266
 
351
- if dry_run_aws:
352
- console.print("🎪 [bold yellow]DRY-RUN-AWS[/bold yellow] - Would delete AWS resources:")
353
- console.print(f" - ECS service: {show.aws_service_name}")
354
- console.print(f" - ECR image: {show.aws_image_tag}")
355
- if aws_sleep > 0:
356
- import time
267
+ # Use PullRequest method for all logic
268
+ result = pr.stop_environment(dry_run_github=False, dry_run_aws=dry_run_aws)
357
269
 
358
- console.print(f"🎪 Sleeping {aws_sleep}s to simulate AWS cleanup...")
359
- time.sleep(aws_sleep)
360
- console.print("🎪 [bold green]Mock AWS cleanup complete![/bold green]")
270
+ if result.success:
271
+ p("🎪 ✅ Environment stopped and cleaned up!")
361
272
  else:
362
- # Real AWS cleanup
363
- from .core.aws import AWSInterface
364
-
365
- console.print("🎪 [bold blue]Starting AWS cleanup...[/bold blue]")
366
- aws = AWSInterface()
367
-
368
- try:
369
- # Get current environment info
370
- pr = PullRequest.from_id(pr_number, github)
371
-
372
- if pr.current_show:
373
- show = pr.current_show
374
-
375
- # Show logs URL for monitoring cleanup
376
- _show_service_urls(show, "cleanup")
377
- console.print(f"🎪 Destroying environment: {show.aws_service_name}")
378
-
379
- # Step 1: Check if ECS service exists and is active
380
- service_name = show.ecs_service_name
381
- console.print(f"🎪 Checking ECS service: {service_name}")
382
-
383
- service_exists = aws._service_exists(service_name)
384
-
385
- if service_exists:
386
- console.print(f"🎪 Found active ECS service: {service_name}")
387
-
388
- # Step 2: Delete ECS service
389
- console.print("🎪 Deleting ECS service...")
390
- success = aws._delete_ecs_service(service_name)
391
-
392
- if success:
393
- console.print("🎪 ✅ ECS service deleted successfully")
394
-
395
- # Step 3: Delete ECR image tag
396
- image_tag = f"pr-{pr_number}-ci" # Match GHA image tag format
397
- console.print(f"🎪 Deleting ECR image tag: {image_tag}")
398
-
399
- ecr_success = aws._delete_ecr_image(image_tag)
400
-
401
- if ecr_success:
402
- console.print("🎪 ✅ ECR image deleted successfully")
403
- else:
404
- console.print("🎪 ⚠️ ECR image deletion failed (may not exist)")
405
-
406
- console.print(
407
- "🎪 [bold green]✅ AWS cleanup completed successfully![/bold green]"
408
- )
409
-
410
- else:
411
- console.print("🎪 [bold red]❌ ECS service deletion failed[/bold red]")
412
-
413
- else:
414
- console.print(f"🎪 No active ECS service found: {service_name}")
415
- console.print("🎪 ✅ No AWS resources to clean up")
416
- else:
417
- console.print(f"🎪 No active environment found for PR #{pr_number}")
418
-
419
- except Exception as e:
420
- console.print(f"🎪 [bold red]❌ AWS cleanup failed:[/bold red] {e}")
421
-
422
- # Remove circus labels
423
- github.remove_circus_labels(pr_number)
424
-
425
- 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)
426
275
 
427
276
  except GitHubError as e:
428
- console.print(f"🎪 [bold red]GitHub error:[/bold red] {e.message}")
277
+ p(f" GitHub error: {e}")
278
+ raise typer.Exit(1) from e
429
279
  except Exception as e:
430
- console.print(f"🎪 [bold red]Error:[/bold red] {e}")
280
+ p(f" Error: {e}")
281
+ raise typer.Exit(1) from e
431
282
 
432
283
 
433
284
  @app.command()
@@ -436,37 +287,33 @@ def list(
436
287
  None, "--status", help="Filter by status (running, building, etc.)"
437
288
  ),
438
289
  user: Optional[str] = typer.Option(None, "--user", help="Filter by user"),
439
- ):
290
+ ) -> None:
440
291
  """List all environments"""
441
292
  try:
442
- github = GitHubInterface()
293
+ # Use PullRequest method for data collection
294
+ all_environments = PullRequest.list_all_environments()
443
295
 
444
- # Find all PRs with circus tent labels
445
- pr_numbers = github.find_prs_with_shows()
446
-
447
- if not pr_numbers:
448
- console.print("🎪 No environments currently running")
296
+ if not all_environments:
297
+ p("🎪 No environments currently running")
449
298
  return
450
299
 
451
- # Collect all shows
452
- all_shows = []
453
- for pr_number in pr_numbers:
454
- pr = PullRequest.from_id(pr_number, github)
455
- for show in pr.shows:
456
- # Apply filters
457
- if status_filter and show.status != status_filter:
458
- continue
459
- if user and show.requested_by != user:
460
- continue
461
- all_shows.append(show)
462
-
463
- 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:
464
311
  filter_msg = ""
465
312
  if status_filter:
466
313
  filter_msg += f" with status '{status_filter}'"
467
314
  if user:
468
315
  filter_msg += f" by user '{user}'"
469
- console.print(f"🎪 No environments found{filter_msg}")
316
+ p(f"🎪 No environments found{filter_msg}")
470
317
  return
471
318
 
472
319
  # Create table with full terminal width
@@ -481,50 +328,56 @@ def list(
481
328
 
482
329
  status_emoji = STATUS_DISPLAY
483
330
 
484
- 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"]
485
334
  # Make Superset URL clickable and show full URL
486
- if show.ip:
487
- full_url = f"http://{show.ip}:8080"
335
+ if show_data["ip"]:
336
+ full_url = f"http://{show_data['ip']}:8080"
488
337
  superset_url = f"[link={full_url}]{full_url}[/link]"
489
338
  else:
490
339
  superset_url = "-"
491
340
 
492
341
  # Get AWS service URLs - iTerm2 supports Rich clickable links
493
- 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"])
494
345
  aws_logs_link = f"[link={aws_urls['logs']}]View[/link]"
495
346
 
496
347
  # Make PR number clickable
497
- pr_url = f"https://github.com/apache/superset/pull/{show.pr_number}"
498
- 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]"
499
350
 
500
351
  table.add_row(
501
352
  clickable_pr,
502
- f"{status_emoji.get(show.status, '❓')} {show.status}",
503
- show.sha,
353
+ f"{status_emoji.get(show_data['status'], '❓')} {show_data['status']}",
354
+ show_data["sha"],
504
355
  superset_url,
505
356
  aws_logs_link,
506
- show.ttl,
507
- 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 "-",
508
359
  )
509
360
 
510
- console.print(table)
361
+ p(table)
511
362
 
512
363
  except GitHubError as e:
513
- console.print(f"🎪 [bold red]GitHub error:[/bold red] {e.message}")
364
+ p(f" GitHub error: {e}")
365
+ raise typer.Exit(1) from e
514
366
  except Exception as e:
515
- console.print(f"🎪 [bold red]Error:[/bold red] {e}")
367
+ p(f" Error: {e}")
368
+ raise typer.Exit(1) from e
516
369
 
517
370
 
518
371
  @app.command()
519
- def labels():
372
+ def labels() -> None:
520
373
  """🎪 Show complete circus tent label reference"""
521
374
  from .core.label_colors import LABEL_DEFINITIONS
522
375
 
523
- console.print("🎪 [bold blue]Circus Tent Label Reference[/bold blue]")
524
- console.print()
376
+ p("🎪 [bold blue]Circus Tent Label Reference[/bold blue]")
377
+ p()
525
378
 
526
379
  # User Action Labels (from LABEL_DEFINITIONS)
527
- 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]")
528
381
  trigger_table = Table()
529
382
  trigger_table.add_column("Label", style="green")
530
383
  trigger_table.add_column("Description", style="dim")
@@ -532,11 +385,11 @@ def labels():
532
385
  for label_name, definition in LABEL_DEFINITIONS.items():
533
386
  trigger_table.add_row(f"`{label_name}`", definition["description"])
534
387
 
535
- console.print(trigger_table)
536
- console.print()
388
+ p(trigger_table)
389
+ p()
537
390
 
538
391
  # State Labels
539
- console.print("[bold cyan]📊 State Labels (Automatically managed):[/bold cyan]")
392
+ p("[bold cyan]📊 State Labels (Automatically managed):[/bold cyan]")
540
393
  state_table = Table()
541
394
  state_table.add_column("Label", style="cyan")
542
395
  state_table.add_column("Meaning", style="white")
@@ -552,93 +405,43 @@ def labels():
552
405
  state_table.add_row("🎪 {sha} ⌛ {ttl-policy}", "TTL policy", "🎪 abc123f ⌛ 24h")
553
406
  state_table.add_row("🎪 {sha} 🤡 {username}", "Requested by", "🎪 abc123f 🤡 maxime")
554
407
 
555
- console.print(state_table)
556
- console.print()
408
+ p(state_table)
409
+ p()
557
410
 
558
411
  # Workflow Examples
559
- console.print("[bold magenta]🎪 Complete Workflow Examples:[/bold magenta]")
560
- console.print()
561
-
562
- console.print("[bold]1. Create Environment:[/bold]")
563
- console.print(" • Add label: [green]🎪 ⚡ showtime-trigger-start[/green]")
564
- console.print(
565
- " • Watch for: [blue]🎪 abc123f 🚦 building[/blue] → [green]🎪 abc123f 🚦 running[/green]"
566
- )
567
- console.print(
568
- " • Get URL from: [cyan]🎪 abc123f 🌐 52.1.2.3:8080[/cyan] → http://52.1.2.3:8080"
569
- )
570
- console.print()
571
-
572
- console.print("[bold]2. Freeze Environment (Optional):[/bold]")
573
- console.print(" • Add label: [orange]🎪 🧊 showtime-freeze[/orange]")
574
- console.print(" • Result: Environment won't auto-update on new commits")
575
- console.print(" • Use case: Test specific SHA while continuing development")
576
- console.print()
577
-
578
- console.print("[bold]3. Update to New Commit (Automatic):[/bold]")
579
- console.print(" • New commit pushed → Automatic blue-green rolling update")
580
- console.print(
581
- " • Watch for: [blue]🎪 abc123f 🚦 updating[/blue] → [green]🎪 def456a 🚦 running[/green]"
582
- )
583
- console.print(" • SHA changes: [cyan]🎪 🎯 abc123f[/cyan] → [cyan]🎪 🎯 def456a[/cyan]")
584
- console.print()
585
-
586
- console.print("[bold]4. Clean Up:[/bold]")
587
- console.print(" • Add label: [red]🎪 🛑 showtime-trigger-stop[/red]")
588
- console.print(" • Result: All 🎪 labels removed, AWS resources deleted")
589
- console.print()
590
-
591
- console.print("[bold]📊 Understanding State:[/bold]")
592
- console.print("• [dim]TTL labels show policy (24h, 48h, close) not time remaining[/dim]")
593
- console.print("• [dim]Use 'showtime status {pr-id}' to calculate actual time remaining[/dim]")
594
- console.print("• [dim]Multiple SHA labels during updates (🎯 active, 🏗️ building)[/dim]")
595
- console.print()
596
-
597
- console.print("[dim]💡 Tip: Only maintainers with write access can add trigger labels[/dim]")
598
-
599
-
600
- @app.command()
601
- def test_lifecycle(
602
- pr_number: int,
603
- dry_run_aws: bool = typer.Option(
604
- True, "--dry-run-aws/--real-aws", help="Use mock AWS operations"
605
- ),
606
- dry_run_github: bool = typer.Option(
607
- True, "--dry-run-github/--real-github", help="Use mock GitHub operations"
608
- ),
609
- aws_sleep: int = typer.Option(10, "--aws-sleep", help="Seconds to sleep during AWS operations"),
610
- ):
611
- """🎪 Test full environment lifecycle with mock triggers"""
612
-
613
- console.print(f"🎪 [bold blue]Testing full lifecycle for PR #{pr_number}[/bold blue]")
614
- console.print(
615
- f"AWS: {'DRY-RUN' if dry_run_aws else 'REAL'}, GitHub: {'DRY-RUN' if dry_run_github else 'REAL'}"
616
- )
617
- console.print()
618
-
619
- try:
620
- github = GitHubInterface()
412
+ p("[bold magenta]🎪 Complete Workflow Examples:[/bold magenta]")
413
+ p()
621
414
 
622
- console.print("🎪 [bold]Step 1: Simulate trigger-start[/bold]")
623
- _handle_start_trigger(pr_number, github, dry_run_aws, dry_run_github, aws_sleep, None)
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()
624
420
 
625
- console.print()
626
- console.print("🎪 [bold]Step 2: Simulate config update[/bold]")
627
- console.print("🎪 [dim]Config changes now done via code commits, not labels[/dim]")
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()
628
426
 
629
- console.print()
630
- console.print("🎪 [bold]Step 3: Simulate trigger-sync (new commit)[/bold]")
631
- _handle_sync_trigger(pr_number, github, dry_run_aws, dry_run_github, aws_sleep)
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()
632
432
 
633
- console.print()
634
- console.print("🎪 [bold]Step 4: Simulate trigger-stop[/bold]")
635
- _handle_stop_trigger(pr_number, github, dry_run_aws, dry_run_github)
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()
636
437
 
637
- console.print()
638
- console.print("🎪 [bold green]Full lifecycle test complete![/bold green]")
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()
639
443
 
640
- except Exception as e:
641
- 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]")
642
445
 
643
446
 
644
447
  @app.command()
@@ -648,158 +451,133 @@ def sync(
648
451
  check_only: bool = typer.Option(
649
452
  False, "--check-only", help="Check what actions are needed without executing"
650
453
  ),
651
- deploy: bool = typer.Option(
652
- False, "--deploy", help="Execute deployment actions (assumes build is complete)"
653
- ),
654
454
  dry_run_aws: bool = typer.Option(
655
455
  False, "--dry-run-aws", help="Skip AWS operations, use mock data"
656
456
  ),
657
457
  dry_run_github: bool = typer.Option(
658
458
  False, "--dry-run-github", help="Skip GitHub label operations"
659
459
  ),
460
+ dry_run_docker: bool = typer.Option(
461
+ False, "--dry-run-docker", help="Skip Docker build, use mock success"
462
+ ),
660
463
  aws_sleep: int = typer.Option(
661
464
  0, "--aws-sleep", help="Seconds to sleep during AWS operations (for testing)"
662
465
  ),
663
466
  docker_tag: Optional[str] = typer.Option(
664
467
  None, "--docker-tag", help="Override Docker image tag (e.g., pr-34639-9a82c20-ci, latest)"
665
468
  ),
666
- ):
469
+ ) -> None:
667
470
  """🎪 Intelligently sync PR to desired state (called by GitHub Actions)"""
668
471
  try:
669
- github = GitHubInterface()
670
- pr = PullRequest.from_id(pr_number, github)
472
+ # Use singletons - no interface creation needed
473
+ pr = PullRequest.from_id(pr_number)
671
474
 
672
- # Get PR metadata for state-based decisions
673
- pr_data = github.get_pr_data(pr_number)
674
- pr_state = pr_data.get("state", "open") # open, closed
675
-
676
- # Get SHA - use provided SHA or default to latest
475
+ # Get target SHA - use provided SHA or default to latest
677
476
  if sha:
678
477
  target_sha = sha
679
- console.print(f"🎪 Using specified SHA: {target_sha[:7]}")
478
+ p(f"🎪 Using specified SHA: {target_sha[:7]}")
680
479
  else:
681
- target_sha = github.get_latest_commit_sha(pr_number)
682
- console.print(f"🎪 Using latest SHA: {target_sha[:7]}")
480
+ from .core.pull_request import get_github
683
481
 
684
- # Determine what actions are needed
685
- action_needed = _determine_sync_action(pr, pr_state, target_sha)
482
+ target_sha = get_github().get_latest_commit_sha(pr_number)
483
+ p(f"🎪 Using latest SHA: {target_sha[:7]}")
686
484
 
687
- if check_only:
688
- # Output structured results for GitHub Actions
689
- console.print(f"action_needed={action_needed}")
485
+ # Get PR state for analysis
486
+ from .core.pull_request import get_github
690
487
 
691
- # Build needed for new environments and updates (SHA changes)
692
- build_needed = action_needed in ["create_environment", "rolling_update", "auto_sync"]
693
- console.print(f"build_needed={str(build_needed).lower()}")
488
+ pr_data = get_github().get_pr_data(pr_number)
489
+ pr_state = pr_data.get("state", "open")
694
490
 
695
- # Deploy needed for everything except no_action
696
- deploy_needed = action_needed != "no_action"
697
- console.print(f"deploy_needed={str(deploy_needed).lower()}")
491
+ if check_only:
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}")
698
498
  return
699
499
 
700
- console.print(
701
- f"🎪 [bold blue]Syncing PR #{pr_number}[/bold blue] (state: {pr_state}, SHA: {target_sha[:7]})"
702
- )
703
- console.print(f"🎪 Action needed: {action_needed}")
500
+ # Execution mode - do the sync
501
+ p(f"🎪 [bold blue]Syncing PR #{pr_number}[/bold blue] (SHA: {target_sha[:7]})")
704
502
 
705
- # Execute the determined action
706
- if action_needed == "cleanup":
707
- console.print("🎪 PR is closed - cleaning up environment")
503
+ # Handle closed PRs specially
504
+ if pr_state == "closed":
505
+ p("🎪 PR is closed - cleaning up environment")
708
506
  if pr.current_show:
709
- _handle_stop_trigger(pr_number, github, dry_run_aws, dry_run_github)
710
- else:
711
- console.print("🎪 No environment to clean up")
712
- return
713
-
714
- # 2. Find explicit trigger labels
715
- trigger_labels = [label for label in pr.labels if "showtime-trigger-" in label]
716
-
717
- # 3. Handle explicit triggers first
718
- if trigger_labels:
719
- console.print(f"🎪 Processing {len(trigger_labels)} explicit trigger(s)")
720
-
721
- for trigger in trigger_labels:
722
- console.print(f"🎪 Processing: {trigger}")
723
-
724
- # Remove trigger label immediately (atomic operation)
725
- if not dry_run_github:
726
- github.remove_label(pr_number, trigger)
507
+ stop_result = pr.stop_environment(
508
+ dry_run_github=dry_run_github, dry_run_aws=dry_run_aws
509
+ )
510
+ if stop_result.success:
511
+ p("🎪 ✅ Cleanup completed")
727
512
  else:
728
- console.print(
729
- f"🎪 [bold yellow]DRY-RUN-GITHUB[/bold yellow] - Would remove: {trigger}"
730
- )
731
-
732
- # Process the trigger
733
- if "showtime-trigger-start" in trigger:
734
- _handle_start_trigger(
735
- pr_number, github, dry_run_aws, dry_run_github, aws_sleep, docker_tag
736
- )
737
- elif "showtime-trigger-stop" in trigger:
738
- _handle_stop_trigger(pr_number, github, dry_run_aws, dry_run_github)
739
-
740
- console.print("🎪 All explicit triggers processed!")
513
+ p(f"🎪 ❌ Cleanup failed: {stop_result.error}")
514
+ else:
515
+ p("🎪 No environment to clean up")
741
516
  return
742
517
 
743
- # 4. No explicit triggers - check for implicit sync needs
744
- 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
+ )
745
525
 
746
- if pr.current_show:
747
- # Environment exists - check if it needs updating
748
- if pr.current_show.needs_update(target_sha):
749
- console.print(
750
- f"🎪 Environment outdated ({pr.current_show.sha} → {target_sha[:7]}) - auto-syncing"
751
- )
752
- _handle_sync_trigger(pr_number, github, dry_run_aws, dry_run_github, aws_sleep)
753
- else:
754
- console.print(f"🎪 Environment is up to date ({pr.current_show.sha})")
526
+ if result.success:
527
+ p(f"🎪 Sync completed: {result.action_taken}")
755
528
  else:
756
- console.print(f"🎪 No environment exists for PR #{pr_number} - no action needed")
757
- console.print("🎪 💡 Add '🎪 trigger-start' label to create an environment")
529
+ p(f"🎪 Sync failed: {result.error}")
530
+ raise typer.Exit(1)
758
531
 
532
+ except GitHubError as e:
533
+ p(f"❌ GitHub error: {e}")
534
+ raise typer.Exit(1) from e
759
535
  except Exception as e:
760
- console.print(f"🎪 [bold red]Error processing triggers:[/bold red] {e}")
536
+ p(f" Error: {e}")
537
+ raise typer.Exit(1) from e
761
538
 
762
539
 
763
540
  @app.command()
764
- def handle_sync(pr_number: int):
541
+ def handle_sync(pr_number: int) -> None:
765
542
  """🎪 Handle new commit sync (called by GitHub Actions on PR synchronize)"""
766
543
  try:
767
- github = GitHubInterface()
768
- pr = PullRequest.from_id(pr_number, github)
544
+ pr = PullRequest.from_id(pr_number)
769
545
 
770
546
  # Only sync if there's an active environment
771
547
  if not pr.current_show:
772
- console.print(f"🎪 No active environment for PR #{pr_number} - skipping sync")
548
+ p(f"🎪 No active environment for PR #{pr_number} - skipping sync")
773
549
  return
774
550
 
775
551
  # Get latest commit SHA
776
- 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)
777
555
 
778
556
  # Check if update is needed
779
557
  if not pr.current_show.needs_update(latest_sha):
780
- console.print(f"🎪 Environment already up to date for PR #{pr_number}")
558
+ p(f"🎪 Environment already up to date for PR #{pr_number}")
781
559
  return
782
560
 
783
- console.print(f"🎪 Syncing PR #{pr_number} to commit {latest_sha[:7]}")
561
+ p(f"🎪 Syncing PR #{pr_number} to commit {latest_sha[:7]}")
784
562
 
785
563
  # TODO: Implement rolling update logic
786
- console.print("🎪 [bold yellow]Sync logic not yet implemented[/bold yellow]")
564
+ p("🎪 [bold yellow]Sync logic not yet implemented[/bold yellow]")
787
565
 
788
566
  except Exception as e:
789
- console.print(f"🎪 [bold red]Error handling sync:[/bold red] {e}")
567
+ p(f"🎪 [bold red]Error handling sync:[/bold red] {e}")
790
568
 
791
569
 
792
570
  @app.command()
793
571
  def setup_labels(
794
572
  dry_run: bool = typer.Option(False, "--dry-run", help="Show what labels would be created"),
795
- ):
573
+ ) -> None:
796
574
  """🎪 Set up GitHub label definitions with colors and descriptions"""
797
575
  try:
798
576
  from .core.label_colors import LABEL_DEFINITIONS
799
577
 
800
578
  github = GitHubInterface()
801
579
 
802
- console.print("🎪 [bold blue]Setting up circus tent label definitions...[/bold blue]")
580
+ p("🎪 [bold blue]Setting up circus tent label definitions...[/bold blue]")
803
581
 
804
582
  created_count = 0
805
583
  updated_count = 0
@@ -809,32 +587,32 @@ def setup_labels(
809
587
  description = definition["description"]
810
588
 
811
589
  if dry_run:
812
- console.print(f"🏷️ Would create: [bold]{label_name}[/bold]")
813
- console.print(f" Color: #{color}")
814
- console.print(f" Description: {description}")
590
+ p(f"🏷️ Would create: [bold]{label_name}[/bold]")
591
+ p(f" Color: #{color}")
592
+ p(f" Description: {description}")
815
593
  else:
816
594
  try:
817
595
  # Try to create or update the label
818
596
  success = github.create_or_update_label(label_name, color, description)
819
597
  if success:
820
598
  created_count += 1
821
- console.print(f"✅ Created: [bold]{label_name}[/bold]")
599
+ p(f"✅ Created: [bold]{label_name}[/bold]")
822
600
  else:
823
601
  updated_count += 1
824
- console.print(f"🔄 Updated: [bold]{label_name}[/bold]")
602
+ p(f"🔄 Updated: [bold]{label_name}[/bold]")
825
603
  except Exception as e:
826
- console.print(f"❌ Failed to create {label_name}: {e}")
604
+ p(f"❌ Failed to create {label_name}: {e}")
827
605
 
828
606
  if not dry_run:
829
- console.print("\n🎪 [bold green]Label setup complete![/bold green]")
830
- console.print(f" 📊 Created: {created_count}")
831
- console.print(f" 🔄 Updated: {updated_count}")
832
- 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(
833
611
  "\n🎪 [dim]Note: Dynamic labels (with SHA) are created automatically during deployment[/dim]"
834
612
  )
835
613
 
836
614
  except Exception as e:
837
- console.print(f"🎪 [bold red]Error setting up labels:[/bold red] {e}")
615
+ p(f"🎪 [bold red]Error setting up labels:[/bold red] {e}")
838
616
 
839
617
 
840
618
  @app.command()
@@ -854,706 +632,45 @@ def cleanup(
854
632
  "--cleanup-labels/--no-cleanup-labels",
855
633
  help="Also cleanup SHA-based label definitions from repository",
856
634
  ),
857
- ):
635
+ ) -> None:
858
636
  """🎪 Clean up orphaned or expired environments and labels"""
859
637
  try:
860
- github = GitHubInterface()
861
-
862
- # Step 1: Clean up expired AWS ECS services
863
- console.print("🎪 [bold blue]Checking AWS ECS services for cleanup...[/bold blue]")
864
-
865
- from .core.aws import AWSInterface
866
-
867
- aws = AWSInterface()
868
-
869
- try:
870
- expired_services = aws.find_expired_services(older_than)
871
-
872
- if expired_services:
873
- console.print(f"🎪 Found {len(expired_services)} expired ECS services")
874
-
875
- for service_info in expired_services:
876
- service_name = service_info["service_name"]
877
- pr_number = service_info["pr_number"]
878
- age_hours = service_info["age_hours"]
879
-
880
- if dry_run:
881
- console.print(
882
- f"🎪 [yellow]Would delete service {service_name} (PR #{pr_number}, {age_hours:.1f}h old)[/yellow]"
883
- )
884
- console.print(
885
- 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]"
886
- )
887
- else:
888
- console.print(
889
- f"🎪 Deleting expired service {service_name} (PR #{pr_number}, {age_hours:.1f}h old)"
890
- )
891
- # Create minimal Show object for URL generation
892
- from .core.circus import Show
893
-
894
- temp_show = Show(
895
- pr_number=pr_number, sha=service_name.split("-")[2], status="cleanup"
896
- )
897
- _show_service_urls(temp_show, "cleanup")
898
-
899
- # Delete ECS service
900
- if aws._delete_ecs_service(service_name):
901
- # Delete ECR image
902
- image_tag = f"pr-{pr_number}-ci"
903
- aws._delete_ecr_image(image_tag)
904
- console.print(f"🎪 ✅ Cleaned up {service_name}")
905
- else:
906
- console.print(f"🎪 ❌ Failed to clean up {service_name}")
907
- else:
908
- console.print("🎪 [dim]No expired ECS services found[/dim]")
909
-
910
- except Exception as e:
911
- console.print(f"🎪 [bold red]AWS cleanup failed:[/bold red] {e}")
912
-
913
- # Step 2: Find and clean up expired environments from PRs
914
- if respect_ttl:
915
- console.print("🎪 Finding environments expired based on individual TTL labels")
916
- else:
917
- console.print(f"🎪 Finding environments older than {older_than}")
918
- prs_with_shows = github.find_prs_with_shows()
919
-
920
- if not prs_with_shows:
921
- console.print("🎪 [dim]No PRs with circus tent labels found[/dim]")
922
- else:
923
- console.print(f"🎪 Found {len(prs_with_shows)} PRs with shows")
924
-
925
- import re
926
- from datetime import datetime, timedelta
927
-
928
- from .core.circus import PullRequest, get_effective_ttl, parse_ttl_days
929
-
930
- # Parse max_age if provided (safety ceiling)
931
- max_age_days = None
932
- if max_age:
933
- max_age_days = parse_ttl_days(max_age)
934
-
935
- cleaned_prs = 0
936
- for pr_number in prs_with_shows:
937
- try:
938
- pr = PullRequest.from_id(pr_number, github)
939
- expired_shows = []
940
-
941
- if respect_ttl:
942
- # Use individual TTL labels
943
- effective_ttl_days = get_effective_ttl(pr)
944
-
945
- if effective_ttl_days is None:
946
- # "never" label found - skip cleanup
947
- console.print(
948
- f"🎪 [blue]PR #{pr_number} marked as 'never expire' - skipping[/blue]"
949
- )
950
- continue
951
-
952
- # Apply max_age ceiling if specified
953
- if max_age_days and effective_ttl_days > max_age_days:
954
- console.print(
955
- f"🎪 [yellow]PR #{pr_number} TTL ({effective_ttl_days}d) exceeds max-age ({max_age_days}d)[/yellow]"
956
- )
957
- effective_ttl_days = max_age_days
958
-
959
- cutoff_time = datetime.now() - timedelta(days=effective_ttl_days)
960
- console.print(
961
- f"🎪 PR #{pr_number} effective TTL: {effective_ttl_days} days"
962
- )
963
-
964
- else:
965
- # Use global older_than parameter (current behavior)
966
- time_match = re.match(r"(\d+)([hd])", older_than)
967
- if not time_match:
968
- console.print(
969
- f"🎪 [bold red]Invalid time format:[/bold red] {older_than}"
970
- )
971
- return
972
-
973
- hours = int(time_match.group(1))
974
- if time_match.group(2) == "d":
975
- hours *= 24
976
-
977
- cutoff_time = datetime.now() - timedelta(hours=hours)
978
-
979
- # Check all shows in the PR for expiration
980
- for show in pr.shows:
981
- if show.created_at:
982
- try:
983
- # Parse timestamp (format: 2024-01-15T14-30)
984
- show_time = datetime.fromisoformat(
985
- show.created_at.replace("-", ":")
986
- )
987
- if show_time < cutoff_time:
988
- expired_shows.append(show)
989
- except (ValueError, AttributeError):
990
- # If we can't parse the timestamp, consider it expired
991
- expired_shows.append(show)
992
-
993
- if expired_shows:
994
- if dry_run:
995
- console.print(
996
- f"🎪 [yellow]Would clean {len(expired_shows)} expired shows from PR #{pr_number}[/yellow]"
997
- )
998
- for show in expired_shows:
999
- console.print(f" - SHA {show.sha} ({show.status})")
1000
- else:
1001
- console.print(
1002
- f"🎪 Cleaning {len(expired_shows)} expired shows from PR #{pr_number}"
1003
- )
1004
-
1005
- # Remove circus labels for expired shows
1006
- current_labels = github.get_circus_labels(pr_number)
1007
- labels_to_keep = []
1008
-
1009
- for label in current_labels:
1010
- # Keep labels that don't belong to expired shows
1011
- should_keep = True
1012
- for expired_show in expired_shows:
1013
- if expired_show.sha in label:
1014
- should_keep = False
1015
- break
1016
- if should_keep:
1017
- labels_to_keep.append(label)
1018
-
1019
- # Update PR labels
1020
- github.remove_circus_labels(pr_number)
1021
- for label in labels_to_keep:
1022
- github.add_label(pr_number, label)
1023
-
1024
- cleaned_prs += 1
1025
-
1026
- except Exception as e:
1027
- console.print(f"🎪 [red]Error processing PR #{pr_number}:[/red] {e}")
1028
-
1029
- if not dry_run and cleaned_prs > 0:
1030
- console.print(f"🎪 [green]Cleaned up environments from {cleaned_prs} PRs[/green]")
1031
-
1032
- # Step 2: Clean up SHA-based label definitions from repository
1033
- if cleanup_labels:
1034
- console.print("🎪 Finding SHA-based labels in repository")
1035
- sha_labels = github.cleanup_sha_labels(dry_run=dry_run)
1036
-
1037
- if sha_labels:
1038
- if dry_run:
1039
- console.print(
1040
- f"🎪 [yellow]Would delete {len(sha_labels)} SHA-based label definitions:[/yellow]"
1041
- )
1042
- for label in sha_labels[:10]: # Show first 10
1043
- console.print(f" - {label}")
1044
- if len(sha_labels) > 10:
1045
- console.print(f" ... and {len(sha_labels) - 10} more")
1046
- else:
1047
- console.print(
1048
- f"🎪 [green]Deleted {len(sha_labels)} SHA-based label definitions[/green]"
1049
- )
1050
- else:
1051
- console.print("🎪 [dim]No SHA-based labels found to clean[/dim]")
1052
-
1053
- except Exception as e:
1054
- console.print(f"🎪 [bold red]Error during cleanup:[/bold red] {e}")
1055
-
1056
-
1057
- # Helper functions for trigger processing
1058
- def _handle_start_trigger(
1059
- pr_number: int,
1060
- github: GitHubInterface,
1061
- dry_run_aws: bool = False,
1062
- dry_run_github: bool = False,
1063
- aws_sleep: int = 0,
1064
- docker_tag_override: Optional[str] = None,
1065
- force: bool = False,
1066
- ):
1067
- """Handle start trigger"""
1068
- import time
1069
- from datetime import datetime
1070
-
1071
- console.print(f"🎪 Starting environment for PR #{pr_number}")
1072
-
1073
- try:
1074
- # Get latest SHA and GitHub actor
1075
- latest_sha = github.get_latest_commit_sha(pr_number)
1076
- github_actor = _get_github_actor()
1077
-
1078
- # Create new show
1079
- show = Show(
1080
- pr_number=pr_number,
1081
- sha=short_sha(latest_sha),
1082
- status="building",
1083
- created_at=datetime.utcnow().strftime("%Y-%m-%dT%H-%M"),
1084
- ttl="24h",
1085
- requested_by=github_actor,
1086
- )
1087
-
1088
- console.print(f"🎪 Creating environment {show.aws_service_name}")
1089
-
1090
- # Post confirmation comment
1091
- confirmation_comment = start_comment(show)
1092
-
1093
- if not dry_run_github:
1094
- github.post_comment(pr_number, confirmation_comment)
1095
-
1096
- # Set building state labels
1097
- building_labels = show.to_circus_labels()
1098
- console.print("🎪 Setting building state labels:")
1099
- for label in building_labels:
1100
- console.print(f" + {label}")
1101
-
1102
- # Set building labels
1103
- if not dry_run_github:
1104
- # Actually set the labels for real testing
1105
- console.print("🎪 Setting labels on GitHub...")
1106
- # Remove existing circus labels first
1107
- github.remove_circus_labels(pr_number)
1108
- # Add new labels one by one
1109
- for label in building_labels:
1110
- github.add_label(pr_number, label)
1111
- console.print("🎪 ✅ Labels set on GitHub!")
1112
- else:
1113
- console.print("🎪 [bold yellow]DRY-RUN-GITHUB[/bold yellow] - Would set labels")
1114
-
1115
- if dry_run_aws:
1116
- console.print("🎪 [bold yellow]DRY-RUN-AWS[/bold yellow] - Skipping AWS operations")
1117
- if aws_sleep > 0:
1118
- console.print(f"🎪 Sleeping {aws_sleep}s to simulate AWS build time...")
1119
- time.sleep(aws_sleep)
1120
-
1121
- # Mock successful deployment
1122
- mock_ip = "52.1.2.3"
1123
- console.print(
1124
- f"🎪 [bold green]Mock AWS deployment successful![/bold green] IP: {mock_ip}"
1125
- )
1126
-
1127
- # Update to running state
1128
- show.status = "running"
1129
- show.ip = mock_ip
1130
-
1131
- running_labels = show.to_circus_labels()
1132
- console.print("🎪 Setting running state labels:")
1133
- for label in running_labels:
1134
- console.print(f" + {label}")
1135
-
1136
- # Set running labels
1137
- if not dry_run_github:
1138
- console.print("🎪 Updating to running state...")
1139
- # Remove existing circus labels first
1140
- github.remove_circus_labels(pr_number)
1141
- # Add new running labels
1142
- for label in running_labels:
1143
- github.add_label(pr_number, label)
1144
- console.print("🎪 ✅ Labels updated to running state!")
1145
- else:
1146
- console.print(
1147
- "🎪 [bold yellow]DRY-RUN-GITHUB[/bold yellow] - Would update to running state"
1148
- )
1149
-
1150
- # Post success comment (only in dry-run-aws mode since we have mock IP)
1151
- # Create mock show with IP for success comment
1152
- mock_show = Show(
1153
- pr_number=show.pr_number,
1154
- sha=show.sha,
1155
- status="running",
1156
- ip=mock_ip,
1157
- ttl=show.ttl,
1158
- requested_by=show.requested_by,
1159
- )
1160
- success_comment_text = success_comment(mock_show)
1161
-
1162
- if not dry_run_github:
1163
- github.post_comment(pr_number, success_comment_text)
1164
-
1165
- else:
1166
- # Real AWS operations
1167
- from .core.aws import AWSInterface, EnvironmentResult
1168
-
1169
- console.print("🎪 [bold blue]Starting AWS deployment...[/bold blue]")
1170
- aws = AWSInterface()
1171
-
1172
- # Show logs URL immediately for monitoring
1173
- _show_service_urls(show, "deployment")
1174
-
1175
- # Parse feature flags from PR description (replicate GHA feature flag logic)
1176
- feature_flags = _extract_feature_flags_from_pr(pr_number, github)
1177
-
1178
- # Create environment (synchronous, matches GHA wait behavior)
1179
- result: EnvironmentResult = aws.create_environment(
1180
- pr_number=pr_number,
1181
- sha=latest_sha,
1182
- github_user=github_actor,
1183
- feature_flags=feature_flags,
1184
- image_tag_override=docker_tag_override,
1185
- force=force,
1186
- )
1187
-
1188
- if result.success:
1189
- console.print(
1190
- f"🎪 [bold green]✅ Green service deployed successfully![/bold green] IP: {result.ip}"
1191
- )
1192
-
1193
- # Show helpful links for the new service
1194
- console.print("\n🎪 [bold blue]Useful Links:[/bold blue]")
1195
- console.print(f" 🌐 Environment: http://{result.ip}:8080")
1196
-
1197
- # Use centralized URL generation
1198
- urls = _get_service_urls(show)
1199
- console.print(f" 📊 ECS Service: {urls['service']}")
1200
- console.print(f" 📝 Service Logs: {urls['logs']}")
1201
- console.print(f" 🏥 Health Checks: {urls['health']}")
1202
- console.print(
1203
- f" 🔍 GitHub PR: https://github.com/apache/superset/pull/{pr_number}"
1204
- )
1205
- console.print(
1206
- "\n🎪 [dim]Note: Superset takes 2-3 minutes to initialize after container starts[/dim]"
1207
- )
1208
-
1209
- # Blue-Green Traffic Switch: Update GitHub labels to point to new service
1210
- console.print(
1211
- f"\n🎪 [bold blue]Switching traffic to green service {latest_sha[:7]}...[/bold blue]"
1212
- )
1213
-
1214
- # Check for existing services to show blue-green transition
1215
- from .core.aws import AWSInterface
1216
-
1217
- aws = AWSInterface()
1218
- existing_services = aws._find_pr_services(pr_number)
1219
-
1220
- if len(existing_services) > 1:
1221
- console.print("🔄 Blue-Green Deployment:")
1222
- blue_services = []
1223
- for svc in existing_services:
1224
- if svc["sha"] == latest_sha[:7]:
1225
- console.print(
1226
- f" 🟢 Green: {svc['service_name']} (NEW - receiving traffic)"
1227
- )
1228
- else:
1229
- console.print(
1230
- f" 🔵 Blue: {svc['service_name']} (OLD - will be cleaned up in 5 minutes)"
1231
- )
1232
- blue_services.append(svc)
1233
-
1234
- # Schedule cleanup of blue services
1235
- if blue_services:
1236
- console.print(
1237
- f"\n🧹 Scheduling cleanup of {len(blue_services)} blue service(s) in 5 minutes..."
1238
- )
1239
- _schedule_blue_cleanup(pr_number, blue_services)
1240
-
1241
- # Update to running state with new SHA
1242
- show.status = "running"
1243
- show.ip = result.ip
1244
-
1245
- # Traffic switching happens here - update GitHub labels atomically
1246
- running_labels = show.to_circus_labels()
1247
- console.print("\n🎪 Setting running state labels (traffic switch):")
1248
- for label in running_labels:
1249
- console.print(f" + {label}")
1250
-
1251
- if not dry_run_github:
1252
- console.print("🎪 Executing traffic switch via GitHub labels...")
1253
- # Remove existing circus labels first
1254
- github.remove_circus_labels(pr_number)
1255
- # Add new running labels - this switches traffic atomically
1256
- for label in running_labels:
1257
- github.add_label(pr_number, label)
1258
- console.print("🎪 ✅ Labels updated to running state!")
1259
-
1260
- # Post success comment with real IP
1261
- # Update show with real IP for comment
1262
- show.ip = result.ip
1263
- show.status = "running"
1264
- success_comment_text = success_comment(show, feature_count=len(feature_flags))
1265
-
1266
- github.post_comment(pr_number, success_comment_text)
1267
-
1268
- else:
1269
- console.print(f"🎪 [bold red]❌ AWS deployment failed:[/bold red] {result.error}")
1270
-
1271
- # Update to failed state
1272
- show.status = "failed"
1273
- failed_labels = show.to_circus_labels()
1274
-
1275
- if not dry_run_github:
1276
- console.print("🎪 Setting failed state labels...")
1277
- github.remove_circus_labels(pr_number)
1278
- for label in failed_labels:
1279
- github.add_label(pr_number, label)
1280
-
1281
- # Post failure comment
1282
- failure_comment = f"""🎪 @{github_actor} Environment creation failed.
1283
-
1284
- **Error:** {result.error}
1285
- **Environment:** `{show.sha}`
1286
-
1287
- Please check the logs and try again.
1288
-
1289
- {_get_showtime_footer()}"""
1290
-
1291
- github.post_comment(pr_number, failure_comment)
1292
-
1293
- except Exception as e:
1294
- console.print(f"🎪 [bold red]Start trigger failed:[/bold red] {e}")
1295
-
1296
-
1297
- def _extract_feature_flags_from_pr(pr_number: int, github: GitHubInterface) -> list:
1298
- """Extract feature flags from PR description (replicate GHA eval-feature-flags step)"""
1299
- import re
1300
-
1301
- try:
1302
- # Get PR description
1303
- pr_data = github.get_pr_data(pr_number)
1304
- description = pr_data.get("body") or ""
1305
-
1306
- # Replicate exact GHA regex pattern: FEATURE_(\w+)=(\w+)
1307
- pattern = r"FEATURE_(\w+)=(\w+)"
1308
- results = []
1309
-
1310
- for match in re.finditer(pattern, description):
1311
- feature_config = {"name": f"SUPERSET_FEATURE_{match.group(1)}", "value": match.group(2)}
1312
- results.append(feature_config)
1313
- console.print(
1314
- f"🎪 Found feature flag: {feature_config['name']}={feature_config['value']}"
1315
- )
1316
-
1317
- return results
1318
-
1319
- except Exception as e:
1320
- console.print(f"🎪 Warning: Could not extract feature flags: {e}")
1321
- return []
1322
-
1323
-
1324
- def _handle_stop_trigger(
1325
- pr_number: int, github: GitHubInterface, dry_run_aws: bool = False, dry_run_github: bool = False
1326
- ):
1327
- """Handle stop trigger"""
1328
-
1329
- console.print(f"🎪 Stopping environment for PR #{pr_number}")
1330
-
1331
- try:
1332
- pr = PullRequest.from_id(pr_number, github)
638
+ # Parse older_than to hours
639
+ import re
1333
640
 
1334
- if not pr.current_show:
1335
- 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}")
1336
644
  return
1337
645
 
1338
- show = pr.current_show
1339
- console.print(f"🎪 Destroying environment: {show.aws_service_name}")
1340
-
1341
- if dry_run_aws:
1342
- console.print("🎪 [bold yellow]DRY-RUN-AWS[/bold yellow] - Would delete AWS resources")
1343
- console.print(f" - ECS service: {show.aws_service_name}")
1344
- console.print(f" - ECR image: {show.aws_image_tag}")
1345
- else:
1346
- # Real AWS cleanup (replicate ephemeral-env-pr-close.yml logic)
1347
- from .core.aws import AWSInterface
1348
-
1349
- console.print("🎪 [bold blue]Starting AWS cleanup...[/bold blue]")
1350
- aws = AWSInterface()
1351
-
1352
- try:
1353
- # Show logs URL for monitoring cleanup
1354
- _show_service_urls(show, "cleanup")
1355
-
1356
- # Step 1: Check if ECS service exists and is active (replicate GHA describe-services)
1357
- service_name = show.ecs_service_name
1358
- console.print(f"🎪 Checking ECS service: {service_name}")
1359
-
1360
- service_exists = aws._service_exists(service_name)
646
+ max_age_hours = int(time_match.group(1))
647
+ if time_match.group(2) == "d":
648
+ max_age_hours *= 24
1361
649
 
1362
- if service_exists:
1363
- console.print(f"🎪 Found active ECS service: {service_name}")
650
+ p(f"🎪 [bold blue]Cleaning environments older than {max_age_hours}h...[/bold blue]")
1364
651
 
1365
- # Step 2: Delete ECS service (replicate GHA delete-service)
1366
- console.print("🎪 Deleting ECS service...")
1367
- success = aws._delete_ecs_service(service_name)
1368
-
1369
- if success:
1370
- console.print("🎪 ✅ ECS service deleted successfully")
1371
-
1372
- # Step 3: Delete ECR image tag (replicate GHA batch-delete-image)
1373
- image_tag = f"pr-{pr_number}-ci" # Match GHA image tag format
1374
- console.print(f"🎪 Deleting ECR image tag: {image_tag}")
1375
-
1376
- ecr_success = aws._delete_ecr_image(image_tag)
1377
-
1378
- if ecr_success:
1379
- console.print("🎪 ✅ ECR image deleted successfully")
1380
- else:
1381
- console.print("🎪 ⚠️ ECR image deletion failed (may not exist)")
1382
-
1383
- console.print(
1384
- "🎪 [bold green]✅ AWS cleanup completed successfully![/bold green]"
1385
- )
1386
-
1387
- else:
1388
- console.print("🎪 [bold red]❌ ECS service deletion failed[/bold red]")
1389
-
1390
- else:
1391
- console.print(f"🎪 No active ECS service found: {service_name}")
1392
- console.print("🎪 ✅ No AWS resources to clean up")
1393
-
1394
- except Exception as e:
1395
- console.print(f"🎪 [bold red]❌ AWS cleanup failed:[/bold red] {e}")
1396
-
1397
- # Remove all circus labels for this PR
1398
- console.print(f"🎪 Removing all circus labels for PR #{pr_number}")
1399
- if not dry_run_github:
1400
- github.remove_circus_labels(pr_number)
1401
-
1402
- # Post cleanup comment
1403
- github_actor = _get_github_actor()
1404
- cleanup_comment = f"""🎪 @{github_actor} Environment `{show.sha}` cleaned up
1405
-
1406
- **AWS Resources:** ECS service and ECR image deleted
1407
- **Cost Impact:** No further charges
1408
-
1409
- Add `🎪 trigger-start` to create a new environment.
1410
-
1411
- {_get_showtime_footer()}"""
1412
-
1413
- if not dry_run_github:
1414
- github.post_comment(pr_number, cleanup_comment)
1415
-
1416
- console.print("🎪 [bold green]Environment stopped![/bold green]")
1417
-
1418
- except Exception as e:
1419
- console.print(f"🎪 [bold red]Stop trigger failed:[/bold red] {e}")
1420
-
1421
-
1422
- def _handle_sync_trigger(
1423
- pr_number: int,
1424
- github: GitHubInterface,
1425
- dry_run_aws: bool = False,
1426
- dry_run_github: bool = False,
1427
- aws_sleep: int = 0,
1428
- ):
1429
- """Handle sync trigger"""
1430
- import time
1431
- from datetime import datetime
1432
-
1433
- console.print(f"🎪 Syncing environment for PR #{pr_number}")
1434
-
1435
- try:
1436
- pr = PullRequest.from_id(pr_number, github)
1437
-
1438
- if not pr.current_show:
1439
- console.print(f"🎪 No active environment for PR #{pr_number}")
1440
- return
1441
-
1442
- latest_sha = github.get_latest_commit_sha(pr_number)
1443
-
1444
- if not pr.current_show.needs_update(latest_sha):
1445
- 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")
1446
656
  return
1447
657
 
1448
- console.print(f"🎪 Rolling update: {pr.current_show.sha} → {latest_sha[:7]}")
1449
-
1450
- # Create new show for building
1451
- new_show = Show(
1452
- pr_number=pr_number,
1453
- sha=latest_sha[:7],
1454
- status="building",
1455
- created_at=datetime.utcnow().strftime("%Y-%m-%dT%H-%M"),
1456
- ttl=pr.current_show.ttl,
1457
- requested_by=pr.current_show.requested_by,
1458
- )
1459
-
1460
- console.print(f"🎪 Building new environment: {new_show.aws_service_name}")
1461
-
1462
- if dry_run_aws:
1463
- console.print("🎪 [bold yellow]DRY-RUN-AWS[/bold yellow] - Mocking rolling update")
1464
- if aws_sleep > 0:
1465
- console.print(f"🎪 Sleeping {aws_sleep}s to simulate build + deploy...")
1466
- time.sleep(aws_sleep)
1467
-
1468
- # Mock successful update
1469
- new_show.status = "running"
1470
- new_show.ip = "52.4.5.6" # New mock IP
1471
-
1472
- console.print("🎪 [bold green]Mock rolling update complete![/bold green]")
1473
- console.print(f"🎪 Traffic switched to {new_show.sha} at {new_show.ip}")
1474
-
1475
- # Post rolling update success comment
1476
- update_comment = f"""🎪 Environment updated: {pr.current_show.sha} → `{new_show.sha}`
1477
-
1478
- **New Environment:** http://{new_show.ip}:8080
1479
- **Update:** Zero-downtime rolling deployment
1480
- **Old Environment:** Automatically cleaned up
1481
-
1482
- Your latest changes are now live.
1483
-
1484
- {_get_showtime_footer()}"""
1485
-
1486
- if not dry_run_github:
1487
- 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
1488
663
 
664
+ if cleaned_count > 0:
665
+ p(f"🎪 ✅ Cleaned up {cleaned_count} expired environments")
1489
666
  else:
1490
- # Real rolling update - use same blue-green deployment logic
1491
-
1492
- from .core.aws import AWSInterface, EnvironmentResult
1493
-
1494
- console.print("🎪 [bold blue]Starting real rolling update...[/bold blue]")
1495
-
1496
- # Post rolling update start comment
1497
- start_comment_text = rolling_start_comment(pr.current_show, latest_sha)
1498
-
1499
- if not dry_run_github:
1500
- github.post_comment(pr_number, start_comment_text)
1501
-
1502
- aws = AWSInterface()
1503
-
1504
- # Get feature flags from PR description
1505
- feature_flags = _extract_feature_flags_from_pr(pr_number, github)
1506
- github_actor = _get_github_actor()
1507
-
1508
- # Use blue-green deployment (create_environment handles existing services)
1509
- result: EnvironmentResult = aws.create_environment(
1510
- pr_number=pr_number,
1511
- sha=latest_sha,
1512
- github_user=github_actor,
1513
- feature_flags=feature_flags,
1514
- force=False, # Don't force - let blue-green handle it
1515
- )
1516
-
1517
- if result.success:
1518
- console.print(
1519
- f"🎪 [bold green]✅ Rolling update complete![/bold green] New IP: {result.ip}"
1520
- )
1521
-
1522
- # Update labels to point to new service
1523
- pr.refresh_labels(github)
1524
- new_show = pr.get_show_by_sha(latest_sha)
1525
- if new_show:
1526
- new_show.status = "running"
1527
- new_show.ip = result.ip
1528
-
1529
- # Update GitHub labels
1530
- github.remove_circus_labels(pr_number)
1531
- for label in new_show.to_circus_labels():
1532
- github.add_label(pr_number, label)
1533
-
1534
- console.print("🎪 ✅ Labels updated to point to new environment")
1535
-
1536
- # Post rolling update success comment
1537
- success_comment_text = rolling_success_comment(pr.current_show, new_show)
1538
-
1539
- if not dry_run_github:
1540
- github.post_comment(pr_number, success_comment_text)
1541
- else:
1542
- console.print(f"🎪 [bold red]❌ Rolling update failed:[/bold red] {result.error}")
1543
-
1544
- # Post rolling update failure comment
1545
- failure_comment_text = rolling_failure_comment(
1546
- pr.current_show, latest_sha, result.error
1547
- )
1548
-
1549
- if not dry_run_github:
1550
- github.post_comment(pr_number, failure_comment_text)
667
+ p("🎪 No expired environments found")
1551
668
 
1552
669
  except Exception as e:
1553
- console.print(f"🎪 [bold red]Sync trigger failed:[/bold red] {e}")
670
+ p(f" Cleanup failed: {e}")
1554
671
 
1555
672
 
1556
- def main():
673
+ def main() -> None:
1557
674
  """Main entry point for the CLI"""
1558
675
  app()
1559
676