mirror of
https://github.com/hanzoai/insights.git
synced 2026-11-03 00:52:32 +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.
297 lines
12 KiB
Python
297 lines
12 KiB
Python
"""Dataclass model, parser, and validator for ``owners.yaml``.
|
|
|
|
Also loads ``products/<name>/product.yaml`` as an *aliased* ownership file: only
|
|
its ``owners:`` list is read (``@handles`` kept, a ``team-CHANGEME``-only list
|
|
treated as empty), every other field ignored.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import TypeGuard
|
|
|
|
import yaml
|
|
|
|
from .matcher import compile_pattern
|
|
|
|
VALID_STATUSES = ("active", "deprecated", "generated", "vendored")
|
|
CHANGEME_SLUG = "team-CHANGEME"
|
|
|
|
# Top-level keys allowed in owners.yaml. Rules allow the same set minus `version`
|
|
# and `rules`, plus the required `match`. `teams` is root-only (see parse).
|
|
_TOP_LEVEL_KEYS = {"version", "owners", "status", "inherit", "rules", "teams"}
|
|
_RULE_KEYS = {"match", "owners", "status", "inherit"}
|
|
_TEAMS_ENTRY_KEYS = {"slack"}
|
|
|
|
|
|
class _Unset:
|
|
"""Sentinel marking a field the file did not set (so it falls through to an
|
|
ancestor). Distinct from ``owners: null`` (explicit unowned-by-design)."""
|
|
|
|
_instance: _Unset | None = None
|
|
|
|
def __new__(cls) -> _Unset:
|
|
if cls._instance is None:
|
|
cls._instance = super().__new__(cls)
|
|
return cls._instance
|
|
|
|
def __repr__(self) -> str:
|
|
return "UNSET"
|
|
|
|
|
|
UNSET = _Unset()
|
|
|
|
|
|
@dataclass
|
|
class OwnersRule:
|
|
"""A per-path override inside a file, evaluated last-match-wins within the file."""
|
|
|
|
match: str
|
|
owners: list[str] | None | _Unset = UNSET
|
|
status: str | _Unset = UNSET
|
|
inherit: bool | _Unset = UNSET
|
|
|
|
|
|
@dataclass
|
|
class OwnersFile:
|
|
"""A parsed ownership file (real ``owners.yaml`` or an aliased ``product.yaml``)."""
|
|
|
|
path: Path
|
|
directory: str # repo-relative posix dir containing the file ("" for repo root)
|
|
owners: list[str] | None # required; None = explicit unowned-by-design; [] = no contribution
|
|
version: int = 1
|
|
status: str | _Unset = UNSET
|
|
inherit: bool = True
|
|
rules: list[OwnersRule] = field(default_factory=list)
|
|
is_alias: bool = False
|
|
# Root-only Slack registry: team slug -> slack (string or False). Empty everywhere
|
|
# but the repo-root file; lets a team declare its channel once instead of per file.
|
|
teams: dict[str, str | bool] = field(default_factory=dict)
|
|
|
|
|
|
def normalize_product_owners(owners: list[str]) -> list[str]:
|
|
"""Drop the ``team-CHANGEME`` scaffold placeholder: it never carries ownership
|
|
signal, so a list consisting only of it is empty. Applied to both ``product.yaml``
|
|
aliases and ``owners.yaml`` owners lists — one CHANGEME semantics everywhere."""
|
|
return [o for o in owners if o != CHANGEME_SLUG]
|
|
|
|
|
|
def _validate_owners_value(value: object, where: str, errors: list[str]) -> list[str] | None | _Unset:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, str) and value:
|
|
value = [value]
|
|
# Empty-string entries are rejected, not filtered: `owners: ['']` would count
|
|
# as covered while the assigner drops the falsy owner and requests nobody.
|
|
if not isinstance(value, list) or not all(isinstance(x, str) and x for x in value):
|
|
errors.append(f"{where}: 'owners' must be a non-empty string, a list of non-empty strings, or null")
|
|
return UNSET
|
|
return normalize_product_owners([str(x) for x in value])
|
|
|
|
|
|
def _is_valid_slack(raw: object) -> TypeGuard[str | bool]:
|
|
"""A Slack channel value is a string starting with '#', or ``false`` for "no
|
|
channel". Shared by the ``teams:`` registry — the only place a channel is set."""
|
|
return raw is False or (isinstance(raw, str) and raw.startswith("#"))
|
|
|
|
|
|
def _validate_teams(value: object, errors: list[str]) -> dict[str, str | bool]:
|
|
"""Validate the root-only ``teams:`` registry — a mapping of team slug to a
|
|
single ``slack`` value."""
|
|
registry: dict[str, str | bool] = {}
|
|
if not isinstance(value, dict):
|
|
errors.append("'teams' must be a mapping of team slug to {slack: ...}")
|
|
return registry
|
|
for slug, entry in value.items():
|
|
where = f"teams['{slug}']"
|
|
if not isinstance(slug, str):
|
|
errors.append(f"teams: slug must be a string, got {slug!r}")
|
|
continue
|
|
if slug.startswith("@"):
|
|
errors.append(f"{where}: registry keys are team slugs, not @handles")
|
|
continue
|
|
if not isinstance(entry, dict):
|
|
errors.append(f"{where}: entry must be a mapping with a 'slack' key")
|
|
continue
|
|
for key in entry:
|
|
if key not in _TEAMS_ENTRY_KEYS:
|
|
errors.append(f"{where}: unknown field '{key}'")
|
|
if "slack" in entry:
|
|
raw = entry["slack"]
|
|
if _is_valid_slack(raw):
|
|
registry[slug] = raw
|
|
else:
|
|
errors.append(f"{where}: 'slack' must be a string starting with '#' or false")
|
|
return registry
|
|
|
|
|
|
def _validate_status(value: object, where: str, errors: list[str]) -> str | _Unset:
|
|
if isinstance(value, str) and value in VALID_STATUSES:
|
|
return value
|
|
errors.append(f"{where}: 'status' must be one of {', '.join(VALID_STATUSES)}")
|
|
return UNSET
|
|
|
|
|
|
def _validate_inherit(value: object, where: str, errors: list[str]) -> bool | _Unset:
|
|
if isinstance(value, bool):
|
|
return value
|
|
errors.append(f"{where}: 'inherit' must be a boolean")
|
|
return UNSET
|
|
|
|
|
|
def _rule_match_patterns(raw_match: object, where: str, errors: list[str]) -> list[str]:
|
|
"""A rule's ``match`` may be one non-empty string or a non-empty list of them.
|
|
Returns every compiling pattern in order; on any malformed or uncompilable entry
|
|
it appends a schema error and returns ``[]`` so the whole rule is dropped (lint
|
|
reports a normal error and the resolver never sees a rule that would crash)."""
|
|
if isinstance(raw_match, str):
|
|
candidates: list[object] = [raw_match]
|
|
elif isinstance(raw_match, list) and raw_match:
|
|
candidates = list(raw_match)
|
|
else:
|
|
errors.append(f"{where}: 'match' is required and must be a non-empty string or a non-empty list of strings")
|
|
return []
|
|
|
|
patterns: list[str] = []
|
|
ok = True
|
|
for candidate in candidates:
|
|
if not isinstance(candidate, str) or not candidate:
|
|
errors.append(f"{where}: each 'match' pattern must be a non-empty string")
|
|
ok = False
|
|
continue
|
|
try:
|
|
compile_pattern(candidate)
|
|
except ValueError as exc:
|
|
errors.append(f"{where}: invalid match pattern '{candidate}': {exc}")
|
|
ok = False
|
|
continue
|
|
patterns.append(candidate)
|
|
return patterns if ok else []
|
|
|
|
|
|
def _parse_rule(raw: object, index: int, errors: list[str]) -> list[OwnersRule]:
|
|
"""Parse one physical rule entry into one ``OwnersRule`` per ``match`` pattern
|
|
(a list ``match`` explodes here so resolver/fmt/lint keep seeing single-pattern
|
|
rules). Returns ``[]`` on a schema error."""
|
|
where = f"rules[{index}]"
|
|
if not isinstance(raw, dict):
|
|
errors.append(f"{where}: each rule must be a mapping")
|
|
return []
|
|
for key in raw:
|
|
if key not in _RULE_KEYS:
|
|
errors.append(f"{where}: unknown field '{key}'")
|
|
patterns = _rule_match_patterns(raw.get("match"), where, errors)
|
|
if not patterns:
|
|
return []
|
|
|
|
owners = _validate_owners_value(raw["owners"], where, errors) if "owners" in raw else UNSET
|
|
status = _validate_status(raw["status"], where, errors) if "status" in raw else UNSET
|
|
inherit = _validate_inherit(raw["inherit"], where, errors) if "inherit" in raw else UNSET
|
|
return [OwnersRule(match=pattern, owners=owners, status=status, inherit=inherit) for pattern in patterns]
|
|
|
|
|
|
def parse_owners_file(text: str, *, path: Path, directory: str) -> tuple[OwnersFile | None, list[str]]:
|
|
"""Parse and validate ``owners.yaml`` contents.
|
|
|
|
Returns ``(file, errors)``. ``file`` is None only when the document itself is
|
|
unusable (bad YAML, not a mapping, missing required fields).
|
|
"""
|
|
errors: list[str] = []
|
|
try:
|
|
data = yaml.safe_load(text)
|
|
except yaml.YAMLError as exc:
|
|
return None, [f"invalid YAML: {exc}"]
|
|
if not isinstance(data, dict):
|
|
return None, ["owners.yaml must be a YAML mapping"]
|
|
|
|
for key in data:
|
|
if key not in _TOP_LEVEL_KEYS:
|
|
errors.append(f"unknown top-level field '{key}'")
|
|
|
|
if data.get("version") != 1:
|
|
errors.append("'version: 1' is required")
|
|
|
|
if "owners" not in data:
|
|
errors.append("'owners' is required (a string, a list of strings, or null for unowned-by-design)")
|
|
owners: list[str] | None = []
|
|
else:
|
|
validated = _validate_owners_value(data["owners"], "owners", errors)
|
|
owners = [] if isinstance(validated, _Unset) else validated
|
|
|
|
file = OwnersFile(path=path, directory=directory, owners=owners)
|
|
|
|
if "status" in data:
|
|
file.status = _validate_status(data["status"], "status", errors)
|
|
if "inherit" in data:
|
|
inherit = _validate_inherit(data["inherit"], "inherit", errors)
|
|
file.inherit = True if isinstance(inherit, _Unset) else inherit
|
|
|
|
if "teams" in data:
|
|
# The registry is a single repo-wide lookup, so it only makes sense at the
|
|
# root; a nested file carrying it would silently do nothing.
|
|
if directory != "":
|
|
errors.append("'teams' is only allowed in the repo-root owners.yaml")
|
|
else:
|
|
file.teams = _validate_teams(data["teams"], errors)
|
|
|
|
if "rules" in data:
|
|
raw_rules = data["rules"]
|
|
if not isinstance(raw_rules, list):
|
|
errors.append("'rules' must be a list")
|
|
else:
|
|
for i, raw_rule in enumerate(raw_rules):
|
|
file.rules.extend(_parse_rule(raw_rule, i, errors))
|
|
|
|
# A missing version or owners makes the file unusable for resolution.
|
|
if data.get("version") != 1 or "owners" not in data:
|
|
return None, errors
|
|
return file, errors
|
|
|
|
|
|
def parse_product_yaml_as_owners(text: str, *, path: Path, directory: str) -> OwnersFile | None:
|
|
"""Load ``product.yaml`` as an aliased ownership file, or None if it has no
|
|
usable ``owners:`` list."""
|
|
try:
|
|
data = yaml.safe_load(text)
|
|
except yaml.YAMLError:
|
|
return None
|
|
if not isinstance(data, dict) or "owners" not in data:
|
|
return None
|
|
raw = data["owners"]
|
|
if not isinstance(raw, list) or not all(isinstance(x, str) and x for x in raw):
|
|
return None
|
|
owners = normalize_product_owners(raw)
|
|
return OwnersFile(path=path, directory=directory, owners=owners, is_alias=True)
|
|
|
|
|
|
def match_is_glob(match: str) -> bool:
|
|
"""A rule match is a crosscutting glob (not a tree boundary) when it carries a
|
|
wildcard character."""
|
|
return any(ch in match for ch in "*?[")
|
|
|
|
|
|
def is_simple_owners_file(parsed: OwnersFile | None, *, allow_anchored_rules: bool = False) -> bool:
|
|
"""Whether a file is "simple" — mechanically relocatable, nothing but ownership.
|
|
|
|
Both callers agree that status/``inherit: false`` (and being a
|
|
``product.yaml`` alias) disqualify a file. So does a ``teams:`` registry:
|
|
it is root-only content relocation would strand. So does any rule carrying
|
|
more than match+owners: relocation only preserves owners, so rule-level
|
|
``status``/``inherit`` must pin the file. They differ on rules:
|
|
|
|
- lint's consolidation suggestions (``allow_anchored_rules=False``) only fold
|
|
files whose entire content is one non-empty ``owners:`` list;
|
|
- fmt (``allow_anchored_rules=True``) reasons about statements, so files whose
|
|
rules are all anchored (no globs) are fair game too.
|
|
"""
|
|
if parsed is None or parsed.is_alias:
|
|
return False
|
|
if parsed.inherit is False or parsed.status is not UNSET or parsed.teams:
|
|
return False
|
|
if any(r.status is not UNSET or r.inherit is not UNSET for r in parsed.rules):
|
|
return False
|
|
if allow_anchored_rules:
|
|
return not any(match_is_glob(r.match) for r in parsed.rules)
|
|
return bool(parsed.owners) and not parsed.rules
|