feat: support one Gitea organization per series
This commit is contained in:
+27
-20
@@ -11,24 +11,30 @@ Komendy kart pracy dzielimy na trzy poziomy:
|
||||
- `series cards` - operacje na kartach w ramach serii
|
||||
- `series cards tasks` - operacje na zadaniach w ramach karty
|
||||
|
||||
`rvctl` tworzy `series_root`, jeżeli katalog jeszcze nie istnieje. Lista serii
|
||||
i kart pochodzi docelowo z publicznego manifestu
|
||||
`~/dev/workspace/rv/meta/workspace-info/workspace.json`. Jeżeli manifestu nie
|
||||
ma, komendy zależne od serii automatycznie klonują repo
|
||||
`edu-workspace/workspace-info`. Jeżeli klonowanie nie jest możliwe, narzędzie
|
||||
używa starego fallbacku przez `original_series_root`.
|
||||
`stemctl` tworzy `series_root`, jeżeli katalog jeszcze nie istnieje. Gdy
|
||||
`workspace-info` jest dostępny, manifest jest jedynym źródłem listy serii i
|
||||
kart. Przypadkowe katalogi robocze nie pojawiają się w katalogu. Fallback przez
|
||||
`original_series_root` działa wyłącznie dla starego workspace bez manifestu.
|
||||
|
||||
Każda operacyjna seria jawnie deklaruje `source_org`. Docelowa konwencja to
|
||||
`edu-<series-id>`, na przykład `freertos-c` → `edu-freertos-c`. Jedno repo
|
||||
odpowiada jednej karcie; kolejnych wydań karty nie zapisujemy jako osobnych
|
||||
repozytoriów ani trwałych branchy, tylko jako historię, tagi i wydania tego
|
||||
repozytorium. Organizacja `edu` przechowuje control plane i katalog, nie setki
|
||||
repozytoriów kart.
|
||||
|
||||
## Szybki przepływ
|
||||
|
||||
```bash
|
||||
./rvctl tokens compare
|
||||
./rvctl series list
|
||||
./rvctl series use inf
|
||||
./rvctl series cards list inf
|
||||
./rvctl card use bss
|
||||
./rvctl series cards fetch inf bss
|
||||
./rvctl tasks list
|
||||
./rvctl tasks switch 1
|
||||
./stemctl workspace audit
|
||||
./stemctl tokens compare
|
||||
./stemctl series list
|
||||
./stemctl series use freertos-c
|
||||
./stemctl series cards list freertos-c
|
||||
./stemctl card use FC02
|
||||
./stemctl series cards fetch freertos-c FC02
|
||||
./stemctl tasks list
|
||||
./stemctl tasks switch 1
|
||||
```
|
||||
|
||||
Dla karty `bss` właściwym repo jest `lab-rv32i-strlen-bss-data-stack`.
|
||||
@@ -37,10 +43,10 @@ Dla karty `bss` właściwym repo jest `lab-rv32i-strlen-bss-data-stack`.
|
||||
|
||||
### `series list`
|
||||
|
||||
Listuje dostępne serie z manifestu `workspace-info` oraz liczbę kart już
|
||||
pobranych do lokalnego workspace. Jeżeli manifestu jeszcze nie ma, komenda
|
||||
pobiera go automatycznie. Jeżeli `~/dev/workspace/rv/series` nie istnieje,
|
||||
komenda tworzy ten katalog.
|
||||
Listuje wyłącznie operacyjne serie z manifestu `workspace-info` oraz liczbę
|
||||
kart już obecnych w lokalnym workspace. Logiczna seria może wskazywać wspólny,
|
||||
przejściowy katalog fizyczny przez `workspace_dir`; na przykład `freertos-c`
|
||||
jest obecnie mapowane na `series/freertos`.
|
||||
|
||||
```bash
|
||||
./rvctl series list
|
||||
@@ -49,8 +55,9 @@ komenda tworzy ten katalog.
|
||||
Typowy wynik:
|
||||
|
||||
```text
|
||||
fiz 3 0
|
||||
inf 3 0
|
||||
fiz 3 0
|
||||
inf 9 5
|
||||
freertos-c 11 11
|
||||
```
|
||||
|
||||
Kolumny oznaczają: seria, liczba kart w źródłach, liczba kart pobranych do
|
||||
|
||||
@@ -16,6 +16,23 @@ wybrany kontener
|
||||
└── wewnętrzny tmux → Neovim + Termdebug/GDB + symulator lub RP2350
|
||||
```
|
||||
|
||||
## Katalog i organizacje Gitea
|
||||
|
||||
`workspace-info/workspace.json` jest źródłem prawdy. Organizacja `edu` pełni
|
||||
rolę control plane, a stabilne serie mają osobne organizacje o nazwie
|
||||
`edu-<series-id>`. Karta jest repozytorium wewnątrz organizacji swojej serii.
|
||||
|
||||
```bash
|
||||
stemctl workspace audit
|
||||
stemctl series list
|
||||
stemctl series cards list freertos-c
|
||||
stemctl series cards show freertos-c FC02
|
||||
```
|
||||
|
||||
`workspace audit` jest lokalny i deterministyczny: sprawdza kontrakt katalogu,
|
||||
nie modyfikuje Gitea. Dzięki temu brak sieci nie uniemożliwia przeprowadzenia
|
||||
zajęć z wcześniej zsynchronizowanego workspace.
|
||||
|
||||
Obiektem operacji `attach` i `detach` jest **połączenie pane z kontenerem**.
|
||||
Neovim i Termdebug są zawartością sesji kontenera, a nie osobnym obiektem
|
||||
podpinanym przez launcher.
|
||||
|
||||
+30
-6
@@ -13,22 +13,34 @@ Implementacja CLI znajduje się w `rvctl.py`. Plik `workspace.json` przechowuje
|
||||
lokalne ścieżki workspace, series, socketów, tokenów i adres publicznego
|
||||
manifestu.
|
||||
|
||||
Publiczny manifest wspólnego workspace jest w repo:
|
||||
Publiczny manifest wspólnego workspace jest częścią control plane w
|
||||
organizacji `edu`. Katalog jest źródłem prawdy dla narzędzi i interfejsu;
|
||||
lista organizacji widoczna w Gitea pozostaje widokiem administracyjnym.
|
||||
|
||||
Model własności:
|
||||
|
||||
```text
|
||||
edu-workspace/workspace-info
|
||||
edu
|
||||
└── workspace-info control plane i katalog
|
||||
|
||||
edu-inf seria legacy
|
||||
edu-fiz seria
|
||||
edu-freertos-c seria
|
||||
├── lab-rv32i-freertos-heap4 jedna karta = jedno repo
|
||||
├── lab-rv32i-freertos-c-first-task
|
||||
└── ...
|
||||
```
|
||||
|
||||
Lokalna kopia manifestu znajduje się w:
|
||||
|
||||
```text
|
||||
~/dev/workspace/rv/meta/workspace-info
|
||||
~/dev/workspace/stem/meta/workspace-info
|
||||
```
|
||||
|
||||
Typowy układ:
|
||||
|
||||
```text
|
||||
~/dev/workspace/rv
|
||||
~/dev/workspace/stem
|
||||
├── meta
|
||||
│ └── workspace-info
|
||||
├── tools
|
||||
@@ -38,8 +50,20 @@ Typowy układ:
|
||||
└── series
|
||||
```
|
||||
|
||||
`workspace-info` opisuje serie, karty, repo źródłowe, repo odpowiedzi i branche.
|
||||
Nie przechowuje tokenów ani lokalnych plików roboczych ucznia.
|
||||
`workspace-info` opisuje rejestr organizacji, operacyjne serie, karty, repo
|
||||
źródłowe, repo odpowiedzi i branche. Każda seria musi jawnie podać
|
||||
`source_org`; globalne `git.source_org` nie zastępuje tej deklaracji.
|
||||
`workspace-info` nie przechowuje tokenów ani lokalnych plików roboczych ucznia.
|
||||
|
||||
Sprawdzenie spójności bez połączenia z Gitea:
|
||||
|
||||
```bash
|
||||
./stemctl workspace audit
|
||||
```
|
||||
|
||||
Audyt wykrywa brak organizacji, brak jawnego `source_org`, niespójne
|
||||
przypisanie seria–organizacja oraz zduplikowane identyfikatory kart i repo.
|
||||
Ostrzeżenie o wspólnym `workspace_dir` jest dopuszczalne podczas migracji.
|
||||
|
||||
Aktualizacja manifestu:
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -175,6 +175,128 @@ class StemctlContractTests(unittest.TestCase):
|
||||
self.assertIn("runtime\tFAIL\tdocker is not supported", rendered)
|
||||
self.assertIn("rootless\tFAIL\trootless Podman is required", rendered)
|
||||
|
||||
def test_manifest_is_authoritative_and_maps_logical_series_directory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
workspace = root / "workspace"
|
||||
info = workspace / "meta" / "workspace-info"
|
||||
(info / "series").mkdir(parents=True)
|
||||
physical_series = workspace / "series" / "freertos"
|
||||
(physical_series / "card-one").mkdir(parents=True)
|
||||
(physical_series / "unregistered-extra").mkdir()
|
||||
(workspace / "series" / ".hidden").mkdir()
|
||||
source_series = root / "original" / "series" / "freertos"
|
||||
(source_series / "card-one").mkdir(parents=True)
|
||||
(info / "series" / "freertos-c.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": "freertos-c",
|
||||
"source_org": "edu-freertos-c",
|
||||
"workspace_dir": "freertos",
|
||||
"source_dir": "freertos",
|
||||
"cards": [{"id": "FC01", "repo": "card-one", "title": "First card"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(info / "workspace.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"organizations": [
|
||||
{"id": "edu", "role": "control-plane"},
|
||||
{"id": "edu-freertos-c", "role": "series", "series": ["freertos-c"]},
|
||||
],
|
||||
"series": [{"id": "freertos-c", "file": "series/freertos-c.json"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"workspace_root": str(workspace),
|
||||
"original_root": str(root / "original"),
|
||||
"original_series_root": str(root / "original" / "series"),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = rvctl.load_config(config_path)
|
||||
|
||||
series_output = io.StringIO()
|
||||
cards_output = io.StringIO()
|
||||
with redirect_stdout(series_output):
|
||||
rvctl.print_series(config)
|
||||
with redirect_stdout(cards_output):
|
||||
rvctl.print_cards(config, "freertos-c")
|
||||
|
||||
self.assertIn("freertos-c\t1\t1", series_output.getvalue())
|
||||
self.assertNotIn(".hidden", series_output.getvalue())
|
||||
self.assertIn("FC01\tcard-one\tworkspace\tFirst card", cards_output.getvalue())
|
||||
self.assertNotIn("unregistered-extra", cards_output.getvalue())
|
||||
self.assertEqual(rvctl.workspace_series_path(config, "freertos-c"), physical_series)
|
||||
self.assertEqual(rvctl.source_series_path(config, "freertos-c"), source_series)
|
||||
self.assertEqual(rvctl.resolve_card_info(config, "freertos-c", "FC01").repo, "card-one")
|
||||
|
||||
def test_workspace_catalog_audit_accepts_registered_series_org(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
workspace = root / "workspace"
|
||||
info = workspace / "meta" / "workspace-info"
|
||||
info.mkdir(parents=True)
|
||||
(info / "workspace.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"organizations": [
|
||||
{"id": "edu", "role": "control-plane"},
|
||||
{"id": "edu-freertos-c", "role": "series", "series": ["freertos-c"]},
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"id": "freertos-c",
|
||||
"source_org": "edu-freertos-c",
|
||||
"cards": [{"id": "FC01", "repo": "card-one"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"workspace_root": str(workspace), "original_root": str(root / "original")}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = rvctl.load_config(config_path)
|
||||
checks = rvctl.audit_workspace_catalog(config)
|
||||
self.assertFalse(any(status == "FAIL" for _, status, _ in checks), checks)
|
||||
self.assertIn(("series:freertos-c", "ok", "source_org=edu-freertos-c"), checks)
|
||||
|
||||
def test_workspace_catalog_audit_rejects_implicit_source_org(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
workspace = root / "workspace"
|
||||
info = workspace / "meta" / "workspace-info"
|
||||
info.mkdir(parents=True)
|
||||
(info / "workspace.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"organizations": [{"id": "edu", "role": "control-plane"}],
|
||||
"series": [{"id": "freertos-c", "cards": [{"id": "FC01", "repo": "card-one"}]}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"workspace_root": str(workspace), "original_root": str(root / "original")}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = rvctl.load_config(config_path)
|
||||
checks = rvctl.audit_workspace_catalog(config)
|
||||
self.assertIn(("series:freertos-c", "FAIL", "missing explicit source_org"), checks)
|
||||
|
||||
def test_existing_legacy_workspace_is_detected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
|
||||
+6
-6
@@ -4,7 +4,7 @@
|
||||
"original_series_root": "~/dev/edu/repos/rv/series",
|
||||
"workspace_root": "~/dev/workspace/stem",
|
||||
"legacy_workspace_root": "~/dev/workspace/rv",
|
||||
"workspace_info_url": "http://77.90.8.171:3001/edu-workspace/workspace-info.git",
|
||||
"workspace_info_url": "https://zsl-gitea.mpabi.pl/edu/workspace-info.git",
|
||||
"env_tool_url": "http://77.90.8.171:3001/edu-tools/rv32i-hazard3-student-env.git",
|
||||
"tools_root_candidates": [
|
||||
"~/dev/workspace/stem/tools/rv32i-hazard3-student-env",
|
||||
@@ -14,17 +14,17 @@
|
||||
"~/dev/edu/repos/rv/rv32i-hazard3-env"
|
||||
],
|
||||
"git": {
|
||||
"base_url": "http://77.90.8.171:3001",
|
||||
"source_org": "edu-inf",
|
||||
"base_url": "https://zsl-gitea.mpabi.pl",
|
||||
"source_org": "edu",
|
||||
"answer_org": "c2025-1a-inf",
|
||||
"source_remote": "r1",
|
||||
"answer_remote": "r1a",
|
||||
"origin_remote": "origin",
|
||||
"fallback_branch": "build"
|
||||
"fallback_branch": "main"
|
||||
},
|
||||
"defaults": {
|
||||
"series": "inf",
|
||||
"card": "pointers",
|
||||
"series": "freertos-c",
|
||||
"card": "FC02",
|
||||
"task": "task1",
|
||||
"editor": "nvim",
|
||||
"instance": "shell",
|
||||
|
||||
Reference in New Issue
Block a user