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.

@@ -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
- return os.getenv("GITHUB_ACTOR", "unknown")
19
+ from .github import GitHubInterface
20
+
21
+ return GitHubInterface.get_current_actor()
20
22
 
21
23
 
22
24
  def get_github_workflow_url() -> str:
@@ -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 os.getenv("GITHUB_ACTIONS") != "true":
224
- return True
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 = os.getenv("GITHUB_ACTOR")
227
- if not actor:
228
- return True # No actor info, allow operation
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
- return False # Not a collaborator
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") -> AnalysisResult:
261
- """Analyze what actions are needed (read-only, for --check-only)
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
- AnalysisResult with action plan and flags
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 AnalysisResult(
273
- action_needed="cleanup", build_needed=False, sync_needed=True, target_sha=target_sha
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
- # Determine action needed
277
- action_needed = self._determine_action(target_sha)
302
+ # Get fresh labels
303
+ self.refresh_labels()
278
304
 
279
- # Determine if Docker build is needed
280
- build_needed = action_needed in ["create_environment", "rolling_update", "auto_sync"]
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 if sync execution is needed
283
- sync_needed = action_needed not in ["no_action", "blocked"]
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
- return AnalysisResult(
338
+ # Build sync state
339
+ return SyncState(
286
340
  action_needed=action_needed,
287
- build_needed=build_needed,
288
- sync_needed=sync_needed,
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(dry_run_github=dry_run_github, dry_run_aws=dry_run_aws)
418
- print("☁️ AWS resources deleted")
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
- print("☁️ AWS resources deleted")
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 based on target SHA state"""
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
- if not self._check_authorization():
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=datetime.utcnow().strftime("%Y-%m-%dT%H-%M"),
634
- ttl="24h",
635
- requested_by=os.getenv("GITHUB_ACTOR", "unknown"),
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 (there should only be one)
996
+ # Remove ALL existing active pointers EXCEPT for this SHA's pointer
731
997
  existing_active_pointers = [
732
- label for label in self.labels if label.startswith("🎪 🎯 ")
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