feat(sources): admin page to add and remove git sources (TODO.md L4)
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user