superset-showtime 0.5.11__tar.gz → 0.5.18__tar.gz
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.
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/.claude/settings.local.json +2 -1
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/CLAUDE.md +5 -4
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/PKG-INFO +1 -1
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/__init__.py +1 -1
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/emojis.py +1 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/git_validation.py +3 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/github.py +0 -8
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/label_colors.py +4 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/pull_request.py +204 -82
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/show.py +23 -17
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/data/ecs-task-definition.json +1 -1
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/tests/unit/test_pull_request.py +296 -20
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/tests/unit/test_show.py +33 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/workflows-reference/showtime-cleanup.yml +0 -1
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/workflows-reference/showtime-trigger.yml +71 -4
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/.gitignore +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/.pre-commit-config.yaml +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/Makefile +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/README.md +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/dev-setup.sh +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/pypi-push.sh +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/pyproject.toml +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/requirements-dev.txt +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/requirements.txt +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/__main__.py +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/cli.py +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/__init__.py +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/aws.py +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/github_messages.py +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/tests/__init__.py +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/tests/unit/__init__.py +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/tests/unit/test_label_transitions.py +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/tests/unit/test_sha_specific_logic.py +0 -0
- {superset_showtime-0.5.11 → superset_showtime-0.5.18}/uv.lock +0 -0
|
@@ -84,6 +84,7 @@ The system uses GitHub labels as a distributed state machine:
|
|
|
84
84
|
- `🎪 ⚡ showtime-trigger-start` - Create environment
|
|
85
85
|
- `🎪 🛑 showtime-trigger-stop` - Destroy environment
|
|
86
86
|
- `🎪 🧊 showtime-freeze` - Prevent auto-sync
|
|
87
|
+
- `🎪 🔒 showtime-blocked` - Block ALL operations (maintenance mode)
|
|
87
88
|
|
|
88
89
|
**State Labels (System Managed):**
|
|
89
90
|
- `🎪 {sha} 🚦 {status}` - Environment status
|
|
@@ -177,7 +178,7 @@ Double triggers can create race conditions in two scenarios:
|
|
|
177
178
|
### Current Atomic Claim Mechanism
|
|
178
179
|
|
|
179
180
|
The `PullRequest._atomic_claim()` method handles basic conflicts by:
|
|
180
|
-
1. Checking if target SHA is already in progress states (`building`, `built`, `deploying`)
|
|
181
|
+
1. Checking if target SHA is already in progress states (`building`, `built`, `deploying`)
|
|
181
182
|
2. Removing trigger labels atomically
|
|
182
183
|
3. Setting building state immediately
|
|
183
184
|
|
|
@@ -198,7 +199,7 @@ def can_start_job(self, target_sha: str, action: str, use_cached: bool = True) -
|
|
|
198
199
|
# Returns (can_start, reason)
|
|
199
200
|
```
|
|
200
201
|
|
|
201
|
-
#### Phase 2: Recovery Path (5% of calls, ~500ms)
|
|
202
|
+
#### Phase 2: Recovery Path (5% of calls, ~500ms)
|
|
202
203
|
```python
|
|
203
204
|
def double_check_and_cleanup_stale_locks(self, target_sha: str, stale_hours: int = 1, dry_run: bool = False) -> bool:
|
|
204
205
|
"""Expensive: refresh labels, detect stale locks (>1h), clean them up"""
|
|
@@ -211,11 +212,11 @@ def double_check_and_cleanup_stale_locks(self, target_sha: str, stale_hours: int
|
|
|
211
212
|
def _atomic_claim(self, target_sha: str, action: str, dry_run: bool = False) -> bool:
|
|
212
213
|
# 1. Fast check with cached labels
|
|
213
214
|
can_start, reason = self.can_start_job(target_sha, action, use_cached=True)
|
|
214
|
-
|
|
215
|
+
|
|
215
216
|
if not can_start:
|
|
216
217
|
# 2. Expensive double-check and cleanup
|
|
217
218
|
can_start = self.double_check_and_cleanup_stale_locks(target_sha, stale_hours=1, dry_run=dry_run)
|
|
218
|
-
|
|
219
|
+
|
|
219
220
|
# 3. Continue with existing trigger removal + building setup
|
|
220
221
|
```
|
|
221
222
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: superset-showtime
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.18
|
|
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/
|
|
@@ -13,6 +13,7 @@ EMOJI_MEANINGS = {
|
|
|
13
13
|
"🚦": "status", # Traffic light for environment status
|
|
14
14
|
"🏗️": "building", # Construction for building environments
|
|
15
15
|
"🎯": "active", # Target for currently active environment
|
|
16
|
+
"🔒": "blocked", # Lock for blocking all operations
|
|
16
17
|
# Metadata
|
|
17
18
|
"📅": "created_at", # Calendar for creation timestamp
|
|
18
19
|
"🌐": "ip", # Globe for IP address
|
|
@@ -134,6 +134,9 @@ def _validate_sha_via_github_api(required_sha: str) -> Tuple[bool, Optional[str]
|
|
|
134
134
|
# If status is 'ahead' or 'identical', required SHA is ancestor (good)
|
|
135
135
|
# If status is 'behind', current is behind required (bad)
|
|
136
136
|
if status in ["ahead", "identical"]:
|
|
137
|
+
print(
|
|
138
|
+
f"✅ Validated that required SHA {required_sha[:7]} is included in current branch"
|
|
139
|
+
)
|
|
137
140
|
return True, None
|
|
138
141
|
else:
|
|
139
142
|
return (
|
|
@@ -111,14 +111,6 @@ class GitHubInterface:
|
|
|
111
111
|
if response.status_code not in (200, 204, 404):
|
|
112
112
|
response.raise_for_status()
|
|
113
113
|
|
|
114
|
-
def set_labels(self, pr_number: int, labels: List[str]) -> None:
|
|
115
|
-
"""Replace all labels on a PR"""
|
|
116
|
-
url = f"{self.base_url}/repos/{self.org}/{self.repo}/issues/{pr_number}/labels"
|
|
117
|
-
|
|
118
|
-
with httpx.Client() as client:
|
|
119
|
-
response = client.put(url, headers=self.headers, json={"labels": labels})
|
|
120
|
-
response.raise_for_status()
|
|
121
|
-
|
|
122
114
|
def get_latest_commit_sha(self, pr_number: int) -> str:
|
|
123
115
|
"""Get the latest commit SHA for a PR"""
|
|
124
116
|
pr_data = self.get_pr_data(pr_number)
|
|
@@ -31,6 +31,10 @@ LABEL_DEFINITIONS = {
|
|
|
31
31
|
"color": "FFE4B5", # Light orange
|
|
32
32
|
"description": "Freeze PR - prevent auto-sync on new commits",
|
|
33
33
|
},
|
|
34
|
+
"🎪 🔒 showtime-blocked": {
|
|
35
|
+
"color": "dc3545", # Red - blocking/danger
|
|
36
|
+
"description": "Block all Showtime operations - maintenance mode",
|
|
37
|
+
},
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
# Status-specific label patterns (generated dynamically)
|
|
@@ -60,7 +60,7 @@ class PullRequest:
|
|
|
60
60
|
|
|
61
61
|
def __init__(self, pr_number: int, labels: List[str]):
|
|
62
62
|
self.pr_number = pr_number
|
|
63
|
-
self.labels = labels
|
|
63
|
+
self.labels = set(labels) # Convert to set for O(1) operations
|
|
64
64
|
self._shows = self._parse_shows_from_labels()
|
|
65
65
|
|
|
66
66
|
@property
|
|
@@ -90,20 +90,10 @@ class PullRequest:
|
|
|
90
90
|
|
|
91
91
|
@property
|
|
92
92
|
def building_show(self) -> Optional[Show]:
|
|
93
|
-
"""The currently building show (from
|
|
94
|
-
building_sha = None
|
|
95
|
-
for label in self.labels:
|
|
96
|
-
if label.startswith("🎪 🏗️ "):
|
|
97
|
-
building_sha = label.split(" ")[2]
|
|
98
|
-
break
|
|
99
|
-
|
|
100
|
-
if not building_sha:
|
|
101
|
-
return None
|
|
102
|
-
|
|
93
|
+
"""The currently building show (from building/deploying status)"""
|
|
103
94
|
for show in self.shows:
|
|
104
|
-
if show.
|
|
95
|
+
if show.status in ["building", "deploying"]:
|
|
105
96
|
return show
|
|
106
|
-
|
|
107
97
|
return None
|
|
108
98
|
|
|
109
99
|
@property
|
|
@@ -151,9 +141,122 @@ class PullRequest:
|
|
|
151
141
|
|
|
152
142
|
def refresh_labels(self) -> None:
|
|
153
143
|
"""Refresh labels from GitHub and reparse shows"""
|
|
154
|
-
self.labels = get_github().get_labels(self.pr_number)
|
|
144
|
+
self.labels = set(get_github().get_labels(self.pr_number))
|
|
155
145
|
self._shows = self._parse_shows_from_labels()
|
|
156
146
|
|
|
147
|
+
def add_label(self, label: str) -> None:
|
|
148
|
+
"""Add label with logging and optimistic state update"""
|
|
149
|
+
print(f"🏷️ Added: {label}")
|
|
150
|
+
get_github().add_label(self.pr_number, label)
|
|
151
|
+
self.labels.add(label)
|
|
152
|
+
|
|
153
|
+
def remove_label(self, label: str) -> None:
|
|
154
|
+
"""Remove label with logging and optimistic state update"""
|
|
155
|
+
print(f"🗑️ Removed: {label}")
|
|
156
|
+
get_github().remove_label(self.pr_number, label)
|
|
157
|
+
self.labels.discard(label) # Safe - won't raise if not present
|
|
158
|
+
|
|
159
|
+
def remove_sha_labels(self, sha: str) -> None:
|
|
160
|
+
"""Remove all labels for a specific SHA"""
|
|
161
|
+
sha_short = sha[:7]
|
|
162
|
+
labels_to_remove = [
|
|
163
|
+
label for label in self.labels if label.startswith("🎪") and sha_short in label
|
|
164
|
+
]
|
|
165
|
+
if labels_to_remove:
|
|
166
|
+
print(f"🗑️ Removing SHA {sha_short} labels: {labels_to_remove}")
|
|
167
|
+
for label in labels_to_remove:
|
|
168
|
+
self.remove_label(label)
|
|
169
|
+
|
|
170
|
+
def remove_showtime_labels(self) -> None:
|
|
171
|
+
"""Remove ALL circus tent labels"""
|
|
172
|
+
circus_labels = [label for label in self.labels if label.startswith("🎪 ")]
|
|
173
|
+
if circus_labels:
|
|
174
|
+
print(f"🎪 Removing all showtime labels: {circus_labels}")
|
|
175
|
+
for label in circus_labels:
|
|
176
|
+
self.remove_label(label)
|
|
177
|
+
|
|
178
|
+
def set_show_status(self, show: Show, new_status: str) -> None:
|
|
179
|
+
"""Atomically update show status with thorough label cleanup"""
|
|
180
|
+
show.status = new_status
|
|
181
|
+
|
|
182
|
+
# 1. Refresh labels to get current GitHub state
|
|
183
|
+
self.refresh_labels()
|
|
184
|
+
|
|
185
|
+
# 2. Remove ALL existing status labels for this SHA (not just the "expected" one)
|
|
186
|
+
status_labels_to_remove = [
|
|
187
|
+
label for label in self.labels if label.startswith(f"🎪 {show.sha} 🚦 ")
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
for label in status_labels_to_remove:
|
|
191
|
+
self.remove_label(label)
|
|
192
|
+
|
|
193
|
+
# 3. Add the new status label
|
|
194
|
+
new_status_label = f"🎪 {show.sha} 🚦 {new_status}"
|
|
195
|
+
self.add_label(new_status_label)
|
|
196
|
+
|
|
197
|
+
def set_active_show(self, show: Show) -> None:
|
|
198
|
+
"""Atomically set this show as the active environment"""
|
|
199
|
+
from .emojis import CIRCUS_PREFIX, MEANING_TO_EMOJI
|
|
200
|
+
|
|
201
|
+
# 1. Refresh to get current state
|
|
202
|
+
self.refresh_labels()
|
|
203
|
+
|
|
204
|
+
# 2. Remove ALL existing active pointers (ensure only one)
|
|
205
|
+
active_emoji = MEANING_TO_EMOJI["active"] # Gets 🎯
|
|
206
|
+
active_prefix = f"{CIRCUS_PREFIX} {active_emoji} " # "🎪 🎯 "
|
|
207
|
+
active_pointers = [label for label in self.labels if label.startswith(active_prefix)]
|
|
208
|
+
|
|
209
|
+
for pointer in active_pointers:
|
|
210
|
+
self.remove_label(pointer)
|
|
211
|
+
|
|
212
|
+
# 3. Set this show as the new active one
|
|
213
|
+
active_pointer = f"{active_prefix}{show.sha}" # "🎪 🎯 abc123f"
|
|
214
|
+
self.add_label(active_pointer)
|
|
215
|
+
|
|
216
|
+
def _check_authorization(self) -> bool:
|
|
217
|
+
"""Check if current GitHub actor is authorized for operations"""
|
|
218
|
+
import os
|
|
219
|
+
|
|
220
|
+
import httpx
|
|
221
|
+
|
|
222
|
+
# Only check in GitHub Actions context
|
|
223
|
+
if os.getenv("GITHUB_ACTIONS") != "true":
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
actor = os.getenv("GITHUB_ACTOR")
|
|
227
|
+
if not actor:
|
|
228
|
+
return True # No actor info, allow operation
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
# Use existing GitHubInterface for consistency
|
|
232
|
+
github = get_github()
|
|
233
|
+
|
|
234
|
+
# Check collaborator permissions
|
|
235
|
+
perm_url = f"{github.base_url}/repos/{github.org}/{github.repo}/collaborators/{actor}/permission"
|
|
236
|
+
|
|
237
|
+
with httpx.Client() as client:
|
|
238
|
+
response = client.get(perm_url, headers=github.headers)
|
|
239
|
+
if response.status_code == 404:
|
|
240
|
+
return False # Not a collaborator
|
|
241
|
+
response.raise_for_status()
|
|
242
|
+
|
|
243
|
+
data = response.json()
|
|
244
|
+
permission = data.get("permission", "none")
|
|
245
|
+
|
|
246
|
+
# Allow write and admin permissions only
|
|
247
|
+
authorized = permission in ["write", "admin"]
|
|
248
|
+
|
|
249
|
+
if not authorized:
|
|
250
|
+
print(f"🚨 Unauthorized actor {actor} (permission: {permission})")
|
|
251
|
+
# Set blocked label for security
|
|
252
|
+
self.add_label("🎪 🔒 showtime-blocked")
|
|
253
|
+
|
|
254
|
+
return authorized
|
|
255
|
+
|
|
256
|
+
except Exception as e:
|
|
257
|
+
print(f"⚠️ Authorization check failed: {e}")
|
|
258
|
+
return True # Fail open for non-security operations
|
|
259
|
+
|
|
157
260
|
def analyze(self, target_sha: str, pr_state: str = "open") -> AnalysisResult:
|
|
158
261
|
"""Analyze what actions are needed (read-only, for --check-only)
|
|
159
262
|
|
|
@@ -177,7 +280,7 @@ class PullRequest:
|
|
|
177
280
|
build_needed = action_needed in ["create_environment", "rolling_update", "auto_sync"]
|
|
178
281
|
|
|
179
282
|
# Determine if sync execution is needed
|
|
180
|
-
sync_needed = action_needed
|
|
283
|
+
sync_needed = action_needed not in ["no_action", "blocked"]
|
|
181
284
|
|
|
182
285
|
return AnalysisResult(
|
|
183
286
|
action_needed=action_needed,
|
|
@@ -213,7 +316,15 @@ class PullRequest:
|
|
|
213
316
|
# 1. Determine what action is needed
|
|
214
317
|
action_needed = self._determine_action(target_sha)
|
|
215
318
|
|
|
216
|
-
# 2.
|
|
319
|
+
# 2. Check for blocked state (fast bailout)
|
|
320
|
+
if action_needed == "blocked":
|
|
321
|
+
return SyncResult(
|
|
322
|
+
success=False,
|
|
323
|
+
action_taken="blocked",
|
|
324
|
+
error="🔒 Showtime operations are blocked for this PR. Remove '🎪 🔒 showtime-blocked' label to re-enable.",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# 3. Atomic claim for environment changes (PR-level lock)
|
|
217
328
|
if action_needed in ["create_environment", "rolling_update", "auto_sync"]:
|
|
218
329
|
print(f"🔒 Claiming environment for {action_needed}...")
|
|
219
330
|
if not self._atomic_claim(target_sha, action_needed, dry_run_github):
|
|
@@ -235,20 +346,22 @@ class PullRequest:
|
|
|
235
346
|
# Phase 1: Docker build
|
|
236
347
|
print("🐳 Building Docker image...")
|
|
237
348
|
show.build_docker(dry_run_docker)
|
|
238
|
-
show.status = "built"
|
|
239
349
|
print("✅ Docker build completed")
|
|
240
|
-
self._update_show_labels(show, dry_run_github)
|
|
241
350
|
|
|
242
351
|
# Phase 2: AWS deployment
|
|
243
352
|
print("☁️ Deploying to AWS ECS...")
|
|
353
|
+
self.set_show_status(show, "deploying")
|
|
244
354
|
show.deploy_aws(dry_run_aws)
|
|
245
|
-
show
|
|
355
|
+
self.set_show_status(show, "running")
|
|
356
|
+
self.set_active_show(show)
|
|
246
357
|
print(f"✅ Deployment completed - environment running at {show.ip}:8080")
|
|
247
358
|
self._update_show_labels(show, dry_run_github)
|
|
248
|
-
|
|
359
|
+
|
|
249
360
|
# Blue-green cleanup: stop all other environments for this PR
|
|
250
|
-
cleaned_count = self.stop_previous_environments(
|
|
251
|
-
|
|
361
|
+
cleaned_count = self.stop_previous_environments(
|
|
362
|
+
show.sha, dry_run_github, dry_run_aws
|
|
363
|
+
)
|
|
364
|
+
|
|
252
365
|
# Show AWS console URLs for monitoring
|
|
253
366
|
self._show_service_urls(show)
|
|
254
367
|
|
|
@@ -267,23 +380,25 @@ class PullRequest:
|
|
|
267
380
|
print(f"🔄 Rolling update: {old_show.sha} → {new_show.sha}")
|
|
268
381
|
self._post_rolling_start_comment(old_show, new_show, dry_run_github)
|
|
269
382
|
|
|
270
|
-
# Phase 1: Docker build
|
|
383
|
+
# Phase 1: Docker build
|
|
271
384
|
print("🐳 Building updated Docker image...")
|
|
272
385
|
new_show.build_docker(dry_run_docker)
|
|
273
|
-
new_show.status = "built"
|
|
274
386
|
print("✅ Docker build completed")
|
|
275
|
-
self._update_show_labels(new_show, dry_run_github)
|
|
276
387
|
|
|
277
388
|
# Phase 2: Blue-green deployment
|
|
278
389
|
print("☁️ Deploying updated environment...")
|
|
390
|
+
self.set_show_status(new_show, "deploying")
|
|
279
391
|
new_show.deploy_aws(dry_run_aws)
|
|
280
|
-
new_show
|
|
392
|
+
self.set_show_status(new_show, "running")
|
|
393
|
+
self.set_active_show(new_show)
|
|
281
394
|
print(f"✅ Rolling update completed - new environment at {new_show.ip}:8080")
|
|
282
395
|
self._update_show_labels(new_show, dry_run_github)
|
|
283
|
-
|
|
396
|
+
|
|
284
397
|
# Blue-green cleanup: stop all other environments for this PR
|
|
285
|
-
cleaned_count = self.stop_previous_environments(
|
|
286
|
-
|
|
398
|
+
cleaned_count = self.stop_previous_environments(
|
|
399
|
+
new_show.sha, dry_run_github, dry_run_aws
|
|
400
|
+
)
|
|
401
|
+
|
|
287
402
|
# Show AWS console URLs for monitoring
|
|
288
403
|
self._show_service_urls(new_show)
|
|
289
404
|
|
|
@@ -298,7 +413,7 @@ class PullRequest:
|
|
|
298
413
|
self._post_cleanup_comment(self.current_show, dry_run_github)
|
|
299
414
|
# Remove all circus labels after successful stop
|
|
300
415
|
if not dry_run_github:
|
|
301
|
-
|
|
416
|
+
self.remove_showtime_labels()
|
|
302
417
|
print("🏷️ GitHub labels cleaned up")
|
|
303
418
|
print("✅ Environment destroyed")
|
|
304
419
|
return SyncResult(success=True, action_taken="destroy_environment")
|
|
@@ -330,7 +445,7 @@ class PullRequest:
|
|
|
330
445
|
self.current_show.stop(**kwargs)
|
|
331
446
|
# Remove all circus labels after successful stop
|
|
332
447
|
if not kwargs.get("dry_run_github", False):
|
|
333
|
-
|
|
448
|
+
self.remove_showtime_labels()
|
|
334
449
|
return SyncResult(success=True, action_taken="stopped")
|
|
335
450
|
except Exception as e:
|
|
336
451
|
return SyncResult(success=False, action_taken="stop_failed", error=str(e))
|
|
@@ -366,15 +481,15 @@ class PullRequest:
|
|
|
366
481
|
for show in pr.shows:
|
|
367
482
|
# Determine show type based on pointer presence
|
|
368
483
|
show_type = "orphaned" # Default
|
|
369
|
-
|
|
484
|
+
|
|
370
485
|
# Check for active pointer
|
|
371
486
|
if any(label == f"🎪 🎯 {show.sha}" for label in pr.labels):
|
|
372
487
|
show_type = "active"
|
|
373
|
-
# Check for building pointer
|
|
374
|
-
elif
|
|
488
|
+
# Check for building pointer
|
|
489
|
+
elif show.status in ["building", "deploying"]:
|
|
375
490
|
show_type = "building"
|
|
376
491
|
# No pointer = orphaned
|
|
377
|
-
|
|
492
|
+
|
|
378
493
|
environment_data = {
|
|
379
494
|
"pr_number": pr_number,
|
|
380
495
|
"status": "active", # Keep for compatibility
|
|
@@ -397,12 +512,20 @@ class PullRequest:
|
|
|
397
512
|
"""Determine what sync action is needed based on target SHA state"""
|
|
398
513
|
# CRITICAL: Get fresh labels before any decisions
|
|
399
514
|
self.refresh_labels()
|
|
400
|
-
|
|
515
|
+
|
|
516
|
+
# Check for blocked state first (fast bailout)
|
|
517
|
+
if "🎪 🔒 showtime-blocked" in self.labels:
|
|
518
|
+
return "blocked"
|
|
519
|
+
|
|
520
|
+
# Check authorization (security layer)
|
|
521
|
+
if not self._check_authorization():
|
|
522
|
+
return "blocked"
|
|
523
|
+
|
|
401
524
|
target_sha_short = target_sha[:7] # Ensure we're working with short SHA
|
|
402
|
-
|
|
525
|
+
|
|
403
526
|
# Get the specific show for the target SHA
|
|
404
527
|
target_show = self.get_show_by_sha(target_sha_short)
|
|
405
|
-
|
|
528
|
+
|
|
406
529
|
# Check for explicit trigger labels
|
|
407
530
|
trigger_labels = [label for label in self.labels if "showtime-trigger-" in label]
|
|
408
531
|
|
|
@@ -440,16 +563,16 @@ class PullRequest:
|
|
|
440
563
|
"""Atomically claim this PR for the current job based on target SHA state"""
|
|
441
564
|
# CRITICAL: Get fresh labels before any decisions
|
|
442
565
|
self.refresh_labels()
|
|
443
|
-
|
|
566
|
+
|
|
444
567
|
target_sha_short = target_sha[:7]
|
|
445
568
|
target_show = self.get_show_by_sha(target_sha_short)
|
|
446
|
-
|
|
447
|
-
# 1. Validate current state allows this action for target SHA
|
|
569
|
+
|
|
570
|
+
# 1. Validate current state allows this action for target SHA
|
|
448
571
|
if action in ["create_environment", "rolling_update", "auto_sync"]:
|
|
449
572
|
if target_show and target_show.status in [
|
|
450
573
|
"building",
|
|
451
|
-
"built",
|
|
452
|
-
"deploying",
|
|
574
|
+
"built",
|
|
575
|
+
"deploying",
|
|
453
576
|
]:
|
|
454
577
|
return False # Target SHA already in progress - ONLY conflict case returns
|
|
455
578
|
|
|
@@ -462,7 +585,7 @@ class PullRequest:
|
|
|
462
585
|
if trigger_labels:
|
|
463
586
|
print(f"🏷️ Removing trigger labels: {trigger_labels}")
|
|
464
587
|
for trigger_label in trigger_labels:
|
|
465
|
-
|
|
588
|
+
self.remove_label(trigger_label)
|
|
466
589
|
else:
|
|
467
590
|
print("🏷️ No trigger labels to remove")
|
|
468
591
|
|
|
@@ -470,17 +593,16 @@ class PullRequest:
|
|
|
470
593
|
if action in ["create_environment", "rolling_update", "auto_sync"]:
|
|
471
594
|
building_show = self._create_new_show(target_sha)
|
|
472
595
|
building_show.status = "building"
|
|
473
|
-
|
|
474
|
-
# Update labels to reflect building state
|
|
475
|
-
print(f"🏷️ Removing
|
|
476
|
-
|
|
477
|
-
|
|
596
|
+
|
|
597
|
+
# Update labels to reflect building state - only remove for this SHA
|
|
598
|
+
print(f"🏷️ Removing labels for SHA {target_sha[:7]}...")
|
|
599
|
+
self.remove_sha_labels(target_sha)
|
|
600
|
+
|
|
478
601
|
new_labels = building_show.to_circus_labels()
|
|
479
602
|
print(f"🏷️ Creating new labels: {new_labels}")
|
|
480
603
|
for label in new_labels:
|
|
481
604
|
try:
|
|
482
|
-
|
|
483
|
-
print(f" ✅ Added: {label}")
|
|
605
|
+
self.add_label(label)
|
|
484
606
|
except Exception as e:
|
|
485
607
|
print(f" ❌ Failed to add {label}: {e}")
|
|
486
608
|
raise
|
|
@@ -583,23 +705,21 @@ class PullRequest:
|
|
|
583
705
|
|
|
584
706
|
# First, remove any existing status labels for this SHA to ensure clean transitions
|
|
585
707
|
sha_status_labels = [
|
|
586
|
-
label for label in self.labels
|
|
587
|
-
if label.startswith(f"🎪 {show.sha} 🚦 ")
|
|
708
|
+
label for label in self.labels if label.startswith(f"🎪 {show.sha} 🚦 ")
|
|
588
709
|
]
|
|
589
710
|
for old_status_label in sha_status_labels:
|
|
590
|
-
|
|
711
|
+
self.remove_label(old_status_label)
|
|
591
712
|
|
|
592
713
|
# For running environments, ensure only ONE active pointer exists
|
|
593
714
|
if show.status == "running":
|
|
594
715
|
# Remove ALL existing active pointers (there should only be one)
|
|
595
716
|
existing_active_pointers = [
|
|
596
|
-
label for label in self.labels
|
|
597
|
-
if label.startswith("🎪 🎯 ")
|
|
717
|
+
label for label in self.labels if label.startswith("🎪 🎯 ")
|
|
598
718
|
]
|
|
599
719
|
for old_pointer in existing_active_pointers:
|
|
600
720
|
print(f"🎯 Removing old active pointer: {old_pointer}")
|
|
601
|
-
|
|
602
|
-
|
|
721
|
+
self.remove_label(old_pointer)
|
|
722
|
+
|
|
603
723
|
# CRITICAL: Refresh after removals before differential calculation
|
|
604
724
|
if existing_active_pointers:
|
|
605
725
|
print("🔄 Refreshing labels after pointer cleanup...")
|
|
@@ -607,11 +727,12 @@ class PullRequest:
|
|
|
607
727
|
|
|
608
728
|
# Now do normal differential updates - only for this SHA
|
|
609
729
|
current_sha_labels = {
|
|
610
|
-
label
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
label.startswith(f"🎪
|
|
730
|
+
label
|
|
731
|
+
for label in self.labels
|
|
732
|
+
if label.startswith("🎪")
|
|
733
|
+
and (
|
|
734
|
+
label.startswith(f"🎪 {show.sha} ") # SHA-first format: 🎪 abc123f 📅 ...
|
|
735
|
+
or label.startswith(f"🎪 🎯 {show.sha}") # Pointer format: 🎪 🎯 abc123f
|
|
615
736
|
)
|
|
616
737
|
}
|
|
617
738
|
desired_labels = set(show.to_circus_labels())
|
|
@@ -622,12 +743,12 @@ class PullRequest:
|
|
|
622
743
|
# Only add labels that don't exist
|
|
623
744
|
labels_to_add = desired_labels - current_sha_labels
|
|
624
745
|
for label in labels_to_add:
|
|
625
|
-
|
|
746
|
+
self.add_label(label)
|
|
626
747
|
|
|
627
748
|
# Only remove labels that shouldn't exist (excluding status labels already handled)
|
|
628
749
|
labels_to_remove = current_sha_labels - desired_labels
|
|
629
750
|
for label in labels_to_remove:
|
|
630
|
-
|
|
751
|
+
self.remove_label(label)
|
|
631
752
|
|
|
632
753
|
# Final refresh to update cache with all changes
|
|
633
754
|
self.refresh_labels()
|
|
@@ -635,54 +756,55 @@ class PullRequest:
|
|
|
635
756
|
def _show_service_urls(self, show: Show) -> None:
|
|
636
757
|
"""Show AWS console URLs for monitoring deployment"""
|
|
637
758
|
from .github_messages import get_aws_console_urls
|
|
638
|
-
|
|
759
|
+
|
|
639
760
|
urls = get_aws_console_urls(show.ecs_service_name)
|
|
640
|
-
print(
|
|
761
|
+
print("\n🎪 Monitor deployment progress:")
|
|
641
762
|
print(f"📝 Logs: {urls['logs']}")
|
|
642
763
|
print(f"📊 Service: {urls['service']}")
|
|
643
764
|
print("")
|
|
644
765
|
|
|
645
|
-
def stop_previous_environments(
|
|
766
|
+
def stop_previous_environments(
|
|
767
|
+
self, keep_sha: str, dry_run_github: bool = False, dry_run_aws: bool = False
|
|
768
|
+
) -> int:
|
|
646
769
|
"""Stop all environments except the specified SHA (blue-green cleanup)
|
|
647
|
-
|
|
770
|
+
|
|
648
771
|
Args:
|
|
649
772
|
keep_sha: SHA of environment to keep running
|
|
650
|
-
dry_run_github: Skip GitHub label operations
|
|
773
|
+
dry_run_github: Skip GitHub label operations
|
|
651
774
|
dry_run_aws: Skip AWS operations
|
|
652
|
-
|
|
775
|
+
|
|
653
776
|
Returns:
|
|
654
777
|
Number of environments stopped
|
|
655
778
|
"""
|
|
656
779
|
# Note: Labels should be fresh from recent _update_show_labels() call
|
|
657
780
|
stopped_count = 0
|
|
658
|
-
|
|
781
|
+
|
|
659
782
|
for show in self.shows:
|
|
660
783
|
if show.sha != keep_sha:
|
|
661
784
|
print(f"🧹 Cleaning up old environment: {show.sha} ({show.status})")
|
|
662
785
|
try:
|
|
663
786
|
show.stop(dry_run_github=dry_run_github, dry_run_aws=dry_run_aws)
|
|
664
|
-
|
|
787
|
+
|
|
665
788
|
# Remove ONLY existing labels for this old environment (not theoretical ones)
|
|
666
789
|
if not dry_run_github:
|
|
667
790
|
existing_labels = [
|
|
668
|
-
label
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
label == f"🎪 🏗️ {show.sha}"
|
|
791
|
+
label
|
|
792
|
+
for label in self.labels
|
|
793
|
+
if label.startswith(f"🎪 {show.sha} ") or label == f"🎪 🎯 {show.sha}"
|
|
672
794
|
]
|
|
673
795
|
print(f"🏷️ Removing existing labels for {show.sha}: {existing_labels}")
|
|
674
796
|
for label in existing_labels:
|
|
675
797
|
try:
|
|
676
|
-
|
|
798
|
+
self.remove_label(label)
|
|
677
799
|
except Exception as e:
|
|
678
800
|
print(f"⚠️ Failed to remove label {label}: {e}")
|
|
679
|
-
|
|
801
|
+
|
|
680
802
|
stopped_count += 1
|
|
681
803
|
print(f"✅ Stopped environment {show.sha}")
|
|
682
|
-
|
|
804
|
+
|
|
683
805
|
except Exception as e:
|
|
684
806
|
print(f"❌ Failed to stop environment {show.sha}: {e}")
|
|
685
|
-
|
|
807
|
+
|
|
686
808
|
if stopped_count > 0:
|
|
687
809
|
print(f"🧹 Blue-green cleanup: stopped {stopped_count} old environments")
|
|
688
810
|
# Refresh labels after cleanup
|
|
@@ -690,5 +812,5 @@ class PullRequest:
|
|
|
690
812
|
self.refresh_labels()
|
|
691
813
|
else:
|
|
692
814
|
print("ℹ️ No old environments to clean up")
|
|
693
|
-
|
|
815
|
+
|
|
694
816
|
return stopped_count
|
|
@@ -96,21 +96,24 @@ class Show:
|
|
|
96
96
|
|
|
97
97
|
def to_circus_labels(self) -> List[str]:
|
|
98
98
|
"""Convert show state to circus tent emoji labels (per-SHA format)"""
|
|
99
|
+
from .emojis import CIRCUS_PREFIX, MEANING_TO_EMOJI
|
|
100
|
+
|
|
99
101
|
if not self.created_at:
|
|
100
102
|
self.created_at = datetime.utcnow().strftime("%Y-%m-%dT%H-%M")
|
|
101
103
|
|
|
102
104
|
labels = [
|
|
103
|
-
f"
|
|
104
|
-
f"
|
|
105
|
-
f"
|
|
106
|
-
f"🎪 {self.sha} ⌛ {self.ttl}", # SHA-first TTL
|
|
105
|
+
f"{CIRCUS_PREFIX} {self.sha} {MEANING_TO_EMOJI['status']} {self.status}", # SHA-first status
|
|
106
|
+
f"{CIRCUS_PREFIX} {self.sha} {MEANING_TO_EMOJI['created_at']} {self.created_at}", # SHA-first timestamp
|
|
107
|
+
f"{CIRCUS_PREFIX} {self.sha} {MEANING_TO_EMOJI['ttl']} {self.ttl}", # SHA-first TTL
|
|
107
108
|
]
|
|
108
109
|
|
|
109
110
|
if self.ip:
|
|
110
|
-
labels.append(f"
|
|
111
|
+
labels.append(f"{CIRCUS_PREFIX} {self.sha} {MEANING_TO_EMOJI['ip']} {self.ip}:8080")
|
|
111
112
|
|
|
112
113
|
if self.requested_by:
|
|
113
|
-
labels.append(
|
|
114
|
+
labels.append(
|
|
115
|
+
f"{CIRCUS_PREFIX} {self.sha} {MEANING_TO_EMOJI['requested_by']} {self.requested_by}"
|
|
116
|
+
)
|
|
114
117
|
|
|
115
118
|
return labels
|
|
116
119
|
|
|
@@ -158,7 +161,6 @@ class Show:
|
|
|
158
161
|
def _build_docker_image(self) -> None:
|
|
159
162
|
"""Build Docker image for this environment"""
|
|
160
163
|
import os
|
|
161
|
-
import platform
|
|
162
164
|
import subprocess
|
|
163
165
|
|
|
164
166
|
tag = f"apache/superset:pr-{self.pr_number}-{self.sha}-ci"
|
|
@@ -187,19 +189,23 @@ class Show:
|
|
|
187
189
|
# Add caching based on environment
|
|
188
190
|
if is_ci:
|
|
189
191
|
# Full registry caching in CI (Docker driver supports it)
|
|
190
|
-
cmd.extend(
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
192
|
+
cmd.extend(
|
|
193
|
+
[
|
|
194
|
+
"--cache-from",
|
|
195
|
+
"type=registry,ref=apache/superset-cache:showtime",
|
|
196
|
+
"--cache-to",
|
|
197
|
+
"type=registry,mode=max,ref=apache/superset-cache:showtime",
|
|
198
|
+
]
|
|
199
|
+
)
|
|
196
200
|
print("🐳 CI environment: Using full registry caching")
|
|
197
201
|
else:
|
|
198
202
|
# Local build: cache-from only (no cache export)
|
|
199
|
-
cmd.extend(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
+
cmd.extend(
|
|
204
|
+
[
|
|
205
|
+
"--cache-from",
|
|
206
|
+
"type=registry,ref=apache/superset-cache:showtime",
|
|
207
|
+
]
|
|
208
|
+
)
|
|
203
209
|
print("🐳 Local environment: Using cache-from only (no export)")
|
|
204
210
|
|
|
205
211
|
# Add --load only when explicitly requested for local testing
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Tests for PullRequest class - PR-level orchestration
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
from unittest.mock import Mock, patch
|
|
6
7
|
|
|
7
8
|
from showtime.core.pull_request import AnalysisResult, PullRequest, SyncResult
|
|
@@ -15,7 +16,7 @@ def test_pullrequest_creation():
|
|
|
15
16
|
pr = PullRequest(1234, labels)
|
|
16
17
|
|
|
17
18
|
assert pr.pr_number == 1234
|
|
18
|
-
assert pr.labels == labels
|
|
19
|
+
assert pr.labels == set(labels)
|
|
19
20
|
assert len(pr.shows) == 1
|
|
20
21
|
assert pr.current_show is not None
|
|
21
22
|
assert pr.current_show.sha == "abc123f"
|
|
@@ -127,7 +128,7 @@ def test_pullrequest_determine_action():
|
|
|
127
128
|
# No triggers, but different SHA - create new environment (SHA-specific)
|
|
128
129
|
pr_auto = PullRequest(1234, ["🎪 abc123f 🚦 running", "🎪 🎯 abc123f"])
|
|
129
130
|
assert pr_auto._determine_action("def456a") == "create_environment"
|
|
130
|
-
|
|
131
|
+
|
|
131
132
|
# Failed environment, no triggers - create new (retry logic)
|
|
132
133
|
pr_failed = PullRequest(1234, ["🎪 abc123f 🚦 failed", "🎪 🎯 abc123f"])
|
|
133
134
|
assert pr_failed._determine_action("abc123f") == "create_environment"
|
|
@@ -452,11 +453,7 @@ def test_pullrequest_sync_same_sha_no_action(mock_get_github):
|
|
|
452
453
|
mock_get_github.return_value = mock_github
|
|
453
454
|
|
|
454
455
|
# PR with existing healthy environment, same SHA, no triggers
|
|
455
|
-
pr = PullRequest(1234, [
|
|
456
|
-
"🎪 abc123f 🚦 running",
|
|
457
|
-
"🎪 🎯 abc123f",
|
|
458
|
-
"bug", "enhancement"
|
|
459
|
-
])
|
|
456
|
+
pr = PullRequest(1234, ["🎪 abc123f 🚦 running", "🎪 🎯 abc123f", "bug", "enhancement"])
|
|
460
457
|
|
|
461
458
|
result = pr.sync("abc123f") # Same SHA as current
|
|
462
459
|
|
|
@@ -631,20 +628,23 @@ def test_pullrequest_update_show_labels(mock_get_github):
|
|
|
631
628
|
mock_github.remove_label.assert_called()
|
|
632
629
|
|
|
633
630
|
|
|
634
|
-
@patch(
|
|
631
|
+
@patch("showtime.core.pull_request.get_github")
|
|
635
632
|
def test_pullrequest_update_show_labels_status_replacement(mock_get_github):
|
|
636
633
|
"""Test that status updates properly remove old status labels"""
|
|
637
634
|
mock_github = Mock()
|
|
638
635
|
mock_get_github.return_value = mock_github
|
|
639
|
-
|
|
636
|
+
|
|
640
637
|
# PR with multiple status labels (the bug scenario)
|
|
641
|
-
pr = PullRequest(
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
638
|
+
pr = PullRequest(
|
|
639
|
+
1234,
|
|
640
|
+
[
|
|
641
|
+
"🎪 abc123f 🚦 building", # Old status
|
|
642
|
+
"🎪 abc123f 🚦 failed", # Another old status
|
|
643
|
+
"🎪 🎯 abc123f", # Pointer
|
|
644
|
+
"🎪 abc123f 📅 2024-01-15T14-30",
|
|
645
|
+
],
|
|
646
|
+
)
|
|
647
|
+
|
|
648
648
|
# Show transitioning to running
|
|
649
649
|
show = Show(
|
|
650
650
|
pr_number=1234,
|
|
@@ -652,15 +652,291 @@ def test_pullrequest_update_show_labels_status_replacement(mock_get_github):
|
|
|
652
652
|
status="running", # New status
|
|
653
653
|
created_at="2024-01-15T14-30",
|
|
654
654
|
)
|
|
655
|
-
|
|
656
|
-
with patch.object(pr,
|
|
655
|
+
|
|
656
|
+
with patch.object(pr, "refresh_labels"):
|
|
657
657
|
pr._update_show_labels(show, dry_run=False)
|
|
658
|
-
|
|
658
|
+
|
|
659
659
|
# Should remove BOTH old status labels
|
|
660
660
|
remove_calls = [call.args[1] for call in mock_github.remove_label.call_args_list]
|
|
661
661
|
assert "🎪 abc123f 🚦 building" in remove_calls
|
|
662
662
|
assert "🎪 abc123f 🚦 failed" in remove_calls
|
|
663
|
-
|
|
663
|
+
|
|
664
664
|
# Should add new status label
|
|
665
665
|
add_calls = [call.args[1] for call in mock_github.add_label.call_args_list]
|
|
666
666
|
assert "🎪 abc123f 🚦 running" in add_calls
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# Test new centralized label management methods
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@patch("showtime.core.pull_request.get_github")
|
|
673
|
+
def test_pullrequest_add_label_with_logging(mock_get_github):
|
|
674
|
+
"""Test PullRequest.add_label() with logging and state update"""
|
|
675
|
+
mock_github = Mock()
|
|
676
|
+
mock_get_github.return_value = mock_github
|
|
677
|
+
|
|
678
|
+
pr = PullRequest(1234, ["existing-label"])
|
|
679
|
+
|
|
680
|
+
# Test adding new label
|
|
681
|
+
pr.add_label("new-label")
|
|
682
|
+
|
|
683
|
+
# Should call GitHub API
|
|
684
|
+
mock_github.add_label.assert_called_once_with(1234, "new-label")
|
|
685
|
+
|
|
686
|
+
# Should update local state
|
|
687
|
+
assert "new-label" in pr.labels
|
|
688
|
+
assert "existing-label" in pr.labels
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@patch("showtime.core.pull_request.get_github")
|
|
692
|
+
def test_pullrequest_remove_label_with_logging(mock_get_github):
|
|
693
|
+
"""Test PullRequest.remove_label() with logging and state update"""
|
|
694
|
+
mock_github = Mock()
|
|
695
|
+
mock_get_github.return_value = mock_github
|
|
696
|
+
|
|
697
|
+
pr = PullRequest(1234, ["label1", "label2"])
|
|
698
|
+
|
|
699
|
+
# Test removing existing label
|
|
700
|
+
pr.remove_label("label1")
|
|
701
|
+
|
|
702
|
+
# Should call GitHub API
|
|
703
|
+
mock_github.remove_label.assert_called_once_with(1234, "label1")
|
|
704
|
+
|
|
705
|
+
# Should update local state
|
|
706
|
+
assert "label1" not in pr.labels
|
|
707
|
+
assert "label2" in pr.labels
|
|
708
|
+
|
|
709
|
+
# Test removing non-existent label (should be safe)
|
|
710
|
+
pr.remove_label("nonexistent")
|
|
711
|
+
assert len(mock_github.remove_label.call_args_list) == 2
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
@patch("showtime.core.pull_request.get_github")
|
|
715
|
+
def test_pullrequest_remove_sha_labels(mock_get_github):
|
|
716
|
+
"""Test PullRequest.remove_sha_labels() for SHA-specific cleanup"""
|
|
717
|
+
mock_github = Mock()
|
|
718
|
+
mock_github.get_labels.return_value = [
|
|
719
|
+
"🎪 abc123f 🚦 building",
|
|
720
|
+
"🎪 abc123f 📅 2025-08-26",
|
|
721
|
+
"🎪 def456a 🚦 running", # Different SHA
|
|
722
|
+
"🎪 🎯 def456a", # Different SHA
|
|
723
|
+
"regular-label",
|
|
724
|
+
]
|
|
725
|
+
mock_get_github.return_value = mock_github
|
|
726
|
+
|
|
727
|
+
pr = PullRequest(1234, [])
|
|
728
|
+
|
|
729
|
+
# Test removing labels for specific SHA
|
|
730
|
+
pr.remove_sha_labels("abc123f789") # Full SHA
|
|
731
|
+
|
|
732
|
+
# Should call GitHub API for abc123f labels only
|
|
733
|
+
remove_calls = [call.args[1] for call in mock_github.remove_label.call_args_list]
|
|
734
|
+
assert "🎪 abc123f 🚦 building" in remove_calls
|
|
735
|
+
assert "🎪 abc123f 📅 2025-08-26" in remove_calls
|
|
736
|
+
assert "🎪 def456a 🚦 running" not in remove_calls
|
|
737
|
+
assert "🎪 🎯 def456a" not in remove_calls
|
|
738
|
+
assert "regular-label" not in remove_calls
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
@patch("showtime.core.pull_request.get_github")
|
|
742
|
+
def test_pullrequest_remove_showtime_labels(mock_get_github):
|
|
743
|
+
"""Test PullRequest.remove_showtime_labels() for complete cleanup"""
|
|
744
|
+
mock_github = Mock()
|
|
745
|
+
mock_github.get_labels.return_value = [
|
|
746
|
+
"🎪 abc123f 🚦 running",
|
|
747
|
+
"🎪 🎯 abc123f",
|
|
748
|
+
"🎪 def456a 🚦 building",
|
|
749
|
+
"regular-label",
|
|
750
|
+
"bug",
|
|
751
|
+
]
|
|
752
|
+
mock_get_github.return_value = mock_github
|
|
753
|
+
|
|
754
|
+
pr = PullRequest(1234, [])
|
|
755
|
+
|
|
756
|
+
# Test removing all showtime labels
|
|
757
|
+
pr.remove_showtime_labels()
|
|
758
|
+
|
|
759
|
+
# Should call GitHub API for all circus labels
|
|
760
|
+
remove_calls = [call.args[1] for call in mock_github.remove_label.call_args_list]
|
|
761
|
+
assert "🎪 abc123f 🚦 running" in remove_calls
|
|
762
|
+
assert "🎪 🎯 abc123f" in remove_calls
|
|
763
|
+
assert "🎪 def456a 🚦 building" in remove_calls
|
|
764
|
+
assert "regular-label" not in remove_calls
|
|
765
|
+
assert "bug" not in remove_calls
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
@patch("showtime.core.pull_request.get_github")
|
|
769
|
+
def test_pullrequest_set_show_status(mock_get_github):
|
|
770
|
+
"""Test PullRequest.set_show_status() atomic status transitions"""
|
|
771
|
+
mock_github = Mock()
|
|
772
|
+
mock_github.get_labels.return_value = [
|
|
773
|
+
"🎪 abc123f 🚦 building",
|
|
774
|
+
"🎪 abc123f 🚦 failed", # Duplicate/stale status
|
|
775
|
+
"🎪 abc123f 📅 2025-08-26",
|
|
776
|
+
]
|
|
777
|
+
mock_get_github.return_value = mock_github
|
|
778
|
+
|
|
779
|
+
pr = PullRequest(1234, [])
|
|
780
|
+
show = Show(pr_number=1234, sha="abc123f", status="building")
|
|
781
|
+
|
|
782
|
+
# Test status transition with cleanup
|
|
783
|
+
pr.set_show_status(show, "deploying")
|
|
784
|
+
|
|
785
|
+
# Should remove all existing status labels
|
|
786
|
+
remove_calls = [call.args[1] for call in mock_github.remove_label.call_args_list]
|
|
787
|
+
assert "🎪 abc123f 🚦 building" in remove_calls
|
|
788
|
+
assert "🎪 abc123f 🚦 failed" in remove_calls
|
|
789
|
+
|
|
790
|
+
# Should add new status label
|
|
791
|
+
add_calls = [call.args[1] for call in mock_github.add_label.call_args_list]
|
|
792
|
+
assert "🎪 abc123f 🚦 deploying" in add_calls
|
|
793
|
+
|
|
794
|
+
# Should update show status
|
|
795
|
+
assert show.status == "deploying"
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
@patch("showtime.core.pull_request.get_github")
|
|
799
|
+
def test_pullrequest_set_active_show(mock_get_github):
|
|
800
|
+
"""Test PullRequest.set_active_show() atomic active pointer management"""
|
|
801
|
+
mock_github = Mock()
|
|
802
|
+
mock_github.get_labels.return_value = [
|
|
803
|
+
"🎪 🎯 old123f", # Old active pointer
|
|
804
|
+
"🎪 🎯 other456", # Another old pointer
|
|
805
|
+
"🎪 abc123f 🚦 running",
|
|
806
|
+
]
|
|
807
|
+
mock_get_github.return_value = mock_github
|
|
808
|
+
|
|
809
|
+
pr = PullRequest(1234, [])
|
|
810
|
+
show = Show(pr_number=1234, sha="abc123f", status="running")
|
|
811
|
+
|
|
812
|
+
# Test setting active show
|
|
813
|
+
pr.set_active_show(show)
|
|
814
|
+
|
|
815
|
+
# Should remove all existing active pointers
|
|
816
|
+
remove_calls = [call.args[1] for call in mock_github.remove_label.call_args_list]
|
|
817
|
+
assert "🎪 🎯 old123f" in remove_calls
|
|
818
|
+
assert "🎪 🎯 other456" in remove_calls
|
|
819
|
+
|
|
820
|
+
# Should add new active pointer
|
|
821
|
+
add_calls = [call.args[1] for call in mock_github.add_label.call_args_list]
|
|
822
|
+
assert "🎪 🎯 abc123f" in add_calls
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
@patch("showtime.core.pull_request.get_github")
|
|
826
|
+
def test_pullrequest_blocked_state(mock_get_github):
|
|
827
|
+
"""Test that blocked state prevents all operations"""
|
|
828
|
+
mock_github = Mock()
|
|
829
|
+
mock_github.get_labels.return_value = [
|
|
830
|
+
"🎪 🔒 showtime-blocked",
|
|
831
|
+
"🎪 abc123f 🚦 running", # Existing environment
|
|
832
|
+
"🎪 ⚡ showtime-trigger-start", # Trigger should be ignored
|
|
833
|
+
]
|
|
834
|
+
mock_get_github.return_value = mock_github
|
|
835
|
+
|
|
836
|
+
pr = PullRequest(1234, [])
|
|
837
|
+
|
|
838
|
+
# Test sync with blocked state
|
|
839
|
+
result = pr.sync("def456a")
|
|
840
|
+
|
|
841
|
+
# Should fail with blocked error
|
|
842
|
+
assert result.success is False
|
|
843
|
+
assert result.action_taken == "blocked"
|
|
844
|
+
assert "🔒 Showtime operations are blocked" in result.error
|
|
845
|
+
assert "showtime-blocked" in result.error
|
|
846
|
+
|
|
847
|
+
# Should not perform any operations
|
|
848
|
+
assert not mock_github.add_label.called
|
|
849
|
+
assert not mock_github.remove_label.called
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
@patch("showtime.core.pull_request.get_github")
|
|
853
|
+
def test_pullrequest_determine_action_blocked(mock_get_github):
|
|
854
|
+
"""Test _determine_action returns 'blocked' when blocked label present"""
|
|
855
|
+
mock_github = Mock()
|
|
856
|
+
mock_github.get_labels.return_value = ["🎪 🔒 showtime-blocked", "🎪 ⚡ showtime-trigger-start"]
|
|
857
|
+
mock_get_github.return_value = mock_github
|
|
858
|
+
|
|
859
|
+
pr = PullRequest(1234, [])
|
|
860
|
+
|
|
861
|
+
action = pr._determine_action("abc123f")
|
|
862
|
+
|
|
863
|
+
assert action == "blocked"
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
@patch.dict(os.environ, {"GITHUB_ACTIONS": "true", "GITHUB_ACTOR": "external-user"})
|
|
867
|
+
@patch("showtime.core.pull_request.get_github")
|
|
868
|
+
def test_pullrequest_authorization_check_unauthorized(mock_get_github):
|
|
869
|
+
"""Test authorization check blocks unauthorized users"""
|
|
870
|
+
mock_github = Mock()
|
|
871
|
+
mock_github.base_url = "https://api.github.com"
|
|
872
|
+
mock_github.org = "apache"
|
|
873
|
+
mock_github.repo = "superset"
|
|
874
|
+
mock_github.headers = {"Authorization": "Bearer token"}
|
|
875
|
+
|
|
876
|
+
# Mock unauthorized response
|
|
877
|
+
mock_response = Mock()
|
|
878
|
+
mock_response.status_code = 200
|
|
879
|
+
mock_response.json.return_value = {"permission": "read"} # Not write/admin
|
|
880
|
+
|
|
881
|
+
with patch("httpx.Client") as mock_client_class:
|
|
882
|
+
mock_client = Mock()
|
|
883
|
+
mock_client.get.return_value = mock_response
|
|
884
|
+
mock_client.__enter__.return_value = mock_client
|
|
885
|
+
mock_client.__exit__.return_value = None
|
|
886
|
+
mock_client_class.return_value = mock_client
|
|
887
|
+
|
|
888
|
+
mock_get_github.return_value = mock_github
|
|
889
|
+
|
|
890
|
+
pr = PullRequest(1234, [])
|
|
891
|
+
|
|
892
|
+
# Test unauthorized actor
|
|
893
|
+
authorized = pr._check_authorization()
|
|
894
|
+
|
|
895
|
+
assert authorized is False
|
|
896
|
+
# Should have added blocked label
|
|
897
|
+
mock_github.add_label.assert_called_once_with(1234, "🎪 🔒 showtime-blocked")
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
@patch.dict(os.environ, {"GITHUB_ACTIONS": "true", "GITHUB_ACTOR": "maintainer-user"})
|
|
901
|
+
@patch("showtime.core.pull_request.get_github")
|
|
902
|
+
def test_pullrequest_authorization_check_authorized(mock_get_github):
|
|
903
|
+
"""Test authorization check allows authorized users"""
|
|
904
|
+
mock_github = Mock()
|
|
905
|
+
mock_github.base_url = "https://api.github.com"
|
|
906
|
+
mock_github.org = "apache"
|
|
907
|
+
mock_github.repo = "superset"
|
|
908
|
+
mock_github.headers = {"Authorization": "Bearer token"}
|
|
909
|
+
|
|
910
|
+
# Mock authorized response
|
|
911
|
+
mock_response = Mock()
|
|
912
|
+
mock_response.status_code = 200
|
|
913
|
+
mock_response.json.return_value = {"permission": "write"} # Authorized
|
|
914
|
+
|
|
915
|
+
with patch("httpx.Client") as mock_client_class:
|
|
916
|
+
mock_client = Mock()
|
|
917
|
+
mock_client.get.return_value = mock_response
|
|
918
|
+
mock_client.__enter__.return_value = mock_client
|
|
919
|
+
mock_client.__exit__.return_value = None
|
|
920
|
+
mock_client_class.return_value = mock_client
|
|
921
|
+
|
|
922
|
+
mock_get_github.return_value = mock_github
|
|
923
|
+
|
|
924
|
+
pr = PullRequest(1234, [])
|
|
925
|
+
|
|
926
|
+
# Test authorized actor
|
|
927
|
+
authorized = pr._check_authorization()
|
|
928
|
+
|
|
929
|
+
assert authorized is True
|
|
930
|
+
# Should not add blocked label
|
|
931
|
+
assert not mock_github.add_label.called
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
@patch.dict(os.environ, {"GITHUB_ACTIONS": "false"})
|
|
935
|
+
def test_pullrequest_authorization_check_local():
|
|
936
|
+
"""Test authorization check skipped in non-GHA environment"""
|
|
937
|
+
pr = PullRequest(1234, [])
|
|
938
|
+
|
|
939
|
+
# Should always return True for local development
|
|
940
|
+
authorized = pr._check_authorization()
|
|
941
|
+
|
|
942
|
+
assert authorized is True
|
|
@@ -290,3 +290,36 @@ def test_show_to_circus_labels_auto_timestamp():
|
|
|
290
290
|
# Should auto-generate timestamp
|
|
291
291
|
assert show.created_at == "2024-01-15T14-30"
|
|
292
292
|
assert any("📅 2024-01-15T14-30" in label for label in labels)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_show_to_circus_labels_no_active_pointer():
|
|
296
|
+
"""Test that to_circus_labels() does not include active pointer"""
|
|
297
|
+
show = Show(
|
|
298
|
+
pr_number=1234, sha="abc123f", status="running", ip="1.2.3.4", requested_by="testuser"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
labels = show.to_circus_labels()
|
|
302
|
+
|
|
303
|
+
# Should include status, timestamp, TTL, IP, requester
|
|
304
|
+
assert any("🎪 abc123f 🚦 running" in label for label in labels)
|
|
305
|
+
assert any("🎪 abc123f 📅" in label for label in labels)
|
|
306
|
+
assert any("🎪 abc123f ⌛ 24h" in label for label in labels)
|
|
307
|
+
assert any("🎪 abc123f 🌐 1.2.3.4:8080" in label for label in labels)
|
|
308
|
+
assert any("🎪 abc123f 🤡 testuser" in label for label in labels)
|
|
309
|
+
|
|
310
|
+
# Should NOT include active pointer
|
|
311
|
+
assert not any("🎪 🎯" in label for label in labels)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_show_to_circus_labels_building_status():
|
|
315
|
+
"""Test to_circus_labels() for building status"""
|
|
316
|
+
show = Show(pr_number=1234, sha="def456a", status="building")
|
|
317
|
+
|
|
318
|
+
labels = show.to_circus_labels()
|
|
319
|
+
|
|
320
|
+
# Should include building status
|
|
321
|
+
assert any("🎪 def456a 🚦 building" in label for label in labels)
|
|
322
|
+
|
|
323
|
+
# Should NOT include active pointer or building pointer
|
|
324
|
+
assert not any("🎪 🎯" in label for label in labels)
|
|
325
|
+
assert not any("🎪 🏗️" in label for label in labels)
|
{superset_showtime-0.5.11 → superset_showtime-0.5.18}/workflows-reference/showtime-trigger.yml
RENAMED
|
@@ -41,10 +41,76 @@ jobs:
|
|
|
41
41
|
pull-requests: write
|
|
42
42
|
|
|
43
43
|
steps:
|
|
44
|
+
- name: Security Check - Authorize Maintainers Only
|
|
45
|
+
id: auth
|
|
46
|
+
uses: actions/github-script@v7
|
|
47
|
+
with:
|
|
48
|
+
script: |
|
|
49
|
+
const actor = context.actor;
|
|
50
|
+
console.log(`🔍 Checking authorization for ${actor}`);
|
|
51
|
+
|
|
52
|
+
// Early exit for workflow_dispatch - assume authorized since it's manually triggered
|
|
53
|
+
if (context.eventName === 'workflow_dispatch') {
|
|
54
|
+
console.log(`✅ Workflow dispatch event - assuming authorized for ${actor}`);
|
|
55
|
+
core.setOutput('authorized', 'true');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({
|
|
60
|
+
owner: context.repo.owner,
|
|
61
|
+
repo: context.repo.repo,
|
|
62
|
+
username: actor
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
console.log(`📊 Permission level for ${actor}: ${permission.permission}`);
|
|
66
|
+
const authorized = ['write', 'admin'].includes(permission.permission);
|
|
67
|
+
|
|
68
|
+
if (!authorized) {
|
|
69
|
+
console.log(`🚨 Unauthorized user ${actor} - skipping all operations`);
|
|
70
|
+
core.setOutput('authorized', 'false');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(`✅ Authorized maintainer: ${actor}`);
|
|
75
|
+
core.setOutput('authorized', 'true');
|
|
76
|
+
|
|
77
|
+
// If this is a synchronize event, check if Showtime is active and set blocked label
|
|
78
|
+
if (context.eventName === 'pull_request_target' && context.payload.action === 'synchronize') {
|
|
79
|
+
console.log(`🔒 Synchronize event detected - checking if Showtime is active`);
|
|
80
|
+
|
|
81
|
+
// Check if PR has any circus tent labels (Showtime is in use)
|
|
82
|
+
const { data: issue } = await github.rest.issues.get({
|
|
83
|
+
owner: context.repo.owner,
|
|
84
|
+
repo: context.repo.repo,
|
|
85
|
+
issue_number: context.payload.pull_request.number
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const hasCircusLabels = issue.labels.some(label => label.name.startsWith('🎪 '));
|
|
89
|
+
|
|
90
|
+
if (hasCircusLabels) {
|
|
91
|
+
console.log(`🎪 Circus labels found - setting blocked label to prevent auto-deployment`);
|
|
92
|
+
|
|
93
|
+
await github.rest.issues.addLabels({
|
|
94
|
+
owner: context.repo.owner,
|
|
95
|
+
repo: context.repo.repo,
|
|
96
|
+
issue_number: context.payload.pull_request.number,
|
|
97
|
+
labels: ['🎪 🔒 showtime-blocked']
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
console.log(`✅ Blocked label set - Showtime will detect and skip operations`);
|
|
101
|
+
} else {
|
|
102
|
+
console.log(`ℹ️ No circus labels found - Showtime not in use, skipping block`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
44
106
|
- name: Install Superset Showtime
|
|
45
|
-
|
|
107
|
+
if: steps.auth.outputs.authorized == 'true'
|
|
108
|
+
run: |
|
|
109
|
+
pip install --upgrade superset-showtime
|
|
110
|
+
showtime version
|
|
46
111
|
|
|
47
112
|
- name: Check what actions are needed
|
|
113
|
+
if: steps.auth.outputs.authorized == 'true'
|
|
48
114
|
id: check
|
|
49
115
|
run: |
|
|
50
116
|
# Bulletproof PR number extraction
|
|
@@ -79,22 +145,23 @@ jobs:
|
|
|
79
145
|
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
|
|
80
146
|
|
|
81
147
|
- name: Checkout PR code (only if build needed)
|
|
82
|
-
if: steps.check.outputs.build_needed == 'true'
|
|
148
|
+
if: steps.auth.outputs.authorized == 'true' && steps.check.outputs.build_needed == 'true'
|
|
83
149
|
uses: actions/checkout@v4
|
|
84
150
|
with:
|
|
85
151
|
ref: ${{ steps.check.outputs.target_sha }}
|
|
86
152
|
persist-credentials: false
|
|
87
153
|
|
|
88
154
|
- name: Setup Docker Environment (only if build needed)
|
|
89
|
-
if: steps.check.outputs.build_needed == 'true'
|
|
155
|
+
if: steps.auth.outputs.authorized == 'true' && steps.check.outputs.build_needed == 'true'
|
|
90
156
|
uses: ./.github/actions/setup-docker
|
|
91
157
|
with:
|
|
92
158
|
dockerhub-user: ${{ env.DOCKERHUB_USER }}
|
|
93
159
|
dockerhub-token: ${{ env.DOCKERHUB_TOKEN }}
|
|
94
160
|
build: "true"
|
|
161
|
+
install-docker-compose: "false"
|
|
95
162
|
|
|
96
163
|
- name: Execute sync (handles everything)
|
|
97
|
-
if: steps.check.outputs.sync_needed == 'true'
|
|
164
|
+
if: steps.auth.outputs.authorized == 'true' && steps.check.outputs.sync_needed == 'true'
|
|
98
165
|
run: |
|
|
99
166
|
PR_NUM="${{ steps.check.outputs.pr_number }}"
|
|
100
167
|
TARGET_SHA="${{ steps.check.outputs.target_sha }}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|