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:
+6
-5
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user