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` - operacje na kartach w ramach serii
|
||||||
- `series cards tasks` - operacje na zadaniach w ramach karty
|
- `series cards tasks` - operacje na zadaniach w ramach karty
|
||||||
|
|
||||||
`rvctl` tworzy `series_root`, jeżeli katalog jeszcze nie istnieje. Lista serii
|
`stemctl` tworzy `series_root`, jeżeli katalog jeszcze nie istnieje. Gdy
|
||||||
i kart pochodzi docelowo z publicznego manifestu
|
`workspace-info` jest dostępny, manifest jest jedynym źródłem listy serii i
|
||||||
`~/dev/workspace/rv/meta/workspace-info/workspace.json`. Jeżeli manifestu nie
|
kart. Przypadkowe katalogi robocze nie pojawiają się w katalogu. Fallback przez
|
||||||
ma, komendy zależne od serii automatycznie klonują repo
|
`original_series_root` działa wyłącznie dla starego workspace bez manifestu.
|
||||||
`edu-workspace/workspace-info`. Jeżeli klonowanie nie jest możliwe, narzędzie
|
|
||||||
używa starego fallbacku przez `original_series_root`.
|
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
|
## Szybki przepływ
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./rvctl tokens compare
|
./stemctl workspace audit
|
||||||
./rvctl series list
|
./stemctl tokens compare
|
||||||
./rvctl series use inf
|
./stemctl series list
|
||||||
./rvctl series cards list inf
|
./stemctl series use freertos-c
|
||||||
./rvctl card use bss
|
./stemctl series cards list freertos-c
|
||||||
./rvctl series cards fetch inf bss
|
./stemctl card use FC02
|
||||||
./rvctl tasks list
|
./stemctl series cards fetch freertos-c FC02
|
||||||
./rvctl tasks switch 1
|
./stemctl tasks list
|
||||||
|
./stemctl tasks switch 1
|
||||||
```
|
```
|
||||||
|
|
||||||
Dla karty `bss` właściwym repo jest `lab-rv32i-strlen-bss-data-stack`.
|
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`
|
### `series list`
|
||||||
|
|
||||||
Listuje dostępne serie z manifestu `workspace-info` oraz liczbę kart już
|
Listuje wyłącznie operacyjne serie z manifestu `workspace-info` oraz liczbę
|
||||||
pobranych do lokalnego workspace. Jeżeli manifestu jeszcze nie ma, komenda
|
kart już obecnych w lokalnym workspace. Logiczna seria może wskazywać wspólny,
|
||||||
pobiera go automatycznie. Jeżeli `~/dev/workspace/rv/series` nie istnieje,
|
przejściowy katalog fizyczny przez `workspace_dir`; na przykład `freertos-c`
|
||||||
komenda tworzy ten katalog.
|
jest obecnie mapowane na `series/freertos`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./rvctl series list
|
./rvctl series list
|
||||||
@@ -49,8 +55,9 @@ komenda tworzy ten katalog.
|
|||||||
Typowy wynik:
|
Typowy wynik:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
fiz 3 0
|
fiz 3 0
|
||||||
inf 3 0
|
inf 9 5
|
||||||
|
freertos-c 11 11
|
||||||
```
|
```
|
||||||
|
|
||||||
Kolumny oznaczają: seria, liczba kart w źródłach, liczba kart pobranych do
|
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
|
└── 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**.
|
Obiektem operacji `attach` i `detach` jest **połączenie pane z kontenerem**.
|
||||||
Neovim i Termdebug są zawartością sesji kontenera, a nie osobnym obiektem
|
Neovim i Termdebug są zawartością sesji kontenera, a nie osobnym obiektem
|
||||||
podpinanym przez launcher.
|
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
|
lokalne ścieżki workspace, series, socketów, tokenów i adres publicznego
|
||||||
manifestu.
|
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
|
```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:
|
Lokalna kopia manifestu znajduje się w:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/dev/workspace/rv/meta/workspace-info
|
~/dev/workspace/stem/meta/workspace-info
|
||||||
```
|
```
|
||||||
|
|
||||||
Typowy układ:
|
Typowy układ:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/dev/workspace/rv
|
~/dev/workspace/stem
|
||||||
├── meta
|
├── meta
|
||||||
│ └── workspace-info
|
│ └── workspace-info
|
||||||
├── tools
|
├── tools
|
||||||
@@ -38,8 +50,20 @@ Typowy układ:
|
|||||||
└── series
|
└── series
|
||||||
```
|
```
|
||||||
|
|
||||||
`workspace-info` opisuje serie, karty, repo źródłowe, repo odpowiedzi i branche.
|
`workspace-info` opisuje rejestr organizacji, operacyjne serie, karty, repo
|
||||||
Nie przechowuje tokenów ani lokalnych plików roboczych ucznia.
|
ź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:
|
Aktualizacja manifestu:
|
||||||
|
|
||||||
|
|||||||
@@ -326,10 +326,130 @@ def workspace_series_manifest(config: WorkspaceConfig, series_name: str) -> dict
|
|||||||
return workspace_series_manifests(config).get(series_name)
|
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:
|
def series_exists(config: WorkspaceConfig, series_name: str) -> bool:
|
||||||
if workspace_series_manifest(config, series_name) is not None:
|
if workspace_series_manifest(config, series_name) is not None:
|
||||||
return True
|
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:
|
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:
|
if not manifest:
|
||||||
return []
|
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))
|
answer_org = str(manifest.get("answer_org", config.git.answer_org))
|
||||||
source_remote = str(manifest.get("source_remote", config.git.source_remote))
|
source_remote = str(manifest.get("source_remote", config.git.source_remote))
|
||||||
answer_remote = str(manifest.get("answer_remote", config.git.answer_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()
|
repo_name = str(raw_card.get("repo", card_id)).strip()
|
||||||
branch_name = str(raw_card.get("branch", fallback_branch)).strip() or fallback_branch
|
branch_name = str(raw_card.get("branch", fallback_branch)).strip() or fallback_branch
|
||||||
title = str(raw_card.get("title", "")).strip()
|
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(
|
cards.append(
|
||||||
CardInfo(
|
CardInfo(
|
||||||
series_id=series_name,
|
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]:
|
def series_directories(config: WorkspaceConfig) -> list[Path]:
|
||||||
config.series_root.mkdir(parents=True, exist_ok=True)
|
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]:
|
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]:
|
def source_series_directories(config: WorkspaceConfig) -> list[Path]:
|
||||||
if not config.original_series_root.is_dir():
|
if not config.original_series_root.is_dir():
|
||||||
return []
|
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]:
|
def source_card_directories(config: WorkspaceConfig, series_name: str) -> list[Path]:
|
||||||
source_series_path = config.original_series_root / series_name
|
series_path = source_series_path(config, series_name)
|
||||||
if not source_series_path.is_dir():
|
if not series_path.is_dir():
|
||||||
return []
|
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:
|
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:
|
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)
|
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:
|
def resolve_card_info(config: WorkspaceConfig, series_name: str, card_name: str) -> CardInfo:
|
||||||
cards = source_card_infos(config, series_name)
|
cards = source_card_infos(config, series_name)
|
||||||
if not cards:
|
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():
|
if config.workspace_info_path.is_file():
|
||||||
source_hint = config.workspace_info_path
|
source_hint = config.workspace_info_path
|
||||||
raise SystemExit(f"Missing source series: {source_hint}")
|
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)
|
manifest_card = resolve_card_info(config, series_name, card_name)
|
||||||
if manifest_card.source_path:
|
if manifest_card.source_path:
|
||||||
return 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:
|
def resolve_source_card_path_from_dirs(config: WorkspaceConfig, series_name: str, card_name: str) -> Path:
|
||||||
cards = source_card_directories(config, series_name)
|
cards = source_card_directories(config, series_name)
|
||||||
if not cards:
|
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")
|
normalized = slug_value(card_name, "card")
|
||||||
matches = []
|
matches = []
|
||||||
@@ -535,7 +665,7 @@ def resolve_selector(
|
|||||||
if not series_default_card:
|
if not series_default_card:
|
||||||
raise SystemExit("Missing default card in config.")
|
raise SystemExit("Missing default card in config.")
|
||||||
return series_arg, series_default_card
|
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
|
return default_series, series_arg
|
||||||
if default_series:
|
if default_series:
|
||||||
try:
|
try:
|
||||||
@@ -553,7 +683,7 @@ def resolve_selector(
|
|||||||
|
|
||||||
|
|
||||||
def resolve_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path:
|
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():
|
if card_path.is_dir():
|
||||||
return card_path
|
return card_path
|
||||||
card_info = resolve_card_info(config, series_name, card_name)
|
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:
|
def print_series(config: WorkspaceConfig) -> None:
|
||||||
config.series_root.mkdir(parents=True, exist_ok=True)
|
config.series_root.mkdir(parents=True, exist_ok=True)
|
||||||
manifest_by_name = workspace_series_manifests(config)
|
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)}
|
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)}
|
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)):
|
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)
|
config.series_root.mkdir(parents=True, exist_ok=True)
|
||||||
source_cards = {card.repo: card for card in source_card_infos(config, series_name)}
|
source_cards = {card.repo: card for card in source_card_infos(config, series_name)}
|
||||||
workspace_series_path = config.series_root / series_name
|
series_path = workspace_series_path(config, series_name)
|
||||||
workspace_cards = {path.name: path for path in card_directories(workspace_series_path)} if workspace_series_path.is_dir() else {}
|
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:
|
if not source_cards and not workspace_cards:
|
||||||
raise SystemExit(f"Missing series: {series_name}")
|
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:
|
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:
|
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:
|
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:
|
def print_series_details(config: WorkspaceConfig, series_name: str) -> None:
|
||||||
config.series_root.mkdir(parents=True, exist_ok=True)
|
config.series_root.mkdir(parents=True, exist_ok=True)
|
||||||
source_path = config.original_series_root / series_name
|
source_path = source_series_path(config, series_name)
|
||||||
workspace_path = config.series_root / series_name
|
workspace_path = workspace_series_path(config, series_name)
|
||||||
cards = source_card_infos(config, series_name)
|
cards = source_card_infos(config, series_name)
|
||||||
if not cards and not workspace_path.is_dir():
|
if not cards and not workspace_path.is_dir():
|
||||||
raise SystemExit(f"Missing series: {series_name}")
|
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:
|
def available_series_hint(config: WorkspaceConfig) -> str:
|
||||||
names = sorted(
|
manifest_names = list(workspace_series_manifests(config))
|
||||||
set(workspace_series_manifests(config))
|
if manifest_names:
|
||||||
| {path.name for path in source_series_directories(config)}
|
names = sorted(manifest_names)
|
||||||
| {path.name for path in series_directories(config)}
|
else:
|
||||||
)
|
names = sorted({path.name for path in source_series_directories(config)} | {path.name for path in series_directories(config)})
|
||||||
return ", ".join(names)
|
return ", ".join(names)
|
||||||
|
|
||||||
|
|
||||||
@@ -4157,6 +4302,8 @@ SESSION_STATUS_MISSING = "brak"
|
|||||||
|
|
||||||
def ordered_series_names(config: WorkspaceConfig) -> list[str]:
|
def ordered_series_names(config: WorkspaceConfig) -> list[str]:
|
||||||
names = list(workspace_series_manifests(config))
|
names = list(workspace_series_manifests(config))
|
||||||
|
if names:
|
||||||
|
return names
|
||||||
for path in [*series_directories(config), *source_series_directories(config)]:
|
for path in [*series_directories(config), *source_series_directories(config)]:
|
||||||
if not path.name.startswith(".") and path.name not in names:
|
if not path.name.startswith(".") and path.name not in names:
|
||||||
names.append(path.name)
|
names.append(path.name)
|
||||||
@@ -5484,6 +5631,16 @@ def run_workspace(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
|||||||
print(f"status\tready")
|
print(f"status\tready")
|
||||||
return
|
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":
|
if args.workspace_command == "migrate":
|
||||||
migrated = 0
|
migrated = 0
|
||||||
already_ready = 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_parser = subparsers.add_parser("workspace", help="Manage workspace metadata manifest.")
|
||||||
workspace_subparsers = workspace_parser.add_subparsers(dest="workspace_command", required=True)
|
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("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 = 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_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.")
|
||||||
workspace_migrate_parser = workspace_subparsers.add_parser(
|
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("runtime\tFAIL\tdocker is not supported", rendered)
|
||||||
self.assertIn("rootless\tFAIL\trootless Podman is required", 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:
|
def test_existing_legacy_workspace_is_detected(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
root = Path(temporary)
|
root = Path(temporary)
|
||||||
|
|||||||
+6
-6
@@ -4,7 +4,7 @@
|
|||||||
"original_series_root": "~/dev/edu/repos/rv/series",
|
"original_series_root": "~/dev/edu/repos/rv/series",
|
||||||
"workspace_root": "~/dev/workspace/stem",
|
"workspace_root": "~/dev/workspace/stem",
|
||||||
"legacy_workspace_root": "~/dev/workspace/rv",
|
"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",
|
"env_tool_url": "http://77.90.8.171:3001/edu-tools/rv32i-hazard3-student-env.git",
|
||||||
"tools_root_candidates": [
|
"tools_root_candidates": [
|
||||||
"~/dev/workspace/stem/tools/rv32i-hazard3-student-env",
|
"~/dev/workspace/stem/tools/rv32i-hazard3-student-env",
|
||||||
@@ -14,17 +14,17 @@
|
|||||||
"~/dev/edu/repos/rv/rv32i-hazard3-env"
|
"~/dev/edu/repos/rv/rv32i-hazard3-env"
|
||||||
],
|
],
|
||||||
"git": {
|
"git": {
|
||||||
"base_url": "http://77.90.8.171:3001",
|
"base_url": "https://zsl-gitea.mpabi.pl",
|
||||||
"source_org": "edu-inf",
|
"source_org": "edu",
|
||||||
"answer_org": "c2025-1a-inf",
|
"answer_org": "c2025-1a-inf",
|
||||||
"source_remote": "r1",
|
"source_remote": "r1",
|
||||||
"answer_remote": "r1a",
|
"answer_remote": "r1a",
|
||||||
"origin_remote": "origin",
|
"origin_remote": "origin",
|
||||||
"fallback_branch": "build"
|
"fallback_branch": "main"
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"series": "inf",
|
"series": "freertos-c",
|
||||||
"card": "pointers",
|
"card": "FC02",
|
||||||
"task": "task1",
|
"task": "task1",
|
||||||
"editor": "nvim",
|
"editor": "nvim",
|
||||||
"instance": "shell",
|
"instance": "shell",
|
||||||
|
|||||||
Reference in New Issue
Block a user