diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 48a540c..011b71a 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -36,7 +36,9 @@ Optional environment variables: |------|-------------| | `/` | Home / operator overview | | `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) | -| `/projects` | Stub — registry (#427) | +| `/projects` | Project registry list (#427) | +| `/projects/{id}` | Project detail + onboarding checklist | +| `/api/projects` | JSON registry export | | `/prompts` | Stub — prompt library (#428) | | `/runtime` | Stub — MCP runtime health (#430) | | `/audit` | Stub — report audit paste (#431) | @@ -46,8 +48,20 @@ Optional environment variables: All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with `read-only-mvp`. +## Project registry (#427) + +Versioned registry file: `webui/data/projects.registry.json` (schema version `1`). + +Override path with `WEBUI_PROJECT_REGISTRY` when operators keep a machine-local +copy outside git. The registry stores repo identity, remotes, profile names, +workflow/schema path references, and onboarding checklist steps — never tokens +or credentials. + +Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`, +`prgs-reviewer`, and `prgs-reconciler` profiles. + ## Tests ```bash -pytest tests/test_webui_skeleton.py -q +pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py -q ``` \ No newline at end of file diff --git a/tests/test_webui_project_registry.py b/tests/test_webui_project_registry.py new file mode 100644 index 0000000..f16774a --- /dev/null +++ b/tests/test_webui_project_registry.py @@ -0,0 +1,111 @@ +"""Tests for web UI project registry (#427).""" +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.project_registry import ( + default_registry_path, + load_registry, + project_to_dict, +) + + +class TestProjectRegistryLoader(unittest.TestCase): + def test_default_registry_loads_gitea_tools(self): + registry = load_registry() + self.assertEqual(registry.version, 1) + self.assertEqual(len(registry.projects), 1) + project = registry.projects[0] + self.assertEqual(project.id, "gitea-tools") + self.assertEqual(project.repo_name, "Gitea-Tools") + self.assertEqual(project.gitea_owner, "Scaled-Tech-Consulting") + self.assertEqual(project.remote_host, "https://gitea.prgs.cc") + self.assertEqual(project.profiles["author"], "prgs-author") + self.assertEqual(project.profiles["reviewer"], "prgs-reviewer") + self.assertEqual(project.profiles["reconciler"], "prgs-reconciler") + self.assertIn("skill", project.workflow_paths) + self.assertGreaterEqual(len(project.onboarding_checklist), 4) + + def test_registry_rejects_credential_keys(self): + payload = { + "version": 1, + "projects": [ + { + "id": "bad", + "repo_name": "Bad", + "gitea_owner": "Org", + "remote_host": "https://gitea.example.invalid", + "default_branch": "main", + "local_checkout_path": ".", + "profiles": { + "author": "a", + "reviewer": "r", + "reconciler": "c", + }, + "workflow_paths": {"skill": "skills/x.md"}, + "api_token": "secret", + } + ], + } + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: + json.dump(payload, handle) + path = Path(handle.name) + try: + with self.assertRaises(ValueError): + load_registry(path) + finally: + path.unlink(missing_ok=True) + + def test_default_registry_path_points_at_packaged_data(self): + path = default_registry_path() + self.assertTrue(path.name == "projects.registry.json") + self.assertTrue(path.parent.name == "data") + + +class TestProjectRegistryRoutes(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_projects_page_lists_gitea_tools(self): + response = self.client.get("/projects") + self.assertEqual(response.status_code, 200) + self.assertIn("Gitea-Tools", response.text) + self.assertIn("Scaled-Tech-Consulting", response.text) + self.assertIn("prgs-author", response.text) + self.assertNotIn("child issue", response.text.lower()) + + def test_project_detail_renders_checklist(self): + response = self.client.get("/projects/gitea-tools") + self.assertEqual(response.status_code, 200) + self.assertIn("Onboarding checklist", response.text) + self.assertIn("Configure execution profiles", response.text) + self.assertIn("branches/", response.text) + + def test_project_detail_404(self): + response = self.client.get("/projects/unknown-repo") + self.assertEqual(response.status_code, 404) + + def test_api_projects_json(self): + response = self.client.get("/api/projects") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["version"], 1) + self.assertEqual(len(data["projects"]), 1) + self.assertEqual(data["projects"][0]["id"], "gitea-tools") + self.assertIn("onboarding_checklist", data["projects"][0]) + + def test_project_to_dict_is_json_safe(self): + registry = load_registry() + encoded = json.dumps(project_to_dict(registry.projects[0])) + self.assertIn("gitea-tools", encoded) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py index 89471af..7e139c6 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -30,12 +30,17 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertIn("Read-only MVP", response.text) def test_route_stubs_render(self): - for path in ("/projects", "/prompts", "/runtime", "/audit"): + for path in ("/prompts", "/runtime", "/audit"): with self.subTest(path=path): response = self.client.get(path) self.assertEqual(response.status_code, 200) self.assertIn("child issue", response.text.lower()) + def test_projects_is_implemented(self): + response = self.client.get("/projects") + self.assertEqual(response.status_code, 200) + self.assertIn("Gitea-Tools", response.text) + def test_extra_stub_routes(self): for path in ("/worktrees", "/leases"): with self.subTest(path=path): diff --git a/webui/app.py b/webui/app.py index aa0c10f..4f1cba9 100644 --- a/webui/app.py +++ b/webui/app.py @@ -10,6 +10,8 @@ from starlette.responses import HTMLResponse, JSONResponse, Response from starlette.routing import Route from webui.layout import render_page +from webui.project_registry import find_project, load_registry, registry_to_dict +from webui.project_views import render_project_detail, render_projects_list _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) @@ -49,10 +51,32 @@ async def health(_request: Request) -> JSONResponse: async def projects(_request: Request) -> HTMLResponse: - return _stub_page( - "Projects", - "Project registry and onboarding model will list configured repos and profiles.", - ) + registry = load_registry() + return HTMLResponse(render_projects_list(registry)) + + +async def project_detail(request: Request) -> HTMLResponse: + project_id = request.path_params["project_id"] + registry = load_registry() + project = find_project(registry, project_id) + if project is None: + return HTMLResponse( + render_page( + title="Project not found", + body_html=( + "

Project not found

" + f"

No registry entry for {project_id}.

" + '

← All projects

' + ), + ), + status_code=404, + ) + return HTMLResponse(render_project_detail(project)) + + +async def api_projects(_request: Request) -> JSONResponse: + registry = load_registry() + return JSONResponse(registry_to_dict(registry)) async def prompts(_request: Request) -> HTMLResponse: @@ -107,6 +131,8 @@ def create_app() -> Starlette: Route("/", home, methods=["GET"]), Route("/health", health, methods=["GET"]), Route("/projects", projects, methods=["GET"]), + Route("/projects/{project_id}", project_detail, methods=["GET"]), + Route("/api/projects", api_projects, methods=["GET"]), Route("/prompts", prompts, methods=["GET"]), Route("/runtime", runtime, methods=["GET"]), Route("/audit", audit, methods=["GET"]), diff --git a/webui/data/projects.registry.json b/webui/data/projects.registry.json new file mode 100644 index 0000000..cd6c852 --- /dev/null +++ b/webui/data/projects.registry.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "projects": [ + { + "id": "gitea-tools", + "repo_name": "Gitea-Tools", + "gitea_owner": "Scaled-Tech-Consulting", + "remote_host": "https://gitea.prgs.cc", + "default_branch": "master", + "local_checkout_path": ".", + "profiles": { + "author": "prgs-author", + "reviewer": "prgs-reviewer", + "reconciler": "prgs-reconciler" + }, + "workflow_paths": { + "skill": "skills/llm-project-workflow/SKILL.md", + "work_issue": "skills/llm-project-workflow/workflows/work-issue.md", + "review_merge": "skills/llm-project-workflow/workflows/review-merge-pr.md" + }, + "schema_paths": { + "mcp_config_v2": "gitea-mcp.v2-contexts.example.json", + "mcp_config_v1": "gitea-mcp.example.json" + }, + "onboarding_checklist": [ + { + "id": "profiles", + "title": "Configure execution profiles", + "description": "Install author, reviewer, and reconciler MCP profiles (prgs-author, prgs-reviewer, prgs-reconciler) in separate namespaces. Tokens stay in keychain — never in this registry." + }, + { + "id": "mcp_config", + "title": "Wire MCP v2 contexts", + "description": "Copy and customize gitea-mcp.v2-contexts.example.json for your machine. Map this repo path under projects with default_owner Scaled-Tech-Consulting and default_repo Gitea-Tools." + }, + { + "id": "wiki_gate", + "title": "Wiki publication readiness", + "description": "For wiki-tracked work, satisfy the live Gitea Wiki proof gate (#224) before closing issues. See docs/wiki/Safety-and-Gates.md." + }, + { + "id": "branches_layout", + "title": "Isolate work under branches/", + "description": "All LLM task edits happen in worktrees under branches/. Main checkout stays clean; use skills/llm-project-workflow templates for start-issue and review flows." + } + ] + } + ] +} \ No newline at end of file diff --git a/webui/layout.py b/webui/layout.py index 0aa2a87..fc75efb 100644 --- a/webui/layout.py +++ b/webui/layout.py @@ -87,6 +87,34 @@ def render_page(*, title: str, body_html: str) -> str: padding-left: 0.85rem; margin: 1rem 0; }} + .meta {{ font-size: 0.85rem; }} + table.registry, table.detail {{ + width: 100%; + border-collapse: collapse; + margin: 1rem 0; + font-size: 0.9rem; + }} + table.registry th, table.registry td, + table.detail th, table.detail td {{ + text-align: left; + padding: 0.45rem 0.6rem; + border-bottom: 1px solid var(--border); + }} + table.registry th, table.detail th {{ + color: var(--muted); + font-weight: 500; + }} + code {{ + font-family: ui-monospace, monospace; + font-size: 0.85em; + color: var(--text); + }} + ol.checklist {{ + padding-left: 1.25rem; + margin: 0.5rem 0 1.5rem; + }} + ol.checklist li {{ margin-bottom: 0.85rem; }} + ol.checklist p {{ margin: 0.25rem 0 0; font-size: 0.9rem; }} diff --git a/webui/project_registry.py b/webui/project_registry.py new file mode 100644 index 0000000..2c5e889 --- /dev/null +++ b/webui/project_registry.py @@ -0,0 +1,196 @@ +"""Load and validate the web UI project registry (#427).""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +_FORBIDDEN_EXACT_KEYS = frozenset({ + "token", + "password", + "secret", + "credential", + "auth", + "api_key", + "api-key", +}) +_FORBIDDEN_KEY_PREFIXES = ("auth_", "api_key_", "api-key_") +_FORBIDDEN_KEY_SUFFIXES = ("_token", "_secret", "_password", "_credential", "_auth") + + +def _is_forbidden_key(key: str) -> bool: + lowered = key.lower() + if lowered in _FORBIDDEN_EXACT_KEYS: + return True + return ( + lowered.startswith(_FORBIDDEN_KEY_PREFIXES) + or lowered.endswith(_FORBIDDEN_KEY_SUFFIXES) + ) + +_REQUIRED_PROJECT_FIELDS = ( + "id", + "repo_name", + "gitea_owner", + "remote_host", + "default_branch", + "local_checkout_path", + "profiles", + "workflow_paths", +) + +_REQUIRED_PROFILE_ROLES = ("author", "reviewer", "reconciler") + + +@dataclass(frozen=True) +class OnboardingStep: + id: str + title: str + description: str + + +@dataclass(frozen=True) +class ProjectRecord: + id: str + repo_name: str + gitea_owner: str + remote_host: str + default_branch: str + local_checkout_path: str + profiles: dict[str, str] + workflow_paths: dict[str, str] + schema_paths: dict[str, str] + onboarding_checklist: tuple[OnboardingStep, ...] + + +@dataclass(frozen=True) +class ProjectRegistry: + version: int + projects: tuple[ProjectRecord, ...] + source_path: Path + + +def default_registry_path() -> Path: + override = os.environ.get("WEBUI_PROJECT_REGISTRY", "").strip() + if override: + return Path(override).expanduser().resolve() + return (Path(__file__).resolve().parent / "data" / "projects.registry.json").resolve() + + +def _reject_credential_keys(obj: Any, *, path: str = "") -> None: + if isinstance(obj, dict): + for key, value in obj.items(): + key_path = f"{path}.{key}" if path else key + if _is_forbidden_key(key): + raise ValueError(f"registry must not store credentials ({key_path})") + _reject_credential_keys(value, path=key_path) + elif isinstance(obj, list): + for index, item in enumerate(obj): + _reject_credential_keys(item, path=f"{path}[{index}]") + + +def _parse_onboarding(raw: list[dict[str, Any]] | None) -> tuple[OnboardingStep, ...]: + if not raw: + return () + steps: list[OnboardingStep] = [] + for item in raw: + steps.append( + OnboardingStep( + id=str(item["id"]), + title=str(item["title"]), + description=str(item["description"]), + ) + ) + return tuple(steps) + + +def _parse_project(raw: dict[str, Any]) -> ProjectRecord: + missing = [field for field in _REQUIRED_PROJECT_FIELDS if field not in raw] + if missing: + raise ValueError(f"project missing required fields: {', '.join(missing)}") + + profiles = raw["profiles"] + if not isinstance(profiles, dict): + raise ValueError("profiles must be an object") + for role in _REQUIRED_PROFILE_ROLES: + if role not in profiles or not profiles[role]: + raise ValueError(f"profiles.{role} is required") + + workflow_paths = raw["workflow_paths"] + if not isinstance(workflow_paths, dict) or not workflow_paths: + raise ValueError("workflow_paths must be a non-empty object") + + schema_paths = raw.get("schema_paths") or {} + if not isinstance(schema_paths, dict): + raise ValueError("schema_paths must be an object when present") + + return ProjectRecord( + id=str(raw["id"]), + repo_name=str(raw["repo_name"]), + gitea_owner=str(raw["gitea_owner"]), + remote_host=str(raw["remote_host"]), + default_branch=str(raw["default_branch"]), + local_checkout_path=str(raw["local_checkout_path"]), + profiles={role: str(profiles[role]) for role in _REQUIRED_PROFILE_ROLES}, + workflow_paths={key: str(value) for key, value in workflow_paths.items()}, + schema_paths={key: str(value) for key, value in schema_paths.items()}, + onboarding_checklist=_parse_onboarding(raw.get("onboarding_checklist")), + ) + + +def load_registry(path: Path | None = None) -> ProjectRegistry: + """Load the versioned project registry from disk.""" + source = (path or default_registry_path()).resolve() + raw_text = source.read_text(encoding="utf-8") + payload = json.loads(raw_text) + if not isinstance(payload, dict): + raise ValueError("registry root must be an object") + + version = payload.get("version") + if version != 1: + raise ValueError(f"unsupported registry version: {version!r}") + + _reject_credential_keys(payload) + + projects_raw = payload.get("projects") + if not isinstance(projects_raw, list) or not projects_raw: + raise ValueError("projects must be a non-empty array") + + projects = tuple(_parse_project(item) for item in projects_raw) + return ProjectRegistry(version=version, projects=projects, source_path=source) + + +def project_to_dict(project: ProjectRecord) -> dict[str, Any]: + """Serialize a project for JSON API responses.""" + return { + "id": project.id, + "repo_name": project.repo_name, + "gitea_owner": project.gitea_owner, + "remote_host": project.remote_host, + "default_branch": project.default_branch, + "local_checkout_path": project.local_checkout_path, + "profiles": dict(project.profiles), + "workflow_paths": dict(project.workflow_paths), + "schema_paths": dict(project.schema_paths), + "onboarding_checklist": [ + {"id": step.id, "title": step.title, "description": step.description} + for step in project.onboarding_checklist + ], + } + + +def registry_to_dict(registry: ProjectRegistry) -> dict[str, Any]: + return { + "version": registry.version, + "source_path": str(registry.source_path), + "projects": [project_to_dict(project) for project in registry.projects], + } + + +def find_project(registry: ProjectRegistry, project_id: str) -> ProjectRecord | None: + for project in registry.projects: + if project.id == project_id: + return project + return None \ No newline at end of file diff --git a/webui/project_views.py b/webui/project_views.py new file mode 100644 index 0000000..c5076ef --- /dev/null +++ b/webui/project_views.py @@ -0,0 +1,93 @@ +"""HTML views for project registry pages (#427).""" + +from __future__ import annotations + +import html + +from webui.layout import render_page +from webui.project_registry import ProjectRecord, ProjectRegistry + + +def _escape(text: str) -> str: + return html.escape(text, quote=True) + + +def render_projects_list(registry: ProjectRegistry) -> str: + rows = [] + for project in registry.projects: + rows.append( + "" + f"{_escape(project.repo_name)}" + f"{_escape(project.gitea_owner)}" + f"{_escape(project.remote_host)}" + f"{_escape(project.default_branch)}" + f"{_escape(project.profiles['author'])}" + "" + ) + table = ( + "" + "" + "" + "" + "" + f"{''.join(rows)}
RepositoryOwnerRemoteBranchAuthor profile
" + ) + body = ( + "

Projects

" + "

Configured repositories managed by the MCP Control Plane.

" + f"

Registry: {_escape(str(registry.source_path))} " + f"(version {registry.version})

" + f"{table}" + "

JSON API

" + ) + return render_page(title="Projects", body_html=body) + + +def render_project_detail(project: ProjectRecord) -> str: + profile_rows = "".join( + f"{_escape(role)}{_escape(name)}" + for role, name in project.profiles.items() + ) + workflow_rows = "".join( + f"{_escape(key)}{_escape(path)}" + for key, path in project.workflow_paths.items() + ) + schema_rows = "".join( + f"{_escape(key)}{_escape(path)}" + for key, path in project.schema_paths.items() + ) + checklist_items = [] + for index, step in enumerate(project.onboarding_checklist, start=1): + checklist_items.append( + "
  • " + f"{index}. {_escape(step.title)}" + f"

    {_escape(step.description)}

    " + "
  • " + ) + checklist_html = ( + "
      " + "".join(checklist_items) + "
    " + if checklist_items + else "

    No onboarding steps defined.

    " + ) + body = ( + f"

    {_escape(project.repo_name)}

    " + "

    ← All projects

    " + "

    Identity

    " + "" + f"" + f"" + f"" + f"" + f"" + "
    Registry id{_escape(project.id)}
    Gitea owner{_escape(project.gitea_owner)}
    Remote host{_escape(project.remote_host)}
    Default branch{_escape(project.default_branch)}
    Local checkout{_escape(project.local_checkout_path)}
    " + "

    Profiles

    " + f"{profile_rows}
    " + "

    Workflow paths

    " + f"{workflow_rows}
    " + "

    Schema paths

    " + f"{schema_rows}
    " + "

    Onboarding checklist

    " + "

    Read-only MVP — complete these steps outside the UI.

    " + f"{checklist_html}" + ) + return render_page(title=project.repo_name, body_html=body) \ No newline at end of file