superset-showtime 0.2.9__py3-none-any.whl → 0.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of superset-showtime might be problematic. Click here for more details.

showtime/core/show.py ADDED
@@ -0,0 +1,279 @@
1
+ """
2
+ 🎪 Show class - Individual ephemeral environment management
3
+
4
+ Single environment operations: Docker build, AWS deployment, state transitions.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from typing import List, Optional
10
+
11
+
12
+ # Import interfaces for singleton access
13
+ # Note: These will be imported when the module loads, creating singletons
14
+ def get_interfaces(): # type: ignore
15
+ """Lazy-load interfaces to avoid circular imports"""
16
+ from .aws import AWSInterface
17
+ from .github import GitHubInterface
18
+
19
+ return GitHubInterface(), AWSInterface()
20
+
21
+
22
+ @dataclass
23
+ class Show:
24
+ """Single ephemeral environment state from circus labels"""
25
+
26
+ pr_number: int
27
+ sha: str # 7-char commit SHA
28
+ status: str # building, built, deploying, running, updating, failed
29
+ ip: Optional[str] = None # Environment IP address
30
+ created_at: Optional[str] = None # ISO timestamp
31
+ ttl: str = "24h" # 24h, 48h, close, etc.
32
+ requested_by: Optional[str] = None # GitHub username
33
+
34
+ @property
35
+ def aws_service_name(self) -> str:
36
+ """Deterministic ECS service name: pr-{pr_number}-{sha}"""
37
+ return f"pr-{self.pr_number}-{self.sha}"
38
+
39
+ @property
40
+ def ecs_service_name(self) -> str:
41
+ """ECS service name with -service suffix"""
42
+ return f"{self.aws_service_name}-service"
43
+
44
+ @property
45
+ def aws_image_tag(self) -> str:
46
+ """Deterministic Docker image tag: pr-{pr_number}-{sha}-ci"""
47
+ return f"pr-{self.pr_number}-{self.sha}-ci"
48
+
49
+ @property
50
+ def short_sha(self) -> str:
51
+ """Return the short SHA (already short)"""
52
+ return self.sha
53
+
54
+ @property
55
+ def is_running(self) -> bool:
56
+ """Check if environment is currently running"""
57
+ return self.status == "running"
58
+
59
+ @property
60
+ def is_building(self) -> bool:
61
+ """Check if environment is currently building"""
62
+ return self.status == "building"
63
+
64
+ @property
65
+ def is_built(self) -> bool:
66
+ """Check if environment is built (Docker complete, ready for deploy)"""
67
+ return self.status == "built"
68
+
69
+ @property
70
+ def is_deploying(self) -> bool:
71
+ """Check if environment is currently deploying to AWS"""
72
+ return self.status == "deploying"
73
+
74
+ @property
75
+ def is_updating(self) -> bool:
76
+ """Check if environment is currently updating"""
77
+ return self.status == "updating"
78
+
79
+ def needs_update(self, latest_sha: str) -> bool:
80
+ """Check if environment needs update to latest SHA"""
81
+ return self.sha != latest_sha[:7]
82
+
83
+ def is_expired(self, max_age_hours: int) -> bool:
84
+ """Check if this environment is expired based on age"""
85
+ if not self.created_at:
86
+ return False
87
+
88
+ try:
89
+ from datetime import datetime, timedelta
90
+
91
+ created_time = datetime.fromisoformat(self.created_at.replace("-", ":"))
92
+ expiry_time = created_time + timedelta(hours=max_age_hours)
93
+ return datetime.now() > expiry_time
94
+ except (ValueError, AttributeError):
95
+ return False # If we can't parse, assume not expired
96
+
97
+ def to_circus_labels(self) -> List[str]:
98
+ """Convert show state to circus tent emoji labels (per-SHA format)"""
99
+ if not self.created_at:
100
+ self.created_at = datetime.utcnow().strftime("%Y-%m-%dT%H-%M")
101
+
102
+ labels = [
103
+ f"🎪 {self.sha} 🚦 {self.status}", # SHA-first status
104
+ f"🎪 🎯 {self.sha}", # Active pointer (no value)
105
+ f"🎪 {self.sha} 📅 {self.created_at}", # SHA-first timestamp
106
+ f"🎪 {self.sha} ⌛ {self.ttl}", # SHA-first TTL
107
+ ]
108
+
109
+ if self.ip:
110
+ labels.append(f"🎪 {self.sha} 🌐 {self.ip}:8080")
111
+
112
+ if self.requested_by:
113
+ labels.append(f"🎪 {self.sha} 🤡 {self.requested_by}")
114
+
115
+ return labels
116
+
117
+ def build_docker(self, dry_run: bool = False) -> None:
118
+ """Build Docker image for this environment (atomic operation)"""
119
+ if not dry_run:
120
+ self._build_docker_image() # Raises on failure
121
+
122
+ def deploy_aws(self, dry_run: bool = False) -> None:
123
+ """Deploy to AWS (atomic operation)"""
124
+ github, aws = get_interfaces()
125
+
126
+ if not dry_run:
127
+ result = aws.create_environment(
128
+ pr_number=self.pr_number,
129
+ sha=self.sha + "0" * (40 - len(self.sha)), # Convert to full SHA
130
+ github_user=self.requested_by or "unknown",
131
+ )
132
+
133
+ if not result.success:
134
+ raise Exception(f"AWS deployment failed: {result.error}")
135
+
136
+ # Update with deployment results
137
+ self.ip = result.ip
138
+ else:
139
+ # Mock successful deployment for dry-run
140
+ self.ip = "52.1.2.3"
141
+
142
+ def stop(self, dry_run_github: bool = False, dry_run_aws: bool = False) -> None:
143
+ """Stop this environment (cleanup AWS resources)
144
+
145
+ Raises:
146
+ Exception: On cleanup failure
147
+ """
148
+ github, aws = get_interfaces()
149
+
150
+ # Delete AWS resources (pure technical work)
151
+ if not dry_run_aws:
152
+ success = aws.delete_environment(self.aws_service_name, self.pr_number)
153
+ if not success:
154
+ raise Exception(f"Failed to delete AWS service: {self.aws_service_name}")
155
+
156
+ # No comments - PullRequest handles that!
157
+
158
+ def _build_docker_image(self) -> None:
159
+ """Build Docker image for this environment"""
160
+ import os
161
+ import platform
162
+ import subprocess
163
+
164
+ tag = f"apache/superset:pr-{self.pr_number}-{self.sha}-ci"
165
+
166
+ # Detect if running in CI environment
167
+ is_ci = bool(os.getenv("GITHUB_ACTIONS") or os.getenv("CI"))
168
+
169
+ # Base command
170
+ cmd = [
171
+ "docker",
172
+ "buildx",
173
+ "build",
174
+ "--push",
175
+ "--platform",
176
+ "linux/amd64",
177
+ "--target",
178
+ "ci",
179
+ "--build-arg",
180
+ "INCLUDE_CHROMIUM=false",
181
+ "--build-arg",
182
+ "LOAD_EXAMPLES_DUCKDB=true",
183
+ "-t",
184
+ tag,
185
+ ".",
186
+ ]
187
+
188
+ # Add caching based on environment
189
+ if is_ci:
190
+ # Full registry caching in CI (Docker driver supports it)
191
+ cmd.extend([
192
+ "--cache-from",
193
+ "type=registry,ref=apache/superset-cache:3.10-slim-bookworm",
194
+ "--cache-to",
195
+ "type=registry,mode=max,ref=apache/superset-cache:3.10-slim-bookworm",
196
+ ])
197
+ print("🐳 CI environment: Using full registry caching")
198
+ else:
199
+ # Local build: cache-from only (no cache export)
200
+ cmd.extend([
201
+ "--cache-from",
202
+ "type=registry,ref=apache/superset-cache:3.10-slim-bookworm",
203
+ ])
204
+ print("🐳 Local environment: Using cache-from only (no export)")
205
+
206
+ # Add --load only when building for native architecture or explicitly requested
207
+ # Intel Mac/Linux can load linux/amd64, Apple Silicon cannot
208
+ native_x86 = platform.machine() in ("x86_64", "AMD64")
209
+ force_load = os.getenv("DOCKER_LOAD", "false").lower() == "true"
210
+
211
+ if native_x86 or force_load:
212
+ cmd.insert(-1, "--load") # Insert before the "." argument
213
+ print("🐳 Will load image to local Docker daemon (native x86_64 platform)")
214
+ else:
215
+ print("🐳 Cross-platform build - pushing to registry only (no local load)")
216
+
217
+ print(f"🐳 Building Docker image: {tag}")
218
+
219
+ # Stream output in real-time
220
+ process = subprocess.Popen(
221
+ cmd,
222
+ stdout=subprocess.PIPE,
223
+ stderr=subprocess.STDOUT,
224
+ text=True,
225
+ bufsize=1,
226
+ universal_newlines=True,
227
+ )
228
+
229
+ if process.stdout:
230
+ for line in process.stdout:
231
+ print(f"🐳 {line.rstrip()}")
232
+
233
+ return_code = process.wait(timeout=3600)
234
+ if return_code != 0:
235
+ raise Exception(f"Docker build failed with exit code: {return_code}")
236
+
237
+ @classmethod
238
+ def from_circus_labels(cls, pr_number: int, labels: List[str], sha: str) -> Optional["Show"]:
239
+ """Create Show from circus tent labels for specific SHA"""
240
+ show_data = {
241
+ "pr_number": pr_number,
242
+ "sha": sha,
243
+ "status": "building", # default
244
+ }
245
+
246
+ for label in labels:
247
+ if not label.startswith("🎪"):
248
+ continue
249
+
250
+ parts = label.split(" ")
251
+ if len(parts) < 3:
252
+ continue
253
+
254
+ # Per-SHA format: 🎪 {sha} {emoji} {value}
255
+ if parts[1] == sha: # This label is for our SHA
256
+ emoji = parts[2]
257
+ value = " ".join(parts[3:]) if len(parts) > 3 else ""
258
+
259
+ if emoji == "🚦": # Status
260
+ show_data["status"] = value
261
+ elif emoji == "📅": # Timestamp
262
+ show_data["created_at"] = value
263
+ elif emoji == "🌐": # IP with port
264
+ show_data["ip"] = value.replace(":8080", "") # Remove port for storage
265
+ elif emoji == "⌛": # TTL
266
+ show_data["ttl"] = value
267
+ elif emoji == "🤡": # User (clown!)
268
+ show_data["requested_by"] = value
269
+
270
+ # Only return Show if we found relevant labels for this SHA
271
+ if any(label.endswith(f" {sha}") for label in labels if "🎯" in label or "🏗️" in label):
272
+ return cls(**show_data) # type: ignore[arg-type]
273
+
274
+ return None
275
+
276
+
277
+ def short_sha(full_sha: str) -> str:
278
+ """Convert full SHA to short SHA (7 chars)"""
279
+ return full_sha[:7]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superset-showtime
3
- Version: 0.2.9
3
+ Version: 0.4.2
4
4
  Summary: 🎪 Apache Superset ephemeral environment management with circus tent emoji state tracking
5
5
  Project-URL: Homepage, https://github.com/apache/superset-showtime
6
6
  Project-URL: Documentation, https://superset-showtime.readthedocs.io/
@@ -49,6 +49,7 @@ Provides-Extra: azure
49
49
  Requires-Dist: azure-mgmt-containerinstance>=10.0.0; extra == 'azure'
50
50
  Requires-Dist: azure-storage-blob>=12.0.0; extra == 'azure'
51
51
  Provides-Extra: dev
52
+ Requires-Dist: boto3-stubs[ec2,ecr,ecs]>=1.40.0; extra == 'dev'
52
53
  Requires-Dist: build>=1.0.0; extra == 'dev'
53
54
  Requires-Dist: mypy>=1.0.0; extra == 'dev'
54
55
  Requires-Dist: pre-commit>=3.0.0; extra == 'dev'
@@ -157,11 +158,11 @@ showtime labels # Complete label reference
157
158
 
158
159
  **Testing/development:**
159
160
  ```bash
160
- showtime start 1234 --dry-run-aws # Test without AWS costs
161
- showtime test-lifecycle 1234 # Full workflow simulation
161
+ showtime sync 1234 --dry-run-aws --dry-run-docker # Test without costs
162
+ showtime cleanup --dry-run --older-than 1h # Test cleanup logic
162
163
  ```
163
164
 
164
- > **Dependency**: This CLI coordinates with Superset's existing GitHub Actions build infrastructure. It orchestrates environments but relies on Superset's build workflows for container creation.
165
+ > **Architecture**: This CLI implements ACID-style atomic transactions with direct Docker integration. It handles complete environment lifecycle from Docker build to AWS deployment with race condition prevention.
165
166
 
166
167
  ## 🎪 Complete Label Reference
167
168
 
@@ -275,7 +276,14 @@ The CLI is primarily used by GitHub Actions, but available for debugging and adv
275
276
  ```bash
276
277
  pip install superset-showtime
277
278
  export GITHUB_TOKEN=your_token
278
- showtime --help # See all available commands
279
+
280
+ # Core commands:
281
+ showtime sync PR_NUMBER # Sync to desired state (main command)
282
+ showtime start PR_NUMBER # Create new environment
283
+ showtime stop PR_NUMBER # Delete environment
284
+ showtime status PR_NUMBER # Show current state
285
+ showtime list # List all environments
286
+ showtime cleanup --older-than 48h # Clean up expired environments
279
287
  ```
280
288
 
281
289
 
@@ -286,11 +294,11 @@ showtime --help # See all available commands
286
294
 
287
295
  **Test with real PRs safely:**
288
296
  ```bash
289
- # Test label management without AWS costs:
290
- showtime start YOUR_PR_NUMBER --dry-run-aws --aws-sleep 10
297
+ # Test full workflow without costs:
298
+ showtime sync YOUR_PR_NUMBER --dry-run-aws --dry-run-docker
291
299
 
292
- # Test full lifecycle:
293
- showtime test-lifecycle YOUR_PR_NUMBER --real-github
300
+ # Test cleanup logic:
301
+ showtime cleanup --dry-run --older-than 24h
294
302
  ```
295
303
 
296
304
  ### Development Setup
@@ -0,0 +1,16 @@
1
+ showtime/__init__.py,sha256=jzoTTK9K3uRo5GuAPaej-89Ny35OFYmHBR3FTQ7MiVk,448
2
+ showtime/__main__.py,sha256=EVaDaTX69yIhCzChg99vqvFSCN4ELstEt7Mpb9FMZX8,109
3
+ showtime/cli.py,sha256=faFM6pe3gz49_1KrzUeri7dQffqz4WP92JmGxPaIOC0,25249
4
+ showtime/core/__init__.py,sha256=54hbdFNGrzuNMBdraezfjT8Zi6g221pKlJ9mREnKwCw,34
5
+ showtime/core/aws.py,sha256=REeZ6_1C9f6mBchBAGa1MeDJeZIwir4IJ92HLRcK5ok,32636
6
+ showtime/core/emojis.py,sha256=MHEDuPIdfNiop4zbNLuviz3eY05QiftYSHHCVbkfKhw,2129
7
+ showtime/core/github.py,sha256=uETvKDO2Yhpqg3fxLtrKaCuZR3b-1LVmgnf5aLcqrAQ,9988
8
+ showtime/core/github_messages.py,sha256=MfgwCukrEsWWesMsuL8saciDgP4nS-gijzu8DXr-Alg,7450
9
+ showtime/core/label_colors.py,sha256=efhbFnz_3nqEnEqmgyF6_hZbxtCu_fmb68BIIUpSsnk,3895
10
+ showtime/core/pull_request.py,sha256=Yxc7Oy0dkvQQm3DbwUKbiCAW0wEivM8U2j5vSRwIDxE,20475
11
+ showtime/core/show.py,sha256=BRMH_Z53UfK0VXAMHfzS0u7_s0h634VKTMzWMniRNsg,9759
12
+ showtime/data/ecs-task-definition.json,sha256=0ZaE0FZ8IWduXd2RyscMhXeVgxyym6qtjH02CK9mXBI,2235
13
+ superset_showtime-0.4.2.dist-info/METADATA,sha256=h_RweT3LG7UW7HLTx49Gv26ulSJUV2c-YVULpYm_gw8,12052
14
+ superset_showtime-0.4.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
+ superset_showtime-0.4.2.dist-info/entry_points.txt,sha256=rDW7oZ57mqyBUS4N_3_R7bZNGVHB-104jwmY-hHC_ck,85
16
+ superset_showtime-0.4.2.dist-info/RECORD,,
@@ -1 +0,0 @@
1
- """Showtime CLI commands"""
@@ -1,40 +0,0 @@
1
- """
2
- 🎪 Start show command - Create ephemeral environments
3
- """
4
-
5
- from dataclasses import dataclass
6
- from typing import Optional
7
-
8
-
9
- @dataclass
10
- class StartResult:
11
- """Result of starting a show"""
12
-
13
- success: bool
14
- url: Optional[str] = None
15
- error: Optional[str] = None
16
-
17
-
18
- def start_show(
19
- pr_number: int, sha: Optional[str] = None, ttl: str = "24h", size: str = "standard"
20
- ) -> StartResult:
21
- """
22
- Start the show! Create ephemeral environment for PR
23
-
24
- Args:
25
- pr_number: PR number to create environment for
26
- sha: Specific commit SHA (default: latest)
27
- ttl: Time to live (24h, 48h, 1w, close)
28
- size: Environment size (standard, large)
29
-
30
- Returns:
31
- StartResult with success status and details
32
- """
33
- # TODO: Implement environment creation
34
- # 1. Get latest SHA if not provided
35
- # 2. Create circus labels
36
- # 3. Build Docker image
37
- # 4. Deploy to ECS
38
- # 5. Update labels with running state
39
-
40
- return StartResult(success=False, error="Not yet implemented")
showtime/core/circus.py DELETED
@@ -1,289 +0,0 @@
1
- """
2
- 🎪 Circus tent emoji label parsing and state management
3
-
4
- Core logic for parsing GitHub labels with circus tent emoji patterns.
5
- """
6
-
7
- from dataclasses import dataclass
8
- from datetime import datetime
9
- from typing import TYPE_CHECKING, List, Optional
10
-
11
- if TYPE_CHECKING:
12
- from .github import GitHubInterface
13
-
14
-
15
- @dataclass
16
- class Show:
17
- """Single ephemeral environment state from circus labels"""
18
-
19
- pr_number: int
20
- sha: str # 7-char commit SHA
21
- status: str # building, built, deploying, running, updating, failed
22
- ip: Optional[str] = None # Environment IP address
23
- created_at: Optional[str] = None # ISO timestamp
24
- ttl: str = "24h" # 24h, 48h, close, etc.
25
- requested_by: Optional[str] = None # GitHub username
26
-
27
- @property
28
- def aws_service_name(self) -> str:
29
- """Deterministic ECS service name: pr-{pr_number}-{sha}"""
30
- return f"pr-{self.pr_number}-{self.sha}"
31
-
32
- @property
33
- def aws_image_tag(self) -> str:
34
- """Deterministic ECR image tag: pr-{pr_number}-{sha}-ci"""
35
- return f"pr-{self.pr_number}-{self.sha}-ci"
36
-
37
- @property
38
- def ecs_service_name(self) -> str:
39
- """Deterministic ECS service name with -service suffix: pr-{pr_number}-{sha}-service"""
40
- return f"{self.aws_service_name}-service"
41
-
42
- @property
43
- def short_sha(self) -> str:
44
- """7-character SHA for display (GitHub standard)"""
45
- return self.sha[:7]
46
-
47
- @property
48
- def is_active(self) -> bool:
49
- """Check if this is the currently active show"""
50
- return self.status == "running"
51
-
52
- @property
53
- def is_building(self) -> bool:
54
- """Check if environment is currently building"""
55
- return self.status == "building"
56
-
57
- @property
58
- def is_built(self) -> bool:
59
- """Check if environment is built (Docker complete, ready for deploy)"""
60
- return self.status == "built"
61
-
62
- @property
63
- def is_deploying(self) -> bool:
64
- """Check if environment is currently deploying to AWS"""
65
- return self.status == "deploying"
66
-
67
- @property
68
- def is_updating(self) -> bool:
69
- """Check if environment is currently updating"""
70
- return self.status == "updating"
71
-
72
- def needs_update(self, latest_sha: str) -> bool:
73
- """Check if environment needs update to latest SHA"""
74
- return self.sha != latest_sha[:7]
75
-
76
- def to_circus_labels(self) -> List[str]:
77
- """Convert show state to circus tent emoji labels (per-SHA format)"""
78
- if not self.created_at:
79
- self.created_at = datetime.utcnow().strftime("%Y-%m-%dT%H-%M")
80
-
81
- labels = [
82
- f"🎪 {self.sha} 🚦 {self.status}", # SHA-first status
83
- f"🎪 🎯 {self.sha}", # Active pointer (no value)
84
- f"🎪 {self.sha} 📅 {self.created_at}", # SHA-first timestamp
85
- f"🎪 {self.sha} ⌛ {self.ttl}", # SHA-first TTL
86
- ]
87
-
88
- if self.ip:
89
- labels.append(f"🎪 {self.sha} 🌐 {self.ip}:8080")
90
-
91
- if self.requested_by:
92
- labels.append(f"🎪 {self.sha} 🤡 {self.requested_by}")
93
-
94
- return labels
95
-
96
- @classmethod
97
- def from_circus_labels(cls, pr_number: int, labels: List[str], sha: str) -> Optional["Show"]:
98
- """Create Show from circus tent labels for specific SHA"""
99
- show_data = {
100
- "pr_number": pr_number,
101
- "sha": sha,
102
- "status": "building", # default
103
- }
104
-
105
- for label in labels:
106
- if not label.startswith("🎪 "):
107
- continue
108
-
109
- parts = label.split(" ", 3) # Split into 4 parts for per-SHA format
110
-
111
- if len(parts) == 3: # Old format: 🎪 🎯 sha
112
- emoji, value = parts[1], parts[2]
113
- if emoji == "🎯" and value == sha: # Active pointer
114
- pass # This SHA is active
115
- elif len(parts) == 4: # SHA-first format: 🎪 sha 🚦 status
116
- label_sha, emoji, value = parts[1], parts[2], parts[3]
117
-
118
- if label_sha != sha: # Only process labels for this SHA
119
- continue
120
-
121
- if emoji == "🚦": # Status
122
- show_data["status"] = value
123
- elif emoji == "📅": # Timestamp
124
- show_data["created_at"] = value
125
- elif emoji == "🌐": # IP with port
126
- show_data["ip"] = value.replace(":8080", "") # Remove port for storage
127
- elif emoji == "⌛": # TTL
128
- show_data["ttl"] = value
129
- elif emoji == "🤡": # User (clown!)
130
- show_data["requested_by"] = value
131
-
132
- # Only return Show if we found relevant labels for this SHA
133
- if any(label.endswith(f" {sha}") for label in labels if "🎯" in label or "🏗️" in label):
134
- return cls(**show_data)
135
-
136
- return None
137
-
138
-
139
- class PullRequest:
140
- """GitHub PR with its shows parsed from circus labels"""
141
-
142
- def __init__(self, pr_number: int, labels: List[str]):
143
- self.pr_number = pr_number
144
- self.labels = labels
145
- self._shows = self._parse_shows_from_labels()
146
-
147
- @property
148
- def shows(self) -> List[Show]:
149
- """All shows found in labels"""
150
- return self._shows
151
-
152
- @property
153
- def current_show(self) -> Optional[Show]:
154
- """The currently active show (from 🎯 label)"""
155
- # Find the SHA that's marked as active (🎯)
156
- active_sha = None
157
- for label in self.labels:
158
- if label.startswith("🎪 🎯 "):
159
- active_sha = label.split(" ")[2]
160
- break
161
-
162
- if not active_sha:
163
- return None
164
-
165
- # Find the show with that SHA
166
- for show in self.shows:
167
- if show.sha == active_sha:
168
- return show
169
-
170
- return None
171
-
172
- @property
173
- def building_show(self) -> Optional[Show]:
174
- """Show currently being built (from 🏗️ label)"""
175
- building_sha = None
176
- for label in self.labels:
177
- if label.startswith("🎪 🏗️ "):
178
- building_sha = label.split(" ")[2]
179
- break
180
-
181
- if not building_sha:
182
- return None
183
-
184
- for show in self.shows:
185
- if show.sha == building_sha:
186
- return show
187
-
188
- return None
189
-
190
- @property
191
- def circus_labels(self) -> List[str]:
192
- """All circus tent labels"""
193
- return [label for label in self.labels if label.startswith("🎪 ")]
194
-
195
- def has_shows(self) -> bool:
196
- """Check if PR has any shows"""
197
- return len(self.shows) > 0
198
-
199
- def get_show_by_sha(self, sha: str) -> Optional[Show]:
200
- """Get specific show by SHA"""
201
- for show in self.shows:
202
- if show.sha == sha[:7]:
203
- return show
204
- return None
205
-
206
- def _parse_shows_from_labels(self) -> List[Show]:
207
- """Parse all shows from circus labels"""
208
- shows = []
209
-
210
- # Find all unique SHAs mentioned in labels
211
- shas = set()
212
- for label in self.labels:
213
- if label.startswith("🎪 🎯 ") or label.startswith("🎪 🏗️ "):
214
- sha = label.split(" ")[2]
215
- shas.add(sha)
216
-
217
- # Create Show object for each SHA
218
- for sha in shas:
219
- show = Show.from_circus_labels(self.pr_number, self.labels, sha)
220
- if show:
221
- shows.append(show)
222
-
223
- return shows
224
-
225
- @classmethod
226
- def from_id(cls, pr_number: int, github: "GitHubInterface") -> "PullRequest":
227
- """Load PR with current labels from GitHub"""
228
- labels = github.get_labels(pr_number)
229
- return cls(pr_number, labels)
230
-
231
- def refresh_labels(self, github: "GitHubInterface") -> None:
232
- """Refresh labels from GitHub and reparse shows"""
233
- self.labels = github.get_labels(self.pr_number)
234
- self._shows = self._parse_shows_from_labels()
235
-
236
-
237
- def parse_ttl_days(ttl_str: str) -> Optional[float]:
238
- """Parse TTL string to days"""
239
- import re
240
-
241
- if ttl_str == "never":
242
- return None # Never expire
243
-
244
- if ttl_str == "close":
245
- return None # Special handling needed
246
-
247
- # Parse number + unit: 24h, 7d, 2w, etc.
248
- match = re.match(r"(\d+(?:\.\d+)?)(h|d|w)", ttl_str.lower())
249
- if not match:
250
- return 2.0 # Default 2 days if invalid
251
-
252
- value = float(match.group(1))
253
- unit = match.group(2)
254
-
255
- if unit == "h":
256
- return value / 24.0 # Hours to days
257
- elif unit == "d":
258
- return value # Already in days
259
- elif unit == "w":
260
- return value * 7.0 # Weeks to days
261
-
262
- return 2.0 # Default
263
-
264
-
265
- def get_effective_ttl(pr) -> Optional[float]:
266
- """Get effective TTL in days for a PR (handles multiple labels, conflicts)"""
267
- ttl_labels = []
268
-
269
- # Find all TTL labels for all shows
270
- for show in pr.shows:
271
- if show.ttl:
272
- ttl_days = parse_ttl_days(show.ttl)
273
- if ttl_days is None: # "never" or "close"
274
- if show.ttl == "never":
275
- return None # Never expire takes precedence
276
- # For "close", continue checking others
277
- else:
278
- ttl_labels.append(ttl_days)
279
-
280
- if not ttl_labels:
281
- return 2.0 # Default 2 days
282
-
283
- # Use longest duration if multiple labels
284
- return max(ttl_labels)
285
-
286
-
287
- def short_sha(full_sha: str) -> str:
288
- """Truncate SHA to 7 characters for display (GitHub standard)"""
289
- return full_sha[:7]