"""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)