mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
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.
327 lines
13 KiB
Python
327 lines
13 KiB
Python
"""Product bootstrapping — scaffold new products and register them in config files."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import json
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
import click
|
|
|
|
from .paths import DB_ROUTING_YAML, DJANGO_SETTINGS, FRONTEND_PACKAGE_JSON, PRODUCTS_DIR, TACH_TOML, load_structure
|
|
|
|
|
|
def flatten_structure(files: dict, prefix: str = "", result: dict | None = None) -> dict[str, dict]:
|
|
"""
|
|
Flatten nested structure dict into flat paths.
|
|
e.g., {"facade/": {"api.py": {...}}} -> {"facade/api.py": {...}}
|
|
"""
|
|
if result is None:
|
|
result = {}
|
|
for name, config in files.items():
|
|
if name.endswith("/"):
|
|
flatten_structure(config, prefix + name, result)
|
|
else:
|
|
result[prefix + name] = config if isinstance(config, dict) else {}
|
|
return result
|
|
|
|
|
|
def _team_scope_template_vars(*, separate_db: bool) -> dict[str, str]:
|
|
"""Pick the right team-scoped base class for the chosen DB layout.
|
|
|
|
Separate-DB products can't FK to Team across databases, so they use
|
|
`ProductTeamModel` (team_id as BigIntegerField). Main-DB products use
|
|
`TeamScopedRootMixin` and declare a regular FK on the subclass. Both
|
|
bundle `TeamScopedManager` so models start fail-closed.
|
|
"""
|
|
if separate_db:
|
|
return {
|
|
"team_base_mixin": "ProductTeamModel",
|
|
"team_base_import": "from insights.models.scoping.product_mixin import ProductTeamModel",
|
|
}
|
|
return {
|
|
"team_base_mixin": "TeamScopedRootMixin",
|
|
"team_base_import": "from insights.models.scoping.root_mixin import TeamScopedRootMixin",
|
|
}
|
|
|
|
|
|
def _render_template(template: str, product_name: str, *, separate_db: bool) -> str:
|
|
pascal_name = "".join(word.capitalize() for word in product_name.split("_"))
|
|
return template.format(
|
|
product=product_name,
|
|
product_kebab=product_name.replace("_", "-"),
|
|
Product=pascal_name,
|
|
**_team_scope_template_vars(separate_db=separate_db),
|
|
)
|
|
|
|
|
|
def _register_in_file(
|
|
file_path: Path, label: str, needle: str, write_fn: Callable[[str], str | None], *, dry_run: bool
|
|
) -> None:
|
|
"""Register a product entry in a config file. Skips if needle already present."""
|
|
if not file_path.exists():
|
|
return
|
|
content = file_path.read_text()
|
|
if needle in content:
|
|
click.echo(f"\n Already in {label}: {needle}")
|
|
return
|
|
if dry_run:
|
|
click.echo(f"\n Would add to {label}: {needle}")
|
|
return
|
|
result = write_fn(content)
|
|
if result is not None:
|
|
file_path.write_text(result)
|
|
click.echo(f"\n Added to {label}: {needle}")
|
|
|
|
|
|
def _add_to_tach_toml(product_name: str, *, dry_run: bool) -> None:
|
|
module_path = f"products.{product_name}"
|
|
block = (
|
|
f"\n[[modules]]\n"
|
|
f'path = "{module_path}"\n'
|
|
f'depends_on = ["insights"]\n'
|
|
f'layer = "modules"\n'
|
|
f"\n[[interfaces]]\n"
|
|
f"expose = [\n"
|
|
f' "backend\\\\.facade.*",\n'
|
|
f' "backend\\\\.presentation\\\\.views.*",\n'
|
|
f"]\n"
|
|
f"from = [\n"
|
|
f' "{module_path}",\n'
|
|
f"]\n"
|
|
)
|
|
_register_in_file(
|
|
TACH_TOML,
|
|
"tach.toml",
|
|
f'path = "{module_path}"',
|
|
lambda content: content.rstrip() + "\n" + block,
|
|
dry_run=dry_run,
|
|
)
|
|
|
|
|
|
def _add_to_frontend_package_json(product_name: str, *, dry_run: bool) -> None:
|
|
pkg_name = f"@hanzo/products-{product_name.replace('_', '-')}"
|
|
|
|
def write(content: str) -> str:
|
|
data = json.loads(content)
|
|
deps = data.get("dependencies", {})
|
|
deps[pkg_name] = "workspace:*"
|
|
data["dependencies"] = dict(sorted(deps.items()))
|
|
return json.dumps(data, indent=4) + "\n"
|
|
|
|
_register_in_file(FRONTEND_PACKAGE_JSON, "frontend/package.json", pkg_name, write, dry_run=dry_run)
|
|
|
|
|
|
def _add_to_django_settings(product_name: str, *, dry_run: bool) -> None:
|
|
pascal_name = "".join(word.capitalize() for word in product_name.split("_"))
|
|
app_config = f"products.{product_name}.backend.apps.{pascal_name}Config"
|
|
|
|
def write(content: str) -> str | None:
|
|
pattern = r'( "products\.[^"]+",\n)(?! "products\.)'
|
|
match = list(re.finditer(pattern, content))
|
|
if not match:
|
|
click.echo(f"\n Could not find INSTALLED_APPS products section — add manually: {app_config}")
|
|
return None
|
|
insert_pos = match[-1].end()
|
|
return content[:insert_pos] + f' "{app_config}",\n' + content[insert_pos:]
|
|
|
|
_register_in_file(DJANGO_SETTINGS, "Django settings", app_config, write, dry_run=dry_run)
|
|
|
|
|
|
def _get_existing_databases() -> list[str]:
|
|
"""Read unique database names from db_routing.yaml."""
|
|
if not DB_ROUTING_YAML.exists():
|
|
return []
|
|
import yaml
|
|
|
|
config = yaml.safe_load(DB_ROUTING_YAML.read_text()) or {}
|
|
return sorted({r["database"] for r in config.get("routes", []) if r.get("database")})
|
|
|
|
|
|
def _add_to_db_routing(product_name: str, database_name: str, *, dry_run: bool) -> None:
|
|
route_entry = f" - app_label: {product_name}\n database: {database_name}\n"
|
|
|
|
def write(content: str) -> str:
|
|
return content.rstrip() + "\n" + route_entry
|
|
|
|
_register_in_file(
|
|
DB_ROUTING_YAML,
|
|
"products/db_routing.yaml",
|
|
f"app_label: {product_name}",
|
|
write,
|
|
dry_run=dry_run,
|
|
)
|
|
|
|
|
|
_VALID_PRODUCT_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
|
|
|
|
def _default_display_name(product_name: str) -> str:
|
|
return product_name.replace("_", " ").capitalize()
|
|
|
|
|
|
def _write_product_yaml(product_dir: Path, display_name: str, owners: list[str], *, dry_run: bool) -> None:
|
|
yaml_path = product_dir / "product.yaml"
|
|
owners_yaml = "\n".join(f" - {o}" for o in owners)
|
|
content = f"name: {display_name}\nowners:\n{owners_yaml}\n"
|
|
if dry_run:
|
|
click.echo(f"\n Would create product.yaml:\n{content}")
|
|
return
|
|
yaml_path.write_text(content)
|
|
click.echo(f"\n Created product.yaml (name={display_name!r}, owners={owners})")
|
|
|
|
|
|
def _prompt_for_separate_db(name: str) -> tuple[bool, str | None]:
|
|
"""Ask the operator whether the new product gets its own database.
|
|
|
|
Returns (separate_db, db_name). `db_name` is None when the operator
|
|
opts to share the main DB. Asked up front (before file generation)
|
|
so the model template can branch on the answer — separate-DB needs
|
|
`ProductTeamModel`, main-DB needs `TeamScopedRootMixin`.
|
|
"""
|
|
click.echo(
|
|
"\n Products get their own database by default — this isolates locks, "
|
|
"connections, and migrations so one product can't bring down the app. "
|
|
"This is new but fully working. Reach out in #team-devex with questions."
|
|
)
|
|
click.secho(
|
|
" ⚠ Adding a route here is only the Django side. The database must also "
|
|
"be provisioned by #team-infrastructure (Terraform + charts).",
|
|
fg="yellow",
|
|
)
|
|
existing_dbs = _get_existing_databases()
|
|
if existing_dbs:
|
|
click.echo(f" Existing databases: {', '.join(existing_dbs)}")
|
|
click.echo(" You can share a database with another product from the same team.")
|
|
|
|
if click.confirm(" Skip separate database?", default=False):
|
|
click.echo(" Using the main database. Models will inherit TeamScopedRootMixin.")
|
|
return False, None
|
|
|
|
db_name = click.prompt(" Database name", default=name, show_default=True)
|
|
click.echo(f" Using separate database '{db_name}'. Models will inherit ProductTeamModel.")
|
|
return True, db_name
|
|
|
|
|
|
def bootstrap_product(
|
|
name: str,
|
|
dry_run: bool,
|
|
force: bool,
|
|
*,
|
|
non_interactive: bool = False,
|
|
separate_db_override: bool | None = None,
|
|
db_name_override: str | None = None,
|
|
owner_override: str | None = None,
|
|
display_name_override: str | None = None,
|
|
) -> None:
|
|
if not _VALID_PRODUCT_NAME_RE.match(name):
|
|
raise click.ClickException(
|
|
f"Invalid product name '{name}' — must be lowercase, start with a letter, and contain only [a-z0-9_]."
|
|
)
|
|
product_dir = PRODUCTS_DIR / name
|
|
|
|
if product_dir.exists() and not force:
|
|
raise click.ClickException(f"Product '{name}' already exists at {product_dir}. Use --force to overwrite.")
|
|
|
|
# Resolve the DB layout up front so file rendering can branch on it.
|
|
# Precedence: explicit --separate-db/--no-separate-db override > non-interactive
|
|
# default > interactive prompt. The non-interactive default stays separate-DB
|
|
# to match the previous CI behavior.
|
|
if separate_db_override is not None:
|
|
separate_db = separate_db_override
|
|
db_name = (db_name_override or name) if separate_db else None
|
|
elif dry_run or non_interactive:
|
|
separate_db, db_name = True, db_name_override or name
|
|
else:
|
|
separate_db, db_name = _prompt_for_separate_db(name)
|
|
|
|
structure = load_structure()
|
|
created: list[str] = []
|
|
skipped: list[str] = []
|
|
|
|
for path, config in flatten_structure(structure.get("root_files", {})).items():
|
|
file_path = product_dir / path
|
|
if dry_run:
|
|
(skipped if (file_path.exists() and not force) else created).append(path)
|
|
continue
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if file_path.exists() and not force:
|
|
skipped.append(path)
|
|
continue
|
|
file_path.write_text(_render_template(config.get("template", ""), name, separate_db=separate_db))
|
|
created.append(path)
|
|
|
|
for path, config in flatten_structure(structure.get("backend_files", {})).items():
|
|
file_path = product_dir / "backend" / path
|
|
label = f"backend/{path}"
|
|
if dry_run:
|
|
(skipped if (file_path.exists() and not force) else created).append(label)
|
|
continue
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if file_path.exists() and not force:
|
|
skipped.append(label)
|
|
continue
|
|
file_path.write_text(_render_template(config.get("template", ""), name, separate_db=separate_db))
|
|
created.append(label)
|
|
|
|
for folder_name in structure.get("frontend_files", {}).keys():
|
|
folder_path = product_dir / "frontend" / folder_name.rstrip("/")
|
|
label = f"frontend/{folder_name}"
|
|
if dry_run:
|
|
created.append(label)
|
|
else:
|
|
folder_path.mkdir(parents=True, exist_ok=True)
|
|
created.append(label)
|
|
|
|
click.echo(f"{'Would create' if dry_run else 'Created'} product '{name}' at {product_dir}")
|
|
if created:
|
|
click.echo(f"\n Created {len(created)} files/folders:")
|
|
for path in created:
|
|
click.echo(f" {path}")
|
|
if skipped:
|
|
click.echo(f"\n Skipped {len(skipped)} existing files:")
|
|
for path in skipped:
|
|
click.echo(f" {path}")
|
|
|
|
# product.yaml — canonical metadata.
|
|
# Precedence: explicit --display-name / --owner override > non-interactive
|
|
# default > interactive prompt.
|
|
display_name = display_name_override or _default_display_name(name)
|
|
if dry_run:
|
|
owners = [owner_override] if owner_override else ["team-CHANGEME"]
|
|
_write_product_yaml(product_dir, display_name, owners, dry_run=True)
|
|
elif non_interactive or owner_override is not None or display_name_override is not None:
|
|
owners = [owner_override] if owner_override else ["team-devex"]
|
|
_write_product_yaml(product_dir, display_name, owners, dry_run=False)
|
|
if not owner_override:
|
|
click.secho(" ⚠ No --owner provided. Set the real team slug in product.yaml before merging.", fg="yellow")
|
|
else:
|
|
prompted_display_name = click.prompt(" Display name", default=display_name, show_default=True)
|
|
owner = click.prompt(
|
|
" Owning GitHub team slug (e.g. team-product-analytics)",
|
|
default="",
|
|
show_default=False,
|
|
)
|
|
owners = [owner] if owner else ["team-CHANGEME"]
|
|
_write_product_yaml(product_dir, prompted_display_name, owners, dry_run=False)
|
|
if not owner:
|
|
click.secho(" ⚠ Set the real team slug in product.yaml before merging.", fg="yellow")
|
|
|
|
_add_to_tach_toml(name, dry_run=dry_run)
|
|
_add_to_frontend_package_json(name, dry_run=dry_run)
|
|
_add_to_django_settings(name, dry_run=dry_run)
|
|
|
|
if separate_db and db_name is not None:
|
|
_add_to_db_routing(name, db_name, dry_run=dry_run)
|
|
elif not dry_run:
|
|
click.echo(" Skipped db_routing entry. Models live in the main database.")
|
|
|
|
if not dry_run:
|
|
mixin = "ProductTeamModel" if separate_db else "TeamScopedRootMixin"
|
|
click.secho(
|
|
f"\n Models inherit `{mixin}` (fail-closed team scoping). "
|
|
"Read insights/models/scoping/README.md for the contract before writing your first model.",
|
|
fg="cyan",
|
|
)
|