feat: support one Gitea organization per series

This commit is contained in:
2026-07-19 16:40:05 +02:00
parent 4dda6de387
commit b0548f0e56
6 changed files with 388 additions and 58 deletions
+186 -26
View File
@@ -326,10 +326,130 @@ def workspace_series_manifest(config: WorkspaceConfig, series_name: str) -> dict
return workspace_series_manifests(config).get(series_name)
def workspace_organizations(config: WorkspaceConfig) -> dict[str, dict]:
if not config.workspace_info:
return {}
organizations: dict[str, dict] = {}
for entry in config.workspace_info.get("organizations", []):
if not isinstance(entry, dict):
continue
organization_id = str(entry.get("id", "")).strip()
if organization_id:
organizations[organization_id] = entry
return organizations
def audit_workspace_catalog(config: WorkspaceConfig) -> list[tuple[str, str, str]]:
"""Validate the catalog-to-Gitea ownership contract without network access."""
checks: list[tuple[str, str, str]] = []
if not config.workspace_info:
return [("catalog", "FAIL", f"missing {config.workspace_info_path}")]
raw_organizations = config.workspace_info.get("organizations", [])
organizations = workspace_organizations(config)
if not isinstance(raw_organizations, list) or not raw_organizations:
checks.append(("organizations", "FAIL", "workspace.json must declare the organization registry"))
else:
raw_ids = [str(entry.get("id", "")).strip() for entry in raw_organizations if isinstance(entry, dict)]
duplicates = sorted({value for value in raw_ids if value and raw_ids.count(value) > 1})
if duplicates:
checks.append(("organizations", "FAIL", f"duplicate ids: {', '.join(duplicates)}"))
else:
checks.append(("organizations", "ok", f"{len(organizations)} registered"))
control_plane = organizations.get("edu")
if not control_plane or control_plane.get("role") != "control-plane":
checks.append(("control-plane", "FAIL", "organization 'edu' must have role 'control-plane'"))
else:
checks.append(("control-plane", "ok", "edu"))
manifests = workspace_series_manifests(config)
if not manifests:
checks.append(("series", "FAIL", "no operational series declared"))
return checks
physical_workspace_dirs: dict[str, list[str]] = {}
for series_id, manifest in manifests.items():
source_org = manifest.get("source_org")
if not isinstance(source_org, str) or not source_org.strip():
checks.append((f"series:{series_id}", "FAIL", "missing explicit source_org"))
continue
source_org = source_org.strip()
organization = organizations.get(source_org)
if organization is None:
checks.append((f"series:{series_id}", "FAIL", f"source_org '{source_org}' is not registered"))
else:
assigned_series = organization.get("series", [])
if not isinstance(assigned_series, list) or series_id not in assigned_series:
checks.append(
(f"series:{series_id}", "FAIL", f"organization '{source_org}' does not assign this series")
)
else:
checks.append((f"series:{series_id}", "ok", f"source_org={source_org}"))
expected_org = f"edu-{series_id.replace('_', '-')}"
if source_org != expected_org and not bool(manifest.get("legacy")):
checks.append(
(f"naming:{series_id}", "WARN", f"expected '{expected_org}', declare legacy=true if intentional")
)
raw_cards = manifest.get("cards", [])
cards = [card for card in raw_cards if isinstance(card, dict)] if isinstance(raw_cards, list) else []
card_ids = [str(card.get("id", "")).strip() for card in cards]
repos = [str(card.get("repo", "")).strip() for card in cards]
duplicate_ids = sorted({value for value in card_ids if value and card_ids.count(value) > 1})
duplicate_repos = sorted({value for value in repos if value and repos.count(value) > 1})
missing = sum(not card_id or not repo for card_id, repo in zip(card_ids, repos))
if duplicate_ids or duplicate_repos or missing:
details = []
if duplicate_ids:
details.append(f"duplicate card ids: {', '.join(duplicate_ids)}")
if duplicate_repos:
details.append(f"duplicate repos: {', '.join(duplicate_repos)}")
if missing:
details.append(f"{missing} cards missing id/repo")
checks.append((f"cards:{series_id}", "FAIL", "; ".join(details)))
else:
checks.append((f"cards:{series_id}", "ok", f"{len(cards)} cards"))
try:
physical_dir = series_path_component(config, series_id, "workspace_dir")
except SystemExit as error:
checks.append((f"path:{series_id}", "FAIL", str(error)))
else:
physical_workspace_dirs.setdefault(physical_dir, []).append(series_id)
for physical_dir, series_ids in physical_workspace_dirs.items():
if len(series_ids) > 1:
checks.append(
("workspace-dir", "WARN", f"'{physical_dir}' is shared by: {', '.join(series_ids)}")
)
return checks
def series_path_component(config: WorkspaceConfig, series_name: str, field: str) -> str:
"""Return a safe physical directory name for one logical series."""
manifest = workspace_series_manifest(config, series_name)
raw_value = manifest.get(field, series_name) if manifest else series_name
value = str(raw_value).strip()
if not value or value in {".", ".."} or Path(value).name != value:
raise SystemExit(f"Invalid {field} for series '{series_name}': {raw_value!r}")
return value
def workspace_series_path(config: WorkspaceConfig, series_name: str) -> Path:
return config.series_root / series_path_component(config, series_name, "workspace_dir")
def source_series_path(config: WorkspaceConfig, series_name: str) -> Path:
return config.original_series_root / series_path_component(config, series_name, "source_dir")
def series_exists(config: WorkspaceConfig, series_name: str) -> bool:
if workspace_series_manifest(config, series_name) is not None:
return True
return (config.series_root / series_name).is_dir() or (config.original_series_root / series_name).is_dir()
return workspace_series_path(config, series_name).is_dir() or source_series_path(config, series_name).is_dir()
def default_card_for_series(config: WorkspaceConfig, series_name: str) -> str | None:
@@ -358,7 +478,13 @@ def card_infos_from_manifest(config: WorkspaceConfig, series_name: str) -> list[
if not manifest:
return []
source_org = str(manifest.get("source_org", config.git.source_org))
raw_source_org = manifest.get("source_org")
if not isinstance(raw_source_org, str) or not raw_source_org.strip():
raise SystemExit(
f"Series '{series_name}' must declare source_org explicitly in workspace-info; "
"the global git.source_org is only a legacy fallback for directory-only workspaces."
)
source_org = raw_source_org.strip()
answer_org = str(manifest.get("answer_org", config.git.answer_org))
source_remote = str(manifest.get("source_remote", config.git.source_remote))
answer_remote = str(manifest.get("answer_remote", config.git.answer_remote))
@@ -373,7 +499,7 @@ def card_infos_from_manifest(config: WorkspaceConfig, series_name: str) -> list[
repo_name = str(raw_card.get("repo", card_id)).strip()
branch_name = str(raw_card.get("branch", fallback_branch)).strip() or fallback_branch
title = str(raw_card.get("title", "")).strip()
source_path = config.original_series_root / series_name / repo_name
source_path = source_series_path(config, series_name) / repo_name
cards.append(
CardInfo(
series_id=series_name,
@@ -420,24 +546,24 @@ def source_card_infos(config: WorkspaceConfig, series_name: str) -> list[CardInf
def series_directories(config: WorkspaceConfig) -> list[Path]:
config.series_root.mkdir(parents=True, exist_ok=True)
return sorted(path for path in config.series_root.iterdir() if path.is_dir())
return sorted(path for path in config.series_root.iterdir() if path.is_dir() and not path.name.startswith("."))
def card_directories(series_path: Path) -> list[Path]:
return sorted(path for path in series_path.iterdir() if path.is_dir())
return sorted(path for path in series_path.iterdir() if path.is_dir() and not path.name.startswith("."))
def source_series_directories(config: WorkspaceConfig) -> list[Path]:
if not config.original_series_root.is_dir():
return []
return sorted(path for path in config.original_series_root.iterdir() if path.is_dir())
return sorted(path for path in config.original_series_root.iterdir() if path.is_dir() and not path.name.startswith("."))
def source_card_directories(config: WorkspaceConfig, series_name: str) -> list[Path]:
source_series_path = config.original_series_root / series_name
if not source_series_path.is_dir():
series_path = source_series_path(config, series_name)
if not series_path.is_dir():
return []
return sorted(path for path in source_series_path.iterdir() if path.is_dir())
return sorted(path for path in series_path.iterdir() if path.is_dir() and not path.name.startswith("."))
def card_label_from_repo_name(repo_name: str) -> str:
@@ -450,14 +576,18 @@ def card_label_from_repo_name(repo_name: str) -> str:
def card_info_matches(card_info: CardInfo, normalized: str) -> bool:
candidates = {card_info.card_id, card_info.repo, card_label_from_repo_name(card_info.repo)}
candidates = {
str(card_info.card_id).lower(),
str(card_info.repo).lower(),
card_label_from_repo_name(card_info.repo).lower(),
}
return normalized in candidates or any(normalized in candidate for candidate in candidates)
def resolve_card_info(config: WorkspaceConfig, series_name: str, card_name: str) -> CardInfo:
cards = source_card_infos(config, series_name)
if not cards:
source_hint = config.original_series_root / series_name
source_hint = source_series_path(config, series_name)
if config.workspace_info_path.is_file():
source_hint = config.workspace_info_path
raise SystemExit(f"Missing source series: {source_hint}")
@@ -477,13 +607,13 @@ def resolve_source_card_path(config: WorkspaceConfig, series_name: str, card_nam
manifest_card = resolve_card_info(config, series_name, card_name)
if manifest_card.source_path:
return manifest_card.source_path
return config.original_series_root / series_name / manifest_card.repo
return source_series_path(config, series_name) / manifest_card.repo
def resolve_source_card_path_from_dirs(config: WorkspaceConfig, series_name: str, card_name: str) -> Path:
cards = source_card_directories(config, series_name)
if not cards:
raise SystemExit(f"Missing source series: {config.original_series_root / series_name}")
raise SystemExit(f"Missing source series: {source_series_path(config, series_name)}")
normalized = slug_value(card_name, "card")
matches = []
@@ -535,7 +665,7 @@ def resolve_selector(
if not series_default_card:
raise SystemExit("Missing default card in config.")
return series_arg, series_default_card
if default_series and (config.series_root / default_series / series_arg).is_dir():
if default_series and (workspace_series_path(config, default_series) / series_arg).is_dir():
return default_series, series_arg
if default_series:
try:
@@ -553,7 +683,7 @@ def resolve_selector(
def resolve_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path:
card_path = config.series_root / series_name / card_name
card_path = workspace_series_path(config, series_name) / card_name
if card_path.is_dir():
return card_path
card_info = resolve_card_info(config, series_name, card_name)
@@ -566,6 +696,14 @@ def resolve_card_path(config: WorkspaceConfig, series_name: str, card_name: str)
def print_series(config: WorkspaceConfig) -> None:
config.series_root.mkdir(parents=True, exist_ok=True)
manifest_by_name = workspace_series_manifests(config)
if manifest_by_name:
for series_name in manifest_by_name:
workspace_path = workspace_series_path(config, series_name)
cards = source_card_infos(config, series_name)
workspace_count = sum((workspace_path / card.repo).is_dir() for card in cards)
print(f"{series_name}\t{len(cards)}\t{workspace_count}\t{workspace_path if workspace_path.is_dir() else ''}")
return
source_by_name = {path.name: path for path in source_series_directories(config)}
workspace_by_name = {path.name: path for path in series_directories(config)}
for series_name in sorted(set(manifest_by_name) | set(source_by_name) | set(workspace_by_name)):
@@ -583,8 +721,15 @@ def print_cards(config: WorkspaceConfig, series_arg: str | None) -> None:
config.series_root.mkdir(parents=True, exist_ok=True)
source_cards = {card.repo: card for card in source_card_infos(config, series_name)}
workspace_series_path = config.series_root / series_name
workspace_cards = {path.name: path for path in card_directories(workspace_series_path)} if workspace_series_path.is_dir() else {}
series_path = workspace_series_path(config, series_name)
if workspace_series_manifest(config, series_name):
workspace_cards = {
card.repo: series_path / card.repo
for card in source_cards.values()
if (series_path / card.repo).is_dir()
}
else:
workspace_cards = {path.name: path for path in card_directories(series_path)} if series_path.is_dir() else {}
if not source_cards and not workspace_cards:
raise SystemExit(f"Missing series: {series_name}")
@@ -2859,11 +3004,11 @@ def ensure_answer_token_from_source(
def workspace_card_path(config: WorkspaceConfig, series_name: str, source_card_path: Path) -> Path:
return config.series_root / series_name / source_card_path.name
return workspace_series_path(config, series_name) / source_card_path.name
def workspace_card_path_for_info(config: WorkspaceConfig, card_info: CardInfo) -> Path:
return config.series_root / card_info.series_id / card_info.repo
return workspace_series_path(config, card_info.series_id) / card_info.repo
def resolve_workspace_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path:
@@ -3176,8 +3321,8 @@ def switch_card_task(
def print_series_details(config: WorkspaceConfig, series_name: str) -> None:
config.series_root.mkdir(parents=True, exist_ok=True)
source_path = config.original_series_root / series_name
workspace_path = config.series_root / series_name
source_path = source_series_path(config, series_name)
workspace_path = workspace_series_path(config, series_name)
cards = source_card_infos(config, series_name)
if not cards and not workspace_path.is_dir():
raise SystemExit(f"Missing series: {series_name}")
@@ -3209,11 +3354,11 @@ def print_card_details(config: WorkspaceConfig, series_name: str, card_name: str
def available_series_hint(config: WorkspaceConfig) -> str:
names = sorted(
set(workspace_series_manifests(config))
| {path.name for path in source_series_directories(config)}
| {path.name for path in series_directories(config)}
)
manifest_names = list(workspace_series_manifests(config))
if manifest_names:
names = sorted(manifest_names)
else:
names = sorted({path.name for path in source_series_directories(config)} | {path.name for path in series_directories(config)})
return ", ".join(names)
@@ -4157,6 +4302,8 @@ SESSION_STATUS_MISSING = "brak"
def ordered_series_names(config: WorkspaceConfig) -> list[str]:
names = list(workspace_series_manifests(config))
if names:
return names
for path in [*series_directories(config), *source_series_directories(config)]:
if not path.name.startswith(".") and path.name not in names:
names.append(path.name)
@@ -5484,6 +5631,16 @@ def run_workspace(config: WorkspaceConfig, args: argparse.Namespace) -> None:
print(f"status\tready")
return
if args.workspace_command == "audit":
ensure_workspace_info(config)
checks = audit_workspace_catalog(config)
print("check\tstatus\tdetail")
for name, status, detail in checks:
print(f"{name}\t{status}\t{detail}")
if any(status == "FAIL" for _, status, _ in checks):
raise SystemExit(1)
return
if args.workspace_command == "migrate":
migrated = 0
already_ready = 0
@@ -5775,6 +5932,9 @@ def build_parser() -> argparse.ArgumentParser:
workspace_parser = subparsers.add_parser("workspace", help="Manage workspace metadata manifest.")
workspace_subparsers = workspace_parser.add_subparsers(dest="workspace_command", required=True)
workspace_subparsers.add_parser("show", help="Show workspace-info configuration.")
workspace_subparsers.add_parser(
"audit", help="Validate series ownership, organization registry and card identities."
)
workspace_sync_parser = workspace_subparsers.add_parser("sync", help="Clone or update workspace-info into meta/.")
workspace_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.")
workspace_migrate_parser = workspace_subparsers.add_parser(