superset-showtime 0.6.4__py3-none-any.whl → 0.6.7__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/__init__.py +1 -1
- showtime/cli.py +115 -62
- showtime/core/aws.py +38 -54
- showtime/core/constants.py +10 -0
- showtime/core/date_utils.py +80 -0
- showtime/core/git_validation.py +21 -12
- showtime/core/github.py +19 -0
- showtime/core/github_messages.py +3 -1
- showtime/core/pull_request.py +307 -39
- showtime/core/service_name.py +104 -0
- showtime/core/show.py +30 -17
- showtime/core/sync_state.py +137 -0
- {superset_showtime-0.6.4.dist-info → superset_showtime-0.6.7.dist-info}/METADATA +1 -1
- superset_showtime-0.6.7.dist-info/RECORD +21 -0
- superset_showtime-0.6.4.dist-info/RECORD +0 -17
- {superset_showtime-0.6.4.dist-info → superset_showtime-0.6.7.dist-info}/WHEEL +0 -0
- {superset_showtime-0.6.4.dist-info → superset_showtime-0.6.7.dist-info}/entry_points.txt +0 -0
showtime/core/github_messages.py
CHANGED
|
@@ -16,7 +16,9 @@ AWS_REGION = "us-west-2"
|
|
|
16
16
|
|
|
17
17
|
def get_github_actor() -> str:
|
|
18
18
|
"""Get current GitHub actor with fallback"""
|
|
19
|
-
|
|
19
|
+
from .github import GitHubInterface
|
|
20
|
+
|
|
21
|
+
return GitHubInterface.get_current_actor()
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
def get_github_workflow_url() -> str:
|
showtime/core/pull_request.py
CHANGED
|
@@ -4,14 +4,13 @@
|
|
|
4
4
|
Handles atomic transactions, trigger processing, and environment orchestration.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import os
|
|
8
7
|
from dataclasses import dataclass
|
|
9
|
-
from datetime import datetime
|
|
10
8
|
from typing import Any, List, Optional
|
|
11
9
|
|
|
12
10
|
from .aws import AWSInterface
|
|
13
11
|
from .github import GitHubInterface
|
|
14
12
|
from .show import Show, short_sha
|
|
13
|
+
from .sync_state import ActionNeeded, AuthStatus, BlockedReason, SyncState
|
|
15
14
|
|
|
16
15
|
# Lazy singletons to avoid import-time failures
|
|
17
16
|
_github = None
|
|
@@ -127,7 +126,7 @@ class PullRequest:
|
|
|
127
126
|
# Create Show objects for each SHA
|
|
128
127
|
shows = []
|
|
129
128
|
for sha in shas:
|
|
130
|
-
show = Show.from_circus_labels(self.pr_number, self.labels, sha)
|
|
129
|
+
show = Show.from_circus_labels(self.pr_number, list(self.labels), sha)
|
|
131
130
|
if show:
|
|
132
131
|
shows.append(show)
|
|
133
132
|
|
|
@@ -213,19 +212,27 @@ class PullRequest:
|
|
|
213
212
|
active_pointer = f"{active_prefix}{show.sha}" # "🎪 🎯 abc123f"
|
|
214
213
|
self.add_label(active_pointer)
|
|
215
214
|
|
|
216
|
-
def _check_authorization(self) -> bool:
|
|
217
|
-
"""Check if current GitHub actor is authorized for operations
|
|
218
|
-
import os
|
|
215
|
+
def _check_authorization(self) -> tuple[bool, dict]:
|
|
216
|
+
"""Check if current GitHub actor is authorized for operations
|
|
219
217
|
|
|
218
|
+
Returns:
|
|
219
|
+
tuple: (is_authorized, debug_info_dict)
|
|
220
|
+
"""
|
|
220
221
|
import httpx
|
|
221
222
|
|
|
223
|
+
# Get actor info using centralized function
|
|
224
|
+
actor_info = GitHubInterface.get_actor_debug_info()
|
|
225
|
+
debug_info = {**actor_info, "permission": "unknown", "auth_status": "unknown"}
|
|
226
|
+
|
|
222
227
|
# Only check in GitHub Actions context
|
|
223
|
-
if
|
|
224
|
-
|
|
228
|
+
if not debug_info["is_github_actions"]:
|
|
229
|
+
debug_info["auth_status"] = "skipped_not_actions"
|
|
230
|
+
return True, debug_info
|
|
225
231
|
|
|
226
|
-
actor =
|
|
227
|
-
if not actor:
|
|
228
|
-
|
|
232
|
+
actor = debug_info["actor"]
|
|
233
|
+
if not actor or actor == "unknown":
|
|
234
|
+
debug_info["auth_status"] = "allowed_no_actor"
|
|
235
|
+
return True, debug_info
|
|
229
236
|
|
|
230
237
|
try:
|
|
231
238
|
# Use existing GitHubInterface for consistency
|
|
@@ -237,58 +244,202 @@ class PullRequest:
|
|
|
237
244
|
with httpx.Client() as client:
|
|
238
245
|
response = client.get(perm_url, headers=github.headers)
|
|
239
246
|
if response.status_code == 404:
|
|
240
|
-
|
|
247
|
+
debug_info["permission"] = "not_collaborator"
|
|
248
|
+
debug_info["auth_status"] = "denied_404"
|
|
249
|
+
return False, debug_info
|
|
250
|
+
|
|
241
251
|
response.raise_for_status()
|
|
242
252
|
|
|
243
253
|
data = response.json()
|
|
244
254
|
permission = data.get("permission", "none")
|
|
255
|
+
debug_info["permission"] = permission
|
|
245
256
|
|
|
246
257
|
# Allow write and admin permissions only
|
|
247
258
|
authorized = permission in ["write", "admin"]
|
|
248
259
|
|
|
249
260
|
if not authorized:
|
|
261
|
+
debug_info["auth_status"] = "denied_insufficient_perms"
|
|
250
262
|
print(f"🚨 Unauthorized actor {actor} (permission: {permission})")
|
|
251
263
|
# Set blocked label for security
|
|
252
264
|
self.add_label("🎪 🔒 showtime-blocked")
|
|
265
|
+
else:
|
|
266
|
+
debug_info["auth_status"] = "authorized"
|
|
253
267
|
|
|
254
|
-
return authorized
|
|
268
|
+
return authorized, debug_info
|
|
255
269
|
|
|
256
270
|
except Exception as e:
|
|
271
|
+
debug_info["auth_status"] = f"error_{type(e).__name__}"
|
|
272
|
+
debug_info["error"] = str(e)
|
|
257
273
|
print(f"⚠️ Authorization check failed: {e}")
|
|
258
|
-
return True # Fail open for non-security operations
|
|
274
|
+
return True, debug_info # Fail open for non-security operations
|
|
259
275
|
|
|
260
|
-
def analyze(self, target_sha: str, pr_state: str = "open") ->
|
|
261
|
-
"""Analyze what actions are needed
|
|
276
|
+
def analyze(self, target_sha: str, pr_state: str = "open") -> SyncState:
|
|
277
|
+
"""Analyze what actions are needed with comprehensive debugging info
|
|
262
278
|
|
|
263
279
|
Args:
|
|
264
280
|
target_sha: Target commit SHA to analyze
|
|
265
281
|
pr_state: PR state (open/closed)
|
|
266
282
|
|
|
267
283
|
Returns:
|
|
268
|
-
|
|
284
|
+
SyncState with complete analysis and debug info
|
|
269
285
|
"""
|
|
286
|
+
import os
|
|
287
|
+
|
|
270
288
|
# Handle closed PRs
|
|
271
289
|
if pr_state == "closed":
|
|
272
|
-
return
|
|
273
|
-
action_needed=
|
|
290
|
+
return SyncState(
|
|
291
|
+
action_needed=ActionNeeded.DESTROY_ENVIRONMENT,
|
|
292
|
+
build_needed=False,
|
|
293
|
+
sync_needed=True,
|
|
294
|
+
target_sha=target_sha,
|
|
295
|
+
github_actor=GitHubInterface.get_current_actor(),
|
|
296
|
+
is_github_actions=os.getenv("GITHUB_ACTIONS") == "true",
|
|
297
|
+
permission_level="cleanup",
|
|
298
|
+
auth_status=AuthStatus.SKIPPED_NOT_ACTIONS,
|
|
299
|
+
action_reason="pr_closed",
|
|
274
300
|
)
|
|
275
301
|
|
|
276
|
-
#
|
|
277
|
-
|
|
302
|
+
# Get fresh labels
|
|
303
|
+
self.refresh_labels()
|
|
278
304
|
|
|
279
|
-
#
|
|
280
|
-
|
|
305
|
+
# Initialize state tracking
|
|
306
|
+
target_sha_short = target_sha[:7]
|
|
307
|
+
target_show = self.get_show_by_sha(target_sha_short)
|
|
308
|
+
trigger_labels = [label for label in self.labels if "showtime-trigger-" in label]
|
|
309
|
+
|
|
310
|
+
# Check for existing blocked label
|
|
311
|
+
blocked_reason = BlockedReason.NOT_BLOCKED
|
|
312
|
+
if "🎪 🔒 showtime-blocked" in self.labels:
|
|
313
|
+
blocked_reason = BlockedReason.EXISTING_BLOCKED_LABEL
|
|
314
|
+
|
|
315
|
+
# Check authorization
|
|
316
|
+
is_authorized, auth_debug = self._check_authorization()
|
|
317
|
+
if not is_authorized and blocked_reason == BlockedReason.NOT_BLOCKED:
|
|
318
|
+
blocked_reason = BlockedReason.AUTHORIZATION_FAILED
|
|
281
319
|
|
|
282
|
-
# Determine
|
|
283
|
-
|
|
320
|
+
# Determine action needed
|
|
321
|
+
action_needed_str = (
|
|
322
|
+
"blocked"
|
|
323
|
+
if blocked_reason != BlockedReason.NOT_BLOCKED
|
|
324
|
+
else self._evaluate_action_logic(target_sha_short, target_show, trigger_labels)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Map string to enum
|
|
328
|
+
action_map = {
|
|
329
|
+
"no_action": ActionNeeded.NO_ACTION,
|
|
330
|
+
"create_environment": ActionNeeded.CREATE_ENVIRONMENT,
|
|
331
|
+
"rolling_update": ActionNeeded.ROLLING_UPDATE,
|
|
332
|
+
"auto_sync": ActionNeeded.AUTO_SYNC,
|
|
333
|
+
"destroy_environment": ActionNeeded.DESTROY_ENVIRONMENT,
|
|
334
|
+
"blocked": ActionNeeded.BLOCKED,
|
|
335
|
+
}
|
|
336
|
+
action_needed = action_map.get(action_needed_str, ActionNeeded.NO_ACTION)
|
|
284
337
|
|
|
285
|
-
|
|
338
|
+
# Build sync state
|
|
339
|
+
return SyncState(
|
|
286
340
|
action_needed=action_needed,
|
|
287
|
-
build_needed=
|
|
288
|
-
|
|
341
|
+
build_needed=action_needed
|
|
342
|
+
in [
|
|
343
|
+
ActionNeeded.CREATE_ENVIRONMENT,
|
|
344
|
+
ActionNeeded.ROLLING_UPDATE,
|
|
345
|
+
ActionNeeded.AUTO_SYNC,
|
|
346
|
+
],
|
|
347
|
+
sync_needed=action_needed not in [ActionNeeded.NO_ACTION, ActionNeeded.BLOCKED],
|
|
289
348
|
target_sha=target_sha,
|
|
349
|
+
github_actor=auth_debug.get("actor", "unknown"),
|
|
350
|
+
is_github_actions=auth_debug.get("is_github_actions", False),
|
|
351
|
+
permission_level=auth_debug.get("permission", "unknown"),
|
|
352
|
+
auth_status=self._parse_auth_status(auth_debug.get("auth_status", "unknown")),
|
|
353
|
+
blocked_reason=blocked_reason,
|
|
354
|
+
trigger_labels=trigger_labels,
|
|
355
|
+
target_show_status=target_show.status if target_show else None,
|
|
356
|
+
has_previous_shows=len(self.shows) > 0,
|
|
357
|
+
action_reason=self._get_action_reason(action_needed_str, target_show, trigger_labels),
|
|
358
|
+
auth_error=auth_debug.get("error"),
|
|
290
359
|
)
|
|
291
360
|
|
|
361
|
+
def _evaluate_action_logic(
|
|
362
|
+
self, target_sha_short: str, target_show: Optional[Show], trigger_labels: List[str]
|
|
363
|
+
) -> str:
|
|
364
|
+
"""Pure logic for evaluating what action is needed (no side effects, for testability)"""
|
|
365
|
+
if trigger_labels:
|
|
366
|
+
for trigger in trigger_labels:
|
|
367
|
+
if "showtime-trigger-start" in trigger:
|
|
368
|
+
if not target_show or target_show.status == "failed":
|
|
369
|
+
return "create_environment" # New SHA or failed SHA
|
|
370
|
+
elif target_show.status in ["building", "built", "deploying"]:
|
|
371
|
+
return "no_action" # Target SHA already in progress
|
|
372
|
+
elif target_show.status == "running":
|
|
373
|
+
return "create_environment" # Force rebuild with trigger
|
|
374
|
+
else:
|
|
375
|
+
return "create_environment" # Default for unknown states
|
|
376
|
+
elif "showtime-trigger-stop" in trigger:
|
|
377
|
+
return "destroy_environment"
|
|
378
|
+
|
|
379
|
+
# No explicit triggers - only auto-create if there's ANY previous environment
|
|
380
|
+
if not target_show:
|
|
381
|
+
# Target SHA doesn't exist - only create if there's any previous environment
|
|
382
|
+
if self.shows: # Any previous environment exists
|
|
383
|
+
return "create_environment"
|
|
384
|
+
else:
|
|
385
|
+
# No previous environments - don't auto-create without explicit trigger
|
|
386
|
+
return "no_action"
|
|
387
|
+
elif target_show.status == "failed":
|
|
388
|
+
# Target SHA failed - rebuild it
|
|
389
|
+
return "create_environment"
|
|
390
|
+
elif target_show.status in ["building", "built", "deploying"]:
|
|
391
|
+
# Target SHA in progress - wait
|
|
392
|
+
return "no_action"
|
|
393
|
+
elif target_show.status == "running":
|
|
394
|
+
# Target SHA already running - no action needed
|
|
395
|
+
return "no_action"
|
|
396
|
+
|
|
397
|
+
return "no_action"
|
|
398
|
+
|
|
399
|
+
def _get_action_reason(
|
|
400
|
+
self, action_needed: str, target_show: Optional[Show], trigger_labels: List[str]
|
|
401
|
+
) -> str:
|
|
402
|
+
"""Get human-readable reason for the action"""
|
|
403
|
+
if action_needed == "blocked":
|
|
404
|
+
return "operation_blocked"
|
|
405
|
+
elif trigger_labels:
|
|
406
|
+
if any("trigger-start" in label for label in trigger_labels):
|
|
407
|
+
if not target_show:
|
|
408
|
+
return "explicit_start_new_sha"
|
|
409
|
+
elif target_show.status == "failed":
|
|
410
|
+
return "explicit_start_failed_sha"
|
|
411
|
+
elif target_show.status == "running":
|
|
412
|
+
return "explicit_start_force_rebuild"
|
|
413
|
+
else:
|
|
414
|
+
return "explicit_start_trigger"
|
|
415
|
+
elif any("trigger-stop" in label for label in trigger_labels):
|
|
416
|
+
return "explicit_stop_trigger"
|
|
417
|
+
elif action_needed == "create_environment":
|
|
418
|
+
if not target_show:
|
|
419
|
+
return "auto_sync_new_commit"
|
|
420
|
+
elif target_show.status == "failed":
|
|
421
|
+
return "auto_rebuild_failed"
|
|
422
|
+
else:
|
|
423
|
+
return "create_environment"
|
|
424
|
+
elif action_needed == "no_action":
|
|
425
|
+
if target_show and target_show.status == "running":
|
|
426
|
+
return "already_running"
|
|
427
|
+
elif target_show and target_show.status in ["building", "deploying"]:
|
|
428
|
+
return "in_progress"
|
|
429
|
+
else:
|
|
430
|
+
return "no_previous_environments"
|
|
431
|
+
return action_needed
|
|
432
|
+
|
|
433
|
+
def _parse_auth_status(self, auth_status_str: str) -> AuthStatus:
|
|
434
|
+
"""Parse auth status string to enum, handling errors gracefully"""
|
|
435
|
+
try:
|
|
436
|
+
return AuthStatus(auth_status_str)
|
|
437
|
+
except ValueError:
|
|
438
|
+
# Handle error cases that include exception type (e.g., "error_UnsupportedProtocol")
|
|
439
|
+
if auth_status_str.startswith("error_"):
|
|
440
|
+
return AuthStatus.ERROR
|
|
441
|
+
return AuthStatus.ERROR
|
|
442
|
+
|
|
292
443
|
def sync(
|
|
293
444
|
self,
|
|
294
445
|
target_sha: str,
|
|
@@ -414,8 +565,13 @@ class PullRequest:
|
|
|
414
565
|
# Stop the current environment if it exists
|
|
415
566
|
if self.current_show:
|
|
416
567
|
print(f"🗑️ Destroying environment {self.current_show.sha}...")
|
|
417
|
-
self.current_show.stop(
|
|
418
|
-
|
|
568
|
+
success = self.current_show.stop(
|
|
569
|
+
dry_run_github=dry_run_github, dry_run_aws=dry_run_aws
|
|
570
|
+
)
|
|
571
|
+
if success:
|
|
572
|
+
print("☁️ AWS resources deleted")
|
|
573
|
+
else:
|
|
574
|
+
print("⚠️ AWS resource deletion may have failed")
|
|
419
575
|
self._post_cleanup_comment(self.current_show, dry_run_github)
|
|
420
576
|
else:
|
|
421
577
|
print("🗑️ No current environment to destroy")
|
|
@@ -448,8 +604,12 @@ class PullRequest:
|
|
|
448
604
|
try:
|
|
449
605
|
# Stop the current environment if it exists
|
|
450
606
|
if self.current_show:
|
|
451
|
-
self.current_show.stop(**kwargs)
|
|
452
|
-
|
|
607
|
+
success = self.current_show.stop(**kwargs)
|
|
608
|
+
if success:
|
|
609
|
+
print("☁️ AWS resources deleted")
|
|
610
|
+
else:
|
|
611
|
+
print("⚠️ AWS resource deletion may have failed")
|
|
612
|
+
return SyncResult(success=False, action_taken="stop_environment")
|
|
453
613
|
else:
|
|
454
614
|
print("🗑️ No current environment to destroy")
|
|
455
615
|
|
|
@@ -486,10 +646,15 @@ class PullRequest:
|
|
|
486
646
|
pr_numbers = get_github().find_prs_with_shows()
|
|
487
647
|
|
|
488
648
|
all_environments = []
|
|
649
|
+
github_service_names = set() # Track services we found via GitHub
|
|
650
|
+
|
|
489
651
|
for pr_number in pr_numbers:
|
|
490
652
|
pr = cls.from_id(pr_number)
|
|
491
653
|
# Show ALL environments, not just current_show
|
|
492
654
|
for show in pr.shows:
|
|
655
|
+
# Track this service name for later
|
|
656
|
+
github_service_names.add(show.ecs_service_name)
|
|
657
|
+
|
|
493
658
|
# Determine show type based on pointer presence
|
|
494
659
|
show_type = "orphaned" # Default
|
|
495
660
|
|
|
@@ -511,16 +676,71 @@ class PullRequest:
|
|
|
511
676
|
"ttl": show.ttl,
|
|
512
677
|
"requested_by": show.requested_by,
|
|
513
678
|
"created_at": show.created_at,
|
|
679
|
+
"age": show.age_display(), # Add age display
|
|
514
680
|
"aws_service_name": show.aws_service_name,
|
|
515
681
|
"show_type": show_type, # New field for display
|
|
682
|
+
"is_legacy": False, # Regular environment
|
|
516
683
|
},
|
|
517
684
|
}
|
|
518
685
|
all_environments.append(environment_data)
|
|
519
686
|
|
|
687
|
+
# TODO: Remove after legacy cleanup - Find AWS-only services (legacy pr-XXXXX-service format)
|
|
688
|
+
try:
|
|
689
|
+
from .aws import get_aws
|
|
690
|
+
from .service_name import ServiceName
|
|
691
|
+
|
|
692
|
+
aws = get_aws()
|
|
693
|
+
aws_services = aws.list_circus_environments()
|
|
694
|
+
|
|
695
|
+
for aws_service in aws_services:
|
|
696
|
+
service_name_str = aws_service.get("service_name", "")
|
|
697
|
+
|
|
698
|
+
# Skip if we already have this from GitHub
|
|
699
|
+
if service_name_str in github_service_names:
|
|
700
|
+
continue
|
|
701
|
+
|
|
702
|
+
# Parse the service name to get PR number
|
|
703
|
+
try:
|
|
704
|
+
svc = ServiceName.from_service_name(service_name_str)
|
|
705
|
+
|
|
706
|
+
# Legacy services have no SHA
|
|
707
|
+
is_legacy = svc.sha is None
|
|
708
|
+
|
|
709
|
+
# Add as a legacy/orphaned environment
|
|
710
|
+
environment_data = {
|
|
711
|
+
"pr_number": svc.pr_number,
|
|
712
|
+
"status": "active", # Keep for compatibility
|
|
713
|
+
"show": {
|
|
714
|
+
"sha": svc.sha or "-", # Show dash for missing SHA
|
|
715
|
+
"status": aws_service["status"].lower()
|
|
716
|
+
if aws_service.get("status")
|
|
717
|
+
else "running",
|
|
718
|
+
"ip": aws_service.get("ip"),
|
|
719
|
+
"ttl": None, # Legacy environments have no TTL labels
|
|
720
|
+
"requested_by": "-", # Unknown user for legacy
|
|
721
|
+
"created_at": None, # Will show as "-" in display
|
|
722
|
+
"age": "-", # Unknown age
|
|
723
|
+
"aws_service_name": svc.base_name, # pr-XXXXX or pr-XXXXX-sha format
|
|
724
|
+
"show_type": "legacy"
|
|
725
|
+
if is_legacy
|
|
726
|
+
else "orphaned", # Mark as legacy type
|
|
727
|
+
"is_legacy": is_legacy, # Flag for display formatting
|
|
728
|
+
},
|
|
729
|
+
}
|
|
730
|
+
all_environments.append(environment_data)
|
|
731
|
+
|
|
732
|
+
except ValueError:
|
|
733
|
+
# Skip services that don't match our pattern
|
|
734
|
+
continue
|
|
735
|
+
|
|
736
|
+
except Exception:
|
|
737
|
+
# If AWS lookup fails, just show GitHub-based environments
|
|
738
|
+
pass
|
|
739
|
+
|
|
520
740
|
return all_environments
|
|
521
741
|
|
|
522
742
|
def _determine_action(self, target_sha: str) -> str:
|
|
523
|
-
"""Determine what sync action is needed
|
|
743
|
+
"""Determine what sync action is needed (includes all checks and refreshes labels)"""
|
|
524
744
|
# CRITICAL: Get fresh labels before any decisions
|
|
525
745
|
self.refresh_labels()
|
|
526
746
|
|
|
@@ -529,7 +749,8 @@ class PullRequest:
|
|
|
529
749
|
return "blocked"
|
|
530
750
|
|
|
531
751
|
# Check authorization (security layer)
|
|
532
|
-
|
|
752
|
+
is_authorized, _ = self._check_authorization()
|
|
753
|
+
if not is_authorized:
|
|
533
754
|
return "blocked"
|
|
534
755
|
|
|
535
756
|
target_sha_short = target_sha[:7] # Ensure we're working with short SHA
|
|
@@ -626,13 +847,16 @@ class PullRequest:
|
|
|
626
847
|
|
|
627
848
|
def _create_new_show(self, target_sha: str) -> Show:
|
|
628
849
|
"""Create a new Show object for the target SHA"""
|
|
850
|
+
from .constants import DEFAULT_TTL
|
|
851
|
+
from .date_utils import format_utc_now
|
|
852
|
+
|
|
629
853
|
return Show(
|
|
630
854
|
pr_number=self.pr_number,
|
|
631
855
|
sha=short_sha(target_sha),
|
|
632
856
|
status="building",
|
|
633
|
-
created_at=
|
|
634
|
-
ttl=
|
|
635
|
-
requested_by=
|
|
857
|
+
created_at=format_utc_now(),
|
|
858
|
+
ttl=DEFAULT_TTL,
|
|
859
|
+
requested_by=GitHubInterface.get_current_actor(),
|
|
636
860
|
)
|
|
637
861
|
|
|
638
862
|
def _post_building_comment(self, show: Show, dry_run: bool = False) -> None:
|
|
@@ -705,6 +929,48 @@ class PullRequest:
|
|
|
705
929
|
|
|
706
930
|
return False # Not expired
|
|
707
931
|
|
|
932
|
+
def cleanup_orphaned_shows(self, max_age_hours: int, dry_run: bool = False) -> int:
|
|
933
|
+
"""Clean up orphaned shows (environments without pointer labels)
|
|
934
|
+
|
|
935
|
+
Args:
|
|
936
|
+
max_age_hours: Maximum age in hours before considering orphaned environment for cleanup
|
|
937
|
+
dry_run: If True, just check don't actually stop
|
|
938
|
+
|
|
939
|
+
Returns:
|
|
940
|
+
Number of orphaned environments cleaned up
|
|
941
|
+
"""
|
|
942
|
+
cleaned_count = 0
|
|
943
|
+
|
|
944
|
+
# Find orphaned shows (shows without active or building pointers)
|
|
945
|
+
orphaned_shows = []
|
|
946
|
+
for show in self.shows:
|
|
947
|
+
has_pointer = any(
|
|
948
|
+
label in self.labels for label in [f"🎪 🎯 {show.sha}", f"🎪 🏗️ {show.sha}"]
|
|
949
|
+
)
|
|
950
|
+
if not has_pointer and show.is_expired(max_age_hours):
|
|
951
|
+
orphaned_shows.append(show)
|
|
952
|
+
|
|
953
|
+
# Clean up each orphaned show
|
|
954
|
+
for show in orphaned_shows:
|
|
955
|
+
if dry_run:
|
|
956
|
+
print(
|
|
957
|
+
f"🎪 [DRY-RUN] Would clean orphaned environment: PR #{self.pr_number} SHA {show.sha}"
|
|
958
|
+
)
|
|
959
|
+
cleaned_count += 1
|
|
960
|
+
else:
|
|
961
|
+
print(f"🧹 Cleaning orphaned environment: PR #{self.pr_number} SHA {show.sha}")
|
|
962
|
+
# Stop the specific show (AWS resources)
|
|
963
|
+
success = show.stop(dry_run_github=False, dry_run_aws=False)
|
|
964
|
+
if success:
|
|
965
|
+
# Also clean up GitHub labels for this specific show
|
|
966
|
+
self.remove_sha_labels(show.sha)
|
|
967
|
+
cleaned_count += 1
|
|
968
|
+
print(f"✅ Cleaned orphaned environment: {show.sha}")
|
|
969
|
+
else:
|
|
970
|
+
print(f"⚠️ Failed to clean orphaned environment: {show.sha}")
|
|
971
|
+
|
|
972
|
+
return cleaned_count
|
|
973
|
+
|
|
708
974
|
@classmethod
|
|
709
975
|
def find_all_with_environments(cls) -> List[int]:
|
|
710
976
|
"""Find all PR numbers that have active environments"""
|
|
@@ -727,9 +993,11 @@ class PullRequest:
|
|
|
727
993
|
|
|
728
994
|
# For running environments, ensure only ONE active pointer exists
|
|
729
995
|
if show.status == "running":
|
|
730
|
-
# Remove ALL existing active pointers
|
|
996
|
+
# Remove ALL existing active pointers EXCEPT for this SHA's pointer
|
|
731
997
|
existing_active_pointers = [
|
|
732
|
-
label
|
|
998
|
+
label
|
|
999
|
+
for label in self.labels
|
|
1000
|
+
if label.startswith("🎪 🎯 ") and label != f"🎪 🎯 {show.sha}"
|
|
733
1001
|
]
|
|
734
1002
|
for old_pointer in existing_active_pointers:
|
|
735
1003
|
print(f"🎯 Removing old active pointer: {old_pointer}")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Service name parsing and generation utilities."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ServiceName:
|
|
10
|
+
"""
|
|
11
|
+
Handles ECS service name parsing and generation.
|
|
12
|
+
|
|
13
|
+
Service names follow the pattern: pr-{pr_number}-{sha}-service
|
|
14
|
+
Where sha can be either full or short (7 chars).
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
pr-34868-service (legacy, no SHA)
|
|
18
|
+
pr-34868-abc123f-service (with short SHA)
|
|
19
|
+
pr-34868-abc123f456def-service (with full SHA)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
pr_number: int
|
|
23
|
+
sha: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_service_name(cls, service_name: str) -> "ServiceName":
|
|
27
|
+
"""
|
|
28
|
+
Parse a service name into its components.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
service_name: ECS service name like "pr-34868-abc123f-service"
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
ServiceName instance with parsed components
|
|
35
|
+
"""
|
|
36
|
+
# Remove -service suffix if present
|
|
37
|
+
base_name = service_name.replace("-service", "")
|
|
38
|
+
|
|
39
|
+
# Parse pattern: pr-{number}[-{sha}]
|
|
40
|
+
match = re.match(r"pr-(\d+)(?:-([a-f0-9]+))?", base_name)
|
|
41
|
+
if not match:
|
|
42
|
+
raise ValueError(f"Invalid service name format: {service_name}")
|
|
43
|
+
|
|
44
|
+
pr_number = int(match.group(1))
|
|
45
|
+
sha = match.group(2) # May be None for legacy services
|
|
46
|
+
|
|
47
|
+
return cls(pr_number=pr_number, sha=sha)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_base_name(cls, base_name: str, pr_number: int) -> "ServiceName":
|
|
51
|
+
"""
|
|
52
|
+
Create from base name (without pr- prefix and -service suffix).
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
base_name: Name like "pr-34868-abc123f"
|
|
56
|
+
pr_number: PR number for validation
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
ServiceName instance
|
|
60
|
+
"""
|
|
61
|
+
# Remove pr- prefix if present
|
|
62
|
+
if base_name.startswith("pr-"):
|
|
63
|
+
base_name = base_name[3:]
|
|
64
|
+
|
|
65
|
+
# Parse pattern: {number}[-{sha}]
|
|
66
|
+
parts = base_name.split("-", 1)
|
|
67
|
+
parsed_pr = int(parts[0])
|
|
68
|
+
|
|
69
|
+
if parsed_pr != pr_number:
|
|
70
|
+
raise ValueError(f"PR number mismatch: expected {pr_number}, got {parsed_pr}")
|
|
71
|
+
|
|
72
|
+
sha = parts[1] if len(parts) > 1 else None
|
|
73
|
+
|
|
74
|
+
return cls(pr_number=pr_number, sha=sha)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def base_name(self) -> str:
|
|
78
|
+
"""Get base name without -service suffix (e.g., pr-34868-abc123f)"""
|
|
79
|
+
if self.sha:
|
|
80
|
+
return f"pr-{self.pr_number}-{self.sha}"
|
|
81
|
+
return f"pr-{self.pr_number}"
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def service_name(self) -> str:
|
|
85
|
+
"""Get full ECS service name (e.g., pr-34868-abc123f-service)"""
|
|
86
|
+
return f"{self.base_name}-service"
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def image_tag(self) -> str:
|
|
90
|
+
"""Get Docker image tag (e.g., pr-34868-abc123f-ci)"""
|
|
91
|
+
if not self.sha:
|
|
92
|
+
raise ValueError("SHA is required for image tag")
|
|
93
|
+
return f"{self.base_name}-ci"
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def short_sha(self) -> Optional[str]:
|
|
97
|
+
"""Get short SHA (first 7 chars) if available"""
|
|
98
|
+
if self.sha:
|
|
99
|
+
return self.sha[:7]
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def __str__(self) -> str:
|
|
103
|
+
"""String representation returns the full service name"""
|
|
104
|
+
return self.service_name
|