feat(registry): hard-cut META_REPO -> REGISTRY_REPO; wire mirror into startup/webhook/sweep

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-03 23:16:51 -07:00
parent f7bd466f31
commit cecc6c0b41
18 changed files with 231 additions and 115 deletions
+6 -5
View File
@@ -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"],
)
+2 -2
View File
@@ -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
+13 -13
View File
@@ -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",
+2 -2
View File
@@ -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
+20 -4
View File
@@ -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/<slug>/<auto-name>` 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)
+5 -11
View File
@@ -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"),
)
+2 -2
View File
@@ -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:
+18 -2
View File
@@ -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:
+29 -27
View File
@@ -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"]
+11 -8
View File
@@ -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)",
+18 -13
View File
@@ -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)
+15 -6
View File
@@ -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)
+1 -1
View File
@@ -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",
@@ -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()
+19 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+50
View File
@@ -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"
+2
View File
@@ -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")