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.

Files changed (34) hide show
  1. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/.claude/settings.local.json +2 -1
  2. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/CLAUDE.md +5 -4
  3. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/PKG-INFO +1 -1
  4. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/__init__.py +1 -1
  5. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/emojis.py +1 -0
  6. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/git_validation.py +3 -0
  7. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/github.py +0 -8
  8. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/label_colors.py +4 -0
  9. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/pull_request.py +204 -82
  10. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/show.py +23 -17
  11. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/data/ecs-task-definition.json +1 -1
  12. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/tests/unit/test_pull_request.py +296 -20
  13. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/tests/unit/test_show.py +33 -0
  14. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/workflows-reference/showtime-cleanup.yml +0 -1
  15. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/workflows-reference/showtime-trigger.yml +71 -4
  16. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/.gitignore +0 -0
  17. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/.pre-commit-config.yaml +0 -0
  18. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/Makefile +0 -0
  19. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/README.md +0 -0
  20. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/dev-setup.sh +0 -0
  21. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/pypi-push.sh +0 -0
  22. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/pyproject.toml +0 -0
  23. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/requirements-dev.txt +0 -0
  24. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/requirements.txt +0 -0
  25. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/__main__.py +0 -0
  26. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/cli.py +0 -0
  27. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/__init__.py +0 -0
  28. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/aws.py +0 -0
  29. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/showtime/core/github_messages.py +0 -0
  30. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/tests/__init__.py +0 -0
  31. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/tests/unit/__init__.py +0 -0
  32. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/tests/unit/test_label_transitions.py +0 -0
  33. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/tests/unit/test_sha_specific_logic.py +0 -0
  34. {superset_showtime-0.5.11 → superset_showtime-0.5.18}/uv.lock +0 -0
@@ -50,7 +50,8 @@
50
50
  "ask": [],
51
51
  "additionalDirectories": [
52
52
  "/private/tmp",
53
- "/Users/max/code/superset"
53
+ "/Users/max/code/superset",
54
+ "/Users/max/.claudette/worktrees/showtime_gha/.github/workflows"
54
55
  ]
55
56
  }
56
57
  }
@@ -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.11
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/
@@ -4,7 +4,7 @@
4
4
  Circus tent emoji state tracking for Apache Superset ephemeral environments.
5
5
  """
6
6
 
7
- __version__ = "0.5.11"
7
+ __version__ = "0.5.18"
8
8
  __author__ = "Maxime Beauchemin"
9
9
  __email__ = "maximebeauchemin@gmail.com"
10
10
 
@@ -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 🏗️ label)"""
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.sha == building_sha:
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 != "no_action"
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. Atomic claim for environment changes (PR-level lock)
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.status = "running"
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(show.sha, dry_run_github, dry_run_aws)
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.status = "running"
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(new_show.sha, dry_run_github, dry_run_aws)
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
- get_github().remove_circus_labels(self.pr_number)
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
- get_github().remove_circus_labels(self.pr_number)
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 any(label == f"🎪 🏗️ {show.sha}" for label in pr.labels):
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
- get_github().remove_label(self.pr_number, trigger_label)
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 existing circus labels...")
476
- get_github().remove_circus_labels(self.pr_number)
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
- get_github().add_label(self.pr_number, label)
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
- get_github().remove_label(self.pr_number, old_status_label)
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
- get_github().remove_label(self.pr_number, old_pointer)
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 for label in self.labels
611
- if label.startswith("🎪") and (
612
- label.startswith(f"🎪 {show.sha} ") or # SHA-first format: 🎪 abc123f 📅 ...
613
- label.startswith(f"🎪 🎯 {show.sha}") or # Pointer format: 🎪 🎯 abc123f
614
- label.startswith(f"🎪 🏗️ {show.sha}") # Building pointer: 🎪 🏗️ abc123f
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
- get_github().add_label(self.pr_number, label)
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
- get_github().remove_label(self.pr_number, label)
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(f"\n🎪 Monitor deployment progress:")
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(self, keep_sha: str, dry_run_github: bool = False, dry_run_aws: bool = False) -> int:
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 for label in self.labels
669
- if label.startswith(f"🎪 {show.sha} ") or
670
- label == f"🎪 🎯 {show.sha}" or
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
- get_github().remove_label(self.pr_number, label)
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"🎪 {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
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"🎪 {self.sha} 🌐 {self.ip}:8080")
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(f"🎪 {self.sha} 🤡 {self.requested_by}")
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
- "--cache-from",
192
- "type=registry,ref=apache/superset-cache:showtime",
193
- "--cache-to",
194
- "type=registry,mode=max,ref=apache/superset-cache:showtime",
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
- "--cache-from",
201
- "type=registry,ref=apache/superset-cache:showtime",
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
@@ -34,7 +34,7 @@
34
34
  },
35
35
  {
36
36
  "name": "SUPERSET__SQLALCHEMY_EXAMPLES_URI",
37
- "value": "duckdb:////app/data/examples.duckdb"
37
+ "value": "duckdb:////app/data/examples.duckdb?access_mode=read_only"
38
38
  },
39
39
  {
40
40
  "name": "SUPERSET_LOG_LEVEL",
@@ -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('showtime.core.pull_request.get_github')
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(1234, [
642
- "🎪 abc123f 🚦 building", # Old status
643
- "🎪 abc123f 🚦 failed", # Another old status
644
- "🎪 🎯 abc123f", # Pointer
645
- "🎪 abc123f 📅 2024-01-15T14-30",
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, 'refresh_labels'):
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)
@@ -13,7 +13,6 @@ on:
13
13
  required: false
14
14
  default: '48'
15
15
  type: string
16
- pattern: '^[0-9]+$'
17
16
 
18
17
  # Common environment variables
19
18
  env:
@@ -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
- run: pip install superset-showtime
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 }}"