feat(sources): admin page to add and remove git sources (TODO.md L4)

This commit is contained in:
2026-08-26 18:42:28 -04:00
parent b2d8696741
commit 1925bb66a8
30 changed files with 2673 additions and 110 deletions
+132
View File
@@ -0,0 +1,132 @@
"""Admin-managed git sources API (phase 35, task 02).
Admin-only CRUD under ``/api/git-sources`` (phase 16 pattern, A10
extended — the public API surface stays stateless and the signed cookie
remains the only session state, same as ``/api/steering`` and
``/api/sync``): the ``git_sources`` table holds the repo URLs the Sync
button (phase 32) and ``import_docs`` (phase 28) clone/pull. DB rows win
over ``BOR_GIT_SOURCES``, which is a fallback while the table is empty
(the phase's locked decision — ``from_env`` tells the UI which list it is
looking at, so the page can show the env note only while the fallback is
active).
Routes: ``GET`` (DB rows oldest-first, or the env list with
``from_env: true`` while the table is empty), ``POST`` (201, validated
create), ``DELETE /{source_id}`` (204). The whole router sits behind
:func:`app.core.auth.require_admin` — anonymous callers get 403 on every
route.
No credential-echo path: URLs may embed ``user:pass@`` (phase 32's
masking discipline), so the 409/422 details are fixed generic strings
that never repeat the submitted URL.
Scope boundary (phase locked decisions): adding or removing a repo does
NOT clone, import, or prune anything — the existing Sync button performs
that (a removal prunes on the next sync, ``prune=True``).
"""
from __future__ import annotations
import re
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core.auth import require_admin
from app.db import get_db
from app.models import GitSource
from app.schemas import GitSourceIn, GitSourceList, GitSourceOut
router = APIRouter(
prefix="/git-sources",
tags=["git-sources"],
dependencies=[Depends(require_admin)], # phase 16 pattern: admin-only surface
)
#: Accepted git URL shapes — the trimmed URL must *start* with one of them.
#: Covers the phase-28 real URLs (HTTPS + ``git@`` SSH); scp-style
#: ``host:repo`` is deliberately rejected (422). ASSUMPTION (task 02): the
#: accepted shapes are exactly these four prefixes.
URL_RE = re.compile(r"^(https?://|ssh://|git@)")
@router.get("", response_model=GitSourceList)
def list_git_sources(
db: Session = Depends(get_db), # noqa: B008
) -> GitSourceList:
"""The effective git source list.
DB rows ordered by ``(added_at, id)`` (oldest first, id tie-break for
same-timestamp inserts) with ``from_env: false``; while the table is
empty, the ``BOR_GIT_SOURCES`` env URLs as rows with null
``id``/``added_at`` and ``from_env: true``.
"""
rows = db.scalars(
select(GitSource).order_by(GitSource.added_at.asc(), GitSource.id.asc())
).all()
if rows:
return GitSourceList(
sources=[GitSourceOut(id=row.id, url=row.url, added_at=row.added_at) for row in rows],
from_env=False,
)
return GitSourceList(
sources=[
GitSourceOut(id=None, url=url, added_at=None)
for url in get_settings().git_source_list
],
from_env=True,
)
@router.post("", response_model=GitSourceOut, status_code=201)
def create_git_source(
payload: GitSourceIn,
db: Session = Depends(get_db), # noqa: B008
) -> GitSourceOut:
"""Store one repo URL (already trimmed by the schema).
422 when the shape is not one of the accepted prefixes (generic
detail — the input is never echoed); 409 when the trimmed URL is
already stored (same; the unique index is the backstop against a
concurrent insert the pre-check missed); 201 + the created row
otherwise.
"""
url = payload.url
if not URL_RE.match(url):
raise HTTPException(
status_code=422, detail="not a valid git URL (expected https://, ssh:// or git@…)"
)
if db.scalar(select(GitSource).where(GitSource.url == url)) is not None:
raise HTTPException(status_code=409, detail="a git source with this URL already exists")
row = GitSource(url=url)
db.add(row)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=409, detail="a git source with this URL already exists"
) from None
db.refresh(row)
return GitSourceOut(id=row.id, url=row.url, added_at=row.added_at)
@router.delete("/{source_id}", status_code=204)
def delete_git_source(
source_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> Response:
"""Remove a stored row; 404 when the id is unknown.
Removing does not touch the clones or the index — the next Sync
(``prune=True``) prunes the dropped repo (phase scope boundary).
"""
row = db.get(GitSource, source_id)
if row is None:
raise HTTPException(status_code=404, detail="git source not found")
db.delete(row)
db.commit()
return Response(status_code=204)
+24 -9
View File
@@ -15,9 +15,12 @@ stale button state (§7.4 adaptation, phase locked decisions).
Pipeline (the canonical "mirror the repos" action — phase locked
decisions):
1. resolve the ``BOR_GIT_SOURCES`` URLs — empty/missing fails loudly
(``no git sources configured``) instead of silently importing the
legacy local directories;
1. resolve the effective git sources — the ``git_sources`` DB rows,
else the ``BOR_GIT_SOURCES`` fallback
(:func:`app.rag.git_sources.effective_git_sources`, shared with the
CLI) — empty on both origins fails loudly (``no git sources
configured``) instead of silently importing the legacy local
directories;
2. :func:`scripts.git_sync.clone_or_pull` each repo into
``BOR_SOURCES_DIR/<repo-name>/`` (phase 28 — reused, not
re-implemented; a failing repo aborts before any import);
@@ -44,6 +47,8 @@ from fastapi import APIRouter, Depends, HTTPException
from app.config import get_settings
from app.core.auth import require_admin
from app.db import SessionLocal
from app.rag.git_sources import effective_git_sources
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from app.rag.overview import regenerate_overview
@@ -141,13 +146,23 @@ async def _run_sync() -> None:
_status.error = None
try:
settings = get_settings()
git_urls = settings.git_source_list
# The background task has no request session: open a short-lived
# one around the shared phase-35 resolver (DB rows win, the
# BOR_GIT_SOURCES list is a fallback while the table is empty).
db = SessionLocal()
try:
git_urls, origin = effective_git_sources(db)
finally:
db.close()
if not git_urls:
# The button targets BOR_GIT_SOURCES only (manual --source
# dirs have no repo to clone) — an empty config fails loudly
# instead of silently importing the legacy directories.
raise GitSyncError("no git sources configured (BOR_GIT_SOURCES)")
logger.info("sync: started repos=%d", len(git_urls))
# The button targets git sources only (manual --source dirs
# have no repo to clone) — an empty config on *both* origins
# fails loudly instead of silently importing the legacy
# directories.
raise GitSyncError(
"no git sources configured (git_sources table empty and BOR_GIT_SOURCES unset)"
)
logger.info("sync: started repos=%d origin=%s", len(git_urls), origin)
sources_root = Path(settings.sources_dir).expanduser()
sources = [clone_or_pull(url, sources_root / repo_name(url)) for url in git_urls]
llm = LLMClient()
+5 -1
View File
@@ -111,7 +111,10 @@ def asset_version(static_dir: str | None = None) -> str:
# Response-layer cache busting (task 02)
# ---------------------------------------------------------------------------
#: The five known HTML pages — the ONLY paths whose body is rewritten.
#: The known HTML pages — the ONLY paths whose body is rewritten.
#: Phase 35 adds the admin git sources page: without this entry it
#: would serve unversioned asset refs, which the immutable-for-a-year
#: asset caching would pin to stale CSS after a deploy.
HTML_PAGES: tuple[str, ...] = (
"/",
"/index.html",
@@ -119,6 +122,7 @@ HTML_PAGES: tuple[str, ...] = (
"/document.html",
"/login.html",
"/tuning.html",
"/git-sources.html", # phase 35: the admin git sources page
)
#: Prefix of the versioned static assets (header-only caching; the body is
+2
View File
@@ -22,6 +22,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app.api.auth import router as auth_router
from app.api.chat import router as chat_router
from app.api.docs import router as docs_router
from app.api.git_sources import router as git_sources_router
from app.api.health import router as health_router
from app.api.steering import router as steering_router
from app.api.suggestions import router as suggestions_router
@@ -64,6 +65,7 @@ def create_app() -> FastAPI:
app.include_router(auth_router, prefix="/api")
app.include_router(suggestions_router, prefix="/api")
app.include_router(docs_router, prefix="/api")
app.include_router(git_sources_router, prefix="/api")
app.include_router(chat_router, prefix="/api")
app.include_router(steering_router, prefix="/api")
app.include_router(sync_router, prefix="/api")
+18
View File
@@ -13,6 +13,8 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
* ``kb_overview`` — single-row lite-generated outline of the KB's basic
categories, injected as the ``<knowledge_base>``
section of every chat turn (phase 31).
* ``git_sources`` — admin-managed git source URLs the Sync button and
import_docs clone/pull (phase 35).
"""
from __future__ import annotations
@@ -133,3 +135,19 @@ class KbOverview(Base):
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class GitSource(Base):
"""One admin-managed git source (phase 35).
The UI-maintained list of repo URLs the Sync button (phase 32) and
import_docs (phase 28) clone/pull. DB rows win over the
BOR_GIT_SOURCES env var, which is a fallback while this table is
empty (see app.rag.git_sources.effective_git_sources).
"""
__tablename__ = "git_sources"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
url: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+44
View File
@@ -0,0 +1,44 @@
"""The shared git-source resolver (phase 35, task 03).
One function, two callers: the in-app Sync pipeline
(``app/api/sync.py::_run_sync``) and the CLI
(``scripts/import_docs.py::_resolve_sources``) — both resolve the repo
URLs to clone/pull through :func:`effective_git_sources`, so the
admin-managed ``git_sources`` table is what actually gets cloned and
indexed from either entry point (the API's ``GET /api/git-sources``
fallback list is the only other place that reads the env list — task 02).
Precedence (the phase's locked decision — the env var is demoted, not
removed): ``git_sources`` DB rows win, in ``(added_at, id)`` order;
``BOR_GIT_SOURCES`` is a fallback only while the table is empty; both
empty → ``([], "env")`` and each caller keeps its existing fail-loud
behavior (sync: ``GitSyncError``; CLI: the legacy ``DEFAULT_SOURCES``
fallback).
"""
from __future__ import annotations
from typing import Literal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import GitSource
def effective_git_sources(db: Session) -> tuple[list[str], Literal["db", "env"]]:
"""``(urls, origin)`` — the repo URLs to clone, and where they came from.
``"db"``: the ``git_sources`` rows in ``(added_at, id)`` order (oldest
first, id tie-break for same-timestamp inserts) — once the table has
any row, ``BOR_GIT_SOURCES`` is ignored entirely.
``"env"``: the ``BOR_GIT_SOURCES`` list — ``Settings.git_source_list``,
the phase-28 CSV parse, reused not re-implemented — used only while
the table is empty; both empty → ``([], "env")``.
"""
rows = db.scalars(
select(GitSource).order_by(GitSource.added_at.asc(), GitSource.id.asc())
).all()
if rows:
return [row.url for row in rows], "db"
return list(get_settings().git_source_list), "env"
+43
View File
@@ -154,3 +154,46 @@ class SteeringNoteList(BaseModel):
"""``GET /api/steering`` response: all notes, newest first."""
notes: list[SteeringNote]
class GitSourceIn(BaseModel):
"""``POST /api/git-sources`` body: one repo URL (phase 35, task 02).
Mirrors :class:`SteeringNoteIn` — the URL is trimmed *before* the
length constraints run, so a whitespace-only body is a 422 and a URL
with surrounding spaces is stored clean. Shape validation
(``https://``, ``ssh://``, ``git@``) happens in the API layer so the
422 detail can be one generic string that never echoes the input.
"""
url: str = Field(min_length=1, max_length=500)
@field_validator("url", mode="before")
@classmethod
def _trim_url(cls, v: object) -> object:
return v.strip() if isinstance(v, str) else v
class GitSourceOut(BaseModel):
"""One git source as returned by the API (phase 35, task 02).
``id`` / ``added_at`` are nullable: env-fallback rows (table empty →
the list comes from ``BOR_GIT_SOURCES``) carry neither, only a URL.
"""
id: uuid.UUID | None
url: str
added_at: datetime | None
class GitSourceList(BaseModel):
"""``GET /api/git-sources`` response (phase 35, task 02).
``from_env`` is True only when the ``git_sources`` table is empty and
the list comes from ``BOR_GIT_SOURCES`` (the phase's locked fallback);
once the table has rows the env var is ignored and ``from_env`` is
False — the UI is the source of truth.
"""
sources: list[GitSourceOut]
from_env: bool