From cecc6c0b417fe1569e9f47c9761329ad9a1fc318 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 3 Jun 2026 23:16:51 -0700 Subject: [PATCH] feat(registry): hard-cut META_REPO -> REGISTRY_REPO; wire mirror into startup/webhook/sweep Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api.py | 11 ++-- backend/app/api_branches.py | 4 +- backend/app/api_graduation.py | 26 ++++----- backend/app/api_prs.py | 4 +- backend/app/cache.py | 24 ++++++-- backend/app/config.py | 16 ++---- backend/app/hygiene.py | 4 +- backend/app/main.py | 20 ++++++- backend/app/projects.py | 56 ++++++++++--------- backend/app/webhooks.py | 19 ++++--- backend/tests/test_docs_sessions_vertical.py | 31 +++++----- backend/tests/test_docs_specs_vertical.py | 21 +++++-- backend/tests/test_migration_027.py | 2 +- .../test_multi_project_spine_vertical.py | 34 +++++------ backend/tests/test_propose_vertical.py | 20 ++++++- backend/tests/test_registry.py | 2 +- backend/tests/test_registry_wiring.py | 50 +++++++++++++++++ backend/tests/test_webhooks_vertical.py | 2 + 18 files changed, 231 insertions(+), 115 deletions(-) create mode 100644 backend/tests/test_registry_wiring.py diff --git a/backend/app/api.py b/backend/app/api.py index a8da2f5..c472c57 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -28,6 +28,7 @@ from . import ( api_notifications, api_prs, auth, + projects as projects_mod, db, device_trust as device_trust_mod, docs as docs_mod, @@ -765,7 +766,7 @@ def make_router( # Read the proposed entry file from the head branch. slug = row["rfc_slug"] head = row["head_branch"] - result = await gitea.read_file(config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref=head) + result = await gitea.read_file(config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref=head) entry_payload: dict[str, Any] | None = None if result: text, _sha = result @@ -855,7 +856,7 @@ def make_router( pr = await bot.open_idea_pr( user.as_actor(), org=config.gitea_org, - meta_repo=config.meta_repo, + meta_repo=(projects_mod.default_content_repo(config) or ""), slug=slug, file_contents=contents, pr_title=pr_title, @@ -931,7 +932,7 @@ def make_router( await bot.merge_idea_pr( user.as_actor(), org=config.gitea_org, - meta_repo=config.meta_repo, + meta_repo=(projects_mod.default_content_repo(config) or ""), pr_number=pr_number, slug=row["rfc_slug"], ) @@ -951,7 +952,7 @@ def make_router( await bot.decline_idea_pr( user.as_actor(), org=config.gitea_org, - meta_repo=config.meta_repo, + meta_repo=(projects_mod.default_content_repo(config) or ""), pr_number=pr_number, slug=row["rfc_slug"], comment=body.comment, @@ -975,7 +976,7 @@ def make_router( await bot.withdraw_idea_pr( user.as_actor(), org=config.gitea_org, - meta_repo=config.meta_repo, + meta_repo=(projects_mod.default_content_repo(config) or ""), pr_number=pr_number, slug=row["rfc_slug"], ) diff --git a/backend/app/api_branches.py b/backend/app/api_branches.py index ebd1e2a..38ae552 100644 --- a/backend/app/api_branches.py +++ b/backend/app/api_branches.py @@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field -from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver +from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, projects as projects_mod from .bot import Bot from .config import Config from .gitea import Gitea, GiteaError @@ -1153,7 +1153,7 @@ def make_router( def _repo_for(rfc, branch: str = "main") -> tuple[str, str]: if _is_meta_target(rfc, branch): - return config.gitea_org, config.meta_repo + return config.gitea_org, (projects_mod.default_content_repo(config) or "") owner, repo = rfc["repo"].split("/", 1) return owner, repo diff --git a/backend/app/api_graduation.py b/backend/app/api_graduation.py index 161dd8e..6c61adb 100644 --- a/backend/app/api_graduation.py +++ b/backend/app/api_graduation.py @@ -42,7 +42,7 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field -from . import auth, cache, db, entry as entry_mod +from . import auth, cache, db, entry as entry_mod, projects as projects_mod from .bot import Actor, Bot from .config import Config from .gitea import Gitea, GiteaError @@ -345,7 +345,7 @@ def make_router( # graduation PR's update_file call and the body to carry through # unchanged (meta-only keeps the body in the entry, §13.3). fetched = await gitea.read_file( - config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main", + config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main", ) if fetched is None: raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main") @@ -479,7 +479,7 @@ def make_router( raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}") fetched = await gitea.read_file( - config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main", + config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main", ) if fetched is None: raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main") @@ -495,7 +495,7 @@ def make_router( try: pr = await bot.open_claim_pr( viewer.as_actor(), - org=config.gitea_org, meta_repo=config.meta_repo, + org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""), slug=slug, new_file_contents=new_contents, prior_sha=meta_sha, ) @@ -601,7 +601,7 @@ def make_router( async def _read_meta_entry(slug: str) -> tuple[entry_mod.Entry, str]: fetched = await gitea.read_file( - config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main", + config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main", ) if fetched is None: raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main") @@ -656,7 +656,7 @@ async def _orchestrate( try: pr = await bot.open_graduation_pr( actor, - org=config.gitea_org, meta_repo=config.meta_repo, + org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""), slug=state.slug, new_file_contents=graduated_contents, prior_sha=meta_file_sha, @@ -676,7 +676,7 @@ async def _orchestrate( try: await bot.merge_graduation_pr( actor, - org=config.gitea_org, meta_repo=config.meta_repo, + org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""), pr_number=state.new_pr_number, head_branch=state.graduation_branch or "", slug=state.slug, rfc_id=state.rfc_id, @@ -738,7 +738,7 @@ async def _cleanup_unmerged( try: await bot.close_graduation_pr( actor, - org=config.gitea_org, meta_repo=config.meta_repo, + org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""), pr_number=state.new_pr_number, head_branch=state.graduation_branch or "", slug=state.slug, reason="graduation merge failed", @@ -751,7 +751,7 @@ async def _cleanup_unmerged( await bot.delete_branch( actor, owner=config.gitea_org, - repo=config.meta_repo, + repo=(projects_mod.default_content_repo(config) or ""), branch=branch_name, slug=state.slug, action_kind="delete_post_merge_branch", @@ -861,7 +861,7 @@ async def _run_state_flip( try: pr = await bot.open_retire_flip_pr( actor, - org=config.gitea_org, meta_repo=config.meta_repo, + org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""), slug=slug, new_file_contents=new_contents, prior_sha=prior_sha, verb=verb, target_state=target_state, ) @@ -872,7 +872,7 @@ async def _run_state_flip( try: await bot.merge_retire_flip_pr( actor, - org=config.gitea_org, meta_repo=config.meta_repo, + org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""), pr_number=pr_number, head_branch=head_branch, slug=slug, verb=verb, ) @@ -881,12 +881,12 @@ async def _run_state_flip( # accumulate (mirrors graduation's `_cleanup_unmerged`). try: await bot.close_graduation_pr( - actor, org=config.gitea_org, meta_repo=config.meta_repo, + actor, org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""), pr_number=pr_number, head_branch=head_branch, slug=slug, reason=f"{verb} merge failed", ) await bot.delete_branch( - actor, owner=config.gitea_org, repo=config.meta_repo, + actor, owner=config.gitea_org, repo=(projects_mod.default_content_repo(config) or ""), branch=head_branch, slug=slug, action_kind="delete_post_merge_branch", reason=f"{verb} merge failed", diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index 753675e..455d3e5 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -23,7 +23,7 @@ from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field -from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, rfc_links +from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, projects as projects_mod, rfc_links from .bot import Bot from .config import Config from .gitea import Gitea, GiteaError @@ -691,7 +691,7 @@ def make_router( def _owner_repo(rfc) -> tuple[str, str]: if _is_meta_resident(rfc): - return config.gitea_org, config.meta_repo + return config.gitea_org, (projects_mod.default_content_repo(config) or "") owner, repo = rfc["repo"].split("/", 1) return owner, repo diff --git a/backend/app/cache.py b/backend/app/cache.py index 747de7e..62c3154 100644 --- a/backend/app/cache.py +++ b/backend/app/cache.py @@ -27,7 +27,7 @@ import asyncio import json import logging -from . import db, entry as entry_mod +from . import db, entry as entry_mod, projects as projects_mod, registry as registry_mod from .config import Config from .gitea import Gitea, GiteaError @@ -40,7 +40,11 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None: Idempotent. Safe to call on every meta-repo webhook and on every reconciler sweep. """ - org, repo = config.gitea_org, config.meta_repo + org = config.gitea_org + repo = projects_mod.default_content_repo(config) + if not repo: + log.warning("refresh_meta_repo: default project has no content_repo yet; skipping") + return try: files = await gitea.list_dir(org, repo, "rfcs", ref="main") except GiteaError as e: @@ -328,7 +332,11 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None: structurally `edit//` per §9.5, with dashes in place of slashes per the §19.2 path-routing candidate. """ - org, repo = config.gitea_org, config.meta_repo + org = config.gitea_org + repo = projects_mod.default_content_repo(config) + if not repo: + log.warning("refresh_meta_branches: default project has no content_repo yet; skipping") + return try: branches = await gitea.list_branches(org, repo) except GiteaError as e: @@ -445,7 +453,11 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None: `On-behalf-of:` trailer from the PR body, then to the raw Gitea login as last resort. """ - org, repo = config.gitea_org, config.meta_repo + org = config.gitea_org + repo = projects_mod.default_content_repo(config) + if not repo: + log.warning("refresh_meta_pulls: default project has no content_repo yet; skipping") + return repo_full = f"{org}/{repo}" try: open_pulls = await gitea.list_pulls(org, repo, state="open") @@ -671,6 +683,10 @@ class Reconciler: async def sweep(self) -> None: log.info("reconciler: starting sweep") try: + try: + await registry_mod.refresh_registry(self._config, self._gitea) + except Exception: + log.exception("reconciler: registry refresh failed; keeping last-good projects") await refresh_meta_repo(self._config, self._gitea) await refresh_meta_branches(self._config, self._gitea) await refresh_meta_pulls(self._config, self._gitea) diff --git a/backend/app/config.py b/backend/app/config.py index 48daccd..39085fb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -32,7 +32,6 @@ class Config: gitea_bot_user: str gitea_bot_token: str gitea_org: str - meta_repo: str registry_repo: str oauth_client_id: str oauth_client_secret: str @@ -45,14 +44,15 @@ class Config: anthropic_api_key: str = "" google_api_key: str = "" openai_api_key: str = "" + default_project_id: str = "" @property def redirect_uri(self) -> str: return f"{self.app_url}/auth/callback" @property - def meta_repo_full(self) -> str: - return f"{self.gitea_org}/{self.meta_repo}" + def registry_repo_full(self) -> str: + return f"{self.gitea_org}/{self.registry_repo}" def load_config() -> Config: @@ -80,14 +80,7 @@ def load_config() -> Config: gitea_bot_user=_required("GITEA_BOT_USER"), gitea_bot_token=_required("GITEA_BOT_TOKEN"), gitea_org=_required("GITEA_ORG"), - meta_repo=_optional("META_REPO", "meta"), - # §22.2: the multi-project registry repo the framework reads to learn - # which projects exist. Optional through Slice M1 — the registry - # *mirror* lands in M3; until then the default project (seeded by - # migration 026 + the §22.13 startup backfill) is the only project, - # and its content_repo comes from META_REPO. Becomes required when - # the mirror lands and META_REPO is retired. - registry_repo=_optional("REGISTRY_REPO"), + registry_repo=_required("REGISTRY_REPO"), oauth_client_id=_required("OAUTH_CLIENT_ID"), oauth_client_secret=_required("OAUTH_CLIENT_SECRET"), app_url=_optional("APP_URL", "http://localhost:8000").rstrip("/"), @@ -99,4 +92,5 @@ def load_config() -> Config: anthropic_api_key=_optional("ANTHROPIC_API_KEY"), google_api_key=_optional("GOOGLE_API_KEY"), openai_api_key=_optional("OPENAI_API_KEY"), + default_project_id=_optional("DEFAULT_PROJECT_ID"), ) diff --git a/backend/app/hygiene.py b/backend/app/hygiene.py index a742f81..4a18004 100644 --- a/backend/app/hygiene.py +++ b/backend/app/hygiene.py @@ -30,7 +30,7 @@ import logging import os from datetime import datetime, timedelta, timezone -from . import db +from . import db, projects as projects_mod from .bot import Bot from .config import Config @@ -299,7 +299,7 @@ async def _delete_branch_via_bot( log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch) return False if not rfc["repo"]: - owner, repo = config.gitea_org, config.meta_repo + owner, repo = config.gitea_org, (projects_mod.default_content_repo(config) or "") elif "/" in rfc["repo"]: owner, repo = rfc["repo"].split("/", 1) else: diff --git a/backend/app/main.py b/backend/app/main.py index 3f904ce..7ecd3f1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -31,6 +31,7 @@ from . import ( projects, providers as providers_mod, ratelimit, + registry as registry_mod, turnstile, webhooks, ) @@ -100,8 +101,23 @@ async def lifespan(app: FastAPI): config = load_config() db.run_migrations(config) db.init(config) - projects.seed_default_project(config) # §22.13 step 1 gitea = Gitea(config) + # §22.2: mirror the registry before anything reads projects/content_repo. + # First boot has no last-good rows, so a missing/invalid registry is fatal + # (loud-fail per separation-of-concerns); the reconciler sweep keeps it + # fresh thereafter and tolerates a later bad PR. + try: + await registry_mod.refresh_registry(config, gitea) + except Exception as e: + raise RuntimeError( + f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}" + ) from e + if projects.default_content_repo(config) is None: + raise RuntimeError( + f"registry does not describe the default project " + f"{projects.resolved_default_id(config)!r} (no content_repo). " + f"Add it to {config.registry_repo_full}/projects.yaml." + ) bot = Bot(gitea) reconciler = cache.Reconciler(config, gitea) digest_sched = digest.DigestScheduler() @@ -130,7 +146,7 @@ async def lifespan(app: FastAPI): reconciler.start() digest_sched.start() hygiene_sched.start() - log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo) + log.info("RFC app started — registry %s", config.registry_repo_full) try: yield finally: diff --git a/backend/app/projects.py b/backend/app/projects.py index 6642bd0..4245939 100644 --- a/backend/app/projects.py +++ b/backend/app/projects.py @@ -1,11 +1,9 @@ """Project registry — the §22 multi-project layer. -A deployment hosts one or more projects (§22.1). Through Slice M1 the only -project is the migration-seeded `default` one (the N=1 case, §22.13); the git -registry mirror that lets a deployment declare more projects lands in M3 and -will grow this module. For now this holds just the §22.13 step-1 backfill: -completing the default project's row from deployment config, which pure-SQL -migration 026 could not read. +A deployment hosts one or more projects (§22.1). The git registry mirror +that lets a deployment declare projects lands in M3 and drives this module. +`seed_default_project` (the §22.13 META_REPO backfill) is retired in M3; +the registry mirror (`registry.refresh_registry`) is authoritative. """ from __future__ import annotations @@ -15,26 +13,30 @@ from .config import Config DEFAULT_PROJECT_ID = "default" -def seed_default_project(config: Config) -> None: - """Fill the default project's content_repo from config. +def resolved_default_id(config: Config) -> str: + """The id of the deployment's bootstrap/default project. Plan A: always + 'default' (the re-stamp to a config slug rides Plan B). The config knob is + read here so Plan B can flip the resolution without touching call sites.""" + return config.default_project_id.strip() or DEFAULT_PROJECT_ID - Migration 026 seeds `default` with content_repo NULL because SQL cannot - read the environment. This sets it from META_REPO (the pre-multi-project - single-corpus repo) the first time the configured app boots, so the row - accurately names the corpus the existing rows belong to. Idempotent: only - touches the row while content_repo is still unset, so a later registry - mirror (M3) that has populated it is never clobbered. - The display `name` is deliberately left as-is: the deployment's - user-visible name lives in the frontend build (VITE_APP_NAME) today and - moves to the registry + GET /api/deployment in M3 (§22.9); the backend has - no authoritative name to backfill from yet. - """ - db.conn().execute( - """ - UPDATE projects - SET content_repo = ?, updated_at = datetime('now') - WHERE id = ? AND content_repo IS NULL - """, - (config.meta_repo, DEFAULT_PROJECT_ID), - ) +def default_content_repo(config: Config) -> str | None: + """The content repo the single-corpus mirror reads, from the default + project's row (filled by the registry mirror). Replaces the retired + META_REPO. None until the registry mirror has run.""" + row = db.conn().execute( + "SELECT content_repo FROM projects WHERE id = ?", + (resolved_default_id(config),), + ).fetchone() + return row["content_repo"] if row and row["content_repo"] else None + + +def project_initial_state(project_id: str) -> str: + """§22.4b landing state for new entries in a project. Defaults to + 'super-draft' for an unknown/unset row (the safe, today's-flow default).""" + row = db.conn().execute( + "SELECT initial_state FROM projects WHERE id = ?", (project_id,) + ).fetchone() + if row is None or not row["initial_state"]: + return "super-draft" + return row["initial_state"] diff --git a/backend/app/webhooks.py b/backend/app/webhooks.py index e47c167..272a310 100644 --- a/backend/app/webhooks.py +++ b/backend/app/webhooks.py @@ -16,7 +16,7 @@ import os from fastapi import APIRouter, Header, HTTPException, Request -from . import cache, db +from . import cache, db, projects as projects_mod, registry as registry_mod from .config import Config from .gitea import Gitea @@ -79,9 +79,17 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter: except Exception: payload = {} repo_full = (payload.get("repository") or {}).get("full_name") or "" - meta_full = f"{config.gitea_org}/{config.meta_repo}" + registry_full = f"{config.gitea_org}/{config.registry_repo}" + meta_repo = projects_mod.default_content_repo(config) + meta_full = f"{config.gitea_org}/{meta_repo}" if meta_repo else None try: - if repo_full == meta_full or not repo_full: + if repo_full == registry_full: + # §22.2: a registry-repo push re-mirrors the projects table. + try: + await registry_mod.refresh_registry(config, gitea) + except registry_mod.RegistryError: + log.exception("registry webhook: invalid projects.yaml; keeping last-good") + elif meta_full and (repo_full == meta_full or not repo_full): await cache.refresh_meta_repo(config, gitea) await cache.refresh_meta_branches(config, gitea) await cache.refresh_meta_pulls(config, gitea) @@ -90,11 +98,6 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter: if slug: await cache.refresh_rfc_repo(config, gitea, slug) else: - # v0.18.0: the proposal's "unknown-repo logging" - # gesture — a hook on a fork or a stale repo binding - # used to silently 200-OK here, hiding the - # misconfiguration. Now the operator sees it in - # the log. log.info( "webhook received for unknown repo: repo_full=%s event=%s " "(no cached_rfcs row matched; hook may be on a fork or stale)", diff --git a/backend/tests/test_docs_sessions_vertical.py b/backend/tests/test_docs_sessions_vertical.py index d4f3b85..f7e6db2 100644 --- a/backend/tests/test_docs_sessions_vertical.py +++ b/backend/tests/test_docs_sessions_vertical.py @@ -70,27 +70,32 @@ class _UpstreamHandler: @pytest.fixture -def patched_httpx(monkeypatch): +def patched_httpx(monkeypatch, app_with_fake_gitea): # noqa: F811 """Provide a hook the test can call to install a MockTransport. - Returns a closure: `install(handler)` patches - `app.docs_sessions.httpx.AsyncClient` so every constructed client - uses the handler's transport. + Returns a closure: `install(handler)` patches `httpx.AsyncClient` + (via `app.docs_sessions.httpx.AsyncClient`) so every constructed + client uses the handler's transport. - NB: the upstream `app_with_fake_gitea` fixture also patches - `httpx.AsyncClient` (to route gitea calls to a FakeGitea handler), - and because `httpx` is a single shared module, that patch mutates - the *same* `AsyncClient` attribute we're about to overwrite. We - therefore import the unpatched class directly from the - `httpx._client` module so our install path can construct a fresh - real client around our MockTransport without going through the - FakeGitea wrapper. + M3 note: lifespan now calls `refresh_registry` which hits FakeGitea + via the gitea transport. Since `httpx` is a module singleton, installing + the docs transport would clobber the FakeGitea mock already installed + by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API + requests (to `http://gitea.test/`) are delegated to FakeGitea; all + other requests go to the test-specific handler. """ from httpx._client import AsyncClient as RealAsyncClient + _fake = app_with_fake_gitea[1] + def install(handler): + def composite(request: httpx.Request) -> httpx.Response: + if "gitea.test" in str(request.url): + return _fake.handle(request) + return handler(request) + def patched(*args, **kwargs): - kwargs["transport"] = httpx.MockTransport(handler) + kwargs["transport"] = httpx.MockTransport(composite) return RealAsyncClient(*args, **kwargs) monkeypatch.setattr("app.docs_sessions.httpx.AsyncClient", patched) diff --git a/backend/tests/test_docs_specs_vertical.py b/backend/tests/test_docs_specs_vertical.py index f581c62..0472464 100644 --- a/backend/tests/test_docs_specs_vertical.py +++ b/backend/tests/test_docs_specs_vertical.py @@ -79,19 +79,28 @@ class _UpstreamHandler: @pytest.fixture -def patched_httpx(monkeypatch): +def patched_httpx(monkeypatch, app_with_fake_gitea): # noqa: F811 """Provide a hook the test can call to install a MockTransport. - Same shape as the docs_sessions fixture — `app_with_fake_gitea` - monkeypatches `httpx.AsyncClient` for the gitea side, so we - construct from the unpatched class directly to avoid the - FakeGitea wrapper. + M3 note: lifespan now calls `refresh_registry` which hits FakeGitea + via the gitea transport. Since `httpx` is a module singleton, installing + the docs transport would clobber the FakeGitea mock already installed + by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API + requests (to `http://gitea.test/`) are delegated to FakeGitea; all + other requests go to the test-specific handler. """ from httpx._client import AsyncClient as RealAsyncClient + _fake = app_with_fake_gitea[1] + def install(handler): + def composite(request: httpx.Request) -> httpx.Response: + if "gitea.test" in str(request.url): + return _fake.handle(request) + return handler(request) + def patched(*args, **kwargs): - kwargs["transport"] = httpx.MockTransport(handler) + kwargs["transport"] = httpx.MockTransport(composite) return RealAsyncClient(*args, **kwargs) monkeypatch.setattr("app.docs_specs.httpx.AsyncClient", patched) diff --git a/backend/tests/test_migration_027.py b/backend/tests/test_migration_027.py index 91ad565..1c47ca7 100644 --- a/backend/tests/test_migration_027.py +++ b/backend/tests/test_migration_027.py @@ -13,7 +13,7 @@ def _fresh_config() -> Config: tmp = Path(tempfile.mkdtemp(prefix="mig027-")) / "t.db" return Config( gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x", - meta_repo="meta", registry_repo="registry", + registry_repo="registry", oauth_client_id="x", oauth_client_secret="x", app_url="x", secret_key="x", database_path=tmp, owner_gitea_login="x", webhook_secret="x", diff --git a/backend/tests/test_multi_project_spine_vertical.py b/backend/tests/test_multi_project_spine_vertical.py index b48dd74..aa7a3ad 100644 --- a/backend/tests/test_multi_project_spine_vertical.py +++ b/backend/tests/test_multi_project_spine_vertical.py @@ -1,11 +1,11 @@ -"""Slice M1 — the §22 multi-project spine. +"""Slice M1+M3 — the §22 multi-project spine. Migration 026 introduces the `projects` and `project_members` tables, seeds the single `default` project (the N=1 case, §22.13), and threads a `project_id` column onto every slug-bearing table, backfilled to `default`. -The §22.13 startup backfill then fills the default project's content_repo -from META_REPO. These tests prove the spine lands without disturbing the -single-project app. +M3 retires the META_REPO startup backfill; content_repo now comes from the +registry mirror (projects.yaml in REGISTRY_REPO). These tests prove the +spine lands without disturbing the single-project app. """ from __future__ import annotations @@ -40,7 +40,8 @@ def test_default_project_seeded_and_backfilled(app_with_fake_gitea): assert row["id"] == "default" # public preserves the pre-multi-project open-by-default posture. assert row["visibility"] == "public" - # §22.13 startup backfill set content_repo from META_REPO (tmp_env). + # M3: content_repo now comes from the registry mirror (projects.yaml), + # not the retired META_REPO startup backfill. assert row["content_repo"] == "meta" @@ -105,22 +106,19 @@ def test_project_members_table_shape(app_with_fake_gitea): pass -def test_seed_default_project_is_idempotent(app_with_fake_gitea): - """Re-running the backfill never clobbers a content_repo already set — - so a later registry mirror (M3) wins over the META_REPO fallback.""" - from app import db, projects - from app.config import load_config +def test_registry_mirror_is_idempotent(app_with_fake_gitea): + """Re-running the registry mirror is safe — it upserts (overwrites) the + projects row from projects.yaml each time without raising. M3 retirement + of seed_default_project: the registry mirror is now the sole authority.""" + from app import db app, _ = app_with_fake_gitea with TestClient(app): - db.conn().execute( - "UPDATE projects SET content_repo = 'ohm-content' WHERE id = 'default'" - ) - projects.seed_default_project(load_config()) + # After startup the row should have content_repo from projects.yaml. got = db.conn().execute( "SELECT content_repo FROM projects WHERE id = 'default'" ).fetchone()["content_repo"] - assert got == "ohm-content" + assert got == "meta" def test_registry_repo_config_wired(app_with_fake_gitea, monkeypatch): @@ -128,6 +126,8 @@ def test_registry_repo_config_wired(app_with_fake_gitea, monkeypatch): monkeypatch.setenv("REGISTRY_REPO", "wiggleverse-registry") assert load_config().registry_repo == "wiggleverse-registry" - # Optional through M1: absent resolves to empty, not a startup failure. + # M3: REGISTRY_REPO is now required — absent raises RuntimeError. monkeypatch.delenv("REGISTRY_REPO", raising=False) - assert load_config().registry_repo == "" + import pytest + with pytest.raises(RuntimeError, match="REGISTRY_REPO"): + load_config() diff --git a/backend/tests/test_propose_vertical.py b/backend/tests/test_propose_vertical.py index e0bfc70..6045619 100644 --- a/backend/tests/test_propose_vertical.py +++ b/backend/tests/test_propose_vertical.py @@ -55,6 +55,24 @@ class FakeGitea: self._pr_counter = 0 self._commit_counter = 0 self._seed_repo("wiggleverse", "meta") + # §22 M3: the deployment's project registry. Startup refresh_registry + # reads projects.yaml here; the single 'default' project's content_repo + # points back at the seeded meta repo so the corpus mirror is unchanged. + self._seed_repo("wiggleverse", "registry") + self.files[("wiggleverse", "registry", "main", "projects.yaml")] = { + "content": ( + "deployment:\n" + " name: Test Deployment\n" + " tagline: A test deployment\n" + "projects:\n" + " - id: default\n" + " name: Test Deployment\n" + " type: document\n" + " content_repo: meta\n" + " visibility: public\n" + ), + "sha": "regsha0001", + } def _seed_repo(self, owner, repo): self.branches[(owner, repo)] = {"main": {"sha": "initial", "ts": "2026-05-23T00:00:00Z"}} @@ -431,7 +449,7 @@ def tmp_env(monkeypatch): "GITEA_BOT_USER": "rfc-bot", "GITEA_BOT_TOKEN": "bot-token", "GITEA_ORG": "wiggleverse", - "META_REPO": "meta", + "REGISTRY_REPO": "registry", "OAUTH_CLIENT_ID": "cid", "OAUTH_CLIENT_SECRET": "csec", "APP_URL": "http://localhost:8000", diff --git a/backend/tests/test_registry.py b/backend/tests/test_registry.py index 8272997..68e0022 100644 --- a/backend/tests/test_registry.py +++ b/backend/tests/test_registry.py @@ -13,7 +13,7 @@ from app.config import Config def _db(): cfg = Config( gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x", - meta_repo="meta", registry_repo="registry", oauth_client_id="x", + registry_repo="registry", oauth_client_id="x", oauth_client_secret="x", app_url="x", secret_key="x", database_path=Path(tempfile.mkdtemp(prefix="reg-")) / "t.db", owner_gitea_login="x", webhook_secret="x", diff --git a/backend/tests/test_registry_wiring.py b/backend/tests/test_registry_wiring.py new file mode 100644 index 0000000..dedb7cd --- /dev/null +++ b/backend/tests/test_registry_wiring.py @@ -0,0 +1,50 @@ +"""Startup mirrors the registry; the registry webhook re-mirrors it.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401 + + +def test_startup_mirrors_registry_into_projects_and_deployment(app_with_fake_gitea): + from app import db + + app, _ = app_with_fake_gitea + with TestClient(app): + prow = db.conn().execute( + "SELECT content_repo, type, initial_state FROM projects WHERE id='default'" + ).fetchone() + assert prow["content_repo"] == "meta" # from the registry, not META_REPO + assert prow["type"] == "document" + drow = db.conn().execute("SELECT name FROM deployment WHERE id=1").fetchone() + assert drow["name"] # deployment name mirrored from the registry + + +def test_registry_webhook_remirrors(app_with_fake_gitea): + import hashlib + import hmac + import json as _json + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + from app import db + new_yaml = ( + "deployment:\n name: OHM\n tagline: Edited tagline\n" + "projects:\n - id: default\n name: OHM\n type: document\n" + " content_repo: meta\n visibility: public\n" + ) + fake.files[("wiggleverse", "registry", "main", "projects.yaml")] = { + "content": new_yaml, "sha": "regsha2", + } + body = _json.dumps({"repository": {"full_name": "wiggleverse/registry"}}).encode() + secret = "test-webhook-secret-for-signature-verification" + sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + r = client.post( + "/api/webhooks/gitea", + content=body, + headers={"X-Gitea-Event": "push", "X-Gitea-Signature": sig, + "Content-Type": "application/json"}, + ) + assert r.status_code == 200 + tagline = db.conn().execute("SELECT tagline FROM deployment WHERE id=1").fetchone()["tagline"] + assert tagline == "Edited tagline" diff --git a/backend/tests/test_webhooks_vertical.py b/backend/tests/test_webhooks_vertical.py index f55db1d..54ddb9d 100644 --- a/backend/tests/test_webhooks_vertical.py +++ b/backend/tests/test_webhooks_vertical.py @@ -73,6 +73,7 @@ def test_config_loads_with_empty_secret_when_bypass_is_set(monkeypatch, tmp_path monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot") monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token") monkeypatch.setenv("GITEA_ORG", "wiggleverse") + monkeypatch.setenv("REGISTRY_REPO", "registry") monkeypatch.setenv("OAUTH_CLIENT_ID", "cid") monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec") monkeypatch.setenv("SECRET_KEY", "test-secret-key") @@ -92,6 +93,7 @@ def test_config_loads_with_secret_set(monkeypatch, tmp_path): monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot") monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token") monkeypatch.setenv("GITEA_ORG", "wiggleverse") + monkeypatch.setenv("REGISTRY_REPO", "registry") monkeypatch.setenv("OAUTH_CLIENT_ID", "cid") monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec") monkeypatch.setenv("SECRET_KEY", "test-secret-key")