feat: support one Gitea organization per series
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user