Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

149 lines
6.4 KiB
Python

"""Decide how to tidy up an experiment's feature-flag code, and build the agent's cleanup prompt.
Pure logic shared by the manual command and (later) the automatic experiment-end trigger:
from an experiment's outcome and its flag variants, pick which variant's code path to keep,
then render the instructions handed to the coding agent.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from products.experiments.backend.models.experiment import Experiment
CONCLUSION_LABELS = {
"won": "won",
"lost": "lost",
"inconclusive": "inconclusive",
"stopped_early": "stopped early",
"invalid": "invalid",
}
# Insights SDK calls that read a flag, across languages — what the agent greps for.
FLAG_SDK_CALLS = (
"isFeatureEnabled, getFeatureFlag, getFeatureFlagPayload, useFeatureFlag, useActiveFeatureFlags, "
"onFeatureFlags, insights.isFeatureEnabled, insights.getFeatureFlag, feature_enabled, get_feature_flag"
)
@dataclass(frozen=True)
class CleanupPlan:
keep_variant: str | None
remove_variants: list[str]
rationale: str
confident: bool
def variant_keys(variants: list[dict]) -> list[str]:
return [v["key"] for v in variants if v.get("key")]
def _fully_rolled_out_variant(variants: list[dict]) -> str | None:
"""After "ship a variant", the winner sits at 100% — return it if exactly one does."""
at_100 = [v["key"] for v in variants if v.get("rollout_percentage") == 100 and v.get("key")]
return at_100[0] if len(at_100) == 1 else None
def cleanup_plan(conclusion: str, variants: list[dict]) -> CleanupPlan:
"""Decide which variant's code path to keep, from the outcome and the flag's variants.
"won" keeps the shipped variant (the one rolled out to 100%); a plain win with nothing
shipped falls back to the single non-control variant as a best guess. Every other
outcome rolls back to the baseline ("control"). Anything uncertain is marked so the
operator (and the PR) flag it for human review rather than guessing silently.
"""
keys = variant_keys(variants)
non_control = [k for k in keys if k != "control"]
has_control = "control" in keys
def plan(keep: str | None, rationale: str, confident: bool) -> CleanupPlan:
return CleanupPlan(
keep_variant=keep,
remove_variants=[k for k in keys if k != keep],
rationale=rationale,
confident=confident,
)
if conclusion == "won":
shipped = _fully_rolled_out_variant(variants)
if shipped:
return plan(
shipped,
f'The experiment won and variant "{shipped}" was shipped (100% rollout). Keep it as the new default.',
True,
)
if len(non_control) == 1:
return plan(
non_control[0],
f'The experiment won but no variant is at 100% rollout. Best guess: keep the winning variant "{non_control[0]}". Confirm which variant you kept in the PR.',
False,
)
return plan(
None,
"The experiment won, but the winning variant can't be determined automatically (more than one non-control variant). Decide per code site and explain your choice in the PR.",
False,
)
keep = "control" if has_control else (keys[0] if keys else None)
if conclusion in ("lost", "invalid"):
return plan(
keep,
f'The experiment {CONCLUSION_LABELS.get(conclusion, conclusion)}. Roll back to the baseline: keep "{keep}" and remove the feature.',
True,
)
return plan(
keep,
f'The experiment was {CONCLUSION_LABELS.get(conclusion, conclusion)}. Defaulting to the baseline "{keep}" — confirm this is the rollback you want.',
False,
)
def build_cleanup_prompt(experiment: Experiment, flag_key: str, plan: CleanupPlan) -> tuple[str, str]:
"""Return (title, description) — the task title and the agent's full instructions."""
title = f"Clean up feature flag {flag_key} after experiment {experiment.id}"
remove = ", ".join(f'"{k}"' for k in plan.remove_variants) or "(none)"
keep_line = (
f'- Keep the code path for variant "{plan.keep_variant}".'
if plan.keep_variant
else "- Keep the winning variant's code path (decide per site — see the note below)."
)
variants = ", ".join(variant_keys(experiment.feature_flag.variants or [])) or "(boolean / none)"
description = "\n".join(
[
"Remove the scaffolding for a Insights experiment feature flag that is no longer needed, and open a draft pull request.",
"",
f'Experiment: "{experiment.name}" (id {experiment.id})',
f"Outcome: {CONCLUSION_LABELS.get(experiment.conclusion or '', experiment.conclusion or 'unknown')}",
f'Feature flag key: "{flag_key}"',
f"Flag variants: {variants}",
"",
"## What to change",
f'Remove all references to the feature flag "{flag_key}" from this codebase and keep the correct code path.',
keep_line,
f"- Remove the code paths for variant(s): {remove}.",
f'- Remove every check of the flag "{flag_key}" itself.',
"",
f"Why: {plan.rationale}",
"",
"## How to find the references",
f'Search the repo for the flag key "{flag_key}" and for Insights SDK calls that read flags, e.g.:',
f" {FLAG_SDK_CALLS}",
"Cover every language used in the repo (JS/TS, Python, Go, Ruby, PHP, etc.).",
"",
"## Rules",
"- For the kept variant: keep that branch's body, delete the surrounding flag check and the other branches.",
"- Boolean-style checks: keep the enabled path's body and drop the if-check (and any else branch).",
"- Remove the now-dead code you create: orphaned branches, unused imports, unused helpers.",
"- Code only. Do NOT change the flag in Insights, and do NOT touch unrelated code.",
"- If the correct path is genuinely ambiguous at a site, leave it unchanged and list it in the PR description for a human to review.",
"",
"## Output",
f'Open a draft pull request titled "{title}". In the description, summarise what you removed and anything you left for manual review.',
]
)
return title, description