Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 89 additions & 2 deletions showtime/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,78 @@ def list(
raise typer.Exit(1) from e


@app.command()
def flags(
pr_number: int = typer.Argument(..., help="PR number to manage feature flags for"),
add: Optional[str] = typer.Option(None, "--add", help="Add flag: FLAG_NAME=true/false"),
remove: Optional[str] = typer.Option(None, "--remove", help="Remove flag by name: FLAG_NAME"),
) -> None:
"""🚩 Manage Superset feature flags via GitHub labels"""
from .core.feature_flags import (
create_feature_flag_label,
parse_feature_flag_label,
)

try:
pr = PullRequest.from_id(pr_number)

if add:
if "=" not in add:
p(f"❌ Invalid flag format: {add}")
p("Expected: FLAG_NAME=true or FLAG_NAME=false")
raise typer.Exit(1)

label = create_feature_flag_label(*add.split("=", 1))
if not parse_feature_flag_label(label):
p(f"❌ Invalid flag: {add}")
p("Flag name must be uppercase with underscores, value must be true/false")
raise typer.Exit(1)

pr.add_label(label)
p(f"🚩 Added: {label}")
p("Flag will take effect on next deployment (push or trigger-start)")
return

if remove:
flag_name = remove.strip().upper()
removed = False
for val in ["true", "false"]:
label = create_feature_flag_label(flag_name, val)
if label in pr.labels:
pr.remove_label(label)
p(f"🚩 Removed: {label}")
removed = True
if not removed:
p(f"🚩 No feature flag label found for {flag_name}")
return

# Default: list current feature flags
feature_flags = pr.get_feature_flags()
if not feature_flags:
p(f"🚩 No feature flags set for PR #{pr_number}")
p("Add flags with: showtime flags {pr} --add EMBEDDED_SUPERSET=true")
return

table = Table(title=f"🚩 Feature Flags - PR #{pr_number}")
table.add_column("Flag", style="cyan")
table.add_column("Enabled", style="green")
table.add_column("Label", style="dim")

for flag_name, enabled in sorted(feature_flags.items()):
status = "βœ… true" if enabled else "❌ false"
label = create_feature_flag_label(flag_name, "true" if enabled else "false")
table.add_row(flag_name, status, label)

p(table)

except GitHubError as e:
p(f"❌ GitHub error: {e}")
raise typer.Exit(1) from e
except Exception as e:
p(f"❌ Error: {e}")
raise typer.Exit(1) from e


@app.command()
def labels() -> None:
"""πŸŽͺ Show complete circus tent label reference"""
Expand Down Expand Up @@ -475,6 +547,17 @@ def labels() -> None:
p(state_table)
p()

# Feature Flag Labels
p("[bold magenta]🚩 Feature Flag Labels (PR-level, persist across rebuilds):[/bold magenta]")
flag_table = Table()
flag_table.add_column("Label", style="green")
flag_table.add_column("Description", style="dim")
flag_table.add_row("πŸŽͺ 🚩 FLAG_NAME=true", "Enable a Superset feature flag")
flag_table.add_row("πŸŽͺ 🚩 FLAG_NAME=false", "Disable a Superset feature flag")
p(flag_table)
p("[dim]πŸ’‘ Use 'showtime flags <pr>' to manage feature flags via CLI[/dim]")
p()

# Workflow Examples
p("[bold magenta]πŸŽͺ Complete Workflow Examples:[/bold magenta]")
p()
Expand Down Expand Up @@ -720,7 +803,9 @@ def cleanup(
if max_age_cap_hours:
p(f" (with max age cap of {max_age_cap_hours}h)")
else:
p(f"πŸŽͺ [bold blue]Cleaning environments older than {default_max_age_hours}h...[/bold blue]")
p(
f"πŸŽͺ [bold blue]Cleaning environments older than {default_max_age_hours}h...[/bold blue]"
)

# Get all PRs with environments
pr_numbers = PullRequest.find_all_with_environments()
Expand Down Expand Up @@ -751,7 +836,9 @@ def cleanup(
continue
else:
# Invalid/unparsable TTL label - warn and use default
p(f"⚠️ PR #{pr_number}: Invalid TTL '{ttl_value}', using default {default_max_age_hours}h")
p(
f"⚠️ PR #{pr_number}: Invalid TTL '{ttl_value}', using default {default_max_age_hours}h"
)
effective_max_age = default_max_age_hours
else:
# No TTL label - use default
Expand Down
4 changes: 4 additions & 0 deletions showtime/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@

# Maximum age for considering environments stale/orphaned
DEFAULT_CLEANUP_AGE = "48h"

# Feature flag label constants
FEATURE_FLAG_ENV_PREFIX = "SUPERSET_FEATURE_"
FEATURE_FLAG_LABEL_PREFIX = "πŸŽͺ 🚩 "
1 change: 1 addition & 0 deletions showtime/core/emojis.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"🌐": "ip", # Globe for IP address
"βŒ›": "ttl", # Hourglass for time-to-live
"🀑": "requested_by", # Clown for who requested (circus theme!)
"🚩": "feature_flag", # Flag for Superset FEATURE_FLAGS
}

# Reverse mapping for creating labels
Expand Down
117 changes: 117 additions & 0 deletions showtime/core/feature_flags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""
πŸŽͺ Feature flag utilities for circus tent label management

Parses and creates feature flag labels: πŸŽͺ 🚩 FLAG_NAME=value
Converts to AWS ECS environment variable format.
"""

import re
from typing import Dict, List, Optional, Set, Tuple

from .constants import FEATURE_FLAG_ENV_PREFIX, FEATURE_FLAG_LABEL_PREFIX

# Regex for valid flag names: uppercase letters, digits, underscores, starting with a letter
_FLAG_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")


def parse_feature_flag_label(label: str) -> Optional[Tuple[str, str]]:
"""Parse a feature flag label into (flag_name, value).

Args:
label: Label like "πŸŽͺ 🚩 EMBEDDED_SUPERSET=true"

Returns:
Tuple of (flag_name, value) like ("EMBEDDED_SUPERSET", "true")
or None if not a valid feature flag label.
"""
if not label.startswith(FEATURE_FLAG_LABEL_PREFIX):
return None

flag_part = label[len(FEATURE_FLAG_LABEL_PREFIX) :].strip()

if "=" not in flag_part:
return None

flag_name, value = flag_part.split("=", 1)
flag_name = flag_name.strip().upper()
value = value.strip().lower()

if not _FLAG_NAME_RE.match(flag_name):
return None

if value not in ("true", "false"):
return None

return (flag_name, value)


def is_feature_flag_label(label: str) -> bool:
"""Check if a label is a feature flag label."""
return label.startswith(FEATURE_FLAG_LABEL_PREFIX)


def create_feature_flag_label(flag_name: str, value: str = "true") -> str:
"""Create a feature flag label string.

Args:
flag_name: Flag name like "EMBEDDED_SUPERSET"
value: "true" or "false"

Returns:
Label string like "πŸŽͺ 🚩 EMBEDDED_SUPERSET=true"
"""
return f"{FEATURE_FLAG_LABEL_PREFIX}{flag_name.upper()}={value.lower()}"


def extract_feature_flags_from_labels(labels: Set[str]) -> Dict[str, bool]:
"""Extract all feature flags from a set of PR labels.

Args:
labels: Set of all PR labels

Returns:
Dict mapping flag name to boolean value.
Example: {"EMBEDDED_SUPERSET": True, "DASHBOARD_NATIVE_FILTERS": False}
"""
flags: Dict[str, bool] = {}
for label in labels:
parsed = parse_feature_flag_label(label)
if parsed:
flag_name, value = parsed
flags[flag_name] = value == "true"
return flags


def feature_flags_to_aws_env(flags: Dict[str, bool]) -> List[Dict[str, str]]:
"""Convert feature flags dict to AWS ECS environment variable format.

Args:
flags: Dict like {"EMBEDDED_SUPERSET": True}

Returns:
List like [{"name": "SUPERSET_FEATURE_EMBEDDED_SUPERSET", "value": "True"}]
This format matches what aws.create_environment() expects.
"""
env_vars: List[Dict[str, str]] = []
for flag_name, enabled in sorted(flags.items()):
env_vars.append(
{
"name": f"{FEATURE_FLAG_ENV_PREFIX}{flag_name}",
"value": "True" if enabled else "False",
}
)
return env_vars


def feature_flags_to_prefixed_dict(flags: Dict[str, bool]) -> Dict[str, bool]:
"""Convert feature flags dict to SUPERSET_FEATURE_ prefixed dict.

This format matches what aws.update_feature_flags() expects.

Args:
flags: Dict like {"EMBEDDED_SUPERSET": True}

Returns:
Dict like {"SUPERSET_FEATURE_EMBEDDED_SUPERSET": True}
"""
return {f"{FEATURE_FLAG_ENV_PREFIX}{name}": enabled for name, enabled in flags.items()}
24 changes: 20 additions & 4 deletions showtime/core/github_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,17 @@ def start_comment(show: Show) -> str:


def success_comment(
show: Show, feature_count: Optional[int] = None, ttl: Optional[str] = None
show: Show,
feature_count: Optional[int] = None,
feature_names: Optional[List[str]] = None,
ttl: Optional[str] = None,
) -> str:
"""Environment success comment

Args:
show: Show object with SHA, IP, etc.
feature_count: Number of enabled feature flags (optional)
feature_names: List of enabled feature flag names (optional)
ttl: Override TTL display (PR-level TTL takes precedence)
"""
links = _create_header_links(show.sha)
Expand All @@ -131,7 +135,8 @@ def success_comment(
]

if feature_count:
bullets.insert(-1, f"**Features:** {feature_count} enabled")
flag_list = f" ({', '.join(feature_names)})" if feature_names else ""
bullets.insert(-1, f"**Features:** {feature_count} flags enabled{flag_list}")

bullets.append("**Updates:** New commits create fresh environments automatically")

Expand Down Expand Up @@ -195,13 +200,19 @@ def rolling_start_comment(current_show: Show, new_sha: str) -> str:


def rolling_success_comment(
old_show: Show, new_show: Show, ttl: Optional[str] = None
old_show: Show,
new_show: Show,
feature_count: Optional[int] = None,
feature_names: Optional[List[str]] = None,
ttl: Optional[str] = None,
) -> str:
"""Rolling update success comment

Args:
old_show: Previous Show object
new_show: New Show object with updated IP, SHA
feature_count: Number of enabled feature flags (optional)
feature_names: List of enabled feature flag names (optional)
ttl: TTL display string (required - TTL is now PR-level, not per-Show)
"""
from .constants import DEFAULT_TTL
Expand All @@ -214,9 +225,14 @@ def rolling_success_comment(
f"**Environment:** http://{new_show.ip}:8080 (admin/admin)",
f"**Lifetime:** {effective_ttl} auto-cleanup",
"**Deployment:** Zero-downtime blue-green",
"**Updates:** New commits create fresh environments automatically",
]

if feature_count:
flag_list = f" ({', '.join(feature_names)})" if feature_names else ""
bullets.append(f"**Features:** {feature_count} flags enabled{flag_list}")

bullets.append("**Updates:** New commits create fresh environments automatically")

return _format_comment(header, bullets)


Expand Down
Loading